checkpoint: pre-split branch cleanup
This commit is contained in:
parent
4c2ae2e5b7
commit
b5db7a7753
276 changed files with 35912 additions and 60119 deletions
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, []);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
84
tests/components/shared/left-panel-filtering.test.ts
Normal file
84
tests/components/shared/left-panel-filtering.test.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, []);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
14
tests/hooks/use-beads-subscription-contract.test.ts
Normal file
14
tests/hooks/use-beads-subscription-contract.test.ts
Normal 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');
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -175,4 +175,4 @@ test('stale reservation conflict and takeover behavior', async () => {
|
|||
assert.equal(wrongRelease.ok, false);
|
||||
assert.equal(wrongRelease.error?.code, 'RELEASE_FORBIDDEN');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
64
tests/lib/epic-graph.test.ts
Normal file
64
tests/lib/epic-graph.test.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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), []);
|
||||
|
|
|
|||
66
tests/lib/install-manifest.test.ts
Normal file
66
tests/lib/install-manifest.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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$/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(', ')}`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()}`);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
21
tests/skills/beadboard-driver/diagnose-env.test.ts
Normal file
21
tests/skills/beadboard-driver/diagnose-env.test.ts
Normal 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');
|
||||
});
|
||||
69
tests/skills/beadboard-driver/heal-common-issues.test.ts
Normal file
69
tests/skills/beadboard-driver/heal-common-issues.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -47,4 +47,4 @@ const parseable: ParseableBeadIssue = {
|
|||
|
||||
if (!parseable.id || !parseable.title) {
|
||||
throw new Error('invalid parseable issue contract');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue