diff --git a/scripts/capture-sessions.mjs b/scripts/capture-sessions.mjs new file mode 100644 index 0000000..5f18097 --- /dev/null +++ b/scripts/capture-sessions.mjs @@ -0,0 +1,39 @@ +import { chromium } from 'playwright'; +import path from 'node:path'; +import fs from 'node:fs'; + +const url = process.argv[2]; +const mode = process.argv[3]; + +if (!url || !mode) { + console.error('Usage: node scripts/capture-sessions.mjs '); + process.exit(1); +} + +const shots = [ + { name: 'mobile', width: 390, height: 844 }, + { name: 'tablet', width: 768, height: 1024 }, + { name: 'desktop', width: 1440, height: 900 }, +]; + +// Ensure artifacts directory exists +if (!fs.existsSync('artifacts')) { + fs.mkdirSync('artifacts', { recursive: true }); +} + +const browser = await chromium.launch({ headless: true }); + +for (const shot of shots) { + const page = await browser.newPage({ viewport: { width: shot.width, height: shot.height } }); + await page.goto(url, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1000); // Wait for session cards to render + await page.screenshot({ + path: path.join('artifacts', `sessions-${shot.name}-${mode}.png`), + fullPage: true, + }); + console.log(`Captured: artifacts/sessions-${shot.name}-${mode}.png`); + await page.close(); +} + +await browser.close(); +console.log('Done!'); diff --git a/scripts/capture-timeline.mjs b/scripts/capture-timeline.mjs new file mode 100644 index 0000000..6d9ef03 --- /dev/null +++ b/scripts/capture-timeline.mjs @@ -0,0 +1,24 @@ +import { chromium } from 'playwright'; +import path from 'node:path'; + +const url = process.argv[2] || 'http://localhost:3003/timeline'; + +const shots = [ + { name: 'desktop', width: 1440, height: 900 }, +]; + +const browser = await chromium.launch({ headless: true }); + +for (const shot of shots) { + const page = await browser.newPage({ viewport: { width: shot.width, height: shot.height } }); + await page.goto(url, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2000); + await page.screenshot({ + path: path.join('artifacts', `timeline-${shot.name}.png`), + fullPage: true, + }); + await page.close(); +} + +await browser.close(); +console.log('Screenshot saved to artifacts/timeline-desktop.png'); diff --git a/src/components/sessions/sessions-header-logic.ts b/src/components/sessions/sessions-header-logic.ts new file mode 100644 index 0000000..a07b2ca --- /dev/null +++ b/src/components/sessions/sessions-header-logic.ts @@ -0,0 +1,47 @@ +import type { AgentRecord, AgentLiveness } from '../../lib/agent-registry'; + +export interface SwarmHealth { + status: 'active' | 'warning' | 'critical' | 'offline'; + color: string; +} + +export function getSwarmHealth( + members: AgentRecord[], + livenessMap: Record +): SwarmHealth { + if (members.length === 0) { + return { status: 'offline', color: 'text-zinc-500' }; + } + + let hasStale = false; + let hasEvicted = false; + let allIdle = true; + + for (const member of members) { + const liveness = livenessMap[member.agent_id] as AgentLiveness || 'active'; + + if (liveness !== 'idle') { + allIdle = false; + } + + if (liveness === 'stale') { + hasStale = true; + } else if (liveness === 'evicted') { + hasEvicted = true; + } + } + + if (allIdle) { + return { status: 'offline', color: 'text-zinc-500' }; + } + + if (hasEvicted) { + return { status: 'critical', color: 'text-rose-400' }; + } + + if (hasStale) { + return { status: 'warning', color: 'text-amber-400' }; + } + + return { status: 'active', color: 'text-emerald-400' }; +} diff --git a/tests/components/sessions/sessions-header-logic.test.ts b/tests/components/sessions/sessions-header-logic.test.ts new file mode 100644 index 0000000..825920f --- /dev/null +++ b/tests/components/sessions/sessions-header-logic.test.ts @@ -0,0 +1,68 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { getSwarmHealth } from '../../../src/components/sessions/sessions-header-logic'; +import type { AgentRecord, AgentLiveness } from '../../../src/lib/agent-registry'; + +// Mock AgentRecord helper +function mockAgent(id: string, liveness: AgentLiveness): { agent: AgentRecord, liveness: AgentLiveness } { + return { + agent: { + agent_id: id, + display_name: id, + role: 'agent', + status: 'idle', + created_at: new Date().toISOString(), + last_seen_at: new Date().toISOString(), + version: 1 + }, + liveness + }; +} + +test('getSwarmHealth returns green/active when all agents are active', () => { + const members = [ + mockAgent('a1', 'active'), + mockAgent('a2', 'active') + ]; + const livenessMap = { a1: 'active', a2: 'active' }; + + const health = getSwarmHealth(members.map(m => m.agent), livenessMap); + assert.equal(health.status, 'active'); + assert.equal(health.color, 'text-emerald-400'); +}); + +test('getSwarmHealth returns yellow/warning when any agent is stale', () => { + const members = [ + mockAgent('a1', 'active'), + mockAgent('a2', 'stale') + ]; + const livenessMap = { a1: 'active', a2: 'stale' }; + + const health = getSwarmHealth(members.map(m => m.agent), livenessMap); + assert.equal(health.status, 'warning'); + assert.equal(health.color, 'text-amber-400'); +}); + +test('getSwarmHealth returns red/critical when any agent is evicted/dead', () => { + const members = [ + mockAgent('a1', 'active'), + mockAgent('a2', 'evicted') + ]; + const livenessMap = { a1: 'active', a2: 'evicted' }; + + const health = getSwarmHealth(members.map(m => m.agent), livenessMap); + assert.equal(health.status, 'critical'); + assert.equal(health.color, 'text-rose-400'); +}); + +test('getSwarmHealth returns gray/offline when all agents are idle', () => { + const members = [ + mockAgent('a1', 'idle'), + mockAgent('a2', 'idle') + ]; + const livenessMap = { a1: 'idle', a2: 'idle' }; + + const health = getSwarmHealth(members.map(m => m.agent), livenessMap); + assert.equal(health.status, 'offline'); + assert.equal(health.color, 'text-zinc-500'); +}); diff --git a/tests/components/sessions/sessions-header.test.ts b/tests/components/sessions/sessions-header.test.ts new file mode 100644 index 0000000..db9f3dc --- /dev/null +++ b/tests/components/sessions/sessions-header.test.ts @@ -0,0 +1,85 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import type { AgentRecord } from '../../../src/lib/agent-registry'; + +interface SwarmGroup { + swarmId: string; + swarmLabel: string; + members: AgentRecord[]; +} + +function groupAgentsBySwarm( + agents: AgentRecord[], + swarmGroups: SwarmGroup[] +): { swarmGroups: SwarmGroup[]; unassigned: AgentRecord[] } { + const nonEmptySwarmGroups = swarmGroups.filter(g => g.members.length > 0); + const assignedIds = new Set(nonEmptySwarmGroups.flatMap(g => g.members.map(m => m.agent_id))); + const unassigned = agents.filter(a => !assignedIds.has(a.agent_id)); + return { swarmGroups: nonEmptySwarmGroups, unassigned }; +} + +describe('SessionsHeader: Agent Grouping', () => { + const mockAgent = (id: string): AgentRecord => ({ + agent_id: id, + display_name: id, + role: 'agent', + status: 'idle', + created_at: new Date().toISOString(), + last_seen_at: new Date().toISOString(), + version: 1, + }); + + it('groups agents by swarm', () => { + const agents = [ + mockAgent('agent-1'), + mockAgent('agent-2'), + mockAgent('agent-3'), + ]; + + const swarmGroups: SwarmGroup[] = [ + { + swarmId: 'bb-buff', + swarmLabel: 'bb-buff', + members: [agents[0], agents[1]], + }, + ]; + + const result = groupAgentsBySwarm(agents, swarmGroups); + + assert.equal(result.swarmGroups.length, 1); + assert.equal(result.swarmGroups[0].members.length, 2); + assert.equal(result.unassigned.length, 1); + assert.equal(result.unassigned[0].agent_id, 'agent-3'); + }); + + it('shows fallback bucket for unassigned agents', () => { + const agents = [ + mockAgent('agent-1'), + mockAgent('agent-2'), + ]; + + const swarmGroups: SwarmGroup[] = []; + + const result = groupAgentsBySwarm(agents, swarmGroups); + + assert.equal(result.swarmGroups.length, 0); + assert.equal(result.unassigned.length, 2); + }); + + it('handles empty swarm groups', () => { + const agents = [mockAgent('agent-1')]; + + const swarmGroups: SwarmGroup[] = [ + { + swarmId: 'bb-empty', + swarmLabel: 'bb-empty', + members: [], + }, + ]; + + const result = groupAgentsBySwarm(agents, swarmGroups); + + assert.equal(result.swarmGroups.length, 0, 'Empty swarm groups should not render'); + assert.equal(result.unassigned.length, 1); + }); +}); diff --git a/tests/hooks/use-beads-subscription-shallow.test.ts b/tests/hooks/use-beads-subscription-shallow.test.ts new file mode 100644 index 0000000..18b1d6a --- /dev/null +++ b/tests/hooks/use-beads-subscription-shallow.test.ts @@ -0,0 +1,31 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; + +// Test for shallow comparison of initialIssues +// This test verifies that the hook doesn't sync state when the array contents are the same +// but the reference has changed (common in Next.js server component re-renders) + +describe('useBeadsSubscription shallow comparison', () => { + it('should NOT sync state when initialIssues reference changes but contents are same', async () => { + // This is a placeholder test - the actual fix requires React Testing Library + // to properly test React hooks in a browser environment. + // + // The bug: When the parent re-renders, initialIssues gets a new array reference. + // The useEffect with [initialIssues] dependency fires, calling setIssues(initialIssues), + // which overwrites local state including form inputs in child components. + // + // The fix: Add shallow comparison before syncing. + + // Expected behavior after fix: + // - When initialIssues changes reference but contents are equal → NO sync + // - When initialIssues actually changes → sync + + const issues1 = [{ id: '1', title: 'Task 1' }]; + const issues2 = [{ id: '1', title: 'Task 1' }]; // Same content, new reference + + // These should be considered equal after JSON.stringify comparison + const isEqual = JSON.stringify(issues1) === JSON.stringify(issues2); + + assert.ok(isEqual, 'Issues with same content should be equal when stringified'); + }); +}); diff --git a/tests/lib/snapshot-differ-stress.test.ts b/tests/lib/snapshot-differ-stress.test.ts new file mode 100644 index 0000000..356fd5f --- /dev/null +++ b/tests/lib/snapshot-differ-stress.test.ts @@ -0,0 +1,435 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import type { BeadIssueWithProject, BeadDependency } from '../../src/lib/types'; +import { diffSnapshots } from '../../src/lib/snapshot-differ'; + +const MOCK_PROJECT = { + key: 'proj-1', + root: 'C:\\test', + displayPath: 'test', + name: 'Test Project', + source: 'local' as const, + addedAt: null, +}; + +function createMockIssue(id: string, overrides: Partial = {}): BeadIssueWithProject { + return { + id, + title: `Title ${id}`, + description: null, + status: 'open', + priority: 2, + issue_type: 'task', + assignee: null, + owner: 'owner', + labels: [], + dependencies: [], + created_at: '2026-02-13T00:00:00Z', + updated_at: '2026-02-13T00:00:00Z', + closed_at: null, + close_reason: null, + closed_by_session: null, + created_by: 'creator', + due_at: null, + estimated_minutes: null, + external_ref: null, + metadata: {}, + project: MOCK_PROJECT, + ...overrides, + }; +} + +describe('Snapshot Differ Stress Tests', () => { + describe('HIGH-FREQUENCY BURSTS', () => { + it('should capture only final diff when status changes multiple times (simulated burst)', () => { + const prev = [createMockIssue('bb-1', { status: 'open' })]; + const curr = [createMockIssue('bb-1', { status: 'closed' })]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].kind, 'closed'); + assert.strictEqual(events[0].payload.from, 'open'); + assert.strictEqual(events[0].payload.to, 'closed'); + }); + + it('should handle rapid status oscillation (open -> in_progress -> blocked -> in_progress)', () => { + const prev = [createMockIssue('bb-1', { status: 'open' })]; + const curr = [createMockIssue('bb-1', { status: 'in_progress' })]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].kind, 'status_changed'); + }); + }); + + describe('BATCH MUTATIONS', () => { + it('should handle 50+ beads all changing status simultaneously', () => { + const prev: BeadIssueWithProject[] = []; + const curr: BeadIssueWithProject[] = []; + + for (let i = 1; i <= 50; i++) { + curr.push(createMockIssue(`bb-${i}`, { status: 'open' })); + } + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 50); + events.forEach(e => { + assert.strictEqual(e.kind, 'created'); + }); + }); + + it('should handle 100 beads with mixed status transitions', () => { + const prev: BeadIssueWithProject[] = []; + const curr: BeadIssueWithProject[] = []; + + for (let i = 1; i <= 100; i++) { + if (i <= 50) { + prev.push(createMockIssue(`bb-${i}`, { status: 'open' })); + curr.push(createMockIssue(`bb-${i}`, { status: 'in_progress' })); + } else { + curr.push(createMockIssue(`bb-${i}`, { status: 'open' })); + } + } + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 100); + + const statusChanged = events.filter(e => e.kind === 'status_changed'); + const created = events.filter(e => e.kind === 'created'); + assert.strictEqual(statusChanged.length, 50); + assert.strictEqual(created.length, 50); + }); + }); + + describe('COMPLEX PERMUTATIONS', () => { + it('should emit events for status + assignee + priority + labels all changing on same bead', () => { + const prev = [createMockIssue('bb-1', { + status: 'open', + assignee: null, + priority: 2, + labels: ['bug'], + })]; + const curr = [createMockIssue('bb-1', { + status: 'in_progress', + assignee: 'alice', + priority: 1, + labels: ['bug', 'urgent'], + })]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 4); + + const kinds = events.map(e => e.kind); + assert.ok(kinds.includes('status_changed')); + assert.ok(kinds.includes('assignee_changed')); + assert.ok(kinds.includes('priority_changed')); + assert.ok(kinds.includes('labels_changed')); + }); + + it('should handle all trackable fields changing simultaneously', () => { + const prev = [createMockIssue('bb-1', { + status: 'open', + title: 'Old Title', + description: null, + priority: 3, + issue_type: 'task', + assignee: null, + labels: [], + dependencies: [], + due_at: null, + estimated_minutes: null, + })]; + const curr = [createMockIssue('bb-1', { + status: 'in_progress', + title: 'New Title', + description: 'New description', + priority: 1, + issue_type: 'feature', + assignee: 'bob', + labels: ['enhancement'], + dependencies: [{ type: 'blocks', target: 'bb-2' }], + due_at: '2026-02-20T00:00:00Z', + estimated_minutes: 120, + })]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 10); + + const kinds = events.map(e => e.kind); + assert.ok(kinds.includes('status_changed')); + assert.ok(kinds.includes('title_changed')); + assert.ok(kinds.includes('description_changed')); + assert.ok(kinds.includes('priority_changed')); + assert.ok(kinds.includes('type_changed')); + assert.ok(kinds.includes('assignee_changed')); + assert.ok(kinds.includes('labels_changed')); + assert.ok(kinds.includes('dependency_added')); + assert.ok(kinds.includes('due_date_changed')); + assert.ok(kinds.includes('estimate_changed')); + }); + }); + + describe('NULL SAFETY', () => { + it('should NOT crash when labels is null in prev (DOCUMENTS BUG - will throw TypeError)', () => { + const prev = [createMockIssue('bb-1', { labels: null as unknown as string[] })]; + const curr = [createMockIssue('bb-1', { labels: ['bug'] })]; + + assert.throws(() => { + diffSnapshots(prev, curr); + }, /TypeError|Cannot read properties of null/); + }); + + it('should NOT crash when labels is null in curr (DOCUMENTS BUG - will throw TypeError)', () => { + const prev = [createMockIssue('bb-1', { labels: ['bug'] })]; + const curr = [createMockIssue('bb-1', { labels: null as unknown as string[] })]; + + assert.throws(() => { + diffSnapshots(prev, curr); + }, /TypeError|Cannot read properties of null/); + }); + + it('should NOT crash when dependencies is null (DOCUMENTS BUG - will throw TypeError)', () => { + const prev = [createMockIssue('bb-1', { dependencies: null as unknown as BeadDependency[] })]; + const curr = [createMockIssue('bb-1', { dependencies: [] })]; + + assert.throws(() => { + diffSnapshots(prev, curr); + }, /TypeError|Cannot read properties of null/); + }); + + it('should NOT crash when dependencies is undefined in curr (DOCUMENTS BUG - will throw TypeError)', () => { + const prev = [createMockIssue('bb-1', { dependencies: [] })]; + const curr = [createMockIssue('bb-1', { dependencies: undefined as unknown as BeadDependency[] })]; + + assert.throws(() => { + diffSnapshots(prev, curr); + }, /TypeError|Cannot read properties of undefined/); + }); + }); + + describe('EMPTY ARRAYS', () => { + it('should not emit event when labels goes from empty to empty', () => { + const prev = [createMockIssue('bb-1', { labels: [] })]; + const curr = [createMockIssue('bb-1', { labels: [] })]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 0); + }); + + it('should emit labels_changed when labels goes from empty to non-empty', () => { + const prev = [createMockIssue('bb-1', { labels: [] })]; + const curr = [createMockIssue('bb-1', { labels: ['bug'] })]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].kind, 'labels_changed'); + }); + + it('should emit labels_changed when labels goes from non-empty to empty', () => { + const prev = [createMockIssue('bb-1', { labels: ['bug'] })]; + const curr = [createMockIssue('bb-1', { labels: [] })]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].kind, 'labels_changed'); + }); + + it('should not emit event when dependencies goes from empty to empty', () => { + const prev = [createMockIssue('bb-1', { dependencies: [] })]; + const curr = [createMockIssue('bb-1', { dependencies: [] })]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 0); + }); + + it('should emit dependency_added when dependencies goes from empty to non-empty', () => { + const prev = [createMockIssue('bb-1', { dependencies: [] })]; + const curr = [createMockIssue('bb-1', { dependencies: [{ type: 'blocks', target: 'bb-2' }] })]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].kind, 'dependency_added'); + }); + + it('should emit dependency_removed when dependencies goes from non-empty to empty', () => { + const prev = [createMockIssue('bb-1', { dependencies: [{ type: 'blocks', target: 'bb-2' }] })]; + const curr = [createMockIssue('bb-1', { dependencies: [] })]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].kind, 'dependency_removed'); + }); + }); + + describe('DELETION DETECTION', () => { + it('should NOT emit any event when issue is deleted (DOCUMENTS CURRENT BEHAVIOR - no deletion event)', () => { + const prev = [createMockIssue('bb-1')]; + const curr: BeadIssueWithProject[] = []; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 0); + }); + + it('should NOT emit deletion event when multiple issues are removed', () => { + const prev = [ + createMockIssue('bb-1'), + createMockIssue('bb-2'), + createMockIssue('bb-3'), + ]; + const curr = [createMockIssue('bb-2')]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 0); + }); + + it('should still emit created events for new issues even when others are deleted', () => { + const prev = [createMockIssue('bb-1')]; + const curr = [createMockIssue('bb-2')]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].kind, 'created'); + assert.strictEqual(events[0].beadId, 'bb-2'); + }); + }); + + describe('LABELS ORDER INDEPENDENCE', () => { + it('should NOT emit labels_changed when same labels are in different order', () => { + const prev = [createMockIssue('bb-1', { labels: ['bug', 'ui', 'urgent'] })]; + const curr = [createMockIssue('bb-1', { labels: ['urgent', 'bug', 'ui'] })]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 0); + }); + + it('should NOT emit labels_changed for single label (trivially same order)', () => { + const prev = [createMockIssue('bb-1', { labels: ['bug'] })]; + const curr = [createMockIssue('bb-1', { labels: ['bug'] })]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 0); + }); + + it('should handle large label sets with shuffled order', () => { + const labels = Array.from({ length: 20 }, (_, i) => `label-${i}`); + const shuffled = [...labels].reverse(); + + const prev = [createMockIssue('bb-1', { labels })]; + const curr = [createMockIssue('bb-1', { labels: shuffled })]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 0); + }); + }); + + describe('DUPLICATE LABELS', () => { + it('should NOT emit event when labels have same duplicates in same order', () => { + const prev = [createMockIssue('bb-1', { labels: ['bug', 'bug', 'ui'] })]; + const curr = [createMockIssue('bb-1', { labels: ['bug', 'bug', 'ui'] })]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 0); + }); + + it('should NOT emit event when labels have same duplicates in different order', () => { + const prev = [createMockIssue('bb-1', { labels: ['bug', 'bug', 'ui'] })]; + const curr = [createMockIssue('bb-1', { labels: ['ui', 'bug', 'bug'] })]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 0); + }); + + it('should emit event when duplicate count differs', () => { + const prev = [createMockIssue('bb-1', { labels: ['bug', 'bug'] })]; + const curr = [createMockIssue('bb-1', { labels: ['bug'] })]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].kind, 'labels_changed'); + }); + + it('should emit event when new duplicate is added', () => { + const prev = [createMockIssue('bb-1', { labels: ['bug'] })]; + const curr = [createMockIssue('bb-1', { labels: ['bug', 'bug'] })]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 1); + assert.strictEqual(events[0].kind, 'labels_changed'); + }); + }); + + describe('EDGE CASES', () => { + it('should handle null previous snapshot (first load)', () => { + const curr = [createMockIssue('bb-1'), createMockIssue('bb-2')]; + + const events = diffSnapshots(null, curr); + assert.strictEqual(events.length, 2); + events.forEach(e => { + assert.strictEqual(e.kind, 'created'); + }); + }); + + it('should handle empty previous and current snapshots', () => { + const events = diffSnapshots([], []); + assert.strictEqual(events.length, 0); + }); + + it('should handle same issue in both snapshots with no changes', () => { + const issue = createMockIssue('bb-1'); + const events = diffSnapshots([issue], [issue]); + assert.strictEqual(events.length, 0); + }); + + it('should handle multiple dependency changes on same bead', () => { + const prev = [createMockIssue('bb-1', { + dependencies: [ + { type: 'blocks', target: 'bb-2' }, + { type: 'relates_to', target: 'bb-3' }, + ], + })]; + const curr = [createMockIssue('bb-1', { + dependencies: [ + { type: 'blocks', target: 'bb-4' }, + ], + })]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 3); + + const kinds = events.map(e => e.kind); + assert.ok(kinds.includes('dependency_added')); + assert.ok(kinds.filter(k => k === 'dependency_removed').length === 2); + }); + + it('should generate valid UUIDs for all events', () => { + const prev: BeadIssueWithProject[] = []; + const curr = [createMockIssue('bb-1')]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events.length, 1); + + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + assert.match(events[0].id, uuidRegex); + }); + + it('should include correct project info in events', () => { + const prev: BeadIssueWithProject[] = []; + const curr = [createMockIssue('bb-1')]; + + const events = diffSnapshots(prev, curr); + assert.strictEqual(events[0].projectId, 'proj-1'); + assert.strictEqual(events[0].projectName, 'Test Project'); + }); + + it('should compute actor from assignee or owner or created_by', () => { + const events1 = diffSnapshots([], [createMockIssue('bb-1', { assignee: 'alice', owner: 'bob', created_by: 'charlie' })]); + assert.strictEqual(events1[0].actor, 'alice'); + + const events2 = diffSnapshots([], [createMockIssue('bb-1', { assignee: null, owner: 'bob', created_by: 'charlie' })]); + assert.strictEqual(events2[0].actor, 'bob'); + + const events3 = diffSnapshots([], [createMockIssue('bb-1', { assignee: null, owner: null, created_by: 'charlie' })]); + assert.strictEqual(events3[0].actor, 'charlie'); + }); + }); +}); \ No newline at end of file