chore: checkpoint before DAG views UX overhaul
This commit is contained in:
parent
5695125a75
commit
a03def1ca1
125 changed files with 40711 additions and 581 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, it } from 'node:test';
|
||||
|
||||
import assert from 'node:assert';
|
||||
// @ts-ignore
|
||||
import { expect, test as bunTest } from 'bun:test';
|
||||
import { expect, test as bunTest, describe, it } from 'bun:test';
|
||||
|
||||
describe('UnifiedShell Component Contract', () => {
|
||||
it('exports UnifiedShell component', async () => {
|
||||
|
|
@ -26,8 +26,7 @@ describe('UnifiedShell Component Contract', () => {
|
|||
});
|
||||
|
||||
bunTest('UnifiedShell handles swarm view conditionally', async () => {
|
||||
const mod = await import('../../src/components/shared/unified-shell');
|
||||
const _UnifiedShell = mod.UnifiedShell;
|
||||
await import('../../src/components/shared/unified-shell');
|
||||
|
||||
// Create a minimal mock state to just render the function
|
||||
// We mock out the hooks if we can, but since this is a Server Component or uses context, it might be tricky.
|
||||
|
|
|
|||
165
tests/lib/realtime-activity-sse.test.ts
Normal file
165
tests/lib/realtime-activity-sse.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* REGRESSION TEST: ActivityPanel SSE Data Parsing
|
||||
*
|
||||
* Bug: ActivityPanel was checking `data?.event` but SSE sends activity event directly.
|
||||
* The toActivitySseFrame function sends `data: ${JSON.stringify(event.event)}` which
|
||||
* means the parsed data IS the activity event, not wrapped in { event: {...} }.
|
||||
*
|
||||
* This test ensures:
|
||||
* 1. toActivitySseFrame produces the correct format
|
||||
* 2. ActivityPanel parsing logic matches the SSE frame format
|
||||
*/
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { ActivityEventBus, toActivitySseFrame } from '../../src/lib/realtime';
|
||||
import type { ActivityEvent } from '../../src/lib/activity';
|
||||
|
||||
function createTestActivityEvent(overrides: Partial<ActivityEvent> = {}): ActivityEvent {
|
||||
return {
|
||||
id: 'test-123',
|
||||
kind: 'created',
|
||||
beadId: 'bead-001',
|
||||
beadTitle: 'Test Bead',
|
||||
projectId: 'C:/test/project',
|
||||
projectName: 'test-project',
|
||||
timestamp: new Date().toISOString(),
|
||||
actor: 'test-user',
|
||||
payload: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('toActivitySseFrame sends activity event directly (not wrapped)', () => {
|
||||
const activityEvent = createTestActivityEvent();
|
||||
|
||||
const frame = toActivitySseFrame({ id: 1, event: activityEvent });
|
||||
|
||||
// Frame should have correct event type
|
||||
assert.ok(frame.includes('event: activity'), 'Frame should have event: activity');
|
||||
|
||||
// Frame should have correct ID
|
||||
assert.ok(frame.includes('id: 1'), 'Frame should have correct SSE ID');
|
||||
|
||||
// Parse the data field to verify structure
|
||||
const dataMatch = frame.match(/data: (.+)\n\n/);
|
||||
assert.ok(dataMatch, 'Frame should have data field');
|
||||
|
||||
const parsedData = JSON.parse(dataMatch![1]);
|
||||
|
||||
// CRITICAL: The parsed data IS the activity event directly
|
||||
// NOT wrapped in { event: {...} }
|
||||
assert.equal(parsedData.id, 'test-123', 'Parsed data should be the activity event itself');
|
||||
assert.equal(parsedData.kind, 'created', 'Parsed data should have kind');
|
||||
assert.equal(parsedData.beadId, 'bead-001', 'Parsed data should have beadId');
|
||||
assert.equal(parsedData.beadTitle, 'Test Bead', 'Parsed data should have beadTitle');
|
||||
|
||||
// CRITICAL: parsedData.event should be UNDEFINED
|
||||
assert.equal(parsedData.event, undefined, 'parsedData.event should NOT exist');
|
||||
});
|
||||
|
||||
test('ActivityPanel SSE handler should check data.beadId not data.event', () => {
|
||||
// Simulate what ActivityPanel receives
|
||||
const sseFrame = toActivitySseFrame({
|
||||
id: 1,
|
||||
event: createTestActivityEvent({
|
||||
id: 'act-001',
|
||||
kind: 'closed',
|
||||
beadId: 'bead-002',
|
||||
beadTitle: 'Closed Bead',
|
||||
payload: { from: 'open', to: 'closed', message: 'Done' },
|
||||
}),
|
||||
});
|
||||
|
||||
// Simulate ActivityPanel's parsing logic (CORRECT version)
|
||||
const dataMatch = sseFrame.match(/data: (.+)\n\n/);
|
||||
assert.ok(dataMatch, 'Frame should have data field');
|
||||
|
||||
const data = JSON.parse(dataMatch![1]);
|
||||
|
||||
// OLD BUG: if (data?.event) { ... } - this would be FALSE
|
||||
const oldBuggyCheck = data?.event;
|
||||
assert.equal(oldBuggyCheck, undefined, 'Old buggy check data?.event should be undefined');
|
||||
|
||||
// CORRECT: Check for activity event properties directly
|
||||
const correctCheck = data?.beadId;
|
||||
assert.ok(correctCheck, 'Correct check data?.beadId should exist');
|
||||
|
||||
// Verify the event can be used directly
|
||||
assert.equal(data.kind, 'closed', 'Should be able to access event properties directly');
|
||||
});
|
||||
|
||||
test('ActivityEventBus emits and serializes correctly', () => {
|
||||
const bus = new ActivityEventBus();
|
||||
let receivedFrame: string | null = null;
|
||||
|
||||
const unsubscribe = bus.subscribe((dispatched) => {
|
||||
receivedFrame = toActivitySseFrame(dispatched);
|
||||
});
|
||||
|
||||
bus.emit(createTestActivityEvent({
|
||||
id: 'emit-test',
|
||||
kind: 'status_changed',
|
||||
beadId: 'bead-003',
|
||||
beadTitle: 'Status Changed',
|
||||
payload: { from: 'open', to: 'in_progress' },
|
||||
}));
|
||||
|
||||
unsubscribe();
|
||||
|
||||
assert.ok(receivedFrame, 'Should have received frame');
|
||||
|
||||
// Parse and verify
|
||||
const frame = receivedFrame as string;
|
||||
const dataMatch = frame.match(/data: (.+)\n\n/);
|
||||
assert.ok(dataMatch, 'Frame should have data field');
|
||||
|
||||
const parsedData = JSON.parse(dataMatch![1]);
|
||||
|
||||
assert.equal(parsedData.kind, 'status_changed', 'Should have correct kind');
|
||||
assert.equal(parsedData.beadId, 'bead-003', 'Should have correct beadId');
|
||||
assert.equal(parsedData.event, undefined, 'Should NOT have nested event property');
|
||||
});
|
||||
|
||||
test('REGRESSION: ActivityPanel must NOT use data.event pattern', () => {
|
||||
/**
|
||||
* This test documents the exact bug that was fixed:
|
||||
*
|
||||
* BEFORE (BUG):
|
||||
* const data = JSON.parse(event.data);
|
||||
* if (data?.event) {
|
||||
* setActivities(prev => [data.event, ...prev]);
|
||||
* }
|
||||
*
|
||||
* AFTER (FIX):
|
||||
* const data = JSON.parse(event.data);
|
||||
* if (data?.beadId) {
|
||||
* setActivities(prev => [data, ...prev]);
|
||||
* }
|
||||
*
|
||||
* The bug caused ActivityPanel to never update because data.event
|
||||
* was always undefined (the event IS the data, not nested).
|
||||
*/
|
||||
|
||||
const sampleSseData = JSON.stringify(createTestActivityEvent({
|
||||
id: 'regression-test',
|
||||
kind: 'comment_added',
|
||||
beadId: 'bead-999',
|
||||
beadTitle: 'Regression Test Bead',
|
||||
actor: 'test-user',
|
||||
payload: { message: 'Test comment' },
|
||||
}));
|
||||
|
||||
const data = JSON.parse(sampleSseData);
|
||||
|
||||
// Document the bug: data.event does NOT exist
|
||||
assert.equal(data.event, undefined, 'BUG: data.event is undefined');
|
||||
|
||||
// Document the fix: check data.beadId instead
|
||||
assert.ok(data.beadId, 'FIX: data.beadId exists');
|
||||
|
||||
// The activity event to add is data itself, not data.event
|
||||
const activityToAdd = data; // NOT data.event
|
||||
assert.equal(activityToAdd.kind, 'comment_added', 'Activity is data directly');
|
||||
});
|
||||
|
|
@ -22,9 +22,11 @@ test('IssuesWatchManager startWatch is idempotent per project', async () => {
|
|||
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');
|
||||
const issuesPath = path.join(beadsDir, 'issues.jsonl');
|
||||
|
||||
await fs.mkdir(beadsDir, { recursive: true });
|
||||
await fs.writeFile(issuesPath, '', 'utf8');
|
||||
|
||||
// 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 });
|
||||
|
|
@ -36,21 +38,37 @@ test('IssuesWatchManager emits event after file change in watched .beads path',
|
|||
|
||||
await manager.startWatch(root);
|
||||
|
||||
await fs.writeFile(issuesPath, `${JSON.stringify({ id: 'bb-1', title: 'watch' })}\n`, 'utf8');
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
// 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(events.length >= 1, true);
|
||||
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 });
|
||||
await fs.writeFile(dbPath, 'seed', 'utf8');
|
||||
|
||||
// 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 });
|
||||
|
|
@ -62,8 +80,18 @@ test('IssuesWatchManager emits telemetry event after beads.db change (not issues
|
|||
|
||||
await manager.startWatch(root);
|
||||
|
||||
await fs.writeFile(dbPath, `seed-${Date.now()}`, 'utf8');
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
// 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();
|
||||
|
|
@ -81,9 +109,14 @@ test('IssuesWatchManager emits telemetry event after beads.db change (not issues
|
|||
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');
|
||||
const walPath = path.join(beadsDir, 'beads.db-wal');
|
||||
|
||||
await fs.mkdir(beadsDir, { recursive: true });
|
||||
await fs.writeFile(walPath, 'seed', 'utf8');
|
||||
|
||||
// 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 });
|
||||
|
|
@ -95,21 +128,33 @@ test('IssuesWatchManager emits event after beads.db-wal change', async () => {
|
|||
|
||||
await manager.startWatch(root);
|
||||
|
||||
await fs.writeFile(walPath, `seed-${Date.now()}`, 'utf8');
|
||||
await new Promise((resolve) => setTimeout(resolve, 220));
|
||||
// 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(events.length >= 1, true);
|
||||
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' });
|
||||
|
||||
|
|
@ -119,10 +164,10 @@ test('IssuesWatchManager emits ActivityEvent on issue change', async () => {
|
|||
|
||||
const issuesBus = new IssuesEventBus();
|
||||
const activityBus = new ActivityEventBus();
|
||||
const manager = new IssuesWatchManager({
|
||||
eventBus: issuesBus,
|
||||
activityBus,
|
||||
debounceMs: 50
|
||||
const manager = new IssuesWatchManager({
|
||||
eventBus: issuesBus,
|
||||
activityBus,
|
||||
debounceMs: 50
|
||||
});
|
||||
|
||||
const activities: string[] = [];
|
||||
|
|
@ -132,7 +177,7 @@ test('IssuesWatchManager emits ActivityEvent on issue change', async () => {
|
|||
|
||||
// Start watching (should load initial snapshot silently)
|
||||
await manager.startWatch(root);
|
||||
|
||||
|
||||
// Wait for initial read to settle
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,374 @@
|
|||
// @ts-ignore
|
||||
import { expect, test, describe } from 'bun:test';
|
||||
import { getArchetypes } from '../../src/lib/server/beads-fs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { getArchetypes, getTemplates, saveArchetype, deleteArchetype, saveTemplate, deleteTemplate, slugify } from '../../src/lib/server/beads-fs';
|
||||
|
||||
describe('beads-fs', () => {
|
||||
test('getArchetypes returns array of archetypes', async () => {
|
||||
const archetypes = await getArchetypes();
|
||||
expect(Array.isArray(archetypes)).toBe(true);
|
||||
});
|
||||
const ARCHE_DIR = path.join(process.cwd(), '.beads', 'archetypes');
|
||||
const TEMPLATE_DIR = path.join(process.cwd(), '.beads', 'templates');
|
||||
const TEST_ARCHETYPE_ID = 'test-custom-archetype';
|
||||
const TEST_TEMPLATE_ID = 'test-custom-template';
|
||||
|
||||
async function cleanupTestArchetype() {
|
||||
try {
|
||||
await fs.unlink(path.join(ARCHE_DIR, `${TEST_ARCHETYPE_ID}.json`));
|
||||
} catch {
|
||||
// File doesn't exist, ignore
|
||||
}
|
||||
}
|
||||
|
||||
test('getArchetypes returns array of archetypes', async () => {
|
||||
const archetypes = await getArchetypes();
|
||||
assert.ok(Array.isArray(archetypes));
|
||||
});
|
||||
|
||||
test('slugify: converts spaces to dashes', () => {
|
||||
assert.equal(slugify('System Architect'), 'system-architect');
|
||||
});
|
||||
|
||||
test('slugify: lowercases all characters', () => {
|
||||
assert.equal(slugify('My Custom Agent'), 'my-custom-agent');
|
||||
});
|
||||
|
||||
test('slugify: removes non-alphanumeric characters except dashes', () => {
|
||||
assert.equal(slugify('Agent!@#$%Name'), 'agentname');
|
||||
});
|
||||
|
||||
test('slugify: handles multiple consecutive spaces', () => {
|
||||
assert.equal(slugify('My Agent'), 'my-agent');
|
||||
});
|
||||
|
||||
test('slugify: handles leading and trailing spaces', () => {
|
||||
assert.equal(slugify(' My Agent '), 'my-agent');
|
||||
});
|
||||
|
||||
test('saveArchetype: creates new archetype with generated id from name', async () => {
|
||||
await cleanupTestArchetype();
|
||||
try {
|
||||
const archetype = await saveArchetype({
|
||||
name: 'Test Custom Archetype',
|
||||
description: 'A test archetype',
|
||||
systemPrompt: 'You are a test agent',
|
||||
capabilities: ['testing'],
|
||||
color: '#ff0000'
|
||||
});
|
||||
|
||||
assert.equal(archetype.id, 'test-custom-archetype');
|
||||
assert.equal(archetype.name, 'Test Custom Archetype');
|
||||
assert.equal(archetype.isBuiltIn, false);
|
||||
assert.ok(archetype.createdAt);
|
||||
assert.ok(archetype.updatedAt);
|
||||
} finally {
|
||||
await cleanupTestArchetype();
|
||||
}
|
||||
});
|
||||
|
||||
test('saveArchetype: creates new archetype with provided id', async () => {
|
||||
const customId = 'custom-id-123-test';
|
||||
try {
|
||||
const archetype = await saveArchetype({
|
||||
id: customId,
|
||||
name: 'My Agent',
|
||||
description: 'Description',
|
||||
systemPrompt: 'Prompt',
|
||||
capabilities: [],
|
||||
color: '#000000'
|
||||
});
|
||||
|
||||
assert.equal(archetype.id, customId);
|
||||
} finally {
|
||||
await fs.unlink(path.join(ARCHE_DIR, `${customId}.json`)).catch(() => { });
|
||||
}
|
||||
});
|
||||
|
||||
test('saveArchetype: updates existing archetype and preserves createdAt', async () => {
|
||||
await cleanupTestArchetype();
|
||||
try {
|
||||
const created = await saveArchetype({
|
||||
id: TEST_ARCHETYPE_ID,
|
||||
name: 'Test Agent',
|
||||
description: 'Original desc',
|
||||
systemPrompt: 'Original prompt',
|
||||
capabilities: [],
|
||||
color: '#000000'
|
||||
});
|
||||
|
||||
const originalCreatedAt = created.createdAt;
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
const updated = await saveArchetype({
|
||||
id: TEST_ARCHETYPE_ID,
|
||||
name: 'Test Agent Updated',
|
||||
description: 'New desc',
|
||||
systemPrompt: 'New prompt',
|
||||
capabilities: ['new-cap'],
|
||||
color: '#ffffff',
|
||||
createdAt: originalCreatedAt,
|
||||
isBuiltIn: false
|
||||
});
|
||||
|
||||
assert.equal(updated.createdAt, originalCreatedAt);
|
||||
assert.equal(updated.name, 'Test Agent Updated');
|
||||
assert.notEqual(updated.updatedAt, originalCreatedAt);
|
||||
} finally {
|
||||
await cleanupTestArchetype();
|
||||
}
|
||||
});
|
||||
|
||||
test('saveArchetype: preserves isBuiltIn status when editing built-in archetype', async () => {
|
||||
const archetypes = await getArchetypes();
|
||||
const builtIn = archetypes.find(a => a.isBuiltIn === true);
|
||||
|
||||
if (builtIn) {
|
||||
const updated = await saveArchetype({
|
||||
...builtIn,
|
||||
isBuiltIn: false // Try to mistakenly strip it
|
||||
});
|
||||
|
||||
assert.equal(updated.isBuiltIn, true, 'isBuiltIn should be preserved as true');
|
||||
|
||||
// Restore
|
||||
await saveArchetype(builtIn);
|
||||
}
|
||||
});
|
||||
|
||||
test('saveArchetype: writes file to archetypes directory', async () => {
|
||||
await cleanupTestArchetype();
|
||||
try {
|
||||
await saveArchetype({
|
||||
id: TEST_ARCHETYPE_ID,
|
||||
name: 'Test',
|
||||
description: 'Desc',
|
||||
systemPrompt: 'Prompt',
|
||||
capabilities: [],
|
||||
color: '#000000'
|
||||
});
|
||||
|
||||
const filePath = path.join(ARCHE_DIR, `${TEST_ARCHETYPE_ID}.json`);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
assert.equal(parsed.id, TEST_ARCHETYPE_ID);
|
||||
} finally {
|
||||
await cleanupTestArchetype();
|
||||
}
|
||||
});
|
||||
|
||||
test('deleteArchetype: deletes non-built-in archetype', async () => {
|
||||
await cleanupTestArchetype();
|
||||
try {
|
||||
await saveArchetype({
|
||||
id: TEST_ARCHETYPE_ID,
|
||||
name: 'Test',
|
||||
description: 'Desc',
|
||||
systemPrompt: 'Prompt',
|
||||
capabilities: [],
|
||||
color: '#000000'
|
||||
});
|
||||
|
||||
await deleteArchetype(TEST_ARCHETYPE_ID);
|
||||
|
||||
const archetypes = await getArchetypes();
|
||||
assert.equal(archetypes.find(a => a.id === TEST_ARCHETYPE_ID), undefined);
|
||||
} finally {
|
||||
await cleanupTestArchetype();
|
||||
}
|
||||
});
|
||||
|
||||
test('deleteArchetype: rejects deletion of built-in archetype', async () => {
|
||||
const archetypes = await getArchetypes();
|
||||
const builtIn = archetypes.find(a => a.isBuiltIn === true);
|
||||
|
||||
if (builtIn) {
|
||||
await assert.rejects(
|
||||
async () => await deleteArchetype(builtIn.id),
|
||||
/built-in/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('deleteArchetype: throws error for non-existent archetype', async () => {
|
||||
await assert.rejects(
|
||||
async () => await deleteArchetype('non-existent-archetype-xyz'),
|
||||
/not found/
|
||||
);
|
||||
});
|
||||
|
||||
async function cleanupTestTemplate() {
|
||||
try {
|
||||
await fs.unlink(path.join(TEMPLATE_DIR, `${TEST_TEMPLATE_ID}.json`));
|
||||
} catch {
|
||||
// File doesn't exist, ignore
|
||||
}
|
||||
try {
|
||||
await fs.unlink(path.join(TEMPLATE_DIR, 'auto-generated-id.json'));
|
||||
} catch { }
|
||||
}
|
||||
|
||||
test('getTemplates returns array of templates', async () => {
|
||||
const templates = await getTemplates();
|
||||
assert.ok(Array.isArray(templates));
|
||||
});
|
||||
|
||||
test('saveTemplate: creates new template with auto-generated id from name', async () => {
|
||||
await cleanupTestTemplate();
|
||||
try {
|
||||
const template = await saveTemplate({
|
||||
name: 'Auto Generated ID',
|
||||
description: 'Test description',
|
||||
team: [{ archetypeId: 'architect', count: 1 }]
|
||||
});
|
||||
|
||||
assert.equal(template.id, 'auto-generated-id');
|
||||
assert.equal(template.name, 'Auto Generated ID');
|
||||
assert.equal(template.isBuiltIn, false);
|
||||
assert.ok(template.createdAt);
|
||||
assert.ok(template.updatedAt);
|
||||
} finally {
|
||||
await cleanupTestTemplate();
|
||||
}
|
||||
});
|
||||
|
||||
test('saveTemplate: creates new template with provided id', async () => {
|
||||
await cleanupTestTemplate();
|
||||
try {
|
||||
const template = await saveTemplate({
|
||||
id: TEST_TEMPLATE_ID,
|
||||
name: 'Test Template',
|
||||
description: 'Test description',
|
||||
team: [{ archetypeId: 'architect', count: 1 }]
|
||||
});
|
||||
|
||||
assert.equal(template.id, TEST_TEMPLATE_ID);
|
||||
assert.equal(template.name, 'Test Template');
|
||||
assert.equal(template.isBuiltIn, false);
|
||||
|
||||
const filePath = path.join(TEMPLATE_DIR, `${TEST_TEMPLATE_ID}.json`);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
assert.equal(parsed.name, 'Test Template');
|
||||
} finally {
|
||||
await cleanupTestTemplate();
|
||||
}
|
||||
});
|
||||
|
||||
test('saveTemplate: updates existing template and preserves createdAt', async () => {
|
||||
await cleanupTestTemplate();
|
||||
try {
|
||||
const created = await saveTemplate({
|
||||
id: TEST_TEMPLATE_ID,
|
||||
name: 'Test Template',
|
||||
description: 'Test description',
|
||||
team: [{ archetypeId: 'architect', count: 1 }]
|
||||
});
|
||||
|
||||
const originalCreatedAt = created.createdAt;
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
const updated = await saveTemplate({
|
||||
id: TEST_TEMPLATE_ID,
|
||||
name: 'Updated Template',
|
||||
description: 'Updated description',
|
||||
team: [{ archetypeId: 'coder', count: 2 }],
|
||||
createdAt: originalCreatedAt,
|
||||
isBuiltIn: false
|
||||
});
|
||||
|
||||
assert.equal(updated.createdAt, originalCreatedAt);
|
||||
assert.equal(updated.name, 'Updated Template');
|
||||
assert.notEqual(updated.updatedAt, originalCreatedAt);
|
||||
} finally {
|
||||
await cleanupTestTemplate();
|
||||
}
|
||||
});
|
||||
|
||||
test('saveTemplate: preserves isBuiltIn status when editing built-in template', async () => {
|
||||
const templates = await getTemplates();
|
||||
const builtIn = templates.find(t => t.isBuiltIn === true);
|
||||
|
||||
if (builtIn) {
|
||||
const updated = await saveTemplate({
|
||||
...builtIn,
|
||||
isBuiltIn: false // Try to mistakenly strip it
|
||||
});
|
||||
|
||||
assert.equal(updated.isBuiltIn, true, 'isBuiltIn should be preserved as true');
|
||||
|
||||
// Restore
|
||||
await saveTemplate(builtIn);
|
||||
}
|
||||
});
|
||||
|
||||
test('saveTemplate: validates archetypeIds exist', async () => {
|
||||
await cleanupTestTemplate();
|
||||
try {
|
||||
await assert.rejects(
|
||||
async () => await saveTemplate({
|
||||
id: TEST_TEMPLATE_ID,
|
||||
name: 'Invalid Template',
|
||||
description: 'Test description',
|
||||
team: [{ archetypeId: 'non-existent-archetype', count: 1 }]
|
||||
}),
|
||||
/archetype/
|
||||
);
|
||||
} finally {
|
||||
await cleanupTestTemplate();
|
||||
}
|
||||
});
|
||||
|
||||
test('saveTemplate: preserves protoFormula when provided', async () => {
|
||||
await cleanupTestTemplate();
|
||||
try {
|
||||
const template = await saveTemplate({
|
||||
id: TEST_TEMPLATE_ID,
|
||||
name: 'Template with Formula',
|
||||
description: 'Test description',
|
||||
team: [{ archetypeId: 'architect', count: 1 }],
|
||||
protoFormula: 'architect:1,coder:2'
|
||||
});
|
||||
|
||||
assert.equal(template.protoFormula, 'architect:1,coder:2');
|
||||
} finally {
|
||||
await cleanupTestTemplate();
|
||||
}
|
||||
});
|
||||
|
||||
test('deleteTemplate: deletes non-built-in template', async () => {
|
||||
await cleanupTestTemplate();
|
||||
try {
|
||||
await saveTemplate({
|
||||
id: TEST_TEMPLATE_ID,
|
||||
name: 'Test Template',
|
||||
description: 'Desc',
|
||||
team: [{ archetypeId: 'architect', count: 1 }]
|
||||
});
|
||||
|
||||
await deleteTemplate(TEST_TEMPLATE_ID);
|
||||
|
||||
const templates = await getTemplates();
|
||||
assert.equal(templates.find(t => t.id === TEST_TEMPLATE_ID), undefined);
|
||||
} finally {
|
||||
await cleanupTestTemplate();
|
||||
}
|
||||
});
|
||||
|
||||
test('deleteTemplate: rejects deletion of built-in template', async () => {
|
||||
const templates = await getTemplates();
|
||||
const builtIn = templates.find(t => t.isBuiltIn === true);
|
||||
|
||||
if (builtIn) {
|
||||
await assert.rejects(
|
||||
async () => await deleteTemplate(builtIn.id),
|
||||
/built-in/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('deleteTemplate: throws error for non-existent template', async () => {
|
||||
await assert.rejects(
|
||||
async () => await deleteTemplate('non-existent-template-xyz'),
|
||||
/not found/
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue