checkpoint: pre-split branch cleanup

This commit is contained in:
ZenchantLive 2026-03-03 16:43:42 -08:00
parent 4c2ae2e5b7
commit b5db7a7753
276 changed files with 35912 additions and 60119 deletions

View file

@ -1,38 +1,38 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { GET as eventsGet } from '../../src/app/api/events/route';
import { getIssuesWatchManager } from '../../src/lib/watcher';
test.afterEach(async () => {
await getIssuesWatchManager().stopAll();
});
test.after(async () => {
await getIssuesWatchManager().stopAll();
});
test('events route returns SSE response with expected headers', async () => {
const response = await eventsGet(new Request('http://localhost/api/events?projectRoot=C:/Repo/Test'));
assert.equal(response.status, 200);
assert.equal(response.headers.get('content-type')?.includes('text/event-stream'), true);
assert.equal(response.headers.get('cache-control')?.includes('no-cache'), true);
const reader = response.body?.getReader();
if (reader) {
await reader.cancel();
}
});
test('events route emits initial connected frame', async () => {
const response = await eventsGet(new Request('http://localhost/api/events?projectRoot=C:/Repo/Test'));
const reader = response.body?.getReader();
assert.equal(Boolean(reader), true);
const first = await reader!.read();
const chunk = new TextDecoder().decode(first.value);
assert.equal(chunk.includes(': connected'), true);
await reader!.cancel();
});
import test from 'node:test';
import assert from 'node:assert/strict';
import { GET as eventsGet } from '../../src/app/api/events/route';
import { getIssuesWatchManager } from '../../src/lib/watcher';
test.afterEach(async () => {
await getIssuesWatchManager().stopAll();
});
test.after(async () => {
await getIssuesWatchManager().stopAll();
});
test('events route returns SSE response with expected headers', async () => {
const response = await eventsGet(new Request('http://localhost/api/events?projectRoot=C:/Repo/Test'));
assert.equal(response.status, 200);
assert.equal(response.headers.get('content-type')?.includes('text/event-stream'), true);
assert.equal(response.headers.get('cache-control')?.includes('no-cache'), true);
const reader = response.body?.getReader();
if (reader) {
await reader.cancel();
}
});
test('events route emits initial connected frame', async () => {
const response = await eventsGet(new Request('http://localhost/api/events?projectRoot=C:/Repo/Test'));
const reader = response.body?.getReader();
assert.equal(Boolean(reader), true);
const first = await reader!.read();
const chunk = new TextDecoder().decode(first.value);
assert.equal(chunk.includes(': connected'), true);
await reader!.cancel();
});

View file

@ -52,4 +52,4 @@ test('comment route returns 400 for missing comment text', async () => {
const data = await readJson(response);
assert.equal(data.ok, false);
assert.equal(typeof data.error.message, 'string');
});
});

View file

@ -1,109 +1,109 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { DELETE, GET, POST } from '../../src/app/api/projects/route';
async function withTempUserProfile(run: (userProfile: string) => Promise<void>): Promise<void> {
const previous = process.env.USERPROFILE;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-api-'));
process.env.USERPROFILE = tempDir;
try {
await run(tempDir);
} finally {
if (previous === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = previous;
}
await fs.rm(tempDir, { recursive: true, force: true });
}
}
async function readJson(response: Response): Promise<unknown> {
return response.json();
}
test('GET /api/projects returns empty list initially', async () => {
await withTempUserProfile(async () => {
const response = await GET();
assert.equal(response.status, 200);
const body = (await readJson(response)) as { projects: unknown[] };
assert.deepEqual(body.projects, []);
});
});
test('POST /api/projects validates payload and path', async () => {
await withTempUserProfile(async () => {
const missing = await POST(new Request('http://localhost/api/projects', { method: 'POST', body: '{}' }));
assert.equal(missing.status, 400);
const missingBody = (await readJson(missing)) as { error: string };
assert.match(missingBody.error, /path/i);
const invalidPath = await POST(
new Request('http://localhost/api/projects', {
method: 'POST',
body: JSON.stringify({ path: '/tmp/project' }),
headers: { 'content-type': 'application/json' },
}),
);
assert.equal(invalidPath.status, 400);
});
});
test('POST deduplicates and GET returns normalized path', async () => {
await withTempUserProfile(async () => {
const first = await POST(
new Request('http://localhost/api/projects', {
method: 'POST',
body: JSON.stringify({ path: 'c:/Users/Zenchant/codex/beadboard/' }),
headers: { 'content-type': 'application/json' },
}),
);
assert.equal(first.status, 201);
const dup = await POST(
new Request('http://localhost/api/projects', {
method: 'POST',
body: JSON.stringify({ path: 'C:\\users\\zenchant\\codex\\beadboard' }),
headers: { 'content-type': 'application/json' },
}),
);
assert.equal(dup.status, 200);
const list = await GET();
const body = (await readJson(list)) as { projects: Array<{ path: string }> };
assert.deepEqual(body.projects, [{ path: 'C:/Users/Zenchant/codex/beadboard' }]);
});
});
test('DELETE /api/projects removes by normalized path', async () => {
await withTempUserProfile(async () => {
await POST(
new Request('http://localhost/api/projects', {
method: 'POST',
body: JSON.stringify({ path: 'D:/Repos/One' }),
headers: { 'content-type': 'application/json' },
}),
);
const removed = await DELETE(
new Request('http://localhost/api/projects', {
method: 'DELETE',
body: JSON.stringify({ path: 'd:\\repos\\one\\' }),
headers: { 'content-type': 'application/json' },
}),
);
assert.equal(removed.status, 200);
const list = await GET();
const body = (await readJson(list)) as { projects: unknown[] };
assert.deepEqual(body.projects, []);
});
});
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { DELETE, GET, POST } from '../../src/app/api/projects/route';
async function withTempUserProfile(run: (userProfile: string) => Promise<void>): Promise<void> {
const previous = process.env.USERPROFILE;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-api-'));
process.env.USERPROFILE = tempDir;
try {
await run(tempDir);
} finally {
if (previous === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = previous;
}
await fs.rm(tempDir, { recursive: true, force: true });
}
}
async function readJson(response: Response): Promise<unknown> {
return response.json();
}
test('GET /api/projects returns empty list initially', async () => {
await withTempUserProfile(async () => {
const response = await GET();
assert.equal(response.status, 200);
const body = (await readJson(response)) as { projects: unknown[] };
assert.deepEqual(body.projects, []);
});
});
test('POST /api/projects validates payload and path', async () => {
await withTempUserProfile(async () => {
const missing = await POST(new Request('http://localhost/api/projects', { method: 'POST', body: '{}' }));
assert.equal(missing.status, 400);
const missingBody = (await readJson(missing)) as { error: string };
assert.match(missingBody.error, /path/i);
const invalidPath = await POST(
new Request('http://localhost/api/projects', {
method: 'POST',
body: JSON.stringify({ path: '/tmp/project' }),
headers: { 'content-type': 'application/json' },
}),
);
assert.equal(invalidPath.status, 400);
});
});
test('POST deduplicates and GET returns normalized path', async () => {
await withTempUserProfile(async () => {
const first = await POST(
new Request('http://localhost/api/projects', {
method: 'POST',
body: JSON.stringify({ path: 'c:/Users/Zenchant/codex/beadboard/' }),
headers: { 'content-type': 'application/json' },
}),
);
assert.equal(first.status, 201);
const dup = await POST(
new Request('http://localhost/api/projects', {
method: 'POST',
body: JSON.stringify({ path: 'C:\\users\\zenchant\\codex\\beadboard' }),
headers: { 'content-type': 'application/json' },
}),
);
assert.equal(dup.status, 200);
const list = await GET();
const body = (await readJson(list)) as { projects: Array<{ path: string }> };
assert.deepEqual(body.projects, [{ path: 'C:/Users/Zenchant/codex/beadboard' }]);
});
});
test('DELETE /api/projects removes by normalized path', async () => {
await withTempUserProfile(async () => {
await POST(
new Request('http://localhost/api/projects', {
method: 'POST',
body: JSON.stringify({ path: 'D:/Repos/One' }),
headers: { 'content-type': 'application/json' },
}),
);
const removed = await DELETE(
new Request('http://localhost/api/projects', {
method: 'DELETE',
body: JSON.stringify({ path: 'd:\\repos\\one\\' }),
headers: { 'content-type': 'application/json' },
}),
);
assert.equal(removed.status, 200);
const list = await GET();
const body = (await readJson(list)) as { projects: unknown[] };
assert.deepEqual(body.projects, []);
});
});

View file

@ -1,21 +1,21 @@
// @ts-ignore
import { expect, test, describe, mock } from 'bun:test';
import { GET } from '../../../src/app/api/swarm/archetypes/route';
// Mock the dependency
mock.module('../../../src/lib/server/beads-fs', () => ({
getArchetypes: async () => [
{ id: 'test-arch', name: 'Test', isBuiltIn: true }
]
}));
describe('/api/swarm/archetypes GET', () => {
test('returns 200 and a JSON array of archetypes', async () => {
const response = await GET();
expect(response.status).toBe(200);
const data = await response.json();
expect(Array.isArray(data)).toBe(true);
expect(data[0].id).toBe('test-arch');
});
});
// @ts-ignore
import { expect, test, describe, mock } from 'bun:test';
import { GET } from '../../../src/app/api/swarm/archetypes/route';
// Mock the dependency
mock.module('../../../src/lib/server/beads-fs', () => ({
getArchetypes: async () => [
{ id: 'test-arch', name: 'Test', isBuiltIn: true }
]
}));
describe('/api/swarm/archetypes GET', () => {
test('returns 200 and a JSON array of archetypes', async () => {
const response = await GET();
expect(response.status).toBe(200);
const data = await response.json();
expect(Array.isArray(data)).toBe(true);
expect(data[0].id).toBe('test-arch');
});
});

View file

@ -27,4 +27,4 @@ test('package.json has next/react/typescript scripts and deps', () => {
assert.equal(typeof pkg.scripts.build, 'string', 'build script required');
assert.equal(typeof pkg.scripts.start, 'string', 'start script required');
assert.equal(typeof pkg.scripts.typecheck, 'string', 'typecheck script required');
});
});

View file

@ -0,0 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
test('dependency graph uses hide-closed filtered epics for epic chip strip', async () => {
const file = await fs.readFile(path.join(process.cwd(), 'src/components/graph/dependency-graph-page.tsx'), 'utf8');
assert.ok(file.includes('const selectableEpics = useMemo'), 'expected selectableEpics memoized list');
assert.ok(file.includes('epics={selectableEpics}'), 'expected EpicChipStrip to receive selectableEpics');
assert.ok(file.includes('selectableEpics.some((epic) => epic.id === requestedEpicId)'), 'expected requested epic validation against selectable epics');
});

View file

@ -0,0 +1,84 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { shouldHideEpicEntry, type LeftPanelFilters } from '../../../src/components/shared/left-panel';
const defaultFilters: LeftPanelFilters = {
query: '',
status: 'all',
priority: 'all',
preset: 'all',
hideClosed: true,
};
test('does not hide epics with no children when hideClosed is the only active toggle', () => {
const hidden = shouldHideEpicEntry({
epicStatus: 'open',
matchedChildrenCount: 0,
totalChildrenCount: 0,
isSelected: false,
filters: defaultFilters,
});
assert.equal(hidden, false);
});
test('hides epics with only closed children when hideClosed is enabled', () => {
const hidden = shouldHideEpicEntry({
epicStatus: 'open',
matchedChildrenCount: 0,
totalChildrenCount: 4,
isSelected: false,
filters: defaultFilters,
});
assert.equal(hidden, true);
});
test('hides epic with children when query filter excludes all children', () => {
const hidden = shouldHideEpicEntry({
epicStatus: 'open',
matchedChildrenCount: 0,
totalChildrenCount: 3,
isSelected: false,
filters: { ...defaultFilters, query: 'nonexistent' },
});
assert.equal(hidden, true);
});
test('keeps selected epic visible even when no children match filters', () => {
const hidden = shouldHideEpicEntry({
epicStatus: 'open',
matchedChildrenCount: 0,
totalChildrenCount: 5,
isSelected: true,
filters: { ...defaultFilters, status: 'blocked' },
});
assert.equal(hidden, false);
});
test('hides closed epic even when it has no children', () => {
const hidden = shouldHideEpicEntry({
epicStatus: 'closed',
matchedChildrenCount: 0,
totalChildrenCount: 0,
isSelected: false,
filters: defaultFilters,
});
assert.equal(hidden, true);
});
test('hides closed selected epic when hideClosed is enabled', () => {
const hidden = shouldHideEpicEntry({
epicStatus: 'closed',
matchedChildrenCount: 2,
totalChildrenCount: 2,
isSelected: true,
filters: defaultFilters,
});
assert.equal(hidden, true);
});

View file

@ -1,66 +1,66 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
describe('LeftPanel Component Contract', () => {
it('exports LeftPanel component', async () => {
try {
const mod = await import('../../../src/components/shared/left-panel');
assert.ok(mod.LeftPanel, 'LeftPanel should be exported');
assert.equal(typeof mod.LeftPanel, 'function', 'LeftPanel should be a function/component');
} catch (err: any) {
assert.fail(`LeftPanel module should exist: ${err.message}`);
}
});
it('LeftPanel accepts issues and onEpicSelect props', async () => {
try {
const mod = await import('../../../src/components/shared/left-panel');
const LeftPanel = mod.LeftPanel;
assert.ok(LeftPanel, 'Component should be callable');
} catch (err: any) {
assert.fail(`Component import failed: ${err.message}`);
}
});
});
describe('LeftPanel Tree Structure', () => {
it('renders epics as expandable tree items', async () => {
try {
const mod = await import('../../../src/components/shared/left-panel');
assert.ok(mod.LeftPanel, 'LeftPanel should exist');
} catch (err: any) {
assert.fail(`LeftPanel should render epic tree: ${err.message}`);
}
});
it('groups beads under their parent epic', async () => {
try {
const mod = await import('../../../src/components/shared/left-panel');
assert.ok(mod.LeftPanel, 'LeftPanel should exist');
} catch (err: any) {
assert.fail(`LeftPanel should group beads under epics: ${err.message}`);
}
});
});
describe('LeftPanel Responsive Behavior', () => {
it('applies responsive classes for desktop, tablet, and mobile', async () => {
try {
const mod = await import('../../../src/components/shared/left-panel');
assert.ok(mod.LeftPanel, 'LeftPanel should exist');
} catch (err: any) {
assert.fail(`LeftPanel should have responsive classes: ${err.message}`);
}
});
});
describe('LeftPanel Scope Controls', () => {
it('renders scope section', async () => {
try {
const mod = await import('../../../src/components/shared/left-panel');
assert.ok(mod.LeftPanel, 'LeftPanel should exist');
} catch (err: any) {
assert.fail(`LeftPanel should render scope section: ${err.message}`);
}
});
});
import { describe, it } from 'node:test';
import assert from 'node:assert';
describe('LeftPanel Component Contract', () => {
it('exports LeftPanel component', async () => {
try {
const mod = await import('../../../src/components/shared/left-panel');
assert.ok(mod.LeftPanel, 'LeftPanel should be exported');
assert.equal(typeof mod.LeftPanel, 'function', 'LeftPanel should be a function/component');
} catch (err: any) {
assert.fail(`LeftPanel module should exist: ${err.message}`);
}
});
it('LeftPanel accepts issues and onEpicSelect props', async () => {
try {
const mod = await import('../../../src/components/shared/left-panel');
const LeftPanel = mod.LeftPanel;
assert.ok(LeftPanel, 'Component should be callable');
} catch (err: any) {
assert.fail(`Component import failed: ${err.message}`);
}
});
});
describe('LeftPanel Tree Structure', () => {
it('renders epics as expandable tree items', async () => {
try {
const mod = await import('../../../src/components/shared/left-panel');
assert.ok(mod.LeftPanel, 'LeftPanel should exist');
} catch (err: any) {
assert.fail(`LeftPanel should render epic tree: ${err.message}`);
}
});
it('groups beads under their parent epic', async () => {
try {
const mod = await import('../../../src/components/shared/left-panel');
assert.ok(mod.LeftPanel, 'LeftPanel should exist');
} catch (err: any) {
assert.fail(`LeftPanel should group beads under epics: ${err.message}`);
}
});
});
describe('LeftPanel Responsive Behavior', () => {
it('applies responsive classes for desktop, tablet, and mobile', async () => {
try {
const mod = await import('../../../src/components/shared/left-panel');
assert.ok(mod.LeftPanel, 'LeftPanel should exist');
} catch (err: any) {
assert.fail(`LeftPanel should have responsive classes: ${err.message}`);
}
});
});
describe('LeftPanel Scope Controls', () => {
it('renders scope section', async () => {
try {
const mod = await import('../../../src/components/shared/left-panel');
assert.ok(mod.LeftPanel, 'LeftPanel should exist');
} catch (err: any) {
assert.fail(`LeftPanel should render scope section: ${err.message}`);
}
});
});

View file

@ -0,0 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
test('UnifiedShell clears selected closed epic when hideClosed is enabled', async () => {
const file = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf8');
assert.ok(file.includes('if (epic.status === \'closed\' || epic.status === \'tombstone\')'), 'expected closed epic guard');
assert.ok(file.includes('setEpicId(null);'), 'expected selected epic reset');
});

View file

@ -1,16 +1,16 @@
// @ts-ignore
import { expect, test, describe, mock } from 'bun:test';
// Mock the hook that the component tries to import
mock.module('@/hooks/use-url-state', () => ({
useUrlState: () => ({ setUrlState: () => { }, swarmId: '1' })
}));
describe('SwarmMissionPicker Component', () => {
test('exports SwarmMissionPicker component that is a function', async () => {
// @ts-ignore
const mod = await import('../../../src/components/swarm/swarm-mission-picker');
expect(mod.SwarmMissionPicker).toBeDefined();
expect(typeof mod.SwarmMissionPicker).toBe('function');
});
});
// @ts-ignore
import { expect, test, describe, mock } from 'bun:test';
// Mock the hook that the component tries to import
mock.module('@/hooks/use-url-state', () => ({
useUrlState: () => ({ setUrlState: () => { }, swarmId: '1' })
}));
describe('SwarmMissionPicker Component', () => {
test('exports SwarmMissionPicker component that is a function', async () => {
// @ts-ignore
const mod = await import('../../../src/components/swarm/swarm-mission-picker');
expect(mod.SwarmMissionPicker).toBeDefined();
expect(typeof mod.SwarmMissionPicker).toBe('function');
});
});

View file

@ -1,11 +1,11 @@
// @ts-ignore
import { expect, test, describe } from 'bun:test';
describe('SwarmWorkspace Component', () => {
test('exports SwarmWorkspace component that is a function', async () => {
// @ts-ignore
const mod = await import('../../../src/components/swarm/swarm-workspace');
expect(mod.SwarmWorkspace).toBeDefined();
expect(typeof mod.SwarmWorkspace).toBe('function');
});
});
// @ts-ignore
import { expect, test, describe } from 'bun:test';
describe('SwarmWorkspace Component', () => {
test('exports SwarmWorkspace component that is a function', async () => {
// @ts-ignore
const mod = await import('../../../src/components/swarm/swarm-workspace');
expect(mod.SwarmWorkspace).toBeDefined();
expect(typeof mod.SwarmWorkspace).toBe('function');
});
});

View file

@ -1,66 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'fs/promises';
import path from 'path';
// Test that the UnifiedShell component exists and exports correctly
test('UnifiedShell - file exists and exports', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
assert.ok(fileContent.includes('export function UnifiedShell'), 'Should export UnifiedShell function');
assert.ok(fileContent.includes('export interface UnifiedShellProps'), 'Should export UnifiedShellProps interface');
});
// Test that UnifiedShell has assignMode state
test('UnifiedShell - has assignMode state', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
assert.ok(fileContent.includes('assignMode'), 'Should have assignMode state');
});
// Test that UnifiedShell has selectedAssignIssue state
test('UnifiedShell - has selectedAssignIssue state', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
assert.ok(fileContent.includes('selectedAssignIssue'), 'Should have selectedAssignIssue state');
});
// Test that SmartDag receives onAssignModeChange callback
test('UnifiedShell - passes onAssignModeChange to SmartDag', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
assert.ok(fileContent.includes('onAssignModeChange'), 'Should pass onAssignModeChange to SmartDag');
});
// Test that SmartDag receives onSelectedIssueChange callback
test('UnifiedShell - passes onSelectedIssueChange to SmartDag', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
assert.ok(fileContent.includes('onSelectedIssueChange'), 'Should pass onSelectedIssueChange to SmartDag');
});
// Test that AssignmentPanel is imported
test('UnifiedShell - imports AssignmentPanel', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
assert.ok(fileContent.includes('AssignmentPanel'), 'Should import AssignmentPanel');
});
test('UnifiedShell - checks bd health and renders setup warning', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
assert.ok(fileContent.includes('useBdHealth'), 'Should use bd health hook');
assert.ok(fileContent.includes('BD setup issue:'), 'Should show bd setup warning text');
});
// Test that AssignmentPanel is rendered conditionally based on view and assignMode
test('UnifiedShell - renders AssignmentPanel conditionally', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
// Check for the condition: view === 'graph' && assignMode
assert.ok(fileContent.includes("view === 'graph' && assignMode"), 'Should check view === graph && assignMode condition for AssignmentPanel');
});
// Test that SwarmWorkspace import is removed (deprecated)
test('UnifiedShell - does not import SwarmWorkspace', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
assert.ok(!fileContent.includes('SwarmWorkspace'), 'Should NOT import SwarmWorkspace (deprecated)');
});
// Test that SwarmMissionPicker import is removed (deprecated)
test('UnifiedShell - does not import SwarmMissionPicker', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
assert.ok(!fileContent.includes('SwarmMissionPicker'), 'Should NOT import SwarmMissionPicker (deprecated)');
});
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'fs/promises';
import path from 'path';
// Test that the UnifiedShell component exists and exports correctly
test('UnifiedShell - file exists and exports', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
assert.ok(fileContent.includes('export function UnifiedShell'), 'Should export UnifiedShell function');
assert.ok(fileContent.includes('export interface UnifiedShellProps'), 'Should export UnifiedShellProps interface');
});
// Test that UnifiedShell has assignMode state
test('UnifiedShell - has assignMode state', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
assert.ok(fileContent.includes('assignMode'), 'Should have assignMode state');
});
// Test that UnifiedShell has selectedAssignIssue state
test('UnifiedShell - has selectedAssignIssue state', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
assert.ok(fileContent.includes('selectedAssignIssue'), 'Should have selectedAssignIssue state');
});
// Test that SmartDag receives onAssignModeChange callback
test('UnifiedShell - passes onAssignModeChange to SmartDag', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
assert.ok(fileContent.includes('onAssignModeChange'), 'Should pass onAssignModeChange to SmartDag');
});
// Test that SmartDag receives onSelectedIssueChange callback
test('UnifiedShell - passes onSelectedIssueChange to SmartDag', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
assert.ok(fileContent.includes('onSelectedIssueChange'), 'Should pass onSelectedIssueChange to SmartDag');
});
// Test that AssignmentPanel is imported
test('UnifiedShell - imports AssignmentPanel', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
assert.ok(fileContent.includes('AssignmentPanel'), 'Should import AssignmentPanel');
});
test('UnifiedShell - checks bd health and renders setup warning', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
assert.ok(fileContent.includes('useBdHealth'), 'Should use bd health hook');
assert.ok(fileContent.includes('BD setup issue:'), 'Should show bd setup warning text');
});
// Test that AssignmentPanel is rendered conditionally based on view and assignMode
test('UnifiedShell - renders AssignmentPanel conditionally', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
// Check for the condition: assignMode && !taskId
assert.ok(fileContent.includes("assignMode && !taskId"), 'Should check assignMode && !taskId condition for AssignmentPanel');
});
// Test that SwarmWorkspace import is removed (deprecated)
test('UnifiedShell - does not import SwarmWorkspace', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
assert.ok(!fileContent.includes('SwarmWorkspace'), 'Should NOT import SwarmWorkspace (deprecated)');
});
// Test that SwarmMissionPicker import is removed (deprecated)
test('UnifiedShell - does not import SwarmMissionPicker', async () => {
const fileContent = await fs.readFile(path.join(process.cwd(), 'src/components/shared/unified-shell.tsx'), 'utf-8');
assert.ok(!fileContent.includes('SwarmMissionPicker'), 'Should NOT import SwarmMissionPicker (deprecated)');
});

View file

@ -1,82 +1,82 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
const ROOT = process.cwd();
async function read(relativePath) {
return fs.readFile(path.join(ROOT, relativePath), 'utf8');
}
test('graph page defines tabbed layout, epic chips, and mobile fallback', async () => {
const graphPage = await read('src/components/graph/dependency-graph-page.tsx');
// Tabbed layout: Tasks and Dependencies tabs
assert.match(graphPage, /WorkflowTabs/, 'should use WorkflowTabs component');
assert.match(graphPage, /activeTab/, 'should track active tab state');
// Epic chip strip replaces sidebar
assert.match(graphPage, /EpicChipStrip/, 'should use EpicChipStrip component');
// Mobile panel toggle preserved
assert.match(graphPage, /Switch to Graph/);
assert.match(graphPage, /Back to Selection/);
// Task card grid extracted
assert.match(graphPage, /TaskCardGrid/, 'should use TaskCardGrid component');
// Task details drawer
assert.match(graphPage, /TaskDetailsDrawer/, 'should use TaskDetailsDrawer drawer');
assert.match(graphPage, /projectRoot=\{projectRoot\}/, 'drawer should receive project root for edits');
assert.match(graphPage, /onIssueUpdated=\{.*refreshIssues\(\)\}/, 'drawer should trigger refresh after edits');
// Dependency flow strip
assert.match(graphPage, /DependencyFlowStrip/, 'should use DependencyFlowStrip component');
// Graph section with ReactFlow
assert.match(graphPage, /GraphSection/, 'should use GraphSection component');
assert.match(graphPage, /ReactFlowProvider/, 'should wrap graph in ReactFlowProvider');
// Edge options and node types still configured
assert.match(graphPage, /defaultEdgeOptions/);
assert.match(graphPage, /nodeTypes/);
// Actionable node detection
assert.match(graphPage, /actionableNodeIds/, 'should compute actionable (unblocked) nodes');
assert.match(graphPage, /ui-field/, 'graph filters should use shared dark field styling');
assert.match(graphPage, /ui-select/, 'graph select should use shared dark select styling');
});
test('extracted graph section has viewport and legend', async () => {
const graphSection = await read('src/components/graph/graph-section.tsx');
assert.match(graphSection, /className=\"workflow-graph-flow\"/, 'graph should have workflow-graph-flow class');
assert.match(graphSection, /workflow-graph-legend/, 'should have legend');
assert.match(graphSection, /translateExtent=\{\[/, 'should set translate extent');
assert.match(graphSection, /defaultEdgeOptions=\{/, 'should pass edge options');
assert.match(graphSection, /blockerAnalysis/, 'should show blocker stats');
assert.match(graphSection, /hideClosed/, 'should support hideClosed state in legend');
assert.match(graphSection, /!hideClosed/, 'done legend should be hidden when closed items are hidden');
assert.match(graphSection, /Read left to right/, 'legend should include plain directional hint');
assert.match(graphSection, /Left = blockers/, 'legend should include left/right dependency meaning');
assert.match(graphSection, /Right = work unblocked by this task/, 'legend should include downstream meaning');
assert.match(graphSection, /min-h-\[24rem\]/, 'graph container should enforce bounded minimum height');
assert.match(graphSection, /md:min-h-\[35rem\]/, 'graph container should scale minimum height on desktop');
});
test('graph node card supports tooltips and actionable glow', async () => {
const nodeCard = await read('src/components/graph/graph-node-card.tsx');
assert.match(nodeCard, /isActionable/, 'should check actionable state');
assert.match(nodeCard, /ring-emerald-400/, 'actionable nodes should have green glow');
assert.match(nodeCard, /node-select-pulse/, 'selected nodes should pulse');
assert.match(nodeCard, /blockerTooltipLines/, 'should display blocker tooltip');
assert.match(nodeCard, /isDimmed/, 'should support dimming non-chain nodes');
assert.match(nodeCard, /Ready to work/, 'actionable tooltip text');
});
test('graph edges expose explicit relation labels', async () => {
const graphPage = await read('src/components/graph/dependency-graph-page.tsx');
assert.match(graphPage, /label:\s*'BLOCKS'/, 'edges should include plain-language relation labels');
});
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
const ROOT = process.cwd();
async function read(relativePath) {
return fs.readFile(path.join(ROOT, relativePath), 'utf8');
}
test('graph page defines tabbed layout, epic chips, and mobile fallback', async () => {
const graphPage = await read('src/components/graph/dependency-graph-page.tsx');
// Tabbed layout: Tasks and Dependencies tabs
assert.match(graphPage, /WorkflowTabs/, 'should use WorkflowTabs component');
assert.match(graphPage, /activeTab/, 'should track active tab state');
// Epic chip strip replaces sidebar
assert.match(graphPage, /EpicChipStrip/, 'should use EpicChipStrip component');
// Mobile panel toggle preserved
assert.match(graphPage, /Switch to Graph/);
assert.match(graphPage, /Back to Selection/);
// Task card grid extracted
assert.match(graphPage, /TaskCardGrid/, 'should use TaskCardGrid component');
// Task details drawer
assert.match(graphPage, /TaskDetailsDrawer/, 'should use TaskDetailsDrawer drawer');
assert.match(graphPage, /projectRoot=\{projectRoot\}/, 'drawer should receive project root for edits');
assert.match(graphPage, /onIssueUpdated=\{.*refreshIssues\(\)\}/, 'drawer should trigger refresh after edits');
// Dependency flow strip
assert.match(graphPage, /DependencyFlowStrip/, 'should use DependencyFlowStrip component');
// Graph section with ReactFlow
assert.match(graphPage, /GraphSection/, 'should use GraphSection component');
assert.match(graphPage, /ReactFlowProvider/, 'should wrap graph in ReactFlowProvider');
// Edge options and node types still configured
assert.match(graphPage, /defaultEdgeOptions/);
assert.match(graphPage, /nodeTypes/);
// Actionable node detection
assert.match(graphPage, /actionableNodeIds/, 'should compute actionable (unblocked) nodes');
assert.match(graphPage, /ui-field/, 'graph filters should use shared dark field styling');
assert.match(graphPage, /ui-select/, 'graph select should use shared dark select styling');
});
test('extracted graph section has viewport and legend', async () => {
const graphSection = await read('src/components/graph/graph-section.tsx');
assert.match(graphSection, /className=\"workflow-graph-flow\"/, 'graph should have workflow-graph-flow class');
assert.match(graphSection, /workflow-graph-legend/, 'should have legend');
assert.match(graphSection, /translateExtent=\{\[/, 'should set translate extent');
assert.match(graphSection, /defaultEdgeOptions=\{/, 'should pass edge options');
assert.match(graphSection, /blockerAnalysis/, 'should show blocker stats');
assert.match(graphSection, /hideClosed/, 'should support hideClosed state in legend');
assert.match(graphSection, /!hideClosed/, 'done legend should be hidden when closed items are hidden');
assert.match(graphSection, /Read left to right/, 'legend should include plain directional hint');
assert.match(graphSection, /Left = blockers/, 'legend should include left/right dependency meaning');
assert.match(graphSection, /Right = work unblocked by this task/, 'legend should include downstream meaning');
assert.match(graphSection, /min-h-\[24rem\]/, 'graph container should enforce bounded minimum height');
assert.match(graphSection, /md:min-h-\[35rem\]/, 'graph container should scale minimum height on desktop');
});
test('graph node card supports tooltips and actionable glow', async () => {
const nodeCard = await read('src/components/graph/graph-node-card.tsx');
assert.match(nodeCard, /isActionable/, 'should check actionable state');
assert.match(nodeCard, /ring-emerald-400/, 'actionable nodes should have green glow');
assert.match(nodeCard, /node-select-pulse/, 'selected nodes should pulse');
assert.match(nodeCard, /blockerTooltipLines/, 'should display blocker tooltip');
assert.match(nodeCard, /isDimmed/, 'should support dimming non-chain nodes');
assert.match(nodeCard, /Ready to work/, 'actionable tooltip text');
});
test('graph edges expose explicit relation labels', async () => {
const graphPage = await read('src/components/graph/dependency-graph-page.tsx');
assert.match(graphPage, /label:\s*'BLOCKS'/, 'edges should include plain-language relation labels');
});

View file

@ -1,52 +1,52 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
const ROOT = process.cwd();
async function read(relativePath) {
return fs.readFile(path.join(ROOT, relativePath), 'utf8');
}
test('kanban board uses expandable vertical swimlanes', async () => {
const board = await read('src/components/kanban/kanban-board.tsx');
assert.match(board, /aria-expanded/);
assert.match(board, /onActivateStatus/);
assert.match(board, /max-h-\[50vh\]/);
assert.match(board, /showClosed/, 'board should accept showClosed control');
assert.match(board, /status !== 'closed' \|\| showClosed/, 'done lane should be hidden when showClosed is false');
});
test('kanban page defines mobile detail drawer behavior', async () => {
const page = await read('src/components/kanban/kanban-page.tsx');
assert.match(page, /fixed inset-0/);
assert.match(page, /lg:hidden/);
assert.match(page, /lg:grid-cols-\[minmax\(0,1fr\)_minmax\(22rem,26rem\)\]/);
assert.match(page, /lg:border-l/);
});
test('kanban controls use fluid full-width sizing on small viewports', async () => {
const controls = await read('src/components/kanban/kanban-controls.tsx');
assert.match(controls, /w-full/);
assert.match(controls, /sm:w-/);
assert.match(controls, /ui-field/, 'controls should use shared dark field styling');
assert.match(controls, /ui-select/, 'selects should use shared dark select styling');
assert.match(controls, /Next Actionable/);
assert.match(controls, /nextActionableFeedback/);
});
test('kanban detail includes execution checklist rendering', async () => {
const detail = await read('src/components/kanban/kanban-detail.tsx');
assert.match(detail, /Execution checklist/i);
assert.match(detail, /Summary/i);
assert.match(detail, /Task metadata/i);
assert.match(detail, /Timeline/i);
assert.match(detail, /Edit fields/i);
assert.match(detail, /Save changes/i);
assert.match(detail, /projectRoot\?/i);
});
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
const ROOT = process.cwd();
async function read(relativePath) {
return fs.readFile(path.join(ROOT, relativePath), 'utf8');
}
test('kanban board uses expandable vertical swimlanes', async () => {
const board = await read('src/components/kanban/kanban-board.tsx');
assert.match(board, /aria-expanded/);
assert.match(board, /onActivateStatus/);
assert.match(board, /max-h-\[50vh\]/);
assert.match(board, /showClosed/, 'board should accept showClosed control');
assert.match(board, /status !== 'closed' \|\| showClosed/, 'done lane should be hidden when showClosed is false');
});
test('kanban page defines mobile detail drawer behavior', async () => {
const page = await read('src/components/kanban/kanban-page.tsx');
assert.match(page, /fixed inset-0/);
assert.match(page, /lg:hidden/);
assert.match(page, /lg:grid-cols-\[minmax\(0,1fr\)_minmax\(22rem,26rem\)\]/);
assert.match(page, /lg:border-l/);
});
test('kanban controls use fluid full-width sizing on small viewports', async () => {
const controls = await read('src/components/kanban/kanban-controls.tsx');
assert.match(controls, /w-full/);
assert.match(controls, /sm:w-/);
assert.match(controls, /ui-field/, 'controls should use shared dark field styling');
assert.match(controls, /ui-select/, 'selects should use shared dark select styling');
assert.match(controls, /Next Actionable/);
assert.match(controls, /nextActionableFeedback/);
});
test('kanban detail includes execution checklist rendering', async () => {
const detail = await read('src/components/kanban/kanban-detail.tsx');
assert.match(detail, /Execution checklist/i);
assert.match(detail, /Summary/i);
assert.match(detail, /Task metadata/i);
assert.match(detail, /Timeline/i);
assert.match(detail, /Edit fields/i);
assert.match(detail, /Save changes/i);
assert.match(detail, /projectRoot\?/i);
});

View file

@ -6,4 +6,4 @@ import { scanForDirectIssuesJsonlWrites } from '../../tools/guardrails/no-direct
test('source tree contains no direct write calls targeting .beads/issues.jsonl', () => {
const violations = scanForDirectIssuesJsonlWrites('src');
assert.deepEqual(violations, []);
});
});

View file

@ -50,7 +50,7 @@ describe('URL State Integration - bb-ui2.22', () => {
const sp = createMockSearchParams({ view: 'graph' });
const state = parseUrlState(sp);
assert.strictEqual(state.view, 'graph');
assert.strictEqual(state.graphTab, 'flow');
assert.strictEqual(state.graphTab, 'overview');
});
it('/?view=graph&task=bb-buff.1 - graph with task selected', () => {
@ -135,7 +135,7 @@ describe('URL State Integration - bb-ui2.22', () => {
it('/?view=graph&graphTab=invalid - invalid graphTab defaults to flow', () => {
const sp = createMockSearchParams({ view: 'graph', graphTab: 'invalid' });
const state = parseUrlState(sp);
assert.strictEqual(state.graphTab, 'flow');
assert.strictEqual(state.graphTab, 'overview');
});
it('/?panel=invalid - invalid panel defaults to open', () => {

View file

@ -0,0 +1,14 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
test('useBeadsSubscription triggers an initial refresh on mount', async () => {
const file = await fs.readFile(path.join(process.cwd(), 'src/hooks/use-beads-subscription.ts'), 'utf8');
assert.ok(file.includes("void refresh({ silent: true })"), 'expected initial refresh call');
});
test('app page forces dynamic rendering to avoid stale prerendered issues', async () => {
const file = await fs.readFile(path.join(process.cwd(), 'src/app/page.tsx'), 'utf8');
assert.ok(file.includes("export const dynamic = 'force-dynamic';"), 'expected force-dynamic export');
});

View file

@ -1,105 +1,105 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
registerAgent,
extendActivityLease,
deriveLiveness,
} from '../../src/lib/agent-registry';
async function withTempUserProfile(run: () => Promise<void>): Promise<void> {
const previous = process.env.USERPROFILE;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-agent-liveness-'));
process.env.USERPROFILE = tempDir;
try {
await run();
} finally {
if (previous === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = previous;
}
await fs.rm(tempDir, { recursive: true, force: true });
}
}
test('extendActivityLease emits heartbeat and returns null data (side effect only)', async () => {
await withTempUserProfile(async () => {
const start = '2026-02-14T10:00:00.000Z';
await registerAgent(
{ name: 'active-agent', role: 'infra' },
{ now: () => start }
);
const result = await extendActivityLease(
{ agent: 'active-agent' },
{ now: () => start }
);
assert.equal(result.ok, true);
assert.equal(result.data, null, 'extendActivityLease returns null data - heartbeat is side effect');
});
});
test('deriveLiveness follows threshold rules (15m/30m default)', () => {
const now = new Date('2026-02-14T12:00:00Z');
// Active: 14 mins ago
assert.equal(
deriveLiveness('2026-02-14T11:46:00Z', now),
'active'
);
// Stale: Exactly 15 mins ago
assert.equal(
deriveLiveness('2026-02-14T11:45:00Z', now),
'stale'
);
// Stale: 29 mins ago
assert.equal(
deriveLiveness('2026-02-14T11:31:00Z', now),
'stale'
);
// Evicted: Exactly 30 mins ago
assert.equal(
deriveLiveness('2026-02-14T11:30:00Z', now),
'evicted'
);
// Evicted: 1 hour ago
// Note: Since we added Idle at 60m, let's test 59m for Evicted and 60m for Idle
assert.equal(
deriveLiveness('2026-02-14T11:01:00Z', now),
'evicted'
);
// Idle: Exactly 60 mins ago
assert.equal(
deriveLiveness('2026-02-14T11:00:00Z', now),
'idle'
);
// Idle: 2 hours ago
assert.equal(
deriveLiveness('2026-02-14T10:00:00Z', now),
'idle'
);
});
test('deriveLiveness respects custom staleMinutes', () => {
const now = new Date('2026-02-14T12:00:00Z');
const customThreshold = 5; // 5m stale, 10m evicted
assert.equal(deriveLiveness('2026-02-14T11:56:00Z', now, customThreshold), 'active');
assert.equal(deriveLiveness('2026-02-14T11:55:00Z', now, customThreshold), 'stale');
assert.equal(deriveLiveness('2026-02-14T11:51:00Z', now, customThreshold), 'stale');
assert.equal(deriveLiveness('2026-02-14T11:50:00Z', now, customThreshold), 'evicted');
});
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
registerAgent,
extendActivityLease,
deriveLiveness,
} from '../../src/lib/agent-registry';
async function withTempUserProfile(run: () => Promise<void>): Promise<void> {
const previous = process.env.USERPROFILE;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-agent-liveness-'));
process.env.USERPROFILE = tempDir;
try {
await run();
} finally {
if (previous === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = previous;
}
await fs.rm(tempDir, { recursive: true, force: true });
}
}
test('extendActivityLease emits heartbeat and returns null data (side effect only)', async () => {
await withTempUserProfile(async () => {
const start = '2026-02-14T10:00:00.000Z';
await registerAgent(
{ name: 'active-agent', role: 'infra' },
{ now: () => start }
);
const result = await extendActivityLease(
{ agent: 'active-agent' },
{ now: () => start }
);
assert.equal(result.ok, true);
assert.equal(result.data, null, 'extendActivityLease returns null data - heartbeat is side effect');
});
});
test('deriveLiveness follows threshold rules (15m/30m default)', () => {
const now = new Date('2026-02-14T12:00:00Z');
// Active: 14 mins ago
assert.equal(
deriveLiveness('2026-02-14T11:46:00Z', now),
'active'
);
// Stale: Exactly 15 mins ago
assert.equal(
deriveLiveness('2026-02-14T11:45:00Z', now),
'stale'
);
// Stale: 29 mins ago
assert.equal(
deriveLiveness('2026-02-14T11:31:00Z', now),
'stale'
);
// Evicted: Exactly 30 mins ago
assert.equal(
deriveLiveness('2026-02-14T11:30:00Z', now),
'evicted'
);
// Evicted: 1 hour ago
// Note: Since we added Idle at 60m, let's test 59m for Evicted and 60m for Idle
assert.equal(
deriveLiveness('2026-02-14T11:01:00Z', now),
'evicted'
);
// Idle: Exactly 60 mins ago
assert.equal(
deriveLiveness('2026-02-14T11:00:00Z', now),
'idle'
);
// Idle: 2 hours ago
assert.equal(
deriveLiveness('2026-02-14T10:00:00Z', now),
'idle'
);
});
test('deriveLiveness respects custom staleMinutes', () => {
const now = new Date('2026-02-14T12:00:00Z');
const customThreshold = 5; // 5m stale, 10m evicted
assert.equal(deriveLiveness('2026-02-14T11:56:00Z', now, customThreshold), 'active');
assert.equal(deriveLiveness('2026-02-14T11:55:00Z', now, customThreshold), 'stale');
assert.equal(deriveLiveness('2026-02-14T11:51:00Z', now, customThreshold), 'stale');
assert.equal(deriveLiveness('2026-02-14T11:50:00Z', now, customThreshold), 'evicted');
});

View file

@ -1,350 +1,350 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { execSync } from 'node:child_process';
import { registerAgent } from '../../src/lib/agent-registry';
import { ackAgentMessage, inboxAgentMessages, readAgentMessage, sendAgentMessage } from '../../src/lib/agent-mail';
async function withTempProject(run: (projectRoot: string) => Promise<void>): Promise<void> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-agent-mail-'));
execSync('bd init --prefix bb --force', { cwd: tempDir, stdio: 'ignore' });
const previousProfile = process.env.USERPROFILE;
process.env.USERPROFILE = tempDir;
const originalCwd = process.cwd();
process.chdir(tempDir);
try {
await run(tempDir);
} finally {
process.chdir(originalCwd);
if (previousProfile === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = previousProfile;
}
for (let i = 0; i < 5; i++) {
try {
await fs.rm(tempDir, { recursive: true, force: true });
break;
} catch {
await new Promise((r) => setTimeout(r, 500));
}
}
}
}
async function seedAgents(): Promise<void> {
const now = '2026-02-14T00:00:00.000Z';
await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { now: () => now });
await registerAgent({ name: 'agent-graph-1', role: 'graph' }, { now: () => now });
}
test('sendAgentMessage rejects unknown sender and recipient', async () => {
await withTempProject(async () => {
const result = await sendAgentMessage({
from: 'unknown',
to: 'also-unknown',
bead: 'bb-1',
category: 'INFO',
subject: 'Hello',
body: 'World',
});
assert.equal(result.ok, false);
assert.equal(result.error?.code, 'UNKNOWN_SENDER');
});
});
test('send/inbox/read/ack flows end-to-end', async () => {
await withTempProject(async () => {
await seedAgents();
const sent = await sendAgentMessage(
{
from: 'agent-ui-1',
to: 'agent-graph-1',
bead: 'bb-dcv.6',
category: 'HANDOFF',
subject: 'Edge direction patch ready',
body: 'Please validate graph screenshots.',
},
{
now: () => '2026-02-14T00:01:00.000Z',
idGenerator: () => 'msg_20260214_000100_test',
},
);
assert.equal(sent.ok, true);
assert.equal(sent.data?.requires_ack, true);
assert.equal(sent.data?.state, 'unread');
const inboxUnread = await inboxAgentMessages({ agent: 'agent-graph-1', state: 'unread' });
assert.equal(inboxUnread.ok, true);
assert.equal(inboxUnread.data?.length, 1);
const read = await readAgentMessage(
{ agent: 'agent-graph-1', message: 'msg_20260214_000100_test' },
{ now: () => '2026-02-14T00:02:00.000Z' },
);
assert.equal(read.ok, true);
assert.equal(read.data?.state, 'read');
const ack = await ackAgentMessage(
{ agent: 'agent-graph-1', message: 'msg_20260214_000100_test' },
{ now: () => '2026-02-14T00:03:00.000Z' },
);
assert.equal(ack.ok, true);
assert.equal(ack.data?.state, 'acked');
assert.equal(ack.data?.acked_at, '2026-02-14T00:03:00.000Z');
const inboxAcked = await inboxAgentMessages({ agent: 'agent-graph-1', state: 'acked' });
assert.equal(inboxAcked.ok, true);
assert.equal(inboxAcked.data?.length, 1);
});
});
test('ackAgentMessage forbids non-recipient agent', async () => {
await withTempProject(async () => {
await seedAgents();
await sendAgentMessage(
{
from: 'agent-ui-1',
to: 'agent-graph-1',
bead: 'bb-dcv.6',
category: 'HANDOFF',
subject: 'subject',
body: 'body',
},
{
now: () => '2026-02-14T00:01:00.000Z',
idGenerator: () => 'msg_20260214_000100_forbidden',
},
);
const forbidden = await ackAgentMessage(
{ agent: 'agent-ui-1', message: 'msg_20260214_000100_forbidden' },
{ now: () => '2026-02-14T00:02:00.000Z' },
);
assert.equal(forbidden.ok, false);
assert.equal(forbidden.error?.code, 'ACK_FORBIDDEN');
});
});
test('sendAgentMessage validates category and bead id', async () => {
await withTempProject(async () => {
await seedAgents();
const invalidCategory = await sendAgentMessage({
from: 'agent-ui-1',
to: 'agent-graph-1',
bead: 'bb-dcv.6',
category: 'NOPE' as never,
subject: 'subject',
body: 'body',
});
assert.equal(invalidCategory.ok, false);
assert.equal(invalidCategory.error?.code, 'INVALID_CATEGORY');
const missingBead = await sendAgentMessage({
from: 'agent-ui-1',
to: 'agent-graph-1',
bead: ' ',
category: 'INFO',
subject: 'subject',
body: 'body',
});
assert.equal(missingBead.ok, false);
assert.equal(missingBead.error?.code, 'MISSING_BEAD_ID');
});
});
async function seedRoleAgents(): Promise<void> {
const now = '2026-02-14T00:00:00.000Z';
await registerAgent({ name: 'ui-agent-1', role: 'ui' }, { now: () => now });
await registerAgent({ name: 'ui-agent-2', role: 'ui' }, { now: () => now });
await registerAgent({ name: 'graph-agent-1', role: 'graph' }, { now: () => now });
}
test('sendAgentMessage routes to role:ui with multiple recipients', async () => {
await withTempProject(async () => {
await seedRoleAgents();
const sent = await sendAgentMessage(
{
from: 'graph-agent-1',
to: 'role:ui',
bead: 'bb-test.1',
category: 'INFO',
subject: 'Hello UI agents',
body: 'Please check the dashboard',
},
{
now: () => '2026-02-14T00:01:00.000Z',
idGenerator: () => 'msg_role_test_1',
},
);
assert.equal(sent.ok, true);
const inbox1 = await inboxAgentMessages({ agent: 'ui-agent-1' });
const inbox2 = await inboxAgentMessages({ agent: 'ui-agent-2' });
const inboxGraph = await inboxAgentMessages({ agent: 'graph-agent-1' });
assert.equal(inbox1.data?.length, 1);
assert.equal(inbox2.data?.length, 1);
assert.equal(inboxGraph.data?.length, 0);
});
});
test('sendAgentMessage role fanout excludes sender from recipient list', async () => {
await withTempProject(async () => {
await seedRoleAgents();
const sent = await sendAgentMessage(
{
from: 'ui-agent-1',
to: 'role:ui',
bead: 'bb-test.2',
category: 'INFO',
subject: 'Peer message',
body: 'Hello fellow UI agents',
},
{
now: () => '2026-02-14T00:01:00.000Z',
idGenerator: () => 'msg_role_test_2',
},
);
assert.equal(sent.ok, true);
const inbox1 = await inboxAgentMessages({ agent: 'ui-agent-1' });
const inbox2 = await inboxAgentMessages({ agent: 'ui-agent-2' });
assert.equal(inbox1.data?.length, 0, 'sender should not receive');
assert.equal(inbox2.data?.length, 1, 'other ui agent should receive');
});
});
test('sendAgentMessage direct send includes recipient even if sender matches recipient role', async () => {
await withTempProject(async () => {
await seedRoleAgents();
const sent = await sendAgentMessage(
{
from: 'ui-agent-1',
to: 'ui-agent-2',
bead: 'bb-test.3',
category: 'INFO',
subject: 'Direct message',
body: 'Hello specifically',
},
{
now: () => '2026-02-14T00:01:00.000Z',
idGenerator: () => 'msg_role_test_3',
},
);
assert.equal(sent.ok, true);
const inbox2 = await inboxAgentMessages({ agent: 'ui-agent-2' });
assert.equal(inbox2.data?.length, 1, 'direct recipient should receive');
});
});
test('sendAgentMessage unknown role returns UNKNOWN_RECIPIENT', async () => {
await withTempProject(async () => {
await seedRoleAgents();
const sent = await sendAgentMessage({
from: 'ui-agent-1',
to: 'role:nonexistent',
bead: 'bb-test.4',
category: 'INFO',
subject: 'Hello',
body: 'Anyone there?',
});
assert.equal(sent.ok, false);
assert.equal(sent.error?.code, 'UNKNOWN_RECIPIENT');
assert.ok(sent.error?.message.includes('no agents found with role'));
});
});
test('sendAgentMessage known role but all agents excluded returns UNKNOWN_RECIPIENT', async () => {
await withTempProject(async () => {
const now = '2026-02-14T00:00:00.000Z';
await registerAgent({ name: 'only-ui-agent', role: 'ui' }, { now: () => now });
const sent = await sendAgentMessage({
from: 'only-ui-agent',
to: 'role:ui',
bead: 'bb-test.5',
category: 'INFO',
subject: 'Hello myself',
body: 'No one else to hear',
});
assert.equal(sent.ok, false);
assert.equal(sent.error?.code, 'UNKNOWN_RECIPIENT');
assert.ok(sent.error?.message.includes('all recipients were excluded'));
});
});
test('sendAgentMessage role fanout HANDOFF creates individual messages with per-recipient ack', async () => {
await withTempProject(async () => {
await seedRoleAgents();
const sent = await sendAgentMessage(
{
from: 'graph-agent-1',
to: 'role:ui',
bead: 'bb-test.6',
category: 'HANDOFF',
subject: 'Take over',
body: 'Please handle this',
},
{
now: () => '2026-02-14T00:01:00.000Z',
idGenerator: () => 'msg_handoff_test',
},
);
assert.equal(sent.ok, true);
const inbox1 = await inboxAgentMessages({ agent: 'ui-agent-1' });
const inbox2 = await inboxAgentMessages({ agent: 'ui-agent-2' });
assert.equal(inbox1.data?.length, 1);
assert.equal(inbox2.data?.length, 1);
const msg1 = inbox1.data![0];
const msg2 = inbox2.data![0];
assert.notEqual(msg1.message_id, msg2.message_id, 'each recipient gets unique message ID');
assert.equal(msg1.state, 'unread');
assert.equal(msg2.state, 'unread');
const ack1 = await ackAgentMessage(
{ agent: 'ui-agent-1', message: msg1.message_id },
{ now: () => '2026-02-14T00:02:00.000Z' },
);
assert.equal(ack1.ok, true);
assert.equal(ack1.data?.state, 'acked');
const inbox1AfterAck = await inboxAgentMessages({ agent: 'ui-agent-1', state: 'acked' });
const inbox2AfterAck = await inboxAgentMessages({ agent: 'ui-agent-2', state: 'unread' });
assert.equal(inbox1AfterAck.data?.length, 1, 'ui-agent-1 message is acked');
assert.equal(inbox2AfterAck.data?.length, 1, 'ui-agent-2 message still unread');
});
});
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { execSync } from 'node:child_process';
import { registerAgent } from '../../src/lib/agent-registry';
import { ackAgentMessage, inboxAgentMessages, readAgentMessage, sendAgentMessage } from '../../src/lib/agent-mail';
async function withTempProject(run: (projectRoot: string) => Promise<void>): Promise<void> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-agent-mail-'));
execSync('bd init --prefix bb --force', { cwd: tempDir, stdio: 'ignore' });
const previousProfile = process.env.USERPROFILE;
process.env.USERPROFILE = tempDir;
const originalCwd = process.cwd();
process.chdir(tempDir);
try {
await run(tempDir);
} finally {
process.chdir(originalCwd);
if (previousProfile === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = previousProfile;
}
for (let i = 0; i < 5; i++) {
try {
await fs.rm(tempDir, { recursive: true, force: true });
break;
} catch {
await new Promise((r) => setTimeout(r, 500));
}
}
}
}
async function seedAgents(): Promise<void> {
const now = '2026-02-14T00:00:00.000Z';
await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { now: () => now });
await registerAgent({ name: 'agent-graph-1', role: 'graph' }, { now: () => now });
}
test('sendAgentMessage rejects unknown sender and recipient', async () => {
await withTempProject(async () => {
const result = await sendAgentMessage({
from: 'unknown',
to: 'also-unknown',
bead: 'bb-1',
category: 'INFO',
subject: 'Hello',
body: 'World',
});
assert.equal(result.ok, false);
assert.equal(result.error?.code, 'UNKNOWN_SENDER');
});
});
test('send/inbox/read/ack flows end-to-end', async () => {
await withTempProject(async () => {
await seedAgents();
const sent = await sendAgentMessage(
{
from: 'agent-ui-1',
to: 'agent-graph-1',
bead: 'bb-dcv.6',
category: 'HANDOFF',
subject: 'Edge direction patch ready',
body: 'Please validate graph screenshots.',
},
{
now: () => '2026-02-14T00:01:00.000Z',
idGenerator: () => 'msg_20260214_000100_test',
},
);
assert.equal(sent.ok, true);
assert.equal(sent.data?.requires_ack, true);
assert.equal(sent.data?.state, 'unread');
const inboxUnread = await inboxAgentMessages({ agent: 'agent-graph-1', state: 'unread' });
assert.equal(inboxUnread.ok, true);
assert.equal(inboxUnread.data?.length, 1);
const read = await readAgentMessage(
{ agent: 'agent-graph-1', message: 'msg_20260214_000100_test' },
{ now: () => '2026-02-14T00:02:00.000Z' },
);
assert.equal(read.ok, true);
assert.equal(read.data?.state, 'read');
const ack = await ackAgentMessage(
{ agent: 'agent-graph-1', message: 'msg_20260214_000100_test' },
{ now: () => '2026-02-14T00:03:00.000Z' },
);
assert.equal(ack.ok, true);
assert.equal(ack.data?.state, 'acked');
assert.equal(ack.data?.acked_at, '2026-02-14T00:03:00.000Z');
const inboxAcked = await inboxAgentMessages({ agent: 'agent-graph-1', state: 'acked' });
assert.equal(inboxAcked.ok, true);
assert.equal(inboxAcked.data?.length, 1);
});
});
test('ackAgentMessage forbids non-recipient agent', async () => {
await withTempProject(async () => {
await seedAgents();
await sendAgentMessage(
{
from: 'agent-ui-1',
to: 'agent-graph-1',
bead: 'bb-dcv.6',
category: 'HANDOFF',
subject: 'subject',
body: 'body',
},
{
now: () => '2026-02-14T00:01:00.000Z',
idGenerator: () => 'msg_20260214_000100_forbidden',
},
);
const forbidden = await ackAgentMessage(
{ agent: 'agent-ui-1', message: 'msg_20260214_000100_forbidden' },
{ now: () => '2026-02-14T00:02:00.000Z' },
);
assert.equal(forbidden.ok, false);
assert.equal(forbidden.error?.code, 'ACK_FORBIDDEN');
});
});
test('sendAgentMessage validates category and bead id', async () => {
await withTempProject(async () => {
await seedAgents();
const invalidCategory = await sendAgentMessage({
from: 'agent-ui-1',
to: 'agent-graph-1',
bead: 'bb-dcv.6',
category: 'NOPE' as never,
subject: 'subject',
body: 'body',
});
assert.equal(invalidCategory.ok, false);
assert.equal(invalidCategory.error?.code, 'INVALID_CATEGORY');
const missingBead = await sendAgentMessage({
from: 'agent-ui-1',
to: 'agent-graph-1',
bead: ' ',
category: 'INFO',
subject: 'subject',
body: 'body',
});
assert.equal(missingBead.ok, false);
assert.equal(missingBead.error?.code, 'MISSING_BEAD_ID');
});
});
async function seedRoleAgents(): Promise<void> {
const now = '2026-02-14T00:00:00.000Z';
await registerAgent({ name: 'ui-agent-1', role: 'ui' }, { now: () => now });
await registerAgent({ name: 'ui-agent-2', role: 'ui' }, { now: () => now });
await registerAgent({ name: 'graph-agent-1', role: 'graph' }, { now: () => now });
}
test('sendAgentMessage routes to role:ui with multiple recipients', async () => {
await withTempProject(async () => {
await seedRoleAgents();
const sent = await sendAgentMessage(
{
from: 'graph-agent-1',
to: 'role:ui',
bead: 'bb-test.1',
category: 'INFO',
subject: 'Hello UI agents',
body: 'Please check the dashboard',
},
{
now: () => '2026-02-14T00:01:00.000Z',
idGenerator: () => 'msg_role_test_1',
},
);
assert.equal(sent.ok, true);
const inbox1 = await inboxAgentMessages({ agent: 'ui-agent-1' });
const inbox2 = await inboxAgentMessages({ agent: 'ui-agent-2' });
const inboxGraph = await inboxAgentMessages({ agent: 'graph-agent-1' });
assert.equal(inbox1.data?.length, 1);
assert.equal(inbox2.data?.length, 1);
assert.equal(inboxGraph.data?.length, 0);
});
});
test('sendAgentMessage role fanout excludes sender from recipient list', async () => {
await withTempProject(async () => {
await seedRoleAgents();
const sent = await sendAgentMessage(
{
from: 'ui-agent-1',
to: 'role:ui',
bead: 'bb-test.2',
category: 'INFO',
subject: 'Peer message',
body: 'Hello fellow UI agents',
},
{
now: () => '2026-02-14T00:01:00.000Z',
idGenerator: () => 'msg_role_test_2',
},
);
assert.equal(sent.ok, true);
const inbox1 = await inboxAgentMessages({ agent: 'ui-agent-1' });
const inbox2 = await inboxAgentMessages({ agent: 'ui-agent-2' });
assert.equal(inbox1.data?.length, 0, 'sender should not receive');
assert.equal(inbox2.data?.length, 1, 'other ui agent should receive');
});
});
test('sendAgentMessage direct send includes recipient even if sender matches recipient role', async () => {
await withTempProject(async () => {
await seedRoleAgents();
const sent = await sendAgentMessage(
{
from: 'ui-agent-1',
to: 'ui-agent-2',
bead: 'bb-test.3',
category: 'INFO',
subject: 'Direct message',
body: 'Hello specifically',
},
{
now: () => '2026-02-14T00:01:00.000Z',
idGenerator: () => 'msg_role_test_3',
},
);
assert.equal(sent.ok, true);
const inbox2 = await inboxAgentMessages({ agent: 'ui-agent-2' });
assert.equal(inbox2.data?.length, 1, 'direct recipient should receive');
});
});
test('sendAgentMessage unknown role returns UNKNOWN_RECIPIENT', async () => {
await withTempProject(async () => {
await seedRoleAgents();
const sent = await sendAgentMessage({
from: 'ui-agent-1',
to: 'role:nonexistent',
bead: 'bb-test.4',
category: 'INFO',
subject: 'Hello',
body: 'Anyone there?',
});
assert.equal(sent.ok, false);
assert.equal(sent.error?.code, 'UNKNOWN_RECIPIENT');
assert.ok(sent.error?.message.includes('no agents found with role'));
});
});
test('sendAgentMessage known role but all agents excluded returns UNKNOWN_RECIPIENT', async () => {
await withTempProject(async () => {
const now = '2026-02-14T00:00:00.000Z';
await registerAgent({ name: 'only-ui-agent', role: 'ui' }, { now: () => now });
const sent = await sendAgentMessage({
from: 'only-ui-agent',
to: 'role:ui',
bead: 'bb-test.5',
category: 'INFO',
subject: 'Hello myself',
body: 'No one else to hear',
});
assert.equal(sent.ok, false);
assert.equal(sent.error?.code, 'UNKNOWN_RECIPIENT');
assert.ok(sent.error?.message.includes('all recipients were excluded'));
});
});
test('sendAgentMessage role fanout HANDOFF creates individual messages with per-recipient ack', async () => {
await withTempProject(async () => {
await seedRoleAgents();
const sent = await sendAgentMessage(
{
from: 'graph-agent-1',
to: 'role:ui',
bead: 'bb-test.6',
category: 'HANDOFF',
subject: 'Take over',
body: 'Please handle this',
},
{
now: () => '2026-02-14T00:01:00.000Z',
idGenerator: () => 'msg_handoff_test',
},
);
assert.equal(sent.ok, true);
const inbox1 = await inboxAgentMessages({ agent: 'ui-agent-1' });
const inbox2 = await inboxAgentMessages({ agent: 'ui-agent-2' });
assert.equal(inbox1.data?.length, 1);
assert.equal(inbox2.data?.length, 1);
const msg1 = inbox1.data![0];
const msg2 = inbox2.data![0];
assert.notEqual(msg1.message_id, msg2.message_id, 'each recipient gets unique message ID');
assert.equal(msg1.state, 'unread');
assert.equal(msg2.state, 'unread');
const ack1 = await ackAgentMessage(
{ agent: 'ui-agent-1', message: msg1.message_id },
{ now: () => '2026-02-14T00:02:00.000Z' },
);
assert.equal(ack1.ok, true);
assert.equal(ack1.data?.state, 'acked');
const inbox1AfterAck = await inboxAgentMessages({ agent: 'ui-agent-1', state: 'acked' });
const inbox2AfterAck = await inboxAgentMessages({ agent: 'ui-agent-2', state: 'unread' });
assert.equal(inbox1AfterAck.data?.length, 1, 'ui-agent-1 message is acked');
assert.equal(inbox2AfterAck.data?.length, 1, 'ui-agent-2 message still unread');
});
});

View file

@ -1,107 +1,107 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { execSync } from 'node:child_process';
import {
listAgents,
registerAgent,
} from '../../src/lib/agent-registry';
async function withTempProject(run: (projectRoot: string) => Promise<void>): Promise<void> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-agent-legacy-test-'));
// Initialize bd rig
execSync('bd init --prefix bb --force', { cwd: tempDir, stdio: 'ignore' });
try {
await run(tempDir);
} finally {
// Windows cleanup retry
for (let i = 0; i < 5; i++) {
try {
await fs.rm(tempDir, { recursive: true, force: true });
break;
} catch {
await new Promise(r => setTimeout(r, 500));
}
}
}
}
test('registerAgent creates stable metadata file with idle status', async () => {
await withTempProject(async (projectRoot) => {
const now = '2026-02-13T23:55:00.000Z';
const result = await registerAgent(
{
name: 'agent-ui-1',
display: 'UI Agent 1',
role: 'ui',
},
{ now: () => now, projectRoot }
);
assert.equal(result.ok, true);
assert.equal(result.data?.agent_id, 'agent-ui-1');
assert.equal(result.data?.status, 'idle');
});
});
test('registerAgent rejects duplicate id without --force-update', async () => {
await withTempProject(async (projectRoot) => {
await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { projectRoot });
const duplicate = await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { projectRoot });
assert.equal(duplicate.ok, false);
assert.equal(duplicate.error?.code, 'DUPLICATE_AGENT_ID');
});
});
test('registerAgent force update mutates display/role but keeps created_at', async () => {
await withTempProject(async (projectRoot) => {
const t1 = '2026-02-13T23:55:00.000Z';
const first = await registerAgent(
{ name: 'agent-ui-1', display: 'UI Agent', role: 'ui' },
{ now: () => t1, projectRoot }
);
assert.equal(first.ok, true);
const updated = await registerAgent(
{ name: 'agent-ui-1', display: 'Frontend Agent', role: 'frontend', forceUpdate: true },
{ projectRoot }
);
assert.equal(updated.ok, true);
assert.equal(updated.data?.display_name, 'Frontend Agent');
assert.equal(updated.data?.role, 'frontend');
});
});
test('listAgents sorts and filters by role/status', async () => {
await withTempProject(async (projectRoot) => {
await registerAgent({ name: 'agent-b', role: 'backend' }, { projectRoot });
await registerAgent({ name: 'agent-a', role: 'ui' }, { projectRoot });
const originalCwd = process.cwd();
process.chdir(projectRoot);
try {
const all = await listAgents({});
assert.equal(all.ok, true);
assert.deepEqual(
all.data?.map((agent) => agent.agent_id),
['agent-a', 'agent-b'],
);
const byRole = await listAgents({ role: 'ui' });
assert.deepEqual(
byRole.data?.map((agent) => agent.agent_id),
['agent-a'],
);
} finally {
process.chdir(originalCwd);
}
});
});
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { execSync } from 'node:child_process';
import {
listAgents,
registerAgent,
} from '../../src/lib/agent-registry';
async function withTempProject(run: (projectRoot: string) => Promise<void>): Promise<void> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-agent-legacy-test-'));
// Initialize bd rig
execSync('bd init --prefix bb --force', { cwd: tempDir, stdio: 'ignore' });
try {
await run(tempDir);
} finally {
// Windows cleanup retry
for (let i = 0; i < 5; i++) {
try {
await fs.rm(tempDir, { recursive: true, force: true });
break;
} catch {
await new Promise(r => setTimeout(r, 500));
}
}
}
}
test('registerAgent creates stable metadata file with idle status', async () => {
await withTempProject(async (projectRoot) => {
const now = '2026-02-13T23:55:00.000Z';
const result = await registerAgent(
{
name: 'agent-ui-1',
display: 'UI Agent 1',
role: 'ui',
},
{ now: () => now, projectRoot }
);
assert.equal(result.ok, true);
assert.equal(result.data?.agent_id, 'agent-ui-1');
assert.equal(result.data?.status, 'idle');
});
});
test('registerAgent rejects duplicate id without --force-update', async () => {
await withTempProject(async (projectRoot) => {
await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { projectRoot });
const duplicate = await registerAgent({ name: 'agent-ui-1', role: 'ui' }, { projectRoot });
assert.equal(duplicate.ok, false);
assert.equal(duplicate.error?.code, 'DUPLICATE_AGENT_ID');
});
});
test('registerAgent force update mutates display/role but keeps created_at', async () => {
await withTempProject(async (projectRoot) => {
const t1 = '2026-02-13T23:55:00.000Z';
const first = await registerAgent(
{ name: 'agent-ui-1', display: 'UI Agent', role: 'ui' },
{ now: () => t1, projectRoot }
);
assert.equal(first.ok, true);
const updated = await registerAgent(
{ name: 'agent-ui-1', display: 'Frontend Agent', role: 'frontend', forceUpdate: true },
{ projectRoot }
);
assert.equal(updated.ok, true);
assert.equal(updated.data?.display_name, 'Frontend Agent');
assert.equal(updated.data?.role, 'frontend');
});
});
test('listAgents sorts and filters by role/status', async () => {
await withTempProject(async (projectRoot) => {
await registerAgent({ name: 'agent-b', role: 'backend' }, { projectRoot });
await registerAgent({ name: 'agent-a', role: 'ui' }, { projectRoot });
const originalCwd = process.cwd();
process.chdir(projectRoot);
try {
const all = await listAgents({});
assert.equal(all.ok, true);
assert.deepEqual(
all.data?.map((agent) => agent.agent_id),
['agent-a', 'agent-b'],
);
const byRole = await listAgents({ role: 'ui' });
assert.deepEqual(
byRole.data?.map((agent) => agent.agent_id),
['agent-a'],
);
} finally {
process.chdir(originalCwd);
}
});
});

View file

@ -175,4 +175,4 @@ test('stale reservation conflict and takeover behavior', async () => {
assert.equal(wrongRelease.ok, false);
assert.equal(wrongRelease.error?.code, 'RELEASE_FORBIDDEN');
});
});
});

View file

@ -1,64 +1,64 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { BeadIssue } from '../../src/lib/types';
/**
* Tests for bb-buff.3.2: Critical Visual Signals
*
* These tests verify that stuck/dead ZFC states are properly
* derived into session states for visual rendering.
*/
// Import the deriveState function (will be exported from agent-sessions)
import { deriveSessionState } from '../../src/lib/agent-sessions';
function makeTask(overrides: Partial<BeadIssue> = {}): BeadIssue {
return {
id: 'bb-1',
title: 'Test Task',
status: 'in_progress',
updated_at: new Date().toISOString(),
dependencies: [],
labels: [],
...overrides
} as BeadIssue;
}
test('deriveSessionState returns stuck when ZFC state is stuck', () => {
const task = makeTask();
const result = deriveSessionState(task, null, false, 'active', 'stuck');
assert.equal(result, 'stuck');
});
test('deriveSessionState returns dead when ZFC state is dead', () => {
const task = makeTask();
const result = deriveSessionState(task, null, false, 'active', 'dead');
assert.equal(result, 'dead');
});
test('deriveSessionState prioritizes stuck over evicted', () => {
const task = makeTask();
// Even if liveness is evicted, stuck should win
const result = deriveSessionState(task, null, false, 'evicted', 'stuck');
assert.equal(result, 'stuck');
});
test('deriveSessionState prioritizes dead over stale', () => {
const task = makeTask();
const result = deriveSessionState(task, null, false, 'stale', 'dead');
assert.equal(result, 'dead');
});
test('deriveSessionState returns evicted when liveness is evicted and no ZFC state', () => {
const task = makeTask();
const result = deriveSessionState(task, null, false, 'evicted', undefined);
assert.equal(result, 'evicted');
});
test('deriveSessionState returns completed when task is closed', () => {
const task = makeTask({ status: 'closed' });
// Even with stuck ZFC state, closed task is completed
const result = deriveSessionState(task, null, false, 'active', 'stuck');
assert.equal(result, 'completed');
});
import test from 'node:test';
import assert from 'node:assert/strict';
import type { BeadIssue } from '../../src/lib/types';
/**
* Tests for bb-buff.3.2: Critical Visual Signals
*
* These tests verify that stuck/dead ZFC states are properly
* derived into session states for visual rendering.
*/
// Import the deriveState function (will be exported from agent-sessions)
import { deriveSessionState } from '../../src/lib/agent-sessions';
function makeTask(overrides: Partial<BeadIssue> = {}): BeadIssue {
return {
id: 'bb-1',
title: 'Test Task',
status: 'in_progress',
updated_at: new Date().toISOString(),
dependencies: [],
labels: [],
...overrides
} as BeadIssue;
}
test('deriveSessionState returns stuck when ZFC state is stuck', () => {
const task = makeTask();
const result = deriveSessionState(task, null, false, 'active', 'stuck');
assert.equal(result, 'stuck');
});
test('deriveSessionState returns dead when ZFC state is dead', () => {
const task = makeTask();
const result = deriveSessionState(task, null, false, 'active', 'dead');
assert.equal(result, 'dead');
});
test('deriveSessionState prioritizes stuck over evicted', () => {
const task = makeTask();
// Even if liveness is evicted, stuck should win
const result = deriveSessionState(task, null, false, 'evicted', 'stuck');
assert.equal(result, 'stuck');
});
test('deriveSessionState prioritizes dead over stale', () => {
const task = makeTask();
const result = deriveSessionState(task, null, false, 'stale', 'dead');
assert.equal(result, 'dead');
});
test('deriveSessionState returns evicted when liveness is evicted and no ZFC state', () => {
const task = makeTask();
const result = deriveSessionState(task, null, false, 'evicted', undefined);
assert.equal(result, 'evicted');
});
test('deriveSessionState returns completed when task is closed', () => {
const task = makeTask({ status: 'closed' });
// Even with stuck ZFC state, closed task is completed
const result = deriveSessionState(task, null, false, 'active', 'stuck');
assert.equal(result, 'completed');
});

View file

@ -1,87 +1,87 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { readIssuesForScope } from '../../src/lib/aggregate-read';
import type { ProjectScopeOption } from '../../src/lib/project-scope';
async function writeIssues(root: string, lines: unknown[]): Promise<void> {
const beadsDir = path.join(root, '.beads');
await fs.mkdir(beadsDir, { recursive: true });
await fs.writeFile(
path.join(beadsDir, 'issues.jsonl'),
lines.map((line) => JSON.stringify(line)).join('\n'),
'utf8',
);
}
test('readIssuesForScope reads selected project in single mode', async () => {
const localRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-scope-single-'));
await writeIssues(localRoot, [{ id: 'bb-1', title: 'Local issue', issue_type: 'task', status: 'open', priority: 1 }]);
const local: ProjectScopeOption = {
key: 'local',
root: localRoot,
displayPath: localRoot.replaceAll('\\', '/'),
source: 'local',
};
const issues = await readIssuesForScope({
mode: 'single',
selected: local,
scopeOptions: [local],
});
assert.equal(issues.length, 1);
assert.equal(issues[0].id, 'bb-1');
});
test('readIssuesForScope scopes ids and dependencies in aggregate mode', async () => {
const localRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-scope-agg-local-'));
const registryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-scope-agg-reg-'));
await writeIssues(localRoot, [
{ id: 'bb-epic', title: 'Local epic', issue_type: 'epic', status: 'open', priority: 1 },
{
id: 'bb-epic.1',
title: 'Local task',
issue_type: 'task',
status: 'open',
priority: 1,
dependencies: [{ type: 'parent-child', depends_on_id: 'bb-epic' }],
},
]);
await writeIssues(registryRoot, [
{ id: 'bb-epic', title: 'Registry epic', issue_type: 'epic', status: 'open', priority: 1 },
]);
const local: ProjectScopeOption = {
key: 'local',
root: localRoot,
displayPath: localRoot.replaceAll('\\', '/'),
source: 'local',
};
const registry: ProjectScopeOption = {
key: 'd:\\repos\\alpha',
root: registryRoot,
displayPath: registryRoot.replaceAll('\\', '/'),
source: 'registry',
};
const issues = await readIssuesForScope({
mode: 'aggregate',
selected: local,
scopeOptions: [local, registry],
});
assert.equal(issues.length, 3);
assert.equal(issues.some((issue) => issue.id === 'local::bb-epic'), true);
assert.equal(issues.some((issue) => issue.id === 'd:\\repos\\alpha::bb-epic'), true);
const localTask = issues.find((issue) => issue.id === 'local::bb-epic.1');
assert.ok(localTask);
assert.equal(localTask.dependencies.some((dependency) => dependency.target === 'local::bb-epic'), true);
assert.equal(localTask.metadata.original_id, 'bb-epic.1');
});
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { readIssuesForScope } from '../../src/lib/aggregate-read';
import type { ProjectScopeOption } from '../../src/lib/project-scope';
async function writeIssues(root: string, lines: unknown[]): Promise<void> {
const beadsDir = path.join(root, '.beads');
await fs.mkdir(beadsDir, { recursive: true });
await fs.writeFile(
path.join(beadsDir, 'issues.jsonl'),
lines.map((line) => JSON.stringify(line)).join('\n'),
'utf8',
);
}
test('readIssuesForScope reads selected project in single mode', async () => {
const localRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-scope-single-'));
await writeIssues(localRoot, [{ id: 'bb-1', title: 'Local issue', issue_type: 'task', status: 'open', priority: 1 }]);
const local: ProjectScopeOption = {
key: 'local',
root: localRoot,
displayPath: localRoot.replaceAll('\\', '/'),
source: 'local',
};
const issues = await readIssuesForScope({
mode: 'single',
selected: local,
scopeOptions: [local],
});
assert.equal(issues.length, 1);
assert.equal(issues[0].id, 'bb-1');
});
test('readIssuesForScope scopes ids and dependencies in aggregate mode', async () => {
const localRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-scope-agg-local-'));
const registryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-scope-agg-reg-'));
await writeIssues(localRoot, [
{ id: 'bb-epic', title: 'Local epic', issue_type: 'epic', status: 'open', priority: 1 },
{
id: 'bb-epic.1',
title: 'Local task',
issue_type: 'task',
status: 'open',
priority: 1,
dependencies: [{ type: 'parent-child', depends_on_id: 'bb-epic' }],
},
]);
await writeIssues(registryRoot, [
{ id: 'bb-epic', title: 'Registry epic', issue_type: 'epic', status: 'open', priority: 1 },
]);
const local: ProjectScopeOption = {
key: 'local',
root: localRoot,
displayPath: localRoot.replaceAll('\\', '/'),
source: 'local',
};
const registry: ProjectScopeOption = {
key: 'd:\\repos\\alpha',
root: registryRoot,
displayPath: registryRoot.replaceAll('\\', '/'),
source: 'registry',
};
const issues = await readIssuesForScope({
mode: 'aggregate',
selected: local,
scopeOptions: [local, registry],
});
assert.equal(issues.length, 3);
assert.equal(issues.some((issue) => issue.id === 'local::bb-epic'), true);
assert.equal(issues.some((issue) => issue.id === 'd:\\repos\\alpha::bb-epic'), true);
const localTask = issues.find((issue) => issue.id === 'local::bb-epic.1');
assert.ok(localTask);
assert.equal(localTask.dependencies.some((dependency) => dependency.target === 'local::bb-epic'), true);
assert.equal(localTask.metadata.original_id, 'bb-epic.1');
});

View file

@ -1,107 +1,107 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { runBdCommand } from '../../src/lib/bridge';
import { normalizeProjectRootForRuntime } from '../../src/lib/project-root';
test('runBdCommand returns structured success payload from exec output', async () => {
const result = await runBdCommand(
{
projectRoot: 'C:/repo/project',
args: ['list', '--json'],
timeoutMs: 2000,
explicitBdPath: 'C:/tools/bd.exe',
},
{
exec: async (command: string, options: any) => {
assert.ok(command.startsWith('bd '));
assert.ok(command.includes('list'));
assert.ok(command.includes('--json'));
assert.equal(options.cwd, normalizeProjectRootForRuntime('C:/repo/project'));
return { stdout: '[{"id":"bb-1"}]\r\n', stderr: '' };
},
},
);
assert.equal(result.success, true);
assert.equal(result.classification, null);
assert.equal(result.stdout, '[{"id":"bb-1"}]');
});
test('runBdCommand classifies missing executable as not_found', async () => {
const result = await runBdCommand(
{ projectRoot: 'C:/repo/project', args: ['list'] },
{
exec: async () => {
const error = new Error('spawn ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
},
},
);
assert.equal(result.success, false);
assert.equal(result.classification, 'not_found');
});
test('runBdCommand classifies timeout failures', async () => {
const result = await runBdCommand(
{ projectRoot: 'C:/repo/project', args: ['list'], timeoutMs: 5 },
{
exec: async () => {
const error = new Error('timed out') as NodeJS.ErrnoException & { killed?: boolean; signal?: string };
error.code = 'ETIMEDOUT';
error.killed = true;
error.signal = 'SIGTERM';
throw error;
},
},
);
assert.equal(result.success, false);
assert.equal(result.classification, 'timeout');
});
test('runBdCommand classifies non-zero bad-argument exits', async () => {
const result = await runBdCommand(
{ projectRoot: 'C:/repo/project', args: ['update', '--bad-flag'] },
{
exec: async () => {
const error = new Error('exit code 1') as NodeJS.ErrnoException & {
stdout?: string;
stderr?: string;
};
(error as any).code = 1;
error.stderr = 'unknown flag: --bad-flag';
error.stdout = '';
throw error;
},
},
);
assert.equal(result.success, false);
assert.equal(result.classification, 'bad_args');
});
test('runBdCommand treats shell "not recognized" stderr as not_found', async () => {
const result = await runBdCommand(
{ projectRoot: 'C:/repo/project', args: ['list'] },
{
exec: async () => {
const error = new Error('exit code 1') as NodeJS.ErrnoException & {
stdout?: string;
stderr?: string;
exitCode?: number;
};
error.code = 'BD_EXIT';
error.stderr = `'bd' is not recognized as an internal or external command`;
error.exitCode = 1;
throw error;
},
},
);
assert.equal(result.success, false);
assert.equal(result.classification, 'not_found');
assert.equal(result.error?.includes('bd command not found in PATH'), true);
});
import test from 'node:test';
import assert from 'node:assert/strict';
import { runBdCommand } from '../../src/lib/bridge';
import { normalizeProjectRootForRuntime } from '../../src/lib/project-root';
test('runBdCommand returns structured success payload from exec output', async () => {
const result = await runBdCommand(
{
projectRoot: 'C:/repo/project',
args: ['list', '--json'],
timeoutMs: 2000,
explicitBdPath: 'C:/tools/bd.exe',
},
{
exec: async (command: string, options: any) => {
assert.ok(command.startsWith('bd '));
assert.ok(command.includes('list'));
assert.ok(command.includes('--json'));
assert.equal(options.cwd, normalizeProjectRootForRuntime('C:/repo/project'));
return { stdout: '[{"id":"bb-1"}]\r\n', stderr: '' };
},
},
);
assert.equal(result.success, true);
assert.equal(result.classification, null);
assert.equal(result.stdout, '[{"id":"bb-1"}]');
});
test('runBdCommand classifies missing executable as not_found', async () => {
const result = await runBdCommand(
{ projectRoot: 'C:/repo/project', args: ['list'] },
{
exec: async () => {
const error = new Error('spawn ENOENT') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
},
},
);
assert.equal(result.success, false);
assert.equal(result.classification, 'not_found');
});
test('runBdCommand classifies timeout failures', async () => {
const result = await runBdCommand(
{ projectRoot: 'C:/repo/project', args: ['list'], timeoutMs: 5 },
{
exec: async () => {
const error = new Error('timed out') as NodeJS.ErrnoException & { killed?: boolean; signal?: string };
error.code = 'ETIMEDOUT';
error.killed = true;
error.signal = 'SIGTERM';
throw error;
},
},
);
assert.equal(result.success, false);
assert.equal(result.classification, 'timeout');
});
test('runBdCommand classifies non-zero bad-argument exits', async () => {
const result = await runBdCommand(
{ projectRoot: 'C:/repo/project', args: ['update', '--bad-flag'] },
{
exec: async () => {
const error = new Error('exit code 1') as NodeJS.ErrnoException & {
stdout?: string;
stderr?: string;
};
(error as any).code = 1;
error.stderr = 'unknown flag: --bad-flag';
error.stdout = '';
throw error;
},
},
);
assert.equal(result.success, false);
assert.equal(result.classification, 'bad_args');
});
test('runBdCommand treats shell "not recognized" stderr as not_found', async () => {
const result = await runBdCommand(
{ projectRoot: 'C:/repo/project', args: ['list'] },
{
exec: async () => {
const error = new Error('exit code 1') as NodeJS.ErrnoException & {
stdout?: string;
stderr?: string;
exitCode?: number;
};
error.code = 'BD_EXIT';
error.stderr = `'bd' is not recognized as an internal or external command`;
error.exitCode = 1;
throw error;
},
},
);
assert.equal(result.success, false);
assert.equal(result.classification, 'not_found');
assert.equal(result.error?.includes('bd command not found in PATH'), true);
});

View file

@ -1,33 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { ProjectEventCoalescer } from '../../src/lib/coalescer';
test('coalescer emits latest payload once per project within debounce window', async () => {
const flushed: Array<{ projectRoot: string; payload: { value: string } }> = [];
const coalescer = new ProjectEventCoalescer<{ value: string }>(20, (event) => {
flushed.push(event);
});
coalescer.queue('C:/Repo/One', { value: 'first' });
coalescer.queue('c:\\repo\\one', { value: 'second' });
await new Promise((resolve) => setTimeout(resolve, 45));
assert.equal(flushed.length, 1);
assert.equal(flushed[0].payload.value, 'second');
});
test('coalescer keeps distinct projects separated', async () => {
const flushed: Array<{ projectRoot: string; payload: { value: string } }> = [];
const coalescer = new ProjectEventCoalescer<{ value: string }>(20, (event) => {
flushed.push(event);
});
coalescer.queue('C:/Repo/One', { value: 'one' });
coalescer.queue('D:/Repo/Two', { value: 'two' });
await new Promise((resolve) => setTimeout(resolve, 45));
assert.equal(flushed.length, 2);
});
import test from 'node:test';
import assert from 'node:assert/strict';
import { ProjectEventCoalescer } from '../../src/lib/coalescer';
test('coalescer emits latest payload once per project within debounce window', async () => {
const flushed: Array<{ projectRoot: string; payload: { value: string } }> = [];
const coalescer = new ProjectEventCoalescer<{ value: string }>(20, (event) => {
flushed.push(event);
});
coalescer.queue('C:/Repo/One', { value: 'first' });
coalescer.queue('c:\\repo\\one', { value: 'second' });
await new Promise((resolve) => setTimeout(resolve, 45));
assert.equal(flushed.length, 1);
assert.equal(flushed[0].payload.value, 'second');
});
test('coalescer keeps distinct projects separated', async () => {
const flushed: Array<{ projectRoot: string; payload: { value: string } }> = [];
const coalescer = new ProjectEventCoalescer<{ value: string }>(20, (event) => {
flushed.push(event);
});
coalescer.queue('C:/Repo/One', { value: 'one' });
coalescer.queue('D:/Repo/Two', { value: 'two' });
await new Promise((resolve) => setTimeout(resolve, 45));
assert.equal(flushed.length, 2);
});

View file

@ -0,0 +1,64 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { BeadIssue } from '../../src/lib/types';
import { collectEpicDescendantIds, buildWorkflowEdges } from '../../src/lib/epic-graph';
function issue(overrides: Partial<BeadIssue>): BeadIssue {
return {
id: overrides.id ?? 'bb-x',
title: overrides.title ?? 'Issue',
description: overrides.description ?? null,
status: overrides.status ?? 'open',
priority: overrides.priority ?? 2,
issue_type: overrides.issue_type ?? 'task',
assignee: overrides.assignee ?? null,
templateId: null,
owner: overrides.owner ?? null,
labels: overrides.labels ?? [],
dependencies: overrides.dependencies ?? [],
created_at: overrides.created_at ?? '2026-03-02T00:00:00Z',
updated_at: overrides.updated_at ?? '2026-03-02T00:00:00Z',
closed_at: overrides.closed_at ?? null,
close_reason: overrides.close_reason ?? null,
closed_by_session: overrides.closed_by_session ?? null,
created_by: overrides.created_by ?? null,
due_at: overrides.due_at ?? null,
estimated_minutes: overrides.estimated_minutes ?? null,
external_ref: overrides.external_ref ?? null,
metadata: overrides.metadata ?? {},
};
}
test('collectEpicDescendantIds includes nested subtasks under selected epic', () => {
const issues: BeadIssue[] = [
issue({ id: 'epic-1', issue_type: 'epic' }),
issue({ id: 'a', dependencies: [{ type: 'parent', target: 'epic-1' }] }),
issue({ id: 'a-1', dependencies: [{ type: 'parent', target: 'a' }] }),
issue({ id: 'a-2', dependencies: [{ type: 'parent', target: 'a' }] }),
issue({ id: 'b', dependencies: [{ type: 'parent', target: 'epic-1' }] }),
issue({ id: 'orphan', dependencies: [{ type: 'parent', target: 'other-epic' }] }),
];
const ids = collectEpicDescendantIds(issues, 'epic-1');
assert.deepEqual([...ids].sort(), ['a', 'a-1', 'a-2', 'b']);
});
test('buildWorkflowEdges includes blocks edges and optional parent edges', () => {
const issues: BeadIssue[] = [
issue({ id: 'epic-1', issue_type: 'epic' }),
issue({ id: 'a', dependencies: [{ type: 'parent', target: 'epic-1' }] }),
issue({ id: 'a-1', dependencies: [{ type: 'parent', target: 'a' }, { type: 'blocks', target: 'b' }] }),
issue({ id: 'b', dependencies: [{ type: 'parent', target: 'epic-1' }] }),
];
const visibleIds = new Set(['a', 'a-1', 'b']);
const withoutHierarchy = buildWorkflowEdges({ issues, visibleIds, includeHierarchy: false, selectedId: null });
const withHierarchy = buildWorkflowEdges({ issues, visibleIds, includeHierarchy: true, selectedId: null });
assert.equal(withoutHierarchy.some((edge) => edge.kind === 'subtask'), false);
assert.equal(withHierarchy.some((edge) => edge.kind === 'subtask'), true);
assert.equal(withoutHierarchy.some((edge) => edge.kind === 'blocks'), true);
});

View file

@ -1,175 +1,188 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildGraphModel } from '../../src/lib/graph';
import { analyzeBlockedChain, buildGraphViewModel, buildPathWorkspace, detectDependencyCycles } from '../../src/lib/graph-view';
import type { BeadIssue } from '../../src/lib/types';
function issue(overrides: Partial<BeadIssue>): BeadIssue {
return {
id: overrides.id ?? 'bb-x',
title: overrides.title ?? 'Issue',
description: overrides.description ?? null,
status: overrides.status ?? 'open',
priority: overrides.priority ?? 2,
issue_type: overrides.issue_type ?? 'task',
assignee: overrides.assignee ?? null,
templateId: null,
owner: overrides.owner ?? null,
labels: overrides.labels ?? [],
dependencies: overrides.dependencies ?? [],
created_at: overrides.created_at ?? '2026-02-12T00:00:00Z',
updated_at: overrides.updated_at ?? '2026-02-12T00:00:00Z',
closed_at: overrides.closed_at ?? null,
close_reason: overrides.close_reason ?? null,
closed_by_session: overrides.closed_by_session ?? null,
created_by: overrides.created_by ?? null,
due_at: overrides.due_at ?? null,
estimated_minutes: overrides.estimated_minutes ?? null,
external_ref: overrides.external_ref ?? null,
metadata: overrides.metadata ?? {},
};
}
test('buildGraphViewModel limits visible nodes by hop depth around focus', () => {
const model = buildGraphModel([
issue({ id: 'bb-1', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
issue({ id: 'bb-2', dependencies: [{ type: 'blocks', target: 'bb-3' }] }),
issue({ id: 'bb-3', dependencies: [{ type: 'blocks', target: 'bb-4' }] }),
issue({ id: 'bb-4' }),
]);
const depth1 = buildGraphViewModel(model, { focusId: 'bb-2', depth: 1, hideClosed: false });
const depth2 = buildGraphViewModel(model, { focusId: 'bb-2', depth: 2, hideClosed: false });
assert.deepEqual(
depth1.nodes.map((x) => x.id),
['bb-2', 'bb-3', 'bb-1'],
);
assert.deepEqual(
depth2.nodes.map((x) => x.id),
['bb-2', 'bb-4', 'bb-3', 'bb-1'],
);
});
test('buildGraphViewModel can hide closed nodes while preserving focused node', () => {
const model = buildGraphModel([
issue({ id: 'bb-1', status: 'closed', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
issue({ id: 'bb-2', status: 'open' }),
]);
const hidden = buildGraphViewModel(model, { focusId: null, depth: 'full', hideClosed: true });
const focused = buildGraphViewModel(model, { focusId: 'bb-1', depth: 'full', hideClosed: true });
assert.deepEqual(hidden.nodes.map((x) => x.id), ['bb-2']);
assert.deepEqual(focused.nodes.map((x) => x.id), ['bb-1', 'bb-2']);
});
test('buildGraphViewModel keeps deterministic edge ordering', () => {
const model = buildGraphModel([
issue({
id: 'bb-2',
dependencies: [
{ type: 'parent', target: 'bb-1' },
{ type: 'blocks', target: 'bb-3' },
],
}),
issue({ id: 'bb-1' }),
issue({ id: 'bb-3' }),
]);
const view = buildGraphViewModel(model, { focusId: null, depth: 'full', hideClosed: false });
assert.deepEqual(
view.edges.map((x) => `${x.source}|${x.type}|${x.target}`),
['bb-2|parent|bb-1', 'bb-3|blocks|bb-2'],
);
assert.equal(view.nodes.every((x) => Number.isFinite(x.position.x) && Number.isFinite(x.position.y)), true);
});
test('buildPathWorkspace returns upstream/downstream levels around focus', () => {
const model = buildGraphModel([
issue({ id: 'bb-1', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
issue({ id: 'bb-2', dependencies: [{ type: 'blocks', target: 'bb-3' }] }),
issue({ id: 'bb-3' }),
]);
const workspace = buildPathWorkspace(model, { focusId: 'bb-2', depth: 2, hideClosed: false });
assert.equal(workspace.focus?.id, 'bb-2');
assert.deepEqual(workspace.blockers.map((level) => level.map((node) => node.id)), [['bb-3']]);
assert.deepEqual(workspace.dependents.map((level) => level.map((node) => node.id)), [['bb-1']]);
});
test('buildPathWorkspace hides closed nodes when requested', () => {
const model = buildGraphModel([
issue({ id: 'bb-1', status: 'closed', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
issue({ id: 'bb-2' }),
]);
const workspace = buildPathWorkspace(model, { focusId: 'bb-2', depth: 2, hideClosed: true });
assert.equal(workspace.blockers.length, 0);
assert.equal(workspace.focus?.id, 'bb-2');
});
test('buildPathWorkspace full depth keeps deterministic blocker and dependent levels', () => {
const model = buildGraphModel([
issue({ id: 'bb-1', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
issue({ id: 'bb-2', dependencies: [{ type: 'blocks', target: 'bb-3' }] }),
issue({ id: 'bb-3', dependencies: [{ type: 'blocks', target: 'bb-4' }] }),
issue({ id: 'bb-4', dependencies: [{ type: 'blocks', target: 'bb-5' }] }),
issue({ id: 'bb-5' }),
]);
const workspace = buildPathWorkspace(model, { focusId: 'bb-3', depth: 'full', hideClosed: false });
assert.deepEqual(workspace.blockers.map((level) => level.map((node) => node.id)), [['bb-4'], ['bb-5']]);
assert.deepEqual(workspace.dependents.map((level) => level.map((node) => node.id)), [['bb-2'], ['bb-1']]);
});
test('analyzeBlockedChain returns blocker counts, first actionable blocker, and chain edges', () => {
const model = buildGraphModel([
issue({ id: 'bb-1', status: 'open' }),
issue({ id: 'bb-2', status: 'in_progress', dependencies: [{ type: 'blocks', target: 'bb-1' }] }),
issue({ id: 'bb-3', status: 'blocked', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
]);
const summary = analyzeBlockedChain(model, { focusId: 'bb-3' });
assert.equal(summary.blockerNodeIds.length, 2);
assert.equal(summary.openBlockerCount, 1);
assert.equal(summary.inProgressBlockerCount, 1);
assert.equal(summary.firstActionableBlockerId, 'bb-1');
assert.deepEqual(summary.chainEdgeIds, ['bb-1:blocks:bb-2', 'bb-2:blocks:bb-3']);
});
test('detectDependencyCycles reports cycle nodes and edges for blocks relations', () => {
const model = buildGraphModel([
issue({ id: 'bb-1', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
issue({ id: 'bb-2', dependencies: [{ type: 'blocks', target: 'bb-3' }] }),
issue({ id: 'bb-3', dependencies: [{ type: 'blocks', target: 'bb-1' }] }),
issue({ id: 'bb-4', dependencies: [{ type: 'blocks', target: 'bb-5' }] }),
issue({ id: 'bb-5' }),
]);
const anomaly = detectDependencyCycles(model);
assert.equal(anomaly.cycles.length, 1);
assert.deepEqual(anomaly.cycleNodeIds, ['bb-1', 'bb-2', 'bb-3']);
assert.deepEqual(anomaly.cycleEdgeIds, ['bb-1:blocks:bb-3', 'bb-2:blocks:bb-1', 'bb-3:blocks:bb-2']);
});
test('detectDependencyCycles does not mark non-cycle predecessor as cyclic', () => {
const model = buildGraphModel([
issue({ id: 'bb-a', dependencies: [{ type: 'blocks', target: 'bb-b' }] }),
issue({ id: 'bb-b', dependencies: [{ type: 'blocks', target: 'bb-c' }] }),
issue({ id: 'bb-c', dependencies: [{ type: 'blocks', target: 'bb-a' }] }),
issue({ id: 'bb-x', dependencies: [{ type: 'blocks', target: 'bb-a' }] }),
]);
const anomaly = detectDependencyCycles(model);
assert.deepEqual(anomaly.cycleNodeIds, ['bb-a', 'bb-b', 'bb-c']);
assert.equal(anomaly.cycleNodeIds.includes('bb-x'), false);
assert.equal(anomaly.cycleEdgeIds.includes('bb-a:blocks:bb-x'), false);
});
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildGraphModel } from '../../src/lib/graph';
import { analyzeBlockedChain, buildGraphViewModel, buildPathWorkspace, detectDependencyCycles, identifyTransitiveEdges } from '../../src/lib/graph-view';
import type { BeadIssue } from '../../src/lib/types';
function issue(overrides: Partial<BeadIssue>): BeadIssue {
return {
id: overrides.id ?? 'bb-x',
title: overrides.title ?? 'Issue',
description: overrides.description ?? null,
status: overrides.status ?? 'open',
priority: overrides.priority ?? 2,
issue_type: overrides.issue_type ?? 'task',
assignee: overrides.assignee ?? null,
templateId: null,
owner: overrides.owner ?? null,
labels: overrides.labels ?? [],
dependencies: overrides.dependencies ?? [],
created_at: overrides.created_at ?? '2026-02-12T00:00:00Z',
updated_at: overrides.updated_at ?? '2026-02-12T00:00:00Z',
closed_at: overrides.closed_at ?? null,
close_reason: overrides.close_reason ?? null,
closed_by_session: overrides.closed_by_session ?? null,
created_by: overrides.created_by ?? null,
due_at: overrides.due_at ?? null,
estimated_minutes: overrides.estimated_minutes ?? null,
external_ref: overrides.external_ref ?? null,
metadata: overrides.metadata ?? {},
};
}
test('buildGraphViewModel limits visible nodes by hop depth around focus', () => {
const model = buildGraphModel([
issue({ id: 'bb-1', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
issue({ id: 'bb-2', dependencies: [{ type: 'blocks', target: 'bb-3' }] }),
issue({ id: 'bb-3', dependencies: [{ type: 'blocks', target: 'bb-4' }] }),
issue({ id: 'bb-4' }),
]);
const depth1 = buildGraphViewModel(model, { focusId: 'bb-2', depth: 1, hideClosed: false });
const depth2 = buildGraphViewModel(model, { focusId: 'bb-2', depth: 2, hideClosed: false });
assert.deepEqual(
depth1.nodes.map((x) => x.id),
['bb-2', 'bb-3', 'bb-1'],
);
assert.deepEqual(
depth2.nodes.map((x) => x.id),
['bb-2', 'bb-4', 'bb-3', 'bb-1'],
);
});
test('buildGraphViewModel can hide closed nodes while preserving focused node', () => {
const model = buildGraphModel([
issue({ id: 'bb-1', status: 'closed', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
issue({ id: 'bb-2', status: 'open' }),
]);
const hidden = buildGraphViewModel(model, { focusId: null, depth: 'full', hideClosed: true });
const focused = buildGraphViewModel(model, { focusId: 'bb-1', depth: 'full', hideClosed: true });
assert.deepEqual(hidden.nodes.map((x) => x.id), ['bb-2']);
assert.deepEqual(focused.nodes.map((x) => x.id), ['bb-1', 'bb-2']);
});
test('buildGraphViewModel keeps deterministic edge ordering', () => {
const model = buildGraphModel([
issue({
id: 'bb-2',
dependencies: [
{ type: 'parent', target: 'bb-1' },
{ type: 'blocks', target: 'bb-3' },
],
}),
issue({ id: 'bb-1' }),
issue({ id: 'bb-3' }),
]);
const view = buildGraphViewModel(model, { focusId: null, depth: 'full', hideClosed: false });
assert.deepEqual(
view.edges.map((x) => `${x.source}|${x.type}|${x.target}`),
['bb-2|parent|bb-1', 'bb-3|blocks|bb-2'],
);
assert.equal(view.nodes.every((x) => Number.isFinite(x.position.x) && Number.isFinite(x.position.y)), true);
});
test('buildPathWorkspace returns upstream/downstream levels around focus', () => {
const model = buildGraphModel([
issue({ id: 'bb-1', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
issue({ id: 'bb-2', dependencies: [{ type: 'blocks', target: 'bb-3' }] }),
issue({ id: 'bb-3' }),
]);
const workspace = buildPathWorkspace(model, { focusId: 'bb-2', depth: 2, hideClosed: false });
assert.equal(workspace.focus?.id, 'bb-2');
assert.deepEqual(workspace.blockers.map((level) => level.map((node) => node.id)), [['bb-3']]);
assert.deepEqual(workspace.dependents.map((level) => level.map((node) => node.id)), [['bb-1']]);
});
test('buildPathWorkspace hides closed nodes when requested', () => {
const model = buildGraphModel([
issue({ id: 'bb-1', status: 'closed', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
issue({ id: 'bb-2' }),
]);
const workspace = buildPathWorkspace(model, { focusId: 'bb-2', depth: 2, hideClosed: true });
assert.equal(workspace.blockers.length, 0);
assert.equal(workspace.focus?.id, 'bb-2');
});
test('buildPathWorkspace full depth keeps deterministic blocker and dependent levels', () => {
const model = buildGraphModel([
issue({ id: 'bb-1', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
issue({ id: 'bb-2', dependencies: [{ type: 'blocks', target: 'bb-3' }] }),
issue({ id: 'bb-3', dependencies: [{ type: 'blocks', target: 'bb-4' }] }),
issue({ id: 'bb-4', dependencies: [{ type: 'blocks', target: 'bb-5' }] }),
issue({ id: 'bb-5' }),
]);
const workspace = buildPathWorkspace(model, { focusId: 'bb-3', depth: 'full', hideClosed: false });
assert.deepEqual(workspace.blockers.map((level) => level.map((node) => node.id)), [['bb-4'], ['bb-5']]);
assert.deepEqual(workspace.dependents.map((level) => level.map((node) => node.id)), [['bb-2'], ['bb-1']]);
});
test('analyzeBlockedChain returns blocker counts, first actionable blocker, and chain edges', () => {
const model = buildGraphModel([
issue({ id: 'bb-1', status: 'open' }),
issue({ id: 'bb-2', status: 'in_progress', dependencies: [{ type: 'blocks', target: 'bb-1' }] }),
issue({ id: 'bb-3', status: 'blocked', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
]);
const summary = analyzeBlockedChain(model, { focusId: 'bb-3' });
assert.equal(summary.blockerNodeIds.length, 2);
assert.equal(summary.openBlockerCount, 1);
assert.equal(summary.inProgressBlockerCount, 1);
assert.equal(summary.firstActionableBlockerId, 'bb-1');
assert.deepEqual(summary.chainEdgeIds, ['bb-1:blocks:bb-2', 'bb-2:blocks:bb-3']);
});
test('detectDependencyCycles reports cycle nodes and edges for blocks relations', () => {
const model = buildGraphModel([
issue({ id: 'bb-1', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
issue({ id: 'bb-2', dependencies: [{ type: 'blocks', target: 'bb-3' }] }),
issue({ id: 'bb-3', dependencies: [{ type: 'blocks', target: 'bb-1' }] }),
issue({ id: 'bb-4', dependencies: [{ type: 'blocks', target: 'bb-5' }] }),
issue({ id: 'bb-5' }),
]);
const anomaly = detectDependencyCycles(model);
assert.equal(anomaly.cycles.length, 1);
assert.deepEqual(anomaly.cycleNodeIds, ['bb-1', 'bb-2', 'bb-3']);
assert.deepEqual(anomaly.cycleEdgeIds, ['bb-1:blocks:bb-3', 'bb-2:blocks:bb-1', 'bb-3:blocks:bb-2']);
});
test('detectDependencyCycles does not mark non-cycle predecessor as cyclic', () => {
const model = buildGraphModel([
issue({ id: 'bb-a', dependencies: [{ type: 'blocks', target: 'bb-b' }] }),
issue({ id: 'bb-b', dependencies: [{ type: 'blocks', target: 'bb-c' }] }),
issue({ id: 'bb-c', dependencies: [{ type: 'blocks', target: 'bb-a' }] }),
issue({ id: 'bb-x', dependencies: [{ type: 'blocks', target: 'bb-a' }] }),
]);
const anomaly = detectDependencyCycles(model);
assert.deepEqual(anomaly.cycleNodeIds, ['bb-a', 'bb-b', 'bb-c']);
assert.equal(anomaly.cycleNodeIds.includes('bb-x'), false);
assert.equal(anomaly.cycleEdgeIds.includes('bb-a:blocks:bb-x'), false);
});
test('identifyTransitiveEdges identifies redundant paths in DAG', () => {
const model = buildGraphModel([
issue({ id: 'bb-a' }), // Upstream blocker
issue({ id: 'bb-b', dependencies: [{ type: 'blocks', target: 'bb-a' }] }), // Blocked by A
issue({ id: 'bb-c', dependencies: [{ type: 'blocks', target: 'bb-b' }, { type: 'blocks', target: 'bb-a' }] }), // Blocked by B and A
]);
const transitiveEdges = identifyTransitiveEdges(model);
assert.equal(transitiveEdges.size, 1);
assert.equal(transitiveEdges.has('bb-a:blocks:bb-c'), true);
});

View file

@ -1,64 +1,64 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { BeadDependency, BeadIssue } from '../../src/lib/types';
import { buildGraphModel } from '../../src/lib/graph';
function issue(overrides: Partial<BeadIssue>): BeadIssue {
return {
id: overrides.id ?? 'bb-x',
title: overrides.title ?? 'Issue',
description: overrides.description ?? null,
status: overrides.status ?? 'open',
priority: overrides.priority ?? 2,
issue_type: overrides.issue_type ?? 'task',
import test from 'node:test';
import assert from 'node:assert/strict';
import type { BeadDependency, BeadIssue } from '../../src/lib/types';
import { buildGraphModel } from '../../src/lib/graph';
function issue(overrides: Partial<BeadIssue>): BeadIssue {
return {
id: overrides.id ?? 'bb-x',
title: overrides.title ?? 'Issue',
description: overrides.description ?? null,
status: overrides.status ?? 'open',
priority: overrides.priority ?? 2,
issue_type: overrides.issue_type ?? 'task',
assignee: overrides.assignee ?? null,
templateId: null,
owner: overrides.owner ?? null,
labels: overrides.labels ?? [],
dependencies: overrides.dependencies ?? [],
created_at: overrides.created_at ?? '2026-02-12T00:00:00Z',
updated_at: overrides.updated_at ?? '2026-02-12T00:00:00Z',
closed_at: overrides.closed_at ?? null,
close_reason: overrides.close_reason ?? null,
closed_by_session: overrides.closed_by_session ?? null,
created_by: overrides.created_by ?? null,
due_at: overrides.due_at ?? null,
estimated_minutes: overrides.estimated_minutes ?? null,
external_ref: overrides.external_ref ?? null,
metadata: overrides.metadata ?? {},
};
}
function dep(type: BeadDependency['type'], target: string): BeadDependency {
return { type, target };
}
test('buildGraphModel extracts supported dependency types with deterministic ordering', () => {
const issues = [
issue({
id: 'bb-2',
dependencies: [
dep('parent', 'bb-1'),
dep('blocks', 'bb-3'),
],
}),
issue({
id: 'bb-1',
dependencies: [dep('supersedes', 'bb-3')],
}),
issue({
id: 'bb-3',
dependencies: [
dep('duplicates', 'bb-1'),
dep('relates_to', 'bb-2'),
],
}),
];
const model = buildGraphModel(issues, { projectKey: 'demo' });
assert.deepEqual(model.nodes.map((x) => x.id), ['bb-1', 'bb-2', 'bb-3']);
labels: overrides.labels ?? [],
dependencies: overrides.dependencies ?? [],
created_at: overrides.created_at ?? '2026-02-12T00:00:00Z',
updated_at: overrides.updated_at ?? '2026-02-12T00:00:00Z',
closed_at: overrides.closed_at ?? null,
close_reason: overrides.close_reason ?? null,
closed_by_session: overrides.closed_by_session ?? null,
created_by: overrides.created_by ?? null,
due_at: overrides.due_at ?? null,
estimated_minutes: overrides.estimated_minutes ?? null,
external_ref: overrides.external_ref ?? null,
metadata: overrides.metadata ?? {},
};
}
function dep(type: BeadDependency['type'], target: string): BeadDependency {
return { type, target };
}
test('buildGraphModel extracts supported dependency types with deterministic ordering', () => {
const issues = [
issue({
id: 'bb-2',
dependencies: [
dep('parent', 'bb-1'),
dep('blocks', 'bb-3'),
],
}),
issue({
id: 'bb-1',
dependencies: [dep('supersedes', 'bb-3')],
}),
issue({
id: 'bb-3',
dependencies: [
dep('duplicates', 'bb-1'),
dep('relates_to', 'bb-2'),
],
}),
];
const model = buildGraphModel(issues, { projectKey: 'demo' });
assert.deepEqual(model.nodes.map((x) => x.id), ['bb-1', 'bb-2', 'bb-3']);
assert.deepEqual(
model.edges.map((x) => `${x.source}|${x.type}|${x.target}`),
[
@ -69,55 +69,55 @@ test('buildGraphModel extracts supported dependency types with deterministic ord
'bb-3|relates_to|bb-2',
],
);
assert.equal(model.projectKey, 'demo');
});
test('buildGraphModel deduplicates duplicate edges and tracks diagnostics', () => {
const issues = [
issue({
id: 'bb-1',
dependencies: [
dep('blocks', 'bb-2'),
dep('blocks', 'bb-2'),
dep('blocks', 'bb-2'),
],
}),
issue({ id: 'bb-2' }),
];
const model = buildGraphModel(issues);
assert.equal(model.edges.length, 1);
assert.equal(model.diagnostics.droppedDuplicates, 2);
assert.equal(model.diagnostics.missingTargets, 0);
});
test('buildGraphModel ignores missing-target edges and unsupported types', () => {
const issues = [
issue({
id: 'bb-1',
dependencies: [
dep('blocks', 'bb-missing'),
dep('replies_to', 'bb-2'),
],
}),
issue({ id: 'bb-2' }),
];
const model = buildGraphModel(issues);
assert.equal(model.edges.length, 0);
assert.equal(model.diagnostics.missingTargets, 1);
assert.equal(model.diagnostics.unsupportedTypes, 1);
});
test('buildGraphModel builds incoming/outgoing adjacency maps', () => {
const issues = [
issue({ id: 'bb-1', dependencies: [dep('blocks', 'bb-2')] }),
issue({ id: 'bb-2', dependencies: [dep('parent', 'bb-3')] }),
issue({ id: 'bb-3' }),
];
assert.equal(model.projectKey, 'demo');
});
test('buildGraphModel deduplicates duplicate edges and tracks diagnostics', () => {
const issues = [
issue({
id: 'bb-1',
dependencies: [
dep('blocks', 'bb-2'),
dep('blocks', 'bb-2'),
dep('blocks', 'bb-2'),
],
}),
issue({ id: 'bb-2' }),
];
const model = buildGraphModel(issues);
assert.equal(model.edges.length, 1);
assert.equal(model.diagnostics.droppedDuplicates, 2);
assert.equal(model.diagnostics.missingTargets, 0);
});
test('buildGraphModel ignores missing-target edges and unsupported types', () => {
const issues = [
issue({
id: 'bb-1',
dependencies: [
dep('blocks', 'bb-missing'),
dep('replies_to', 'bb-2'),
],
}),
issue({ id: 'bb-2' }),
];
const model = buildGraphModel(issues);
assert.equal(model.edges.length, 0);
assert.equal(model.diagnostics.missingTargets, 1);
assert.equal(model.diagnostics.unsupportedTypes, 1);
});
test('buildGraphModel builds incoming/outgoing adjacency maps', () => {
const issues = [
issue({ id: 'bb-1', dependencies: [dep('blocks', 'bb-2')] }),
issue({ id: 'bb-2', dependencies: [dep('parent', 'bb-3')] }),
issue({ id: 'bb-3' }),
];
const model = buildGraphModel(issues);
assert.deepEqual(model.adjacency['bb-1'].outgoing.map((x) => x.target), []);

View file

@ -0,0 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { validateInstallerManifest } from '../../src/lib/install-manifest';
function validManifest(): any {
return {
version: 'installer.v1',
distribution: {
packageName: 'beadboard',
shimNames: ['bb', 'beadboard'],
},
wrappers: {
windows: { script: 'install.ps1' },
posix: { script: 'install.sh' },
},
runtime: {
start: 'beadboard start',
open: 'beadboard open',
status: 'beadboard status',
},
driver: {
remediationMode: 'detect_only',
installSideEffects: false,
},
};
}
test('validateInstallerManifest accepts canonical installer.v1 shape', () => {
const result = validateInstallerManifest(validManifest());
assert.equal(result.ok, true);
});
test('validateInstallerManifest rejects wrong version', () => {
const manifest = validManifest();
manifest.version = 'installer.v2';
const result = validateInstallerManifest(manifest);
assert.equal(result.ok, false);
if (!result.ok) {
assert.match(result.error, /version/i);
}
});
test('validateInstallerManifest rejects missing runtime status command', () => {
const manifest = validManifest();
delete manifest.runtime.status;
const result = validateInstallerManifest(manifest);
assert.equal(result.ok, false);
if (!result.ok) {
assert.match(result.error, /runtime\.status/i);
}
});
test('validateInstallerManifest rejects driver mode that is not detect_only', () => {
const manifest = validManifest();
manifest.driver.remediationMode = 'install';
const result = validateInstallerManifest(manifest);
assert.equal(result.ok, false);
if (!result.ok) {
assert.match(result.error, /detect_only/i);
}
});

View file

@ -1,143 +1,143 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildEditableIssueDraft,
buildIssueUpdatePayload,
classifyEditState,
parseLabelsInput,
validateEditableIssueDraft,
type EditableIssueDraft,
} from '../../src/lib/issue-editor';
import type { BeadIssue } from '../../src/lib/types';
function makeIssue(overrides: Partial<BeadIssue> = {}): BeadIssue {
const has = (key: keyof BeadIssue) => Object.prototype.hasOwnProperty.call(overrides, key);
return {
id: overrides.id ?? 'bb-101',
title: overrides.title ?? 'Implement shared edit surface',
description: has('description') ? (overrides.description as string | null) : 'First line',
status: overrides.status ?? 'open',
priority: overrides.priority ?? 2,
issue_type: overrides.issue_type ?? 'task',
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildEditableIssueDraft,
buildIssueUpdatePayload,
classifyEditState,
parseLabelsInput,
validateEditableIssueDraft,
type EditableIssueDraft,
} from '../../src/lib/issue-editor';
import type { BeadIssue } from '../../src/lib/types';
function makeIssue(overrides: Partial<BeadIssue> = {}): BeadIssue {
const has = (key: keyof BeadIssue) => Object.prototype.hasOwnProperty.call(overrides, key);
return {
id: overrides.id ?? 'bb-101',
title: overrides.title ?? 'Implement shared edit surface',
description: has('description') ? (overrides.description as string | null) : 'First line',
status: overrides.status ?? 'open',
priority: overrides.priority ?? 2,
issue_type: overrides.issue_type ?? 'task',
assignee: has('assignee') ? (overrides.assignee as string | null) : null,
templateId: null,
owner: has('owner') ? (overrides.owner as string | null) : null,
labels: overrides.labels ?? ['ux', 'graph'],
dependencies: overrides.dependencies ?? [],
created_at: overrides.created_at ?? '2026-02-13T00:00:00.000Z',
updated_at: overrides.updated_at ?? '2026-02-13T00:00:00.000Z',
closed_at: overrides.closed_at ?? null,
close_reason: overrides.close_reason ?? null,
closed_by_session: overrides.closed_by_session ?? null,
created_by: overrides.created_by ?? 'zenchantlive',
due_at: overrides.due_at ?? null,
estimated_minutes: overrides.estimated_minutes ?? null,
external_ref: overrides.external_ref ?? null,
metadata: overrides.metadata ?? {},
};
}
test('buildEditableIssueDraft normalizes nullable issue fields', () => {
const issue = makeIssue({
description: null,
assignee: null,
owner: null,
labels: ['graph', ' ux ', ''],
});
const draft = buildEditableIssueDraft(issue);
assert.equal(draft.title, issue.title);
assert.equal(draft.description, '');
assert.equal(draft.status, issue.status);
assert.equal(draft.assignee, '');
assert.equal(draft.owner, '');
assert.equal(draft.labelsInput, 'graph, ux');
});
test('validateEditableIssueDraft returns validation errors for invalid input', () => {
const draft = {
title: ' ',
description: 'desc',
status: 'tombstone',
priority: 7,
issueType: '',
assignee: 'dev-a',
owner: '',
labelsInput: 'ok, , invalid',
} as unknown as EditableIssueDraft;
const result = validateEditableIssueDraft(draft);
assert.equal(result.ok, false);
assert.equal(result.errors.title, 'Title is required.');
assert.equal(result.errors.status, 'Status must be open, in progress, blocked, deferred, or closed.');
assert.equal(result.errors.priority, 'Priority must be between 0 and 4.');
assert.equal(result.errors.issueType, 'Issue type is required.');
assert.equal(result.errors.labelsInput, 'Labels must be comma-separated non-empty values.');
});
test('buildIssueUpdatePayload includes only changed mutable fields', () => {
const issue = makeIssue({
title: 'Old title',
description: 'Old description',
priority: 2,
issue_type: 'task',
assignee: 'old-assignee',
labels: ['legacy'],
owner: 'owner-a',
});
const draft: EditableIssueDraft = {
title: 'New title',
description: 'Old description',
status: 'in_progress',
priority: 1,
issueType: 'feature',
assignee: 'new-assignee',
owner: 'owner-b',
labelsInput: 'legacy,ui',
};
const payload = buildIssueUpdatePayload(issue, draft, 'C:/repo');
assert.deepEqual(payload, {
projectRoot: 'C:/repo',
id: issue.id,
title: 'New title',
status: 'in_progress',
priority: 1,
issueType: 'feature',
assignee: 'new-assignee',
labels: ['legacy', 'ui'],
});
});
test('buildIssueUpdatePayload returns null when no mutable fields changed', () => {
const issue = makeIssue({
title: 'Same title',
description: 'Same description',
status: 'open',
priority: 2,
issue_type: 'task',
assignee: null,
owner: 'owner-a',
labels: ['a', 'b'],
});
const draft = buildEditableIssueDraft(issue);
const payload = buildIssueUpdatePayload(issue, draft, 'C:/repo');
assert.equal(payload, null);
});
test('parseLabelsInput deduplicates, trims, and preserves order', () => {
assert.deepEqual(parseLabelsInput(' alpha, beta,alpha, gamma , ,beta'), ['alpha', 'beta', 'gamma']);
});
test('classifyEditState derives ui state from dirty/saving/error flags', () => {
assert.equal(classifyEditState({ dirty: false, saving: false, error: null }), 'pristine');
assert.equal(classifyEditState({ dirty: true, saving: false, error: null }), 'dirty');
assert.equal(classifyEditState({ dirty: true, saving: true, error: null }), 'saving');
assert.equal(classifyEditState({ dirty: true, saving: false, error: 'boom' }), 'error');
});
labels: overrides.labels ?? ['ux', 'graph'],
dependencies: overrides.dependencies ?? [],
created_at: overrides.created_at ?? '2026-02-13T00:00:00.000Z',
updated_at: overrides.updated_at ?? '2026-02-13T00:00:00.000Z',
closed_at: overrides.closed_at ?? null,
close_reason: overrides.close_reason ?? null,
closed_by_session: overrides.closed_by_session ?? null,
created_by: overrides.created_by ?? 'zenchantlive',
due_at: overrides.due_at ?? null,
estimated_minutes: overrides.estimated_minutes ?? null,
external_ref: overrides.external_ref ?? null,
metadata: overrides.metadata ?? {},
};
}
test('buildEditableIssueDraft normalizes nullable issue fields', () => {
const issue = makeIssue({
description: null,
assignee: null,
owner: null,
labels: ['graph', ' ux ', ''],
});
const draft = buildEditableIssueDraft(issue);
assert.equal(draft.title, issue.title);
assert.equal(draft.description, '');
assert.equal(draft.status, issue.status);
assert.equal(draft.assignee, '');
assert.equal(draft.owner, '');
assert.equal(draft.labelsInput, 'graph, ux');
});
test('validateEditableIssueDraft returns validation errors for invalid input', () => {
const draft = {
title: ' ',
description: 'desc',
status: 'tombstone',
priority: 7,
issueType: '',
assignee: 'dev-a',
owner: '',
labelsInput: 'ok, , invalid',
} as unknown as EditableIssueDraft;
const result = validateEditableIssueDraft(draft);
assert.equal(result.ok, false);
assert.equal(result.errors.title, 'Title is required.');
assert.equal(result.errors.status, 'Status must be open, in progress, blocked, deferred, or closed.');
assert.equal(result.errors.priority, 'Priority must be between 0 and 4.');
assert.equal(result.errors.issueType, 'Issue type is required.');
assert.equal(result.errors.labelsInput, 'Labels must be comma-separated non-empty values.');
});
test('buildIssueUpdatePayload includes only changed mutable fields', () => {
const issue = makeIssue({
title: 'Old title',
description: 'Old description',
priority: 2,
issue_type: 'task',
assignee: 'old-assignee',
labels: ['legacy'],
owner: 'owner-a',
});
const draft: EditableIssueDraft = {
title: 'New title',
description: 'Old description',
status: 'in_progress',
priority: 1,
issueType: 'feature',
assignee: 'new-assignee',
owner: 'owner-b',
labelsInput: 'legacy,ui',
};
const payload = buildIssueUpdatePayload(issue, draft, 'C:/repo');
assert.deepEqual(payload, {
projectRoot: 'C:/repo',
id: issue.id,
title: 'New title',
status: 'in_progress',
priority: 1,
issueType: 'feature',
assignee: 'new-assignee',
labels: ['legacy', 'ui'],
});
});
test('buildIssueUpdatePayload returns null when no mutable fields changed', () => {
const issue = makeIssue({
title: 'Same title',
description: 'Same description',
status: 'open',
priority: 2,
issue_type: 'task',
assignee: null,
owner: 'owner-a',
labels: ['a', 'b'],
});
const draft = buildEditableIssueDraft(issue);
const payload = buildIssueUpdatePayload(issue, draft, 'C:/repo');
assert.equal(payload, null);
});
test('parseLabelsInput deduplicates, trims, and preserves order', () => {
assert.deepEqual(parseLabelsInput(' alpha, beta,alpha, gamma , ,beta'), ['alpha', 'beta', 'gamma']);
});
test('classifyEditState derives ui state from dirty/saving/error flags', () => {
assert.equal(classifyEditState({ dirty: false, saving: false, error: null }), 'pristine');
assert.equal(classifyEditState({ dirty: true, saving: false, error: null }), 'dirty');
assert.equal(classifyEditState({ dirty: true, saving: true, error: null }), 'saving');
assert.equal(classifyEditState({ dirty: true, saving: false, error: 'boom' }), 'error');
});

View file

@ -1,63 +1,63 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { BeadIssue } from '../../src/lib/types';
import {
KANBAN_STATUSES,
buildExecutionChecklist,
buildBlockedByTree,
buildKanbanColumns,
buildKanbanStats,
buildUnblocksCountByIssue,
findIssueLane,
filterKanbanIssues,
pickNextActionableIssue,
} from '../../src/lib/kanban';
function issue(overrides: Partial<BeadIssue>): BeadIssue {
return {
id: overrides.id ?? 'bb-x',
title: overrides.title ?? 'Issue',
description: overrides.description ?? null,
status: overrides.status ?? 'open',
priority: overrides.priority ?? 2,
issue_type: overrides.issue_type ?? 'task',
import test from 'node:test';
import assert from 'node:assert/strict';
import type { BeadIssue } from '../../src/lib/types';
import {
KANBAN_STATUSES,
buildExecutionChecklist,
buildBlockedByTree,
buildKanbanColumns,
buildKanbanStats,
buildUnblocksCountByIssue,
findIssueLane,
filterKanbanIssues,
pickNextActionableIssue,
} from '../../src/lib/kanban';
function issue(overrides: Partial<BeadIssue>): BeadIssue {
return {
id: overrides.id ?? 'bb-x',
title: overrides.title ?? 'Issue',
description: overrides.description ?? null,
status: overrides.status ?? 'open',
priority: overrides.priority ?? 2,
issue_type: overrides.issue_type ?? 'task',
assignee: overrides.assignee ?? null,
templateId: null,
owner: overrides.owner ?? null,
labels: overrides.labels ?? [],
dependencies: overrides.dependencies ?? [],
created_at: overrides.created_at ?? '2026-02-12T00:00:00Z',
updated_at: overrides.updated_at ?? '2026-02-12T00:00:00Z',
closed_at: overrides.closed_at ?? null,
close_reason: overrides.close_reason ?? null,
closed_by_session: overrides.closed_by_session ?? null,
created_by: overrides.created_by ?? null,
due_at: overrides.due_at ?? null,
estimated_minutes: overrides.estimated_minutes ?? null,
external_ref: overrides.external_ref ?? null,
metadata: overrides.metadata ?? {},
};
}
test('filterKanbanIssues filters by query/type/priority and closed visibility', () => {
const issues = [
issue({ id: 'bb-1', title: 'OAuth integration', labels: ['security'], status: 'open', priority: 1, issue_type: 'feature' }),
issue({ id: 'bb-2', title: 'Fix timezone bug', status: 'in_progress', priority: 0, issue_type: 'bug' }),
issue({ id: 'bb-3', title: 'Done task', status: 'closed', priority: 2, issue_type: 'task' }),
];
const filtered = filterKanbanIssues(issues, {
query: 'oauth',
type: 'feature',
priority: '1',
showClosed: false,
});
assert.equal(filtered.length, 1);
assert.equal(filtered[0].id, 'bb-1');
});
labels: overrides.labels ?? [],
dependencies: overrides.dependencies ?? [],
created_at: overrides.created_at ?? '2026-02-12T00:00:00Z',
updated_at: overrides.updated_at ?? '2026-02-12T00:00:00Z',
closed_at: overrides.closed_at ?? null,
close_reason: overrides.close_reason ?? null,
closed_by_session: overrides.closed_by_session ?? null,
created_by: overrides.created_by ?? null,
due_at: overrides.due_at ?? null,
estimated_minutes: overrides.estimated_minutes ?? null,
external_ref: overrides.external_ref ?? null,
metadata: overrides.metadata ?? {},
};
}
test('filterKanbanIssues filters by query/type/priority and closed visibility', () => {
const issues = [
issue({ id: 'bb-1', title: 'OAuth integration', labels: ['security'], status: 'open', priority: 1, issue_type: 'feature' }),
issue({ id: 'bb-2', title: 'Fix timezone bug', status: 'in_progress', priority: 0, issue_type: 'bug' }),
issue({ id: 'bb-3', title: 'Done task', status: 'closed', priority: 2, issue_type: 'task' }),
];
const filtered = filterKanbanIssues(issues, {
query: 'oauth',
type: 'feature',
priority: '1',
showClosed: false,
});
assert.equal(filtered.length, 1);
assert.equal(filtered[0].id, 'bb-1');
});
test('buildKanbanColumns groups by core statuses and sorts by priority ascending', () => {
const issues = [
issue({ id: 'bb-1', status: 'open', priority: 2 }),
@ -78,26 +78,26 @@ test('buildKanbanColumns groups by core statuses and sorts by priority ascending
assert.deepEqual(columns.blocked.map((x) => x.id), ['bb-5', 'bb-6']);
assert.equal(columns.closed.length, 0);
});
test('buildKanbanStats reports total/ready/active/blocked/done/p0', () => {
const issues = [
issue({ status: 'open', priority: 0 }),
issue({ status: 'open', priority: 2 }),
issue({ status: 'in_progress', priority: 1 }),
issue({ status: 'blocked', priority: 1 }),
issue({ status: 'closed', priority: 3 }),
];
const stats = buildKanbanStats(issues);
assert.equal(stats.total, 5);
assert.equal(stats.ready, 2);
assert.equal(stats.active, 1);
assert.equal(stats.blocked, 1);
assert.equal(stats.done, 1);
assert.equal(stats.p0, 1);
});
test('buildKanbanStats reports total/ready/active/blocked/done/p0', () => {
const issues = [
issue({ status: 'open', priority: 0 }),
issue({ status: 'open', priority: 2 }),
issue({ status: 'in_progress', priority: 1 }),
issue({ status: 'blocked', priority: 1 }),
issue({ status: 'closed', priority: 3 }),
];
const stats = buildKanbanStats(issues);
assert.equal(stats.total, 5);
assert.equal(stats.ready, 2);
assert.equal(stats.active, 1);
assert.equal(stats.blocked, 1);
assert.equal(stats.done, 1);
assert.equal(stats.p0, 1);
});
test('buildBlockedByTree returns compact blocker tree with depth and total', () => {
const issues = [
issue({ id: 'bb-1', title: 'Target issue' }),
@ -114,22 +114,22 @@ test('buildBlockedByTree returns compact blocker tree with depth and total', ()
['bb-2:1', 'bb-1:2'],
);
});
test('findIssueLane resolves ready lane for unblocked linked issues', () => {
const issues = [
issue({ id: 'bb-1', status: 'open' }),
issue({ id: 'bb-2', status: 'blocked' }),
issue({ id: 'bb-3', status: 'closed' }),
];
const columns = buildKanbanColumns(issues);
assert.equal(findIssueLane(columns, 'bb-1'), 'ready');
assert.equal(findIssueLane(columns, 'bb-2'), 'blocked');
assert.equal(findIssueLane(columns, 'bb-3'), 'closed');
assert.equal(findIssueLane(columns, 'bb-404'), null);
});
test('findIssueLane resolves ready lane for unblocked linked issues', () => {
const issues = [
issue({ id: 'bb-1', status: 'open' }),
issue({ id: 'bb-2', status: 'blocked' }),
issue({ id: 'bb-3', status: 'closed' }),
];
const columns = buildKanbanColumns(issues);
assert.equal(findIssueLane(columns, 'bb-1'), 'ready');
assert.equal(findIssueLane(columns, 'bb-2'), 'blocked');
assert.equal(findIssueLane(columns, 'bb-3'), 'closed');
assert.equal(findIssueLane(columns, 'bb-404'), null);
});
test('pickNextActionableIssue is deterministic by priority asc, unblocks desc, updated desc, id asc', () => {
const issues = [
issue({
@ -160,40 +160,40 @@ test('pickNextActionableIssue is deterministic by priority asc, unblocks desc, u
dependencies: [{ type: 'blocks', target: 'bb-2' }],
}),
];
const columns = buildKanbanColumns(issues);
const next = pickNextActionableIssue(columns, issues);
assert.equal(next?.id, 'bb-2');
});
test('pickNextActionableIssue returns null when no ready issue exists', () => {
const issues = [issue({ id: 'bb-1', status: 'in_progress' }), issue({ id: 'bb-2', status: 'closed' })];
const columns = buildKanbanColumns(issues);
assert.equal(pickNextActionableIssue(columns, issues), null);
});
const columns = buildKanbanColumns(issues);
const next = pickNextActionableIssue(columns, issues);
assert.equal(next?.id, 'bb-2');
});
test('pickNextActionableIssue returns null when no ready issue exists', () => {
const issues = [issue({ id: 'bb-1', status: 'in_progress' }), issue({ id: 'bb-2', status: 'closed' })];
const columns = buildKanbanColumns(issues);
assert.equal(pickNextActionableIssue(columns, issues), null);
});
test('buildUnblocksCountByIssue counts unique blocks dependencies per issue', () => {
const issues = [
issue({
id: 'bb-1',
dependencies: [
{ type: 'blocks', target: 'bb-2' },
{ type: 'blocks', target: 'bb-2' },
{ type: 'blocks', target: 'bb-3' },
{ type: 'relates_to', target: 'bb-4' },
],
}),
];
issue({
id: 'bb-1',
dependencies: [
{ type: 'blocks', target: 'bb-2' },
{ type: 'blocks', target: 'bb-2' },
{ type: 'blocks', target: 'bb-3' },
{ type: 'relates_to', target: 'bb-4' },
],
}),
];
const map = buildUnblocksCountByIssue(issues);
assert.equal(map.get('bb-1'), 0);
assert.equal(map.get('bb-2'), 1);
assert.equal(map.get('bb-3'), 1);
});
test('buildExecutionChecklist evaluates owner, blockers, quality signal, and execution-compatible lane', () => {
const issues = [
issue({
@ -209,7 +209,7 @@ test('buildExecutionChecklist evaluates owner, blockers, quality signal, and exe
const checklist = buildExecutionChecklist(issues[0], issues);
assert.deepEqual(
checklist.map((item) => item.passed),
checklist.map((item) => item.passed),
[true, true, true, true],
);
});

View file

@ -1,148 +1,148 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
getAgentActiveMissions,
getActiveMissionCount,
getMissionsByAgent,
type SessionTaskCard,
type EpicBucket
} from '../../src/lib/agent-sessions';
/**
* Tests for bb-buff.3.3: Active Mission Pathing
*
* These tests verify the mapping between working agents and their tasks.
*/
// Helper to create test data
function makeBucket(tasks: Partial<SessionTaskCard>[]): EpicBucket {
return {
epic: { id: 'epic-1', title: 'Test Epic', status: 'open' },
tasks: tasks.map((t, i) => ({
id: t.id || `task-${i}`,
title: t.title || 'Test Task',
epicId: 'epic-1',
status: t.status || 'in_progress',
sessionState: t.sessionState || 'active',
owner: t.owner || null,
lastActor: null,
lastActivityAt: new Date().toISOString(),
communication: { unreadCount: 0, pendingRequired: false, latestSnippet: null },
...t
})) as SessionTaskCard[]
};
}
test('getAgentActiveMissions returns tasks owned by agent', () => {
const feed = [
makeBucket([
{ id: 'task-1', owner: 'agent-alpha' },
{ id: 'task-2', owner: 'agent-beta' },
{ id: 'task-3', owner: 'agent-alpha' },
])
];
const missions = getAgentActiveMissions(feed, 'agent-alpha');
assert.equal(missions.length, 2);
assert.equal(missions[0].id, 'task-1');
assert.equal(missions[1].id, 'task-3');
});
test('getAgentActiveMissions excludes closed tasks', () => {
const feed = [
makeBucket([
{ id: 'task-1', owner: 'agent-alpha', status: 'in_progress' },
{ id: 'task-2', owner: 'agent-alpha', status: 'closed' },
])
];
const missions = getAgentActiveMissions(feed, 'agent-alpha');
assert.equal(missions.length, 1);
assert.equal(missions[0].id, 'task-1');
});
test('getAgentActiveMissions returns empty array for unknown agent', () => {
const feed = [
makeBucket([
{ id: 'task-1', owner: 'agent-alpha' },
])
];
const missions = getAgentActiveMissions(feed, 'unknown-agent');
assert.equal(missions.length, 0);
});
test('getAgentActiveMissions returns empty array for null owner', () => {
const feed = [
makeBucket([
{ id: 'task-1', owner: null },
])
];
const missions = getAgentActiveMissions(feed, 'agent-alpha');
assert.equal(missions.length, 0);
});
test('getAgentActiveMissions works across multiple epics', () => {
const bucket1 = makeBucket([{ id: 'task-1', owner: 'agent-alpha', epicId: 'epic-1' }]);
const bucket2: EpicBucket = {
epic: { id: 'epic-2', title: 'Epic 2', status: 'open' },
tasks: [{
id: 'task-2',
title: 'Task in Epic 2',
epicId: 'epic-2',
status: 'in_progress',
sessionState: 'active',
owner: 'agent-alpha',
lastActor: null,
lastActivityAt: new Date().toISOString(),
communication: { unreadCount: 0, pendingRequired: false, latestSnippet: null },
}]
};
const missions = getAgentActiveMissions([bucket1, bucket2], 'agent-alpha');
assert.equal(missions.length, 2);
});
test('getActiveMissionCount returns correct count', () => {
const feed = [
makeBucket([
{ id: 'task-1', owner: 'agent-alpha' },
{ id: 'task-2', owner: 'agent-alpha' },
{ id: 'task-3', owner: 'agent-beta' },
])
];
assert.equal(getActiveMissionCount(feed, 'agent-alpha'), 2);
assert.equal(getActiveMissionCount(feed, 'agent-beta'), 1);
assert.equal(getActiveMissionCount(feed, 'unknown'), 0);
});
test('getMissionsByAgent groups all agents', () => {
const feed = [
makeBucket([
{ id: 'task-1', owner: 'agent-alpha' },
{ id: 'task-2', owner: 'agent-beta' },
{ id: 'task-3', owner: 'agent-alpha' },
{ id: 'task-4', owner: null }, // No owner
])
];
const byAgent = getMissionsByAgent(feed);
assert.deepEqual(Object.keys(byAgent).sort(), ['agent-alpha', 'agent-beta']);
assert.equal(byAgent['agent-alpha'].length, 2);
assert.equal(byAgent['agent-beta'].length, 1);
});
test('getMissionsByAgent excludes closed tasks', () => {
const feed = [
makeBucket([
{ id: 'task-1', owner: 'agent-alpha', status: 'in_progress' },
{ id: 'task-2', owner: 'agent-alpha', status: 'closed' },
])
];
const byAgent = getMissionsByAgent(feed);
assert.equal(byAgent['agent-alpha'].length, 1);
assert.equal(byAgent['agent-alpha'][0].id, 'task-1');
});
import test from 'node:test';
import assert from 'node:assert/strict';
import {
getAgentActiveMissions,
getActiveMissionCount,
getMissionsByAgent,
type SessionTaskCard,
type EpicBucket
} from '../../src/lib/agent-sessions';
/**
* Tests for bb-buff.3.3: Active Mission Pathing
*
* These tests verify the mapping between working agents and their tasks.
*/
// Helper to create test data
function makeBucket(tasks: Partial<SessionTaskCard>[]): EpicBucket {
return {
epic: { id: 'epic-1', title: 'Test Epic', status: 'open' },
tasks: tasks.map((t, i) => ({
id: t.id || `task-${i}`,
title: t.title || 'Test Task',
epicId: 'epic-1',
status: t.status || 'in_progress',
sessionState: t.sessionState || 'active',
owner: t.owner || null,
lastActor: null,
lastActivityAt: new Date().toISOString(),
communication: { unreadCount: 0, pendingRequired: false, latestSnippet: null },
...t
})) as SessionTaskCard[]
};
}
test('getAgentActiveMissions returns tasks owned by agent', () => {
const feed = [
makeBucket([
{ id: 'task-1', owner: 'agent-alpha' },
{ id: 'task-2', owner: 'agent-beta' },
{ id: 'task-3', owner: 'agent-alpha' },
])
];
const missions = getAgentActiveMissions(feed, 'agent-alpha');
assert.equal(missions.length, 2);
assert.equal(missions[0].id, 'task-1');
assert.equal(missions[1].id, 'task-3');
});
test('getAgentActiveMissions excludes closed tasks', () => {
const feed = [
makeBucket([
{ id: 'task-1', owner: 'agent-alpha', status: 'in_progress' },
{ id: 'task-2', owner: 'agent-alpha', status: 'closed' },
])
];
const missions = getAgentActiveMissions(feed, 'agent-alpha');
assert.equal(missions.length, 1);
assert.equal(missions[0].id, 'task-1');
});
test('getAgentActiveMissions returns empty array for unknown agent', () => {
const feed = [
makeBucket([
{ id: 'task-1', owner: 'agent-alpha' },
])
];
const missions = getAgentActiveMissions(feed, 'unknown-agent');
assert.equal(missions.length, 0);
});
test('getAgentActiveMissions returns empty array for null owner', () => {
const feed = [
makeBucket([
{ id: 'task-1', owner: null },
])
];
const missions = getAgentActiveMissions(feed, 'agent-alpha');
assert.equal(missions.length, 0);
});
test('getAgentActiveMissions works across multiple epics', () => {
const bucket1 = makeBucket([{ id: 'task-1', owner: 'agent-alpha', epicId: 'epic-1' }]);
const bucket2: EpicBucket = {
epic: { id: 'epic-2', title: 'Epic 2', status: 'open' },
tasks: [{
id: 'task-2',
title: 'Task in Epic 2',
epicId: 'epic-2',
status: 'in_progress',
sessionState: 'active',
owner: 'agent-alpha',
lastActor: null,
lastActivityAt: new Date().toISOString(),
communication: { unreadCount: 0, pendingRequired: false, latestSnippet: null },
}]
};
const missions = getAgentActiveMissions([bucket1, bucket2], 'agent-alpha');
assert.equal(missions.length, 2);
});
test('getActiveMissionCount returns correct count', () => {
const feed = [
makeBucket([
{ id: 'task-1', owner: 'agent-alpha' },
{ id: 'task-2', owner: 'agent-alpha' },
{ id: 'task-3', owner: 'agent-beta' },
])
];
assert.equal(getActiveMissionCount(feed, 'agent-alpha'), 2);
assert.equal(getActiveMissionCount(feed, 'agent-beta'), 1);
assert.equal(getActiveMissionCount(feed, 'unknown'), 0);
});
test('getMissionsByAgent groups all agents', () => {
const feed = [
makeBucket([
{ id: 'task-1', owner: 'agent-alpha' },
{ id: 'task-2', owner: 'agent-beta' },
{ id: 'task-3', owner: 'agent-alpha' },
{ id: 'task-4', owner: null }, // No owner
])
];
const byAgent = getMissionsByAgent(feed);
assert.deepEqual(Object.keys(byAgent).sort(), ['agent-alpha', 'agent-beta']);
assert.equal(byAgent['agent-alpha'].length, 2);
assert.equal(byAgent['agent-beta'].length, 1);
});
test('getMissionsByAgent excludes closed tasks', () => {
const feed = [
makeBucket([
{ id: 'task-1', owner: 'agent-alpha', status: 'in_progress' },
{ id: 'task-2', owner: 'agent-alpha', status: 'closed' },
])
];
const byAgent = getMissionsByAgent(feed);
assert.equal(byAgent['agent-alpha'].length, 1);
assert.equal(byAgent['agent-alpha'][0].id, 'task-1');
});

View file

@ -1,170 +1,170 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
MutationValidationError,
buildBdMutationArgs,
validateMutationPayload,
executeMutation,
} from '../../src/lib/mutations';
const root = 'C:/Users/Zenchant/codex/beadboard';
test('validateMutationPayload rejects invalid payloads', () => {
assert.throws(
() => validateMutationPayload('create', { projectRoot: '', title: '' }),
(error: unknown) => error instanceof MutationValidationError,
);
});
test('buildBdMutationArgs maps reopen correctly', () => {
const payload = validateMutationPayload('reopen', {
projectRoot: root,
id: 'bb-123',
reason: 'retry work',
});
const args = buildBdMutationArgs('reopen', payload);
assert.deepEqual(args, ['reopen', 'bb-123', '-r', 'retry work', '--json']);
});
test('buildBdMutationArgs maps update issue type correctly', () => {
const payload = validateMutationPayload('update', {
projectRoot: root,
id: 'bb-123',
issueType: 'feature',
});
const args = buildBdMutationArgs('update', payload);
assert.deepEqual(args, ['update', 'bb-123', '-t', 'feature', '--json']);
});
test('buildBdMutationArgs maps comment correctly', () => {
const payload = validateMutationPayload('comment', {
projectRoot: root,
id: 'bb-123',
text: 'Added notes',
});
const args = buildBdMutationArgs('comment', payload);
assert.deepEqual(args, ['comments', 'add', 'bb-123', 'Added notes', '--json']);
});
test('executeMutation surfaces bridge failures in normalized response', async () => {
const payload = validateMutationPayload('close', {
projectRoot: root,
id: 'bb-123',
reason: 'completed',
});
const result = await executeMutation('close', payload, {
runBdCommand: async ({ args }) => {
assert.deepEqual(args, ['close', 'bb-123', '-r', 'completed', '--json']);
return {
success: false,
classification: 'non_zero_exit',
command: 'bd',
args,
cwd: root,
stdout: '',
stderr: 'cannot close',
code: 1,
durationMs: 3,
error: 'cannot close',
};
},
});
assert.equal(result.ok, false);
assert.equal(result.error?.classification, 'non_zero_exit');
});
test('executeMutation returns successful normalized response', async () => {
const payload = validateMutationPayload('update', {
projectRoot: root,
id: 'bb-123',
status: 'in_progress',
priority: 1,
});
const result = await executeMutation('update', payload, {
runBdCommand: async ({ args }) => {
assert.deepEqual(args, ['update', 'bb-123', '-s', 'in_progress', '-p', '1', '--json']);
return {
success: true,
classification: null,
command: 'bd',
args,
cwd: root,
stdout: '{"id":"bb-123"}',
stderr: '',
code: 0,
durationMs: 2,
error: null,
};
},
});
assert.equal(result.ok, true);
assert.equal(result.operation, 'update');
assert.equal(result.command.success, true);
});
test('executeMutation includes --actor when provided in payload', async () => {
const payload = validateMutationPayload('comment', {
projectRoot: root,
id: 'bb-123',
text: 'Operator note',
actor: 'zenchant',
});
const result = await executeMutation('comment', payload, {
runBdCommand: async ({ args }) => {
assert.deepEqual(args, ['--actor', 'zenchant', 'comments', 'add', 'bb-123', 'Operator note', '--json']);
return {
success: true,
classification: null,
command: 'bd',
args,
cwd: root,
stdout: '{"ok":true}',
stderr: '',
code: 0,
durationMs: 2,
error: null,
};
},
});
assert.equal(result.ok, true);
});
test('executeMutation ignores bdPath and uses default runner contract', async () => {
const payload = validateMutationPayload('update', {
projectRoot: root,
id: 'bb-123',
status: 'in_progress',
bdPath: 'C:/Tools/beads/bd.exe',
});
const result = await executeMutation('update', payload, {
runBdCommand: async (options) => {
assert.equal(options.explicitBdPath, undefined);
assert.deepEqual(options.args, ['update', 'bb-123', '-s', 'in_progress', '--json']);
return {
success: true,
classification: null,
command: 'bd',
args: options.args,
cwd: root,
stdout: '{"ok":true}',
stderr: '',
code: 0,
durationMs: 2,
error: null,
};
},
});
assert.equal(result.ok, true);
});
import test from 'node:test';
import assert from 'node:assert/strict';
import {
MutationValidationError,
buildBdMutationArgs,
validateMutationPayload,
executeMutation,
} from '../../src/lib/mutations';
const root = 'C:/Users/Zenchant/codex/beadboard';
test('validateMutationPayload rejects invalid payloads', () => {
assert.throws(
() => validateMutationPayload('create', { projectRoot: '', title: '' }),
(error: unknown) => error instanceof MutationValidationError,
);
});
test('buildBdMutationArgs maps reopen correctly', () => {
const payload = validateMutationPayload('reopen', {
projectRoot: root,
id: 'bb-123',
reason: 'retry work',
});
const args = buildBdMutationArgs('reopen', payload);
assert.deepEqual(args, ['reopen', 'bb-123', '-r', 'retry work', '--json']);
});
test('buildBdMutationArgs maps update issue type correctly', () => {
const payload = validateMutationPayload('update', {
projectRoot: root,
id: 'bb-123',
issueType: 'feature',
});
const args = buildBdMutationArgs('update', payload);
assert.deepEqual(args, ['update', 'bb-123', '-t', 'feature', '--json']);
});
test('buildBdMutationArgs maps comment correctly', () => {
const payload = validateMutationPayload('comment', {
projectRoot: root,
id: 'bb-123',
text: 'Added notes',
});
const args = buildBdMutationArgs('comment', payload);
assert.deepEqual(args, ['comments', 'add', 'bb-123', 'Added notes', '--json']);
});
test('executeMutation surfaces bridge failures in normalized response', async () => {
const payload = validateMutationPayload('close', {
projectRoot: root,
id: 'bb-123',
reason: 'completed',
});
const result = await executeMutation('close', payload, {
runBdCommand: async ({ args }) => {
assert.deepEqual(args, ['close', 'bb-123', '-r', 'completed', '--json']);
return {
success: false,
classification: 'non_zero_exit',
command: 'bd',
args,
cwd: root,
stdout: '',
stderr: 'cannot close',
code: 1,
durationMs: 3,
error: 'cannot close',
};
},
});
assert.equal(result.ok, false);
assert.equal(result.error?.classification, 'non_zero_exit');
});
test('executeMutation returns successful normalized response', async () => {
const payload = validateMutationPayload('update', {
projectRoot: root,
id: 'bb-123',
status: 'in_progress',
priority: 1,
});
const result = await executeMutation('update', payload, {
runBdCommand: async ({ args }) => {
assert.deepEqual(args, ['update', 'bb-123', '-s', 'in_progress', '-p', '1', '--json']);
return {
success: true,
classification: null,
command: 'bd',
args,
cwd: root,
stdout: '{"id":"bb-123"}',
stderr: '',
code: 0,
durationMs: 2,
error: null,
};
},
});
assert.equal(result.ok, true);
assert.equal(result.operation, 'update');
assert.equal(result.command.success, true);
});
test('executeMutation includes --actor when provided in payload', async () => {
const payload = validateMutationPayload('comment', {
projectRoot: root,
id: 'bb-123',
text: 'Operator note',
actor: 'zenchant',
});
const result = await executeMutation('comment', payload, {
runBdCommand: async ({ args }) => {
assert.deepEqual(args, ['--actor', 'zenchant', 'comments', 'add', 'bb-123', 'Operator note', '--json']);
return {
success: true,
classification: null,
command: 'bd',
args,
cwd: root,
stdout: '{"ok":true}',
stderr: '',
code: 0,
durationMs: 2,
error: null,
};
},
});
assert.equal(result.ok, true);
});
test('executeMutation ignores bdPath and uses default runner contract', async () => {
const payload = validateMutationPayload('update', {
projectRoot: root,
id: 'bb-123',
status: 'in_progress',
bdPath: 'C:/Tools/beads/bd.exe',
});
const result = await executeMutation('update', payload, {
runBdCommand: async (options) => {
assert.equal(options.explicitBdPath, undefined);
assert.deepEqual(options.args, ['update', 'bb-123', '-s', 'in_progress', '--json']);
return {
success: true,
classification: null,
command: 'bd',
args: options.args,
cwd: root,
stdout: '{"ok":true}',
stderr: '',
code: 0,
durationMs: 2,
error: null,
};
},
});
assert.equal(result.ok, true);
});

View file

@ -1,72 +1,72 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseIssuesJsonl } from '../../src/lib/parser';
test('parseIssuesJsonl applies defaults and preserves priority 0', () => {
const input = [
JSON.stringify({ id: 'bb-1', title: 'One', priority: 0 }),
JSON.stringify({ id: 'bb-2', title: 'Two' }),
].join('\n');
const result = parseIssuesJsonl(input);
assert.equal(result.length, 2);
assert.equal(result[0].priority, 0);
assert.equal(result[0].status, 'open');
assert.equal(result[0].issue_type, 'task');
assert.equal(result[1].priority, 2);
});
test('parseIssuesJsonl skips malformed and blank lines', () => {
const input = [' ', '{bad json', JSON.stringify({ id: 'bb-3', title: 'Three' })].join('\n');
const result = parseIssuesJsonl(input);
assert.equal(result.length, 1);
assert.equal(result[0].id, 'bb-3');
});
test('parseIssuesJsonl filters tombstones by default', () => {
const input = [
JSON.stringify({ id: 'bb-4', title: 'Live', status: 'open' }),
JSON.stringify({ id: 'bb-5', title: 'Gone', status: 'tombstone' }),
].join('\n');
const result = parseIssuesJsonl(input);
assert.equal(result.length, 1);
assert.equal(result[0].id, 'bb-4');
});
test('parseIssuesJsonl can include tombstones when requested', () => {
const input = [
JSON.stringify({ id: 'bb-4', title: 'Live', status: 'open' }),
JSON.stringify({ id: 'bb-5', title: 'Gone', status: 'tombstone' }),
].join('\n');
const result = parseIssuesJsonl(input, { includeTombstones: true });
assert.equal(result.length, 2);
});
test('parseIssuesJsonl supports beads dependency schema with depends_on_id and parent-child', () => {
const input = JSON.stringify({
id: 'bb-6',
title: 'Dependency test',
dependencies: [
{ type: 'blocks', depends_on_id: 'bb-1' },
{ type: 'parent-child', depends_on_id: 'bb-epic' },
{ type: 'relates_to', target: 'bb-2' },
],
});
const result = parseIssuesJsonl(input);
assert.equal(result.length, 1);
assert.deepEqual(result[0].dependencies, [
{ type: 'blocks', target: 'bb-1' },
{ type: 'parent', target: 'bb-epic' },
{ type: 'relates_to', target: 'bb-2' },
]);
});
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseIssuesJsonl } from '../../src/lib/parser';
test('parseIssuesJsonl applies defaults and preserves priority 0', () => {
const input = [
JSON.stringify({ id: 'bb-1', title: 'One', priority: 0 }),
JSON.stringify({ id: 'bb-2', title: 'Two' }),
].join('\n');
const result = parseIssuesJsonl(input);
assert.equal(result.length, 2);
assert.equal(result[0].priority, 0);
assert.equal(result[0].status, 'open');
assert.equal(result[0].issue_type, 'task');
assert.equal(result[1].priority, 2);
});
test('parseIssuesJsonl skips malformed and blank lines', () => {
const input = [' ', '{bad json', JSON.stringify({ id: 'bb-3', title: 'Three' })].join('\n');
const result = parseIssuesJsonl(input);
assert.equal(result.length, 1);
assert.equal(result[0].id, 'bb-3');
});
test('parseIssuesJsonl filters tombstones by default', () => {
const input = [
JSON.stringify({ id: 'bb-4', title: 'Live', status: 'open' }),
JSON.stringify({ id: 'bb-5', title: 'Gone', status: 'tombstone' }),
].join('\n');
const result = parseIssuesJsonl(input);
assert.equal(result.length, 1);
assert.equal(result[0].id, 'bb-4');
});
test('parseIssuesJsonl can include tombstones when requested', () => {
const input = [
JSON.stringify({ id: 'bb-4', title: 'Live', status: 'open' }),
JSON.stringify({ id: 'bb-5', title: 'Gone', status: 'tombstone' }),
].join('\n');
const result = parseIssuesJsonl(input, { includeTombstones: true });
assert.equal(result.length, 2);
});
test('parseIssuesJsonl supports beads dependency schema with depends_on_id and parent-child', () => {
const input = JSON.stringify({
id: 'bb-6',
title: 'Dependency test',
dependencies: [
{ type: 'blocks', depends_on_id: 'bb-1' },
{ type: 'parent-child', depends_on_id: 'bb-epic' },
{ type: 'relates_to', target: 'bb-2' },
],
});
const result = parseIssuesJsonl(input);
assert.equal(result.length, 1);
assert.deepEqual(result[0].dependencies, [
{ type: 'blocks', target: 'bb-1' },
{ type: 'parent', target: 'bb-epic' },
{ type: 'relates_to', target: 'bb-2' },
]);
});

View file

@ -27,4 +27,4 @@ test('toDisplayPath renders forward slashes for UI readability', () => {
test('sameWindowsPath handles case/separator differences', () => {
assert.equal(sameWindowsPath('D:/Repos/One', 'd:\\repos\\one\\'), true);
});
});

View file

@ -1,15 +1,15 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildProjectContext } from '../../src/lib/project-context';
test('buildProjectContext derives normalized project identity', () => {
const project = buildProjectContext('C:/Repo/Project');
assert.equal(project.root, 'C:\\Repo\\Project');
assert.equal(project.key, 'c:\\repo\\project');
assert.equal(project.displayPath, 'C:/Repo/Project');
assert.equal(project.name, 'Project');
assert.equal(project.source, 'local');
assert.equal(project.addedAt, null);
});
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildProjectContext } from '../../src/lib/project-context';
test('buildProjectContext derives normalized project identity', () => {
const project = buildProjectContext('C:/Repo/Project');
assert.equal(project.root, 'C:\\Repo\\Project');
assert.equal(project.key, 'c:\\repo\\project');
assert.equal(project.displayPath, 'C:/Repo/Project');
assert.equal(project.name, 'Project');
assert.equal(project.source, 'local');
assert.equal(project.addedAt, null);
});

View file

@ -1,87 +1,87 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveProjectScope, type ProjectScopeRegistryEntry } from '../../src/lib/project-scope';
const REGISTRY: ProjectScopeRegistryEntry[] = [
{ path: 'D:/Repos/Alpha' },
{ path: 'D:/Repos/Beta' },
];
test('resolveProjectScope defaults to local when query key is missing', () => {
const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
registryProjects: REGISTRY,
});
assert.equal(scope.mode, 'single');
assert.equal(scope.selected.source, 'local');
assert.equal(scope.selected.root, 'C:\\Users\\Zenchant\\codex\\beadboard');
assert.equal(scope.selected.key, 'local');
assert.deepEqual(scope.readRoots, ['C:\\Users\\Zenchant\\codex\\beadboard']);
assert.equal(scope.options[0].key, 'local');
assert.equal(scope.options.length, 3);
});
test('resolveProjectScope selects registry project when key matches', () => {
const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
registryProjects: REGISTRY,
requestedProjectKey: 'd:\\repos\\beta',
});
assert.equal(scope.selected.source, 'registry');
assert.equal(scope.selected.root, 'D:\\Repos\\Beta');
assert.equal(scope.selected.key, 'd:\\repos\\beta');
assert.deepEqual(scope.readRoots, ['D:\\Repos\\Beta']);
});
test('resolveProjectScope falls back to local when query key is unknown', () => {
const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
registryProjects: REGISTRY,
requestedProjectKey: 'd:\\repos\\missing',
});
assert.equal(scope.selected.source, 'local');
assert.equal(scope.selected.key, 'local');
assert.deepEqual(scope.readRoots, ['C:\\Users\\Zenchant\\codex\\beadboard']);
});
test('resolveProjectScope deduplicates registry entries by normalized key', () => {
const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
registryProjects: [{ path: 'D:/Repos/Alpha/' }, { path: 'd:\\repos\\alpha' }],
});
assert.equal(scope.options.length, 2);
assert.equal(scope.options.filter((option) => option.source === 'registry').length, 1);
});
test('resolveProjectScope supports aggregate mode and reads all roots', () => {
const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
registryProjects: REGISTRY,
requestedProjectKey: 'd:\\repos\\alpha',
requestedMode: 'aggregate',
});
assert.equal(scope.mode, 'aggregate');
assert.equal(scope.selected.key, 'd:\\repos\\alpha');
assert.deepEqual(scope.readRoots, [
'C:\\Users\\Zenchant\\codex\\beadboard',
'D:\\Repos\\Alpha',
'D:\\Repos\\Beta',
]);
});
test('resolveProjectScope falls back to single mode for unknown mode values', () => {
const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
registryProjects: REGISTRY,
requestedMode: 'invalid-mode',
});
assert.equal(scope.mode, 'single');
assert.deepEqual(scope.readRoots, ['C:\\Users\\Zenchant\\codex\\beadboard']);
});
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveProjectScope, type ProjectScopeRegistryEntry } from '../../src/lib/project-scope';
const REGISTRY: ProjectScopeRegistryEntry[] = [
{ path: 'D:/Repos/Alpha' },
{ path: 'D:/Repos/Beta' },
];
test('resolveProjectScope defaults to local when query key is missing', () => {
const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
registryProjects: REGISTRY,
});
assert.equal(scope.mode, 'single');
assert.equal(scope.selected.source, 'local');
assert.equal(scope.selected.root, 'C:\\Users\\Zenchant\\codex\\beadboard');
assert.equal(scope.selected.key, 'local');
assert.deepEqual(scope.readRoots, ['C:\\Users\\Zenchant\\codex\\beadboard']);
assert.equal(scope.options[0].key, 'local');
assert.equal(scope.options.length, 3);
});
test('resolveProjectScope selects registry project when key matches', () => {
const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
registryProjects: REGISTRY,
requestedProjectKey: 'd:\\repos\\beta',
});
assert.equal(scope.selected.source, 'registry');
assert.equal(scope.selected.root, 'D:\\Repos\\Beta');
assert.equal(scope.selected.key, 'd:\\repos\\beta');
assert.deepEqual(scope.readRoots, ['D:\\Repos\\Beta']);
});
test('resolveProjectScope falls back to local when query key is unknown', () => {
const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
registryProjects: REGISTRY,
requestedProjectKey: 'd:\\repos\\missing',
});
assert.equal(scope.selected.source, 'local');
assert.equal(scope.selected.key, 'local');
assert.deepEqual(scope.readRoots, ['C:\\Users\\Zenchant\\codex\\beadboard']);
});
test('resolveProjectScope deduplicates registry entries by normalized key', () => {
const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
registryProjects: [{ path: 'D:/Repos/Alpha/' }, { path: 'd:\\repos\\alpha' }],
});
assert.equal(scope.options.length, 2);
assert.equal(scope.options.filter((option) => option.source === 'registry').length, 1);
});
test('resolveProjectScope supports aggregate mode and reads all roots', () => {
const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
registryProjects: REGISTRY,
requestedProjectKey: 'd:\\repos\\alpha',
requestedMode: 'aggregate',
});
assert.equal(scope.mode, 'aggregate');
assert.equal(scope.selected.key, 'd:\\repos\\alpha');
assert.deepEqual(scope.readRoots, [
'C:\\Users\\Zenchant\\codex\\beadboard',
'D:\\Repos\\Alpha',
'D:\\Repos\\Beta',
]);
});
test('resolveProjectScope falls back to single mode for unknown mode values', () => {
const scope = resolveProjectScope({
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
registryProjects: REGISTRY,
requestedMode: 'invalid-mode',
});
assert.equal(scope.mode, 'single');
assert.deepEqual(scope.readRoots, ['C:\\Users\\Zenchant\\codex\\beadboard']);
});

View file

@ -1,69 +1,69 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { readIssuesFromDisk, resolveIssuesJsonlPath, resolveIssuesJsonlPathCandidates } from '../../src/lib/read-issues';
import { canonicalizeWindowsPath, sameWindowsPath, toDisplayPath, windowsPathKey } from '../../src/lib/pathing';
test('resolveIssuesJsonlPath appends .beads/issues.jsonl using windows-safe pathing', () => {
const resolved = resolveIssuesJsonlPath('C:/Repo/Project');
assert.equal(sameWindowsPath(resolved, 'C:/Repo/Project/.beads/issues.jsonl'), true);
});
test('resolveIssuesJsonlPathCandidates includes .jsonl and .jsonl.new fallback paths', () => {
const [primary, fallback] = resolveIssuesJsonlPathCandidates('C:/Repo/Project');
assert.equal(sameWindowsPath(primary, 'C:/Repo/Project/.beads/issues.jsonl'), true);
assert.equal(sameWindowsPath(fallback, 'C:/Repo/Project/.beads/issues.jsonl.new'), true);
});
test('readIssuesFromDisk parses JSONL issues from disk', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-read-'));
const beadsDir = path.join(root, '.beads');
const issuesPath = path.join(beadsDir, 'issues.jsonl');
await fs.mkdir(beadsDir, { recursive: true });
await fs.writeFile(
issuesPath,
[
JSON.stringify({ id: 'bb-1', title: 'Open issue', status: 'open', priority: 0, issue_type: 'task' }),
JSON.stringify({ id: 'bb-2', title: 'Hidden tombstone', status: 'tombstone' }),
].join('\n'),
'utf8',
);
const issues = await readIssuesFromDisk({ projectRoot: root });
assert.equal(issues.length, 1);
assert.equal(issues[0].id, 'bb-1');
assert.equal(issues[0].priority, 0);
assert.equal(issues[0].project.root, canonicalizeWindowsPath(root));
assert.equal(issues[0].project.key, windowsPathKey(root));
assert.equal(issues[0].project.displayPath, toDisplayPath(root));
assert.equal(issues[0].project.name, path.basename(canonicalizeWindowsPath(root)));
assert.equal(issues[0].project.source, 'local');
assert.equal(issues[0].project.addedAt, null);
});
test('readIssuesFromDisk returns empty list when issues file does not exist', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-read-missing-'));
const issues = await readIssuesFromDisk({ projectRoot: root });
assert.deepEqual(issues, []);
});
test('readIssuesFromDisk falls back to issues.jsonl.new when issues.jsonl is missing', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-read-fallback-'));
const beadsDir = path.join(root, '.beads');
const fallbackPath = path.join(beadsDir, 'issues.jsonl.new');
await fs.mkdir(beadsDir, { recursive: true });
await fs.writeFile(
fallbackPath,
JSON.stringify({ id: 'bb-fallback', title: 'From fallback', status: 'open', priority: 2, issue_type: 'task' }),
'utf8',
);
const issues = await readIssuesFromDisk({ projectRoot: root });
assert.equal(issues.length, 1);
assert.equal(issues[0].id, 'bb-fallback');
});
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { readIssuesFromDisk, resolveIssuesJsonlPath, resolveIssuesJsonlPathCandidates } from '../../src/lib/read-issues';
import { canonicalizeWindowsPath, sameWindowsPath, toDisplayPath, windowsPathKey } from '../../src/lib/pathing';
test('resolveIssuesJsonlPath appends .beads/issues.jsonl using windows-safe pathing', () => {
const resolved = resolveIssuesJsonlPath('C:/Repo/Project');
assert.equal(sameWindowsPath(resolved, 'C:/Repo/Project/.beads/issues.jsonl'), true);
});
test('resolveIssuesJsonlPathCandidates includes .jsonl and .jsonl.new fallback paths', () => {
const [primary, fallback] = resolveIssuesJsonlPathCandidates('C:/Repo/Project');
assert.equal(sameWindowsPath(primary, 'C:/Repo/Project/.beads/issues.jsonl'), true);
assert.equal(sameWindowsPath(fallback, 'C:/Repo/Project/.beads/issues.jsonl.new'), true);
});
test('readIssuesFromDisk parses JSONL issues from disk', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-read-'));
const beadsDir = path.join(root, '.beads');
const issuesPath = path.join(beadsDir, 'issues.jsonl');
await fs.mkdir(beadsDir, { recursive: true });
await fs.writeFile(
issuesPath,
[
JSON.stringify({ id: 'bb-1', title: 'Open issue', status: 'open', priority: 0, issue_type: 'task' }),
JSON.stringify({ id: 'bb-2', title: 'Hidden tombstone', status: 'tombstone' }),
].join('\n'),
'utf8',
);
const issues = await readIssuesFromDisk({ projectRoot: root });
assert.equal(issues.length, 1);
assert.equal(issues[0].id, 'bb-1');
assert.equal(issues[0].priority, 0);
assert.equal(issues[0].project.root, canonicalizeWindowsPath(root));
assert.equal(issues[0].project.key, windowsPathKey(root));
assert.equal(issues[0].project.displayPath, toDisplayPath(root));
assert.equal(issues[0].project.name, path.basename(canonicalizeWindowsPath(root)));
assert.equal(issues[0].project.source, 'local');
assert.equal(issues[0].project.addedAt, null);
});
test('readIssuesFromDisk returns empty list when issues file does not exist', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-read-missing-'));
const issues = await readIssuesFromDisk({ projectRoot: root });
assert.deepEqual(issues, []);
});
test('readIssuesFromDisk falls back to issues.jsonl.new when issues.jsonl is missing', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-read-fallback-'));
const beadsDir = path.join(root, '.beads');
const fallbackPath = path.join(beadsDir, 'issues.jsonl.new');
await fs.mkdir(beadsDir, { recursive: true });
await fs.writeFile(
fallbackPath,
JSON.stringify({ id: 'bb-fallback', title: 'From fallback', status: 'open', priority: 2, issue_type: 'task' }),
'utf8',
);
const issues = await readIssuesFromDisk({ projectRoot: root });
assert.equal(issues.length, 1);
assert.equal(issues[0].id, 'bb-fallback');
});

View file

@ -1,27 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { readTextFileWithRetry } from '../../src/lib/read-text-retry';
test('readTextFileWithRetry reads file content', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-retry-read-'));
const target = path.join(root, 'sample.txt');
await fs.writeFile(target, 'ok', 'utf8');
const content = await readTextFileWithRetry(target);
assert.equal(content, 'ok');
});
test('readTextFileWithRetry does not retry non-retryable errors', async () => {
await assert.rejects(
() => readTextFileWithRetry('C:/definitely/missing/file.txt', { retries: 3, delayMs: 1 }),
(error: unknown) => {
const code = (error as NodeJS.ErrnoException).code;
assert.equal(code, 'ENOENT');
return true;
},
);
});
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { readTextFileWithRetry } from '../../src/lib/read-text-retry';
test('readTextFileWithRetry reads file content', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-retry-read-'));
const target = path.join(root, 'sample.txt');
await fs.writeFile(target, 'ok', 'utf8');
const content = await readTextFileWithRetry(target);
assert.equal(content, 'ok');
});
test('readTextFileWithRetry does not retry non-retryable errors', async () => {
await assert.rejects(
() => readTextFileWithRetry('C:/definitely/missing/file.txt', { retries: 3, delayMs: 1 }),
(error: unknown) => {
const code = (error as NodeJS.ErrnoException).code;
assert.equal(code, 'ENOENT');
return true;
},
);
});

View file

@ -1,57 +1,57 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { IssuesEventBus, toSseFrame } from '../../src/lib/realtime';
test('IssuesEventBus emits monotonically increasing IDs', () => {
const bus = new IssuesEventBus();
const seen: number[] = [];
const unsubscribe = bus.subscribe((event) => seen.push(event.id));
bus.emit('C:/Repo/One');
bus.emit('C:/Repo/One');
unsubscribe();
assert.deepEqual(seen, [1, 2]);
});
test('IssuesEventBus filters by project root', () => {
const bus = new IssuesEventBus();
const one: number[] = [];
const two: number[] = [];
const stopOne = bus.subscribe((event) => one.push(event.id), { projectRoot: 'C:/Repo/One' });
const stopTwo = bus.subscribe((event) => two.push(event.id), { projectRoot: 'D:/Repo/Two' });
bus.emit('c:\\repo\\one');
bus.emit('D:/Repo/Two');
stopOne();
stopTwo();
assert.deepEqual(one, [1]);
assert.deepEqual(two, [2]);
});
test('toSseFrame includes id, event name, and data payload', () => {
const frame = toSseFrame({
id: 9,
projectRoot: 'C:\\Repo\\One',
kind: 'changed',
at: '2026-02-12T01:00:00.000Z',
});
assert.equal(frame.includes('id: 9'), true);
assert.equal(frame.includes('event: issues'), true);
assert.equal(frame.includes('"projectRoot":"C:\\\\Repo\\\\One"'), true);
});
test('toSseFrame uses telemetry event name for telemetry kind', () => {
const frame = toSseFrame({
id: 42,
projectRoot: 'C:/Repo',
kind: 'telemetry',
at: new Date().toISOString(),
});
assert.ok(frame.includes('event: telemetry'), 'Should use telemetry event name');
assert.ok(frame.includes('id: 42'), 'Should preserve ID');
});
import test from 'node:test';
import assert from 'node:assert/strict';
import { IssuesEventBus, toSseFrame } from '../../src/lib/realtime';
test('IssuesEventBus emits monotonically increasing IDs', () => {
const bus = new IssuesEventBus();
const seen: number[] = [];
const unsubscribe = bus.subscribe((event) => seen.push(event.id));
bus.emit('C:/Repo/One');
bus.emit('C:/Repo/One');
unsubscribe();
assert.deepEqual(seen, [1, 2]);
});
test('IssuesEventBus filters by project root', () => {
const bus = new IssuesEventBus();
const one: number[] = [];
const two: number[] = [];
const stopOne = bus.subscribe((event) => one.push(event.id), { projectRoot: 'C:/Repo/One' });
const stopTwo = bus.subscribe((event) => two.push(event.id), { projectRoot: 'D:/Repo/Two' });
bus.emit('c:\\repo\\one');
bus.emit('D:/Repo/Two');
stopOne();
stopTwo();
assert.deepEqual(one, [1]);
assert.deepEqual(two, [2]);
});
test('toSseFrame includes id, event name, and data payload', () => {
const frame = toSseFrame({
id: 9,
projectRoot: 'C:\\Repo\\One',
kind: 'changed',
at: '2026-02-12T01:00:00.000Z',
});
assert.equal(frame.includes('id: 9'), true);
assert.equal(frame.includes('event: issues'), true);
assert.equal(frame.includes('"projectRoot":"C:\\\\Repo\\\\One"'), true);
});
test('toSseFrame uses telemetry event name for telemetry kind', () => {
const frame = toSseFrame({
id: 42,
projectRoot: 'C:/Repo',
kind: 'telemetry',
at: new Date().toISOString(),
});
assert.ok(frame.includes('event: telemetry'), 'Should use telemetry event name');
assert.ok(frame.includes('id: 42'), 'Should preserve ID');
});

View file

@ -1,86 +1,86 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
addProject,
listProjects,
removeProject,
registryFilePath,
type RegistryProject,
} from '../../src/lib/registry';
async function withTempUserProfile(run: (userProfile: string) => Promise<void>): Promise<void> {
const previous = process.env.USERPROFILE;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-registry-'));
process.env.USERPROFILE = tempDir;
try {
await run(tempDir);
} finally {
if (previous === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = previous;
}
await fs.rm(tempDir, { recursive: true, force: true });
}
}
test('registryFilePath resolves under %USERPROFILE%/.beadboard/projects.json', async () => {
await withTempUserProfile(async (userProfile) => {
const result = registryFilePath();
assert.equal(result, path.join(userProfile, '.beadboard', 'projects.json'));
});
});
test('listProjects returns empty when registry does not exist', async () => {
await withTempUserProfile(async () => {
const result = await listProjects();
assert.deepEqual(result, []);
});
});
test('addProject persists normalized path and deduplicates case/separators', async () => {
await withTempUserProfile(async () => {
const first = await addProject('c:/Work/Alpha/');
assert.equal(first.added, true);
const second = await addProject('C:\\work\\alpha');
assert.equal(second.added, false);
const listed = await listProjects();
assert.equal(listed.length, 1);
assert.equal(listed[0].path, 'C:/Work/Alpha');
const file = await fs.readFile(registryFilePath(), 'utf8');
const parsed = JSON.parse(file) as { projects: RegistryProject[] };
assert.equal(parsed.projects.length, 1);
});
});
test('removeProject removes matching normalized path', async () => {
await withTempUserProfile(async () => {
await addProject('D:/Repos/One');
await addProject('D:/Repos/Two');
const removed = await removeProject('d:\\repos\\one\\');
assert.equal(removed.removed, true);
const listed = await listProjects();
assert.deepEqual(
listed.map((project) => project.path),
['D:/Repos/Two'],
);
});
});
test('addProject rejects non-Windows absolute paths', async () => {
await withTempUserProfile(async () => {
await assert.rejects(() => addProject('/tmp/project'), /Windows absolute path/i);
await assert.rejects(() => addProject('relative/path'), /Windows absolute path/i);
});
});
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
addProject,
listProjects,
removeProject,
registryFilePath,
type RegistryProject,
} from '../../src/lib/registry';
async function withTempUserProfile(run: (userProfile: string) => Promise<void>): Promise<void> {
const previous = process.env.USERPROFILE;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-registry-'));
process.env.USERPROFILE = tempDir;
try {
await run(tempDir);
} finally {
if (previous === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = previous;
}
await fs.rm(tempDir, { recursive: true, force: true });
}
}
test('registryFilePath resolves under %USERPROFILE%/.beadboard/projects.json', async () => {
await withTempUserProfile(async (userProfile) => {
const result = registryFilePath();
assert.equal(result, path.join(userProfile, '.beadboard', 'projects.json'));
});
});
test('listProjects returns empty when registry does not exist', async () => {
await withTempUserProfile(async () => {
const result = await listProjects();
assert.deepEqual(result, []);
});
});
test('addProject persists normalized path and deduplicates case/separators', async () => {
await withTempUserProfile(async () => {
const first = await addProject('c:/Work/Alpha/');
assert.equal(first.added, true);
const second = await addProject('C:\\work\\alpha');
assert.equal(second.added, false);
const listed = await listProjects();
assert.equal(listed.length, 1);
assert.equal(listed[0].path, 'C:/Work/Alpha');
const file = await fs.readFile(registryFilePath(), 'utf8');
const parsed = JSON.parse(file) as { projects: RegistryProject[] };
assert.equal(parsed.projects.length, 1);
});
});
test('removeProject removes matching normalized path', async () => {
await withTempUserProfile(async () => {
await addProject('D:/Repos/One');
await addProject('D:/Repos/Two');
const removed = await removeProject('d:\\repos\\one\\');
assert.equal(removed.removed, true);
const listed = await listProjects();
assert.deepEqual(
listed.map((project) => project.path),
['D:/Repos/Two'],
);
});
});
test('addProject rejects non-Windows absolute paths', async () => {
await withTempUserProfile(async () => {
await assert.rejects(() => addProject('/tmp/project'), /Windows absolute path/i);
await assert.rejects(() => addProject('relative/path'), /Windows absolute path/i);
});
});

View file

@ -9,6 +9,6 @@ test('normalizeVersion supports semver and rejects empty', () => {
test('getRuntimePaths builds ~/.beadboard/runtime/<version> layout', () => {
const p = getRuntimePaths('/tmp/home', '1.2.3');
assert.match(p.runtimeRoot, /runtime\/1\.2\.3$/);
assert.match(p.shimDir, /\.beadboard\/bin$/);
assert.match(p.runtimeRoot, /[/\\]runtime[/\\]1\.2\.3$/);
assert.match(p.shimDir, /\.beadboard[/\\]bin$/);
});

View file

@ -1,139 +1,139 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { addProject } from '../../src/lib/registry';
import { scanForProjects, resolveScanRoots } from '../../src/lib/scanner';
import { canonicalizeWindowsPath, sameWindowsPath, windowsPathKey } from '../../src/lib/pathing';
async function withTempUserProfile(run: (userProfile: string) => Promise<void>): Promise<void> {
const previous = process.env.USERPROFILE;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-scan-'));
process.env.USERPROFILE = tempDir;
try {
await run(tempDir);
} finally {
if (previous === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = previous;
}
await fs.rm(tempDir, { recursive: true, force: true });
}
}
test('resolveScanRoots includes profile and registry roots by default', async () => {
await withTempUserProfile(async (userProfile) => {
const registryRoot = path.join(userProfile, 'Registered');
await fs.mkdir(registryRoot, { recursive: true });
await addProject(registryRoot);
const roots = await resolveScanRoots();
assert.equal(roots.some((root) => sameWindowsPath(root, userProfile)), true);
assert.equal(roots.some((root) => sameWindowsPath(root, registryRoot)), true);
assert.equal(roots.some((root) => windowsPathKey(root) === windowsPathKey('C:\\')), false);
});
});
test('resolveScanRoots includes full-drive roots only when requested', async () => {
await withTempUserProfile(async () => {
const roots = await resolveScanRoots({ mode: 'full-drive' });
assert.equal(roots.some((root) => windowsPathKey(root) === windowsPathKey('C:\\')), true);
});
});
test('scanForProjects respects depth limits and ignore list', async () => {
await withTempUserProfile(async (userProfile) => {
const projectRoot = path.join(userProfile, 'ProjectA');
await fs.mkdir(path.join(projectRoot, '.beads'), { recursive: true });
await fs.writeFile(
path.join(projectRoot, '.beads', 'issues.jsonl'),
JSON.stringify({ id: 'bb-a', title: 'A', issue_type: 'task', status: 'open', priority: 1 }),
'utf8',
);
const ignoredRoot = path.join(userProfile, 'node_modules', 'Ignored');
await fs.mkdir(path.join(ignoredRoot, '.beads'), { recursive: true });
await fs.writeFile(
path.join(ignoredRoot, '.beads', 'issues.jsonl'),
JSON.stringify({ id: 'bb-ignored', title: 'Ignored', issue_type: 'task', status: 'open', priority: 1 }),
'utf8',
);
const deepRoot = path.join(userProfile, 'Deep', 'Level1', 'Level2', 'ProjectDeep');
await fs.mkdir(path.join(deepRoot, '.beads'), { recursive: true });
await fs.writeFile(
path.join(deepRoot, '.beads', 'issues.jsonl'),
JSON.stringify({ id: 'bb-deep', title: 'Deep', issue_type: 'task', status: 'open', priority: 1 }),
'utf8',
);
const result = await scanForProjects({ maxDepth: 1 });
const keys = result.projects.map((project) => project.key);
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(projectRoot))), true);
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(ignoredRoot))), false);
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(deepRoot))), false);
});
});
test('scanForProjects ignores directories that have .beads but no issues JSONL files', async () => {
await withTempUserProfile(async (userProfile) => {
const falsePositiveRoot = path.join(userProfile, 'LooksLikeBeadsProject');
await fs.mkdir(path.join(falsePositiveRoot, '.beads'), { recursive: true });
const validRoot = path.join(userProfile, 'ValidProject');
await fs.mkdir(path.join(validRoot, '.beads'), { recursive: true });
await fs.writeFile(
path.join(validRoot, '.beads', 'issues.jsonl'),
JSON.stringify({ id: 'bb-1', title: 'valid', issue_type: 'task', status: 'open', priority: 1 }),
'utf8',
);
const result = await scanForProjects();
const keys = result.projects.map((project) => project.key);
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(falsePositiveRoot))), false);
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(validRoot))), true);
});
});
test('scanForProjects ignores tool/cache paths even if they contain issues JSONL', async () => {
await withTempUserProfile(async (userProfile) => {
const tempBeads = path.join(userProfile, 'AppData', 'Local', 'Temp', 'beadboard-read-X');
await fs.mkdir(path.join(tempBeads, '.beads'), { recursive: true });
await fs.writeFile(
path.join(tempBeads, '.beads', 'issues.jsonl'),
JSON.stringify({ id: 'bb-temp', title: 'temp', issue_type: 'task', status: 'open', priority: 1 }),
'utf8',
);
const skillsBeads = path.join(userProfile, '.agents', 'skills', 'create-beads-orchestration');
await fs.mkdir(path.join(skillsBeads, '.beads'), { recursive: true });
await fs.writeFile(
path.join(skillsBeads, '.beads', 'issues.jsonl'),
JSON.stringify({ id: 'bb-skill', title: 'skill', issue_type: 'task', status: 'open', priority: 1 }),
'utf8',
);
const realProject = path.join(userProfile, 'RealProject');
await fs.mkdir(path.join(realProject, '.beads'), { recursive: true });
await fs.writeFile(
path.join(realProject, '.beads', 'issues.jsonl'),
JSON.stringify({ id: 'bb-real', title: 'real', issue_type: 'task', status: 'open', priority: 1 }),
'utf8',
);
const result = await scanForProjects();
const keys = result.projects.map((project) => project.key);
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(tempBeads))), false);
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(skillsBeads))), false);
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(realProject))), true);
});
});
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { addProject } from '../../src/lib/registry';
import { scanForProjects, resolveScanRoots } from '../../src/lib/scanner';
import { canonicalizeWindowsPath, sameWindowsPath, windowsPathKey } from '../../src/lib/pathing';
async function withTempUserProfile(run: (userProfile: string) => Promise<void>): Promise<void> {
const previous = process.env.USERPROFILE;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-scan-'));
process.env.USERPROFILE = tempDir;
try {
await run(tempDir);
} finally {
if (previous === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = previous;
}
await fs.rm(tempDir, { recursive: true, force: true });
}
}
test('resolveScanRoots includes profile and registry roots by default', async () => {
await withTempUserProfile(async (userProfile) => {
const registryRoot = path.join(userProfile, 'Registered');
await fs.mkdir(registryRoot, { recursive: true });
await addProject(registryRoot);
const roots = await resolveScanRoots();
assert.equal(roots.some((root) => sameWindowsPath(root, userProfile)), true);
assert.equal(roots.some((root) => sameWindowsPath(root, registryRoot)), true);
assert.equal(roots.some((root) => windowsPathKey(root) === windowsPathKey('C:\\')), false);
});
});
test('resolveScanRoots includes full-drive roots only when requested', async () => {
await withTempUserProfile(async () => {
const roots = await resolveScanRoots({ mode: 'full-drive' });
assert.equal(roots.some((root) => windowsPathKey(root) === windowsPathKey('C:\\')), true);
});
});
test('scanForProjects respects depth limits and ignore list', async () => {
await withTempUserProfile(async (userProfile) => {
const projectRoot = path.join(userProfile, 'ProjectA');
await fs.mkdir(path.join(projectRoot, '.beads'), { recursive: true });
await fs.writeFile(
path.join(projectRoot, '.beads', 'issues.jsonl'),
JSON.stringify({ id: 'bb-a', title: 'A', issue_type: 'task', status: 'open', priority: 1 }),
'utf8',
);
const ignoredRoot = path.join(userProfile, 'node_modules', 'Ignored');
await fs.mkdir(path.join(ignoredRoot, '.beads'), { recursive: true });
await fs.writeFile(
path.join(ignoredRoot, '.beads', 'issues.jsonl'),
JSON.stringify({ id: 'bb-ignored', title: 'Ignored', issue_type: 'task', status: 'open', priority: 1 }),
'utf8',
);
const deepRoot = path.join(userProfile, 'Deep', 'Level1', 'Level2', 'ProjectDeep');
await fs.mkdir(path.join(deepRoot, '.beads'), { recursive: true });
await fs.writeFile(
path.join(deepRoot, '.beads', 'issues.jsonl'),
JSON.stringify({ id: 'bb-deep', title: 'Deep', issue_type: 'task', status: 'open', priority: 1 }),
'utf8',
);
const result = await scanForProjects({ maxDepth: 1 });
const keys = result.projects.map((project) => project.key);
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(projectRoot))), true);
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(ignoredRoot))), false);
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(deepRoot))), false);
});
});
test('scanForProjects ignores directories that have .beads but no issues JSONL files', async () => {
await withTempUserProfile(async (userProfile) => {
const falsePositiveRoot = path.join(userProfile, 'LooksLikeBeadsProject');
await fs.mkdir(path.join(falsePositiveRoot, '.beads'), { recursive: true });
const validRoot = path.join(userProfile, 'ValidProject');
await fs.mkdir(path.join(validRoot, '.beads'), { recursive: true });
await fs.writeFile(
path.join(validRoot, '.beads', 'issues.jsonl'),
JSON.stringify({ id: 'bb-1', title: 'valid', issue_type: 'task', status: 'open', priority: 1 }),
'utf8',
);
const result = await scanForProjects();
const keys = result.projects.map((project) => project.key);
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(falsePositiveRoot))), false);
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(validRoot))), true);
});
});
test('scanForProjects ignores tool/cache paths even if they contain issues JSONL', async () => {
await withTempUserProfile(async (userProfile) => {
const tempBeads = path.join(userProfile, 'AppData', 'Local', 'Temp', 'beadboard-read-X');
await fs.mkdir(path.join(tempBeads, '.beads'), { recursive: true });
await fs.writeFile(
path.join(tempBeads, '.beads', 'issues.jsonl'),
JSON.stringify({ id: 'bb-temp', title: 'temp', issue_type: 'task', status: 'open', priority: 1 }),
'utf8',
);
const skillsBeads = path.join(userProfile, '.agents', 'skills', 'create-beads-orchestration');
await fs.mkdir(path.join(skillsBeads, '.beads'), { recursive: true });
await fs.writeFile(
path.join(skillsBeads, '.beads', 'issues.jsonl'),
JSON.stringify({ id: 'bb-skill', title: 'skill', issue_type: 'task', status: 'open', priority: 1 }),
'utf8',
);
const realProject = path.join(userProfile, 'RealProject');
await fs.mkdir(path.join(realProject, '.beads'), { recursive: true });
await fs.writeFile(
path.join(realProject, '.beads', 'issues.jsonl'),
JSON.stringify({ id: 'bb-real', title: 'real', issue_type: 'task', status: 'open', priority: 1 }),
'utf8',
);
const result = await scanForProjects();
const keys = result.projects.map((project) => project.key);
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(tempBeads))), false);
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(skillsBeads))), false);
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(realProject))), true);
});
});

View file

@ -1,205 +1,205 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { execSync } from 'node:child_process';
import { IssuesEventBus, ActivityEventBus } from '../../src/lib/realtime';
import { IssuesWatchManager } from '../../src/lib/watcher';
test('IssuesWatchManager startWatch is idempotent per project', async () => {
const bus = new IssuesEventBus();
const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 20 });
await manager.startWatch('C:/Repo/One');
await manager.startWatch('c:\\repo\\one');
assert.equal(manager.getWatchedProjectCount(), 1);
await manager.stopAll();
});
test('IssuesWatchManager emits event after file change in watched .beads path', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-'));
const beadsDir = path.join(root, '.beads');
await fs.mkdir(beadsDir, { recursive: true });
// Initialize bd in temp dir
execSync('bd init --prefix bb --force', { cwd: root, stdio: 'ignore' });
const bus = new IssuesEventBus();
const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 40 });
const events: string[] = [];
const stop = bus.subscribe((event) => {
events.push(event.projectRoot);
});
await manager.startWatch(root);
// Wait for initial read to settle
await new Promise((resolve) => setTimeout(resolve, 100));
// Create issue via bd to trigger a valid mutation
execSync('bd create "Task watch" --id bb-1', { cwd: root, stdio: 'ignore' });
let found = false;
for (let i = 0; i < 10; i++) {
await new Promise((resolve) => setTimeout(resolve, 200));
if (events.length >= 1) {
found = true;
break;
}
}
stop();
await manager.stopAll();
assert.equal(found, true, 'Expected event from file change');
});
test('IssuesWatchManager emits telemetry event after beads.db change (not issues)', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-db-'));
const beadsDir = path.join(root, '.beads');
const dbPath = path.join(beadsDir, 'beads.db');
await fs.mkdir(beadsDir, { recursive: true });
// Initialize bd to create valid db
execSync('bd init --prefix bb --force', { cwd: root, stdio: 'ignore' });
execSync('bd create "Task A" --id bb-1', { cwd: root, stdio: 'ignore' });
const bus = new IssuesEventBus();
const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 40 });
const events: Array<{ kind: string; changedPath?: string }> = [];
const stop = bus.subscribe((event) => {
events.push({ kind: event.kind, changedPath: event.changedPath });
});
await manager.startWatch(root);
// Wait for initial read to settle
await new Promise((resolve) => setTimeout(resolve, 100));
// Touch beads.db directly without mutating issues to simulate a connection write/telemetry pulse
await fs.appendFile(dbPath, ' ', 'utf8');
for (let i = 0; i < 10; i++) {
await new Promise((resolve) => setTimeout(resolve, 200));
if (events.length >= 1) {
break;
}
}
stop();
await manager.stopAll();
// REGRESSION: beads.db should emit 'telemetry', not 'issues'
// This prevents the "typing interrupt" refresh loop during agent heartbeats
assert.equal(events.length >= 1, true, 'Expected at least one event');
const dbEvents = events.filter(e => e.changedPath?.includes('beads.db'));
assert.ok(dbEvents.length > 0, 'Expected beads.db change event');
for (const event of dbEvents) {
assert.equal(event.kind, 'telemetry', `beads.db change should emit 'telemetry', got '${event.kind}'. This prevents refresh loops during agent heartbeats.`);
}
});
test('IssuesWatchManager emits event after beads.db-wal change', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-wal-'));
const beadsDir = path.join(root, '.beads');
await fs.mkdir(beadsDir, { recursive: true });
// Initialize bd in temp dir
execSync('bd init --prefix bb --force', { cwd: root, stdio: 'ignore' });
// Initial state: 1 issue via bd
execSync('bd create "Task A" --id bb-1', { cwd: root, stdio: 'ignore' });
const bus = new IssuesEventBus();
const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 40 });
const events: string[] = [];
const stop = bus.subscribe((event) => {
events.push(event.projectRoot);
});
await manager.startWatch(root);
// Wait for initial read to settle
await new Promise((resolve) => setTimeout(resolve, 100));
// Modify issue via bd: status change. This updates beads.db-wal
execSync('bd update bb-1 --status in_progress', { cwd: root, stdio: 'ignore' });
let found = false;
for (let i = 0; i < 10; i++) {
await new Promise((resolve) => setTimeout(resolve, 200));
if (events.length >= 1) {
found = true;
break;
}
}
stop();
await manager.stopAll();
assert.equal(found, true, 'Expected event from db-wal change');
});
test('IssuesWatchManager emits ActivityEvent on issue change', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-activity-'));
const beadsDir = path.join(root, '.beads');
await fs.mkdir(beadsDir, { recursive: true });
// Initialize bd in temp dir
execSync('bd init --prefix bb --force', { cwd: root, stdio: 'ignore' });
// Initial state: 1 issue via bd
execSync('bd create "Task A" --id bb-1', { cwd: root, stdio: 'ignore' });
execSync('bd update bb-1 --status open', { cwd: root, stdio: 'ignore' });
const issuesBus = new IssuesEventBus();
const activityBus = new ActivityEventBus();
const manager = new IssuesWatchManager({
eventBus: issuesBus,
activityBus,
debounceMs: 50
});
const activities: string[] = [];
const stop = activityBus.subscribe((e) => {
activities.push(`${e.event.kind}:${e.event.beadId}`);
});
// Start watching (should load initial snapshot silently)
await manager.startWatch(root);
// Wait for initial read to settle
await new Promise((resolve) => setTimeout(resolve, 100));
// Modify issue via bd: status change
execSync('bd update bb-1 --status in_progress', { cwd: root, stdio: 'ignore' });
// Wait for debounce + processing with retry loop
let found = false;
for (let i = 0; i < 10; i++) {
await new Promise((resolve) => setTimeout(resolve, 200));
if (activities.includes('status_changed:bb-1')) {
found = true;
break;
}
}
stop();
await manager.stopAll();
// Expect status_changed for bb-1
if (!found) {
console.error('WATCHER FAIL. Activities found:', JSON.stringify(activities, null, 2));
}
assert.ok(found, `Expected status_changed event. Got: ${activities.join(', ')}`);
});
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { execSync } from 'node:child_process';
import { IssuesEventBus, ActivityEventBus } from '../../src/lib/realtime';
import { IssuesWatchManager } from '../../src/lib/watcher';
test('IssuesWatchManager startWatch is idempotent per project', async () => {
const bus = new IssuesEventBus();
const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 20 });
await manager.startWatch('C:/Repo/One');
await manager.startWatch('c:\\repo\\one');
assert.equal(manager.getWatchedProjectCount(), 1);
await manager.stopAll();
});
test('IssuesWatchManager emits event after file change in watched .beads path', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-'));
const beadsDir = path.join(root, '.beads');
await fs.mkdir(beadsDir, { recursive: true });
// Initialize bd in temp dir
execSync('bd init --prefix bb --force', { cwd: root, stdio: 'ignore' });
const bus = new IssuesEventBus();
const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 40 });
const events: string[] = [];
const stop = bus.subscribe((event) => {
events.push(event.projectRoot);
});
await manager.startWatch(root);
// Wait for initial read to settle
await new Promise((resolve) => setTimeout(resolve, 100));
// Create issue via bd to trigger a valid mutation
execSync('bd create "Task watch" --id bb-1', { cwd: root, stdio: 'ignore' });
let found = false;
for (let i = 0; i < 10; i++) {
await new Promise((resolve) => setTimeout(resolve, 200));
if (events.length >= 1) {
found = true;
break;
}
}
stop();
await manager.stopAll();
assert.equal(found, true, 'Expected event from file change');
});
test('IssuesWatchManager emits telemetry event after beads.db change (not issues)', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-db-'));
const beadsDir = path.join(root, '.beads');
const dbPath = path.join(beadsDir, 'beads.db');
await fs.mkdir(beadsDir, { recursive: true });
// Initialize bd to create valid db
execSync('bd init --prefix bb --force', { cwd: root, stdio: 'ignore' });
execSync('bd create "Task A" --id bb-1', { cwd: root, stdio: 'ignore' });
const bus = new IssuesEventBus();
const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 40 });
const events: Array<{ kind: string; changedPath?: string }> = [];
const stop = bus.subscribe((event) => {
events.push({ kind: event.kind, changedPath: event.changedPath });
});
await manager.startWatch(root);
// Wait for initial read to settle
await new Promise((resolve) => setTimeout(resolve, 100));
// Touch beads.db directly without mutating issues to simulate a connection write/telemetry pulse
await fs.appendFile(dbPath, ' ', 'utf8');
for (let i = 0; i < 10; i++) {
await new Promise((resolve) => setTimeout(resolve, 200));
if (events.length >= 1) {
break;
}
}
stop();
await manager.stopAll();
// REGRESSION: beads.db should emit 'telemetry', not 'issues'
// This prevents the "typing interrupt" refresh loop during agent heartbeats
assert.equal(events.length >= 1, true, 'Expected at least one event');
const dbEvents = events.filter(e => e.changedPath?.includes('beads.db'));
assert.ok(dbEvents.length > 0, 'Expected beads.db change event');
for (const event of dbEvents) {
assert.equal(event.kind, 'telemetry', `beads.db change should emit 'telemetry', got '${event.kind}'. This prevents refresh loops during agent heartbeats.`);
}
});
test('IssuesWatchManager emits event after beads.db-wal change', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-wal-'));
const beadsDir = path.join(root, '.beads');
await fs.mkdir(beadsDir, { recursive: true });
// Initialize bd in temp dir
execSync('bd init --prefix bb --force', { cwd: root, stdio: 'ignore' });
// Initial state: 1 issue via bd
execSync('bd create "Task A" --id bb-1', { cwd: root, stdio: 'ignore' });
const bus = new IssuesEventBus();
const manager = new IssuesWatchManager({ eventBus: bus, debounceMs: 40 });
const events: string[] = [];
const stop = bus.subscribe((event) => {
events.push(event.projectRoot);
});
await manager.startWatch(root);
// Wait for initial read to settle
await new Promise((resolve) => setTimeout(resolve, 100));
// Modify issue via bd: status change. This updates beads.db-wal
execSync('bd update bb-1 --status in_progress', { cwd: root, stdio: 'ignore' });
let found = false;
for (let i = 0; i < 10; i++) {
await new Promise((resolve) => setTimeout(resolve, 200));
if (events.length >= 1) {
found = true;
break;
}
}
stop();
await manager.stopAll();
assert.equal(found, true, 'Expected event from db-wal change');
});
test('IssuesWatchManager emits ActivityEvent on issue change', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-watch-activity-'));
const beadsDir = path.join(root, '.beads');
await fs.mkdir(beadsDir, { recursive: true });
// Initialize bd in temp dir
execSync('bd init --prefix bb --force', { cwd: root, stdio: 'ignore' });
// Initial state: 1 issue via bd
execSync('bd create "Task A" --id bb-1', { cwd: root, stdio: 'ignore' });
execSync('bd update bb-1 --status open', { cwd: root, stdio: 'ignore' });
const issuesBus = new IssuesEventBus();
const activityBus = new ActivityEventBus();
const manager = new IssuesWatchManager({
eventBus: issuesBus,
activityBus,
debounceMs: 50
});
const activities: string[] = [];
const stop = activityBus.subscribe((e) => {
activities.push(`${e.event.kind}:${e.event.beadId}`);
});
// Start watching (should load initial snapshot silently)
await manager.startWatch(root);
// Wait for initial read to settle
await new Promise((resolve) => setTimeout(resolve, 100));
// Modify issue via bd: status change
execSync('bd update bb-1 --status in_progress', { cwd: root, stdio: 'ignore' });
// Wait for debounce + processing with retry loop
let found = false;
for (let i = 0; i < 10; i++) {
await new Promise((resolve) => setTimeout(resolve, 200));
if (activities.includes('status_changed:bb-1')) {
found = true;
break;
}
}
stop();
await manager.stopAll();
// Expect status_changed for bb-1
if (!found) {
console.error('WATCHER FAIL. Activities found:', JSON.stringify(activities, null, 2));
}
assert.ok(found, `Expected status_changed event. Got: ${activities.join(', ')}`);
});

View file

@ -53,4 +53,4 @@ test('applyOptimisticStatus updates selected issue status and timestamps', () =>
assert.equal(updated[0].status, 'closed');
assert.equal(updated[0].closed_at, '2026-02-12T00:00:00Z');
assert.equal(updated[0].updated_at, '2026-02-12T00:00:00Z');
});
});

View file

@ -1,91 +1,91 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execSync } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { registerAgent } from '../../src/lib/agent-registry';
const projectRoot = path.resolve(__dirname, '../../');
const initScript = path.join(projectRoot, 'scripts', 'bb-init.mjs');
async function withTempRegistry(run: (tempDir: string) => Promise<void>): Promise<void> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bb-init-lease-'));
// Initialize a fake git repo first
execSync('git init', { cwd: tempDir, stdio: 'ignore' });
await fs.writeFile(path.join(tempDir, 'dummy'), 'data');
execSync('git add dummy && git commit -m "initial"', { cwd: tempDir, stdio: 'ignore' });
// Initialize bd rig with explicit prefix
execSync('bd init --prefix bb- --force', { cwd: tempDir, stdio: 'ignore' });
execSync('bd migrate --update-repo-id', { cwd: tempDir, stdio: 'ignore' });
// Create a dummy issue to force a flush
execSync('bd create --title "Warmup" --id bb-warmup', { cwd: tempDir, stdio: 'ignore' });
execSync('bd admin flush', { cwd: tempDir, stdio: 'ignore' });
try {
await run(tempDir);
} finally {
// Cleanup with retries for Windows
for (let i = 0; i < 5; i++) {
try {
await fs.rm(tempDir, { recursive: true, force: true });
break;
} catch {
await new Promise(r => setTimeout(r, 500));
}
}
}
}
test('REGISTRY: registerAgent includes rig fingerprint', async () => {
await withTempRegistry(async (projectRoot) => {
const agentId = 'direct-agent';
const rigId = 'test-rig-123';
const result = await registerAgent({
name: agentId,
role: 'tester',
rig: rigId
}, { projectRoot });
assert.equal(result.ok, true, `registerAgent failed: ${result.error?.message}`);
assert.equal(result.data?.rig, rigId);
// Verify persistence via bd list
const listOut = execSync('bd list --all --json', { cwd: projectRoot, encoding: 'utf8' });
const agents = JSON.parse(listOut);
const agentData = agents.find((a: { id: string }) => a.id.includes(agentId));
assert.ok(agentData, `Agent ${agentId} should exist in list`);
const rigLabel = agentData.labels?.find((l: string) => l.startsWith('rig:'));
assert.ok(rigLabel, `Rig fingerprint should be present in labels`);
assert.equal(rigLabel, `rig:${rigId}`);
});
});
test('FINGERPRINT: bb-init --register includes rig fingerprint', async () => {
await withTempRegistry(async (tempDir) => {
const agentId = 'fingerprint-agent';
const cmd = `node ${initScript} --register ${agentId} --role test --project-root ${tempDir} --json`;
execSync(cmd, {
cwd: tempDir,
env: { ...process.env, BB_REPO: projectRoot, BD_NO_DAEMON: 'false' }
});
// Verify Registry Entry exists via bd list
const listOut = execSync('bd list --all --json', { cwd: tempDir, encoding: 'utf8' });
const agents = JSON.parse(listOut);
const agentData = agents.find((a: { id: string }) => a.id.includes(agentId));
// Check for fingerprint fields
assert.ok(agentData, `Agent ${agentId} should exist in list`);
const rigLabel = agentData.labels?.find((l: string) => l.startsWith('rig:'));
assert.ok(rigLabel, 'Rig fingerprint should be present in labels');
const rigValue = rigLabel?.split(':')[1];
assert.ok(rigValue?.includes(os.platform()), `Rig ${rigValue} should include platform ${os.platform()}`);
});
import test from 'node:test';
import assert from 'node:assert/strict';
import { execSync } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import { registerAgent } from '../../src/lib/agent-registry';
const projectRoot = path.resolve(__dirname, '../../');
const initScript = path.join(projectRoot, 'scripts', 'bb-init.mjs');
async function withTempRegistry(run: (tempDir: string) => Promise<void>): Promise<void> {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bb-init-lease-'));
// Initialize a fake git repo first
execSync('git init', { cwd: tempDir, stdio: 'ignore' });
await fs.writeFile(path.join(tempDir, 'dummy'), 'data');
execSync('git add dummy && git commit -m "initial"', { cwd: tempDir, stdio: 'ignore' });
// Initialize bd rig with explicit prefix
execSync('bd init --prefix bb- --force', { cwd: tempDir, stdio: 'ignore' });
execSync('bd migrate --update-repo-id', { cwd: tempDir, stdio: 'ignore' });
// Create a dummy issue to force a flush
execSync('bd create --title "Warmup" --id bb-warmup', { cwd: tempDir, stdio: 'ignore' });
execSync('bd admin flush', { cwd: tempDir, stdio: 'ignore' });
try {
await run(tempDir);
} finally {
// Cleanup with retries for Windows
for (let i = 0; i < 5; i++) {
try {
await fs.rm(tempDir, { recursive: true, force: true });
break;
} catch {
await new Promise(r => setTimeout(r, 500));
}
}
}
}
test('REGISTRY: registerAgent includes rig fingerprint', async () => {
await withTempRegistry(async (projectRoot) => {
const agentId = 'direct-agent';
const rigId = 'test-rig-123';
const result = await registerAgent({
name: agentId,
role: 'tester',
rig: rigId
}, { projectRoot });
assert.equal(result.ok, true, `registerAgent failed: ${result.error?.message}`);
assert.equal(result.data?.rig, rigId);
// Verify persistence via bd list
const listOut = execSync('bd list --all --json', { cwd: projectRoot, encoding: 'utf8' });
const agents = JSON.parse(listOut);
const agentData = agents.find((a: { id: string }) => a.id.includes(agentId));
assert.ok(agentData, `Agent ${agentId} should exist in list`);
const rigLabel = agentData.labels?.find((l: string) => l.startsWith('rig:'));
assert.ok(rigLabel, `Rig fingerprint should be present in labels`);
assert.equal(rigLabel, `rig:${rigId}`);
});
});
test('FINGERPRINT: bb-init --register includes rig fingerprint', async () => {
await withTempRegistry(async (tempDir) => {
const agentId = 'fingerprint-agent';
const cmd = `node ${initScript} --register ${agentId} --role test --project-root ${tempDir} --json`;
execSync(cmd, {
cwd: tempDir,
env: { ...process.env, BB_REPO: projectRoot, BD_NO_DAEMON: 'false' }
});
// Verify Registry Entry exists via bd list
const listOut = execSync('bd list --all --json', { cwd: tempDir, encoding: 'utf8' });
const agents = JSON.parse(listOut);
const agentData = agents.find((a: { id: string }) => a.id.includes(agentId));
// Check for fingerprint fields
assert.ok(agentData, `Agent ${agentId} should exist in list`);
const rigLabel = agentData.labels?.find((l: string) => l.startsWith('rig:'));
assert.ok(rigLabel, 'Rig fingerprint should be present in labels');
const rigValue = rigLabel?.split(':')[1];
assert.ok(rigValue?.includes(os.platform()), `Rig ${rigValue} should include platform ${os.platform()}`);
});
});

View file

@ -22,4 +22,8 @@ test('status --json reports runtime root and install mode', async () => {
assert.ok('path' in payload.bd);
assert.ok(payload.bd.project);
assert.equal(typeof payload.bd.project.hasBeadsDir, 'boolean');
assert.ok(payload.bd.backend);
assert.equal(typeof payload.bd.backend.sqliteLegacyDb, 'boolean');
assert.equal(typeof payload.bd.backend.sqliteMigratedDb, 'boolean');
assert.equal(typeof payload.bd.backend.doltRepo, 'boolean');
});

View file

@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { spawnSync } from 'node:child_process';
import { promisify } from 'node:util';
import path from 'node:path';
@ -26,5 +27,15 @@ test('status text output includes runtime and bd diagnostics', async () => {
assert.match(stdout, /bd Path:/i);
assert.match(stdout, /Project CWD:/i);
assert.match(stdout, /\.beads Dir:/i);
assert.match(stdout, /\.beads DB:/i);
assert.match(stdout, /SQLite Legacy DB:/i);
assert.match(stdout, /SQLite Migrated DB:/i);
assert.match(stdout, /Dolt Repo:/i);
});
test('status text mode exits success even when runtime is down', () => {
const result = spawnSync(process.execPath, [launcherPath, 'status'], {
env: { ...process.env, BB_PORT: '65534' },
encoding: 'utf8',
});
assert.equal(result.status, 0);
});

View file

@ -0,0 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const scriptPath = path.resolve('skills/beadboard-driver/scripts/diagnose-env.mjs');
test('diagnose-env returns stable schema', async () => {
const { stdout } = await execFileAsync(process.execPath, [scriptPath], {
env: { ...process.env, PATH: '' },
});
const result = JSON.parse(stdout);
assert.equal(typeof result.ok, 'boolean');
assert.equal(typeof result.timestamp, 'string');
assert.equal(Array.isArray(result.findings), true);
assert.equal(Array.isArray(result.recommendations), true);
assert.equal(typeof result.environment?.cwd, 'string');
});

View file

@ -0,0 +1,69 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
const scriptPath = path.resolve('skills/beadboard-driver/scripts/heal-common-issues.mjs');
async function withTempDir(run: (root: string) => Promise<void>) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bb-skill-heal-repo-'));
try {
await run(root);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
}
test('heal-common-issues dry-run preserves git index.lock', async () => {
await withTempDir(async (root) => {
const lockDir = path.join(root, '.git');
const lockPath = path.join(lockDir, 'index.lock');
await fs.mkdir(lockDir, { recursive: true });
await fs.writeFile(lockPath, 'locked', 'utf8');
const { stdout } = await execFileAsync(process.execPath, [scriptPath, '--project-root', root], {
env: process.env,
});
const result = JSON.parse(stdout);
const lockStillExists = await fs
.access(lockPath)
.then(() => true)
.catch(() => false);
assert.equal(result.ok, true);
assert.equal(result.mode, 'dry-run');
assert.equal(lockStillExists, true);
});
});
test('heal-common-issues apply removes git index.lock when opted in', async () => {
await withTempDir(async (root) => {
const lockDir = path.join(root, '.git');
const lockPath = path.join(lockDir, 'index.lock');
await fs.mkdir(lockDir, { recursive: true });
await fs.writeFile(lockPath, 'locked', 'utf8');
const { stdout } = await execFileAsync(
process.execPath,
[scriptPath, '--project-root', root, '--apply', '--fix-git-index-lock'],
{
env: process.env,
},
);
const result = JSON.parse(stdout);
const lockStillExists = await fs
.access(lockPath)
.then(() => true)
.catch(() => false);
assert.equal(result.ok, true);
assert.equal(result.mode, 'apply');
assert.equal(lockStillExists, false);
});
});

View file

@ -47,4 +47,4 @@ const parseable: ParseableBeadIssue = {
if (!parseable.id || !parseable.title) {
throw new Error('invalid parseable issue contract');
}
}