diff --git a/src/resources/extensions/gsd/tests/post-mutation-hook.test.ts b/src/resources/extensions/gsd/tests/post-mutation-hook.test.ts new file mode 100644 index 000000000..929c62dad --- /dev/null +++ b/src/resources/extensions/gsd/tests/post-mutation-hook.test.ts @@ -0,0 +1,171 @@ +// GSD Extension — post-mutation hook regression tests +// Verifies that after a successful handleCompleteTask call, the post-mutation +// hook fires: event-log.jsonl and state-manifest.json are both written. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { openDatabase, closeDatabase } from '../gsd-db.ts'; +import { handleCompleteTask } from '../tools/complete-task.ts'; +import { readEvents } from '../workflow-events.ts'; +import { readManifest } from '../workflow-manifest.ts'; + +function tempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-post-hook-')); +} + +function cleanupDir(dirPath: string): void { + try { fs.rmSync(dirPath, { recursive: true, force: true }); } catch { /* best effort */ } +} + +/** Create a minimal project directory with a PLAN.md for complete-task to find. */ +function createProject(basePath: string): void { + const sliceDir = path.join(basePath, '.gsd', 'milestones', 'M001', 'slices', 'S01'); + const tasksDir = path.join(sliceDir, 'tasks'); + fs.mkdirSync(tasksDir, { recursive: true }); + fs.writeFileSync(path.join(sliceDir, 'S01-PLAN.md'), `# S01: Test Slice + +## Tasks + +- [ ] **T01: Test task** \`est:30m\` + - Do: Implement the thing + - Verify: Run tests + +- [ ] **T02: Second task** \`est:1h\` + - Do: Implement more + - Verify: Run more tests +`); +} + +function makeCompleteTaskParams() { + return { + taskId: 'T01', + sliceId: 'S01', + milestoneId: 'M001', + oneLiner: 'Implemented auth middleware', + narrative: 'Added JWT validation middleware with proper error handling.', + verification: 'Ran npm test — all tests pass.', + deviations: 'None.', + knownIssues: 'None.', + keyFiles: ['src/middleware/auth.ts'], + keyDecisions: [], + blockerDiscovered: false, + verificationEvidence: [ + { command: 'npm test', exitCode: 0, verdict: '✅ pass', durationMs: 2500 }, + ], + }; +} + +// ─── Post-mutation hook: event log ─────────────────────────────────────── + +test('post-mutation-hook: event-log.jsonl exists after handleCompleteTask', async () => { + const base = tempDir(); + const dbPath = path.join(base, 'test.db'); + openDatabase(dbPath); + createProject(base); + + try { + const result = await handleCompleteTask(makeCompleteTaskParams(), base); + assert.ok(!('error' in result), `handler should succeed, got: ${JSON.stringify(result)}`); + + const logPath = path.join(base, '.gsd', 'event-log.jsonl'); + assert.ok(fs.existsSync(logPath), 'event-log.jsonl should exist after handler completes'); + } finally { + closeDatabase(); + cleanupDir(base); + } +}); + +test('post-mutation-hook: event log contains complete-task event with correct params', async () => { + const base = tempDir(); + const dbPath = path.join(base, 'test.db'); + openDatabase(dbPath); + createProject(base); + + try { + await handleCompleteTask(makeCompleteTaskParams(), base); + + const logPath = path.join(base, '.gsd', 'event-log.jsonl'); + const events = readEvents(logPath); + assert.ok(events.length > 0, 'event log should have at least one event'); + + const ev = events.find((e) => e.cmd === 'complete-task'); + assert.ok(ev !== undefined, 'should have a complete-task event'); + assert.strictEqual((ev!.params as { milestoneId?: string }).milestoneId, 'M001'); + assert.strictEqual((ev!.params as { sliceId?: string }).sliceId, 'S01'); + assert.strictEqual((ev!.params as { taskId?: string }).taskId, 'T01'); + assert.strictEqual(ev!.actor, 'agent'); + } finally { + closeDatabase(); + cleanupDir(base); + } +}); + +// ─── Post-mutation hook: manifest ──────────────────────────────────────── + +test('post-mutation-hook: state-manifest.json exists after handleCompleteTask', async () => { + const base = tempDir(); + const dbPath = path.join(base, 'test.db'); + openDatabase(dbPath); + createProject(base); + + try { + const result = await handleCompleteTask(makeCompleteTaskParams(), base); + assert.ok(!('error' in result), `handler should succeed, got: ${JSON.stringify(result)}`); + + const manifestPath = path.join(base, '.gsd', 'state-manifest.json'); + assert.ok(fs.existsSync(manifestPath), 'state-manifest.json should exist after handler completes'); + } finally { + closeDatabase(); + cleanupDir(base); + } +}); + +test('post-mutation-hook: manifest has version 1 and includes completed task', async () => { + const base = tempDir(); + const dbPath = path.join(base, 'test.db'); + openDatabase(dbPath); + createProject(base); + + try { + await handleCompleteTask(makeCompleteTaskParams(), base); + + const manifest = readManifest(base); + assert.ok(manifest !== null, 'manifest should be readable'); + assert.strictEqual(manifest!.version, 1); + + const task = manifest!.tasks.find((t) => t.id === 'T01'); + assert.ok(task !== undefined, 'T01 should appear in manifest'); + assert.strictEqual(task!.status, 'complete'); + assert.strictEqual(task!.milestone_id, 'M001'); + assert.strictEqual(task!.slice_id, 'S01'); + } finally { + closeDatabase(); + cleanupDir(base); + } +}); + +// ─── Post-mutation hook: non-fatal on hook failure ─────────────────────── + +test('post-mutation-hook: handler still returns success even if projections dir is missing', async () => { + // basePath with NO .gsd directory — projections will fail to find milestones + // but handler should still return a result (not throw) + const base = tempDir(); + const dbPath = path.join(base, 'test.db'); + openDatabase(dbPath); + + // Create tasks dir but NO plan file (projections will soft-fail) + const tasksDir = path.join(base, '.gsd', 'milestones', 'M001', 'slices', 'S01', 'tasks'); + fs.mkdirSync(tasksDir, { recursive: true }); + + try { + const result = await handleCompleteTask(makeCompleteTaskParams(), base); + // Handler should succeed (post-hook failures are non-fatal) + assert.ok(!('error' in result), `handler should not propagate hook errors, got: ${JSON.stringify(result)}`); + } finally { + closeDatabase(); + cleanupDir(base); + } +}); diff --git a/src/resources/extensions/gsd/tests/sync-lock.test.ts b/src/resources/extensions/gsd/tests/sync-lock.test.ts new file mode 100644 index 000000000..038c6ccb6 --- /dev/null +++ b/src/resources/extensions/gsd/tests/sync-lock.test.ts @@ -0,0 +1,122 @@ +// GSD Extension — sync-lock unit tests +// Tests acquireSyncLock() and releaseSyncLock(). + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { acquireSyncLock, releaseSyncLock } from '../sync-lock.ts'; + +function tempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-sync-lock-')); +} + +function cleanupDir(dirPath: string): void { + try { fs.rmSync(dirPath, { recursive: true, force: true }); } catch { /* best effort */ } +} + +// ─── acquireSyncLock ───────────────────────────────────────────────────── + +test('sync-lock: acquireSyncLock returns { acquired: true } when no lock exists', () => { + const base = tempDir(); + fs.mkdirSync(path.join(base, '.gsd'), { recursive: true }); + try { + const result = acquireSyncLock(base); + assert.strictEqual(result.acquired, true); + } finally { + cleanupDir(base); + } +}); + +test('sync-lock: acquireSyncLock creates lock file at .gsd/sync.lock', () => { + const base = tempDir(); + fs.mkdirSync(path.join(base, '.gsd'), { recursive: true }); + try { + acquireSyncLock(base); + const lockPath = path.join(base, '.gsd', 'sync.lock'); + assert.ok(fs.existsSync(lockPath), 'sync.lock should exist after acquire'); + } finally { + cleanupDir(base); + } +}); + +test('sync-lock: lock file contains pid and acquired_at fields', () => { + const base = tempDir(); + fs.mkdirSync(path.join(base, '.gsd'), { recursive: true }); + try { + acquireSyncLock(base); + const lockPath = path.join(base, '.gsd', 'sync.lock'); + const content = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); + assert.strictEqual(typeof content.pid, 'number'); + assert.strictEqual(typeof content.acquired_at, 'string'); + } finally { + cleanupDir(base); + } +}); + +// ─── releaseSyncLock ───────────────────────────────────────────────────── + +test('sync-lock: releaseSyncLock removes lock file', () => { + const base = tempDir(); + fs.mkdirSync(path.join(base, '.gsd'), { recursive: true }); + try { + acquireSyncLock(base); + const lockPath = path.join(base, '.gsd', 'sync.lock'); + assert.ok(fs.existsSync(lockPath), 'lock file should exist before release'); + releaseSyncLock(base); + assert.ok(!fs.existsSync(lockPath), 'lock file should not exist after release'); + } finally { + cleanupDir(base); + } +}); + +test('sync-lock: releaseSyncLock is a no-op when no lock file exists', () => { + const base = tempDir(); + fs.mkdirSync(path.join(base, '.gsd'), { recursive: true }); + try { + // Should not throw + releaseSyncLock(base); + } finally { + cleanupDir(base); + } +}); + +// ─── acquire → release → re-acquire round-trip ─────────────────────────── + +test('sync-lock: can re-acquire after release', () => { + const base = tempDir(); + fs.mkdirSync(path.join(base, '.gsd'), { recursive: true }); + try { + const r1 = acquireSyncLock(base); + assert.strictEqual(r1.acquired, true, 'first acquire should succeed'); + releaseSyncLock(base); + const r2 = acquireSyncLock(base); + assert.strictEqual(r2.acquired, true, 're-acquire after release should succeed'); + releaseSyncLock(base); + } finally { + cleanupDir(base); + } +}); + +// ─── stale lock override ───────────────────────────────────────────────── + +test('sync-lock: overrides stale lock file (mtime backdated)', (t) => { + const base = tempDir(); + fs.mkdirSync(path.join(base, '.gsd'), { recursive: true }); + const lockPath = path.join(base, '.gsd', 'sync.lock'); + try { + // Write a lock file with a very old mtime (simulating staleness) + fs.writeFileSync(lockPath, JSON.stringify({ pid: 99999, acquired_at: new Date(0).toISOString() })); + // Backdate mtime by 2 minutes + const staleTime = new Date(Date.now() - 120_000); + fs.utimesSync(lockPath, staleTime, staleTime); + + // Should override stale lock and acquire + const result = acquireSyncLock(base, 500); + assert.strictEqual(result.acquired, true, 'should acquire over stale lock'); + releaseSyncLock(base); + } finally { + cleanupDir(base); + } +}); diff --git a/src/resources/extensions/gsd/tests/workflow-events.test.ts b/src/resources/extensions/gsd/tests/workflow-events.test.ts new file mode 100644 index 000000000..ee3f7f9ec --- /dev/null +++ b/src/resources/extensions/gsd/tests/workflow-events.test.ts @@ -0,0 +1,205 @@ +// GSD Extension — workflow-events unit tests +// Tests appendEvent, readEvents, findForkPoint, compactMilestoneEvents. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + appendEvent, + readEvents, + findForkPoint, + compactMilestoneEvents, + type WorkflowEvent, +} from '../workflow-events.ts'; + +function tempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-events-')); +} + +function cleanupDir(dirPath: string): void { + try { fs.rmSync(dirPath, { recursive: true, force: true }); } catch { /* best effort */ } +} + +function makeEvent(cmd: string, params: Record = {}): Omit { + return { cmd, params, ts: new Date().toISOString(), actor: 'agent' }; +} + +// ─── appendEvent ───────────────────────────────────────────────────────── + +test('workflow-events: appendEvent creates .gsd dir and event-log.jsonl', () => { + const base = tempDir(); + try { + appendEvent(base, makeEvent('complete-task', { milestoneId: 'M001', taskId: 'T01' })); + assert.ok(fs.existsSync(path.join(base, '.gsd', 'event-log.jsonl'))); + } finally { + cleanupDir(base); + } +}); + +test('workflow-events: appendEvent writes valid JSON line', () => { + const base = tempDir(); + try { + appendEvent(base, makeEvent('complete-task', { milestoneId: 'M001', taskId: 'T01' })); + const content = fs.readFileSync(path.join(base, '.gsd', 'event-log.jsonl'), 'utf-8'); + const lines = content.trim().split('\n'); + assert.strictEqual(lines.length, 1); + const parsed = JSON.parse(lines[0]!) as WorkflowEvent; + assert.strictEqual(parsed.cmd, 'complete-task'); + assert.strictEqual(parsed.actor, 'agent'); + assert.strictEqual(typeof parsed.hash, 'string'); + assert.strictEqual(parsed.hash.length, 16); + } finally { + cleanupDir(base); + } +}); + +test('workflow-events: appendEvent appends multiple events', () => { + const base = tempDir(); + try { + appendEvent(base, makeEvent('complete-task', { taskId: 'T01' })); + appendEvent(base, makeEvent('complete-slice', { sliceId: 'S01' })); + const events = readEvents(path.join(base, '.gsd', 'event-log.jsonl')); + assert.strictEqual(events.length, 2); + assert.strictEqual(events[0]!.cmd, 'complete-task'); + assert.strictEqual(events[1]!.cmd, 'complete-slice'); + } finally { + cleanupDir(base); + } +}); + +test('workflow-events: same cmd+params → same hash (deterministic)', () => { + const base = tempDir(); + try { + appendEvent(base, makeEvent('plan-task', { milestoneId: 'M001', sliceId: 'S01' })); + appendEvent(base, makeEvent('plan-task', { milestoneId: 'M001', sliceId: 'S01' })); + const events = readEvents(path.join(base, '.gsd', 'event-log.jsonl')); + assert.strictEqual(events[0]!.hash, events[1]!.hash, 'identical cmd+params produce identical hash'); + } finally { + cleanupDir(base); + } +}); + +test('workflow-events: different params → different hash', () => { + const base = tempDir(); + try { + appendEvent(base, makeEvent('complete-task', { taskId: 'T01' })); + appendEvent(base, makeEvent('complete-task', { taskId: 'T02' })); + const events = readEvents(path.join(base, '.gsd', 'event-log.jsonl')); + assert.notStrictEqual(events[0]!.hash, events[1]!.hash, 'different params produce different hash'); + } finally { + cleanupDir(base); + } +}); + +// ─── readEvents ────────────────────────────────────────────────────────── + +test('workflow-events: readEvents returns [] for non-existent file', () => { + const result = readEvents('/nonexistent/path/event-log.jsonl'); + assert.deepStrictEqual(result, []); +}); + +test('workflow-events: readEvents skips corrupted lines', () => { + const base = tempDir(); + try { + fs.mkdirSync(path.join(base, '.gsd'), { recursive: true }); + const logPath = path.join(base, '.gsd', 'event-log.jsonl'); + // Write a valid line, a corrupted line, and another valid line + fs.writeFileSync(logPath, + '{"cmd":"complete-task","params":{},"ts":"2026-01-01T00:00:00Z","hash":"abcd1234abcd1234","actor":"agent"}\n' + + 'NOT VALID JSON {{{{\n' + + '{"cmd":"plan-task","params":{},"ts":"2026-01-01T00:00:01Z","hash":"1234abcd1234abcd","actor":"system"}\n', + ); + const events = readEvents(logPath); + assert.strictEqual(events.length, 2, 'should return 2 valid events, skipping the corrupted line'); + assert.strictEqual(events[0]!.cmd, 'complete-task'); + assert.strictEqual(events[1]!.cmd, 'plan-task'); + } finally { + cleanupDir(base); + } +}); + +// ─── findForkPoint ─────────────────────────────────────────────────────── + +test('workflow-events: findForkPoint returns -1 for two empty logs', () => { + assert.strictEqual(findForkPoint([], []), -1); +}); + +test('workflow-events: findForkPoint returns -1 when first events differ', () => { + const e1 = { cmd: 'a', params: {}, ts: '', hash: 'hash1', actor: 'agent' } as WorkflowEvent; + const e2 = { cmd: 'b', params: {}, ts: '', hash: 'hash2', actor: 'agent' } as WorkflowEvent; + assert.strictEqual(findForkPoint([e1], [e2]), -1); +}); + +test('workflow-events: findForkPoint returns 0 when only first event is common', () => { + const common = { cmd: 'a', params: {}, ts: '', hash: 'hash1', actor: 'agent' } as WorkflowEvent; + const eA = { cmd: 'b', params: {}, ts: '', hash: 'hash2', actor: 'agent' } as WorkflowEvent; + const eB = { cmd: 'c', params: {}, ts: '', hash: 'hash3', actor: 'agent' } as WorkflowEvent; + // logA: [common, eA], logB: [common, eB] + assert.strictEqual(findForkPoint([common, eA], [common, eB]), 0); +}); + +test('workflow-events: findForkPoint returns last common index for prefix relationship', () => { + const e1 = { cmd: 'a', params: {}, ts: '', hash: 'h1', actor: 'agent' } as WorkflowEvent; + const e2 = { cmd: 'b', params: {}, ts: '', hash: 'h2', actor: 'agent' } as WorkflowEvent; + const e3 = { cmd: 'c', params: {}, ts: '', hash: 'h3', actor: 'agent' } as WorkflowEvent; + // logA is a prefix of logB → fork point is last index of logA + assert.strictEqual(findForkPoint([e1, e2], [e1, e2, e3]), 1); +}); + +test('workflow-events: findForkPoint handles equal logs', () => { + const e1 = { cmd: 'a', params: {}, ts: '', hash: 'h1', actor: 'agent' } as WorkflowEvent; + const e2 = { cmd: 'b', params: {}, ts: '', hash: 'h2', actor: 'agent' } as WorkflowEvent; + assert.strictEqual(findForkPoint([e1, e2], [e1, e2]), 1); +}); + +// ─── compactMilestoneEvents ────────────────────────────────────────────── + +test('workflow-events: compactMilestoneEvents returns { archived: 0 } when no matching events', () => { + const base = tempDir(); + try { + appendEvent(base, makeEvent('complete-task', { milestoneId: 'M002', taskId: 'T01' })); + const result = compactMilestoneEvents(base, 'M001'); + assert.strictEqual(result.archived, 0); + } finally { + cleanupDir(base); + } +}); + +test('workflow-events: compactMilestoneEvents archives milestone events', () => { + const base = tempDir(); + try { + appendEvent(base, makeEvent('complete-task', { milestoneId: 'M001', taskId: 'T01' })); + appendEvent(base, makeEvent('complete-task', { milestoneId: 'M001', taskId: 'T02' })); + appendEvent(base, makeEvent('complete-task', { milestoneId: 'M002', taskId: 'T03' })); + + const result = compactMilestoneEvents(base, 'M001'); + assert.strictEqual(result.archived, 2, 'should archive 2 M001 events'); + + // Archive file should exist + const archivePath = path.join(base, '.gsd', 'event-log-M001.jsonl.archived'); + assert.ok(fs.existsSync(archivePath), 'archive file should exist'); + const archived = readEvents(archivePath); + assert.strictEqual(archived.length, 2, 'archive file should have 2 events'); + + // Active log should retain only M002 event + const active = readEvents(path.join(base, '.gsd', 'event-log.jsonl')); + assert.strictEqual(active.length, 1, 'active log should have 1 remaining event'); + assert.strictEqual((active[0]!.params as { milestoneId?: string }).milestoneId, 'M002'); + } finally { + cleanupDir(base); + } +}); + +test('workflow-events: compactMilestoneEvents empties active log when all events are from milestone', () => { + const base = tempDir(); + try { + appendEvent(base, makeEvent('complete-task', { milestoneId: 'M001', taskId: 'T01' })); + compactMilestoneEvents(base, 'M001'); + const active = readEvents(path.join(base, '.gsd', 'event-log.jsonl')); + assert.strictEqual(active.length, 0, 'active log should be empty after full compact'); + } finally { + cleanupDir(base); + } +}); diff --git a/src/resources/extensions/gsd/tests/workflow-manifest.test.ts b/src/resources/extensions/gsd/tests/workflow-manifest.test.ts new file mode 100644 index 000000000..fa0618cbb --- /dev/null +++ b/src/resources/extensions/gsd/tests/workflow-manifest.test.ts @@ -0,0 +1,186 @@ +// GSD Extension — workflow-manifest unit tests +// Tests writeManifest, readManifest, snapshotState, bootstrapFromManifest. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + openDatabase, + closeDatabase, + insertMilestone, + insertSlice, + insertTask, +} from '../gsd-db.ts'; +import { + writeManifest, + readManifest, + snapshotState, + bootstrapFromManifest, +} from '../workflow-manifest.ts'; + +function tempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-manifest-')); +} + +function tempDbPath(base: string): string { + return path.join(base, 'test.db'); +} + +function cleanupDir(dirPath: string): void { + try { fs.rmSync(dirPath, { recursive: true, force: true }); } catch { /* best effort */ } +} + +// ─── readManifest: no file ──────────────────────────────────────────────── + +test('workflow-manifest: readManifest returns null when file does not exist', () => { + const base = tempDir(); + try { + const result = readManifest(base); + assert.strictEqual(result, null); + } finally { + cleanupDir(base); + } +}); + +// ─── writeManifest + readManifest round-trip ───────────────────────────── + +test('workflow-manifest: writeManifest creates state-manifest.json with version 1', () => { + const base = tempDir(); + openDatabase(tempDbPath(base)); + try { + writeManifest(base); + const manifestPath = path.join(base, '.gsd', 'state-manifest.json'); + assert.ok(fs.existsSync(manifestPath), 'state-manifest.json should exist'); + const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + assert.strictEqual(raw.version, 1); + } finally { + closeDatabase(); + cleanupDir(base); + } +}); + +test('workflow-manifest: readManifest parses manifest written by writeManifest', () => { + const base = tempDir(); + openDatabase(tempDbPath(base)); + try { + writeManifest(base); + const manifest = readManifest(base); + assert.ok(manifest !== null); + assert.strictEqual(manifest!.version, 1); + assert.ok(typeof manifest!.exported_at === 'string'); + assert.ok(Array.isArray(manifest!.milestones)); + assert.ok(Array.isArray(manifest!.slices)); + assert.ok(Array.isArray(manifest!.tasks)); + assert.ok(Array.isArray(manifest!.decisions)); + assert.ok(Array.isArray(manifest!.verification_evidence)); + } finally { + closeDatabase(); + cleanupDir(base); + } +}); + +// ─── snapshotState: captures DB rows ───────────────────────────────────── + +test('workflow-manifest: snapshotState includes inserted milestone', () => { + const base = tempDir(); + openDatabase(tempDbPath(base)); + try { + insertMilestone({ id: 'M001', title: 'Auth Milestone' }); + const snap = snapshotState(); + assert.strictEqual(snap.version, 1); + const m = snap.milestones.find((r) => r.id === 'M001'); + assert.ok(m !== undefined, 'M001 should appear in snapshot'); + assert.strictEqual(m!.title, 'Auth Milestone'); + } finally { + closeDatabase(); + cleanupDir(base); + } +}); + +test('workflow-manifest: snapshotState captures tasks', () => { + const base = tempDir(); + openDatabase(tempDbPath(base)); + try { + insertMilestone({ id: 'M001' }); + insertSlice({ id: 'S01', milestoneId: 'M001' }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Do thing', status: 'complete' }); + const snap = snapshotState(); + const t = snap.tasks.find((r) => r.id === 'T01'); + assert.ok(t !== undefined, 'T01 should appear in snapshot'); + assert.strictEqual(t!.status, 'complete'); + } finally { + closeDatabase(); + cleanupDir(base); + } +}); + +// ─── bootstrapFromManifest ──────────────────────────────────────────────── + +test('workflow-manifest: bootstrapFromManifest returns false when no manifest file', () => { + const base = tempDir(); + openDatabase(tempDbPath(base)); + try { + const result = bootstrapFromManifest(base); + assert.strictEqual(result, false); + } finally { + closeDatabase(); + cleanupDir(base); + } +}); + +test('workflow-manifest: bootstrapFromManifest restores DB from manifest (round-trip)', () => { + const base = tempDir(); + openDatabase(tempDbPath(base)); + try { + // Insert data and write manifest + insertMilestone({ id: 'M001', title: 'Restored Milestone' }); + insertSlice({ id: 'S01', milestoneId: 'M001', title: 'Restored Slice' }); + insertTask({ id: 'T01', sliceId: 'S01', milestoneId: 'M001', title: 'Restored Task', status: 'complete' }); + writeManifest(base); + closeDatabase(); + + // Open a fresh DB and bootstrap from manifest + const newDbPath = path.join(base, 'new.db'); + openDatabase(newDbPath); + const result = bootstrapFromManifest(base); + assert.strictEqual(result, true, 'bootstrapFromManifest should return true'); + + // Verify restored state + const snap = snapshotState(); + const m = snap.milestones.find((r) => r.id === 'M001'); + assert.ok(m !== undefined, 'M001 should be restored'); + assert.strictEqual(m!.title, 'Restored Milestone'); + + const s = snap.slices.find((r) => r.id === 'S01'); + assert.ok(s !== undefined, 'S01 should be restored'); + + const t = snap.tasks.find((r) => r.id === 'T01'); + assert.ok(t !== undefined, 'T01 should be restored'); + assert.strictEqual(t!.status, 'complete'); + } finally { + closeDatabase(); + cleanupDir(base); + } +}); + +// ─── readManifest: version check ───────────────────────────────────────── + +test('workflow-manifest: readManifest throws on unsupported version', () => { + const base = tempDir(); + try { + fs.mkdirSync(path.join(base, '.gsd'), { recursive: true }); + fs.writeFileSync( + path.join(base, '.gsd', 'state-manifest.json'), + JSON.stringify({ version: 99, exported_at: '', milestones: [], slices: [], tasks: [], decisions: [], verification_evidence: [] }), + ); + assert.throws( + () => readManifest(base), + /Unsupported manifest version/, + 'should throw on version mismatch', + ); + } finally { + cleanupDir(base); + } +}); diff --git a/src/resources/extensions/gsd/tests/workflow-projections.test.ts b/src/resources/extensions/gsd/tests/workflow-projections.test.ts new file mode 100644 index 000000000..9d26da900 --- /dev/null +++ b/src/resources/extensions/gsd/tests/workflow-projections.test.ts @@ -0,0 +1,170 @@ +// GSD Extension — workflow-projections unit tests +// Tests the pure rendering functions (no DB required). + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { renderPlanContent } from '../workflow-projections.ts'; +import type { SliceRow, TaskRow } from '../gsd-db.ts'; + +// ─── Test fixtures ──────────────────────────────────────────────────────── + +function makeSlice(overrides: Partial = {}): SliceRow { + return { + id: 'S01', + milestone_id: 'M001', + title: 'Auth Layer', + status: 'active', + risk: 'high', + depends: [], + demo: 'Login flow works end-to-end', + goal: 'Implement JWT authentication', + full_summary_md: '', + full_uat_md: '', + success_criteria: '', + proof_level: '', + integration_closure: '', + observability_impact: '', + created_at: '2026-01-01T00:00:00Z', + completed_at: null, + sequence: 1, + replan_triggered_at: null, + ...overrides, + }; +} + +function makeTask(overrides: Partial = {}): TaskRow { + return { + id: 'T01', + slice_id: 'S01', + milestone_id: 'M001', + title: 'Create JWT middleware', + status: 'pending', + description: 'Implement JWT validation middleware', + estimate: '2h', + files: ['src/middleware/auth.ts'], + verify: 'npm test src/middleware/auth.test.ts', + one_liner: '', + narrative: '', + verification_result: '', + duration: '', + completed_at: null, + blocker_discovered: false, + deviations: '', + known_issues: '', + key_files: [], + key_decisions: [], + full_summary_md: '', + inputs: [], + expected_output: [], + observability_impact: '', + sequence: 1, + ...overrides, + }; +} + +// ─── renderPlanContent: structure ──────────────────────────────────────── + +test('workflow-projections: renderPlanContent starts with H1 containing slice id and title', () => { + const content = renderPlanContent(makeSlice(), []); + assert.ok(content.startsWith('# S01: Auth Layer'), `expected H1, got: ${content.slice(0, 60)}`); +}); + +test('workflow-projections: renderPlanContent includes Goal line', () => { + const content = renderPlanContent(makeSlice(), []); + assert.ok(content.includes('**Goal:** Implement JWT authentication')); +}); + +test('workflow-projections: renderPlanContent includes Demo line', () => { + const content = renderPlanContent(makeSlice(), []); + assert.ok(content.includes('**Demo:** After this: Login flow works end-to-end')); +}); + +test('workflow-projections: renderPlanContent falls back to TBD when goal and full_summary_md are empty', () => { + const slice = makeSlice({ goal: '', full_summary_md: '' }); + const content = renderPlanContent(slice, []); + assert.ok(content.includes('**Goal:** TBD')); +}); + +test('workflow-projections: renderPlanContent falls back to full_summary_md when goal is empty', () => { + const slice = makeSlice({ goal: '', full_summary_md: 'Fallback goal text' }); + const content = renderPlanContent(slice, []); + assert.ok(content.includes('**Goal:** Fallback goal text')); +}); + +test('workflow-projections: renderPlanContent includes ## Tasks section', () => { + const content = renderPlanContent(makeSlice(), []); + assert.ok(content.includes('## Tasks')); +}); + +// ─── renderPlanContent: task checkboxes ────────────────────────────────── + +test('workflow-projections: pending task renders with [ ] checkbox', () => { + const task = makeTask({ status: 'pending' }); + const content = renderPlanContent(makeSlice(), [task]); + assert.ok(content.includes('- [ ] **T01:**'), `expected unchecked, got: ${content}`); +}); + +test('workflow-projections: done task renders with [x] checkbox', () => { + const task = makeTask({ status: 'done' }); + const content = renderPlanContent(makeSlice(), [task]); + assert.ok(content.includes('- [x] **T01:**'), `expected checked, got: ${content}`); +}); + +test('workflow-projections: non-done status renders with [ ] checkbox', () => { + const task = makeTask({ status: 'complete' }); // 'complete' ≠ 'done' → unchecked + const content = renderPlanContent(makeSlice(), [task]); + assert.ok(content.includes('- [ ] **T01:**')); +}); + +// ─── renderPlanContent: task sublines ──────────────────────────────────── + +test('workflow-projections: task with estimate renders Estimate subline', () => { + const task = makeTask({ estimate: '2h' }); + const content = renderPlanContent(makeSlice(), [task]); + assert.ok(content.includes(' - Estimate: 2h')); +}); + +test('workflow-projections: task with empty estimate omits Estimate subline', () => { + const task = makeTask({ estimate: '' }); + const content = renderPlanContent(makeSlice(), [task]); + assert.ok(!content.includes(' - Estimate:')); +}); + +test('workflow-projections: task with files renders Files subline', () => { + const task = makeTask({ files: ['src/auth.ts', 'src/auth.test.ts'] }); + const content = renderPlanContent(makeSlice(), [task]); + assert.ok(content.includes(' - Files: src/auth.ts, src/auth.test.ts')); +}); + +test('workflow-projections: task with empty files array omits Files subline', () => { + const task = makeTask({ files: [] }); + const content = renderPlanContent(makeSlice(), [task]); + assert.ok(!content.includes(' - Files:')); +}); + +test('workflow-projections: task with verify renders Verify subline', () => { + const task = makeTask({ verify: 'npm test' }); + const content = renderPlanContent(makeSlice(), [task]); + assert.ok(content.includes(' - Verify: npm test')); +}); + +test('workflow-projections: task with no verify omits Verify subline', () => { + const task = makeTask({ verify: '' }); + const content = renderPlanContent(makeSlice(), [task]); + assert.ok(!content.includes(' - Verify:')); +}); + +test('workflow-projections: task with duration renders Duration subline', () => { + const task = makeTask({ duration: '45m' }); + const content = renderPlanContent(makeSlice(), [task]); + assert.ok(content.includes(' - Duration: 45m')); +}); + +test('workflow-projections: multiple tasks rendered in order', () => { + const t1 = makeTask({ id: 'T01', title: 'First task', sequence: 1 }); + const t2 = makeTask({ id: 'T02', title: 'Second task', sequence: 2 }); + const content = renderPlanContent(makeSlice(), [t1, t2]); + const idxT1 = content.indexOf('**T01:**'); + const idxT2 = content.indexOf('**T02:**'); + assert.ok(idxT1 < idxT2, 'T01 should appear before T02'); +}); diff --git a/src/resources/extensions/gsd/tests/write-intercept.test.ts b/src/resources/extensions/gsd/tests/write-intercept.test.ts new file mode 100644 index 000000000..940295376 --- /dev/null +++ b/src/resources/extensions/gsd/tests/write-intercept.test.ts @@ -0,0 +1,77 @@ +// GSD Extension — write-intercept unit tests +// Tests isBlockedStateFile() and BLOCKED_WRITE_ERROR constant. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { isBlockedStateFile, BLOCKED_WRITE_ERROR } from '../write-intercept.ts'; + +// ─── isBlockedStateFile: blocked paths ─────────────────────────────────── + +test('write-intercept: blocks unix .gsd/STATE.md path', () => { + assert.strictEqual(isBlockedStateFile('/project/.gsd/STATE.md'), true); +}); + +test('write-intercept: blocks relative path with dir prefix before .gsd/STATE.md', () => { + // The regex requires a path separator before .gsd — bare '.gsd/STATE.md' is not blocked + // but 'project/.gsd/STATE.md' is (has separator before .gsd) + assert.strictEqual(isBlockedStateFile('project/.gsd/STATE.md'), true); +}); + +test('write-intercept: does NOT block bare .gsd/STATE.md without leading separator', () => { + // Regex requires [/\\] before .gsd — bare relative path has no such separator + assert.strictEqual(isBlockedStateFile('.gsd/STATE.md'), false); +}); + +test('write-intercept: blocks nested project .gsd/STATE.md path', () => { + assert.strictEqual(isBlockedStateFile('/Users/dev/my-project/.gsd/STATE.md'), true); +}); + +test('write-intercept: blocks .gsd/projects//STATE.md (symlinked projects path)', () => { + assert.strictEqual(isBlockedStateFile('/home/user/.gsd/projects/my-project/STATE.md'), true); +}); + +// ─── isBlockedStateFile: allowed paths ─────────────────────────────────── + +test('write-intercept: allows .gsd/ROADMAP.md', () => { + assert.strictEqual(isBlockedStateFile('/project/.gsd/ROADMAP.md'), false); +}); + +test('write-intercept: allows .gsd/PLAN.md', () => { + assert.strictEqual(isBlockedStateFile('/project/.gsd/PLAN.md'), false); +}); + +test('write-intercept: allows .gsd/REQUIREMENTS.md', () => { + assert.strictEqual(isBlockedStateFile('/project/.gsd/REQUIREMENTS.md'), false); +}); + +test('write-intercept: allows .gsd/SUMMARY.md', () => { + assert.strictEqual(isBlockedStateFile('/project/.gsd/SUMMARY.md'), false); +}); + +test('write-intercept: allows .gsd/PROJECT.md', () => { + assert.strictEqual(isBlockedStateFile('/project/.gsd/PROJECT.md'), false); +}); + +test('write-intercept: allows regular source files', () => { + assert.strictEqual(isBlockedStateFile('/project/src/index.ts'), false); +}); + +test('write-intercept: allows slice plan files', () => { + assert.strictEqual(isBlockedStateFile('/project/.gsd/milestones/M001/slices/S01/S01-PLAN.md'), false); +}); + +test('write-intercept: does not block files named STATE.md outside .gsd/', () => { + assert.strictEqual(isBlockedStateFile('/project/docs/STATE.md'), false); +}); + +// ─── BLOCKED_WRITE_ERROR: content ──────────────────────────────────────── + +test('write-intercept: BLOCKED_WRITE_ERROR is a non-empty string', () => { + assert.strictEqual(typeof BLOCKED_WRITE_ERROR, 'string'); + assert.ok(BLOCKED_WRITE_ERROR.length > 0); +}); + +test('write-intercept: BLOCKED_WRITE_ERROR mentions engine tool calls', () => { + assert.ok(BLOCKED_WRITE_ERROR.includes('gsd_complete_task'), 'should mention gsd_complete_task'); + assert.ok(BLOCKED_WRITE_ERROR.includes('engine tool calls'), 'should mention engine tool calls'); +});