diff --git a/src/resources/extensions/gsd/tests/overrides.test.ts b/src/resources/extensions/gsd/tests/overrides.test.ts index f8302d03c..fbc5087f6 100644 --- a/src/resources/extensions/gsd/tests/overrides.test.ts +++ b/src/resources/extensions/gsd/tests/overrides.test.ts @@ -1,15 +1,14 @@ // GSD Extension - Override Tests // Tests for parseOverrides, appendOverride, loadActiveOverrides, formatOverridesSection, resolveAllOverrides +import { describe, test, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { createTestContext } from './test-helpers.ts'; import { parseOverrides, appendOverride, loadActiveOverrides, formatOverridesSection, resolveAllOverrides } from '../files.ts'; import type { Override } from '../files.ts'; -const { assertEq, assertTrue, assertMatch, assertNoMatch, report } = createTestContext(); - const tempDirs: string[] = []; function makeTempDir(prefix: string): string { @@ -26,106 +25,100 @@ function cleanup(): void { tempDirs.length = 0; } -console.log('\n=== parseOverrides: empty content ==='); -{ const result = parseOverrides(""); assertEq(result.length, 0, "empty content returns no overrides"); } +describe('overrides', () => { + afterEach(() => cleanup()); -console.log('\n=== parseOverrides: single active override ==='); -{ - const content = `# GSD Overrides\n\nUser-issued overrides that supersede plan document content.\n\n---\n\n## Override: 2026-03-14T10:00:00.000Z\n\n**Change:** Use Postgres instead of SQLite\n**Scope:** active\n**Applied-at:** M001/S02/T03\n\n---\n`; - const result = parseOverrides(content); - assertEq(result.length, 1, "parses one override"); - assertEq(result[0].timestamp, "2026-03-14T10:00:00.000Z", "correct timestamp"); - assertEq(result[0].change, "Use Postgres instead of SQLite", "correct change"); - assertEq(result[0].scope, "active", "correct scope"); - assertEq(result[0].appliedAt, "M001/S02/T03", "correct appliedAt"); -} + test('parseOverrides: empty content', () => { + const result = parseOverrides(""); assert.deepStrictEqual(result.length, 0, "empty content returns no overrides"); + }); -console.log('\n=== parseOverrides: multiple overrides, mixed scopes ==='); -{ - const content = `# GSD Overrides\n\n---\n\n## Override: 2026-03-14T10:00:00.000Z\n\n**Change:** Use Postgres instead of SQLite\n**Scope:** resolved\n**Applied-at:** M001/S02/T03\n\n---\n\n## Override: 2026-03-14T11:00:00.000Z\n\n**Change:** Use JWT instead of session cookies\n**Scope:** active\n**Applied-at:** M001/S03/T01\n\n---\n`; - const result = parseOverrides(content); - assertEq(result.length, 2, "parses two overrides"); - assertEq(result[0].scope, "resolved", "first is resolved"); - assertEq(result[1].scope, "active", "second is active"); - assertEq(result[1].change, "Use JWT instead of session cookies", "second change text"); -} + test('parseOverrides: single active override', () => { + const content = `# GSD Overrides\n\nUser-issued overrides that supersede plan document content.\n\n---\n\n## Override: 2026-03-14T10:00:00.000Z\n\n**Change:** Use Postgres instead of SQLite\n**Scope:** active\n**Applied-at:** M001/S02/T03\n\n---\n`; + const result = parseOverrides(content); + assert.deepStrictEqual(result.length, 1, "parses one override"); + assert.deepStrictEqual(result[0].timestamp, "2026-03-14T10:00:00.000Z", "correct timestamp"); + assert.deepStrictEqual(result[0].change, "Use Postgres instead of SQLite", "correct change"); + assert.deepStrictEqual(result[0].scope, "active", "correct scope"); + assert.deepStrictEqual(result[0].appliedAt, "M001/S02/T03", "correct appliedAt"); + }); -console.log('\n=== appendOverride: creates new file ==='); -{ - const tmp = makeTempDir("append-new"); - await appendOverride(tmp, "Use Postgres", "M001/S01/T01"); - const content = readFileSync(join(tmp, ".gsd", "OVERRIDES.md"), "utf-8"); - assertTrue(content.includes("# GSD Overrides"), "has header"); - assertTrue(content.includes("**Change:** Use Postgres"), "has change"); - assertTrue(content.includes("**Scope:** active"), "has active scope"); - assertTrue(content.includes("**Applied-at:** M001/S01/T01"), "has appliedAt"); -} + test('parseOverrides: multiple overrides, mixed scopes', () => { + const content = `# GSD Overrides\n\n---\n\n## Override: 2026-03-14T10:00:00.000Z\n\n**Change:** Use Postgres instead of SQLite\n**Scope:** resolved\n**Applied-at:** M001/S02/T03\n\n---\n\n## Override: 2026-03-14T11:00:00.000Z\n\n**Change:** Use JWT instead of session cookies\n**Scope:** active\n**Applied-at:** M001/S03/T01\n\n---\n`; + const result = parseOverrides(content); + assert.deepStrictEqual(result.length, 2, "parses two overrides"); + assert.deepStrictEqual(result[0].scope, "resolved", "first is resolved"); + assert.deepStrictEqual(result[1].scope, "active", "second is active"); + assert.deepStrictEqual(result[1].change, "Use JWT instead of session cookies", "second change text"); + }); -console.log('\n=== appendOverride: appends to existing file ==='); -{ - const tmp = makeTempDir("append-existing"); - await appendOverride(tmp, "First override", "M001/S01/T01"); - await appendOverride(tmp, "Second override", "M001/S02/T02"); - const content = readFileSync(join(tmp, ".gsd", "OVERRIDES.md"), "utf-8"); - assertTrue(content.includes("**Change:** First override"), "has first override"); - assertTrue(content.includes("**Change:** Second override"), "has second override"); - const parsed = parseOverrides(content); - assertEq(parsed.length, 2, "two overrides in file"); -} + test('appendOverride: creates new file', async () => { + const tmp = makeTempDir("append-new"); + await appendOverride(tmp, "Use Postgres", "M001/S01/T01"); + const content = readFileSync(join(tmp, ".gsd", "OVERRIDES.md"), "utf-8"); + assert.ok(content.includes("# GSD Overrides"), "has header"); + assert.ok(content.includes("**Change:** Use Postgres"), "has change"); + assert.ok(content.includes("**Scope:** active"), "has active scope"); + assert.ok(content.includes("**Applied-at:** M001/S01/T01"), "has appliedAt"); + }); -console.log('\n=== loadActiveOverrides: no file ==='); -{ - const tmp = makeTempDir("load-no-file"); - const result = await loadActiveOverrides(tmp); - assertEq(result.length, 0, "returns empty when no file"); -} + test('appendOverride: appends to existing file', async () => { + const tmp = makeTempDir("append-existing"); + await appendOverride(tmp, "First override", "M001/S01/T01"); + await appendOverride(tmp, "Second override", "M001/S02/T02"); + const content = readFileSync(join(tmp, ".gsd", "OVERRIDES.md"), "utf-8"); + assert.ok(content.includes("**Change:** First override"), "has first override"); + assert.ok(content.includes("**Change:** Second override"), "has second override"); + const parsed = parseOverrides(content); + assert.deepStrictEqual(parsed.length, 2, "two overrides in file"); + }); -console.log('\n=== loadActiveOverrides: filters to active only ==='); -{ - const tmp = makeTempDir("load-filter"); - const content = `# GSD Overrides\n\n---\n\n## Override: 2026-03-14T10:00:00.000Z\n\n**Change:** Resolved change\n**Scope:** resolved\n**Applied-at:** M001/S01/T01\n\n---\n\n## Override: 2026-03-14T11:00:00.000Z\n\n**Change:** Active change\n**Scope:** active\n**Applied-at:** M001/S02/T01\n\n---\n`; - writeFileSync(join(tmp, ".gsd", "OVERRIDES.md"), content, "utf-8"); - const result = await loadActiveOverrides(tmp); - assertEq(result.length, 1, "only one active override"); - assertEq(result[0].change, "Active change", "correct active change"); -} + test('loadActiveOverrides: no file', async () => { + const tmp = makeTempDir("load-no-file"); + const result = await loadActiveOverrides(tmp); + assert.deepStrictEqual(result.length, 0, "returns empty when no file"); + }); -console.log('\n=== formatOverridesSection: empty array ==='); -{ const result = formatOverridesSection([]); assertEq(result, "", "empty overrides returns empty string"); } + test('loadActiveOverrides: filters to active only', async () => { + const tmp = makeTempDir("load-filter"); + const content = `# GSD Overrides\n\n---\n\n## Override: 2026-03-14T10:00:00.000Z\n\n**Change:** Resolved change\n**Scope:** resolved\n**Applied-at:** M001/S01/T01\n\n---\n\n## Override: 2026-03-14T11:00:00.000Z\n\n**Change:** Active change\n**Scope:** active\n**Applied-at:** M001/S02/T01\n\n---\n`; + writeFileSync(join(tmp, ".gsd", "OVERRIDES.md"), content, "utf-8"); + const result = await loadActiveOverrides(tmp); + assert.deepStrictEqual(result.length, 1, "only one active override"); + assert.deepStrictEqual(result[0].change, "Active change", "correct active change"); + }); -console.log('\n=== formatOverridesSection: formats section ==='); -{ - const overrides: Override[] = [ - { timestamp: "2026-03-14T10:00:00.000Z", change: "Use Postgres", scope: "active", appliedAt: "M001/S01/T01" }, - ]; - const result = formatOverridesSection(overrides); - assertTrue(result.includes("## Active Overrides (supersede plan content)"), "has header"); - assertTrue(result.includes("**Use Postgres**"), "has change text"); - assertTrue(result.includes("supersede any conflicting content"), "has instruction"); -} + test('formatOverridesSection: empty array', () => { + const result = formatOverridesSection([]); assert.deepStrictEqual(result, "", "empty overrides returns empty string"); + }); -console.log('\n=== resolveAllOverrides: marks all as resolved ==='); -{ - const tmp = makeTempDir("resolve-all"); - await appendOverride(tmp, "First", "M001/S01/T01"); - await appendOverride(tmp, "Second", "M001/S02/T01"); - let active = await loadActiveOverrides(tmp); - assertEq(active.length, 2, "two active before resolve"); - await resolveAllOverrides(tmp); - active = await loadActiveOverrides(tmp); - assertEq(active.length, 0, "no active after resolve"); - const content = readFileSync(join(tmp, ".gsd", "OVERRIDES.md"), "utf-8"); - const allOverrides = parseOverrides(content); - assertEq(allOverrides.length, 2, "still two overrides total"); - assertTrue(allOverrides.every(o => o.scope === "resolved"), "all resolved"); -} + test('formatOverridesSection: formats section', () => { + const overrides: Override[] = [ + { timestamp: "2026-03-14T10:00:00.000Z", change: "Use Postgres", scope: "active", appliedAt: "M001/S01/T01" }, + ]; + const result = formatOverridesSection(overrides); + assert.ok(result.includes("## Active Overrides (supersede plan content)"), "has header"); + assert.ok(result.includes("**Use Postgres**"), "has change text"); + assert.ok(result.includes("supersede any conflicting content"), "has instruction"); + }); -console.log('\n=== resolveAllOverrides: no file — no error ==='); -{ - const tmp = makeTempDir("resolve-no-file"); - await resolveAllOverrides(tmp); - assertTrue(true, "resolveAllOverrides with no file does not throw"); -} + test('resolveAllOverrides: marks all as resolved', async () => { + const tmp = makeTempDir("resolve-all"); + await appendOverride(tmp, "First", "M001/S01/T01"); + await appendOverride(tmp, "Second", "M001/S02/T01"); + let active = await loadActiveOverrides(tmp); + assert.deepStrictEqual(active.length, 2, "two active before resolve"); + await resolveAllOverrides(tmp); + active = await loadActiveOverrides(tmp); + assert.deepStrictEqual(active.length, 0, "no active after resolve"); + const content = readFileSync(join(tmp, ".gsd", "OVERRIDES.md"), "utf-8"); + const allOverrides = parseOverrides(content); + assert.deepStrictEqual(allOverrides.length, 2, "still two overrides total"); + assert.ok(allOverrides.every(o => o.scope === "resolved"), "all resolved"); + }); -cleanup(); -report(); + test('resolveAllOverrides: no file — no error', async () => { + const tmp = makeTempDir("resolve-no-file"); + await resolveAllOverrides(tmp); + assert.ok(true, "resolveAllOverrides with no file does not throw"); + }); +}); diff --git a/src/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts b/src/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts index 9e38c7262..9e1564e9e 100644 --- a/src/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts @@ -5,6 +5,8 @@ * restored after a coordinator crash, with PID liveness filtering. */ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, @@ -24,10 +26,6 @@ import { type PersistedState, } from "../parallel-orchestrator.ts"; import { writeSessionStatus, readAllSessionStatuses, removeSessionStatus } from "../session-status-io.ts"; -import { createTestContext } from './test-helpers.ts'; - -const { assertEq, assertTrue, report } = createTestContext(); - // ─── Helpers ────────────────────────────────────────────────────────────────── function makeTempDir(): string { @@ -57,8 +55,9 @@ function makePersistedState(overrides: Partial = {}): PersistedS // ─── Tests ──────────────────────────────────────────────────────────────────── -// Test 1: persistState writes valid JSON -{ + +describe('parallel-crash-recovery', () => { +test('Test 1: persistState writes valid JSON', () => { const basePath = makeTempDir(); try { // We can't call persistState directly without internal state set up, @@ -82,29 +81,27 @@ function makePersistedState(overrides: Partial = {}): PersistedS const raw = readFileSync(stateFilePath(basePath), "utf-8"); const parsed = JSON.parse(raw) as PersistedState; - assertEq(parsed.active, true, "persistState: active field preserved"); - assertEq(parsed.workers.length, 1, "persistState: worker count preserved"); - assertEq(parsed.workers[0].milestoneId, "M001", "persistState: milestoneId preserved"); - assertEq(parsed.workers[0].cost, 0.15, "persistState: cost preserved"); - assertEq(parsed.totalCost, 0.15, "persistState: totalCost preserved"); + assert.deepStrictEqual(parsed.active, true, "persistState: active field preserved"); + assert.deepStrictEqual(parsed.workers.length, 1, "persistState: worker count preserved"); + assert.deepStrictEqual(parsed.workers[0].milestoneId, "M001", "persistState: milestoneId preserved"); + assert.deepStrictEqual(parsed.workers[0].cost, 0.15, "persistState: cost preserved"); + assert.deepStrictEqual(parsed.totalCost, 0.15, "persistState: totalCost preserved"); } finally { rmSync(basePath, { recursive: true, force: true }); } -} +}); -// Test 2: restoreState returns null for missing file -{ +test('Test 2: restoreState returns null for missing file', () => { const basePath = makeTempDir(); try { const result = restoreState(basePath); - assertEq(result, null, "restoreState: returns null when no state file"); + assert.deepStrictEqual(result, null, "restoreState: returns null when no state file"); } finally { rmSync(basePath, { recursive: true, force: true }); } -} +}); -// Test 3: restoreState filters dead PIDs -{ +test('Test 3: restoreState filters dead PIDs', () => { const basePath = makeTempDir(); try { // PID 99999999 is almost certainly not alive @@ -136,15 +133,14 @@ function makePersistedState(overrides: Partial = {}): PersistedS const result = restoreState(basePath); // Both PIDs are dead, so result should be null and file should be cleaned up - assertEq(result, null, "restoreState: returns null when all PIDs dead"); - assertTrue(!existsSync(stateFilePath(basePath)), "restoreState: cleans up state file when all dead"); + assert.deepStrictEqual(result, null, "restoreState: returns null when all PIDs dead"); + assert.ok(!existsSync(stateFilePath(basePath)), "restoreState: cleans up state file when all dead"); } finally { rmSync(basePath, { recursive: true, force: true }); } -} +}); -// Test 4: restoreState keeps alive PIDs -{ +test('Test 4: restoreState keeps alive PIDs', () => { const basePath = makeTempDir(); try { // Use current process PID (definitely alive) @@ -176,18 +172,17 @@ function makePersistedState(overrides: Partial = {}): PersistedS writeStateFile(basePath, state); const result = restoreState(basePath); - assertTrue(result !== null, "restoreState: returns state when alive PID exists"); - assertEq(result!.workers.length, 1, "restoreState: filters out dead PID"); - assertEq(result!.workers[0].milestoneId, "M001", "restoreState: keeps alive worker"); - assertEq(result!.workers[0].pid, process.pid, "restoreState: preserves PID"); - assertEq(result!.workers[0].completedUnits, 5, "restoreState: preserves progress"); + assert.ok(result !== null, "restoreState: returns state when alive PID exists"); + assert.deepStrictEqual(result!.workers.length, 1, "restoreState: filters out dead PID"); + assert.deepStrictEqual(result!.workers[0].milestoneId, "M001", "restoreState: keeps alive worker"); + assert.deepStrictEqual(result!.workers[0].pid, process.pid, "restoreState: preserves PID"); + assert.deepStrictEqual(result!.workers[0].completedUnits, 5, "restoreState: preserves progress"); } finally { rmSync(basePath, { recursive: true, force: true }); } -} +}); -// Test 5: restoreState skips stopped/error workers even with alive PIDs -{ +test('Test 5: restoreState skips stopped/error workers even with alive PIDs', () => { const basePath = makeTempDir(); try { const state = makePersistedState({ @@ -207,14 +202,13 @@ function makePersistedState(overrides: Partial = {}): PersistedS writeStateFile(basePath, state); const result = restoreState(basePath); - assertEq(result, null, "restoreState: skips stopped workers"); + assert.deepStrictEqual(result, null, "restoreState: skips stopped workers"); } finally { rmSync(basePath, { recursive: true, force: true }); } -} +}); -// Test 6: orphan detection finds stale sessions -{ +test('Test 6: orphan detection finds stale sessions', () => { const basePath = makeTempDir(); try { // Write a session status with a dead PID @@ -246,7 +240,7 @@ function makePersistedState(overrides: Partial = {}): PersistedS // Read all sessions — both should exist initially const before = readAllSessionStatuses(basePath); - assertEq(before.length, 2, "orphan: both sessions exist before detection"); + assert.deepStrictEqual(before.length, 2, "orphan: both sessions exist before detection"); // Now simulate orphan detection logic (same as prepareParallelStart) const sessions = readAllSessionStatuses(basePath); @@ -265,34 +259,33 @@ function makePersistedState(overrides: Partial = {}): PersistedS } } - assertTrue(orphans.length === 2, "orphan: detected both sessions"); + assert.ok(orphans.length === 2, "orphan: detected both sessions"); const deadOrphan = orphans.find(o => o.milestoneId === "M001"); - assertTrue(deadOrphan !== undefined && !deadOrphan.alive, "orphan: M001 detected as dead"); + assert.ok(deadOrphan !== undefined && !deadOrphan.alive, "orphan: M001 detected as dead"); const aliveOrphan = orphans.find(o => o.milestoneId === "M002"); - assertTrue(aliveOrphan !== undefined && aliveOrphan.alive, "orphan: M002 detected as alive"); + assert.ok(aliveOrphan !== undefined && aliveOrphan.alive, "orphan: M002 detected as alive"); // Dead session should be cleaned up const after = readAllSessionStatuses(basePath); - assertEq(after.length, 1, "orphan: dead session cleaned up"); - assertEq(after[0].milestoneId, "M002", "orphan: alive session remains"); + assert.deepStrictEqual(after.length, 1, "orphan: dead session cleaned up"); + assert.deepStrictEqual(after[0].milestoneId, "M002", "orphan: alive session remains"); } finally { rmSync(basePath, { recursive: true, force: true }); } -} +}); -// Test 7: restoreState handles corrupt JSON gracefully -{ +test('Test 7: restoreState handles corrupt JSON gracefully', () => { const basePath = makeTempDir(); try { writeFileSync(stateFilePath(basePath), "{ not valid json !!!", "utf-8"); const result = restoreState(basePath); - assertEq(result, null, "restoreState: returns null for corrupt JSON"); + assert.deepStrictEqual(result, null, "restoreState: returns null for corrupt JSON"); } finally { rmSync(basePath, { recursive: true, force: true }); } -} +}); // Clean up module state resetOrchestrator(); -report(); +}); diff --git a/src/resources/extensions/gsd/tests/parallel-worker-monitoring.test.ts b/src/resources/extensions/gsd/tests/parallel-worker-monitoring.test.ts index ba7920645..227abc565 100644 --- a/src/resources/extensions/gsd/tests/parallel-worker-monitoring.test.ts +++ b/src/resources/extensions/gsd/tests/parallel-worker-monitoring.test.ts @@ -10,12 +10,11 @@ * 6. completedUnits counter increments on assistant message_end */ +import assert from 'node:assert/strict'; import { describe, it, after } from "node:test"; import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { createTestContext } from "./test-helpers.ts"; - // We test processWorkerLine indirectly via the module's exported state. // To test the internal function, we use the exported accessors. import { @@ -27,8 +26,6 @@ import { refreshWorkerStatuses, } from "../parallel-orchestrator.ts"; -const { assertEq, assertTrue, report } = createTestContext(); - // ─── Helpers ────────────────────────────────────────────────────────────── /** Create a minimal message_end NDJSON line with cost data. */ @@ -52,7 +49,7 @@ function makeMessageEndLine(cost: number, role = "assistant"): string { describe("parallel-worker-monitoring", () => { after(() => { resetOrchestrator(); - report(); + }); // Note: processWorkerLine is not exported, so we test the observable effects @@ -61,39 +58,39 @@ describe("parallel-worker-monitoring", () => { it("isBudgetExceeded returns false when no state exists", () => { resetOrchestrator(); - assertTrue(!isBudgetExceeded(), "no state = not exceeded"); + assert.ok(!isBudgetExceeded(), "no state = not exceeded"); }); it("isBudgetExceeded returns false when no ceiling configured", () => { resetOrchestrator(); // Can't directly set state without startParallel, so test the accessor - assertTrue(!isBudgetExceeded(), "no ceiling = not exceeded"); + assert.ok(!isBudgetExceeded(), "no ceiling = not exceeded"); }); it("getAggregateCost returns 0 when no state exists", () => { resetOrchestrator(); - assertEq(getAggregateCost(), 0, "no state = zero cost"); + assert.deepStrictEqual(getAggregateCost(), 0, "no state = zero cost"); }); it("isParallelActive returns false after reset", () => { resetOrchestrator(); - assertTrue(!isParallelActive(), "reset = not active"); + assert.ok(!isParallelActive(), "reset = not active"); }); it("getWorkerStatuses returns empty array when no state", () => { resetOrchestrator(); - assertEq(getWorkerStatuses().length, 0, "no state = empty workers"); + assert.deepStrictEqual(getWorkerStatuses().length, 0, "no state = empty workers"); }); it("NDJSON message_end format matches expected structure", () => { // Verify the NDJSON line format we expect from workers const line = makeMessageEndLine(0.05); const parsed = JSON.parse(line); - assertEq(parsed.type, "message_end", "type is message_end"); - assertEq(parsed.message.role, "assistant", "role is assistant"); - assertEq(parsed.message.usage.cost.total, 0.05, "cost.total is 0.05"); - assertTrue(typeof parsed.message.usage.input === "number", "input is number"); - assertTrue(typeof parsed.message.usage.output === "number", "output is number"); + assert.deepStrictEqual(parsed.type, "message_end", "type is message_end"); + assert.deepStrictEqual(parsed.message.role, "assistant", "role is assistant"); + assert.deepStrictEqual(parsed.message.usage.cost.total, 0.05, "cost.total is 0.05"); + assert.ok(typeof parsed.message.usage.input === "number", "input is number"); + assert.ok(typeof parsed.message.usage.output === "number", "output is number"); }); it("malformed JSON does not throw (tested via parse safety)", () => { @@ -111,7 +108,7 @@ describe("parallel-worker-monitoring", () => { JSON.parse(line); } catch { // Expected — processWorkerLine catches this silently - assertTrue(true, `malformed line "${line.slice(0, 20)}" handled`); + assert.ok(true, `malformed line "${line.slice(0, 20)}" handled`); } } }); @@ -122,25 +119,25 @@ describe("parallel-worker-monitoring", () => { let total = 0; for (const c of costs) total += c; // Floating point: round to 2 decimal places for comparison - assertEq(Math.round(total * 100) / 100, 0.28, "cost sum is correct"); + assert.deepStrictEqual(Math.round(total * 100) / 100, 0.28, "cost sum is correct"); }); it("budget ceiling comparison works with typical values", () => { // Test the ceiling check pattern const ceiling = 5.0; - assertTrue(0 < ceiling, "0 is under ceiling"); - assertTrue(4.99 < ceiling, "4.99 is under ceiling"); - assertTrue(!(5.0 < ceiling), "5.0 is at ceiling"); - assertTrue(!(5.01 < ceiling), "5.01 is over ceiling"); + assert.ok(0 < ceiling, "0 is under ceiling"); + assert.ok(4.99 < ceiling, "4.99 is under ceiling"); + assert.ok(!(5.0 < ceiling), "5.0 is at ceiling"); + assert.ok(!(5.01 < ceiling), "5.01 is over ceiling"); }); it("worker spawn args include --mode json", () => { // Verify the spawn command includes JSON mode for NDJSON output. // We can't easily test the actual spawn, but we verify the args pattern. const expectedArgs = ["--mode", "json", "--print", "/gsd auto"]; - assertTrue(expectedArgs.includes("--mode"), "args include --mode"); - assertTrue(expectedArgs.includes("json"), "args include json"); - assertTrue(expectedArgs.indexOf("--mode") < expectedArgs.indexOf("json"), + assert.ok(expectedArgs.includes("--mode"), "args include --mode"); + assert.ok(expectedArgs.includes("json"), "args include json"); + assert.ok(expectedArgs.indexOf("--mode") < expectedArgs.indexOf("json"), "--mode comes before json"); }); @@ -168,8 +165,8 @@ describe("parallel-worker-monitoring", () => { }, null, 2)); refreshWorkerStatuses(base, { restoreIfNeeded: true }); const workers = getWorkerStatuses(); - assertEq(workers.length, 1, "restored one worker"); - assertEq(workers[0].milestoneId, "M001", "worker restored from persisted state"); + assert.deepStrictEqual(workers.length, 1, "restored one worker"); + assert.deepStrictEqual(workers[0].milestoneId, "M001", "worker restored from persisted state"); } finally { resetOrchestrator(); rmSync(base, { recursive: true, force: true }); @@ -193,8 +190,8 @@ describe("parallel-worker-monitoring", () => { }, null, 2)); refreshWorkerStatuses(base, { restoreIfNeeded: true }); const workers = getWorkerStatuses(); - assertEq(workers[0].state, "running", "live session status restored"); - assertEq(workers[0].completedUnits, 3, "completed units restored from status file"); + assert.deepStrictEqual(workers[0].state, "running", "live session status restored"); + assert.deepStrictEqual(workers[0].completedUnits, 3, "completed units restored from status file"); } finally { resetOrchestrator(); rmSync(base, { recursive: true, force: true }); diff --git a/src/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts b/src/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts index c25c966f6..ae4eccf62 100644 --- a/src/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts +++ b/src/resources/extensions/gsd/tests/parallel-workers-multi-milestone-e2e.test.ts @@ -13,11 +13,12 @@ * - Cost projection with budget ceiling awareness */ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { createTestContext } from './test-helpers.ts'; import { registerWorker, updateWorker, @@ -43,8 +44,6 @@ import { predictRemainingCost, } from '../metrics.ts'; -const { assertEq, assertTrue, assertMatch, report } = createTestContext(); - // ─── Fixture helpers ────────────────────────────────────────────────────────── function createFixtureBase(): string { @@ -83,9 +82,9 @@ function cleanup(base: string): void { // ─── E2E: Parallel workers across M001 and M002 ────────────────────────────── -console.log("\n=== E2E: Parallel workers across milestones ==="); -{ +describe('parallel-workers-multi-milestone-e2e', () => { +test('E2E: Parallel workers across milestones', () => { resetWorkerRegistry(); const base = createFixtureBase(); @@ -99,52 +98,49 @@ console.log("\n=== E2E: Parallel workers across milestones ==="); const w2 = registerWorker("researcher", "Research M001 APIs", 1, 3, batch1Id); const w3 = registerWorker("worker", "Implement M001 feature", 2, 3, batch1Id); - assertEq(getActiveWorkers().length, 3, "M001: 3 parallel workers registered"); - assertTrue(hasActiveWorkers(), "M001: has active workers"); + assert.deepStrictEqual(getActiveWorkers().length, 3, "M001: 3 parallel workers registered"); + assert.ok(hasActiveWorkers(), "M001: has active workers"); const batches1 = getWorkerBatches(); - assertEq(batches1.size, 1, "M001: single batch"); - assertEq(batches1.get(batch1Id)!.length, 3, "M001: batch has 3 workers"); + assert.deepStrictEqual(batches1.size, 1, "M001: single batch"); + assert.deepStrictEqual(batches1.get(batch1Id)!.length, 3, "M001: batch has 3 workers"); // Complete M001 workers updateWorker(w1, "completed"); updateWorker(w2, "completed"); updateWorker(w3, "completed"); - assertTrue(!hasActiveWorkers(), "M001: no active workers after completion"); + assert.ok(!hasActiveWorkers(), "M001: no active workers after completion"); // Simulate M002 parallel workers (batch 2) — overlapping with M001 cleanup const batch2Id = "batch-m002"; const w4 = registerWorker("scout", "Explore M002 codebase", 0, 2, batch2Id); const w5 = registerWorker("worker", "Implement M002 feature", 1, 2, batch2Id); - assertTrue(hasActiveWorkers(), "M002: has active workers"); + assert.ok(hasActiveWorkers(), "M002: has active workers"); const batches2 = getWorkerBatches(); // M001 workers may still be in cleanup window (5s timeout), M002 workers are active - assertTrue(batches2.has(batch2Id), "M002: batch exists"); - assertEq(batches2.get(batch2Id)!.length, 2, "M002: batch has 2 workers"); + assert.ok(batches2.has(batch2Id), "M002: batch exists"); + assert.deepStrictEqual(batches2.get(batch2Id)!.length, 2, "M002: batch has 2 workers"); // One worker fails in M002 updateWorker(w4, "completed"); updateWorker(w5, "failed"); - assertTrue(!hasActiveWorkers(), "M002: no active workers after all finish"); + assert.ok(!hasActiveWorkers(), "M002: no active workers after all finish"); // Verify worker statuses reflect correctly const allWorkers = getActiveWorkers(); const m002Workers = allWorkers.filter(w => w.batchId === batch2Id); if (m002Workers.length > 0) { const failedWorker = m002Workers.find(w => w.status === "failed"); - assertTrue(failedWorker !== undefined, "M002: failed worker tracked"); - assertEq(failedWorker?.agent, "worker", "M002: failed worker is 'worker'"); + assert.ok(failedWorker !== undefined, "M002: failed worker tracked"); + assert.deepStrictEqual(failedWorker?.agent, "worker", "M002: failed worker is 'worker'"); } cleanup(base); -} +}); // ─── E2E: Metrics accumulation across milestones ────────────────────────────── - -console.log("\n=== E2E: Metrics across milestones ==="); - -{ +test('E2E: Metrics across milestones', () => { const base = createFixtureBase(); // Build a ledger spanning two milestones @@ -175,90 +171,84 @@ console.log("\n=== E2E: Metrics across milestones ==="); // Verify totals const totals = getProjectTotals(loaded.units); - assertEq(totals.units, 13, "metrics: 13 total units across M001+M002"); + assert.deepStrictEqual(totals.units, 13, "metrics: 13 total units across M001+M002"); const totalCost = loaded.units.reduce((sum, u) => sum + u.cost, 0); - assertTrue(Math.abs(totals.cost - totalCost) < 0.001, "metrics: total cost matches sum"); + assert.ok(Math.abs(totals.cost - totalCost) < 0.001, "metrics: total cost matches sum"); // Verify phase aggregation const phases = aggregateByPhase(loaded.units); const research = phases.find(p => p.phase === "research"); - assertTrue(research !== undefined, "metrics: research phase exists"); - assertEq(research!.units, 2, "metrics: 2 research units (M001 + M002)"); + assert.ok(research !== undefined, "metrics: research phase exists"); + assert.deepStrictEqual(research!.units, 2, "metrics: 2 research units (M001 + M002)"); const execution = phases.find(p => p.phase === "execution"); - assertTrue(execution !== undefined, "metrics: execution phase exists"); - assertEq(execution!.units, 4, "metrics: 4 execution units across both milestones"); + assert.ok(execution !== undefined, "metrics: execution phase exists"); + assert.deepStrictEqual(execution!.units, 4, "metrics: 4 execution units across both milestones"); // Verify slice aggregation const slices = aggregateBySlice(loaded.units); - assertTrue(slices.length >= 4, "metrics: at least 4 slice aggregates (M001/S01, M001/S02, M002/S01, milestone-level)"); + assert.ok(slices.length >= 4, "metrics: at least 4 slice aggregates (M001/S01, M001/S02, M002/S01, milestone-level)"); const m001s01 = slices.find(s => s.sliceId === "M001/S01"); - assertTrue(m001s01 !== undefined, "metrics: M001/S01 slice aggregate exists"); + assert.ok(m001s01 !== undefined, "metrics: M001/S01 slice aggregate exists"); // M001/S01 has: plan-slice + T01 + T02 + complete-slice = 4 units - assertEq(m001s01!.units, 4, "metrics: M001/S01 has 4 units"); + assert.deepStrictEqual(m001s01!.units, 4, "metrics: M001/S01 has 4 units"); // Cost projection const projLines = formatCostProjection(slices, 3, 2.0); - assertTrue(projLines.length >= 1, "metrics: cost projection generated"); - assertMatch(projLines[0], /Projected remaining/, "metrics: projection line text"); + assert.ok(projLines.length >= 1, "metrics: cost projection generated"); + assert.match(projLines[0], /Projected remaining/, "metrics: projection line text"); cleanup(base); -} +}); // ─── E2E: Budget alert progression through all thresholds ───────────────────── - -console.log("\n=== E2E: Budget alert progression 0→75→80→90→100 ==="); - -{ +test('E2E: Budget alert progression 0→75→80→90→100', () => { // Simulate spending progression against a $10 budget ceiling const ceiling = 10.0; // Start: 50% spent let lastLevel = getBudgetAlertLevel(5.0 / ceiling); - assertEq(lastLevel, 0, "budget: 50% → level 0"); - assertEq(getNewBudgetAlertLevel(0, 5.0 / ceiling), null, "budget: no alert at 50%"); + assert.deepStrictEqual(lastLevel, 0, "budget: 50% → level 0"); + assert.deepStrictEqual(getNewBudgetAlertLevel(0, 5.0 / ceiling), null, "budget: no alert at 50%"); // Spend to 75% let newLevel = getNewBudgetAlertLevel(lastLevel, 7.5 / ceiling); - assertEq(newLevel, 75, "budget: alert fires at 75%"); + assert.deepStrictEqual(newLevel, 75, "budget: alert fires at 75%"); lastLevel = newLevel!; // Spend to 78% — no alert (between 75 and 80) - assertEq(getNewBudgetAlertLevel(lastLevel, 7.8 / ceiling), null, "budget: no alert at 78%"); + assert.deepStrictEqual(getNewBudgetAlertLevel(lastLevel, 7.8 / ceiling), null, "budget: no alert at 78%"); // Spend to 80% — 80% approach alert newLevel = getNewBudgetAlertLevel(lastLevel, 8.0 / ceiling); - assertEq(newLevel, 80, "budget: approach alert fires at 80%"); + assert.deepStrictEqual(newLevel, 80, "budget: approach alert fires at 80%"); lastLevel = newLevel!; // Spend to 85% — no alert (still at 80 level) - assertEq(getNewBudgetAlertLevel(lastLevel, 8.5 / ceiling), null, "budget: no alert at 85%"); + assert.deepStrictEqual(getNewBudgetAlertLevel(lastLevel, 8.5 / ceiling), null, "budget: no alert at 85%"); // Spend to 90% newLevel = getNewBudgetAlertLevel(lastLevel, 9.0 / ceiling); - assertEq(newLevel, 90, "budget: alert fires at 90%"); + assert.deepStrictEqual(newLevel, 90, "budget: alert fires at 90%"); lastLevel = newLevel!; // Spend to 100% newLevel = getNewBudgetAlertLevel(lastLevel, 10.0 / ceiling); - assertEq(newLevel, 100, "budget: alert fires at 100%"); + assert.deepStrictEqual(newLevel, 100, "budget: alert fires at 100%"); lastLevel = newLevel!; // Over budget — no re-emission - assertEq(getNewBudgetAlertLevel(lastLevel, 12.0 / ceiling), null, "budget: no re-alert over 100%"); + assert.deepStrictEqual(getNewBudgetAlertLevel(lastLevel, 12.0 / ceiling), null, "budget: no re-alert over 100%"); // Enforcement at 80% — still "none" (enforcement only at 100%) - assertEq(getBudgetEnforcementAction("pause", 0.80), "none", "budget: no enforcement at 80%"); - assertEq(getBudgetEnforcementAction("halt", 0.80), "none", "budget: no enforcement at 80%"); - assertEq(getBudgetEnforcementAction("warn", 0.80), "none", "budget: no enforcement at 80%"); -} + assert.deepStrictEqual(getBudgetEnforcementAction("pause", 0.80), "none", "budget: no enforcement at 80%"); + assert.deepStrictEqual(getBudgetEnforcementAction("halt", 0.80), "none", "budget: no enforcement at 80%"); + assert.deepStrictEqual(getBudgetEnforcementAction("warn", 0.80), "none", "budget: no enforcement at 80%"); +}); // ─── E2E: Budget prediction with multi-milestone cost data ──────────────────── - -console.log("\n=== E2E: Budget prediction across milestones ==="); - -{ +test('E2E: Budget prediction across milestones', () => { const units: UnitMetrics[] = [ makeUnit({ type: "execute-task", id: "M001/S01/T01", cost: 0.10 }), makeUnit({ type: "execute-task", id: "M001/S01/T02", cost: 0.15 }), @@ -268,30 +258,27 @@ console.log("\n=== E2E: Budget prediction across milestones ==="); ]; const avgCosts = getAverageCostPerUnitType(units); - assertTrue(avgCosts.has("execute-task"), "prediction: has execute-task average"); - assertTrue(avgCosts.has("plan-slice"), "prediction: has plan-slice average"); + assert.ok(avgCosts.has("execute-task"), "prediction: has execute-task average"); + assert.ok(avgCosts.has("plan-slice"), "prediction: has plan-slice average"); // Average execute-task cost: (0.10 + 0.15 + 0.20) / 3 = 0.15 const execAvg = avgCosts.get("execute-task")!; - assertTrue(Math.abs(execAvg - 0.15) < 0.001, `prediction: execute-task avg is $0.15 (got ${execAvg})`); + assert.ok(Math.abs(execAvg - 0.15) < 0.001, `prediction: execute-task avg is $0.15 (got ${execAvg})`); // Average plan-slice cost: (0.05 + 0.08) / 2 = 0.065 const planAvg = avgCosts.get("plan-slice")!; - assertTrue(Math.abs(planAvg - 0.065) < 0.001, `prediction: plan-slice avg is $0.065 (got ${planAvg})`); + assert.ok(Math.abs(planAvg - 0.065) < 0.001, `prediction: plan-slice avg is $0.065 (got ${planAvg})`); // Predict remaining cost for 3 more execute-tasks and 1 plan-slice const remaining = predictRemainingCost(avgCosts, [ "execute-task", "execute-task", "execute-task", "plan-slice", ]); // Expected: 3 * 0.15 + 1 * 0.065 = 0.515 - assertTrue(Math.abs(remaining - 0.515) < 0.001, `prediction: remaining cost ~$0.515 (got ${remaining})`); -} + assert.ok(Math.abs(remaining - 0.515) < 0.001, `prediction: remaining cost ~$0.515 (got ${remaining})`); +}); // ─── E2E: Parallel workers + budget alerts combined scenario ────────────────── - -console.log("\n=== E2E: Combined parallel workers + budget monitoring ==="); - -{ +test('E2E: Combined parallel workers + budget monitoring', () => { resetWorkerRegistry(); // Simulate a scenario: 3 parallel workers running while budget is at 78% @@ -303,34 +290,31 @@ console.log("\n=== E2E: Combined parallel workers + budget monitoring ==="); // Budget is at 78% — no alert yet (between 75 and 80) const ceiling = 10.0; let lastLevel: ReturnType = 75; // already got 75% alert - assertEq(getNewBudgetAlertLevel(lastLevel, 7.8 / ceiling), null, "combined: no alert at 78% with workers running"); - assertTrue(hasActiveWorkers(), "combined: workers running during budget check"); + assert.deepStrictEqual(getNewBudgetAlertLevel(lastLevel, 7.8 / ceiling), null, "combined: no alert at 78% with workers running"); + assert.ok(hasActiveWorkers(), "combined: workers running during budget check"); // First worker completes, cost rises to 80% updateWorker(w1, "completed"); const level80 = getNewBudgetAlertLevel(lastLevel, 8.0 / ceiling); - assertEq(level80, 80, "combined: 80% approach alert fires after worker completes"); + assert.deepStrictEqual(level80, 80, "combined: 80% approach alert fires after worker completes"); lastLevel = level80!; // Second worker completes, cost rises to 88% updateWorker(w2, "completed"); - assertEq(getNewBudgetAlertLevel(lastLevel, 8.8 / ceiling), null, "combined: no alert at 88%"); + assert.deepStrictEqual(getNewBudgetAlertLevel(lastLevel, 8.8 / ceiling), null, "combined: no alert at 88%"); // Third worker completes, cost reaches 90% updateWorker(w3, "completed"); const level90 = getNewBudgetAlertLevel(lastLevel, 9.0 / ceiling); - assertEq(level90, 90, "combined: 90% alert fires after all workers complete"); + assert.deepStrictEqual(level90, 90, "combined: 90% alert fires after all workers complete"); - assertTrue(!hasActiveWorkers(), "combined: no active workers at end"); + assert.ok(!hasActiveWorkers(), "combined: no active workers at end"); resetWorkerRegistry(); -} +}); // ─── E2E: formatCostProjection with budget ceiling warnings ─────────────────── - -console.log("\n=== E2E: Cost projection ceiling warnings ==="); - -{ +test('E2E: Cost projection ceiling warnings', () => { const slices = [ { sliceId: "M001/S01", units: 4, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 3.0, duration: 10000 }, { sliceId: "M001/S02", units: 3, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 4.0, duration: 8000 }, @@ -339,16 +323,15 @@ console.log("\n=== E2E: Cost projection ceiling warnings ==="); // With ceiling NOT yet reached const proj1 = formatCostProjection(slices, 2, 20.0); - assertTrue(proj1.length >= 1, "projection: has projection line"); - assertMatch(proj1[0], /Projected remaining/, "projection: shows projection"); - assertTrue(proj1.length === 1, "projection: no ceiling warning when under budget"); + assert.ok(proj1.length >= 1, "projection: has projection line"); + assert.match(proj1[0], /Projected remaining/, "projection: shows projection"); + assert.ok(proj1.length === 1, "projection: no ceiling warning when under budget"); // With ceiling reached (spent 12.0 >= ceiling 10.0) const proj2 = formatCostProjection(slices, 2, 10.0); - assertTrue(proj2.length >= 2, "projection: has ceiling warning when over budget"); - assertMatch(proj2[1], /ceiling/, "projection: ceiling warning text"); -} + assert.ok(proj2.length >= 2, "projection: has ceiling warning when over budget"); + assert.match(proj2[1], /ceiling/, "projection: ceiling warning text"); +}); // ─── Summary ────────────────────────────────────────────────────────────────── - -report(); +}); diff --git a/src/resources/extensions/gsd/tests/park-edge-cases.test.ts b/src/resources/extensions/gsd/tests/park-edge-cases.test.ts index f69bfeaad..f4c54d4f4 100644 --- a/src/resources/extensions/gsd/tests/park-edge-cases.test.ts +++ b/src/resources/extensions/gsd/tests/park-edge-cases.test.ts @@ -12,6 +12,8 @@ * 8. Discard milestone that has depends_on on others */ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -20,16 +22,6 @@ import { deriveState, invalidateStateCache } from '../state.ts'; import { clearPathCache } from '../paths.ts'; import { parkMilestone, unparkMilestone, discardMilestone } from '../milestone-actions.ts'; -let passed = 0; -let failed = 0; - -function assert(condition: boolean, message: string): void { - if (condition) { passed++; } else { failed++; console.error(` FAIL: ${message}`); } -} -function assertEq(actual: T, expected: T, message: string): void { - if (JSON.stringify(actual) === JSON.stringify(expected)) { passed++; } - else { failed++; console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); } -} function createFixture(): string { const b = mkdtempSync(join(tmpdir(), 'gsd-edge-')); @@ -61,11 +53,10 @@ function createM(b: string, mid: string, opts?: { roadmap?: boolean; summary?: b function clear(): void { clearPathCache(); invalidateStateCache(); } function cleanup(b: string): void { rmSync(b, { recursive: true, force: true }); } -async function main(): Promise { - // ─── EDGE 1: Discard breaks depends_on → downstream is BLOCKED ──────── - console.log('\n=== EDGE 1: Discard breaks depends_on chain ==='); - { + +describe('park-edge-cases', () => { +test('EDGE 1: Discard breaks depends_on chain', async () => { const b = createFixture(); try { createM(b, 'M001', { roadmap: true, summary: true }); // complete @@ -78,17 +69,16 @@ async function main(): Promise { // M003 depends on M002 which no longer exists. // M002 is not in completeMilestoneIds → dep is unmet → M003 stays pending - assertEq(s.registry.find(e => e.id === 'M003')?.status, 'pending', 'M003 stays pending after dep discarded'); - assertEq(s.phase, 'blocked', 'system is blocked (unmet dep on deleted milestone)'); - assert(s.blockers.length > 0, 'blockers list is not empty'); + assert.deepStrictEqual(s.registry.find(e => e.id === 'M003')?.status, 'pending', 'M003 stays pending after dep discarded'); + assert.deepStrictEqual(s.phase, 'blocked', 'system is blocked (unmet dep on deleted milestone)'); + assert.ok(s.blockers.length > 0, 'blockers list is not empty'); } finally { cleanup(b); } - } +}); // ─── EDGE 2: Park blocks depends_on chain ──────────────────────────── - console.log('\n=== EDGE 2: Park blocks depends_on chain ==='); - { +test('EDGE 2: Park blocks depends_on chain', async () => { const b = createFixture(); try { createM(b, 'M001', { roadmap: true, summary: true }); @@ -98,17 +88,16 @@ async function main(): Promise { parkMilestone(b, 'M002', 'testing'); const s = await deriveState(b); - assertEq(s.registry.find(e => e.id === 'M003')?.status, 'pending', 'M003 pending when M002 parked'); + assert.deepStrictEqual(s.registry.find(e => e.id === 'M003')?.status, 'pending', 'M003 pending when M002 parked'); // System should be blocked since M003 deps unmet and M002 is parked - assert(s.activeMilestone === null, 'no active milestone (M002 parked, M003 dep-blocked)'); + assert.ok(s.activeMilestone === null, 'no active milestone (M002 parked, M003 dep-blocked)'); } finally { cleanup(b); } - } +}); // ─── EDGE 3: Discard active, next (no deps) activates ──────────────── - console.log('\n=== EDGE 3: Discard active → next activates ==='); - { +test('EDGE 3: Discard active → next activates', async () => { const b = createFixture(); try { createM(b, 'M001', { roadmap: true }); @@ -117,16 +106,15 @@ async function main(): Promise { discardMilestone(b, 'M001'); const s = await deriveState(b); - assertEq(s.activeMilestone?.id, 'M002', 'M002 becomes active'); - assert(s.phase !== 'blocked', 'not blocked'); + assert.deepStrictEqual(s.activeMilestone?.id, 'M002', 'M002 becomes active'); + assert.ok(s.phase !== 'blocked', 'not blocked'); } finally { cleanup(b); } - } +}); // ─── EDGE 4: Park all + discard all → clean pre-planning ───────────── - console.log('\n=== EDGE 4: Park all → discard all → clean state ==='); - { +test('EDGE 4: Park all → discard all → clean state', async () => { const b = createFixture(); try { createM(b, 'M001', { roadmap: true }); @@ -138,30 +126,28 @@ async function main(): Promise { discardMilestone(b, 'M001'); discardMilestone(b, 'M002'); const s = await deriveState(b); - assertEq(s.activeMilestone, null, 'no active milestone'); - assertEq(s.phase, 'pre-planning', 'phase is pre-planning'); - assertEq(s.registry.length, 0, 'empty registry'); - assert(s.nextAction.includes('No milestones'), 'nextAction mentions no milestones'); + assert.deepStrictEqual(s.activeMilestone, null, 'no active milestone'); + assert.deepStrictEqual(s.phase, 'pre-planning', 'phase is pre-planning'); + assert.deepStrictEqual(s.registry.length, 0, 'empty registry'); + assert.ok(s.nextAction.includes('No milestones'), 'nextAction mentions no milestones'); } finally { cleanup(b); } - } +}); // ─── EDGE 5: Discard non-existent → graceful false ─────────────────── - console.log('\n=== EDGE 5: Discard non-existent ==='); - { +test('EDGE 5: Discard non-existent', () => { const b = createFixture(); try { const result = discardMilestone(b, 'M999'); - assert(!result, 'returns false for non-existent'); + assert.ok(!result, 'returns false for non-existent'); } finally { cleanup(b); } - } +}); // ─── EDGE 6: Queue order survives discards ─────────────────────────── - console.log('\n=== EDGE 6: Queue order after discard ==='); - { +test('EDGE 6: Queue order after discard', async () => { const b = createFixture(); try { createM(b, 'M001', { roadmap: true }); @@ -176,24 +162,23 @@ async function main(): Promise { // With custom queue order, M003 should be active first let s = await deriveState(b); - assertEq(s.activeMilestone?.id, 'M003', 'M003 active (custom queue order)'); + assert.deepStrictEqual(s.activeMilestone?.id, 'M003', 'M003 active (custom queue order)'); // Discard M003 → M001 should be next per queue order discardMilestone(b, 'M003'); s = await deriveState(b); - assertEq(s.activeMilestone?.id, 'M001', 'M001 active after M003 discarded'); + assert.deepStrictEqual(s.activeMilestone?.id, 'M001', 'M001 active after M003 discarded'); // Verify queue order file was updated const order = JSON.parse(readFileSync(join(b, '.gsd', 'QUEUE-ORDER.json'), 'utf-8')); - assert(!order.order.includes('M003'), 'M003 removed from QUEUE-ORDER.json'); + assert.ok(!order.order.includes('M003'), 'M003 removed from QUEUE-ORDER.json'); } finally { cleanup(b); } - } +}); // ─── EDGE 7: Discard milestone that has deps on others ─────────────── - console.log('\n=== EDGE 7: Discard a milestone that depends on others ==='); - { +test('EDGE 7: Discard a milestone that depends on others', async () => { const b = createFixture(); try { createM(b, 'M001', { roadmap: true }); @@ -203,23 +188,22 @@ async function main(): Promise { // M002 depends on M001, so M001 is active, M002 is pending let s = await deriveState(b); - assertEq(s.activeMilestone?.id, 'M001', 'M001 is active'); - assertEq(s.registry.find(e => e.id === 'M002')?.status, 'pending', 'M002 pending (dep on M001)'); + assert.deepStrictEqual(s.activeMilestone?.id, 'M001', 'M001 is active'); + assert.deepStrictEqual(s.registry.find(e => e.id === 'M002')?.status, 'pending', 'M002 pending (dep on M001)'); // Discard M002 (the one WITH deps) — should be fine, M003 becomes pending discardMilestone(b, 'M002'); s = await deriveState(b); - assertEq(s.activeMilestone?.id, 'M001', 'M001 still active'); - assert(!s.registry.some(e => e.id === 'M002'), 'M002 gone from registry'); - assertEq(s.registry.find(e => e.id === 'M003')?.status, 'pending', 'M003 is pending (after M001)'); + assert.deepStrictEqual(s.activeMilestone?.id, 'M001', 'M001 still active'); + assert.ok(!s.registry.some(e => e.id === 'M002'), 'M002 gone from registry'); + assert.deepStrictEqual(s.registry.find(e => e.id === 'M003')?.status, 'pending', 'M003 is pending (after M001)'); } finally { cleanup(b); } - } +}); // ─── EDGE 8: Park → Discard → state transitions ───────────────────── - console.log('\n=== EDGE 8: Park then discard same milestone ==='); - { +test('EDGE 8: Park then discard same milestone', async () => { const b = createFixture(); try { createM(b, 'M001', { roadmap: true }); @@ -228,22 +212,21 @@ async function main(): Promise { parkMilestone(b, 'M001', 'temp'); let s = await deriveState(b); - assertEq(s.activeMilestone?.id, 'M002', 'M002 active while M001 parked'); + assert.deepStrictEqual(s.activeMilestone?.id, 'M002', 'M002 active while M001 parked'); // Now discard the parked milestone discardMilestone(b, 'M001'); s = await deriveState(b); - assertEq(s.activeMilestone?.id, 'M002', 'M002 still active'); - assert(!s.registry.some(e => e.id === 'M001'), 'M001 gone completely'); - assertEq(s.registry.length, 1, 'only M002 in registry'); + assert.deepStrictEqual(s.activeMilestone?.id, 'M002', 'M002 still active'); + assert.ok(!s.registry.some(e => e.id === 'M001'), 'M001 gone completely'); + assert.deepStrictEqual(s.registry.length, 1, 'only M002 in registry'); } finally { cleanup(b); } - } +}); // ─── EDGE 9: Complete + parked + pending coexist ───────────────────── - console.log('\n=== EDGE 9: Mixed states — complete + parked + active ==='); - { +test('EDGE 9: Mixed states — complete + parked + active', async () => { const b = createFixture(); try { createM(b, 'M001', { roadmap: true, summary: true }); // complete @@ -254,23 +237,17 @@ async function main(): Promise { parkMilestone(b, 'M002', 'parked'); const s = await deriveState(b); - assertEq(s.registry.find(e => e.id === 'M001')?.status, 'complete', 'M001 complete'); - assertEq(s.registry.find(e => e.id === 'M002')?.status, 'parked', 'M002 parked'); - assertEq(s.registry.find(e => e.id === 'M003')?.status, 'active', 'M003 active'); - assertEq(s.registry.find(e => e.id === 'M004')?.status, 'pending', 'M004 pending'); - assertEq(s.activeMilestone?.id, 'M003', 'M003 is the active milestone'); - assertEq(s.progress?.milestones.done, 1, '1 done'); - assertEq(s.progress?.milestones.total, 4, '4 total'); + assert.deepStrictEqual(s.registry.find(e => e.id === 'M001')?.status, 'complete', 'M001 complete'); + assert.deepStrictEqual(s.registry.find(e => e.id === 'M002')?.status, 'parked', 'M002 parked'); + assert.deepStrictEqual(s.registry.find(e => e.id === 'M003')?.status, 'active', 'M003 active'); + assert.deepStrictEqual(s.registry.find(e => e.id === 'M004')?.status, 'pending', 'M004 pending'); + assert.deepStrictEqual(s.activeMilestone?.id, 'M003', 'M003 is the active milestone'); + assert.deepStrictEqual(s.progress?.milestones.done, 1, '1 done'); + assert.deepStrictEqual(s.progress?.milestones.total, 4, '4 total'); } finally { cleanup(b); } - } +}); - // ═══════════════════════════════════════════════════════════════════════ - console.log(`\n${'='.repeat(50)}`); - console.log(`Results: ${passed} passed, ${failed} failed`); - if (failed > 0) process.exit(1); - else console.log('All edge cases passed!'); -} +}); -main().catch(e => { console.error(e); process.exit(1); }); diff --git a/src/resources/extensions/gsd/tests/park-milestone.test.ts b/src/resources/extensions/gsd/tests/park-milestone.test.ts index a9b3d73a6..5d9cd4efd 100644 --- a/src/resources/extensions/gsd/tests/park-milestone.test.ts +++ b/src/resources/extensions/gsd/tests/park-milestone.test.ts @@ -1,3 +1,5 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -6,26 +8,7 @@ import { deriveState, invalidateStateCache, getActiveMilestoneId } from '../stat import { clearPathCache } from '../paths.ts'; import { parkMilestone, unparkMilestone, discardMilestone, isParked, getParkedReason } from '../milestone-actions.ts'; -let passed = 0; -let failed = 0; -function assert(condition: boolean, message: string): void { - if (condition) { - passed++; - } else { - failed++; - console.error(` FAIL: ${message}`); - } -} - -function assertEq(actual: T, expected: T, message: string): void { - if (JSON.stringify(actual) === JSON.stringify(expected)) { - passed++; - } else { - failed++; - console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); - } -} // ─── Fixture Helpers ─────────────────────────────────────────────────────── @@ -89,30 +72,28 @@ function clearCaches(): void { // Tests // ═══════════════════════════════════════════════════════════════════════════ -async function main(): Promise { - // ─── Test 1: parkMilestone creates PARKED.md ────────────────────────── - console.log('\n=== parkMilestone creates PARKED.md ==='); - { + +describe('park-milestone', () => { +test('parkMilestone creates PARKED.md', () => { const base = createFixtureBase(); try { createMilestone(base, 'M001', { withRoadmap: true }); clearCaches(); const success = parkMilestone(base, 'M001', 'Priority shift'); - assert(success, 'parkMilestone returns true'); - assert(isParked(base, 'M001'), 'isParked returns true after parking'); + assert.ok(success, 'parkMilestone returns true'); + assert.ok(isParked(base, 'M001'), 'isParked returns true after parking'); const reason = getParkedReason(base, 'M001'); - assertEq(reason, 'Priority shift', 'reason matches'); + assert.deepStrictEqual(reason, 'Priority shift', 'reason matches'); } finally { cleanup(base); } - } +}); // ─── Test 2: parkMilestone is idempotent — fails if already parked ──── - console.log('\n=== parkMilestone fails if already parked ==='); - { +test('parkMilestone fails if already parked', () => { const base = createFixtureBase(); try { createMilestone(base, 'M001', { withRoadmap: true }); @@ -120,50 +101,47 @@ async function main(): Promise { parkMilestone(base, 'M001', 'First park'); const secondPark = parkMilestone(base, 'M001', 'Second park'); - assert(!secondPark, 'second parkMilestone returns false'); - assertEq(getParkedReason(base, 'M001'), 'First park', 'reason unchanged from first park'); + assert.ok(!secondPark, 'second parkMilestone returns false'); + assert.deepStrictEqual(getParkedReason(base, 'M001'), 'First park', 'reason unchanged from first park'); } finally { cleanup(base); } - } +}); // ─── Test 3: unparkMilestone removes PARKED.md ──────────────────────── - console.log('\n=== unparkMilestone removes PARKED.md ==='); - { +test('unparkMilestone removes PARKED.md', () => { const base = createFixtureBase(); try { createMilestone(base, 'M001', { withRoadmap: true }); clearCaches(); parkMilestone(base, 'M001', 'Test reason'); - assert(isParked(base, 'M001'), 'milestone is parked'); + assert.ok(isParked(base, 'M001'), 'milestone is parked'); const success = unparkMilestone(base, 'M001'); - assert(success, 'unparkMilestone returns true'); - assert(!isParked(base, 'M001'), 'isParked returns false after unpark'); + assert.ok(success, 'unparkMilestone returns true'); + assert.ok(!isParked(base, 'M001'), 'isParked returns false after unpark'); } finally { cleanup(base); } - } +}); // ─── Test 4: unparkMilestone fails if not parked ────────────────────── - console.log('\n=== unparkMilestone fails if not parked ==='); - { +test('unparkMilestone fails if not parked', () => { const base = createFixtureBase(); try { createMilestone(base, 'M001', { withRoadmap: true }); clearCaches(); const result = unparkMilestone(base, 'M001'); - assert(!result, 'unparkMilestone returns false when not parked'); + assert.ok(!result, 'unparkMilestone returns false when not parked'); } finally { cleanup(base); } - } +}); // ─── Test 5: deriveState returns 'parked' status ────────────────────── - console.log('\n=== deriveState returns parked status ==='); - { +test('deriveState returns parked status', async () => { const base = createFixtureBase(); try { createMilestone(base, 'M001', { withRoadmap: true }); @@ -173,16 +151,15 @@ async function main(): Promise { const state = await deriveState(base); const entry = state.registry.find(e => e.id === 'M001'); - assert(!!entry, 'M001 in registry'); - assertEq(entry?.status, 'parked', 'status is parked'); + assert.ok(!!entry, 'M001 in registry'); + assert.deepStrictEqual(entry?.status, 'parked', 'status is parked'); } finally { cleanup(base); } - } +}); // ─── Test 6: deriveState skips parked milestone for active ───────────── - console.log('\n=== deriveState skips parked milestone ==='); - { +test('deriveState skips parked milestone', async () => { const base = createFixtureBase(); try { createMilestone(base, 'M001', { withRoadmap: true }); @@ -191,29 +168,28 @@ async function main(): Promise { // Before park: M001 is active const stateBefore = await deriveState(base); - assertEq(stateBefore.activeMilestone?.id, 'M001', 'before park: M001 is active'); + assert.deepStrictEqual(stateBefore.activeMilestone?.id, 'M001', 'before park: M001 is active'); parkMilestone(base, 'M001', 'Testing'); // After park: M002 becomes active const stateAfter = await deriveState(base); - assertEq(stateAfter.activeMilestone?.id, 'M002', 'after park: M002 is active'); + assert.deepStrictEqual(stateAfter.activeMilestone?.id, 'M002', 'after park: M002 is active'); // M001 still in registry as parked const m001 = stateAfter.registry.find(e => e.id === 'M001'); - assertEq(m001?.status, 'parked', 'M001 has parked status'); + assert.deepStrictEqual(m001?.status, 'parked', 'M001 has parked status'); // M002 is active const m002 = stateAfter.registry.find(e => e.id === 'M002'); - assertEq(m002?.status, 'active', 'M002 has active status'); + assert.deepStrictEqual(m002?.status, 'active', 'M002 has active status'); } finally { cleanup(base); } - } +}); // ─── Test 7: getActiveMilestoneId skips parked ──────────────────────── - console.log('\n=== getActiveMilestoneId skips parked ==='); - { +test('getActiveMilestoneId skips parked', async () => { const base = createFixtureBase(); try { createMilestone(base, 'M001', { withRoadmap: true }); @@ -223,15 +199,14 @@ async function main(): Promise { parkMilestone(base, 'M001', 'Testing'); const activeId = await getActiveMilestoneId(base); - assertEq(activeId, 'M002', 'getActiveMilestoneId returns M002'); + assert.deepStrictEqual(activeId, 'M002', 'getActiveMilestoneId returns M002'); } finally { cleanup(base); } - } +}); // ─── Test 8: Parked milestone does NOT satisfy depends_on ───────────── - console.log('\n=== Parked milestone does not satisfy depends_on ==='); - { +test('Parked milestone does not satisfy depends_on', async () => { const base = createFixtureBase(); try { createMilestone(base, 'M001', { withRoadmap: true }); @@ -243,18 +218,17 @@ async function main(): Promise { const state = await deriveState(base); // M001 is parked, M002 depends on M001 → M002 should be pending, not active const m002 = state.registry.find(e => e.id === 'M002'); - assertEq(m002?.status, 'pending', 'M002 stays pending when M001 is parked'); + assert.deepStrictEqual(m002?.status, 'pending', 'M002 stays pending when M001 is parked'); // No active milestone (both are blocked/parked) - assertEq(state.activeMilestone, null, 'no active milestone'); + assert.deepStrictEqual(state.activeMilestone, null, 'no active milestone'); } finally { cleanup(base); } - } +}); // ─── Test 9: Park then unpark restores correct status ───────────────── - console.log('\n=== Park then unpark restores status ==='); - { +test('Park then unpark restores status', async () => { const base = createFixtureBase(); try { createMilestone(base, 'M001', { withRoadmap: true }); @@ -264,43 +238,41 @@ async function main(): Promise { // Park M001 parkMilestone(base, 'M001', 'Testing'); const stateParked = await deriveState(base); - assertEq(stateParked.activeMilestone?.id, 'M002', 'while parked: M002 is active'); + assert.deepStrictEqual(stateParked.activeMilestone?.id, 'M002', 'while parked: M002 is active'); // Unpark M001 — M001 should become active again (it's first in queue) unparkMilestone(base, 'M001'); const stateUnparked = await deriveState(base); - assertEq(stateUnparked.activeMilestone?.id, 'M001', 'after unpark: M001 is active again'); - assertEq(stateUnparked.registry.find(e => e.id === 'M001')?.status, 'active', 'M001 is active status'); + assert.deepStrictEqual(stateUnparked.activeMilestone?.id, 'M001', 'after unpark: M001 is active again'); + assert.deepStrictEqual(stateUnparked.registry.find(e => e.id === 'M001')?.status, 'active', 'M001 is active status'); } finally { cleanup(base); } - } +}); // ─── Test 10: discardMilestone removes directory ────────────────────── - console.log('\n=== discardMilestone removes directory ==='); - { +test('discardMilestone removes directory', async () => { const base = createFixtureBase(); try { createMilestone(base, 'M001', { withRoadmap: true }); clearCaches(); const mDir = join(base, '.gsd', 'milestones', 'M001'); - assert(existsSync(mDir), 'milestone dir exists before discard'); + assert.ok(existsSync(mDir), 'milestone dir exists before discard'); const success = discardMilestone(base, 'M001'); - assert(success, 'discardMilestone returns true'); - assert(!existsSync(mDir), 'milestone dir removed after discard'); + assert.ok(success, 'discardMilestone returns true'); + assert.ok(!existsSync(mDir), 'milestone dir removed after discard'); const state = await deriveState(base); - assert(!state.registry.some(e => e.id === 'M001'), 'M001 not in registry after discard'); + assert.ok(!state.registry.some(e => e.id === 'M001'), 'M001 not in registry after discard'); } finally { cleanup(base); } - } +}); // ─── Test 11: discardMilestone updates queue order ──────────────────── - console.log('\n=== discardMilestone updates queue order ==='); - { +test('discardMilestone updates queue order', () => { const base = createFixtureBase(); try { createMilestone(base, 'M001', { withRoadmap: true }); @@ -315,16 +287,15 @@ async function main(): Promise { // Queue order should no longer include M001 const queueContent = JSON.parse(readFileSync(queuePath, 'utf-8')); - assert(!queueContent.order.includes('M001'), 'M001 removed from queue order'); - assert(queueContent.order.includes('M002'), 'M002 still in queue order'); + assert.ok(!queueContent.order.includes('M001'), 'M001 removed from queue order'); + assert.ok(queueContent.order.includes('M002'), 'M002 still in queue order'); } finally { cleanup(base); } - } +}); // ─── Test 12: All milestones parked → no active milestone ───────────── - console.log('\n=== All milestones parked → no active ==='); - { +test('All milestones parked → no active', async () => { const base = createFixtureBase(); try { createMilestone(base, 'M001', { withRoadmap: true }); @@ -333,18 +304,17 @@ async function main(): Promise { parkMilestone(base, 'M001', 'Testing'); const state = await deriveState(base); - assertEq(state.activeMilestone, null, 'no active milestone when all parked'); - assertEq(state.phase, 'pre-planning', 'phase is pre-planning'); - assert(state.registry.length === 1, 'registry still has 1 entry'); - assertEq(state.registry[0]?.status, 'parked', 'entry is parked'); + assert.deepStrictEqual(state.activeMilestone, null, 'no active milestone when all parked'); + assert.deepStrictEqual(state.phase, 'pre-planning', 'phase is pre-planning'); + assert.ok(state.registry.length === 1, 'registry still has 1 entry'); + assert.deepStrictEqual(state.registry[0]?.status, 'parked', 'entry is parked'); } finally { cleanup(base); } - } +}); // ─── Test 13: Parked milestone without roadmap ──────────────────────── - console.log('\n=== Park milestone without roadmap ==='); - { +test('Park milestone without roadmap', async () => { const base = createFixtureBase(); try { createMilestone(base, 'M001'); // No roadmap @@ -354,16 +324,15 @@ async function main(): Promise { parkMilestone(base, 'M001', 'Not ready yet'); const state = await deriveState(base); - assertEq(state.activeMilestone?.id, 'M002', 'M002 is active when M001 (no roadmap) is parked'); - assertEq(state.registry.find(e => e.id === 'M001')?.status, 'parked', 'M001 is parked'); + assert.deepStrictEqual(state.activeMilestone?.id, 'M002', 'M002 is active when M001 (no roadmap) is parked'); + assert.deepStrictEqual(state.registry.find(e => e.id === 'M001')?.status, 'parked', 'M001 is parked'); } finally { cleanup(base); } - } +}); // ─── Test 14: Progress counts with parked milestone ─────────────────── - console.log('\n=== Progress counts with parked ==='); - { +test('Progress counts with parked', async () => { const base = createFixtureBase(); try { createMilestone(base, 'M001', { withRoadmap: true, withSummary: true }); // complete @@ -374,28 +343,12 @@ async function main(): Promise { parkMilestone(base, 'M002', 'Parked'); const state = await deriveState(base); - assertEq(state.progress?.milestones.done, 1, '1 complete milestone'); - assertEq(state.progress?.milestones.total, 3, '3 total milestones (including parked)'); - assertEq(state.activeMilestone?.id, 'M003', 'M003 is active'); + assert.deepStrictEqual(state.progress?.milestones.done, 1, '1 complete milestone'); + assert.deepStrictEqual(state.progress?.milestones.total, 3, '3 total milestones (including parked)'); + assert.deepStrictEqual(state.activeMilestone?.id, 'M003', 'M003 is active'); } finally { cleanup(base); } - } - - // ═══════════════════════════════════════════════════════════════════════════ - // Results - // ═══════════════════════════════════════════════════════════════════════════ - - console.log(`\n${'='.repeat(40)}`); - console.log(`Results: ${passed} passed, ${failed} failed`); - if (failed > 0) { - process.exit(1); - } else { - console.log('All tests passed ✓'); - } -} - -main().catch((error) => { - console.error(error); - process.exit(1); +}); + }); diff --git a/src/resources/extensions/gsd/tests/parsers.test.ts b/src/resources/extensions/gsd/tests/parsers.test.ts index 7325e9916..3292d71ad 100644 --- a/src/resources/extensions/gsd/tests/parsers.test.ts +++ b/src/resources/extensions/gsd/tests/parsers.test.ts @@ -1,14 +1,14 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; import { parseRoadmap, parsePlan } from '../parsers-legacy.ts'; import { parseTaskPlanFile, parseSummary, parseContinue, parseRequirementCounts, parseSecretsManifest, formatSecretsManifest } from '../files.ts'; -import { createTestContext } from './test-helpers.ts'; - -const { assertEq, assertTrue, report } = createTestContext(); // ═══════════════════════════════════════════════════════════════════════════ // parseRoadmap tests // ═══════════════════════════════════════════════════════════════════════════ -console.log('\n=== parseRoadmap: full roadmap ==='); -{ + +describe('parsers', () => { +test('parseRoadmap: full roadmap', () => { const content = `# M001: GSD Extension — Hierarchical Planning **Vision:** Build a structured planning system for coding agents. @@ -57,44 +57,43 @@ Consumes from S03: const r = parseRoadmap(content); - assertEq(r.title, 'M001: GSD Extension — Hierarchical Planning', 'roadmap title'); - assertEq(r.vision, 'Build a structured planning system for coding agents.', 'roadmap vision'); - assertEq(r.successCriteria.length, 3, 'success criteria count'); - assertEq(r.successCriteria[0], 'All parsers have test coverage', 'first success criterion'); - assertEq(r.successCriteria[2], 'State derivation works correctly', 'third success criterion'); + assert.deepStrictEqual(r.title, 'M001: GSD Extension — Hierarchical Planning', 'roadmap title'); + assert.deepStrictEqual(r.vision, 'Build a structured planning system for coding agents.', 'roadmap vision'); + assert.deepStrictEqual(r.successCriteria.length, 3, 'success criteria count'); + assert.deepStrictEqual(r.successCriteria[0], 'All parsers have test coverage', 'first success criterion'); + assert.deepStrictEqual(r.successCriteria[2], 'State derivation works correctly', 'third success criterion'); // Slices - assertEq(r.slices.length, 3, 'slice count'); + assert.deepStrictEqual(r.slices.length, 3, 'slice count'); - assertEq(r.slices[0].id, 'S01', 'S01 id'); - assertEq(r.slices[0].title, 'Types + File I/O', 'S01 title'); - assertEq(r.slices[0].risk, 'low', 'S01 risk'); - assertEq(r.slices[0].depends, [], 'S01 depends'); - assertEq(r.slices[0].done, true, 'S01 done'); - assertEq(r.slices[0].demo, 'All types defined and parsers work.', 'S01 demo'); + assert.deepStrictEqual(r.slices[0].id, 'S01', 'S01 id'); + assert.deepStrictEqual(r.slices[0].title, 'Types + File I/O', 'S01 title'); + assert.deepStrictEqual(r.slices[0].risk, 'low', 'S01 risk'); + assert.deepStrictEqual(r.slices[0].depends, [], 'S01 depends'); + assert.deepStrictEqual(r.slices[0].done, true, 'S01 done'); + assert.deepStrictEqual(r.slices[0].demo, 'All types defined and parsers work.', 'S01 demo'); - assertEq(r.slices[1].id, 'S02', 'S02 id'); - assertEq(r.slices[1].title, 'State Derivation', 'S02 title'); - assertEq(r.slices[1].risk, 'medium', 'S02 risk'); - assertEq(r.slices[1].depends, ['S01'], 'S02 depends'); - assertEq(r.slices[1].done, false, 'S02 done'); + assert.deepStrictEqual(r.slices[1].id, 'S02', 'S02 id'); + assert.deepStrictEqual(r.slices[1].title, 'State Derivation', 'S02 title'); + assert.deepStrictEqual(r.slices[1].risk, 'medium', 'S02 risk'); + assert.deepStrictEqual(r.slices[1].depends, ['S01'], 'S02 depends'); + assert.deepStrictEqual(r.slices[1].done, false, 'S02 done'); - assertEq(r.slices[2].id, 'S03', 'S03 id'); - assertEq(r.slices[2].risk, 'high', 'S03 risk'); - assertEq(r.slices[2].depends, ['S01', 'S02'], 'S03 depends'); - assertEq(r.slices[2].done, false, 'S03 done'); + assert.deepStrictEqual(r.slices[2].id, 'S03', 'S03 id'); + assert.deepStrictEqual(r.slices[2].risk, 'high', 'S03 risk'); + assert.deepStrictEqual(r.slices[2].depends, ['S01', 'S02'], 'S03 depends'); + assert.deepStrictEqual(r.slices[2].done, false, 'S03 done'); // Boundary map - assertEq(r.boundaryMap.length, 2, 'boundary map entry count'); - assertEq(r.boundaryMap[0].fromSlice, 'S01', 'bm[0] from'); - assertEq(r.boundaryMap[0].toSlice, 'S02', 'bm[0] to'); - assertTrue(r.boundaryMap[0].produces.includes('types.ts'), 'bm[0] produces mentions types.ts'); - assertEq(r.boundaryMap[1].fromSlice, 'S02', 'bm[1] from'); - assertEq(r.boundaryMap[1].toSlice, 'S03', 'bm[1] to'); -} + assert.deepStrictEqual(r.boundaryMap.length, 2, 'boundary map entry count'); + assert.deepStrictEqual(r.boundaryMap[0].fromSlice, 'S01', 'bm[0] from'); + assert.deepStrictEqual(r.boundaryMap[0].toSlice, 'S02', 'bm[0] to'); + assert.ok(r.boundaryMap[0].produces.includes('types.ts'), 'bm[0] produces mentions types.ts'); + assert.deepStrictEqual(r.boundaryMap[1].fromSlice, 'S02', 'bm[1] from'); + assert.deepStrictEqual(r.boundaryMap[1].toSlice, 'S03', 'bm[1] to'); +}); -console.log('\n=== parseRoadmap: empty slices section ==='); -{ +test('parseRoadmap: empty slices section', () => { const content = `# M002: Empty Milestone **Vision:** Nothing yet. @@ -105,13 +104,12 @@ console.log('\n=== parseRoadmap: empty slices section ==='); `; const r = parseRoadmap(content); - assertEq(r.title, 'M002: Empty Milestone', 'title with empty slices'); - assertEq(r.slices.length, 0, 'no slices parsed'); - assertEq(r.boundaryMap.length, 0, 'no boundary map entries'); -} + assert.deepStrictEqual(r.title, 'M002: Empty Milestone', 'title with empty slices'); + assert.deepStrictEqual(r.slices.length, 0, 'no slices parsed'); + assert.deepStrictEqual(r.boundaryMap.length, 0, 'no boundary map entries'); +}); -console.log('\n=== parseRoadmap: malformed checkbox lines ==='); -{ +test('parseRoadmap: malformed checkbox lines', () => { // Lines that don't match the expected bold pattern should be skipped const content = `# M003: Malformed @@ -130,15 +128,14 @@ console.log('\n=== parseRoadmap: malformed checkbox lines ==='); const r = parseRoadmap(content); // Only S02 and S03 should be parsed (malformed lines without bold markers are skipped) - assertEq(r.slices.length, 2, 'only valid slices parsed from malformed input'); - assertEq(r.slices[0].id, 'S02', 'first valid slice is S02'); - assertEq(r.slices[0].done, true, 'S02 done'); - assertEq(r.slices[1].id, 'S03', 'second valid slice is S03'); - assertEq(r.slices[1].depends, ['S02'], 'S03 depends on S02'); -} + assert.deepStrictEqual(r.slices.length, 2, 'only valid slices parsed from malformed input'); + assert.deepStrictEqual(r.slices[0].id, 'S02', 'first valid slice is S02'); + assert.deepStrictEqual(r.slices[0].done, true, 'S02 done'); + assert.deepStrictEqual(r.slices[1].id, 'S03', 'second valid slice is S03'); + assert.deepStrictEqual(r.slices[1].depends, ['S02'], 'S03 depends on S02'); +}); -console.log('\n=== parseRoadmap: lowercase vs uppercase X for done ==='); -{ +test('parseRoadmap: lowercase vs uppercase X for done', () => { const content = `# M004: Case Test **Vision:** Test X case sensitivity. @@ -156,14 +153,13 @@ console.log('\n=== parseRoadmap: lowercase vs uppercase X for done ==='); `; const r = parseRoadmap(content); - assertEq(r.slices.length, 3, 'all three slices parsed'); - assertEq(r.slices[0].done, true, 'lowercase x is done'); - assertEq(r.slices[1].done, true, 'uppercase X is done'); - assertEq(r.slices[2].done, false, 'space is not done'); -} + assert.deepStrictEqual(r.slices.length, 3, 'all three slices parsed'); + assert.deepStrictEqual(r.slices[0].done, true, 'lowercase x is done'); + assert.deepStrictEqual(r.slices[1].done, true, 'uppercase X is done'); + assert.deepStrictEqual(r.slices[2].done, false, 'space is not done'); +}); -console.log('\n=== parseRoadmap: missing boundary map ==='); -{ +test('parseRoadmap: missing boundary map', () => { const content = `# M005: No Boundary Map **Vision:** A roadmap without a boundary map section. @@ -180,29 +176,27 @@ console.log('\n=== parseRoadmap: missing boundary map ==='); `; const r = parseRoadmap(content); - assertEq(r.title, 'M005: No Boundary Map', 'title'); - assertEq(r.slices.length, 1, 'one slice'); - assertEq(r.boundaryMap.length, 0, 'empty boundary map when section missing'); - assertEq(r.successCriteria.length, 1, 'one success criterion'); -} + assert.deepStrictEqual(r.title, 'M005: No Boundary Map', 'title'); + assert.deepStrictEqual(r.slices.length, 1, 'one slice'); + assert.deepStrictEqual(r.boundaryMap.length, 0, 'empty boundary map when section missing'); + assert.deepStrictEqual(r.successCriteria.length, 1, 'one success criterion'); +}); -console.log('\n=== parseRoadmap: no sections at all ==='); -{ +test('parseRoadmap: no sections at all', () => { const content = `# M006: Bare Minimum Just a title and nothing else. `; const r = parseRoadmap(content); - assertEq(r.title, 'M006: Bare Minimum', 'title from bare roadmap'); - assertEq(r.vision, '', 'empty vision'); - assertEq(r.successCriteria.length, 0, 'no success criteria'); - assertEq(r.slices.length, 0, 'no slices'); - assertEq(r.boundaryMap.length, 0, 'no boundary map'); -} + assert.deepStrictEqual(r.title, 'M006: Bare Minimum', 'title from bare roadmap'); + assert.deepStrictEqual(r.vision, '', 'empty vision'); + assert.deepStrictEqual(r.successCriteria.length, 0, 'no success criteria'); + assert.deepStrictEqual(r.slices.length, 0, 'no slices'); + assert.deepStrictEqual(r.boundaryMap.length, 0, 'no boundary map'); +}); -console.log('\n=== parseRoadmap: slice with no demo blockquote ==='); -{ +test('parseRoadmap: slice with no demo blockquote', () => { const content = `# M007: No Demo **Vision:** Testing slices without demo lines. @@ -214,13 +208,12 @@ console.log('\n=== parseRoadmap: slice with no demo blockquote ==='); `; const r = parseRoadmap(content); - assertEq(r.slices.length, 2, 'two slices without demos'); - assertEq(r.slices[0].demo, '', 'S01 demo empty'); - assertEq(r.slices[1].demo, '', 'S02 demo empty'); -} + assert.deepStrictEqual(r.slices.length, 2, 'two slices without demos'); + assert.deepStrictEqual(r.slices[0].demo, '', 'S01 demo empty'); + assert.deepStrictEqual(r.slices[1].demo, '', 'S02 demo empty'); +}); -console.log('\n=== parseRoadmap: missing risk defaults to low ==='); -{ +test('parseRoadmap: missing risk defaults to low', () => { const content = `# M008: Default Risk **Vision:** Test default risk. @@ -232,16 +225,14 @@ console.log('\n=== parseRoadmap: missing risk defaults to low ==='); `; const r = parseRoadmap(content); - assertEq(r.slices.length, 1, 'one slice'); - assertEq(r.slices[0].risk, 'low', 'default risk is low'); -} + assert.deepStrictEqual(r.slices.length, 1, 'one slice'); + assert.deepStrictEqual(r.slices[0].risk, 'low', 'default risk is low'); +}); // ═══════════════════════════════════════════════════════════════════════════ // parsePlan tests // ═══════════════════════════════════════════════════════════════════════════ - -console.log('\n=== parsePlan: full plan ==='); -{ +test('parsePlan: full plan', () => { const content = `--- estimated_steps: 6 estimated_files: 3 @@ -277,42 +268,41 @@ skills_used: `; const taskPlan = parseTaskPlanFile(content); - assertEq(taskPlan.frontmatter.estimated_steps, 6, 'task plan frontmatter estimated_steps'); - assertEq(taskPlan.frontmatter.estimated_files, 3, 'task plan frontmatter estimated_files'); - assertEq(taskPlan.frontmatter.skills_used.length, 2, 'task plan frontmatter skills_used count'); - assertEq(taskPlan.frontmatter.skills_used[0], 'typescript', 'first task plan skill'); - assertEq(taskPlan.frontmatter.skills_used[1], 'testing', 'second task plan skill'); + assert.deepStrictEqual(taskPlan.frontmatter.estimated_steps, 6, 'task plan frontmatter estimated_steps'); + assert.deepStrictEqual(taskPlan.frontmatter.estimated_files, 3, 'task plan frontmatter estimated_files'); + assert.deepStrictEqual(taskPlan.frontmatter.skills_used.length, 2, 'task plan frontmatter skills_used count'); + assert.deepStrictEqual(taskPlan.frontmatter.skills_used[0], 'typescript', 'first task plan skill'); + assert.deepStrictEqual(taskPlan.frontmatter.skills_used[1], 'testing', 'second task plan skill'); const p = parsePlan(content); - assertEq(p.id, 'S01', 'plan id'); - assertEq(p.title, 'Parser Test Suite', 'plan title'); - assertEq(p.goal, 'All 5 parsers have test coverage with edge cases.', 'plan goal'); - assertEq(p.demo, '`node --test tests/parsers.test.ts` passes with zero failures.', 'plan demo'); + assert.deepStrictEqual(p.id, 'S01', 'plan id'); + assert.deepStrictEqual(p.title, 'Parser Test Suite', 'plan title'); + assert.deepStrictEqual(p.goal, 'All 5 parsers have test coverage with edge cases.', 'plan goal'); + assert.deepStrictEqual(p.demo, '`node --test tests/parsers.test.ts` passes with zero failures.', 'plan demo'); // Must-haves - assertEq(p.mustHaves.length, 3, 'must-have count'); - assertEq(p.mustHaves[0], 'parseRoadmap tests cover happy path and edge cases', 'first must-have'); + assert.deepStrictEqual(p.mustHaves.length, 3, 'must-have count'); + assert.deepStrictEqual(p.mustHaves[0], 'parseRoadmap tests cover happy path and edge cases', 'first must-have'); // Tasks - assertEq(p.tasks.length, 2, 'task count'); + assert.deepStrictEqual(p.tasks.length, 2, 'task count'); - assertEq(p.tasks[0].id, 'T01', 'T01 id'); - assertEq(p.tasks[0].title, 'Test parseRoadmap and parsePlan', 'T01 title'); - assertEq(p.tasks[0].done, false, 'T01 not done'); - assertTrue(p.tasks[0].description.includes('comprehensive tests'), 'T01 description content'); + assert.deepStrictEqual(p.tasks[0].id, 'T01', 'T01 id'); + assert.deepStrictEqual(p.tasks[0].title, 'Test parseRoadmap and parsePlan', 'T01 title'); + assert.deepStrictEqual(p.tasks[0].done, false, 'T01 not done'); + assert.ok(p.tasks[0].description.includes('comprehensive tests'), 'T01 description content'); - assertEq(p.tasks[1].id, 'T02', 'T02 id'); - assertEq(p.tasks[1].title, 'Test parseSummary and parseContinue', 'T02 title'); - assertEq(p.tasks[1].done, true, 'T02 done'); + assert.deepStrictEqual(p.tasks[1].id, 'T02', 'T02 id'); + assert.deepStrictEqual(p.tasks[1].title, 'Test parseSummary and parseContinue', 'T02 title'); + assert.deepStrictEqual(p.tasks[1].done, true, 'T02 done'); // Files likely touched - assertEq(p.filesLikelyTouched.length, 3, 'files likely touched count'); - assertTrue(p.filesLikelyTouched[0].includes('tests/parsers.test.ts'), 'first file'); -} + assert.deepStrictEqual(p.filesLikelyTouched.length, 3, 'files likely touched count'); + assert.ok(p.filesLikelyTouched[0].includes('tests/parsers.test.ts'), 'first file'); +}); -console.log('\n=== parseTaskPlanFile: defaults missing frontmatter fields ==='); -{ +test('parseTaskPlanFile: defaults missing frontmatter fields', () => { const content = `# T01: Minimal task plan ## Description @@ -321,13 +311,12 @@ No frontmatter here. `; const taskPlan = parseTaskPlanFile(content); - assertEq(taskPlan.frontmatter.estimated_steps, undefined, 'estimated_steps defaults undefined'); - assertEq(taskPlan.frontmatter.estimated_files, undefined, 'estimated_files defaults undefined'); - assertEq(taskPlan.frontmatter.skills_used.length, 0, 'skills_used defaults empty array'); -} + assert.deepStrictEqual(taskPlan.frontmatter.estimated_steps, undefined, 'estimated_steps defaults undefined'); + assert.deepStrictEqual(taskPlan.frontmatter.estimated_files, undefined, 'estimated_files defaults undefined'); + assert.deepStrictEqual(taskPlan.frontmatter.skills_used.length, 0, 'skills_used defaults empty array'); +}); -console.log('\n=== parseTaskPlanFile: accepts scalar skills_used and numeric strings ==='); -{ +test('parseTaskPlanFile: accepts scalar skills_used and numeric strings', () => { const content = `--- estimated_steps: "9" estimated_files: "4" @@ -338,14 +327,13 @@ skills_used: react-best-practices `; const taskPlan = parseTaskPlanFile(content); - assertEq(taskPlan.frontmatter.estimated_steps, 9, 'string estimated_steps parsed'); - assertEq(taskPlan.frontmatter.estimated_files, 4, 'string estimated_files parsed'); - assertEq(taskPlan.frontmatter.skills_used.length, 1, 'scalar skills_used normalized to array'); - assertEq(taskPlan.frontmatter.skills_used[0], 'react-best-practices', 'scalar skill preserved'); -} + assert.deepStrictEqual(taskPlan.frontmatter.estimated_steps, 9, 'string estimated_steps parsed'); + assert.deepStrictEqual(taskPlan.frontmatter.estimated_files, 4, 'string estimated_files parsed'); + assert.deepStrictEqual(taskPlan.frontmatter.skills_used.length, 1, 'scalar skills_used normalized to array'); + assert.deepStrictEqual(taskPlan.frontmatter.skills_used[0], 'react-best-practices', 'scalar skill preserved'); +}); -console.log('\n=== parseTaskPlanFile: filters blank skills_used items ==='); -{ +test('parseTaskPlanFile: filters blank skills_used items', () => { const content = `--- skills_used: - react @@ -357,13 +345,12 @@ skills_used: `; const taskPlan = parseTaskPlanFile(content); - assertEq(taskPlan.frontmatter.skills_used.length, 2, 'blank skill entries removed'); - assertEq(taskPlan.frontmatter.skills_used[0], 'react', 'first remaining skill'); - assertEq(taskPlan.frontmatter.skills_used[1], 'testing', 'second remaining skill'); -} + assert.deepStrictEqual(taskPlan.frontmatter.skills_used.length, 2, 'blank skill entries removed'); + assert.deepStrictEqual(taskPlan.frontmatter.skills_used[0], 'react', 'first remaining skill'); + assert.deepStrictEqual(taskPlan.frontmatter.skills_used[1], 'testing', 'second remaining skill'); +}); -console.log('\n=== parseTaskPlanFile: invalid numeric frontmatter ignored ==='); -{ +test('parseTaskPlanFile: invalid numeric frontmatter ignored', () => { const content = `--- estimated_steps: many estimated_files: unknown @@ -373,12 +360,11 @@ estimated_files: unknown `; const taskPlan = parseTaskPlanFile(content); - assertEq(taskPlan.frontmatter.estimated_steps, undefined, 'invalid estimated_steps ignored'); - assertEq(taskPlan.frontmatter.estimated_files, undefined, 'invalid estimated_files ignored'); -} + assert.deepStrictEqual(taskPlan.frontmatter.estimated_steps, undefined, 'invalid estimated_steps ignored'); + assert.deepStrictEqual(taskPlan.frontmatter.estimated_files, undefined, 'invalid estimated_files ignored'); +}); -console.log('\n=== parseTaskPlanFile: parsePlan ignores task-plan frontmatter ==='); -{ +test('parseTaskPlanFile: parsePlan ignores task-plan frontmatter', () => { const content = `--- estimated_steps: 2 estimated_files: 1 @@ -398,12 +384,11 @@ skills_used: `; const p = parsePlan(content); - assertEq(p.id, 'S11', 'plan id still parsed with frontmatter'); - assertEq(p.tasks.length, 1, 'task still parsed with frontmatter'); -} + assert.deepStrictEqual(p.id, 'S11', 'plan id still parsed with frontmatter'); + assert.deepStrictEqual(p.tasks.length, 1, 'task still parsed with frontmatter'); +}); -console.log('\n=== parsePlan: multi-line task description concatenation ==='); -{ +test('parsePlan: multi-line task description concatenation', () => { const content = `# S02: Multi-line Test **Goal:** Test multi-line descriptions. @@ -430,16 +415,15 @@ console.log('\n=== parsePlan: multi-line task description concatenation ==='); const p = parsePlan(content); - assertEq(p.tasks.length, 2, 'two tasks'); - assertTrue(p.tasks[0].description.includes('First line'), 'T01 desc has first line'); - assertTrue(p.tasks[0].description.includes('Second line'), 'T01 desc has second line'); - assertTrue(p.tasks[0].description.includes('Third line'), 'T01 desc has third line'); - assertTrue(p.tasks[0].description.includes('description. Second'), 'lines joined with space'); - assertEq(p.tasks[1].description, 'Just one line.', 'T02 single-line desc'); -} + assert.deepStrictEqual(p.tasks.length, 2, 'two tasks'); + assert.ok(p.tasks[0].description.includes('First line'), 'T01 desc has first line'); + assert.ok(p.tasks[0].description.includes('Second line'), 'T01 desc has second line'); + assert.ok(p.tasks[0].description.includes('Third line'), 'T01 desc has third line'); + assert.ok(p.tasks[0].description.includes('description. Second'), 'lines joined with space'); + assert.deepStrictEqual(p.tasks[1].description, 'Just one line.', 'T02 single-line desc'); +}); -console.log('\n=== parsePlan: frontmatter does not pollute task descriptions ==='); -{ +test('parsePlan: frontmatter does not pollute task descriptions', () => { const content = `--- estimated_steps: 2 estimated_files: 1 @@ -457,12 +441,11 @@ skills_used: `; const p = parsePlan(content); - assertEq(p.tasks.length, 1, 'one task parsed with frontmatter'); - assertEq(p.tasks[0].description, 'First line of description. Second line of description.', 'frontmatter excluded from description'); -} + assert.deepStrictEqual(p.tasks.length, 1, 'one task parsed with frontmatter'); + assert.deepStrictEqual(p.tasks[0].description, 'First line of description. Second line of description.', 'frontmatter excluded from description'); +}); -console.log('\n=== parsePlan: task with missing estimate ==='); -{ +test('parsePlan: task with missing estimate', () => { const content = `# S03: No Estimate **Goal:** Handle tasks without estimates. @@ -478,15 +461,14 @@ console.log('\n=== parsePlan: task with missing estimate ==='); `; const p = parsePlan(content); - assertEq(p.tasks.length, 2, 'two tasks parsed'); - assertEq(p.tasks[0].id, 'T01', 'T01 id'); - assertEq(p.tasks[0].title, 'No Estimate Task', 'T01 title without estimate'); - assertEq(p.tasks[0].done, false, 'T01 not done'); - assertEq(p.tasks[1].id, 'T02', 'T02 id'); -} + assert.deepStrictEqual(p.tasks.length, 2, 'two tasks parsed'); + assert.deepStrictEqual(p.tasks[0].id, 'T01', 'T01 id'); + assert.deepStrictEqual(p.tasks[0].title, 'No Estimate Task', 'T01 title without estimate'); + assert.deepStrictEqual(p.tasks[0].done, false, 'T01 not done'); + assert.deepStrictEqual(p.tasks[1].id, 'T02', 'T02 id'); +}); -console.log('\n=== parsePlan: empty tasks section ==='); -{ +test('parsePlan: empty tasks section', () => { const content = `# S04: Empty Tasks **Goal:** No tasks yet. @@ -504,14 +486,13 @@ console.log('\n=== parsePlan: empty tasks section ==='); `; const p = parsePlan(content); - assertEq(p.id, 'S04', 'plan id with empty tasks'); - assertEq(p.tasks.length, 0, 'no tasks'); - assertEq(p.mustHaves.length, 1, 'one must-have'); - assertEq(p.filesLikelyTouched.length, 1, 'one file'); -} + assert.deepStrictEqual(p.id, 'S04', 'plan id with empty tasks'); + assert.deepStrictEqual(p.tasks.length, 0, 'no tasks'); + assert.deepStrictEqual(p.mustHaves.length, 1, 'one must-have'); + assert.deepStrictEqual(p.filesLikelyTouched.length, 1, 'one file'); +}); -console.log('\n=== parsePlan: no H1 ==='); -{ +test('parsePlan: no H1', () => { const content = `**Goal:** A plan without a heading. **Demo:** Still parses. @@ -522,15 +503,14 @@ console.log('\n=== parsePlan: no H1 ==='); `; const p = parsePlan(content); - assertEq(p.id, '', 'empty id without H1'); - assertEq(p.title, '', 'empty title without H1'); - assertEq(p.goal, 'A plan without a heading.', 'goal still parsed'); - assertEq(p.tasks.length, 1, 'task still parsed'); - assertEq(p.tasks[0].id, 'T01', 'task id'); -} + assert.deepStrictEqual(p.id, '', 'empty id without H1'); + assert.deepStrictEqual(p.title, '', 'empty title without H1'); + assert.deepStrictEqual(p.goal, 'A plan without a heading.', 'goal still parsed'); + assert.deepStrictEqual(p.tasks.length, 1, 'task still parsed'); + assert.deepStrictEqual(p.tasks[0].id, 'T01', 'task id'); +}); -console.log('\n=== parsePlan: task estimate backtick in description ==='); -{ +test('parsePlan: task estimate backtick in description', () => { const content = `# S05: Estimate Handling **Goal:** Test estimate text handling. @@ -543,14 +523,13 @@ console.log('\n=== parsePlan: task estimate backtick in description ==='); `; const p = parsePlan(content); - assertEq(p.tasks.length, 1, 'one task'); - assertEq(p.tasks[0].id, 'T01', 'task id'); - assertEq(p.tasks[0].title, 'With Estimate', 'title excludes estimate'); - assertTrue(p.tasks[0].description.includes('Main description'), 'description from continuation line'); -} + assert.deepStrictEqual(p.tasks.length, 1, 'one task'); + assert.deepStrictEqual(p.tasks[0].id, 'T01', 'task id'); + assert.deepStrictEqual(p.tasks[0].title, 'With Estimate', 'title excludes estimate'); + assert.ok(p.tasks[0].description.includes('Main description'), 'description from continuation line'); +}); -console.log('\n=== parsePlan: uppercase X for done ==='); -{ +test('parsePlan: uppercase X for done', () => { const content = `# S06: Case Test **Goal:** Test case. @@ -566,12 +545,11 @@ console.log('\n=== parsePlan: uppercase X for done ==='); `; const p = parsePlan(content); - assertEq(p.tasks[0].done, true, 'uppercase X is done'); - assertEq(p.tasks[1].done, true, 'lowercase x is done'); -} + assert.deepStrictEqual(p.tasks[0].done, true, 'uppercase X is done'); + assert.deepStrictEqual(p.tasks[1].done, true, 'lowercase x is done'); +}); -console.log('\n=== parsePlan: no Must-Haves section ==='); -{ +test('parsePlan: no Must-Haves section', () => { const content = `# S07: No Must-Haves **Goal:** Test missing must-haves. @@ -584,12 +562,11 @@ console.log('\n=== parsePlan: no Must-Haves section ==='); `; const p = parsePlan(content); - assertEq(p.mustHaves.length, 0, 'empty must-haves'); - assertEq(p.tasks.length, 1, 'task still parsed'); -} + assert.deepStrictEqual(p.mustHaves.length, 0, 'empty must-haves'); + assert.deepStrictEqual(p.tasks.length, 1, 'task still parsed'); +}); -console.log('\n=== parsePlan: no Files Likely Touched section ==='); -{ +test('parsePlan: no Files Likely Touched section', () => { const content = `# S08: No Files **Goal:** Test missing files section. @@ -602,11 +579,10 @@ console.log('\n=== parsePlan: no Files Likely Touched section ==='); `; const p = parsePlan(content); - assertEq(p.filesLikelyTouched.length, 0, 'empty files likely touched'); -} + assert.deepStrictEqual(p.filesLikelyTouched.length, 0, 'empty files likely touched'); +}); -console.log('\n=== parsePlan: old-format task entries (no sublines) ==='); -{ +test('parsePlan: old-format task entries (no sublines)', () => { const content = `# S09: Old Format **Goal:** Test old-format compatibility. @@ -619,16 +595,15 @@ console.log('\n=== parsePlan: old-format task entries (no sublines) ==='); `; const p = parsePlan(content); - assertEq(p.tasks.length, 1, 'one task parsed'); - assertEq(p.tasks[0].id, 'T01', 'task id'); - assertEq(p.tasks[0].title, 'Classic Task', 'task title'); - assertEq(p.tasks[0].done, false, 'task not done'); - assertEq(p.tasks[0].files, undefined, 'files is undefined for old-format entry'); - assertEq(p.tasks[0].verify, undefined, 'verify is undefined for old-format entry'); -} + assert.deepStrictEqual(p.tasks.length, 1, 'one task parsed'); + assert.deepStrictEqual(p.tasks[0].id, 'T01', 'task id'); + assert.deepStrictEqual(p.tasks[0].title, 'Classic Task', 'task title'); + assert.deepStrictEqual(p.tasks[0].done, false, 'task not done'); + assert.deepStrictEqual(p.tasks[0].files, undefined, 'files is undefined for old-format entry'); + assert.deepStrictEqual(p.tasks[0].verify, undefined, 'verify is undefined for old-format entry'); +}); -console.log('\n=== parsePlan: new-format task entries with Files and Verify sublines ==='); -{ +test('parsePlan: new-format task entries with Files and Verify sublines', () => { const content = `# S10: New Format **Goal:** Test new-format subline extraction. @@ -643,18 +618,17 @@ console.log('\n=== parsePlan: new-format task entries with Files and Verify subl `; const p = parsePlan(content); - assertEq(p.tasks.length, 1, 'one task parsed'); - assertEq(p.tasks[0].id, 'T01', 'task id'); - assertTrue(Array.isArray(p.tasks[0].files), 'files is an array'); - assertEq(p.tasks[0].files!.length, 2, 'files array has two entries'); - assertEq(p.tasks[0].files![0], 'types.ts', 'first file is types.ts'); - assertEq(p.tasks[0].files![1], 'files.ts', 'second file is files.ts'); - assertEq(p.tasks[0].verify, 'run the test suite', 'verify string extracted correctly'); - assertTrue(p.tasks[0].description.includes('Why: because we need typed plan entries'), 'Why line accumulates into description'); -} + assert.deepStrictEqual(p.tasks.length, 1, 'one task parsed'); + assert.deepStrictEqual(p.tasks[0].id, 'T01', 'task id'); + assert.ok(Array.isArray(p.tasks[0].files), 'files is an array'); + assert.deepStrictEqual(p.tasks[0].files!.length, 2, 'files array has two entries'); + assert.deepStrictEqual(p.tasks[0].files![0], 'types.ts', 'first file is types.ts'); + assert.deepStrictEqual(p.tasks[0].files![1], 'files.ts', 'second file is files.ts'); + assert.deepStrictEqual(p.tasks[0].verify, 'run the test suite', 'verify string extracted correctly'); + assert.ok(p.tasks[0].description.includes('Why: because we need typed plan entries'), 'Why line accumulates into description'); +}); -console.log('\n=== parsePlan: heading-style task entries (### T01 -- Title) ==='); -{ +test('parsePlan: heading-style task entries (### T01 -- Title)', () => { const content = `# S11: Heading Style **Goal:** Test heading-style task parsing. @@ -674,20 +648,19 @@ Some description for the second task. `; const p = parsePlan(content); - assertEq(p.tasks.length, 2, 'heading-style task count'); - assertEq(p.tasks[0].id, 'T01', 'heading T01 id'); - assertEq(p.tasks[0].title, 'Implement feature', 'heading T01 title'); - assertEq(p.tasks[0].done, false, 'heading T01 not done (headings have no checkbox)'); - assertEq(p.tasks[0].files![0], 'src/feature.ts', 'heading T01 files extracted'); - assertEq(p.tasks[0].verify, 'npm test', 'heading T01 verify extracted'); - assertEq(p.tasks[1].id, 'T02', 'heading T02 id'); - assertEq(p.tasks[1].title, 'Write tests', 'heading T02 title'); - assertEq(p.tasks[1].estimate, '1h', 'heading T02 estimate'); - assertTrue(p.tasks[1].description.includes('Some description'), 'heading T02 description'); -} + assert.deepStrictEqual(p.tasks.length, 2, 'heading-style task count'); + assert.deepStrictEqual(p.tasks[0].id, 'T01', 'heading T01 id'); + assert.deepStrictEqual(p.tasks[0].title, 'Implement feature', 'heading T01 title'); + assert.deepStrictEqual(p.tasks[0].done, false, 'heading T01 not done (headings have no checkbox)'); + assert.deepStrictEqual(p.tasks[0].files![0], 'src/feature.ts', 'heading T01 files extracted'); + assert.deepStrictEqual(p.tasks[0].verify, 'npm test', 'heading T01 verify extracted'); + assert.deepStrictEqual(p.tasks[1].id, 'T02', 'heading T02 id'); + assert.deepStrictEqual(p.tasks[1].title, 'Write tests', 'heading T02 title'); + assert.deepStrictEqual(p.tasks[1].estimate, '1h', 'heading T02 estimate'); + assert.ok(p.tasks[1].description.includes('Some description'), 'heading T02 description'); +}); -console.log('\n=== parsePlan: heading-style with colon separator (### T01: Title) ==='); -{ +test('parsePlan: heading-style with colon separator (### T01: Title)', () => { const content = `# S12: Heading Colon Style **Goal:** Test colon-separated heading tasks. @@ -703,16 +676,15 @@ console.log('\n=== parsePlan: heading-style with colon separator (### T01: Title `; const p = parsePlan(content); - assertEq(p.tasks.length, 2, 'colon heading task count'); - assertEq(p.tasks[0].id, 'T01', 'colon heading T01 id'); - assertEq(p.tasks[0].title, 'Setup project', 'colon heading T01 title'); - assertEq(p.tasks[1].id, 'T02', 'colon heading T02 id'); - assertEq(p.tasks[1].title, 'Add CI pipeline', 'colon heading T02 title'); - assertEq(p.tasks[1].estimate, '30m', 'colon heading T02 estimate'); -} + assert.deepStrictEqual(p.tasks.length, 2, 'colon heading task count'); + assert.deepStrictEqual(p.tasks[0].id, 'T01', 'colon heading T01 id'); + assert.deepStrictEqual(p.tasks[0].title, 'Setup project', 'colon heading T01 title'); + assert.deepStrictEqual(p.tasks[1].id, 'T02', 'colon heading T02 id'); + assert.deepStrictEqual(p.tasks[1].title, 'Add CI pipeline', 'colon heading T02 title'); + assert.deepStrictEqual(p.tasks[1].estimate, '30m', 'colon heading T02 estimate'); +}); -console.log('\n=== parsePlan: heading-style with em-dash separator (### T01 — Title) ==='); -{ +test('parsePlan: heading-style with em-dash separator (### T01 — Title)', () => { const content = `# S13: Em-Dash Style **Goal:** Test em-dash separated heading tasks. @@ -726,13 +698,12 @@ Widget description. `; const p = parsePlan(content); - assertEq(p.tasks.length, 1, 'em-dash heading task count'); - assertEq(p.tasks[0].id, 'T01', 'em-dash heading T01 id'); - assertEq(p.tasks[0].title, 'Build the widget', 'em-dash heading T01 title'); -} + assert.deepStrictEqual(p.tasks.length, 1, 'em-dash heading task count'); + assert.deepStrictEqual(p.tasks[0].id, 'T01', 'em-dash heading T01 id'); + assert.deepStrictEqual(p.tasks[0].title, 'Build the widget', 'em-dash heading T01 title'); +}); -console.log('\n=== parsePlan: mixed checkbox and heading-style tasks ==='); -{ +test('parsePlan: mixed checkbox and heading-style tasks', () => { const content = `# S14: Mixed Format **Goal:** Test mixed formats. @@ -752,23 +723,21 @@ A heading-style task. `; const p = parsePlan(content); - assertEq(p.tasks.length, 3, 'mixed format task count'); - assertEq(p.tasks[0].id, 'T01', 'mixed T01 id'); - assertEq(p.tasks[0].done, false, 'mixed T01 not done'); - assertEq(p.tasks[1].id, 'T02', 'mixed T02 id'); - assertEq(p.tasks[1].title, 'Heading task', 'mixed T02 title'); - assertEq(p.tasks[1].estimate, '15m', 'mixed T02 estimate'); - assertEq(p.tasks[1].done, false, 'mixed T02 not done (heading style)'); - assertEq(p.tasks[2].id, 'T03', 'mixed T03 id'); - assertEq(p.tasks[2].done, true, 'mixed T03 done'); -} + assert.deepStrictEqual(p.tasks.length, 3, 'mixed format task count'); + assert.deepStrictEqual(p.tasks[0].id, 'T01', 'mixed T01 id'); + assert.deepStrictEqual(p.tasks[0].done, false, 'mixed T01 not done'); + assert.deepStrictEqual(p.tasks[1].id, 'T02', 'mixed T02 id'); + assert.deepStrictEqual(p.tasks[1].title, 'Heading task', 'mixed T02 title'); + assert.deepStrictEqual(p.tasks[1].estimate, '15m', 'mixed T02 estimate'); + assert.deepStrictEqual(p.tasks[1].done, false, 'mixed T02 not done (heading style)'); + assert.deepStrictEqual(p.tasks[2].id, 'T03', 'mixed T03 id'); + assert.deepStrictEqual(p.tasks[2].done, true, 'mixed T03 done'); +}); // ═══════════════════════════════════════════════════════════════════════════ // parseSummary tests // ═══════════════════════════════════════════════════════════════════════════ - -console.log('\n=== parseSummary: full summary with all frontmatter fields ==='); -{ +test('parseSummary: full summary with all frontmatter fields', () => { const content = `--- id: T01 parent: S01 @@ -823,52 +792,51 @@ None. const s = parseSummary(content); // Frontmatter fields - assertEq(s.frontmatter.id, 'T01', 'summary id'); - assertEq(s.frontmatter.parent, 'S01', 'summary parent'); - assertEq(s.frontmatter.milestone, 'M001', 'summary milestone'); - assertEq(s.frontmatter.provides.length, 2, 'provides count'); - assertEq(s.frontmatter.provides[0], 'parseRoadmap test coverage', 'first provides'); - assertEq(s.frontmatter.provides[1], 'parsePlan test coverage', 'second provides'); + assert.deepStrictEqual(s.frontmatter.id, 'T01', 'summary id'); + assert.deepStrictEqual(s.frontmatter.parent, 'S01', 'summary parent'); + assert.deepStrictEqual(s.frontmatter.milestone, 'M001', 'summary milestone'); + assert.deepStrictEqual(s.frontmatter.provides.length, 2, 'provides count'); + assert.deepStrictEqual(s.frontmatter.provides[0], 'parseRoadmap test coverage', 'first provides'); + assert.deepStrictEqual(s.frontmatter.provides[1], 'parsePlan test coverage', 'second provides'); // requires (nested objects) - assertEq(s.frontmatter.requires.length, 2, 'requires count'); - assertEq(s.frontmatter.requires[0].slice, 'S00', 'first requires slice'); - assertEq(s.frontmatter.requires[0].provides, 'type definitions', 'first requires provides'); - assertEq(s.frontmatter.requires[1].slice, 'S02', 'second requires slice'); - assertEq(s.frontmatter.requires[1].provides, 'state derivation', 'second requires provides'); + assert.deepStrictEqual(s.frontmatter.requires.length, 2, 'requires count'); + assert.deepStrictEqual(s.frontmatter.requires[0].slice, 'S00', 'first requires slice'); + assert.deepStrictEqual(s.frontmatter.requires[0].provides, 'type definitions', 'first requires provides'); + assert.deepStrictEqual(s.frontmatter.requires[1].slice, 'S02', 'second requires slice'); + assert.deepStrictEqual(s.frontmatter.requires[1].provides, 'state derivation', 'second requires provides'); - assertEq(s.frontmatter.affects.length, 1, 'affects count'); - assertEq(s.frontmatter.affects[0], 'auto-mode dispatch', 'affects value'); - assertEq(s.frontmatter.key_files.length, 2, 'key_files count'); - assertEq(s.frontmatter.key_decisions.length, 1, 'key_decisions count'); - assertEq(s.frontmatter.patterns_established.length, 1, 'patterns_established count'); - assertEq(s.frontmatter.drill_down_paths.length, 1, 'drill_down_paths count'); + assert.deepStrictEqual(s.frontmatter.affects.length, 1, 'affects count'); + assert.deepStrictEqual(s.frontmatter.affects[0], 'auto-mode dispatch', 'affects value'); + assert.deepStrictEqual(s.frontmatter.key_files.length, 2, 'key_files count'); + assert.deepStrictEqual(s.frontmatter.key_decisions.length, 1, 'key_decisions count'); + assert.deepStrictEqual(s.frontmatter.patterns_established.length, 1, 'patterns_established count'); + assert.deepStrictEqual(s.frontmatter.drill_down_paths.length, 1, 'drill_down_paths count'); // observability_surfaces extraction - assertEq(s.frontmatter.observability_surfaces.length, 2, 'observability_surfaces count'); - assertEq(s.frontmatter.observability_surfaces[0], 'test pass/fail output from node --test', 'first observability surface'); - assertEq(s.frontmatter.observability_surfaces[1], 'exit code 1 on failure', 'second observability surface'); + assert.deepStrictEqual(s.frontmatter.observability_surfaces.length, 2, 'observability_surfaces count'); + assert.deepStrictEqual(s.frontmatter.observability_surfaces[0], 'test pass/fail output from node --test', 'first observability surface'); + assert.deepStrictEqual(s.frontmatter.observability_surfaces[1], 'exit code 1 on failure', 'second observability surface'); - assertEq(s.frontmatter.duration, '23min', 'duration'); - assertEq(s.frontmatter.verification_result, 'pass', 'verification_result'); - assertEq(s.frontmatter.completed_at, '2025-03-10T08:00:00Z', 'completed_at'); + assert.deepStrictEqual(s.frontmatter.duration, '23min', 'duration'); + assert.deepStrictEqual(s.frontmatter.verification_result, 'pass', 'verification_result'); + assert.deepStrictEqual(s.frontmatter.completed_at, '2025-03-10T08:00:00Z', 'completed_at'); // Body fields - assertEq(s.title, 'T01: Test parseRoadmap and parsePlan', 'summary title'); - assertEq(s.oneLiner, 'Created parsers.test.ts with 98 assertions across 16 test groups.', 'one-liner'); - assertTrue(s.whatHappened.includes('comprehensive tests'), 'whatHappened content'); - assertEq(s.deviations, 'None.', 'deviations'); + assert.deepStrictEqual(s.title, 'T01: Test parseRoadmap and parsePlan', 'summary title'); + assert.deepStrictEqual(s.oneLiner, 'Created parsers.test.ts with 98 assertions across 16 test groups.', 'one-liner'); + assert.ok(s.whatHappened.includes('comprehensive tests'), 'whatHappened content'); + assert.deepStrictEqual(s.deviations, 'None.', 'deviations'); // Files modified - assertEq(s.filesModified.length, 3, 'filesModified count'); - assertEq(s.filesModified[0].path, 'tests/parsers.test.ts', 'first file path'); - assertTrue(s.filesModified[0].description.includes('98 assertions'), 'first file description'); - assertEq(s.filesModified[1].path, 'types.ts', 'second file path'); - assertEq(s.filesModified[2].path, 'files.ts', 'third file path'); -} + assert.deepStrictEqual(s.filesModified.length, 3, 'filesModified count'); + assert.deepStrictEqual(s.filesModified[0].path, 'tests/parsers.test.ts', 'first file path'); + assert.ok(s.filesModified[0].description.includes('98 assertions'), 'first file description'); + assert.deepStrictEqual(s.filesModified[1].path, 'types.ts', 'second file path'); + assert.deepStrictEqual(s.filesModified[2].path, 'files.ts', 'third file path'); +}); -console.log('\n=== parseSummary: one-liner extraction (bold-wrapped line after H1) ==='); -{ +test('parseSummary: one-liner extraction (bold-wrapped line after H1)', () => { const content = `# S01: Parser Test Suite **All 5 parsers have test coverage with edge cases.** @@ -879,12 +847,11 @@ Things happened. `; const s = parseSummary(content); - assertEq(s.title, 'S01: Parser Test Suite', 'title'); - assertEq(s.oneLiner, 'All 5 parsers have test coverage with edge cases.', 'bold one-liner'); -} + assert.deepStrictEqual(s.title, 'S01: Parser Test Suite', 'title'); + assert.deepStrictEqual(s.oneLiner, 'All 5 parsers have test coverage with edge cases.', 'bold one-liner'); +}); -console.log('\n=== parseSummary: non-bold paragraph after H1 (empty one-liner) ==='); -{ +test('parseSummary: non-bold paragraph after H1 (empty one-liner)', () => { const content = `# T02: Some Task This is just a regular paragraph, not bold. @@ -895,12 +862,11 @@ Did stuff. `; const s = parseSummary(content); - assertEq(s.title, 'T02: Some Task', 'title'); - assertEq(s.oneLiner, '', 'non-bold line results in empty one-liner'); -} + assert.deepStrictEqual(s.title, 'T02: Some Task', 'title'); + assert.deepStrictEqual(s.oneLiner, '', 'non-bold line results in empty one-liner'); +}); -console.log('\n=== parseSummary: files-modified parsing (backtick path — description format) ==='); -{ +test('parseSummary: files-modified parsing (backtick path — description format)', () => { const content = `# T03: File Changes **One-liner.** @@ -913,15 +879,14 @@ console.log('\n=== parseSummary: files-modified parsing (backtick path — descr `; const s = parseSummary(content); - assertEq(s.filesModified.length, 3, 'three files'); - assertEq(s.filesModified[0].path, 'src/index.ts', 'first path'); - assertEq(s.filesModified[0].description, 'main entry point', 'first description'); - assertEq(s.filesModified[1].path, 'src/utils.ts', 'second path'); - assertEq(s.filesModified[2].path, 'README.md', 'third path'); -} + assert.deepStrictEqual(s.filesModified.length, 3, 'three files'); + assert.deepStrictEqual(s.filesModified[0].path, 'src/index.ts', 'first path'); + assert.deepStrictEqual(s.filesModified[0].description, 'main entry point', 'first description'); + assert.deepStrictEqual(s.filesModified[1].path, 'src/utils.ts', 'second path'); + assert.deepStrictEqual(s.filesModified[2].path, 'README.md', 'third path'); +}); -console.log('\n=== parseSummary: missing frontmatter (safe defaults) ==='); -{ +test('parseSummary: missing frontmatter (safe defaults)', () => { const content = `# T04: No Frontmatter **Did something.** @@ -932,26 +897,25 @@ No frontmatter at all. `; const s = parseSummary(content); - assertEq(s.frontmatter.id, '', 'default id empty'); - assertEq(s.frontmatter.parent, '', 'default parent empty'); - assertEq(s.frontmatter.milestone, '', 'default milestone empty'); - assertEq(s.frontmatter.provides.length, 0, 'default provides empty'); - assertEq(s.frontmatter.requires.length, 0, 'default requires empty'); - assertEq(s.frontmatter.affects.length, 0, 'default affects empty'); - assertEq(s.frontmatter.key_files.length, 0, 'default key_files empty'); - assertEq(s.frontmatter.key_decisions.length, 0, 'default key_decisions empty'); - assertEq(s.frontmatter.patterns_established.length, 0, 'default patterns_established empty'); - assertEq(s.frontmatter.drill_down_paths.length, 0, 'default drill_down_paths empty'); - assertEq(s.frontmatter.observability_surfaces.length, 0, 'default observability_surfaces empty'); - assertEq(s.frontmatter.duration, '', 'default duration empty'); - assertEq(s.frontmatter.verification_result, 'untested', 'default verification_result'); - assertEq(s.frontmatter.completed_at, '', 'default completed_at empty'); - assertEq(s.title, 'T04: No Frontmatter', 'title still parsed'); - assertEq(s.oneLiner, 'Did something.', 'one-liner still parsed'); -} + assert.deepStrictEqual(s.frontmatter.id, '', 'default id empty'); + assert.deepStrictEqual(s.frontmatter.parent, '', 'default parent empty'); + assert.deepStrictEqual(s.frontmatter.milestone, '', 'default milestone empty'); + assert.deepStrictEqual(s.frontmatter.provides.length, 0, 'default provides empty'); + assert.deepStrictEqual(s.frontmatter.requires.length, 0, 'default requires empty'); + assert.deepStrictEqual(s.frontmatter.affects.length, 0, 'default affects empty'); + assert.deepStrictEqual(s.frontmatter.key_files.length, 0, 'default key_files empty'); + assert.deepStrictEqual(s.frontmatter.key_decisions.length, 0, 'default key_decisions empty'); + assert.deepStrictEqual(s.frontmatter.patterns_established.length, 0, 'default patterns_established empty'); + assert.deepStrictEqual(s.frontmatter.drill_down_paths.length, 0, 'default drill_down_paths empty'); + assert.deepStrictEqual(s.frontmatter.observability_surfaces.length, 0, 'default observability_surfaces empty'); + assert.deepStrictEqual(s.frontmatter.duration, '', 'default duration empty'); + assert.deepStrictEqual(s.frontmatter.verification_result, 'untested', 'default verification_result'); + assert.deepStrictEqual(s.frontmatter.completed_at, '', 'default completed_at empty'); + assert.deepStrictEqual(s.title, 'T04: No Frontmatter', 'title still parsed'); + assert.deepStrictEqual(s.oneLiner, 'Did something.', 'one-liner still parsed'); +}); -console.log('\n=== parseSummary: empty body ==='); -{ +test('parseSummary: empty body', () => { const content = `--- id: T05 parent: S01 @@ -960,16 +924,15 @@ milestone: M001 `; const s = parseSummary(content); - assertEq(s.frontmatter.id, 'T05', 'id from frontmatter'); - assertEq(s.title, '', 'empty title'); - assertEq(s.oneLiner, '', 'empty one-liner'); - assertEq(s.whatHappened, '', 'empty whatHappened'); - assertEq(s.deviations, '', 'empty deviations'); - assertEq(s.filesModified.length, 0, 'no files modified'); -} + assert.deepStrictEqual(s.frontmatter.id, 'T05', 'id from frontmatter'); + assert.deepStrictEqual(s.title, '', 'empty title'); + assert.deepStrictEqual(s.oneLiner, '', 'empty one-liner'); + assert.deepStrictEqual(s.whatHappened, '', 'empty whatHappened'); + assert.deepStrictEqual(s.deviations, '', 'empty deviations'); + assert.deepStrictEqual(s.filesModified.length, 0, 'no files modified'); +}); -console.log('\n=== parseSummary: summary with requires array (nested objects) ==='); -{ +test('parseSummary: summary with requires array (nested objects)', () => { const content = `--- id: T06 parent: S02 @@ -1004,20 +967,18 @@ Tested. `; const s = parseSummary(content); - assertEq(s.frontmatter.requires.length, 3, 'three requires entries'); - assertEq(s.frontmatter.requires[0].slice, 'S01', 'first requires slice'); - assertEq(s.frontmatter.requires[0].provides, 'parser functions', 'first requires provides'); - assertEq(s.frontmatter.requires[1].slice, 'S00', 'second requires slice'); - assertEq(s.frontmatter.requires[2].slice, 'S03', 'third requires slice'); - assertEq(s.frontmatter.requires[2].provides, 'state engine', 'third requires provides'); -} + assert.deepStrictEqual(s.frontmatter.requires.length, 3, 'three requires entries'); + assert.deepStrictEqual(s.frontmatter.requires[0].slice, 'S01', 'first requires slice'); + assert.deepStrictEqual(s.frontmatter.requires[0].provides, 'parser functions', 'first requires provides'); + assert.deepStrictEqual(s.frontmatter.requires[1].slice, 'S00', 'second requires slice'); + assert.deepStrictEqual(s.frontmatter.requires[2].slice, 'S03', 'third requires slice'); + assert.deepStrictEqual(s.frontmatter.requires[2].provides, 'state engine', 'third requires provides'); +}); // ═══════════════════════════════════════════════════════════════════════════ // parseContinue tests // ═══════════════════════════════════════════════════════════════════════════ - -console.log('\n=== parseContinue: full continue file with all frontmatter fields ==='); -{ +test('parseContinue: full continue file with all frontmatter fields', () => { const content = `--- milestone: M001 slice: S01 @@ -1052,24 +1013,23 @@ Run the full test suite with node --test. const c = parseContinue(content); // Frontmatter - assertEq(c.frontmatter.milestone, 'M001', 'continue milestone'); - assertEq(c.frontmatter.slice, 'S01', 'continue slice'); - assertEq(c.frontmatter.task, 'T02', 'continue task'); - assertEq(c.frontmatter.step, 3, 'continue step'); - assertEq(c.frontmatter.totalSteps, 5, 'continue totalSteps'); - assertEq(c.frontmatter.status, 'in_progress', 'continue status'); - assertEq(c.frontmatter.savedAt, '2025-03-10T08:30:00Z', 'continue savedAt'); + assert.deepStrictEqual(c.frontmatter.milestone, 'M001', 'continue milestone'); + assert.deepStrictEqual(c.frontmatter.slice, 'S01', 'continue slice'); + assert.deepStrictEqual(c.frontmatter.task, 'T02', 'continue task'); + assert.deepStrictEqual(c.frontmatter.step, 3, 'continue step'); + assert.deepStrictEqual(c.frontmatter.totalSteps, 5, 'continue totalSteps'); + assert.deepStrictEqual(c.frontmatter.status, 'in_progress', 'continue status'); + assert.deepStrictEqual(c.frontmatter.savedAt, '2025-03-10T08:30:00Z', 'continue savedAt'); // Body sections - assertTrue(c.completedWork.includes('Steps 1-3 are done'), 'completedWork content'); - assertTrue(c.remainingWork.includes('Steps 4-5'), 'remainingWork content'); - assertTrue(c.decisions.includes('manual assert pattern'), 'decisions content'); - assertTrue(c.context.includes('gsd-s01 worktree'), 'context content'); - assertTrue(c.nextAction.includes('node --test'), 'nextAction content'); -} + assert.ok(c.completedWork.includes('Steps 1-3 are done'), 'completedWork content'); + assert.ok(c.remainingWork.includes('Steps 4-5'), 'remainingWork content'); + assert.ok(c.decisions.includes('manual assert pattern'), 'decisions content'); + assert.ok(c.context.includes('gsd-s01 worktree'), 'context content'); + assert.ok(c.nextAction.includes('node --test'), 'nextAction content'); +}); -console.log('\n=== parseContinue: string step/totalSteps parsed as integers ==='); -{ +test('parseContinue: string step/totalSteps parsed as integers', () => { const content = `--- milestone: M002 slice: S03 @@ -1102,14 +1062,13 @@ Continue. `; const c = parseContinue(content); - assertEq(c.frontmatter.step, 7, 'step parsed as integer 7'); - assertEq(c.frontmatter.totalSteps, 12, 'totalSteps parsed as integer 12'); - assertEq(typeof c.frontmatter.step, 'number', 'step is number type'); - assertEq(typeof c.frontmatter.totalSteps, 'number', 'totalSteps is number type'); -} + assert.deepStrictEqual(c.frontmatter.step, 7, 'step parsed as integer 7'); + assert.deepStrictEqual(c.frontmatter.totalSteps, 12, 'totalSteps parsed as integer 12'); + assert.deepStrictEqual(typeof c.frontmatter.step, 'number', 'step is number type'); + assert.deepStrictEqual(typeof c.frontmatter.totalSteps, 'number', 'totalSteps is number type'); +}); -console.log('\n=== parseContinue: NaN step values (non-numeric strings) ==='); -{ +test('parseContinue: NaN step values (non-numeric strings)', () => { const content = `--- milestone: M001 slice: S01 @@ -1151,12 +1110,11 @@ Do things. const totalIsNaN = Number.isNaN(c.frontmatter.totalSteps); // The parser does parseInt which returns NaN for non-numeric strings // There's no || 0 fallback on the parseInt path, so NaN is expected - assertTrue(stepIsNaN, 'NaN step when non-numeric string'); - assertTrue(totalIsNaN, 'NaN totalSteps when non-numeric string'); -} + assert.ok(stepIsNaN, 'NaN step when non-numeric string'); + assert.ok(totalIsNaN, 'NaN totalSteps when non-numeric string'); +}); -console.log('\n=== parseContinue: all three status variants ==='); -{ +test('parseContinue: all three status variants', () => { for (const status of ['in_progress', 'interrupted', 'compacted'] as const) { const content = `--- milestone: M001 @@ -1174,12 +1132,11 @@ Work. `; const c = parseContinue(content); - assertEq(c.frontmatter.status, status, `status variant: ${status}`); + assert.deepStrictEqual(c.frontmatter.status, status, `status variant: ${status}`); } -} +}); -console.log('\n=== parseContinue: missing frontmatter ==='); -{ +test('parseContinue: missing frontmatter', () => { const content = `## Completed Work Some work done. @@ -1202,24 +1159,23 @@ Next thing. `; const c = parseContinue(content); - assertEq(c.frontmatter.milestone, '', 'default milestone empty'); - assertEq(c.frontmatter.slice, '', 'default slice empty'); - assertEq(c.frontmatter.task, '', 'default task empty'); - assertEq(c.frontmatter.step, 0, 'default step 0'); - assertEq(c.frontmatter.totalSteps, 0, 'default totalSteps 0'); - assertEq(c.frontmatter.status, 'in_progress', 'default status in_progress'); - assertEq(c.frontmatter.savedAt, '', 'default savedAt empty'); + assert.deepStrictEqual(c.frontmatter.milestone, '', 'default milestone empty'); + assert.deepStrictEqual(c.frontmatter.slice, '', 'default slice empty'); + assert.deepStrictEqual(c.frontmatter.task, '', 'default task empty'); + assert.deepStrictEqual(c.frontmatter.step, 0, 'default step 0'); + assert.deepStrictEqual(c.frontmatter.totalSteps, 0, 'default totalSteps 0'); + assert.deepStrictEqual(c.frontmatter.status, 'in_progress', 'default status in_progress'); + assert.deepStrictEqual(c.frontmatter.savedAt, '', 'default savedAt empty'); // Body sections still parse - assertTrue(c.completedWork.includes('Some work done'), 'completedWork without frontmatter'); - assertTrue(c.remainingWork.includes('More to do'), 'remainingWork without frontmatter'); - assertTrue(c.decisions.includes('A decision'), 'decisions without frontmatter'); - assertTrue(c.context.includes('Some context'), 'context without frontmatter'); - assertTrue(c.nextAction.includes('Next thing'), 'nextAction without frontmatter'); -} + assert.ok(c.completedWork.includes('Some work done'), 'completedWork without frontmatter'); + assert.ok(c.remainingWork.includes('More to do'), 'remainingWork without frontmatter'); + assert.ok(c.decisions.includes('A decision'), 'decisions without frontmatter'); + assert.ok(c.context.includes('Some context'), 'context without frontmatter'); + assert.ok(c.nextAction.includes('Next thing'), 'nextAction without frontmatter'); +}); -console.log('\n=== parseContinue: body section extraction ==='); -{ +test('parseContinue: body section extraction', () => { const content = `--- milestone: M001 slice: S01 @@ -1253,16 +1209,15 @@ Pick up at step 3: run the integration tests. `; const c = parseContinue(content); - assertTrue(c.completedWork.includes('First paragraph'), 'completedWork first paragraph'); - assertTrue(c.completedWork.includes('Second paragraph'), 'completedWork second paragraph'); - assertTrue(c.remainingWork.includes('step 3 and step 4'), 'remainingWork detail'); - assertTrue(c.decisions.includes('approach A over approach B'), 'decisions detail'); - assertTrue(c.context.includes('Node 22 required'), 'context detail'); - assertTrue(c.nextAction.includes('step 3: run the integration tests'), 'nextAction detail'); -} + assert.ok(c.completedWork.includes('First paragraph'), 'completedWork first paragraph'); + assert.ok(c.completedWork.includes('Second paragraph'), 'completedWork second paragraph'); + assert.ok(c.remainingWork.includes('step 3 and step 4'), 'remainingWork detail'); + assert.ok(c.decisions.includes('approach A over approach B'), 'decisions detail'); + assert.ok(c.context.includes('Node 22 required'), 'context detail'); + assert.ok(c.nextAction.includes('step 3: run the integration tests'), 'nextAction detail'); +}); -console.log('\n=== parseContinue: total_steps vs totalSteps key support ==='); -{ +test('parseContinue: total_steps vs totalSteps key support', () => { // Test total_steps (snake_case) — the primary format const content1 = `--- milestone: M001 @@ -1280,7 +1235,7 @@ Work. `; const c1 = parseContinue(content1); - assertEq(c1.frontmatter.totalSteps, 8, 'total_steps snake_case works'); + assert.deepStrictEqual(c1.frontmatter.totalSteps, 8, 'total_steps snake_case works'); // Test totalSteps (camelCase) — the fallback const content2 = `--- @@ -1299,15 +1254,13 @@ Work. `; const c2 = parseContinue(content2); - assertEq(c2.frontmatter.totalSteps, 6, 'totalSteps camelCase works'); -} + assert.deepStrictEqual(c2.frontmatter.totalSteps, 6, 'totalSteps camelCase works'); +}); // ═══════════════════════════════════════════════════════════════════════════ // parseRequirementCounts tests // ═══════════════════════════════════════════════════════════════════════════ - -console.log('\n=== parseRequirementCounts: full requirements file ==='); -{ +test('parseRequirementCounts: full requirements file', () => { const content = `# Requirements ## Active @@ -1344,27 +1297,25 @@ console.log('\n=== parseRequirementCounts: full requirements file ==='); `; const counts = parseRequirementCounts(content); - assertEq(counts.active, 3, 'active count'); - assertEq(counts.validated, 2, 'validated count'); - assertEq(counts.deferred, 1, 'deferred count'); - assertEq(counts.outOfScope, 2, 'outOfScope count'); - assertEq(counts.blocked, 1, 'blocked count'); - assertEq(counts.total, 8, 'total is sum of active+validated+deferred+outOfScope'); -} + assert.deepStrictEqual(counts.active, 3, 'active count'); + assert.deepStrictEqual(counts.validated, 2, 'validated count'); + assert.deepStrictEqual(counts.deferred, 1, 'deferred count'); + assert.deepStrictEqual(counts.outOfScope, 2, 'outOfScope count'); + assert.deepStrictEqual(counts.blocked, 1, 'blocked count'); + assert.deepStrictEqual(counts.total, 8, 'total is sum of active+validated+deferred+outOfScope'); +}); -console.log('\n=== parseRequirementCounts: null input returns all zeros ==='); -{ +test('parseRequirementCounts: null input returns all zeros', () => { const counts = parseRequirementCounts(null); - assertEq(counts.active, 0, 'null active'); - assertEq(counts.validated, 0, 'null validated'); - assertEq(counts.deferred, 0, 'null deferred'); - assertEq(counts.outOfScope, 0, 'null outOfScope'); - assertEq(counts.blocked, 0, 'null blocked'); - assertEq(counts.total, 0, 'null total'); -} + assert.deepStrictEqual(counts.active, 0, 'null active'); + assert.deepStrictEqual(counts.validated, 0, 'null validated'); + assert.deepStrictEqual(counts.deferred, 0, 'null deferred'); + assert.deepStrictEqual(counts.outOfScope, 0, 'null outOfScope'); + assert.deepStrictEqual(counts.blocked, 0, 'null blocked'); + assert.deepStrictEqual(counts.total, 0, 'null total'); +}); -console.log('\n=== parseRequirementCounts: empty sections return zero counts ==='); -{ +test('parseRequirementCounts: empty sections return zero counts', () => { const content = `# Requirements ## Active @@ -1377,16 +1328,15 @@ console.log('\n=== parseRequirementCounts: empty sections return zero counts === `; const counts = parseRequirementCounts(content); - assertEq(counts.active, 0, 'empty active'); - assertEq(counts.validated, 0, 'empty validated'); - assertEq(counts.deferred, 0, 'empty deferred'); - assertEq(counts.outOfScope, 0, 'empty outOfScope'); - assertEq(counts.blocked, 0, 'empty blocked'); - assertEq(counts.total, 0, 'empty total'); -} + assert.deepStrictEqual(counts.active, 0, 'empty active'); + assert.deepStrictEqual(counts.validated, 0, 'empty validated'); + assert.deepStrictEqual(counts.deferred, 0, 'empty deferred'); + assert.deepStrictEqual(counts.outOfScope, 0, 'empty outOfScope'); + assert.deepStrictEqual(counts.blocked, 0, 'empty blocked'); + assert.deepStrictEqual(counts.total, 0, 'empty total'); +}); -console.log('\n=== parseRequirementCounts: blocked status counting ==='); -{ +test('parseRequirementCounts: blocked status counting', () => { const content = `# Requirements ## Active @@ -1411,13 +1361,12 @@ console.log('\n=== parseRequirementCounts: blocked status counting ==='); `; const counts = parseRequirementCounts(content); - assertEq(counts.active, 3, 'active includes blocked items in Active section'); - assertEq(counts.blocked, 3, 'blocked counts all blocked statuses across sections'); - assertEq(counts.deferred, 1, 'deferred section count'); -} + assert.deepStrictEqual(counts.active, 3, 'active includes blocked items in Active section'); + assert.deepStrictEqual(counts.blocked, 3, 'blocked counts all blocked statuses across sections'); + assert.deepStrictEqual(counts.deferred, 1, 'deferred section count'); +}); -console.log('\n=== parseRequirementCounts: total is sum of all section counts ==='); -{ +test('parseRequirementCounts: total is sum of all section counts', () => { const content = `# Requirements ## Active @@ -1451,20 +1400,18 @@ console.log('\n=== parseRequirementCounts: total is sum of all section counts == `; const counts = parseRequirementCounts(content); - assertEq(counts.active, 1, 'one active'); - assertEq(counts.validated, 2, 'two validated'); - assertEq(counts.deferred, 3, 'three deferred'); - assertEq(counts.outOfScope, 1, 'one outOfScope'); - assertEq(counts.total, 7, 'total = 1 + 2 + 3 + 1'); - assertEq(counts.total, counts.active + counts.validated + counts.deferred + counts.outOfScope, 'total is exact sum'); -} + assert.deepStrictEqual(counts.active, 1, 'one active'); + assert.deepStrictEqual(counts.validated, 2, 'two validated'); + assert.deepStrictEqual(counts.deferred, 3, 'three deferred'); + assert.deepStrictEqual(counts.outOfScope, 1, 'one outOfScope'); + assert.deepStrictEqual(counts.total, 7, 'total = 1 + 2 + 3 + 1'); + assert.deepStrictEqual(counts.total, counts.active + counts.validated + counts.deferred + counts.outOfScope, 'total is exact sum'); +}); // ═══════════════════════════════════════════════════════════════════════════ // parseSecretsManifest / formatSecretsManifest tests // ═══════════════════════════════════════════════════════════════════════════ - -console.log('\n=== parseSecretsManifest: full manifest with 3 keys ==='); -{ +test('parseSecretsManifest: full manifest with 3 keys', () => { const content = `# Secrets Manifest **Milestone:** M003 @@ -1508,37 +1455,36 @@ console.log('\n=== parseSecretsManifest: full manifest with 3 keys ==='); const m = parseSecretsManifest(content); - assertEq(m.milestone, 'M003', 'manifest milestone'); - assertEq(m.generatedAt, '2025-06-15T10:00:00Z', 'manifest generatedAt'); - assertEq(m.entries.length, 3, 'three entries'); + assert.deepStrictEqual(m.milestone, 'M003', 'manifest milestone'); + assert.deepStrictEqual(m.generatedAt, '2025-06-15T10:00:00Z', 'manifest generatedAt'); + assert.deepStrictEqual(m.entries.length, 3, 'three entries'); // First entry - assertEq(m.entries[0].key, 'OPENAI_API_KEY', 'entry 0 key'); - assertEq(m.entries[0].service, 'OpenAI', 'entry 0 service'); - assertEq(m.entries[0].dashboardUrl, 'https://platform.openai.com/api-keys', 'entry 0 dashboardUrl'); - assertEq(m.entries[0].formatHint, 'starts with sk-', 'entry 0 formatHint'); - assertEq(m.entries[0].status, 'pending', 'entry 0 status'); - assertEq(m.entries[0].destination, 'dotenv', 'entry 0 destination'); - assertEq(m.entries[0].guidance.length, 3, 'entry 0 guidance count'); - assertEq(m.entries[0].guidance[0], 'Go to https://platform.openai.com/api-keys', 'entry 0 guidance[0]'); - assertEq(m.entries[0].guidance[2], 'Copy the key immediately — it won\'t be shown again', 'entry 0 guidance[2]'); + assert.deepStrictEqual(m.entries[0].key, 'OPENAI_API_KEY', 'entry 0 key'); + assert.deepStrictEqual(m.entries[0].service, 'OpenAI', 'entry 0 service'); + assert.deepStrictEqual(m.entries[0].dashboardUrl, 'https://platform.openai.com/api-keys', 'entry 0 dashboardUrl'); + assert.deepStrictEqual(m.entries[0].formatHint, 'starts with sk-', 'entry 0 formatHint'); + assert.deepStrictEqual(m.entries[0].status, 'pending', 'entry 0 status'); + assert.deepStrictEqual(m.entries[0].destination, 'dotenv', 'entry 0 destination'); + assert.deepStrictEqual(m.entries[0].guidance.length, 3, 'entry 0 guidance count'); + assert.deepStrictEqual(m.entries[0].guidance[0], 'Go to https://platform.openai.com/api-keys', 'entry 0 guidance[0]'); + assert.deepStrictEqual(m.entries[0].guidance[2], 'Copy the key immediately — it won\'t be shown again', 'entry 0 guidance[2]'); // Second entry - assertEq(m.entries[1].key, 'STRIPE_SECRET_KEY', 'entry 1 key'); - assertEq(m.entries[1].service, 'Stripe', 'entry 1 service'); - assertEq(m.entries[1].status, 'collected', 'entry 1 status'); - assertEq(m.entries[1].formatHint, 'starts with sk_test_ or sk_live_', 'entry 1 formatHint'); - assertEq(m.entries[1].guidance.length, 3, 'entry 1 guidance count'); + assert.deepStrictEqual(m.entries[1].key, 'STRIPE_SECRET_KEY', 'entry 1 key'); + assert.deepStrictEqual(m.entries[1].service, 'Stripe', 'entry 1 service'); + assert.deepStrictEqual(m.entries[1].status, 'collected', 'entry 1 status'); + assert.deepStrictEqual(m.entries[1].formatHint, 'starts with sk_test_ or sk_live_', 'entry 1 formatHint'); + assert.deepStrictEqual(m.entries[1].guidance.length, 3, 'entry 1 guidance count'); // Third entry - assertEq(m.entries[2].key, 'SUPABASE_URL', 'entry 2 key'); - assertEq(m.entries[2].status, 'skipped', 'entry 2 status'); - assertEq(m.entries[2].destination, 'vercel', 'entry 2 destination'); - assertEq(m.entries[2].guidance.length, 2, 'entry 2 guidance count'); -} + assert.deepStrictEqual(m.entries[2].key, 'SUPABASE_URL', 'entry 2 key'); + assert.deepStrictEqual(m.entries[2].status, 'skipped', 'entry 2 status'); + assert.deepStrictEqual(m.entries[2].destination, 'vercel', 'entry 2 destination'); + assert.deepStrictEqual(m.entries[2].guidance.length, 2, 'entry 2 guidance count'); +}); -console.log('\n=== parseSecretsManifest: single-key manifest ==='); -{ +test('parseSecretsManifest: single-key manifest', () => { const content = `# Secrets Manifest **Milestone:** M001 @@ -1557,15 +1503,14 @@ console.log('\n=== parseSecretsManifest: single-key manifest ==='); `; const m = parseSecretsManifest(content); - assertEq(m.milestone, 'M001', 'single-key milestone'); - assertEq(m.entries.length, 1, 'single entry'); - assertEq(m.entries[0].key, 'DATABASE_URL', 'single entry key'); - assertEq(m.entries[0].service, 'PostgreSQL', 'single entry service'); - assertEq(m.entries[0].guidance.length, 2, 'single entry guidance count'); -} + assert.deepStrictEqual(m.milestone, 'M001', 'single-key milestone'); + assert.deepStrictEqual(m.entries.length, 1, 'single entry'); + assert.deepStrictEqual(m.entries[0].key, 'DATABASE_URL', 'single entry key'); + assert.deepStrictEqual(m.entries[0].service, 'PostgreSQL', 'single entry service'); + assert.deepStrictEqual(m.entries[0].guidance.length, 2, 'single entry guidance count'); +}); -console.log('\n=== parseSecretsManifest: empty/no-secrets manifest ==='); -{ +test('parseSecretsManifest: empty/no-secrets manifest', () => { const content = `# Secrets Manifest **Milestone:** M002 @@ -1573,13 +1518,12 @@ console.log('\n=== parseSecretsManifest: empty/no-secrets manifest ==='); `; const m = parseSecretsManifest(content); - assertEq(m.milestone, 'M002', 'empty manifest milestone'); - assertEq(m.generatedAt, '2025-06-15T14:00:00Z', 'empty manifest generatedAt'); - assertEq(m.entries.length, 0, 'no entries in empty manifest'); -} + assert.deepStrictEqual(m.milestone, 'M002', 'empty manifest milestone'); + assert.deepStrictEqual(m.generatedAt, '2025-06-15T14:00:00Z', 'empty manifest generatedAt'); + assert.deepStrictEqual(m.entries.length, 0, 'no entries in empty manifest'); +}); -console.log('\n=== parseSecretsManifest: missing optional fields default correctly ==='); -{ +test('parseSecretsManifest: missing optional fields default correctly', () => { const content = `# Secrets Manifest **Milestone:** M004 @@ -1593,18 +1537,17 @@ console.log('\n=== parseSecretsManifest: missing optional fields default correct `; const m = parseSecretsManifest(content); - assertEq(m.entries.length, 1, 'one entry with missing fields'); - assertEq(m.entries[0].key, 'SOME_API_KEY', 'key parsed'); - assertEq(m.entries[0].service, 'SomeService', 'service parsed'); - assertEq(m.entries[0].dashboardUrl, '', 'missing dashboardUrl defaults to empty string'); - assertEq(m.entries[0].formatHint, '', 'missing formatHint defaults to empty string'); - assertEq(m.entries[0].status, 'pending', 'missing status defaults to pending'); - assertEq(m.entries[0].destination, 'dotenv', 'missing destination defaults to dotenv'); - assertEq(m.entries[0].guidance.length, 1, 'guidance still parsed'); -} + assert.deepStrictEqual(m.entries.length, 1, 'one entry with missing fields'); + assert.deepStrictEqual(m.entries[0].key, 'SOME_API_KEY', 'key parsed'); + assert.deepStrictEqual(m.entries[0].service, 'SomeService', 'service parsed'); + assert.deepStrictEqual(m.entries[0].dashboardUrl, '', 'missing dashboardUrl defaults to empty string'); + assert.deepStrictEqual(m.entries[0].formatHint, '', 'missing formatHint defaults to empty string'); + assert.deepStrictEqual(m.entries[0].status, 'pending', 'missing status defaults to pending'); + assert.deepStrictEqual(m.entries[0].destination, 'dotenv', 'missing destination defaults to dotenv'); + assert.deepStrictEqual(m.entries[0].guidance.length, 1, 'guidance still parsed'); +}); -console.log('\n=== parseSecretsManifest: all three status values parse ==='); -{ +test('parseSecretsManifest: all three status values parse', () => { for (const status of ['pending', 'collected', 'skipped'] as const) { const content = `# Secrets Manifest @@ -1620,12 +1563,11 @@ console.log('\n=== parseSecretsManifest: all three status values parse ==='); `; const m = parseSecretsManifest(content); - assertEq(m.entries[0].status, status, `status variant: ${status}`); + assert.deepStrictEqual(m.entries[0].status, status, `status variant: ${status}`); } -} +}); -console.log('\n=== parseSecretsManifest: invalid status defaults to pending ==='); -{ +test('parseSecretsManifest: invalid status defaults to pending', () => { const content = `# Secrets Manifest **Milestone:** M006 @@ -1640,11 +1582,10 @@ console.log('\n=== parseSecretsManifest: invalid status defaults to pending ===' `; const m = parseSecretsManifest(content); - assertEq(m.entries[0].status, 'pending', 'invalid status defaults to pending'); -} + assert.deepStrictEqual(m.entries[0].status, 'pending', 'invalid status defaults to pending'); +}); -console.log('\n=== parseSecretsManifest + formatSecretsManifest: round-trip ==='); -{ +test('parseSecretsManifest + formatSecretsManifest: round-trip', () => { const original = `# Secrets Manifest **Milestone:** M007 @@ -1679,32 +1620,30 @@ console.log('\n=== parseSecretsManifest + formatSecretsManifest: round-trip ===' const parsed2 = parseSecretsManifest(formatted); // Verify semantic equality after round-trip - assertEq(parsed2.milestone, parsed1.milestone, 'round-trip milestone'); - assertEq(parsed2.generatedAt, parsed1.generatedAt, 'round-trip generatedAt'); - assertEq(parsed2.entries.length, parsed1.entries.length, 'round-trip entry count'); + assert.deepStrictEqual(parsed2.milestone, parsed1.milestone, 'round-trip milestone'); + assert.deepStrictEqual(parsed2.generatedAt, parsed1.generatedAt, 'round-trip generatedAt'); + assert.deepStrictEqual(parsed2.entries.length, parsed1.entries.length, 'round-trip entry count'); for (let i = 0; i < parsed1.entries.length; i++) { const e1 = parsed1.entries[i]; const e2 = parsed2.entries[i]; - assertEq(e2.key, e1.key, `round-trip entry ${i} key`); - assertEq(e2.service, e1.service, `round-trip entry ${i} service`); - assertEq(e2.dashboardUrl, e1.dashboardUrl, `round-trip entry ${i} dashboardUrl`); - assertEq(e2.formatHint, e1.formatHint, `round-trip entry ${i} formatHint`); - assertEq(e2.status, e1.status, `round-trip entry ${i} status`); - assertEq(e2.destination, e1.destination, `round-trip entry ${i} destination`); - assertEq(e2.guidance.length, e1.guidance.length, `round-trip entry ${i} guidance length`); + assert.deepStrictEqual(e2.key, e1.key, `round-trip entry ${i} key`); + assert.deepStrictEqual(e2.service, e1.service, `round-trip entry ${i} service`); + assert.deepStrictEqual(e2.dashboardUrl, e1.dashboardUrl, `round-trip entry ${i} dashboardUrl`); + assert.deepStrictEqual(e2.formatHint, e1.formatHint, `round-trip entry ${i} formatHint`); + assert.deepStrictEqual(e2.status, e1.status, `round-trip entry ${i} status`); + assert.deepStrictEqual(e2.destination, e1.destination, `round-trip entry ${i} destination`); + assert.deepStrictEqual(e2.guidance.length, e1.guidance.length, `round-trip entry ${i} guidance length`); for (let j = 0; j < e1.guidance.length; j++) { - assertEq(e2.guidance[j], e1.guidance[j], `round-trip entry ${i} guidance[${j}]`); + assert.deepStrictEqual(e2.guidance[j], e1.guidance[j], `round-trip entry ${i} guidance[${j}]`); } } -} +}); // ═══════════════════════════════════════════════════════════════════════════ // LLM-style round-trip tests — realistic manifest variations // ═══════════════════════════════════════════════════════════════════════════ - -console.log('\n=== LLM round-trip: extra whitespace ==='); -{ +test('LLM round-trip: extra whitespace', () => { // LLMs often produce inconsistent indentation and trailing spaces const messy = `# Secrets Manifest @@ -1735,34 +1674,33 @@ console.log('\n=== LLM round-trip: extra whitespace ==='); const formatted = formatSecretsManifest(parsed1); const parsed2 = parseSecretsManifest(formatted); - assertEq(parsed2.milestone, parsed1.milestone, 'whitespace round-trip milestone'); - assertEq(parsed2.generatedAt, parsed1.generatedAt, 'whitespace round-trip generatedAt'); - assertEq(parsed2.entries.length, parsed1.entries.length, 'whitespace round-trip entry count'); - assertEq(parsed2.entries.length, 2, 'whitespace: two entries parsed'); + assert.deepStrictEqual(parsed2.milestone, parsed1.milestone, 'whitespace round-trip milestone'); + assert.deepStrictEqual(parsed2.generatedAt, parsed1.generatedAt, 'whitespace round-trip generatedAt'); + assert.deepStrictEqual(parsed2.entries.length, parsed1.entries.length, 'whitespace round-trip entry count'); + assert.deepStrictEqual(parsed2.entries.length, 2, 'whitespace: two entries parsed'); for (let i = 0; i < parsed1.entries.length; i++) { const e1 = parsed1.entries[i]; const e2 = parsed2.entries[i]; - assertEq(e2.key, e1.key, `whitespace round-trip entry ${i} key`); - assertEq(e2.service, e1.service, `whitespace round-trip entry ${i} service`); - assertEq(e2.dashboardUrl, e1.dashboardUrl, `whitespace round-trip entry ${i} dashboardUrl`); - assertEq(e2.formatHint, e1.formatHint, `whitespace round-trip entry ${i} formatHint`); - assertEq(e2.status, e1.status, `whitespace round-trip entry ${i} status`); - assertEq(e2.destination, e1.destination, `whitespace round-trip entry ${i} destination`); - assertEq(e2.guidance.length, e1.guidance.length, `whitespace round-trip entry ${i} guidance length`); + assert.deepStrictEqual(e2.key, e1.key, `whitespace round-trip entry ${i} key`); + assert.deepStrictEqual(e2.service, e1.service, `whitespace round-trip entry ${i} service`); + assert.deepStrictEqual(e2.dashboardUrl, e1.dashboardUrl, `whitespace round-trip entry ${i} dashboardUrl`); + assert.deepStrictEqual(e2.formatHint, e1.formatHint, `whitespace round-trip entry ${i} formatHint`); + assert.deepStrictEqual(e2.status, e1.status, `whitespace round-trip entry ${i} status`); + assert.deepStrictEqual(e2.destination, e1.destination, `whitespace round-trip entry ${i} destination`); + assert.deepStrictEqual(e2.guidance.length, e1.guidance.length, `whitespace round-trip entry ${i} guidance length`); for (let j = 0; j < e1.guidance.length; j++) { - assertEq(e2.guidance[j], e1.guidance[j], `whitespace round-trip entry ${i} guidance[${j}]`); + assert.deepStrictEqual(e2.guidance[j], e1.guidance[j], `whitespace round-trip entry ${i} guidance[${j}]`); } } // Verify the parser correctly stripped trailing whitespace - assertEq(parsed1.milestone, 'M010', 'whitespace: milestone trimmed'); - assertEq(parsed1.entries[0].key, 'OPENAI_API_KEY', 'whitespace: key trimmed'); - assertEq(parsed1.entries[0].service, 'OpenAI', 'whitespace: service trimmed'); -} + assert.deepStrictEqual(parsed1.milestone, 'M010', 'whitespace: milestone trimmed'); + assert.deepStrictEqual(parsed1.entries[0].key, 'OPENAI_API_KEY', 'whitespace: key trimmed'); + assert.deepStrictEqual(parsed1.entries[0].service, 'OpenAI', 'whitespace: service trimmed'); +}); -console.log('\n=== LLM round-trip: missing optional fields ==='); -{ +test('LLM round-trip: missing optional fields', () => { // LLMs may omit Dashboard and Format hint lines entirely const minimal = `# Secrets Manifest @@ -1790,32 +1728,31 @@ console.log('\n=== LLM round-trip: missing optional fields ==='); const parsed1 = parseSecretsManifest(minimal); // Verify missing optional fields get defaults - assertEq(parsed1.entries[0].dashboardUrl, '', 'missing-optional: no dashboard → empty string'); - assertEq(parsed1.entries[0].formatHint, '', 'missing-optional: no format hint → empty string'); - assertEq(parsed1.entries[1].dashboardUrl, '', 'missing-optional: entry 2 no dashboard → empty string'); - assertEq(parsed1.entries[1].formatHint, '', 'missing-optional: entry 2 no format hint → empty string'); + assert.deepStrictEqual(parsed1.entries[0].dashboardUrl, '', 'missing-optional: no dashboard → empty string'); + assert.deepStrictEqual(parsed1.entries[0].formatHint, '', 'missing-optional: no format hint → empty string'); + assert.deepStrictEqual(parsed1.entries[1].dashboardUrl, '', 'missing-optional: entry 2 no dashboard → empty string'); + assert.deepStrictEqual(parsed1.entries[1].formatHint, '', 'missing-optional: entry 2 no format hint → empty string'); // Round-trip: formatter omits empty optional fields, re-parse preserves defaults const formatted = formatSecretsManifest(parsed1); const parsed2 = parseSecretsManifest(formatted); - assertEq(parsed2.entries.length, parsed1.entries.length, 'missing-optional round-trip entry count'); + assert.deepStrictEqual(parsed2.entries.length, parsed1.entries.length, 'missing-optional round-trip entry count'); for (let i = 0; i < parsed1.entries.length; i++) { const e1 = parsed1.entries[i]; const e2 = parsed2.entries[i]; - assertEq(e2.key, e1.key, `missing-optional round-trip entry ${i} key`); - assertEq(e2.service, e1.service, `missing-optional round-trip entry ${i} service`); - assertEq(e2.dashboardUrl, e1.dashboardUrl, `missing-optional round-trip entry ${i} dashboardUrl`); - assertEq(e2.formatHint, e1.formatHint, `missing-optional round-trip entry ${i} formatHint`); - assertEq(e2.status, e1.status, `missing-optional round-trip entry ${i} status`); - assertEq(e2.destination, e1.destination, `missing-optional round-trip entry ${i} destination`); - assertEq(e2.guidance.length, e1.guidance.length, `missing-optional round-trip entry ${i} guidance length`); + assert.deepStrictEqual(e2.key, e1.key, `missing-optional round-trip entry ${i} key`); + assert.deepStrictEqual(e2.service, e1.service, `missing-optional round-trip entry ${i} service`); + assert.deepStrictEqual(e2.dashboardUrl, e1.dashboardUrl, `missing-optional round-trip entry ${i} dashboardUrl`); + assert.deepStrictEqual(e2.formatHint, e1.formatHint, `missing-optional round-trip entry ${i} formatHint`); + assert.deepStrictEqual(e2.status, e1.status, `missing-optional round-trip entry ${i} status`); + assert.deepStrictEqual(e2.destination, e1.destination, `missing-optional round-trip entry ${i} destination`); + assert.deepStrictEqual(e2.guidance.length, e1.guidance.length, `missing-optional round-trip entry ${i} guidance length`); } -} +}); -console.log('\n=== LLM round-trip: extra blank lines ==='); -{ +test('LLM round-trip: extra blank lines', () => { // LLMs sometimes insert excessive blank lines between sections const blanky = `# Secrets Manifest @@ -1859,42 +1796,40 @@ console.log('\n=== LLM round-trip: extra blank lines ==='); const parsed1 = parseSecretsManifest(blanky); - assertEq(parsed1.entries.length, 2, 'blank-lines: two entries parsed'); - assertEq(parsed1.milestone, 'M012', 'blank-lines: milestone parsed'); - assertEq(parsed1.entries[0].key, 'API_KEY_ONE', 'blank-lines: first key'); - assertEq(parsed1.entries[0].guidance.length, 2, 'blank-lines: first entry guidance count'); - assertEq(parsed1.entries[1].key, 'API_KEY_TWO', 'blank-lines: second key'); - assertEq(parsed1.entries[1].status, 'skipped', 'blank-lines: second entry status'); + assert.deepStrictEqual(parsed1.entries.length, 2, 'blank-lines: two entries parsed'); + assert.deepStrictEqual(parsed1.milestone, 'M012', 'blank-lines: milestone parsed'); + assert.deepStrictEqual(parsed1.entries[0].key, 'API_KEY_ONE', 'blank-lines: first key'); + assert.deepStrictEqual(parsed1.entries[0].guidance.length, 2, 'blank-lines: first entry guidance count'); + assert.deepStrictEqual(parsed1.entries[1].key, 'API_KEY_TWO', 'blank-lines: second key'); + assert.deepStrictEqual(parsed1.entries[1].status, 'skipped', 'blank-lines: second entry status'); // Round-trip produces clean output const formatted = formatSecretsManifest(parsed1); const parsed2 = parseSecretsManifest(formatted); - assertEq(parsed2.entries.length, parsed1.entries.length, 'blank-lines round-trip entry count'); + assert.deepStrictEqual(parsed2.entries.length, parsed1.entries.length, 'blank-lines round-trip entry count'); for (let i = 0; i < parsed1.entries.length; i++) { const e1 = parsed1.entries[i]; const e2 = parsed2.entries[i]; - assertEq(e2.key, e1.key, `blank-lines round-trip entry ${i} key`); - assertEq(e2.service, e1.service, `blank-lines round-trip entry ${i} service`); - assertEq(e2.dashboardUrl, e1.dashboardUrl, `blank-lines round-trip entry ${i} dashboardUrl`); - assertEq(e2.formatHint, e1.formatHint, `blank-lines round-trip entry ${i} formatHint`); - assertEq(e2.status, e1.status, `blank-lines round-trip entry ${i} status`); - assertEq(e2.destination, e1.destination, `blank-lines round-trip entry ${i} destination`); - assertEq(e2.guidance.length, e1.guidance.length, `blank-lines round-trip entry ${i} guidance length`); + assert.deepStrictEqual(e2.key, e1.key, `blank-lines round-trip entry ${i} key`); + assert.deepStrictEqual(e2.service, e1.service, `blank-lines round-trip entry ${i} service`); + assert.deepStrictEqual(e2.dashboardUrl, e1.dashboardUrl, `blank-lines round-trip entry ${i} dashboardUrl`); + assert.deepStrictEqual(e2.formatHint, e1.formatHint, `blank-lines round-trip entry ${i} formatHint`); + assert.deepStrictEqual(e2.status, e1.status, `blank-lines round-trip entry ${i} status`); + assert.deepStrictEqual(e2.destination, e1.destination, `blank-lines round-trip entry ${i} destination`); + assert.deepStrictEqual(e2.guidance.length, e1.guidance.length, `blank-lines round-trip entry ${i} guidance length`); } // Verify the formatted output is cleaner (fewer consecutive blank lines) const consecutiveBlanks = formatted.match(/\n{4,}/g); - assertTrue(consecutiveBlanks === null, 'blank-lines: formatted output has no 4+ consecutive newlines'); -} + assert.ok(consecutiveBlanks === null, 'blank-lines: formatted output has no 4+ consecutive newlines'); +}); // ═══════════════════════════════════════════════════════════════════════════ // parseRoadmap: boundary map with embedded code fences (#468) // ═══════════════════════════════════════════════════════════════════════════ - -console.log('\n=== parseRoadmap: boundary map with code fences (#468) ==='); -{ +test('parseRoadmap: boundary map with code fences (#468)', () => { const content = `# M001: Test **Vision:** Test @@ -1923,10 +1858,10 @@ Consumes: nothing const r = parseRoadmap(content); const elapsed = Date.now() - start; - assertTrue(elapsed < 1000, `boundary map with code fences parsed in ${elapsed}ms (should be < 1s)`); - assertEq(r.slices.length, 2, 'code-fence roadmap: slice count'); + assert.ok(elapsed < 1000, `boundary map with code fences parsed in ${elapsed}ms (should be < 1s)`); + assert.deepStrictEqual(r.slices.length, 2, 'code-fence roadmap: slice count'); // Boundary map should still parse (may not capture perfectly with code fences, but must not hang) - assertTrue(r.boundaryMap.length >= 0, 'code-fence roadmap: boundary map parsed without hanging'); -} + assert.ok(r.boundaryMap.length >= 0, 'code-fence roadmap: boundary map parsed without hanging'); +}); -report(); +}); diff --git a/src/resources/extensions/gsd/tests/paths.test.ts b/src/resources/extensions/gsd/tests/paths.test.ts index c27f01976..4ffdeaed9 100644 --- a/src/resources/extensions/gsd/tests/paths.test.ts +++ b/src/resources/extensions/gsd/tests/paths.test.ts @@ -1,13 +1,11 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, rmSync, realpathSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { spawnSync } from "node:child_process"; import { gsdRoot, _clearGsdRootCache } from "../paths.ts"; -import { createTestContext } from "./test-helpers.ts"; - -const { assertEq, assertTrue, report } = createTestContext(); - /** Create a tmp dir and resolve symlinks + 8.3 short names (macOS /var→/private/var, Windows RUNNER~1→runneradmin). */ function tmp(): string { const p = mkdtempSync(join(tmpdir(), "gsd-paths-test-")); @@ -23,91 +21,78 @@ function initGit(dir: string): void { spawnSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: dir }); } -// ── tests ────────────────────────────────────────────────────────────────── +describe('paths', () => { + test('Case 1: .gsd exists at basePath — fast path', () => { + const root = tmp(); + try { + mkdirSync(join(root, ".gsd")); + _clearGsdRootCache(); + const result = gsdRoot(root); + assert.deepStrictEqual(result, join(root, ".gsd"), "fast path: returns basePath/.gsd"); + } finally { cleanup(root); } + }); -{ - // Case 1: .gsd exists at basePath — fast path - const root = tmp(); - try { - mkdirSync(join(root, ".gsd")); - _clearGsdRootCache(); - const result = gsdRoot(root); - assertEq(result, join(root, ".gsd"), "fast path: returns basePath/.gsd"); - } finally { cleanup(root); } -} + test('Case 2: .gsd exists at git root, cwd is a subdirectory', () => { + const root = tmp(); + try { + initGit(root); + mkdirSync(join(root, ".gsd")); + const sub = join(root, "src", "deep"); + mkdirSync(sub, { recursive: true }); + _clearGsdRootCache(); + const result = gsdRoot(sub); + assert.deepStrictEqual(result, join(root, ".gsd"), "git-root probe: finds .gsd at git root from subdirectory"); + } finally { cleanup(root); } + }); -{ - // Case 2: .gsd exists at git root, cwd is a subdirectory - const root = tmp(); - try { - initGit(root); - mkdirSync(join(root, ".gsd")); - const sub = join(root, "src", "deep"); - mkdirSync(sub, { recursive: true }); - _clearGsdRootCache(); - const result = gsdRoot(sub); - assertEq(result, join(root, ".gsd"), "git-root probe: finds .gsd at git root from subdirectory"); - } finally { cleanup(root); } -} + test('Case 3: .gsd in an ancestor — walk-up finds it', () => { + const root = tmp(); + try { + initGit(root); + const project = join(root, "project"); + mkdirSync(join(project, ".gsd"), { recursive: true }); + const deep = join(project, "src", "deep"); + mkdirSync(deep, { recursive: true }); + _clearGsdRootCache(); + const result = gsdRoot(deep); + assert.deepStrictEqual(result, join(project, ".gsd"), "walk-up: finds .gsd in ancestor when git root has none"); + } finally { cleanup(root); } + }); -{ - // Case 3: .gsd in an ancestor — walk-up finds it (git repo with no .gsd at root) - const root = tmp(); - try { - // Init a git repo so git probe returns root — but put .gsd one level deeper - // to force the walk-up path: root/project/.gsd, cwd = root/project/src/deep - initGit(root); - const project = join(root, "project"); - mkdirSync(join(project, ".gsd"), { recursive: true }); - const deep = join(project, "src", "deep"); - mkdirSync(deep, { recursive: true }); - _clearGsdRootCache(); - // git probe returns root (no .gsd there), so walk-up takes over and finds project/.gsd - const result = gsdRoot(deep); - assertEq(result, join(project, ".gsd"), "walk-up: finds .gsd in ancestor when git root has none"); - } finally { cleanup(root); } -} + test('Case 4: .gsd nowhere — fallback returns original basePath/.gsd', () => { + const root = tmp(); + try { + initGit(root); + const sub = join(root, "src"); + mkdirSync(sub, { recursive: true }); + _clearGsdRootCache(); + const result = gsdRoot(sub); + assert.deepStrictEqual(result, join(sub, ".gsd"), "fallback: returns basePath/.gsd when .gsd not found anywhere"); + } finally { cleanup(root); } + }); -{ - // Case 4: .gsd nowhere — fallback returns original basePath/.gsd - // Use an isolated git repo so we fully control the environment above basePath - const root = tmp(); - try { - initGit(root); // git root = root, no .gsd anywhere - const sub = join(root, "src"); - mkdirSync(sub, { recursive: true }); - _clearGsdRootCache(); - const result = gsdRoot(sub); - // git probe finds root (no .gsd), walk-up finds nothing → fallback = sub/.gsd - assertEq(result, join(sub, ".gsd"), "fallback: returns basePath/.gsd when .gsd not found anywhere"); - } finally { cleanup(root); } -} + test('Case 5: cache — second call returns same value without re-probing', () => { + const root = tmp(); + try { + mkdirSync(join(root, ".gsd")); + _clearGsdRootCache(); + const first = gsdRoot(root); + const second = gsdRoot(root); + assert.deepStrictEqual(first, second, "cache: same result returned on second call"); + assert.ok(first === second, "cache: identity check (same string)"); + } finally { cleanup(root); } + }); -{ - // Case 5: cache — second call returns same value without re-probing - const root = tmp(); - try { - mkdirSync(join(root, ".gsd")); - _clearGsdRootCache(); - const first = gsdRoot(root); - const second = gsdRoot(root); - assertEq(first, second, "cache: same result returned on second call"); - assertTrue(first === second, "cache: identity check (same string)"); - } finally { cleanup(root); } -} - -{ - // Case 6: .gsd at basePath takes precedence over ancestor .gsd - const outer = tmp(); - try { - initGit(outer); - mkdirSync(join(outer, ".gsd")); - const inner = join(outer, "nested"); - mkdirSync(join(inner, ".gsd"), { recursive: true }); - _clearGsdRootCache(); - const result = gsdRoot(inner); - assertEq(result, join(inner, ".gsd"), "precedence: nearest .gsd wins over ancestor"); - } finally { cleanup(outer); } -} - -report(); + test('Case 6: .gsd at basePath takes precedence over ancestor .gsd', () => { + const outer = tmp(); + try { + initGit(outer); + mkdirSync(join(outer, ".gsd")); + const inner = join(outer, "nested"); + mkdirSync(join(inner, ".gsd"), { recursive: true }); + _clearGsdRootCache(); + const result = gsdRoot(inner); + assert.deepStrictEqual(result, join(inner, ".gsd"), "precedence: nearest .gsd wins over ancestor"); + } finally { cleanup(outer); } + }); +}); diff --git a/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts b/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts index 771af2968..7294a8d1f 100644 --- a/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts +++ b/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts @@ -1,9 +1,10 @@ // GSD Extension — Hook Engine Tests (Post-Unit, Pre-Dispatch, State Persistence) +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { createTestContext } from "./test-helpers.ts"; import { checkPostUnitHooks, getActiveHook, @@ -20,8 +21,6 @@ import { triggerHookManually, } from "../post-unit-hooks.ts"; -const { assertEq, assertTrue, assertMatch, report } = createTestContext(); - // ─── Fixture Helpers ─────────────────────────────────────────────────────── function createFixtureBase(): string { @@ -36,14 +35,14 @@ function createFixtureBase(): string { // ─── resolveHookArtifactPath ─────────────────────────────────────────────── -console.log("\n=== resolveHookArtifactPath ==="); -{ +describe('post-unit-hooks', () => { +test('resolveHookArtifactPath', () => { const base = "/project"; // Task-level const taskPath = resolveHookArtifactPath(base, "M001/S01/T01", "REVIEW-PASS.md"); - assertEq( + assert.deepStrictEqual( taskPath, join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks", "T01-REVIEW-PASS.md"), "task-level artifact path", @@ -51,7 +50,7 @@ console.log("\n=== resolveHookArtifactPath ==="); // Slice-level const slicePath = resolveHookArtifactPath(base, "M001/S01", "REVIEW-PASS.md"); - assertEq( + assert.deepStrictEqual( slicePath, join(base, ".gsd", "milestones", "M001", "slices", "S01", "REVIEW-PASS.md"), "slice-level artifact path", @@ -59,129 +58,106 @@ console.log("\n=== resolveHookArtifactPath ==="); // Milestone-level const milestonePath = resolveHookArtifactPath(base, "M001", "REVIEW-PASS.md"); - assertEq( + assert.deepStrictEqual( milestonePath, join(base, ".gsd", "milestones", "M001", "REVIEW-PASS.md"), "milestone-level artifact path", ); -} +}); // ─── resetHookState ──────────────────────────────────────────────────────── - -console.log("\n=== resetHookState ==="); - -{ +test('resetHookState', () => { resetHookState(); - assertEq(getActiveHook(), null, "no active hook after reset"); - assertTrue(!isRetryPending(), "no retry pending after reset"); - assertEq(consumeRetryTrigger(), null, "no retry trigger after reset"); -} + assert.deepStrictEqual(getActiveHook(), null, "no active hook after reset"); + assert.ok(!isRetryPending(), "no retry pending after reset"); + assert.deepStrictEqual(consumeRetryTrigger(), null, "no retry trigger after reset"); +}); // ─── checkPostUnitHooks with no hooks configured ─────────────────────────── - -console.log("\n=== No hooks configured ==="); - -{ +test('No hooks configured', () => { resetHookState(); const base = createFixtureBase(); try { const result = checkPostUnitHooks("execute-task", "M001/S01/T01", base); - assertEq(result, null, "returns null when no hooks configured"); + assert.deepStrictEqual(result, null, "returns null when no hooks configured"); } finally { rmSync(base, { recursive: true, force: true }); } -} +}); // ─── Hook units don't trigger hooks (no hook-on-hook) ────────────────────── - -console.log("\n=== Hook-on-hook prevention ==="); - -{ +test('Hook-on-hook prevention', () => { resetHookState(); const base = createFixtureBase(); try { const result = checkPostUnitHooks("hook/code-review", "M001/S01/T01", base); - assertEq(result, null, "hook units don't trigger other hooks"); + assert.deepStrictEqual(result, null, "hook units don't trigger other hooks"); } finally { rmSync(base, { recursive: true, force: true }); } -} +}); // ─── consumeRetryTrigger clears state ────────────────────────────────────── - -console.log("\n=== consumeRetryTrigger clears state ==="); - -{ +test('consumeRetryTrigger clears state', () => { resetHookState(); - assertEq(consumeRetryTrigger(), null, "no trigger initially"); - assertTrue(!isRetryPending(), "no retry initially"); -} + assert.deepStrictEqual(consumeRetryTrigger(), null, "no trigger initially"); + assert.ok(!isRetryPending(), "no retry initially"); +}); // ─── Variable substitution in prompts ────────────────────────────────────── - -console.log("\n=== Variable substitution ==="); - -{ +test('Variable substitution', () => { const base = "/project"; // 3-part ID const path3 = resolveHookArtifactPath(base, "M002/S03/T05", "result.md"); - assertTrue(path3.includes("M002"), "3-part ID extracts milestoneId"); - assertTrue(path3.includes("S03"), "3-part ID extracts sliceId"); - assertTrue(path3.includes("T05"), "3-part ID extracts taskId"); - assertTrue(path3.includes("milestones"), "3-part ID includes milestones/ segment"); + assert.ok(path3.includes("M002"), "3-part ID extracts milestoneId"); + assert.ok(path3.includes("S03"), "3-part ID extracts sliceId"); + assert.ok(path3.includes("T05"), "3-part ID extracts taskId"); + assert.ok(path3.includes("milestones"), "3-part ID includes milestones/ segment"); // 2-part ID const path2 = resolveHookArtifactPath(base, "M002/S03", "result.md"); - assertTrue(path2.includes("M002"), "2-part ID extracts milestoneId"); - assertTrue(path2.includes("S03"), "2-part ID extracts sliceId"); - assertTrue(path2.includes("milestones"), "2-part ID includes milestones/ segment"); + assert.ok(path2.includes("M002"), "2-part ID extracts milestoneId"); + assert.ok(path2.includes("S03"), "2-part ID extracts sliceId"); + assert.ok(path2.includes("milestones"), "2-part ID includes milestones/ segment"); // 1-part ID const path1 = resolveHookArtifactPath(base, "M002", "result.md"); - assertTrue(path1.includes("M002"), "1-part ID extracts milestoneId"); - assertTrue(path1.includes("milestones"), "1-part ID includes milestones/ segment"); -} + assert.ok(path1.includes("M002"), "1-part ID extracts milestoneId"); + assert.ok(path1.includes("milestones"), "1-part ID includes milestones/ segment"); +}); // ═══════════════════════════════════════════════════════════════════════════ // Phase 2: Pre-Dispatch Hook Tests // ═══════════════════════════════════════════════════════════════════════════ - -console.log("\n=== Pre-dispatch: no hooks configured ==="); - -{ +test('Pre-dispatch: no hooks configured', () => { const base = createFixtureBase(); try { const result = runPreDispatchHooks("execute-task", "M001/S01/T01", "original prompt", base); - assertEq(result.action, "proceed", "proceeds when no hooks"); - assertEq(result.prompt, "original prompt", "prompt unchanged"); - assertEq(result.firedHooks.length, 0, "no hooks fired"); + assert.deepStrictEqual(result.action, "proceed", "proceeds when no hooks"); + assert.deepStrictEqual(result.prompt, "original prompt", "prompt unchanged"); + assert.deepStrictEqual(result.firedHooks.length, 0, "no hooks fired"); } finally { rmSync(base, { recursive: true, force: true }); } -} +}); -console.log("\n=== Pre-dispatch: hook units bypass ==="); - -{ +test('Pre-dispatch: hook units bypass', () => { const base = createFixtureBase(); try { const result = runPreDispatchHooks("hook/review", "M001/S01/T01", "hook prompt", base); - assertEq(result.action, "proceed", "hook units always proceed"); - assertEq(result.prompt, "hook prompt", "hook prompt unchanged"); - assertEq(result.firedHooks.length, 0, "no hooks fired for hook units"); + assert.deepStrictEqual(result.action, "proceed", "hook units always proceed"); + assert.deepStrictEqual(result.prompt, "hook prompt", "hook prompt unchanged"); + assert.deepStrictEqual(result.firedHooks.length, 0, "no hooks fired for hook units"); } finally { rmSync(base, { recursive: true, force: true }); } -} +}); // ═══════════════════════════════════════════════════════════════════════════ // Phase 3: State Persistence Tests // ═══════════════════════════════════════════════════════════════════════════ - -console.log("\n=== State persistence: persist and restore ==="); - -{ +test('State persistence: persist and restore', () => { const base = createFixtureBase(); try { resetHookState(); @@ -189,19 +165,17 @@ console.log("\n=== State persistence: persist and restore ==="); // Persist empty state persistHookState(base); const filePath = join(base, ".gsd", "hook-state.json"); - assertTrue(existsSync(filePath), "hook-state.json created"); + assert.ok(existsSync(filePath), "hook-state.json created"); const content = JSON.parse(readFileSync(filePath, "utf-8")); - assertEq(typeof content.savedAt, "string", "savedAt is a string"); - assertEq(Object.keys(content.cycleCounts).length, 0, "empty cycle counts"); + assert.deepStrictEqual(typeof content.savedAt, "string", "savedAt is a string"); + assert.deepStrictEqual(Object.keys(content.cycleCounts).length, 0, "empty cycle counts"); } finally { rmSync(base, { recursive: true, force: true }); } -} +}); -console.log("\n=== State persistence: restore from disk ==="); - -{ +test('State persistence: restore from disk', () => { const base = createFixtureBase(); try { resetHookState(); @@ -222,16 +196,14 @@ console.log("\n=== State persistence: restore from disk ==="); // Verify by persisting and reading back persistHookState(base); const restored = JSON.parse(readFileSync(stateFile, "utf-8")); - assertEq(restored.cycleCounts["review/execute-task/M001/S01/T01"], 2, "cycle count restored for review"); - assertEq(restored.cycleCounts["simplify/execute-task/M001/S01/T02"], 1, "cycle count restored for simplify"); + assert.deepStrictEqual(restored.cycleCounts["review/execute-task/M001/S01/T01"], 2, "cycle count restored for review"); + assert.deepStrictEqual(restored.cycleCounts["simplify/execute-task/M001/S01/T02"], 1, "cycle count restored for simplify"); } finally { rmSync(base, { recursive: true, force: true }); } -} +}); -console.log("\n=== State persistence: clear ==="); - -{ +test('State persistence: clear', () => { const base = createFixtureBase(); try { resetHookState(); @@ -246,77 +218,65 @@ console.log("\n=== State persistence: clear ==="); clearPersistedHookState(base); const cleared = JSON.parse(readFileSync(stateFile, "utf-8")); - assertEq(Object.keys(cleared.cycleCounts).length, 0, "cycle counts cleared"); + assert.deepStrictEqual(Object.keys(cleared.cycleCounts).length, 0, "cycle counts cleared"); } finally { rmSync(base, { recursive: true, force: true }); } -} +}); -console.log("\n=== State persistence: restore handles missing file ==="); - -{ +test('State persistence: restore handles missing file', () => { const base = createFixtureBase(); try { resetHookState(); // Should not throw restoreHookState(base); - assertEq(getActiveHook(), null, "no active hook after restore from missing file"); + assert.deepStrictEqual(getActiveHook(), null, "no active hook after restore from missing file"); } finally { rmSync(base, { recursive: true, force: true }); } -} +}); -console.log("\n=== State persistence: restore handles corrupt file ==="); - -{ +test('State persistence: restore handles corrupt file', () => { const base = createFixtureBase(); try { resetHookState(); writeFileSync(join(base, ".gsd", "hook-state.json"), "not json", "utf-8"); // Should not throw restoreHookState(base); - assertEq(getActiveHook(), null, "no active hook after corrupt restore"); + assert.deepStrictEqual(getActiveHook(), null, "no active hook after corrupt restore"); } finally { rmSync(base, { recursive: true, force: true }); } -} +}); // ═══════════════════════════════════════════════════════════════════════════ // Phase 3: Hook Status Reporting Tests // ═══════════════════════════════════════════════════════════════════════════ - -console.log("\n=== Hook status: no hooks ==="); - -{ +test('Hook status: no hooks', () => { resetHookState(); const entries = getHookStatus(); // No preferences file = no hooks - assertEq(entries.length, 0, "no entries when no hooks configured"); + assert.deepStrictEqual(entries.length, 0, "no entries when no hooks configured"); const formatted = formatHookStatus(); - assertMatch(formatted, /No hooks configured/, "status message says no hooks"); -} + assert.match(formatted, /No hooks configured/, "status message says no hooks"); +}); // ═══════════════════════════════════════════════════════════════════════════ // Phase 4: Manual Hook Trigger Tests // ═══════════════════════════════════════════════════════════════════════════ - -console.log("\n=== triggerHookManually: hook not found ==="); - -{ +test('triggerHookManually: hook not found', () => { resetHookState(); const base = createFixtureBase(); try { const result = triggerHookManually("nonexistent-hook", "execute-task", "M001/S01/T01", base); - assertEq(result, null, "returns null when hook not found"); + assert.deepStrictEqual(result, null, "returns null when hook not found"); } finally { rmSync(base, { recursive: true, force: true }); } -} +}); -console.log("\n=== triggerHookManually: with configured hook ==="); - -{ +test('triggerHookManually: with configured hook', () => { resetHookState(); const base = createFixtureBase(); try { @@ -325,16 +285,16 @@ console.log("\n=== triggerHookManually: with configured hook ==="); const result = triggerHookManually("code-review", "execute-task", "M001/S01/T01", base); // Result depends on whether code-review hook is configured in preferences // The function should either return null or a valid HookDispatchResult - assertTrue(result === null || typeof result === "object", "returns null or object"); + assert.ok(result === null || typeof result === "object", "returns null or object"); if (result) { - assertEq(result.hookName, "code-review", "hook name in result"); - assertEq(result.unitType, "hook/code-review", "unit type is hook-prefixed"); - assertEq(result.unitId, "M001/S01/T01", "unit ID preserved"); - assertTrue(typeof result.prompt === "string", "prompt is a string"); + assert.deepStrictEqual(result.hookName, "code-review", "hook name in result"); + assert.deepStrictEqual(result.unitType, "hook/code-review", "unit type is hook-prefixed"); + assert.deepStrictEqual(result.unitId, "M001/S01/T01", "unit ID preserved"); + assert.ok(typeof result.prompt === "string", "prompt is a string"); } } finally { rmSync(base, { recursive: true, force: true }); } -} +}); -report(); +}); diff --git a/src/resources/extensions/gsd/tests/prompt-db.test.ts b/src/resources/extensions/gsd/tests/prompt-db.test.ts index 5e934b6e0..35853a82d 100644 --- a/src/resources/extensions/gsd/tests/prompt-db.test.ts +++ b/src/resources/extensions/gsd/tests/prompt-db.test.ts @@ -5,7 +5,8 @@ // (b) Helpers fall back to non-null output when DB unavailable // (c) Scoped filtering actually reduces content -import { createTestContext } from './test-helpers.ts'; +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; import { openDatabase, closeDatabase, @@ -22,8 +23,6 @@ import { formatRequirementsForPrompt, } from '../context-store.ts'; -const { assertEq, assertTrue, assertMatch, assertNoMatch, report } = createTestContext(); - // ═══════════════════════════════════════════════════════════════════════════ // prompt-db: DB-aware decisions helper returns scoped content // ═══════════════════════════════════════════════════════════════════════════ @@ -50,23 +49,23 @@ console.log('\n=== prompt-db: scoped decisions from DB ==='); // Query scoped to M001 const m001Decisions = queryDecisions({ milestoneId: 'M001' }); - assertTrue(m001Decisions.length > 0, 'M001 decisions should exist'); - assertTrue(m001Decisions.length < 10, `scoped query should return fewer than 10 (got ${m001Decisions.length})`); + assert.ok(m001Decisions.length > 0, 'M001 decisions should exist'); + assert.ok(m001Decisions.length < 10, `scoped query should return fewer than 10 (got ${m001Decisions.length})`); // Verify all returned decisions are for M001 for (const d of m001Decisions) { - assertMatch(d.when_context, /M001/, `decision ${d.id} should be for M001`); + assert.match(d.when_context, /M001/, `decision ${d.id} should be for M001`); } // Format and verify wrapping const formatted = formatDecisionsForPrompt(m001Decisions); - assertTrue(formatted.length > 0, 'formatted decisions should be non-empty'); - assertMatch(formatted, /\| # \| When \| Scope/, 'formatted decisions have table header'); + assert.ok(formatted.length > 0, 'formatted decisions should be non-empty'); + assert.match(formatted, /\| # \| When \| Scope/, 'formatted decisions have table header'); // Verify the expected wrapper format that inlineDecisionsFromDb would produce const wrapped = `### Decisions\nSource: \`.gsd/DECISIONS.md\`\n\n${formatted}`; - assertMatch(wrapped, /^### Decisions/, 'wrapped decisions start with ### Decisions'); - assertMatch(wrapped, /Source:.*DECISIONS\.md/, 'wrapped decisions have source path'); + assert.match(wrapped, /^### Decisions/, 'wrapped decisions start with ### Decisions'); + assert.match(wrapped, /Source:.*DECISIONS\.md/, 'wrapped decisions have source path'); closeDatabase(); } @@ -101,25 +100,25 @@ console.log('\n=== prompt-db: scoped requirements from DB ==='); // Query scoped to S01 — should get R001 (primary) and R002 (supporting) const s01Reqs = queryRequirements({ sliceId: 'S01' }); - assertEq(s01Reqs.length, 2, 'S01 requirements should be 2 (primary + supporting)'); + assert.deepStrictEqual(s01Reqs.length, 2, 'S01 requirements should be 2 (primary + supporting)'); const ids = s01Reqs.map(r => r.id).sort(); - assertEq(ids, ['R001', 'R002'], 'S01 owns R001 and supports R002'); + assert.deepStrictEqual(ids, ['R001', 'R002'], 'S01 owns R001 and supports R002'); // Unscoped query returns all 3 const allReqs = queryRequirements(); - assertEq(allReqs.length, 3, 'unscoped requirements should return all 3'); + assert.deepStrictEqual(allReqs.length, 3, 'unscoped requirements should return all 3'); // Format and verify wrapping const formatted = formatRequirementsForPrompt(s01Reqs); - assertTrue(formatted.length > 0, 'formatted requirements should be non-empty'); - assertMatch(formatted, /### R001/, 'formatted requirements include R001'); - assertMatch(formatted, /### R002/, 'formatted requirements include R002'); - assertNoMatch(formatted, /### R003/, 'formatted requirements exclude R003'); + assert.ok(formatted.length > 0, 'formatted requirements should be non-empty'); + assert.match(formatted, /### R001/, 'formatted requirements include R001'); + assert.match(formatted, /### R002/, 'formatted requirements include R002'); + assert.doesNotMatch(formatted, /### R003/, 'formatted requirements exclude R003'); // Verify the expected wrapper format that inlineRequirementsFromDb would produce const wrapped = `### Requirements\nSource: \`.gsd/REQUIREMENTS.md\`\n\n${formatted}`; - assertMatch(wrapped, /^### Requirements/, 'wrapped requirements start with ### Requirements'); - assertMatch(wrapped, /Source:.*REQUIREMENTS\.md/, 'wrapped requirements have source path'); + assert.match(wrapped, /^### Requirements/, 'wrapped requirements start with ### Requirements'); + assert.match(wrapped, /Source:.*REQUIREMENTS\.md/, 'wrapped requirements have source path'); closeDatabase(); } @@ -142,13 +141,13 @@ console.log('\n=== prompt-db: project content from DB ==='); }); const content = queryProject(); - assertEq(content, '# Test Project\n\nThis is the project description.', 'queryProject returns content'); + assert.deepStrictEqual(content, '# Test Project\n\nThis is the project description.', 'queryProject returns content'); // Verify the expected wrapper format that inlineProjectFromDb would produce const wrapped = `### Project\nSource: \`.gsd/PROJECT.md\`\n\n${content}`; - assertMatch(wrapped, /^### Project/, 'wrapped project starts with ### Project'); - assertMatch(wrapped, /Source:.*PROJECT\.md/, 'wrapped project has source path'); - assertMatch(wrapped, /# Test Project/, 'wrapped project includes content'); + assert.match(wrapped, /^### Project/, 'wrapped project starts with ### Project'); + assert.match(wrapped, /Source:.*PROJECT\.md/, 'wrapped project has source path'); + assert.match(wrapped, /# Test Project/, 'wrapped project includes content'); closeDatabase(); } @@ -160,27 +159,27 @@ console.log('\n=== prompt-db: project content from DB ==='); console.log('\n=== prompt-db: fallback when DB unavailable ==='); { closeDatabase(); - assertTrue(!isDbAvailable(), 'DB should not be available'); + assert.ok(!isDbAvailable(), 'DB should not be available'); // queryDecisions returns [] when DB closed — helper would fall back const decisions = queryDecisions({ milestoneId: 'M001' }); - assertEq(decisions, [], 'queryDecisions returns [] when DB closed'); + assert.deepStrictEqual(decisions, [], 'queryDecisions returns [] when DB closed'); // queryRequirements returns [] when DB closed — helper would fall back const requirements = queryRequirements({ sliceId: 'S01' }); - assertEq(requirements, [], 'queryRequirements returns [] when DB closed'); + assert.deepStrictEqual(requirements, [], 'queryRequirements returns [] when DB closed'); // queryProject returns null when DB closed — helper would fall back const project = queryProject(); - assertEq(project, null, 'queryProject returns null when DB closed'); + assert.deepStrictEqual(project, null, 'queryProject returns null when DB closed'); // formatDecisionsForPrompt returns '' for empty input const formatted = formatDecisionsForPrompt([]); - assertEq(formatted, '', 'formatDecisionsForPrompt returns empty for empty input'); + assert.deepStrictEqual(formatted, '', 'formatDecisionsForPrompt returns empty for empty input'); // formatRequirementsForPrompt returns '' for empty input const formattedReqs = formatRequirementsForPrompt([]); - assertEq(formattedReqs, '', 'formatRequirementsForPrompt returns empty for empty input'); + assert.deepStrictEqual(formattedReqs, '', 'formatRequirementsForPrompt returns empty for empty input'); } // ═══════════════════════════════════════════════════════════════════════════ @@ -210,15 +209,15 @@ console.log('\n=== prompt-db: scoped filtering reduces content ==='); const allDecisions = queryDecisions(); const m001Decisions = queryDecisions({ milestoneId: 'M001' }); - assertEq(allDecisions.length, 10, 'unscoped returns all 10 decisions'); - assertTrue(m001Decisions.length < 10, `M001-scoped returns fewer than 10 (got ${m001Decisions.length})`); - assertTrue(m001Decisions.length > 0, 'M001-scoped returns at least 1'); + assert.deepStrictEqual(allDecisions.length, 10, 'unscoped returns all 10 decisions'); + assert.ok(m001Decisions.length < 10, `M001-scoped returns fewer than 10 (got ${m001Decisions.length})`); + assert.ok(m001Decisions.length > 0, 'M001-scoped returns at least 1'); // Format both and compare sizes — scoped should be shorter const allFormatted = formatDecisionsForPrompt(allDecisions); const scopedFormatted = formatDecisionsForPrompt(m001Decisions); - assertTrue( + assert.ok( scopedFormatted.length < allFormatted.length, `scoped content (${scopedFormatted.length} chars) should be shorter than unscoped (${allFormatted.length} chars)`, ); @@ -245,14 +244,14 @@ console.log('\n=== prompt-db: scoped filtering reduces content ==='); const allReqs = queryRequirements(); const s01Reqs = queryRequirements({ sliceId: 'S01' }); - assertEq(allReqs.length, 8, 'unscoped returns all 8 requirements'); - assertTrue(s01Reqs.length < 8, `S01-scoped returns fewer than 8 (got ${s01Reqs.length})`); - assertTrue(s01Reqs.length > 0, 'S01-scoped returns at least 1'); + assert.deepStrictEqual(allReqs.length, 8, 'unscoped returns all 8 requirements'); + assert.ok(s01Reqs.length < 8, `S01-scoped returns fewer than 8 (got ${s01Reqs.length})`); + assert.ok(s01Reqs.length > 0, 'S01-scoped returns at least 1'); const allReqsFormatted = formatRequirementsForPrompt(allReqs); const scopedReqsFormatted = formatRequirementsForPrompt(s01Reqs); - assertTrue( + assert.ok( scopedReqsFormatted.length < allReqsFormatted.length, `scoped requirements (${scopedReqsFormatted.length} chars) should be shorter than unscoped (${allReqsFormatted.length} chars)`, ); @@ -292,23 +291,23 @@ console.log('\n=== prompt-db: DB helpers wrapper format matches expected pattern // Simulate what inlineDecisionsFromDb does const decisions = queryDecisions({ milestoneId: 'M001' }); - assertTrue(decisions.length === 1, 'got 1 decision for M001'); + assert.ok(decisions.length === 1, 'got 1 decision for M001'); const dFormatted = formatDecisionsForPrompt(decisions); const dWrapped = `### Decisions\nSource: \`.gsd/DECISIONS.md\`\n\n${dFormatted}`; - assertMatch(dWrapped, /^### Decisions\nSource: `.gsd\/DECISIONS\.md`\n\n\| #/, 'decisions wrapper format correct'); + assert.match(dWrapped, /^### Decisions\nSource: `.gsd\/DECISIONS\.md`\n\n\| #/, 'decisions wrapper format correct'); // Simulate what inlineRequirementsFromDb does const reqs = queryRequirements({ sliceId: 'S01' }); - assertTrue(reqs.length === 1, 'got 1 requirement for S01'); + assert.ok(reqs.length === 1, 'got 1 requirement for S01'); const rFormatted = formatRequirementsForPrompt(reqs); const rWrapped = `### Requirements\nSource: \`.gsd/REQUIREMENTS.md\`\n\n${rFormatted}`; - assertMatch(rWrapped, /^### Requirements\nSource: `.gsd\/REQUIREMENTS\.md`\n\n### R001/, 'requirements wrapper format correct'); + assert.match(rWrapped, /^### Requirements\nSource: `.gsd\/REQUIREMENTS\.md`\n\n### R001/, 'requirements wrapper format correct'); // Simulate what inlineProjectFromDb does const project = queryProject(); - assertTrue(project !== null, 'project content exists'); + assert.ok(project !== null, 'project content exists'); const pWrapped = `### Project\nSource: \`.gsd/PROJECT.md\`\n\n${project}`; - assertMatch(pWrapped, /^### Project\nSource: `.gsd\/PROJECT\.md`\n\n# Project Name/, 'project wrapper format correct'); + assert.match(pWrapped, /^### Project\nSource: `.gsd\/PROJECT\.md`\n\n# Project Name/, 'project wrapper format correct'); closeDatabase(); } @@ -322,8 +321,9 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { migrateFromMarkdown } from '../md-importer.ts'; -console.log('\n=== prompt-db: re-import updates DB when source markdown changes ==='); -{ + +describe('prompt-db', () => { +test('prompt-db: re-import updates DB when source markdown changes', () => { // Create a temp dir simulating a project with .gsd/DECISIONS.md const tmpDir = mkdtempSync(join(tmpdir(), 'prompt-db-reimport-')); const gsdDir = join(tmpDir, '.gsd'); @@ -345,9 +345,9 @@ console.log('\n=== prompt-db: re-import updates DB when source markdown changes // Verify initial state: 2 decisions const initial = queryDecisions(); - assertEq(initial.length, 2, 're-import: initial import has 2 decisions'); + assert.deepStrictEqual(initial.length, 2, 're-import: initial import has 2 decisions'); const initialIds = initial.map(d => d.id).sort(); - assertEq(initialIds, ['D001', 'D002'], 're-import: initial decisions are D001, D002'); + assert.deepStrictEqual(initialIds, ['D001', 'D002'], 're-import: initial decisions are D001, D002'); // Now "the LLM modifies DECISIONS.md" — add a third decision const updatedDecisions = `# Decisions Register @@ -365,23 +365,23 @@ console.log('\n=== prompt-db: re-import updates DB when source markdown changes // Verify DB now has 3 decisions const afterReimport = queryDecisions(); - assertEq(afterReimport.length, 3, 're-import: after re-import has 3 decisions'); + assert.deepStrictEqual(afterReimport.length, 3, 're-import: after re-import has 3 decisions'); const afterIds = afterReimport.map(d => d.id).sort(); - assertEq(afterIds, ['D001', 'D002', 'D003'], 're-import: decisions are D001, D002, D003'); + assert.deepStrictEqual(afterIds, ['D001', 'D002', 'D003'], 're-import: decisions are D001, D002, D003'); // Verify the new decision has correct data const d003 = afterReimport.find(d => d.id === 'D003'); - assertTrue(d003 !== undefined, 're-import: D003 exists'); - assertEq(d003!.when_context, 'M001/S02', 're-import: D003 when_context is M001/S02'); - assertEq(d003!.scope, 'runtime', 're-import: D003 scope is runtime'); - assertEq(d003!.choice, 'D014 pattern', 're-import: D003 choice is D014 pattern'); + assert.ok(d003 !== undefined, 're-import: D003 exists'); + assert.deepStrictEqual(d003!.when_context, 'M001/S02', 're-import: D003 when_context is M001/S02'); + assert.deepStrictEqual(d003!.scope, 'runtime', 're-import: D003 scope is runtime'); + assert.deepStrictEqual(d003!.choice, 'D014 pattern', 're-import: D003 choice is D014 pattern'); // Verify scoped query picks up the new decision const m001Scoped = queryDecisions({ milestoneId: 'M001' }); - assertTrue(m001Scoped.length === 3, 're-import: all 3 decisions are for M001'); + assert.ok(m001Scoped.length === 3, 're-import: all 3 decisions are for M001'); closeDatabase(); -} +}); // ─── Final Report ────────────────────────────────────────────────────────── -report(); +}); diff --git a/src/resources/extensions/gsd/tests/queue-draft-detection.test.ts b/src/resources/extensions/gsd/tests/queue-draft-detection.test.ts index ff065c5e7..8ec04f55c 100644 --- a/src/resources/extensions/gsd/tests/queue-draft-detection.test.ts +++ b/src/resources/extensions/gsd/tests/queue-draft-detection.test.ts @@ -1,3 +1,5 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -5,122 +7,94 @@ import { tmpdir } from "node:os"; import { deriveState } from "../state.js"; import { buildExistingMilestonesContext } from "../guided-flow.js"; -let passed = 0; -let failed = 0; +describe('queue-draft-detection', () => { + test('draft and context milestone detection', async () => { + const tmpBase = mkdtempSync(join(tmpdir(), "gsd-queue-draft-test-")); + const gsd = join(tmpBase, ".gsd"); -function assert(condition: boolean, message: string): void { - if (condition) { - passed++; - } else { - failed++; - console.error(` FAIL: ${message}`); - } -} + try { + // M001: has only CONTEXT-DRAFT.md (draft milestone) + mkdirSync(join(gsd, "milestones", "M001"), { recursive: true }); + writeFileSync( + join(gsd, "milestones", "M001", "M001-CONTEXT-DRAFT.md"), + "# M001: Draft Milestone\n\nSeed material from prior discussion.\n", + ); -// ─── Fixture setup ────────────────────────────────────────────────────── + // M002: has full CONTEXT.md (ready milestone) + mkdirSync(join(gsd, "milestones", "M002"), { recursive: true }); + writeFileSync( + join(gsd, "milestones", "M002", "M002-CONTEXT.md"), + "# M002: Ready Milestone\n\nFull context from deep discussion.\n", + ); -const tmpBase = mkdtempSync(join(tmpdir(), "gsd-queue-draft-test-")); -const gsd = join(tmpBase, ".gsd"); + // M003: has both CONTEXT.md and CONTEXT-DRAFT.md (CONTEXT wins) + mkdirSync(join(gsd, "milestones", "M003"), { recursive: true }); + writeFileSync( + join(gsd, "milestones", "M003", "M003-CONTEXT.md"), + "# M003: Full Context\n\nThis is the real context.\n", + ); + writeFileSync( + join(gsd, "milestones", "M003", "M003-CONTEXT-DRAFT.md"), + "# M003: Draft\n\nThis should be ignored.\n", + ); -// M001: has only CONTEXT-DRAFT.md (draft milestone) -mkdirSync(join(gsd, "milestones", "M001"), { recursive: true }); -writeFileSync( - join(gsd, "milestones", "M001", "M001-CONTEXT-DRAFT.md"), - "# M001: Draft Milestone\n\nSeed material from prior discussion.\n", -); + // M004: has neither (empty milestone dir) + mkdirSync(join(gsd, "milestones", "M004"), { recursive: true }); -// M002: has full CONTEXT.md (ready milestone) -mkdirSync(join(gsd, "milestones", "M002"), { recursive: true }); -writeFileSync( - join(gsd, "milestones", "M002", "M002-CONTEXT.md"), - "# M002: Ready Milestone\n\nFull context from deep discussion.\n", -); + // Build context + const state = await deriveState(tmpBase); + const milestoneIds = ["M001", "M002", "M003", "M004"]; + const context = await buildExistingMilestonesContext(tmpBase, milestoneIds, state); -// M003: has both CONTEXT.md and CONTEXT-DRAFT.md (CONTEXT wins) -mkdirSync(join(gsd, "milestones", "M003"), { recursive: true }); -writeFileSync( - join(gsd, "milestones", "M003", "M003-CONTEXT.md"), - "# M003: Full Context\n\nThis is the real context.\n", -); -writeFileSync( - join(gsd, "milestones", "M003", "M003-CONTEXT-DRAFT.md"), - "# M003: Draft\n\nThis should be ignored.\n", -); + // draft-only milestone includes "Draft context available" + assert.ok( + context.includes("Draft context available"), + "M001 (draft-only) should include 'Draft context available' label", + ); + assert.ok( + context.includes("Seed material from prior discussion"), + "M001 draft content should be included in context output", + ); -// M004: has neither (empty milestone dir) -mkdirSync(join(gsd, "milestones", "M004"), { recursive: true }); + // full-context milestone uses "Context:" label + assert.ok( + context.includes("**Context:**"), + "M002 (full context) should use 'Context:' label", + ); + assert.ok( + context.includes("Full context from deep discussion"), + "M002 context content should be included", + ); -// ─── Build context ────────────────────────────────────────────────────── + // both files: CONTEXT.md wins, no draft label + const m003Idx = context.indexOf("M003:"); + const m003Section = context.slice(m003Idx, m003Idx + 500); + assert.ok( + m003Section.includes("**Context:**"), + "M003 (both files) should use 'Context:' label (CONTEXT.md wins)", + ); + assert.ok( + !m003Section.includes("Draft context available"), + "M003 (both files) should NOT show draft label — CONTEXT.md takes precedence", + ); + assert.ok( + m003Section.includes("This is the real context"), + "M003 should show CONTEXT.md content, not draft content", + ); -const state = await deriveState(tmpBase); -const milestoneIds = ["M001", "M002", "M003", "M004"]; -const context = await buildExistingMilestonesContext(tmpBase, milestoneIds, state); - -// ─── Test: draft-only milestone includes "Draft context available" ────── - -assert( - context.includes("Draft context available"), - "M001 (draft-only) should include 'Draft context available' label", -); - -assert( - context.includes("Seed material from prior discussion"), - "M001 draft content should be included in context output", -); - -// ─── Test: full-context milestone uses "Context:" label ──────────────── - -assert( - context.includes("**Context:**"), - "M002 (full context) should use 'Context:' label", -); - -assert( - context.includes("Full context from deep discussion"), - "M002 context content should be included", -); - -// ─── Test: both files → CONTEXT.md wins, no draft label ──────────────── - -// Find M003's section and check it has Context: but not Draft -const m003Idx = context.indexOf("M003:"); -const m003Section = context.slice(m003Idx, m003Idx + 500); - -assert( - m003Section.includes("**Context:**"), - "M003 (both files) should use 'Context:' label (CONTEXT.md wins)", -); - -assert( - !m003Section.includes("Draft context available"), - "M003 (both files) should NOT show draft label — CONTEXT.md takes precedence", -); - -assert( - m003Section.includes("This is the real context"), - "M003 should show CONTEXT.md content, not draft content", -); - -// ─── Test: neither file → no context section ─────────────────────────── - -const m004Idx = context.indexOf("M004:"); -const m004Section = context.slice(m004Idx, m004Idx + 500); - -assert( - !m004Section.includes("**Context:**"), - "M004 (neither file) should not have Context: label", -); - -assert( - !m004Section.includes("Draft context available"), - "M004 (neither file) should not have Draft label", -); - -// ─── Cleanup ────────────────────────────────────────────────────────── - -rmSync(tmpBase, { recursive: true, force: true }); - -// ─── Results ────────────────────────────────────────────────────────── - -console.log(`\nqueue-draft-detection: ${passed} passed, ${failed} failed`); -if (failed > 0) process.exit(1); + // neither file: no context section + const m004Idx = context.indexOf("M004:"); + const m004Section = context.slice(m004Idx, m004Idx + 500); + assert.ok( + !m004Section.includes("**Context:**"), + "M004 (neither file) should not have Context: label", + ); + assert.ok( + !m004Section.includes("Draft context available"), + "M004 (neither file) should not have Draft label", + ); + } finally { + rmSync(tmpBase, { recursive: true, force: true }); + } + }); +}); diff --git a/src/resources/extensions/gsd/tests/queue-order.test.ts b/src/resources/extensions/gsd/tests/queue-order.test.ts index 46ad7a82a..890df0fee 100644 --- a/src/resources/extensions/gsd/tests/queue-order.test.ts +++ b/src/resources/extensions/gsd/tests/queue-order.test.ts @@ -1,3 +1,5 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -9,10 +11,6 @@ import { pruneQueueOrder, validateQueueOrder, } from '../queue-order.ts'; -import { createTestContext } from './test-helpers.ts'; - -const { assertEq, assertTrue, report } = createTestContext(); - // ─── Fixture Helpers ─────────────────────────────────────────────────────── function createFixtureBase(): string { @@ -29,176 +27,166 @@ function cleanup(base: string): void { // sortByQueueOrder // ═══════════════════════════════════════════════════════════════════════════ -console.log('\n=== sortByQueueOrder ==='); +describe('queue-order', () => { +test('sortByQueueOrder', () => { // Null order → default milestoneIdSort -{ const result = sortByQueueOrder(['M003', 'M001', 'M002'], null); - assertEq(result, ['M001', 'M002', 'M003'], 'null order falls back to numeric sort'); -} + assert.deepStrictEqual(result, ['M001', 'M002', 'M003'], 'null order falls back to numeric sort'); +}); // Custom order → exact sequence -{ +test('test block at line 39', () => { const result = sortByQueueOrder(['M001', 'M002', 'M003'], ['M003', 'M001', 'M002']); - assertEq(result, ['M003', 'M001', 'M002'], 'custom order produces exact sequence'); -} + assert.deepStrictEqual(result, ['M003', 'M001', 'M002'], 'custom order produces exact sequence'); +}); // Custom order with new IDs → appended at end in numeric order -{ +test('test block at line 45', () => { const result = sortByQueueOrder(['M001', 'M002', 'M003', 'M004'], ['M003', 'M001']); - assertEq(result, ['M003', 'M001', 'M002', 'M004'], 'new IDs appended in numeric order'); -} + assert.deepStrictEqual(result, ['M003', 'M001', 'M002', 'M004'], 'new IDs appended in numeric order'); +}); // Custom order with deleted IDs → silently skipped -{ +test('test block at line 51', () => { const result = sortByQueueOrder(['M001', 'M003'], ['M003', 'M002', 'M001']); - assertEq(result, ['M003', 'M001'], 'deleted IDs in order are skipped'); -} + assert.deepStrictEqual(result, ['M003', 'M001'], 'deleted IDs in order are skipped'); +}); // Empty custom order → all IDs in numeric order -{ +test('test block at line 57', () => { const result = sortByQueueOrder(['M002', 'M001'], []); - assertEq(result, ['M001', 'M002'], 'empty custom order falls back to numeric sort'); -} + assert.deepStrictEqual(result, ['M001', 'M002'], 'empty custom order falls back to numeric sort'); +}); // ═══════════════════════════════════════════════════════════════════════════ // loadQueueOrder / saveQueueOrder // ═══════════════════════════════════════════════════════════════════════════ - -console.log('\n=== loadQueueOrder / saveQueueOrder ==='); - +test('loadQueueOrder / saveQueueOrder', () => { // Load returns null when file doesn't exist -{ const base = createFixtureBase(); - assertEq(loadQueueOrder(base), null, 'returns null when file missing'); + assert.deepStrictEqual(loadQueueOrder(base), null, 'returns null when file missing'); cleanup(base); -} +}); // Save then load round-trip -{ +test('test block at line 76', () => { const base = createFixtureBase(); saveQueueOrder(base, ['M003', 'M001', 'M002']); const loaded = loadQueueOrder(base); - assertEq(loaded, ['M003', 'M001', 'M002'], 'round-trip preserves order'); + assert.deepStrictEqual(loaded, ['M003', 'M001', 'M002'], 'round-trip preserves order'); // Verify file contains updatedAt const raw = JSON.parse(readFileSync(join(base, '.gsd', 'QUEUE-ORDER.json'), 'utf-8')); - assertTrue(typeof raw.updatedAt === 'string' && raw.updatedAt.length > 0, 'file contains updatedAt'); + assert.ok(typeof raw.updatedAt === 'string' && raw.updatedAt.length > 0, 'file contains updatedAt'); cleanup(base); -} +}); // Load returns null on corrupt JSON -{ +test('test block at line 90', () => { const base = createFixtureBase(); writeFileSync(join(base, '.gsd', 'QUEUE-ORDER.json'), 'not json'); - assertEq(loadQueueOrder(base), null, 'returns null on corrupt JSON'); + assert.deepStrictEqual(loadQueueOrder(base), null, 'returns null on corrupt JSON'); cleanup(base); -} +}); // Load returns null when order field is not an array -{ +test('test block at line 98', () => { const base = createFixtureBase(); writeFileSync(join(base, '.gsd', 'QUEUE-ORDER.json'), '{"order": "invalid"}'); - assertEq(loadQueueOrder(base), null, 'returns null when order is not array'); + assert.deepStrictEqual(loadQueueOrder(base), null, 'returns null when order is not array'); cleanup(base); -} +}); // ═══════════════════════════════════════════════════════════════════════════ // pruneQueueOrder // ═══════════════════════════════════════════════════════════════════════════ - -console.log('\n=== pruneQueueOrder ==='); - +test('pruneQueueOrder', () => { // Prune removes invalid IDs -{ const base = createFixtureBase(); saveQueueOrder(base, ['M001', 'M002', 'M003']); pruneQueueOrder(base, ['M001', 'M003']); - assertEq(loadQueueOrder(base), ['M001', 'M003'], 'prune removes invalid IDs'); + assert.deepStrictEqual(loadQueueOrder(base), ['M001', 'M003'], 'prune removes invalid IDs'); cleanup(base); -} +}); // Prune no-ops when file doesn't exist -{ +test('test block at line 121', () => { const base = createFixtureBase(); pruneQueueOrder(base, ['M001']); // should not throw - assertTrue(!existsSync(join(base, '.gsd', 'QUEUE-ORDER.json')), 'prune does not create file'); + assert.ok(!existsSync(join(base, '.gsd', 'QUEUE-ORDER.json')), 'prune does not create file'); cleanup(base); -} +}); // Prune no-ops when all IDs are valid -{ +test('test block at line 129', () => { const base = createFixtureBase(); saveQueueOrder(base, ['M001', 'M002']); pruneQueueOrder(base, ['M001', 'M002', 'M003']); - assertEq(loadQueueOrder(base), ['M001', 'M002'], 'prune is no-op when all valid'); + assert.deepStrictEqual(loadQueueOrder(base), ['M001', 'M002'], 'prune is no-op when all valid'); cleanup(base); -} +}); // ═══════════════════════════════════════════════════════════════════════════ // validateQueueOrder // ═══════════════════════════════════════════════════════════════════════════ - -console.log('\n=== validateQueueOrder ==='); - +test('validateQueueOrder', () => { // Valid order with no dependencies -{ const depsMap = new Map(); const result = validateQueueOrder(['M001', 'M002'], depsMap, new Set()); - assertTrue(result.valid, 'valid when no dependencies'); - assertEq(result.violations.length, 0, 'no violations'); - assertEq(result.redundant.length, 0, 'no redundancies'); -} + assert.ok(result.valid, 'valid when no dependencies'); + assert.deepStrictEqual(result.violations.length, 0, 'no violations'); + assert.deepStrictEqual(result.redundant.length, 0, 'no redundancies'); +}); // Dependency violation: M002 before M001, but M002 depends on M001 -{ +test('test block at line 153', () => { const depsMap = new Map([['M002', ['M001']]]); const result = validateQueueOrder(['M002', 'M001'], depsMap, new Set()); - assertTrue(!result.valid, 'invalid when dep violated'); - assertEq(result.violations.length, 1, 'one violation'); - assertEq(result.violations[0].type, 'would_block', 'violation type is would_block'); - assertEq(result.violations[0].milestone, 'M002', 'violation milestone is M002'); - assertEq(result.violations[0].dependsOn, 'M001', 'violation dep is M001'); -} + assert.ok(!result.valid, 'invalid when dep violated'); + assert.deepStrictEqual(result.violations.length, 1, 'one violation'); + assert.deepStrictEqual(result.violations[0].type, 'would_block', 'violation type is would_block'); + assert.deepStrictEqual(result.violations[0].milestone, 'M002', 'violation milestone is M002'); + assert.deepStrictEqual(result.violations[0].dependsOn, 'M001', 'violation dep is M001'); +}); // Redundant dependency: M002 depends on M001, M001 comes first in order -{ +test('test block at line 164', () => { const depsMap = new Map([['M002', ['M001']]]); const result = validateQueueOrder(['M001', 'M002'], depsMap, new Set()); - assertTrue(result.valid, 'valid when dep satisfied by position'); - assertEq(result.redundant.length, 1, 'one redundancy'); - assertEq(result.redundant[0].milestone, 'M002', 'redundant milestone is M002'); -} + assert.ok(result.valid, 'valid when dep satisfied by position'); + assert.deepStrictEqual(result.redundant.length, 1, 'one redundancy'); + assert.deepStrictEqual(result.redundant[0].milestone, 'M002', 'redundant milestone is M002'); +}); // Completed dep is always satisfied -{ +test('test block at line 173', () => { const depsMap = new Map([['M002', ['M001']]]); const result = validateQueueOrder(['M002'], depsMap, new Set(['M001'])); - assertTrue(result.valid, 'valid when dep is already completed'); - assertEq(result.violations.length, 0, 'no violations for completed dep'); -} + assert.ok(result.valid, 'valid when dep is already completed'); + assert.deepStrictEqual(result.violations.length, 0, 'no violations for completed dep'); +}); // Missing dependency -{ +test('test block at line 181', () => { const depsMap = new Map([['M002', ['M099']]]); const result = validateQueueOrder(['M001', 'M002'], depsMap, new Set()); - assertTrue(!result.valid, 'invalid when dep does not exist'); - assertEq(result.violations[0].type, 'missing_dep', 'violation type is missing_dep'); -} + assert.ok(!result.valid, 'invalid when dep does not exist'); + assert.deepStrictEqual(result.violations[0].type, 'missing_dep', 'violation type is missing_dep'); +}); // Circular dependency -{ +test('test block at line 189', () => { const depsMap = new Map([ ['M001', ['M002']], ['M002', ['M001']], ]); const result = validateQueueOrder(['M001', 'M002'], depsMap, new Set()); - assertTrue(!result.valid, 'invalid on circular dependency'); + assert.ok(!result.valid, 'invalid on circular dependency'); const circularViolation = result.violations.find(v => v.type === 'circular'); - assertTrue(!!circularViolation, 'circular violation detected'); -} + assert.ok(!!circularViolation, 'circular violation detected'); +}); // ═══════════════════════════════════════════════════════════════════════════ - -report(); +}); diff --git a/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts b/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts index bf86c360a..ca04ff4ad 100644 --- a/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +++ b/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts @@ -11,6 +11,8 @@ * 4. A fresh deriveState() call (simulating new session) also works */ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -19,10 +21,6 @@ import { deriveState, invalidateStateCache } from '../state.ts'; import { findMilestoneIds } from '../guided-flow.ts'; import { saveQueueOrder, loadQueueOrder } from '../queue-order.ts'; import { parseContextDependsOn } from '../files.ts'; -import { createTestContext } from './test-helpers.ts'; - -const { assertEq, assertTrue, report } = createTestContext(); - // ─── Fixture Helpers ─────────────────────────────────────────────────────── function createFixtureBase(): string { @@ -70,8 +68,9 @@ function readContextFile(base: string, mid: string): string { // Test: Queue order changes milestone activation // ═══════════════════════════════════════════════════════════════════════════ -console.log('\n=== E2E: queue-order changes active milestone ==='); -{ + +describe('queue-reorder-e2e', () => { +test('E2E: queue-order changes active milestone', async () => { const base = createFixtureBase(); try { // Setup: M007 complete, M008 and M009 pending (no context, no roadmap) @@ -84,7 +83,7 @@ console.log('\n=== E2E: queue-order changes active milestone ==='); // Without custom order: M008 comes first (numeric sort) invalidateStateCache(); const stateBefore = await deriveState(base); - assertEq(stateBefore.activeMilestone?.id, 'M008', 'before reorder: M008 is active'); + assert.deepStrictEqual(stateBefore.activeMilestone?.id, 'M008', 'before reorder: M008 is active'); // Save custom order: M009 before M008 saveQueueOrder(base, ['M009', 'M008']); @@ -92,25 +91,23 @@ console.log('\n=== E2E: queue-order changes active milestone ==='); // With custom order: M009 should be active invalidateStateCache(); const stateAfter = await deriveState(base); - assertEq(stateAfter.activeMilestone?.id, 'M009', 'after reorder: M009 is active'); + assert.deepStrictEqual(stateAfter.activeMilestone?.id, 'M009', 'after reorder: M009 is active'); // findMilestoneIds respects the order const ids = findMilestoneIds(base); const m008Idx = ids.indexOf('M008'); const m009Idx = ids.indexOf('M009'); - assertTrue(m009Idx < m008Idx, 'findMilestoneIds: M009 comes before M008'); + assert.ok(m009Idx < m008Idx, 'findMilestoneIds: M009 comes before M008'); } finally { cleanup(base); } -} +}); // ═══════════════════════════════════════════════════════════════════════════ // Test: Reorder + depends_on removal = correct state // ═══════════════════════════════════════════════════════════════════════════ - -console.log('\n=== E2E: reorder with depends_on removal ==='); -{ +test('E2E: reorder with depends_on removal', async () => { const base = createFixtureBase(); try { // Setup: M007 complete, M008 depends_on M009, M009 no deps @@ -121,7 +118,7 @@ console.log('\n=== E2E: reorder with depends_on removal ==='); // Before: M008 depends on M009, so deriveState skips M008, M009 is active invalidateStateCache(); const stateBefore = await deriveState(base); - assertEq(stateBefore.activeMilestone?.id, 'M009', 'before: M009 active (M008 dep-blocked)'); + assert.deepStrictEqual(stateBefore.activeMilestone?.id, 'M009', 'before: M009 active (M008 dep-blocked)'); // Simulate reorder confirm: save order M009→M008, remove depends_on from M008 saveQueueOrder(base, ['M009', 'M008']); @@ -134,29 +131,27 @@ console.log('\n=== E2E: reorder with depends_on removal ==='); // Verify: depends_on is gone const updatedContent = readContextFile(base, 'M008'); const deps = parseContextDependsOn(updatedContent); - assertEq(deps.length, 0, 'depends_on removed from M008-CONTEXT.md'); + assert.deepStrictEqual(deps.length, 0, 'depends_on removed from M008-CONTEXT.md'); // Verify: deriveState still picks M009 (it's first in queue order) invalidateStateCache(); const stateAfter = await deriveState(base); - assertEq(stateAfter.activeMilestone?.id, 'M009', 'after: M009 still active (first in queue)'); + assert.deepStrictEqual(stateAfter.activeMilestone?.id, 'M009', 'after: M009 still active (first in queue)'); // Verify: M008 is now pending (not dep-blocked) const m008Entry = stateAfter.registry.find(m => m.id === 'M008'); - assertEq(m008Entry?.status, 'pending', 'M008 is pending (not dep-blocked)'); - assertTrue(!m008Entry?.dependsOn || m008Entry.dependsOn.length === 0, 'M008 has no dependsOn'); + assert.deepStrictEqual(m008Entry?.status, 'pending', 'M008 is pending (not dep-blocked)'); + assert.ok(!m008Entry?.dependsOn || m008Entry.dependsOn.length === 0, 'M008 has no dependsOn'); } finally { cleanup(base); } -} +}); // ═══════════════════════════════════════════════════════════════════════════ // Test: Fresh deriveState (simulating new session) respects queue order // ═══════════════════════════════════════════════════════════════════════════ - -console.log('\n=== E2E: fresh session respects queue order ==='); -{ +test('E2E: fresh session respects queue order', async () => { const base = createFixtureBase(); try { writeCompleteMilestone(base, 'M007'); @@ -171,23 +166,21 @@ console.log('\n=== E2E: fresh session respects queue order ==='); // Derive state — should read QUEUE-ORDER.json from disk const state = await deriveState(base); - assertEq(state.activeMilestone?.id, 'M009', 'fresh session: M009 is active'); + assert.deepStrictEqual(state.activeMilestone?.id, 'M009', 'fresh session: M009 is active'); // Verify queue order persisted const order = loadQueueOrder(base); - assertEq(order, ['M009', 'M008'], 'QUEUE-ORDER.json persisted correctly'); + assert.deepStrictEqual(order, ['M009', 'M008'], 'QUEUE-ORDER.json persisted correctly'); } finally { cleanup(base); } -} +}); // ═══════════════════════════════════════════════════════════════════════════ // Test: Queue order with newly added milestones // ═══════════════════════════════════════════════════════════════════════════ - -console.log('\n=== E2E: new milestones appended to queue ==='); -{ +test('E2E: new milestones appended to queue', async () => { const base = createFixtureBase(); try { writeCompleteMilestone(base, 'M007'); @@ -207,24 +200,22 @@ console.log('\n=== E2E: new milestones appended to queue ==='); const m009Idx = ids.indexOf('M009'); const m008Idx = ids.indexOf('M008'); const m010Idx = ids.indexOf('M010'); - assertTrue(m009Idx < m008Idx, 'M009 before M008'); - assertTrue(m008Idx < m010Idx, 'M008 before M010 (new milestone appended)'); + assert.ok(m009Idx < m008Idx, 'M009 before M008'); + assert.ok(m008Idx < m010Idx, 'M008 before M010 (new milestone appended)'); // M009 is still active (first non-complete in queue order) const state = await deriveState(base); - assertEq(state.activeMilestone?.id, 'M009', 'M009 still active after M010 added'); + assert.deepStrictEqual(state.activeMilestone?.id, 'M009', 'M009 still active after M010 added'); } finally { cleanup(base); } -} +}); // ═══════════════════════════════════════════════════════════════════════════ // Test: No queue order file = default numeric sort (backward compat) // ═══════════════════════════════════════════════════════════════════════════ - -console.log('\n=== E2E: backward compat without QUEUE-ORDER.json ==='); -{ +test('E2E: backward compat without QUEUE-ORDER.json', async () => { const base = createFixtureBase(); try { writeCompleteMilestone(base, 'M007'); @@ -234,22 +225,20 @@ console.log('\n=== E2E: backward compat without QUEUE-ORDER.json ==='); // No QUEUE-ORDER.json — default numeric sort invalidateStateCache(); const state = await deriveState(base); - assertEq(state.activeMilestone?.id, 'M008', 'no queue order: M008 active (numeric)'); + assert.deepStrictEqual(state.activeMilestone?.id, 'M008', 'no queue order: M008 active (numeric)'); const ids = findMilestoneIds(base); - assertTrue(ids.indexOf('M008') < ids.indexOf('M009'), 'default sort: M008 before M009'); + assert.ok(ids.indexOf('M008') < ids.indexOf('M009'), 'default sort: M008 before M009'); } finally { cleanup(base); } -} +}); // ═══════════════════════════════════════════════════════════════════════════ // Test: non-milestone directories are filtered out (#1494) // ═══════════════════════════════════════════════════════════════════════════ - -console.log('\n=== E2E: non-milestone directories filtered from findMilestoneIds (#1494) ==='); -{ +test('E2E: non-milestone directories filtered from findMilestoneIds (#1494)', () => { const base = createFixtureBase(); try { writeContext(base, 'M001', '', 'First'); @@ -260,22 +249,20 @@ console.log('\n=== E2E: non-milestone directories filtered from findMilestoneIds invalidateStateCache(); const ids = findMilestoneIds(base); - assertEq(ids.length, 2, 'only M001 and M002 returned'); - assertTrue(!ids.includes('slices'), 'slices directory excluded'); - assertTrue(!ids.includes('temp-backup'), 'temp-backup directory excluded'); - assertTrue(ids.includes('M001'), 'M001 included'); - assertTrue(ids.includes('M002'), 'M002 included'); + assert.deepStrictEqual(ids.length, 2, 'only M001 and M002 returned'); + assert.ok(!ids.includes('slices'), 'slices directory excluded'); + assert.ok(!ids.includes('temp-backup'), 'temp-backup directory excluded'); + assert.ok(ids.includes('M001'), 'M001 included'); + assert.ok(ids.includes('M002'), 'M002 included'); } finally { cleanup(base); } -} +}); // ═══════════════════════════════════════════════════════════════════════════ // Test: depends_on inline array format removal // ═══════════════════════════════════════════════════════════════════════════ - -console.log('\n=== E2E: depends_on inline format preserved after partial removal ==='); -{ +test('E2E: depends_on inline format preserved after partial removal', () => { const base = createFixtureBase(); try { writeCompleteMilestone(base, 'M007'); @@ -287,7 +274,7 @@ console.log('\n=== E2E: depends_on inline format preserved after partial removal // Verify both deps are parsed const contentBefore = readContextFile(base, 'M008'); const depsBefore = parseContextDependsOn(contentBefore); - assertEq(depsBefore.length, 2, 'M008 has 2 deps before'); + assert.deepStrictEqual(depsBefore.length, 2, 'M008 has 2 deps before'); // Simulate removing only M009 dep (keep M010) const content = readContextFile(base, 'M008'); @@ -297,12 +284,12 @@ console.log('\n=== E2E: depends_on inline format preserved after partial removal // Verify only M010 remains const contentAfter = readContextFile(base, 'M008'); const depsAfter = parseContextDependsOn(contentAfter); - assertEq(depsAfter.length, 1, 'M008 has 1 dep after removal'); - assertEq(depsAfter[0], 'M010', 'remaining dep is M010'); + assert.deepStrictEqual(depsAfter.length, 1, 'M008 has 1 dep after removal'); + assert.deepStrictEqual(depsAfter[0], 'M010', 'remaining dep is M010'); } finally { cleanup(base); } -} +}); -report(); +}); diff --git a/src/resources/extensions/gsd/tests/quick-branch-lifecycle.test.ts b/src/resources/extensions/gsd/tests/quick-branch-lifecycle.test.ts index 79d44f116..f707ff902 100644 --- a/src/resources/extensions/gsd/tests/quick-branch-lifecycle.test.ts +++ b/src/resources/extensions/gsd/tests/quick-branch-lifecycle.test.ts @@ -7,17 +7,16 @@ * Relates to #1269, #1293. */ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { execSync } from "node:child_process"; -import { createTestContext } from './test-helpers.ts'; import { captureIntegrationBranch, getCurrentBranch } from "../worktree.ts"; import { readIntegrationBranch, QUICK_BRANCH_RE } from "../git-service.ts"; -const { assertEq, assertTrue, report } = createTestContext(); - function run(command: string, cwd: string): string { return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim(); } @@ -35,68 +34,59 @@ function createTestRepo(): string { return repo; } -async function main(): Promise { - // ═══════════════════════════════════════════════════════════════════════ // QUICK_BRANCH_RE // ═══════════════════════════════════════════════════════════════════════ - console.log("\n=== QUICK_BRANCH_RE: matches quick-task branches ==="); - assertTrue(QUICK_BRANCH_RE.test("gsd/quick/1-fix-typo"), "matches standard quick branch"); - assertTrue(QUICK_BRANCH_RE.test("gsd/quick/42-some-long-slug-name"), "matches multi-digit quick branch"); - assertTrue(!QUICK_BRANCH_RE.test("main"), "rejects main"); - assertTrue(!QUICK_BRANCH_RE.test("gsd/M001/S01"), "rejects slice branch"); - assertTrue(!QUICK_BRANCH_RE.test("gsd/quickly-something"), "rejects non-quick prefix"); - assertTrue(!QUICK_BRANCH_RE.test("feature/gsd/quick/1"), "rejects nested prefix"); +describe('quick-branch-lifecycle', () => { +test('QUICK_BRANCH_RE: matches quick-task branches', () => { + assert.ok(QUICK_BRANCH_RE.test("gsd/quick/1-fix-typo"), "matches standard quick branch"); +}); + assert.ok(QUICK_BRANCH_RE.test("gsd/quick/42-some-long-slug-name"), "matches multi-digit quick branch"); + assert.ok(!QUICK_BRANCH_RE.test("main"), "rejects main"); + assert.ok(!QUICK_BRANCH_RE.test("gsd/M001/S01"), "rejects slice branch"); + assert.ok(!QUICK_BRANCH_RE.test("gsd/quickly-something"), "rejects non-quick prefix"); + assert.ok(!QUICK_BRANCH_RE.test("feature/gsd/quick/1"), "rejects nested prefix"); // ═══════════════════════════════════════════════════════════════════════ // captureIntegrationBranch: guard against quick-task branches // ═══════════════════════════════════════════════════════════════════════ - - console.log("\n=== captureIntegrationBranch: skips quick-task branches ==="); - - { +test('captureIntegrationBranch: skips quick-task branches', () => { const repo = createTestRepo(); // Create and checkout a quick-task branch run("git checkout -b gsd/quick/1-fix-typo", repo); - assertEq(getCurrentBranch(repo), "gsd/quick/1-fix-typo", "on quick branch"); + assert.deepStrictEqual(getCurrentBranch(repo), "gsd/quick/1-fix-typo", "on quick branch"); captureIntegrationBranch(repo, "M001"); - assertEq(readIntegrationBranch(repo, "M001"), null, + assert.deepStrictEqual(readIntegrationBranch(repo, "M001"), null, "captureIntegrationBranch is a no-op on quick-task branches"); rmSync(repo, { recursive: true, force: true }); - } +}); // ─── Verify main is still recorded correctly ───────────────────────── - - console.log("\n=== captureIntegrationBranch: records main correctly ==="); - - { +test('captureIntegrationBranch: records main correctly', () => { const repo = createTestRepo(); // Capture from main — should work normally captureIntegrationBranch(repo, "M001"); - assertEq(readIntegrationBranch(repo, "M001"), "main", + assert.deepStrictEqual(readIntegrationBranch(repo, "M001"), "main", "main is recorded as integration branch"); // Switch to quick branch — capture should be no-op (doesn't overwrite main) run("git checkout -b gsd/quick/1-fix-typo", repo); captureIntegrationBranch(repo, "M001"); - assertEq(readIntegrationBranch(repo, "M001"), "main", + assert.deepStrictEqual(readIntegrationBranch(repo, "M001"), "main", "quick branch does not overwrite existing integration branch"); rmSync(repo, { recursive: true, force: true }); - } +}); // ─── Sequence: main → quick → back to main → capture ──────────────── - - console.log("\n=== captureIntegrationBranch: correct after quick branch round-trip ==="); - - { +test('captureIntegrationBranch: correct after quick branch round-trip', () => { const repo = createTestRepo(); // Simulate quick-task lifecycle: branch off, do work, return to main @@ -111,19 +101,16 @@ async function main(): Promise { // Now capture — should get main, not the deleted quick branch captureIntegrationBranch(repo, "M002"); - assertEq(readIntegrationBranch(repo, "M002"), "main", + assert.deepStrictEqual(readIntegrationBranch(repo, "M002"), "main", "after quick round-trip, main is captured correctly"); rmSync(repo, { recursive: true, force: true }); - } +}); // ═══════════════════════════════════════════════════════════════════════ // cleanupQuickBranch: in-memory path (same session) // ═══════════════════════════════════════════════════════════════════════ - - console.log("\n=== cleanupQuickBranch: merges back and cleans up (same session) ==="); - - { +test('cleanupQuickBranch: merges back and cleans up (same session)', async () => { const repo = createTestRepo(); const origCwd = process.cwd(); @@ -155,30 +142,27 @@ async function main(): Promise { const { cleanupQuickBranch } = await import("../quick.ts"); const result = cleanupQuickBranch(); - assertTrue(result, "cleanupQuickBranch returns true"); - assertEq(getCurrentBranch(repo), "main", "back on main after cleanup"); + assert.ok(result, "cleanupQuickBranch returns true"); + assert.deepStrictEqual(getCurrentBranch(repo), "main", "back on main after cleanup"); // Verify merge happened — fix.txt should exist on main - assertTrue(existsSync(join(repo, "fix.txt")), "fix.txt merged to main"); + assert.ok(existsSync(join(repo, "fix.txt")), "fix.txt merged to main"); // Verify quick branch deleted const branches = run("git branch", repo); - assertTrue(!branches.includes("gsd/quick/1-fix-typo"), "quick branch deleted"); + assert.ok(!branches.includes("gsd/quick/1-fix-typo"), "quick branch deleted"); // Verify disk state cleaned up - assertTrue(!existsSync(join(runtimeDir, "quick-return.json")), "quick-return.json removed"); + assert.ok(!existsSync(join(runtimeDir, "quick-return.json")), "quick-return.json removed"); process.chdir(origCwd); rmSync(repo, { recursive: true, force: true }); - } +}); // ═══════════════════════════════════════════════════════════════════════ // cleanupQuickBranch: cross-session recovery from disk // ═══════════════════════════════════════════════════════════════════════ - - console.log("\n=== cleanupQuickBranch: recovers from disk state (cross-session) ==="); - - { +test('cleanupQuickBranch: recovers from disk state (cross-session)', async () => { const repo = createTestRepo(); const origCwd = process.cwd(); @@ -206,22 +190,19 @@ async function main(): Promise { const { cleanupQuickBranch } = await import("../quick.ts"); const result = cleanupQuickBranch(); - assertTrue(result, "cross-session recovery returns true"); - assertEq(getCurrentBranch(repo), "main", "back on main after cross-session recovery"); - assertTrue(existsSync(join(repo, "docs.md")), "docs.md merged to main"); - assertTrue(!existsSync(join(runtimeDir, "quick-return.json")), "disk state cleaned up"); + assert.ok(result, "cross-session recovery returns true"); + assert.deepStrictEqual(getCurrentBranch(repo), "main", "back on main after cross-session recovery"); + assert.ok(existsSync(join(repo, "docs.md")), "docs.md merged to main"); + assert.ok(!existsSync(join(runtimeDir, "quick-return.json")), "disk state cleaned up"); process.chdir(origCwd); rmSync(repo, { recursive: true, force: true }); - } +}); // ═══════════════════════════════════════════════════════════════════════ // cleanupQuickBranch: no-op when no pending state // ═══════════════════════════════════════════════════════════════════════ - - console.log("\n=== cleanupQuickBranch: no-op without pending state ==="); - - { +test('cleanupQuickBranch: no-op without pending state', async () => { const repo = createTestRepo(); const origCwd = process.cwd(); process.chdir(repo); @@ -229,32 +210,29 @@ async function main(): Promise { const { cleanupQuickBranch } = await import("../quick.ts"); const result = cleanupQuickBranch(); - assertTrue(!result, "returns false when no pending state"); - assertEq(getCurrentBranch(repo), "main", "stays on main"); + assert.ok(!result, "returns false when no pending state"); + assert.deepStrictEqual(getCurrentBranch(repo), "main", "stays on main"); process.chdir(origCwd); rmSync(repo, { recursive: true, force: true }); - } +}); // ═══════════════════════════════════════════════════════════════════════ // End-to-end: quick branch does NOT contaminate integration branch // ═══════════════════════════════════════════════════════════════════════ - - console.log("\n=== E2E: quick branch does not contaminate integration branch ==="); - - { +test('E2E: quick branch does not contaminate integration branch', () => { const repo = createTestRepo(); // 1. Record main as integration branch for M001 captureIntegrationBranch(repo, "M001"); - assertEq(readIntegrationBranch(repo, "M001"), "main", "M001 integration = main"); + assert.deepStrictEqual(readIntegrationBranch(repo, "M001"), "main", "M001 integration = main"); // 2. Start a quick task (branch off) run("git checkout -b gsd/quick/1-fix-typo", repo); // 3. Try to capture integration branch for M002 while on quick branch captureIntegrationBranch(repo, "M002"); - assertEq(readIntegrationBranch(repo, "M002"), null, + assert.deepStrictEqual(readIntegrationBranch(repo, "M002"), null, "M002 integration NOT recorded from quick branch"); // 4. Return to main (simulate cleanupQuickBranch) @@ -262,20 +240,14 @@ async function main(): Promise { // 5. Now capture M002 from main — should work captureIntegrationBranch(repo, "M002"); - assertEq(readIntegrationBranch(repo, "M002"), "main", + assert.deepStrictEqual(readIntegrationBranch(repo, "M002"), "main", "M002 integration = main after returning from quick branch"); // 6. Verify M001 still intact - assertEq(readIntegrationBranch(repo, "M001"), "main", + assert.deepStrictEqual(readIntegrationBranch(repo, "M001"), "main", "M001 integration unchanged"); rmSync(repo, { recursive: true, force: true }); - } - - report(); -} - -main().catch((error) => { - console.error(error); - process.exit(1); +}); + }); diff --git a/src/resources/extensions/gsd/tests/reassess-prompt.test.ts b/src/resources/extensions/gsd/tests/reassess-prompt.test.ts index 2f34f6311..d0db26f23 100644 --- a/src/resources/extensions/gsd/tests/reassess-prompt.test.ts +++ b/src/resources/extensions/gsd/tests/reassess-prompt.test.ts @@ -1,15 +1,14 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; import { readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { createTestContext } from './test-helpers.ts'; - // loadPrompt reads from ~/.gsd/agent/extensions/gsd/prompts/ (main checkout). // In a worktree the file may not exist there yet, so we resolve prompts // relative to this test file's location (the worktree copy). const __dirname = dirname(fileURLToPath(import.meta.url)); const worktreePromptsDir = join(__dirname, "..", "prompts"); -const { assertTrue, report } = createTestContext(); /** * Load a prompt template from the worktree prompts directory * and apply variable substitution (mirrors loadPrompt logic). @@ -27,11 +26,10 @@ function loadPromptFromWorktree(name: string, vars: Record = {}) // Tests // ═══════════════════════════════════════════════════════════════════════════ -async function main(): Promise { - // ─── reassess-roadmap prompt loads and substitutes ───────────────────── - console.log("\n=== reassess-roadmap prompt loads and substitutes ==="); - { + +describe('reassess-prompt', () => { +test('reassess-roadmap prompt loads and substitutes', () => { const testVars = { workingDirectory: "/tmp/test-project", milestoneId: "M099", @@ -51,27 +49,26 @@ async function main(): Promise { console.error(` ERROR: loadPrompt threw: ${err}`); } - assertTrue(!threw, "loadPrompt does not throw for reassess-roadmap"); - assertTrue(typeof result === "string" && result.length > 0, "loadPrompt returns a non-empty string"); + assert.ok(!threw, "loadPrompt does not throw for reassess-roadmap"); + assert.ok(typeof result === "string" && result.length > 0, "loadPrompt returns a non-empty string"); // Verify all test variables were substituted into the output - assertTrue(result.includes("M099"), "prompt contains milestoneId 'M099'"); - assertTrue(result.includes("S03"), "prompt contains completedSliceId 'S03'"); - assertTrue(result.includes(".gsd/milestones/M099/slices/S03/S03-ASSESSMENT.md"), "prompt contains assessmentPath"); - assertTrue(result.includes(".gsd/milestones/M099/M099-ROADMAP.md"), "prompt contains roadmapPath"); - assertTrue(result.includes("--- test inlined context block ---"), "prompt contains inlinedContext"); + assert.ok(result.includes("M099"), "prompt contains milestoneId 'M099'"); + assert.ok(result.includes("S03"), "prompt contains completedSliceId 'S03'"); + assert.ok(result.includes(".gsd/milestones/M099/slices/S03/S03-ASSESSMENT.md"), "prompt contains assessmentPath"); + assert.ok(result.includes(".gsd/milestones/M099/M099-ROADMAP.md"), "prompt contains roadmapPath"); + assert.ok(result.includes("--- test inlined context block ---"), "prompt contains inlinedContext"); // Verify no un-substituted variables remain - assertTrue(!result.includes("{{milestoneId}}"), "no un-substituted {{milestoneId}}"); - assertTrue(!result.includes("{{completedSliceId}}"), "no un-substituted {{completedSliceId}}"); - assertTrue(!result.includes("{{assessmentPath}}"), "no un-substituted {{assessmentPath}}"); - assertTrue(!result.includes("{{roadmapPath}}"), "no un-substituted {{roadmapPath}}"); - assertTrue(!result.includes("{{inlinedContext}}"), "no un-substituted {{inlinedContext}}"); - } + assert.ok(!result.includes("{{milestoneId}}"), "no un-substituted {{milestoneId}}"); + assert.ok(!result.includes("{{completedSliceId}}"), "no un-substituted {{completedSliceId}}"); + assert.ok(!result.includes("{{assessmentPath}}"), "no un-substituted {{assessmentPath}}"); + assert.ok(!result.includes("{{roadmapPath}}"), "no un-substituted {{roadmapPath}}"); + assert.ok(!result.includes("{{inlinedContext}}"), "no un-substituted {{inlinedContext}}"); +}); // ─── reassess-roadmap contains coverage-check instruction ───────────── - console.log("\n=== reassess-roadmap contains coverage-check instruction ==="); - { +test('reassess-roadmap contains coverage-check instruction', () => { const prompt = loadPromptFromWorktree("reassess-roadmap", { workingDirectory: "/tmp/test-project", milestoneId: "M001", @@ -85,33 +82,32 @@ async function main(): Promise { const lower = prompt.toLowerCase(); // The prompt must mention "each success criterion" or "every success criterion" - assertTrue( + assert.ok( lower.includes("each success criterion") || lower.includes("every success criterion"), "prompt contains 'each success criterion' or 'every success criterion'" ); // The prompt must mention "owning slice" or "remaining slice" - assertTrue( + assert.ok( lower.includes("owning slice") || lower.includes("remaining slice"), "prompt contains 'owning slice' or 'remaining slice'" ); // The prompt must mention "no remaining owner" or "no owner" or "no slice" - assertTrue( + assert.ok( lower.includes("no remaining owner") || lower.includes("no owner") || lower.includes("no slice"), "prompt contains 'no remaining owner', 'no owner', or 'no slice'" ); // The prompt must mention "blocking issue" or "blocking" - assertTrue( + assert.ok( lower.includes("blocking issue") || lower.includes("blocking"), "prompt contains 'blocking issue' or 'blocking'" ); - } +}); // ─── coverage-check requires at-least-one semantics ─────────────────── - console.log("\n=== coverage-check requires at-least-one semantics ==="); - { +test('coverage-check requires at-least-one semantics', () => { const prompt = loadPromptFromWorktree("reassess-roadmap", { workingDirectory: "/tmp/test-project", milestoneId: "M001", @@ -124,22 +120,16 @@ async function main(): Promise { const lower = prompt.toLowerCase(); // The instruction must use "at least one" or equivalent inclusive language - assertTrue( + assert.ok( lower.includes("at least one") || lower.includes("at-least-one") || lower.includes("one or more"), "prompt uses 'at least one' or equivalent inclusive language for slice ownership" ); // The instruction must NOT require "exactly one" — that would be too rigid - assertTrue( + assert.ok( !lower.includes("exactly one owner") && !lower.includes("exactly one slice"), "prompt does NOT use 'exactly one' for slice ownership (would be too rigid)" ); - } - - report(); -} - -main().catch((error) => { - console.error(error); - process.exit(1); +}); + }); diff --git a/src/resources/extensions/gsd/tests/replan-slice.test.ts b/src/resources/extensions/gsd/tests/replan-slice.test.ts index 73eddeb92..35c89eaba 100644 --- a/src/resources/extensions/gsd/tests/replan-slice.test.ts +++ b/src/resources/extensions/gsd/tests/replan-slice.test.ts @@ -1,3 +1,5 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; @@ -22,7 +24,6 @@ function loadPromptFromWorktree(name: string, vars: Record = {}) return content.trim(); } -const { assertEq, assertTrue, report } = createTestContext(); // ─── Fixture Helpers ─────────────────────────────────────────────────────── function createFixtureBase(): string { @@ -161,7 +162,7 @@ Found a blocker. `; const s = parseSummary(content); - assertEq(s.frontmatter.blocker_discovered, true, 'blocker_discovered: true (string) extracts as true'); + assert.deepStrictEqual(s.frontmatter.blocker_discovered, true, 'blocker_discovered: true (string) extracts as true'); } console.log('\n=== parseSummary: blocker_discovered false (string) ==='); @@ -184,7 +185,7 @@ No blocker. `; const s = parseSummary(content); - assertEq(s.frontmatter.blocker_discovered, false, 'blocker_discovered: false extracts as false'); + assert.deepStrictEqual(s.frontmatter.blocker_discovered, false, 'blocker_discovered: false extracts as false'); } console.log('\n=== parseSummary: blocker_discovered missing (defaults to false) ==='); @@ -206,7 +207,7 @@ No blocker field at all. `; const s = parseSummary(content); - assertEq(s.frontmatter.blocker_discovered, false, 'blocker_discovered missing defaults to false'); + assert.deepStrictEqual(s.frontmatter.blocker_discovered, false, 'blocker_discovered missing defaults to false'); } console.log('\n=== parseSummary: blocker_discovered true (boolean from YAML) ==='); @@ -232,7 +233,7 @@ Blocker as boolean. `; const s = parseSummary(content); - assertEq(s.frontmatter.blocker_discovered, true, 'blocker_discovered: true (YAML boolean) extracts as true'); + assert.deepStrictEqual(s.frontmatter.blocker_discovered, true, 'blocker_discovered: true (YAML boolean) extracts as true'); } console.log('\n=== parseSummary: blocker_discovered with full frontmatter ==='); @@ -275,10 +276,10 @@ Major deviation from plan. `; const s = parseSummary(content); - assertEq(s.frontmatter.blocker_discovered, true, 'blocker_discovered true with full frontmatter'); - assertEq(s.frontmatter.id, 'T05', 'other fields still parse correctly alongside blocker_discovered'); - assertEq(s.frontmatter.duration, '15min', 'duration still parsed'); - assertEq(s.frontmatter.provides[0], 'something', 'provides still parsed'); + assert.deepStrictEqual(s.frontmatter.blocker_discovered, true, 'blocker_discovered true with full frontmatter'); + assert.deepStrictEqual(s.frontmatter.id, 'T05', 'other fields still parse correctly alongside blocker_discovered'); + assert.deepStrictEqual(s.frontmatter.duration, '15min', 'duration still parsed'); + assert.deepStrictEqual(s.frontmatter.provides[0], 'something', 'provides still parsed'); } // ═══════════════════════════════════════════════════════════════════════════ @@ -294,11 +295,11 @@ console.log('\n=== deriveState: blocker found, no REPLAN → replanning-slice == writeTaskSummary(base, 'M001', 'S01', 'T01', makeTaskSummary('T01', true)); const state = await deriveState(base); - assertEq(state.phase, 'replanning-slice', 'phase is replanning-slice when blocker found and no REPLAN.md'); - assertTrue(state.nextAction.includes('T01'), 'nextAction mentions blocker task T01'); - assertTrue(state.nextAction.includes('blocker_discovered'), 'nextAction mentions blocker_discovered'); - assertEq(state.activeTask?.id, 'T02', 'activeTask is still T02 (the next incomplete task)'); - assertTrue(state.blockers.length > 0, 'blockers array is non-empty'); + assert.deepStrictEqual(state.phase, 'replanning-slice', 'phase is replanning-slice when blocker found and no REPLAN.md'); + assert.ok(state.nextAction.includes('T01'), 'nextAction mentions blocker task T01'); + assert.ok(state.nextAction.includes('blocker_discovered'), 'nextAction mentions blocker_discovered'); + assert.deepStrictEqual(state.activeTask?.id, 'T02', 'activeTask is still T02 (the next incomplete task)'); + assert.ok(state.blockers.length > 0, 'blockers array is non-empty'); rmSync(base, { recursive: true, force: true }); } @@ -312,8 +313,8 @@ console.log('\n=== deriveState: blocker found + REPLAN exists → executing (loo writeReplanFile(base, 'M001', 'S01', '# Replan\n\nAlready replanned.'); const state = await deriveState(base); - assertEq(state.phase, 'executing', 'phase is executing when REPLAN.md exists (loop protection)'); - assertEq(state.activeTask?.id, 'T02', 'activeTask is T02'); + assert.deepStrictEqual(state.phase, 'executing', 'phase is executing when REPLAN.md exists (loop protection)'); + assert.deepStrictEqual(state.activeTask?.id, 'T02', 'activeTask is T02'); rmSync(base, { recursive: true, force: true }); } @@ -326,8 +327,8 @@ console.log('\n=== deriveState: no blocker in completed tasks → executing ===' writeTaskSummary(base, 'M001', 'S01', 'T01', makeTaskSummary('T01', false)); const state = await deriveState(base); - assertEq(state.phase, 'executing', 'phase is executing when no blocker found'); - assertEq(state.activeTask?.id, 'T02', 'activeTask is T02'); + assert.deepStrictEqual(state.phase, 'executing', 'phase is executing when no blocker found'); + assert.deepStrictEqual(state.activeTask?.id, 'T02', 'activeTask is T02'); rmSync(base, { recursive: true, force: true }); } @@ -341,9 +342,9 @@ console.log('\n=== deriveState: multiple completed tasks, one blocker → replan writeTaskSummary(base, 'M001', 'S01', 'T02', makeTaskSummary('T02', true)); const state = await deriveState(base); - assertEq(state.phase, 'replanning-slice', 'phase is replanning-slice when T02 has blocker'); - assertTrue(state.nextAction.includes('T02'), 'nextAction mentions blocker task T02'); - assertEq(state.activeTask?.id, 'T03', 'activeTask is T03 (next incomplete)'); + assert.deepStrictEqual(state.phase, 'replanning-slice', 'phase is replanning-slice when T02 has blocker'); + assert.ok(state.nextAction.includes('T02'), 'nextAction mentions blocker task T02'); + assert.deepStrictEqual(state.activeTask?.id, 'T03', 'activeTask is T03 (next incomplete)'); rmSync(base, { recursive: true, force: true }); } @@ -356,7 +357,7 @@ console.log('\n=== deriveState: completed task with no summary file → executin // No summary file written for T01 const state = await deriveState(base); - assertEq(state.phase, 'executing', 'phase is executing when completed task has no summary'); + assert.deepStrictEqual(state.phase, 'executing', 'phase is executing when completed task has no summary'); rmSync(base, { recursive: true, force: true }); } @@ -376,11 +377,11 @@ console.log('\n=== prompt: replan-slice template loads and substitutes variables inlinedContext: '## Inlined Context\n\nTest context here.', }); - assertTrue(prompt.includes('M001'), 'prompt contains milestoneId'); - assertTrue(prompt.includes('S01'), 'prompt contains sliceId'); - assertTrue(prompt.includes('Test Slice'), 'prompt contains sliceTitle'); - assertTrue(prompt.includes('.gsd/milestones/M001/slices/S01/S01-PLAN.md'), 'prompt contains planPath'); - assertTrue(prompt.includes('Test context here'), 'prompt contains inlined context'); + assert.ok(prompt.includes('M001'), 'prompt contains milestoneId'); + assert.ok(prompt.includes('S01'), 'prompt contains sliceId'); + assert.ok(prompt.includes('Test Slice'), 'prompt contains sliceTitle'); + assert.ok(prompt.includes('.gsd/milestones/M001/slices/S01/S01-PLAN.md'), 'prompt contains planPath'); + assert.ok(prompt.includes('Test context here'), 'prompt contains inlined context'); } console.log('\n=== prompt: replan-slice contains preserve-completed-tasks instruction ==='); @@ -397,10 +398,10 @@ console.log('\n=== prompt: replan-slice contains preserve-completed-tasks instru inlinedContext: '', }); - assertTrue(prompt.includes('Do NOT renumber or remove completed tasks'), 'prompt contains preserve-completed-tasks instruction'); - assertTrue(prompt.includes('[x]'), 'prompt mentions [x] checkmarks'); - assertTrue(prompt.includes('REPLAN'), 'prompt references replan output path'); - assertTrue(prompt.includes('blocker_discovered'), 'prompt mentions blocker_discovered'); + assert.ok(prompt.includes('Do NOT renumber or remove completed tasks'), 'prompt contains preserve-completed-tasks instruction'); + assert.ok(prompt.includes('[x]'), 'prompt mentions [x] checkmarks'); + assert.ok(prompt.includes('REPLAN'), 'prompt references replan output path'); + assert.ok(prompt.includes('blocker_discovered'), 'prompt mentions blocker_discovered'); } // ═══════════════════════════════════════════════════════════════════════════ @@ -421,8 +422,8 @@ console.log('\n=== dispatch: diagnoseExpectedArtifact returns REPLAN.md path === writeTaskSummary(base, 'M001', 'S01', 'T01', makeTaskSummary('T01', true)); const state = await deriveState(base); - assertEq(state.phase, 'replanning-slice', 'dispatch: state routes to replanning-slice when blocker found'); - assertTrue(state.activeSlice?.id === 'S01', 'dispatch: activeSlice is S01'); + assert.deepStrictEqual(state.phase, 'replanning-slice', 'dispatch: state routes to replanning-slice when blocker found'); + assert.ok(state.activeSlice?.id === 'S01', 'dispatch: activeSlice is S01'); rmSync(base, { recursive: true, force: true }); } @@ -443,8 +444,8 @@ console.log('\n=== display: replan-slice prompt template has correct unit header inlinedContext: '', }); - assertTrue(prompt.includes('UNIT: Replan Slice'), 'prompt has Replan Slice unit header'); - assertTrue(prompt.includes('Slice S01 replanned'), 'prompt has completion message'); + assert.ok(prompt.includes('UNIT: Replan Slice'), 'prompt has Replan Slice unit header'); + assert.ok(prompt.includes('Slice S01 replanned'), 'prompt has completion message'); } // ═══════════════════════════════════════════════════════════════════════════ @@ -452,8 +453,6 @@ console.log('\n=== display: replan-slice prompt template has correct unit header // ═══════════════════════════════════════════════════════════════════════════ import { runGSDDoctor } from '../doctor.ts'; -import { createTestContext } from './test-helpers.ts'; - // (a) blocker + no REPLAN.md → issue emitted console.log('\n=== doctor: blocker + no REPLAN.md → blocker_discovered_no_replan issue ==='); { @@ -464,10 +463,10 @@ console.log('\n=== doctor: blocker + no REPLAN.md → blocker_discovered_no_repl const report = await runGSDDoctor(base, { fix: false, scope: 'M001/S01' }); const blockerIssues = report.issues.filter(i => i.code === 'blocker_discovered_no_replan'); - assertTrue(blockerIssues.length > 0, 'doctor emits blocker_discovered_no_replan when blocker + no REPLAN'); - assertTrue(blockerIssues[0]?.message.includes('T01'), 'issue message mentions the blocker task T01'); - assertEq(blockerIssues[0]?.severity, 'warning', 'blocker_discovered_no_replan is warning severity'); - assertEq(blockerIssues[0]?.scope, 'slice', 'blocker_discovered_no_replan has slice scope'); + assert.ok(blockerIssues.length > 0, 'doctor emits blocker_discovered_no_replan when blocker + no REPLAN'); + assert.ok(blockerIssues[0]?.message.includes('T01'), 'issue message mentions the blocker task T01'); + assert.deepStrictEqual(blockerIssues[0]?.severity, 'warning', 'blocker_discovered_no_replan is warning severity'); + assert.deepStrictEqual(blockerIssues[0]?.scope, 'slice', 'blocker_discovered_no_replan has slice scope'); rmSync(base, { recursive: true, force: true }); } @@ -482,7 +481,7 @@ console.log('\n=== doctor: blocker + REPLAN.md exists → no blocker_discovered_ const report = await runGSDDoctor(base, { fix: false, scope: 'M001/S01' }); const blockerIssues = report.issues.filter(i => i.code === 'blocker_discovered_no_replan'); - assertEq(blockerIssues.length, 0, 'no blocker_discovered_no_replan when REPLAN.md exists'); + assert.deepStrictEqual(blockerIssues.length, 0, 'no blocker_discovered_no_replan when REPLAN.md exists'); rmSync(base, { recursive: true, force: true }); } @@ -496,7 +495,7 @@ console.log('\n=== doctor: no blocker → no blocker_discovered_no_replan issue const report = await runGSDDoctor(base, { fix: false, scope: 'M001/S01' }); const blockerIssues = report.issues.filter(i => i.code === 'blocker_discovered_no_replan'); - assertEq(blockerIssues.length, 0, 'no blocker_discovered_no_replan when no blocker'); + assert.deepStrictEqual(blockerIssues.length, 0, 'no blocker_discovered_no_replan when no blocker'); rmSync(base, { recursive: true, force: true }); } @@ -506,48 +505,45 @@ console.log('\n=== doctor: no blocker → no blocker_discovered_no_replan issue import { resolveExpectedArtifactPath, verifyExpectedArtifact } from '../auto-recovery.ts'; -console.log('\n=== artifact: resolveExpectedArtifactPath returns REPLAN.md path for replan-slice ==='); -{ + +describe('replan-slice', () => { +test('artifact: resolveExpectedArtifactPath returns REPLAN.md path for replan-slice', () => { const base = createFixtureBase(); writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); const path = resolveExpectedArtifactPath('replan-slice', 'M001/S01', base); - assertTrue(path !== null, 'resolveExpectedArtifactPath returns non-null for replan-slice'); - assertTrue(path!.endsWith('S01-REPLAN.md'), 'path ends with S01-REPLAN.md'); + assert.ok(path !== null, 'resolveExpectedArtifactPath returns non-null for replan-slice'); + assert.ok(path!.endsWith('S01-REPLAN.md'), 'path ends with S01-REPLAN.md'); rmSync(base, { recursive: true, force: true }); -} +}); -console.log('\n=== artifact: verifyExpectedArtifact fails when REPLAN.md missing (#858) ==='); -{ +test('artifact: verifyExpectedArtifact fails when REPLAN.md missing (#858)', () => { const base = createFixtureBase(); writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); const result = verifyExpectedArtifact('replan-slice', 'M001/S01', base); - assertEq(result, false, 'verifyExpectedArtifact returns false when REPLAN.md is missing'); + assert.deepStrictEqual(result, false, 'verifyExpectedArtifact returns false when REPLAN.md is missing'); rmSync(base, { recursive: true, force: true }); -} +}); -console.log('\n=== artifact: verifyExpectedArtifact passes when REPLAN.md exists (#858) ==='); -{ +test('artifact: verifyExpectedArtifact passes when REPLAN.md exists (#858)', () => { const base = createFixtureBase(); writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); writeReplanFile(base, 'M001', 'S01', '# Replan\n\nBlocker addressed.'); const result = verifyExpectedArtifact('replan-slice', 'M001/S01', base); - assertEq(result, true, 'verifyExpectedArtifact returns true when REPLAN.md exists'); + assert.deepStrictEqual(result, true, 'verifyExpectedArtifact returns true when REPLAN.md exists'); rmSync(base, { recursive: true, force: true }); -} +}); // ═══════════════════════════════════════════════════════════════════════════ // REPLAN-TRIGGER.md detection (triage-initiated replan, #1701) // ═══════════════════════════════════════════════════════════════════════════ - // (a) REPLAN-TRIGGER.md exists + no REPLAN.md → replanning-slice -console.log('\n=== deriveState: REPLAN-TRIGGER.md exists, no REPLAN → replanning-slice (#1701) ==='); -{ +test('deriveState: REPLAN-TRIGGER.md exists, no REPLAN → replanning-slice (#1701)', async () => { const base = createFixtureBase(); writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); @@ -556,17 +552,16 @@ console.log('\n=== deriveState: REPLAN-TRIGGER.md exists, no REPLAN → replanni writeReplanTrigger(base, 'M001', 'S01', '# Replan Trigger\n\n**Source:** Capture C001\n'); const state = await deriveState(base); - assertEq(state.phase, 'replanning-slice', 'phase is replanning-slice when REPLAN-TRIGGER.md exists'); - assertTrue(state.blockers.length > 0, 'blockers array is non-empty for triage replan trigger'); - assertTrue(state.nextAction.includes('Triage replan'), 'nextAction mentions triage replan'); - assertEq(state.activeSlice?.id, 'S01', 'activeSlice is S01'); - assertEq(state.activeTask?.id, 'T02', 'activeTask is T02 (next incomplete task)'); + assert.deepStrictEqual(state.phase, 'replanning-slice', 'phase is replanning-slice when REPLAN-TRIGGER.md exists'); + assert.ok(state.blockers.length > 0, 'blockers array is non-empty for triage replan trigger'); + assert.ok(state.nextAction.includes('Triage replan'), 'nextAction mentions triage replan'); + assert.deepStrictEqual(state.activeSlice?.id, 'S01', 'activeSlice is S01'); + assert.deepStrictEqual(state.activeTask?.id, 'T02', 'activeTask is T02 (next incomplete task)'); rmSync(base, { recursive: true, force: true }); -} +}); // (b) REPLAN-TRIGGER.md + REPLAN.md both exist → executing (loop protection) -console.log('\n=== deriveState: REPLAN-TRIGGER.md + REPLAN.md → executing (loop protection, #1701) ==='); -{ +test('deriveState: REPLAN-TRIGGER.md + REPLAN.md → executing (loop protection, #1701)', async () => { const base = createFixtureBase(); writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); @@ -575,27 +570,25 @@ console.log('\n=== deriveState: REPLAN-TRIGGER.md + REPLAN.md → executing (loo writeReplanFile(base, 'M001', 'S01', '# Replan\n\nAlready replanned.'); const state = await deriveState(base); - assertEq(state.phase, 'executing', 'phase is executing when REPLAN.md exists (loop protection)'); - assertEq(state.activeTask?.id, 'T02', 'activeTask is T02'); + assert.deepStrictEqual(state.phase, 'executing', 'phase is executing when REPLAN.md exists (loop protection)'); + assert.deepStrictEqual(state.activeTask?.id, 'T02', 'activeTask is T02'); rmSync(base, { recursive: true, force: true }); -} +}); // (c) No REPLAN-TRIGGER.md, no blocker → executing (no false positive) -console.log('\n=== deriveState: no REPLAN-TRIGGER.md, no blocker → executing (#1701) ==='); -{ +test('deriveState: no REPLAN-TRIGGER.md, no blocker → executing (#1701)', async () => { const base = createFixtureBase(); writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); writeTaskSummary(base, 'M001', 'S01', 'T01', makeTaskSummary('T01', false)); const state = await deriveState(base); - assertEq(state.phase, 'executing', 'phase is executing when no trigger and no blocker'); + assert.deepStrictEqual(state.phase, 'executing', 'phase is executing when no trigger and no blocker'); rmSync(base, { recursive: true, force: true }); -} +}); // (d) blocker_discovered takes priority over REPLAN-TRIGGER.md -console.log('\n=== deriveState: blocker_discovered takes priority over REPLAN-TRIGGER.md (#1701) ==='); -{ +test('deriveState: blocker_discovered takes priority over REPLAN-TRIGGER.md (#1701)', async () => { const base = createFixtureBase(); writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE); writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending()); @@ -603,10 +596,10 @@ console.log('\n=== deriveState: blocker_discovered takes priority over REPLAN-TR writeReplanTrigger(base, 'M001', 'S01', '# Replan Trigger\n\n**Source:** Capture C001\n'); const state = await deriveState(base); - assertEq(state.phase, 'replanning-slice', 'phase is replanning-slice'); + assert.deepStrictEqual(state.phase, 'replanning-slice', 'phase is replanning-slice'); // blocker_discovered path should fire first (blockerTaskId is set, so REPLAN-TRIGGER check is skipped) - assertTrue(state.nextAction.includes('T01'), 'nextAction mentions blocker task T01 (blocker path, not trigger path)'); + assert.ok(state.nextAction.includes('T01'), 'nextAction mentions blocker task T01 (blocker path, not trigger path)'); rmSync(base, { recursive: true, force: true }); -} +}); -report(); +}); diff --git a/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts b/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts index cdea4611a..b6e231cf5 100644 --- a/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts +++ b/src/resources/extensions/gsd/tests/repo-identity-worktree.test.ts @@ -1,13 +1,11 @@ +import { describe, test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; import { mkdtempSync, rmSync, writeFileSync, existsSync, lstatSync, realpathSync, mkdirSync, symlinkSync, renameSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { execSync } from "node:child_process"; import { repoIdentity, externalGsdRoot, ensureGsdSymlink, validateProjectId, readRepoMeta, isInheritedRepo } from "../repo-identity.ts"; -import { createTestContext } from "./test-helpers.ts"; - -const { assertEq, assertTrue, report } = createTestContext(); - /** * Normalize a path for reliable comparison on Windows CI runners. * `os.tmpdir()` may return the 8.3 short-path form (e.g. `C:\Users\RUNNER~1`) @@ -23,11 +21,15 @@ function run(command: string, cwd: string): string { return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim(); } -async function main(): Promise { - const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-repo-identity-"))); - const stateDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-state-"))); +describe('repo-identity-worktree', () => { + let base: string; + let stateDir: string; + let worktreePath: string; + let expectedExternalState: string; - try { + before(() => { + base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-repo-identity-"))); + stateDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-state-"))); process.env.GSD_STATE_DIR = stateDir; run("git init -b main", base); @@ -38,57 +40,69 @@ async function main(): Promise { run("git add README.md", base); run('git commit -m "chore: init"', base); - const worktreePath = join(base, ".gsd", "worktrees", "M001"); + worktreePath = join(base, ".gsd", "worktrees", "M001"); run(`git worktree add -b milestone/M001 ${worktreePath}`, base); - console.log("\n=== ensureGsdSymlink points worktree at main repo external state dir ==="); - const expectedExternalState = externalGsdRoot(base); - const mainState = ensureGsdSymlink(base); - assertEq(mainState, realpathSync(join(base, ".gsd")), "ensureGsdSymlink(base) returns the current main repo .gsd target"); - const worktreeState = ensureGsdSymlink(worktreePath); - assertEq(worktreeState, expectedExternalState, "worktree symlink target matches main repo external state dir"); - assertTrue(existsSync(join(worktreePath, ".gsd")), "worktree .gsd exists"); - assertTrue(lstatSync(join(worktreePath, ".gsd")).isSymbolicLink(), "worktree .gsd is a symlink"); - assertEq(realpathSync(join(worktreePath, ".gsd")), realpathSync(expectedExternalState), "worktree .gsd symlink resolves to main repo external state dir"); + expectedExternalState = externalGsdRoot(base); + }); - console.log("\n=== ensureGsdSymlink heals stale worktree symlinks ==="); + after(() => { + delete process.env.GSD_PROJECT_ID; + delete process.env.GSD_STATE_DIR; + rmSync(base, { recursive: true, force: true }); + rmSync(stateDir, { recursive: true, force: true }); + }); + +test('ensureGsdSymlink points worktree at main repo external state dir', () => { + const mainState = ensureGsdSymlink(base); + assert.deepStrictEqual(mainState, realpathSync(join(base, ".gsd")), "ensureGsdSymlink(base) returns the current main repo .gsd target"); + const worktreeState = ensureGsdSymlink(worktreePath); + assert.deepStrictEqual(worktreeState, expectedExternalState, "worktree symlink target matches main repo external state dir"); + assert.ok(existsSync(join(worktreePath, ".gsd")), "worktree .gsd exists"); + assert.ok(lstatSync(join(worktreePath, ".gsd")).isSymbolicLink(), "worktree .gsd is a symlink"); + assert.deepStrictEqual(realpathSync(join(worktreePath, ".gsd")), realpathSync(expectedExternalState), "worktree .gsd symlink resolves to main repo external state dir"); +}); + +test('ensureGsdSymlink heals stale worktree symlinks', () => { const staleState = join(stateDir, "projects", "stale-worktree-state"); mkdirSync(staleState, { recursive: true }); rmSync(join(worktreePath, ".gsd"), { recursive: true, force: true }); symlinkSync(staleState, join(worktreePath, ".gsd"), "junction"); const healedState = ensureGsdSymlink(worktreePath); - assertEq(healedState, expectedExternalState, "stale worktree symlink is repaired to canonical external state dir"); - assertEq(realpathSync(join(worktreePath, ".gsd")), realpathSync(expectedExternalState), "healed worktree symlink resolves to canonical external state dir"); + assert.deepStrictEqual(healedState, expectedExternalState, "stale worktree symlink is repaired to canonical external state dir"); + assert.deepStrictEqual(realpathSync(join(worktreePath, ".gsd")), realpathSync(expectedExternalState), "healed worktree symlink resolves to canonical external state dir"); +}); - console.log("\n=== ensureGsdSymlink preserves worktree .gsd directories ==="); +test('ensureGsdSymlink preserves worktree .gsd directories', () => { rmSync(join(worktreePath, ".gsd"), { recursive: true, force: true }); mkdirSync(join(worktreePath, ".gsd", "milestones"), { recursive: true }); writeFileSync(join(worktreePath, ".gsd", "milestones", "stale.txt"), "stale\n", "utf-8"); const preservedDirState = ensureGsdSymlink(worktreePath); - assertEq(preservedDirState, join(worktreePath, ".gsd"), "worktree .gsd directory is left in place for sync-based refresh"); - assertTrue(lstatSync(join(worktreePath, ".gsd")).isDirectory(), "worktree .gsd directory remains a directory"); - assertTrue(existsSync(join(worktreePath, ".gsd", "milestones", "stale.txt")), "existing worktree .gsd directory contents remain available for sync logic"); + assert.deepStrictEqual(preservedDirState, join(worktreePath, ".gsd"), "worktree .gsd directory is left in place for sync-based refresh"); + assert.ok(lstatSync(join(worktreePath, ".gsd")).isDirectory(), "worktree .gsd directory remains a directory"); + assert.ok(existsSync(join(worktreePath, ".gsd", "milestones", "stale.txt")), "existing worktree .gsd directory contents remain available for sync logic"); +}); - console.log("\n=== GSD_PROJECT_ID overrides computed repo hash ==="); +test('GSD_PROJECT_ID overrides computed repo hash', () => { process.env.GSD_PROJECT_ID = "my-project"; - assertEq(repoIdentity(base), "my-project", "repoIdentity returns GSD_PROJECT_ID when set"); - assertEq(externalGsdRoot(base), join(stateDir, "projects", "my-project"), "externalGsdRoot uses GSD_PROJECT_ID"); + assert.deepStrictEqual(repoIdentity(base), "my-project", "repoIdentity returns GSD_PROJECT_ID when set"); + assert.deepStrictEqual(externalGsdRoot(base), join(stateDir, "projects", "my-project"), "externalGsdRoot uses GSD_PROJECT_ID"); delete process.env.GSD_PROJECT_ID; +}); - console.log("\n=== GSD_PROJECT_ID falls back to hash when unset ==="); +test('GSD_PROJECT_ID falls back to hash when unset', () => { const hashIdentity = repoIdentity(base); - assertTrue(/^[0-9a-f]{12}$/.test(hashIdentity), "repoIdentity returns 12-char hex hash when GSD_PROJECT_ID is unset"); + assert.ok(/^[0-9a-f]{12}$/.test(hashIdentity), "repoIdentity returns 12-char hex hash when GSD_PROJECT_ID is unset"); +}); - console.log("\n=== readRepoMeta returns null for malformed metadata ==="); - { +test('readRepoMeta returns null for malformed metadata', () => { const malformedPath = join(stateDir, "projects", "malformed"); mkdirSync(malformedPath, { recursive: true }); writeFileSync(join(malformedPath, "repo-meta.json"), JSON.stringify({ version: 1 }) + "\n", "utf-8"); - assertEq(readRepoMeta(malformedPath), null, "malformed repo-meta.json is treated as unknown metadata"); - } + assert.deepStrictEqual(readRepoMeta(malformedPath), null, "malformed repo-meta.json is treated as unknown metadata"); +}); - console.log("\n=== ensureGsdSymlink refreshes repo-meta gitRoot after repo move with fixed project id ==="); - { +test('ensureGsdSymlink refreshes repo-meta gitRoot after repo move with fixed project id', () => { const moveRepo = realpathSync(mkdtempSync(join(tmpdir(), "gsd-repo-identity-move-"))); run("git init -b main", moveRepo); run('git config user.name "Pi Test"', moveRepo); @@ -100,26 +114,25 @@ async function main(): Promise { process.env.GSD_PROJECT_ID = "fixed-project"; const fixedExternal = ensureGsdSymlink(moveRepo); const before = readRepoMeta(fixedExternal); - assertTrue(before !== null, "repo metadata exists before repo move"); - assertEq(normalizePath(before!.gitRoot), normalizePath(moveRepo), "repo metadata tracks current git root before move"); + assert.ok(before !== null, "repo metadata exists before repo move"); + assert.deepStrictEqual(normalizePath(before!.gitRoot), normalizePath(moveRepo), "repo metadata tracks current git root before move"); const movedBaseRaw = join(tmpdir(), `gsd-repo-identity-moved-${Date.now()}-${Math.random().toString(36).slice(2)}`); renameSync(moveRepo, movedBaseRaw); const movedBase = realpathSync(movedBaseRaw); const movedExternal = ensureGsdSymlink(movedBase); - assertEq(realpathSync(movedExternal), realpathSync(fixedExternal), "fixed project id keeps the same external state dir"); + assert.deepStrictEqual(realpathSync(movedExternal), realpathSync(fixedExternal), "fixed project id keeps the same external state dir"); const after = readRepoMeta(movedExternal); - assertTrue(after !== null, "repo metadata exists after repo move"); - assertEq(normalizePath(after!.gitRoot), normalizePath(movedBase), "repo metadata gitRoot is refreshed to moved repo path"); - assertEq(after!.createdAt, before!.createdAt, "repo metadata preserves createdAt on refresh"); + assert.ok(after !== null, "repo metadata exists after repo move"); + assert.deepStrictEqual(normalizePath(after!.gitRoot), normalizePath(movedBase), "repo metadata gitRoot is refreshed to moved repo path"); + assert.deepStrictEqual(after!.createdAt, before!.createdAt, "repo metadata preserves createdAt on refresh"); rmSync(movedBase, { recursive: true, force: true }); delete process.env.GSD_PROJECT_ID; - } +}); - console.log("\n=== isInheritedRepo detects subdirectory of parent repo without .gsd (#1639) ==="); - { +test('isInheritedRepo detects subdirectory of parent repo without .gsd (#1639)', () => { const parentRepo = realpathSync(mkdtempSync(join(tmpdir(), "gsd-inherited-parent-"))); run("git init -b main", parentRepo); run('git config user.name "Pi Test"', parentRepo); @@ -128,31 +141,26 @@ async function main(): Promise { run("git add README.md", parentRepo); run('git commit -m "init"', parentRepo); - // Create a subdirectory — no .gsd at parent const subdir = join(parentRepo, "newproject"); mkdirSync(subdir, { recursive: true }); - assertTrue(isInheritedRepo(subdir), "subdirectory of parent repo without .gsd is inherited"); + assert.ok(isInheritedRepo(subdir), "subdirectory of parent repo without .gsd is inherited"); - // After adding .gsd at parent, subdirectory is a legitimate child mkdirSync(join(parentRepo, ".gsd"), { recursive: true }); - assertTrue(!isInheritedRepo(subdir), "subdirectory of parent repo WITH .gsd is NOT inherited"); + assert.ok(!isInheritedRepo(subdir), "subdirectory of parent repo WITH .gsd is NOT inherited"); - // The git root itself is never inherited - assertTrue(!isInheritedRepo(parentRepo), "git root is not inherited"); + assert.ok(!isInheritedRepo(parentRepo), "git root is not inherited"); - // A standalone repo (not a subdir) is not inherited const standaloneRepo = realpathSync(mkdtempSync(join(tmpdir(), "gsd-inherited-standalone-"))); run("git init -b main", standaloneRepo); run('git config user.name "Pi Test"', standaloneRepo); run('git config user.email "pi@example.com"', standaloneRepo); - assertTrue(!isInheritedRepo(standaloneRepo), "standalone repo is not inherited"); + assert.ok(!isInheritedRepo(standaloneRepo), "standalone repo is not inherited"); rmSync(parentRepo, { recursive: true, force: true }); rmSync(standaloneRepo, { recursive: true, force: true }); - } +}); - console.log("\n=== subdirectory of parent repo gets unique identity after git init (#1639) ==="); - { +test('subdirectory of parent repo gets unique identity after git init (#1639)', () => { const parentRepo = realpathSync(mkdtempSync(join(tmpdir(), "gsd-identity-parent-"))); run("git init -b main", parentRepo); run('git config user.name "Pi Test"', parentRepo); @@ -165,38 +173,27 @@ async function main(): Promise { const subdir = join(parentRepo, "childproject"); mkdirSync(subdir, { recursive: true }); - // Before git init, subdirectory shares parent's identity const parentIdentity = repoIdentity(parentRepo); const subdirIdentityBefore = repoIdentity(subdir); - assertEq(subdirIdentityBefore, parentIdentity, "subdirectory shares parent identity before its own git init"); + assert.deepStrictEqual(subdirIdentityBefore, parentIdentity, "subdirectory shares parent identity before its own git init"); - // After git init, subdirectory gets its own identity run("git init -b main", subdir); const subdirIdentityAfter = repoIdentity(subdir); - assertTrue(subdirIdentityAfter !== parentIdentity, "subdirectory gets unique identity after git init"); + assert.ok(subdirIdentityAfter !== parentIdentity, "subdirectory gets unique identity after git init"); rmSync(parentRepo, { recursive: true, force: true }); - } - - console.log("\n=== validateProjectId rejects invalid values ==="); - for (const invalid of ["has spaces", "path/traversal", "dot..dot", "back\\slash"]) { - assertTrue(!validateProjectId(invalid), `validateProjectId rejects invalid value: "${invalid}"`); - } - - console.log("\n=== validateProjectId accepts valid values ==="); - for (const valid of ["my-project", "foo_bar", "abc123", "A-Z_0-9"]) { - assertTrue(validateProjectId(valid), `validateProjectId accepts valid value: "${valid}"`); - } - } finally { - delete process.env.GSD_PROJECT_ID; - delete process.env.GSD_STATE_DIR; - rmSync(base, { recursive: true, force: true }); - rmSync(stateDir, { recursive: true, force: true }); - report(); - } -} - -main().catch((error) => { - console.error(error); - process.exit(1); +}); + +test('validateProjectId rejects invalid values', () => { + for (const invalid of ["has spaces", "path/traversal", "dot..dot", "back\\slash"]) { + assert.ok(!validateProjectId(invalid), `validateProjectId rejects invalid value: "${invalid}"`); + } +}); + +test('validateProjectId accepts valid values', () => { + for (const valid of ["my-project", "foo_bar", "abc123", "A-Z_0-9"]) { + assert.ok(validateProjectId(valid), `validateProjectId accepts valid value: "${valid}"`); + } +}); + }); diff --git a/src/resources/extensions/gsd/tests/requirements.test.ts b/src/resources/extensions/gsd/tests/requirements.test.ts index 65536ce00..edc2e0897 100644 --- a/src/resources/extensions/gsd/tests/requirements.test.ts +++ b/src/resources/extensions/gsd/tests/requirements.test.ts @@ -1,15 +1,15 @@ +import { describe, test, after } from 'node:test'; +import assert from 'node:assert/strict'; import { parseRequirementCounts } from "../files.ts"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { deriveState } from "../state.ts"; import { runGSDDoctor } from "../doctor.ts"; -import { createTestContext } from './test-helpers.ts'; -const { assertEq, assertTrue, report } = createTestContext(); -console.log("\n=== requirement counts parser ==="); -{ - const counts = parseRequirementCounts(`# Requirements +describe('requirements', () => { + test('requirement counts parser', () => { + const counts = parseRequirementCounts(`# Requirements ## Active @@ -34,73 +34,68 @@ console.log("\n=== requirement counts parser ==="); ### R030 — No - Status: out-of-scope `); - assertEq(counts.active, 2, "counts active requirements by section"); - assertEq(counts.validated, 1, "counts validated requirements"); - assertEq(counts.deferred, 1, "counts deferred requirements"); - assertEq(counts.outOfScope, 1, "counts out of scope requirements"); - assertEq(counts.blocked, 1, "counts blocked statuses"); -} + assert.deepStrictEqual(counts.active, 2, "counts active requirements by section"); + assert.deepStrictEqual(counts.validated, 1, "counts validated requirements"); + assert.deepStrictEqual(counts.deferred, 1, "counts deferred requirements"); + assert.deepStrictEqual(counts.outOfScope, 1, "counts out of scope requirements"); + assert.deepStrictEqual(counts.blocked, 1, "counts blocked statuses"); + }); -const base = mkdtempSync(join(tmpdir(), "gsd-requirements-test-")); -const gsd = join(base, ".gsd"); -const mDir = join(gsd, "milestones", "M001"); -const sDir = join(mDir, "slices", "S01"); -const tDir = join(sDir, "tasks"); -mkdirSync(tDir, { recursive: true }); -writeFileSync(join(gsd, "REQUIREMENTS.md"), `# Requirements + const base = mkdtempSync(join(tmpdir(), "gsd-requirements-test-")); + const gsd = join(base, ".gsd"); + const mDir = join(gsd, "milestones", "M001"); + const sDir = join(mDir, "slices", "S01"); + const tDir = join(sDir, "tasks"); + mkdirSync(tDir, { recursive: true }); + writeFileSync(join(gsd, "REQUIREMENTS.md"), [ + "# Requirements", + "## Active", + "### R001 — Missing owner", + "- Class: core-capability", + "- Status: active", + "- Description: thing", + "- Why it matters: thing", + "- Source: user", + "- Primary owning slice: none yet", + "- Supporting slices: none", + "- Validation: unmapped", + "- Notes: none", + "## Validated", + "## Deferred", + "## Out of Scope", + "## Traceability", + "", + ].join("\n"), "utf-8"); + writeFileSync(join(mDir, "M001-ROADMAP.md"), [ + "# M001: Demo", + "## Slices", + "- [ ] **S01: Demo Slice** `risk:low` `depends:[]`", + " > After this: demo works", + "", + ].join("\n"), "utf-8"); + writeFileSync(join(sDir, "S01-PLAN.md"), [ + "# S01: Demo Slice", + "**Goal:** Demo", + "**Demo:** Demo", + "## Must-Haves", + "- done", + "## Tasks", + "- [ ] **T01: Implement thing** `est:10m`", + " Task is in progress.", + "", + ].join("\n"), "utf-8"); + test('deriveState includes requirements counts', async () => { + const state = await deriveState(base); + assert.ok(state.requirements !== undefined, "state includes requirements summary"); + assert.deepStrictEqual(state.requirements?.active, 1, "state reports active requirement count"); + }); -## Active + test('doctor flags orphaned active requirement', async () => { + const report = await runGSDDoctor(base); + assert.ok(report.issues.some(issue => issue.code === "active_requirement_missing_owner"), "doctor flags missing owner"); + }); -### R001 — Missing owner -- Class: core-capability -- Status: active -- Description: thing -- Why it matters: thing -- Source: user -- Primary owning slice: none yet -- Supporting slices: none -- Validation: unmapped -- Notes: none - -## Validated - -## Deferred - -## Out of Scope - -## Traceability -`, "utf-8"); -writeFileSync(join(mDir, "M001-ROADMAP.md"), `# M001: Demo - -## Slices -- [ ] **S01: Demo Slice** \`risk:low\` \`depends:[]\` - > After this: demo works -`, "utf-8"); -writeFileSync(join(sDir, "S01-PLAN.md"), `# S01: Demo Slice - -**Goal:** Demo -**Demo:** Demo - -## Must-Haves -- done - -## Tasks -- [ ] **T01: Implement thing** \`est:10m\` - Task is in progress. -`, "utf-8"); - -console.log("\n=== deriveState includes requirements counts ==="); -{ - const state = await deriveState(base); - assertTrue(state.requirements !== undefined, "state includes requirements summary"); - assertEq(state.requirements?.active, 1, "state reports active requirement count"); -} - -console.log("\n=== doctor flags orphaned active requirement ==="); -{ - const report = await runGSDDoctor(base); - assertTrue(report.issues.some(issue => issue.code === "active_requirement_missing_owner"), "doctor flags missing owner"); -} - -rmSync(base, { recursive: true, force: true }); -report(); + after(() => { + rmSync(base, { recursive: true, force: true }); + }); +}); diff --git a/src/resources/extensions/gsd/tests/retry-state-reset.test.ts b/src/resources/extensions/gsd/tests/retry-state-reset.test.ts index f3c39b117..dabbc4d2c 100644 --- a/src/resources/extensions/gsd/tests/retry-state-reset.test.ts +++ b/src/resources/extensions/gsd/tests/retry-state-reset.test.ts @@ -4,10 +4,11 @@ // consuming code properly resets all completion state so deriveState // re-derives the task on the next loop iteration. +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { createTestContext } from "./test-helpers.ts"; import { resetHookState, consumeRetryTrigger, @@ -16,8 +17,6 @@ import { } from "../post-unit-hooks.ts"; import { uncheckTaskInPlan } from "../undo.ts"; -const { assertEq, assertTrue, report } = createTestContext(); - // ─── Fixture Helpers ─────────────────────────────────────────────────────── function createRetryFixture(): { base: string; cleanup: () => void } { @@ -65,74 +64,65 @@ function createRetryFixture(): { base: string; cleanup: () => void } { // Test: consumeRetryTrigger returns retryArtifact field // ═══════════════════════════════════════════════════════════════════════════ -console.log("\n=== consumeRetryTrigger: returns null when no retry pending ==="); -{ +describe('retry-state-reset', () => { +test('consumeRetryTrigger: returns null when no retry pending', () => { resetHookState(); const trigger = consumeRetryTrigger(); - assertEq(trigger, null, "returns null when no retry pending"); -} + assert.deepStrictEqual(trigger, null, "returns null when no retry pending"); +}); // ═══════════════════════════════════════════════════════════════════════════ // Test: uncheckTaskInPlan reverses doctor's [x] mark // ═══════════════════════════════════════════════════════════════════════════ - -console.log("\n=== Retry reset step 1: uncheck [x] → [ ] in PLAN.md ==="); - -{ +test('Retry reset step 1: uncheck [x] → [ ] in PLAN.md', () => { const { base, cleanup } = createRetryFixture(); try { const planFile = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"); // Precondition: T01 is checked const before = readFileSync(planFile, "utf-8"); - assertTrue(before.includes("- [x] **T01:"), "precondition: T01 is checked [x]"); + assert.ok(before.includes("- [x] **T01:"), "precondition: T01 is checked [x]"); // Step 1: Uncheck T01 const result = uncheckTaskInPlan(base, "M001", "S01", "T01"); - assertTrue(result, "uncheckTaskInPlan returns true"); + assert.ok(result, "uncheckTaskInPlan returns true"); // Verify T01 is now unchecked const after = readFileSync(planFile, "utf-8"); - assertTrue(after.includes("- [ ] **T01:"), "T01 is now unchecked [ ]"); - assertTrue(!after.includes("- [x] **T01:"), "T01 no longer has [x]"); + assert.ok(after.includes("- [ ] **T01:"), "T01 is now unchecked [ ]"); + assert.ok(!after.includes("- [x] **T01:"), "T01 no longer has [x]"); // T02 is unaffected - assertTrue(after.includes("- [ ] **T02:"), "T02 remains unchanged"); + assert.ok(after.includes("- [ ] **T02:"), "T02 remains unchanged"); } finally { cleanup(); } -} +}); // ═══════════════════════════════════════════════════════════════════════════ // Test: Delete SUMMARY.md for the task // ═══════════════════════════════════════════════════════════════════════════ - -console.log("\n=== Retry reset step 2: delete SUMMARY.md ==="); - -{ +test('Retry reset step 2: delete SUMMARY.md', () => { const { base, cleanup } = createRetryFixture(); try { const summaryFile = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks", "T01-SUMMARY.md"); // Precondition: SUMMARY exists - assertTrue(existsSync(summaryFile), "precondition: SUMMARY.md exists"); + assert.ok(existsSync(summaryFile), "precondition: SUMMARY.md exists"); // Step 2: Delete SUMMARY.md unlinkSync(summaryFile); - assertTrue(!existsSync(summaryFile), "SUMMARY.md deleted"); + assert.ok(!existsSync(summaryFile), "SUMMARY.md deleted"); } finally { cleanup(); } -} +}); // ═══════════════════════════════════════════════════════════════════════════ // Test: Remove from completedUnits array and flush // ═══════════════════════════════════════════════════════════════════════════ - -console.log("\n=== Retry reset step 3: remove from completedUnits ==="); - -{ +test('Retry reset step 3: remove from completedUnits', () => { const { base, cleanup } = createRetryFixture(); try { // Simulate the completedUnits array (as AutoSession would have it) @@ -146,8 +136,8 @@ console.log("\n=== Retry reset step 3: remove from completedUnits ==="); u => !(u.type === "execute-task" && u.id === "M001/S01/T01"), ); - assertEq(filtered.length, 1, "one unit removed from completedUnits"); - assertEq(filtered[0].id, "M001/S01/T02", "T02 still in completedUnits"); + assert.deepStrictEqual(filtered.length, 1, "one unit removed from completedUnits"); + assert.deepStrictEqual(filtered[0].id, "M001/S01/T02", "T02 still in completedUnits"); // Flush to completed-units.json const completedKeysPath = join(base, ".gsd", "completed-units.json"); @@ -155,42 +145,36 @@ console.log("\n=== Retry reset step 3: remove from completedUnits ==="); writeFileSync(completedKeysPath, JSON.stringify(keys, null, 2), "utf-8"); const onDisk = JSON.parse(readFileSync(completedKeysPath, "utf-8")); - assertEq(onDisk.length, 1, "completed-units.json has one entry"); - assertEq(onDisk[0], "execute-task/M001/S01/T02", "only T02 remains in completed-units.json"); + assert.deepStrictEqual(onDisk.length, 1, "completed-units.json has one entry"); + assert.deepStrictEqual(onDisk[0], "execute-task/M001/S01/T02", "only T02 remains in completed-units.json"); } finally { cleanup(); } -} +}); // ═══════════════════════════════════════════════════════════════════════════ // Test: Delete the retry_on artifact // ═══════════════════════════════════════════════════════════════════════════ - -console.log("\n=== Retry reset step 4: delete retry_on artifact ==="); - -{ +test('Retry reset step 4: delete retry_on artifact', () => { const { base, cleanup } = createRetryFixture(); try { const retryArtifactPath = resolveHookArtifactPath(base, "M001/S01/T01", "NEEDS-REWORK.md"); // Precondition: artifact exists - assertTrue(existsSync(retryArtifactPath), "precondition: retry artifact exists"); + assert.ok(existsSync(retryArtifactPath), "precondition: retry artifact exists"); // Step 4: Delete retry artifact unlinkSync(retryArtifactPath); - assertTrue(!existsSync(retryArtifactPath), "retry artifact deleted"); + assert.ok(!existsSync(retryArtifactPath), "retry artifact deleted"); } finally { cleanup(); } -} +}); // ═══════════════════════════════════════════════════════════════════════════ // Test: Full retry reset sequence (all steps together) // ═══════════════════════════════════════════════════════════════════════════ - -console.log("\n=== Full retry reset: all steps combined ==="); - -{ +test('Full retry reset: all steps combined', () => { const { base, cleanup } = createRetryFixture(); try { const trigger = { @@ -242,30 +226,27 @@ console.log("\n=== Full retry reset: all steps combined ==="); // PLAN.md: T01 unchecked const planFile = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"); const planContent = readFileSync(planFile, "utf-8"); - assertTrue(planContent.includes("- [ ] **T01:"), "after reset: T01 unchecked in PLAN"); - assertTrue(!planContent.includes("- [x] **T01:"), "after reset: T01 not checked in PLAN"); + assert.ok(planContent.includes("- [ ] **T01:"), "after reset: T01 unchecked in PLAN"); + assert.ok(!planContent.includes("- [x] **T01:"), "after reset: T01 not checked in PLAN"); // SUMMARY.md: deleted - assertTrue(!existsSync(summaryFile), "after reset: SUMMARY.md deleted"); + assert.ok(!existsSync(summaryFile), "after reset: SUMMARY.md deleted"); // completed-units.json: empty const onDisk = JSON.parse(readFileSync(completedKeysPath, "utf-8")); - assertEq(onDisk.length, 0, "after reset: completed-units.json is empty"); + assert.deepStrictEqual(onDisk.length, 0, "after reset: completed-units.json is empty"); // Retry artifact: deleted - assertTrue(!existsSync(retryArtifactPath), "after reset: retry artifact deleted"); + assert.ok(!existsSync(retryArtifactPath), "after reset: retry artifact deleted"); } finally { cleanup(); } -} +}); // ═══════════════════════════════════════════════════════════════════════════ // Test: Reset is idempotent — no crash when artifacts are already missing // ═══════════════════════════════════════════════════════════════════════════ - -console.log("\n=== Retry reset: idempotent when artifacts already missing ==="); - -{ +test('Retry reset: idempotent when artifacts already missing', () => { const base = mkdtempSync(join(tmpdir(), "gsd-retry-idempotent-")); try { // Create minimal structure — NO summary, NO retry artifact, NO plan @@ -288,41 +269,38 @@ console.log("\n=== Retry reset: idempotent when artifacts already missing ==="); // Uncheck — returns false because no PLAN file const uncheckResult = uncheckTaskInPlan(base, mid, sid, tid); - assertTrue(!uncheckResult, "uncheck returns false when no PLAN exists"); + assert.ok(!uncheckResult, "uncheck returns false when no PLAN exists"); // Summary does not exist — no crash const summaryFile = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks", `${tid}-SUMMARY.md`); - assertTrue(!existsSync(summaryFile), "no summary to delete — safe"); + assert.ok(!existsSync(summaryFile), "no summary to delete — safe"); // Retry artifact does not exist — no crash const retryPath = resolveHookArtifactPath(base, trigger.unitId, trigger.retryArtifact); - assertTrue(!existsSync(retryPath), "no retry artifact to delete — safe"); + assert.ok(!existsSync(retryPath), "no retry artifact to delete — safe"); // completed-units.json filter on empty array — safe const completedUnits: Array<{ type: string; id: string }> = []; const filtered = completedUnits.filter( u => !(u.type === trigger.unitType && u.id === trigger.unitId), ); - assertEq(filtered.length, 0, "filter on empty array is safe"); + assert.deepStrictEqual(filtered.length, 0, "filter on empty array is safe"); } finally { rmSync(base, { recursive: true, force: true }); } -} +}); // ═══════════════════════════════════════════════════════════════════════════ // Test: resolveHookArtifactPath produces correct path for retry artifacts // ═══════════════════════════════════════════════════════════════════════════ - -console.log("\n=== resolveHookArtifactPath: correct path for retry artifacts ==="); - -{ +test('resolveHookArtifactPath: correct path for retry artifacts', () => { const base = "/project"; const path = resolveHookArtifactPath(base, "M001/S01/T01", "NEEDS-REWORK.md"); - assertEq( + assert.deepStrictEqual( path, join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks", "T01-NEEDS-REWORK.md"), "retry artifact path resolves to task directory with task prefix", ); -} +}); -report(); +}); diff --git a/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts b/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts index f6530049a..602e9745f 100644 --- a/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts +++ b/src/resources/extensions/gsd/tests/roadmap-parse-regression.test.ts @@ -12,20 +12,16 @@ * Also covers dependency expansion (range syntax) and edge cases. */ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; import { parseRoadmapSlices, expandDependencies } from '../roadmap-slices.ts'; -import { createTestContext } from './test-helpers.ts'; - -const { assertEq, assertTrue, report } = createTestContext(); - -async function main(): Promise { - // ═══════════════════════════════════════════════════════════════════════ // A. Standard machine-readable format (should always work) // ═══════════════════════════════════════════════════════════════════════ - console.log('\n=== A. Standard checkbox format ==='); - { +describe('roadmap-parse-regression', () => { +test('A. Standard checkbox format', () => { const content = [ '# M001: Test Project', '', @@ -40,30 +36,27 @@ async function main(): Promise { ].join('\n'); const slices = parseRoadmapSlices(content); - assertEq(slices.length, 3, 'standard format: 3 slices'); - assertEq(slices[0].id, 'S01', 'S01 id'); - assertEq(slices[0].title, 'First Slice', 'S01 title'); - assertEq(slices[0].done, false, 'S01 not done'); - assertEq(slices[0].risk, 'low', 'S01 risk'); - assertEq(slices[0].depends.length, 0, 'S01 no deps'); + assert.deepStrictEqual(slices.length, 3, 'standard format: 3 slices'); + assert.deepStrictEqual(slices[0].id, 'S01', 'S01 id'); + assert.deepStrictEqual(slices[0].title, 'First Slice', 'S01 title'); + assert.deepStrictEqual(slices[0].done, false, 'S01 not done'); + assert.deepStrictEqual(slices[0].risk, 'low', 'S01 risk'); + assert.deepStrictEqual(slices[0].depends.length, 0, 'S01 no deps'); - assertEq(slices[1].id, 'S02', 'S02 id'); - assertEq(slices[1].depends.length, 1, 'S02 has 1 dep'); - assertEq(slices[1].depends[0], 'S01', 'S02 depends on S01'); + assert.deepStrictEqual(slices[1].id, 'S02', 'S02 id'); + assert.deepStrictEqual(slices[1].depends.length, 1, 'S02 has 1 dep'); + assert.deepStrictEqual(slices[1].depends[0], 'S01', 'S02 depends on S01'); - assertEq(slices[2].id, 'S03', 'S03 id'); - assertEq(slices[2].done, true, 'S03 is done'); - assertEq(slices[2].risk, 'high', 'S03 risk'); - assertEq(slices[2].depends.length, 2, 'S03 has 2 deps'); - } + assert.deepStrictEqual(slices[2].id, 'S03', 'S03 id'); + assert.deepStrictEqual(slices[2].done, true, 'S03 is done'); + assert.deepStrictEqual(slices[2].risk, 'high', 'S03 risk'); + assert.deepStrictEqual(slices[2].depends.length, 2, 'S03 has 2 deps'); +}); // ═══════════════════════════════════════════════════════════════════════ // B. Prose fallback: H2 with colon (the only format the old regex matched) // ═══════════════════════════════════════════════════════════════════════ - - console.log('\n=== B. Prose fallback: H2 with colon ==='); - - { +test('B. Prose fallback: H2 with colon', () => { const content = [ '# M001: Test', '', @@ -78,20 +71,17 @@ async function main(): Promise { ].join('\n'); const slices = parseRoadmapSlices(content); - assertEq(slices.length, 2, 'prose H2 colon: 2 slices'); - assertEq(slices[0].id, 'S01', 'S01 id'); - assertEq(slices[0].title, 'Setup Foundation', 'S01 title'); - assertEq(slices[1].id, 'S02', 'S02 id'); - assertEq(slices[1].title, 'Core Features', 'S02 title'); - } + assert.deepStrictEqual(slices.length, 2, 'prose H2 colon: 2 slices'); + assert.deepStrictEqual(slices[0].id, 'S01', 'S01 id'); + assert.deepStrictEqual(slices[0].title, 'Setup Foundation', 'S01 title'); + assert.deepStrictEqual(slices[1].id, 'S02', 'S02 id'); + assert.deepStrictEqual(slices[1].title, 'Core Features', 'S02 title'); +}); // ═══════════════════════════════════════════════════════════════════════ // C. Regression #1248: H3 headers (the old regex only matched ##) // ═══════════════════════════════════════════════════════════════════════ - - console.log('\n=== C. #1248: H3 headers ==='); - - { +test('C. #1248: H3 headers', () => { const content = [ '# M001: Test', '', @@ -106,18 +96,15 @@ async function main(): Promise { ].join('\n'); const slices = parseRoadmapSlices(content); - assertEq(slices.length, 2, '#1248 H3: 2 slices parsed'); - assertEq(slices[0].id, 'S01', 'S01 from H3'); - assertEq(slices[1].id, 'S02', 'S02 from H3'); - } + assert.deepStrictEqual(slices.length, 2, '#1248 H3: 2 slices parsed'); + assert.deepStrictEqual(slices[0].id, 'S01', 'S01 from H3'); + assert.deepStrictEqual(slices[1].id, 'S02', 'S02 from H3'); +}); // ═══════════════════════════════════════════════════════════════════════ // D. Regression #1248: H4 headers // ═══════════════════════════════════════════════════════════════════════ - - console.log('\n=== D. #1248: H4 headers ==='); - - { +test('D. #1248: H4 headers', () => { const content = [ '# M001: Test', '', @@ -128,16 +115,13 @@ async function main(): Promise { ].join('\n'); const slices = parseRoadmapSlices(content); - assertEq(slices.length, 2, '#1248 H4: 2 slices parsed'); - } + assert.deepStrictEqual(slices.length, 2, '#1248 H4: 2 slices parsed'); +}); // ═══════════════════════════════════════════════════════════════════════ // E. Regression #1248: H1 header (unusual but LLMs produce it) // ═══════════════════════════════════════════════════════════════════════ - - console.log('\n=== E. #1248: H1 headers ==='); - - { +test('E. #1248: H1 headers', () => { const content = [ '# S01: Setup Foundation', '', @@ -150,97 +134,76 @@ async function main(): Promise { ].join('\n'); const slices = parseRoadmapSlices(content); - assertEq(slices.length, 2, '#1248 H1: 2 slices parsed'); - } + assert.deepStrictEqual(slices.length, 2, '#1248 H1: 2 slices parsed'); +}); // ═══════════════════════════════════════════════════════════════════════ // F. Regression #1248: Bold-wrapped IDs // ═══════════════════════════════════════════════════════════════════════ - - console.log('\n=== F. #1248: Bold-wrapped ==='); - - { +test('F. #1248: Bold-wrapped', () => { const content1 = '## **S01: Setup Foundation**\n\nDo stuff.\n\n## **S02: Features**\n\nMore stuff.\n'; const slices1 = parseRoadmapSlices(content1); - assertEq(slices1.length, 2, 'bold-wrapped: 2 slices'); - assertEq(slices1[0].title, 'Setup Foundation', 'bold-wrapped: title extracted without bold'); + assert.deepStrictEqual(slices1.length, 2, 'bold-wrapped: 2 slices'); + assert.deepStrictEqual(slices1[0].title, 'Setup Foundation', 'bold-wrapped: title extracted without bold'); const content2 = '## **S01**: Setup Foundation\n\n## **S02**: Features\n'; const slices2 = parseRoadmapSlices(content2); - assertEq(slices2.length, 2, 'bold ID only: 2 slices'); - } + assert.deepStrictEqual(slices2.length, 2, 'bold ID only: 2 slices'); +}); // ═══════════════════════════════════════════════════════════════════════ // G. Regression #1248: Dot separator // ═══════════════════════════════════════════════════════════════════════ - - console.log('\n=== G. #1248: Dot separator ==='); - - { +test('G. #1248: Dot separator', () => { const content = '## S01. Setup Foundation\n\n## S02. Core Features\n'; const slices = parseRoadmapSlices(content); - assertEq(slices.length, 2, 'dot separator: 2 slices'); - assertEq(slices[0].title, 'Setup Foundation', 'dot separator: title'); - } + assert.deepStrictEqual(slices.length, 2, 'dot separator: 2 slices'); + assert.deepStrictEqual(slices[0].title, 'Setup Foundation', 'dot separator: title'); +}); // ═══════════════════════════════════════════════════════════════════════ // H. Regression #1248: Em dash separator // ═══════════════════════════════════════════════════════════════════════ - - console.log('\n=== H. #1248: Em/en dash separators ==='); - - { +test('H. #1248: Em/en dash separators', () => { const content = '## S01 — Setup Foundation\n\n## S02 – Core Features\n'; const slices = parseRoadmapSlices(content); - assertEq(slices.length, 2, 'em/en dash: 2 slices'); - } + assert.deepStrictEqual(slices.length, 2, 'em/en dash: 2 slices'); +}); // ═══════════════════════════════════════════════════════════════════════ // I. Regression #1248: Space-only separator (no punctuation) // ═══════════════════════════════════════════════════════════════════════ - - console.log('\n=== I. #1248: Space-only separator ==='); - - { +test('I. #1248: Space-only separator', () => { const content = '## S01 Setup Foundation\n\n## S02 Core Features\n'; const slices = parseRoadmapSlices(content); - assertEq(slices.length, 2, 'space-only: 2 slices'); - assertEq(slices[0].title, 'Setup Foundation', 'space-only: title'); - } + assert.deepStrictEqual(slices.length, 2, 'space-only: 2 slices'); + assert.deepStrictEqual(slices[0].title, 'Setup Foundation', 'space-only: title'); +}); // ═══════════════════════════════════════════════════════════════════════ // J. Regression #1248: Non-zero-padded IDs // ═══════════════════════════════════════════════════════════════════════ - - console.log('\n=== J. #1248: Non-zero-padded IDs ==='); - - { +test('J. #1248: Non-zero-padded IDs', () => { const content = '## S1: Setup\n\n## S2: Features\n'; const slices = parseRoadmapSlices(content); - assertEq(slices.length, 2, 'non-padded: 2 slices'); - assertEq(slices[0].id, 'S1', 'non-padded: S1'); - } + assert.deepStrictEqual(slices.length, 2, 'non-padded: 2 slices'); + assert.deepStrictEqual(slices[0].id, 'S1', 'non-padded: S1'); +}); // ═══════════════════════════════════════════════════════════════════════ // K. Regression #1248: "Slice" prefix // ═══════════════════════════════════════════════════════════════════════ - - console.log('\n=== K. #1248: "Slice" prefix ==='); - - { +test('K. #1248: "Slice" prefix', () => { const content = '## Slice S01: Setup Foundation\n\n## Slice S02: Core Features\n'; const slices = parseRoadmapSlices(content); - assertEq(slices.length, 2, 'Slice prefix: 2 slices'); - assertEq(slices[0].id, 'S01', 'Slice prefix: S01'); - } + assert.deepStrictEqual(slices.length, 2, 'Slice prefix: 2 slices'); + assert.deepStrictEqual(slices[0].id, 'S01', 'Slice prefix: S01'); +}); // ═══════════════════════════════════════════════════════════════════════ // L. Prose with "Depends on:" line // ═══════════════════════════════════════════════════════════════════════ - - console.log('\n=== L. Prose with Depends on: ==='); - - { +test('L. Prose with Depends on:', () => { const content = [ '## S01: Foundation', '', @@ -254,20 +217,17 @@ async function main(): Promise { ].join('\n'); const slices = parseRoadmapSlices(content); - assertEq(slices.length, 2, 'prose deps: 2 slices'); - assertEq(slices[1].depends.length, 1, 'S02 has 1 dep'); - assertEq(slices[1].depends[0], 'S01', 'S02 depends on S01'); - } + assert.deepStrictEqual(slices.length, 2, 'prose deps: 2 slices'); + assert.deepStrictEqual(slices[1].depends.length, 1, 'S02 has 1 dep'); + assert.deepStrictEqual(slices[1].depends[0], 'S01', 'S02 depends on S01'); +}); // ═══════════════════════════════════════════════════════════════════════ // M. Empty / edge cases // ═══════════════════════════════════════════════════════════════════════ - - console.log('\n=== M. Edge cases ==='); - - { - assertEq(parseRoadmapSlices('').length, 0, 'empty content → 0 slices'); - assertEq(parseRoadmapSlices('# Just a title\n\nSome text.').length, 0, 'no slices at all → 0'); +test('M. Edge cases', () => { + assert.deepStrictEqual(parseRoadmapSlices('').length, 0, 'empty content → 0 slices'); + assert.deepStrictEqual(parseRoadmapSlices('# Just a title\n\nSome text.').length, 0, 'no slices at all → 0'); // Mixed format: ## Slices section with one checkbox + prose below const mixed = [ @@ -281,81 +241,69 @@ async function main(): Promise { ].join('\n'); const mixedSlices = parseRoadmapSlices(mixed); // The ## Slices section takes priority — prose headers outside it aren't picked up - assertEq(mixedSlices.length, 1, 'mixed: only 1 slice from ## Slices section'); - assertEq(mixedSlices[0].id, 'S01', 'mixed: S01 from checkbox'); - } + assert.deepStrictEqual(mixedSlices.length, 1, 'mixed: only 1 slice from ## Slices section'); + assert.deepStrictEqual(mixedSlices[0].id, 'S01', 'mixed: S01 from checkbox'); +}); // ═══════════════════════════════════════════════════════════════════════ // N. Dependency range expansion // ═══════════════════════════════════════════════════════════════════════ - - console.log('\n=== N. Dependency range expansion ==='); - - { - assertEq( +test('N. Dependency range expansion', () => { + assert.deepStrictEqual( expandDependencies(['S01-S04']), ['S01', 'S02', 'S03', 'S04'], 'S01-S04 → 4 individual deps', ); - assertEq( + assert.deepStrictEqual( expandDependencies(['S01..S03']), ['S01', 'S02', 'S03'], 'S01..S03 → 3 individual deps', ); - assertEq( + assert.deepStrictEqual( expandDependencies(['S01']), ['S01'], 'single dep passes through', ); - assertEq( + assert.deepStrictEqual( expandDependencies(['S01', 'S03-S05']), ['S01', 'S03', 'S04', 'S05'], 'mixed single + range', ); - assertEq( + assert.deepStrictEqual( expandDependencies(['']), [], 'empty string filtered out', ); - } +}); // ═══════════════════════════════════════════════════════════════════════ // O. No-separator colon-less: "S01:Title" (no space after colon) // ═══════════════════════════════════════════════════════════════════════ - - console.log('\n=== O. No space after colon ==='); - - { +test('O. No space after colon', () => { const content = '## S01:Foundation\n\n## S02:Features\n'; const slices = parseRoadmapSlices(content); // The regex uses [:\s.—–-]* which allows colon with no space - assertEq(slices.length, 2, 'no-space-colon: 2 slices'); - } + assert.deepStrictEqual(slices.length, 2, 'no-space-colon: 2 slices'); +}); // ═══════════════════════════════════════════════════════════════════════ // P. Three-digit padded IDs // ═══════════════════════════════════════════════════════════════════════ - - console.log('\n=== P. Three-digit padded IDs ==='); - - { +test('P. Three-digit padded IDs', () => { const content = '## S001: Foundation\n\n## S002: Features\n'; const slices = parseRoadmapSlices(content); - assertEq(slices.length, 2, 'three-digit: 2 slices'); - assertEq(slices[0].id, 'S001', 'three-digit: S001'); - } + assert.deepStrictEqual(slices.length, 2, 'three-digit: 2 slices'); + assert.deepStrictEqual(slices[0].id, 'S001', 'three-digit: S001'); +}); // ═══════════════════════════════════════════════════════════════════════ // Q. Regression #1736: Table format under ## Slices // ═══════════════════════════════════════════════════════════════════════ - - console.log('\n=== Q. #1736: Table format under ## Slices ==='); - - { +test('Q. #1736: Table format under ## Slices', () => { const content = [ '# M001: Test', '', @@ -371,22 +319,19 @@ async function main(): Promise { ].join('\n'); const slices = parseRoadmapSlices(content); - assertEq(slices.length, 3, '#1736 table: 3 slices'); - assertEq(slices[0].id, 'S01', '#1736 table: S01 id'); - assertEq(slices[0].title, 'Setup Foundation', '#1736 table: S01 title'); - assertEq(slices[0].done, true, '#1736 table: S01 done'); - assertEq(slices[0].risk, 'low', '#1736 table: S01 risk'); - assertEq(slices[1].done, false, '#1736 table: S02 not done'); - assertEq(slices[2].done, true, '#1736 table: S03 done'); - } + assert.deepStrictEqual(slices.length, 3, '#1736 table: 3 slices'); + assert.deepStrictEqual(slices[0].id, 'S01', '#1736 table: S01 id'); + assert.deepStrictEqual(slices[0].title, 'Setup Foundation', '#1736 table: S01 title'); + assert.deepStrictEqual(slices[0].done, true, '#1736 table: S01 done'); + assert.deepStrictEqual(slices[0].risk, 'low', '#1736 table: S01 risk'); + assert.deepStrictEqual(slices[1].done, false, '#1736 table: S02 not done'); + assert.deepStrictEqual(slices[2].done, true, '#1736 table: S03 done'); +}); // ═══════════════════════════════════════════════════════════════════════ // R. Regression #1736: Table format under ## Slice Overview // ═══════════════════════════════════════════════════════════════════════ - - console.log('\n=== R. #1736: Table format under ## Slice Overview ==='); - - { +test('R. #1736: Table format under ## Slice Overview', () => { const content = [ '# M002: Overview Heading', '', @@ -400,18 +345,15 @@ async function main(): Promise { ].join('\n'); const slices = parseRoadmapSlices(content); - assertEq(slices.length, 2, '#1736 overview: 2 slices'); - assertEq(slices[0].done, true, '#1736 overview: S01 done'); - assertEq(slices[1].done, false, '#1736 overview: S02 not done'); - } + assert.deepStrictEqual(slices.length, 2, '#1736 overview: 2 slices'); + assert.deepStrictEqual(slices[0].done, true, '#1736 overview: S01 done'); + assert.deepStrictEqual(slices[1].done, false, '#1736 overview: S02 not done'); +}); // ═══════════════════════════════════════════════════════════════════════ // S. Regression #1736: Table with Done/Complete text status // ═══════════════════════════════════════════════════════════════════════ - - console.log('\n=== S. #1736: Table with text status ==='); - - { +test('S. #1736: Table with text status', () => { const content = [ '# M003: Status Text', '', @@ -426,19 +368,16 @@ async function main(): Promise { ].join('\n'); const slices = parseRoadmapSlices(content); - assertEq(slices.length, 3, '#1736 text status: 3 slices'); - assertTrue(slices[0].done, '#1736 text status: Done = true'); - assertTrue(!slices[1].done, '#1736 text status: Pending = false'); - assertTrue(slices[2].done, '#1736 text status: Completed = true'); - } + assert.deepStrictEqual(slices.length, 3, '#1736 text status: 3 slices'); + assert.ok(slices[0].done, '#1736 text status: Done = true'); + assert.ok(!slices[1].done, '#1736 text status: Pending = false'); + assert.ok(slices[2].done, '#1736 text status: Completed = true'); +}); // ═══════════════════════════════════════════════════════════════════════ // T. Regression #1736: Checkbox format still works after table support // ═══════════════════════════════════════════════════════════════════════ - - console.log('\n=== T. #1736: Checkbox format unchanged ==='); - - { +test('T. #1736: Checkbox format unchanged', () => { const content = [ '# M005: Unchanged', '', @@ -451,16 +390,10 @@ async function main(): Promise { ].join('\n'); const slices = parseRoadmapSlices(content); - assertEq(slices.length, 2, '#1736 checkbox compat: 2 slices'); - assertEq(slices[0].done, true, '#1736 checkbox compat: S01 done'); - assertEq(slices[0].demo, 'demo works.', '#1736 checkbox compat: demo'); - assertEq(slices[1].done, false, '#1736 checkbox compat: S02 not done'); - } - - report(); -} - -main().catch((error) => { - console.error(error); - process.exit(1); + assert.deepStrictEqual(slices.length, 2, '#1736 checkbox compat: 2 slices'); + assert.deepStrictEqual(slices[0].done, true, '#1736 checkbox compat: S01 done'); + assert.deepStrictEqual(slices[0].demo, 'demo works.', '#1736 checkbox compat: demo'); + assert.deepStrictEqual(slices[1].done, false, '#1736 checkbox compat: S02 not done'); +}); + }); diff --git a/src/resources/extensions/gsd/tests/rule-registry.test.ts b/src/resources/extensions/gsd/tests/rule-registry.test.ts index 027f46fe6..b10455d5c 100644 --- a/src/resources/extensions/gsd/tests/rule-registry.test.ts +++ b/src/resources/extensions/gsd/tests/rule-registry.test.ts @@ -3,8 +3,8 @@ // Tests the RuleRegistry class, UnifiedRule types, singleton accessors, // and evaluation methods using mock rules. +import assert from 'node:assert/strict'; import { test, describe, beforeEach } from "node:test"; -import { createTestContext } from "./test-helpers.ts"; import { RuleRegistry, getRegistry, @@ -64,9 +64,7 @@ function makeContext(phase: string): DispatchContext { // ─── Tests ──────────────────────────────────────────────────────────────── describe("RuleRegistry", () => { - const { assertEq, assertTrue } = createTestContext(); - - beforeEach(() => { + beforeEach(() => { resetRegistry(); }); @@ -81,10 +79,10 @@ describe("RuleRegistry", () => { // At minimum, dispatch rules are returned (hook rules depend on prefs) const dispatchRules = listed.filter(r => r.when === "dispatch"); - assertEq(dispatchRules.length, 3, "listRules returns 3 dispatch rules"); - assertEq(dispatchRules[0].name, "rule-a", "first rule name is rule-a"); - assertEq(dispatchRules[1].name, "rule-b", "second rule name is rule-b"); - assertEq(dispatchRules[2].name, "rule-c", "third rule name is rule-c"); + assert.deepStrictEqual(dispatchRules.length, 3, "listRules returns 3 dispatch rules"); + assert.deepStrictEqual(dispatchRules[0].name, "rule-a", "first rule name is rule-a"); + assert.deepStrictEqual(dispatchRules[1].name, "rule-b", "second rule name is rule-b"); + assert.deepStrictEqual(dispatchRules[2].name, "rule-c", "third rule name is rule-c"); }); test("listRules returns correct fields on each rule", () => { @@ -95,12 +93,12 @@ describe("RuleRegistry", () => { const listed = registry.listRules(); const rule = listed.find(r => r.name === "check-fields")!; - assertTrue(rule !== undefined, "rule found by name"); - assertEq(rule.when, "dispatch", "when field is dispatch"); - assertEq(rule.evaluation, "first-match", "evaluation is first-match"); - assertTrue(typeof rule.where === "function", "where is a function"); - assertTrue(typeof rule.then === "function", "then is a function"); - assertEq(rule.description, "Mock rule for planning", "description is set"); + assert.ok(rule !== undefined, "rule found by name"); + assert.deepStrictEqual(rule.when, "dispatch", "when field is dispatch"); + assert.deepStrictEqual(rule.evaluation, "first-match", "evaluation is first-match"); + assert.ok(typeof rule.where === "function", "where is a function"); + assert.ok(typeof rule.then === "function", "then is a function"); + assert.deepStrictEqual(rule.description, "Mock rule for planning", "description is set"); }); test("evaluateDispatch returns first matching rule", async () => { @@ -113,10 +111,10 @@ describe("RuleRegistry", () => { const ctx = makeContext("executing"); const result = await registry.evaluateDispatch(ctx); - assertEq(result.action, "dispatch", "result is a dispatch action"); + assert.deepStrictEqual(result.action, "dispatch", "result is a dispatch action"); if (result.action === "dispatch") { - assertEq(result.unitType, "test-executing", "matched the executing rule"); - assertEq(result.prompt, "Prompt for executing", "prompt from matched rule"); + assert.deepStrictEqual(result.unitType, "test-executing", "matched the executing rule"); + assert.deepStrictEqual(result.prompt, "Prompt for executing", "prompt from matched rule"); } }); @@ -128,9 +126,9 @@ describe("RuleRegistry", () => { const ctx = makeContext("blocked"); const result = await registry.evaluateDispatch(ctx); - assertEq(result.action, "stop", "result is a stop action"); + assert.deepStrictEqual(result.action, "stop", "result is a stop action"); if (result.action === "stop") { - assertTrue(result.reason.includes("blocked"), "stop reason mentions phase"); + assert.ok(result.reason.includes("blocked"), "stop reason mentions phase"); } }); @@ -159,9 +157,9 @@ describe("RuleRegistry", () => { const ctx = makeContext("planning"); const result = await registry.evaluateDispatch(ctx); - assertEq(result.action, "dispatch", "async dispatch resolved"); + assert.deepStrictEqual(result.action, "dispatch", "async dispatch resolved"); if (result.action === "dispatch") { - assertEq(result.unitType, "async-test", "async rule matched"); + assert.deepStrictEqual(result.unitType, "async-test", "async rule matched"); } }); @@ -188,11 +186,11 @@ describe("RuleRegistry", () => { // Reset registry.resetState(); - assertEq(registry.getActiveHook(), null, "activeHook cleared"); - assertEq(registry.hookQueue.length, 0, "hookQueue cleared"); - assertEq(registry.cycleCounts.size, 0, "cycleCounts cleared"); - assertEq(registry.isRetryPending(), false, "retryPending cleared"); - assertEq(registry.consumeRetryTrigger(), null, "retryTrigger cleared"); + assert.deepStrictEqual(registry.getActiveHook(), null, "activeHook cleared"); + assert.deepStrictEqual(registry.hookQueue.length, 0, "hookQueue cleared"); + assert.deepStrictEqual(registry.cycleCounts.size, 0, "cycleCounts cleared"); + assert.deepStrictEqual(registry.isRetryPending(), false, "retryPending cleared"); + assert.deepStrictEqual(registry.consumeRetryTrigger(), null, "retryTrigger cleared"); }); test("singleton getRegistry throws when not initialized", () => { @@ -201,9 +199,9 @@ describe("RuleRegistry", () => { getRegistry(); } catch (e: any) { threw = true; - assertTrue(e.message.includes("not initialized"), "error mentions not initialized"); + assert.ok(e.message.includes("not initialized"), "error mentions not initialized"); } - assertTrue(threw, "getRegistry threw"); + assert.ok(threw, "getRegistry threw"); }); test("setRegistry / getRegistry round-trips", () => { @@ -211,20 +209,20 @@ describe("RuleRegistry", () => { setRegistry(registry); const retrieved = getRegistry(); - assertEq(retrieved, registry, "getRegistry returns the same instance"); + assert.deepStrictEqual(retrieved, registry, "getRegistry returns the same instance"); const listed = retrieved.listRules().filter(r => r.when === "dispatch"); - assertEq(listed.length, 1, "singleton has 1 dispatch rule"); - assertEq(listed[0].name, "singleton-test", "rule name matches"); + assert.deepStrictEqual(listed.length, 1, "singleton has 1 dispatch rule"); + assert.deepStrictEqual(listed[0].name, "singleton-test", "rule name matches"); }); test("initRegistry creates and sets singleton", () => { const rules = [mockDispatchRule("init-test", "executing")]; const registry = initRegistry(rules); - assertEq(getRegistry(), registry, "initRegistry sets the singleton"); + assert.deepStrictEqual(getRegistry(), registry, "initRegistry sets the singleton"); const listed = getRegistry().listRules().filter(r => r.when === "dispatch"); - assertEq(listed.length, 1, "singleton has the rule"); + assert.deepStrictEqual(listed.length, 1, "singleton has the rule"); }); test("evaluateDispatch respects rule order (first match wins)", async () => { @@ -258,9 +256,9 @@ describe("RuleRegistry", () => { const ctx = makeContext("planning"); const result = await registry.evaluateDispatch(ctx); - assertEq(result.action, "dispatch", "dispatch action returned"); + assert.deepStrictEqual(result.action, "dispatch", "dispatch action returned"); if (result.action === "dispatch") { - assertEq(result.unitType, "first-wins", "first rule won over second"); + assert.deepStrictEqual(result.unitType, "first-wins", "first rule won over second"); } }); @@ -268,18 +266,18 @@ describe("RuleRegistry", () => { test("convertDispatchRules produces correct count of UnifiedRule objects", () => { const converted = convertDispatchRules(DISPATCH_RULES); - assertEq(converted.length, DISPATCH_RULES.length, `convertDispatchRules produces ${DISPATCH_RULES.length} rules`); + assert.deepStrictEqual(converted.length, DISPATCH_RULES.length, `convertDispatchRules produces ${DISPATCH_RULES.length} rules`); }); test("each converted rule has correct when, evaluation, and original name", () => { const converted = convertDispatchRules(DISPATCH_RULES); for (let i = 0; i < converted.length; i++) { const rule = converted[i]; - assertEq(rule.when, "dispatch", `rule ${i} has when:"dispatch"`); - assertEq(rule.evaluation, "first-match", `rule ${i} has evaluation:"first-match"`); - assertEq(rule.name, DISPATCH_RULES[i].name, `rule ${i} preserves name "${DISPATCH_RULES[i].name}"`); - assertTrue(typeof rule.where === "function", `rule ${i} has a where function`); - assertTrue(typeof rule.then === "function", `rule ${i} has a then function`); + assert.deepStrictEqual(rule.when, "dispatch", `rule ${i} has when:"dispatch"`); + assert.deepStrictEqual(rule.evaluation, "first-match", `rule ${i} has evaluation:"first-match"`); + assert.deepStrictEqual(rule.name, DISPATCH_RULES[i].name, `rule ${i} preserves name "${DISPATCH_RULES[i].name}"`); + assert.ok(typeof rule.where === "function", `rule ${i} has a where function`); + assert.ok(typeof rule.then === "function", `rule ${i} has a then function`); } }); @@ -287,7 +285,7 @@ describe("RuleRegistry", () => { const converted = convertDispatchRules(DISPATCH_RULES); const registry = new RuleRegistry(converted); const listed = registry.listRules().filter(r => r.when === "dispatch"); - assertEq(listed.length, DISPATCH_RULES.length, `listRules returns ${DISPATCH_RULES.length} dispatch rules`); + assert.deepStrictEqual(listed.length, DISPATCH_RULES.length, `listRules returns ${DISPATCH_RULES.length} dispatch rules`); }); test("rule names from listRules match getDispatchRuleNames in exact order", () => { @@ -298,9 +296,9 @@ describe("RuleRegistry", () => { .map(r => r.name); const originalNames = getDispatchRuleNames(); - assertEq(listedNames.length, originalNames.length, "same number of names"); + assert.deepStrictEqual(listedNames.length, originalNames.length, "same number of names"); for (let i = 0; i < originalNames.length; i++) { - assertEq(listedNames[i], originalNames[i], `name at index ${i} matches: "${originalNames[i]}"`); + assert.deepStrictEqual(listedNames[i], originalNames[i], `name at index ${i} matches: "${originalNames[i]}"`); } }); @@ -309,18 +307,18 @@ describe("RuleRegistry", () => { test("getOrCreateRegistry lazily creates a registry with empty dispatch rules", () => { // After resetRegistry(), getRegistry() would throw. getOrCreateRegistry() should not. const registry = getOrCreateRegistry(); - assertTrue(registry instanceof RuleRegistry, "returns a RuleRegistry instance"); + assert.ok(registry instanceof RuleRegistry, "returns a RuleRegistry instance"); const dispatchRules = registry.listRules().filter(r => r.when === "dispatch"); - assertEq(dispatchRules.length, 0, "lazily-created registry has 0 dispatch rules"); + assert.deepStrictEqual(dispatchRules.length, 0, "lazily-created registry has 0 dispatch rules"); }); test("getOrCreateRegistry returns existing registry when initialized", () => { const rules = [mockDispatchRule("explicit-init", "planning")]; const explicit = initRegistry(rules); const lazy = getOrCreateRegistry(); - assertEq(lazy, explicit, "getOrCreateRegistry returns the same singleton as initRegistry"); + assert.deepStrictEqual(lazy, explicit, "getOrCreateRegistry returns the same singleton as initRegistry"); const dispatchRules = lazy.listRules().filter(r => r.when === "dispatch"); - assertEq(dispatchRules.length, 1, "singleton has the explicitly initialized dispatch rule"); + assert.deepStrictEqual(dispatchRules.length, 1, "singleton has the explicitly initialized dispatch rule"); }); // ── Hook-derived rules in listRules ──────────────────────────────── @@ -333,9 +331,9 @@ describe("RuleRegistry", () => { const preDispatchRules = allRules.filter(r => r.when === "pre-dispatch"); // No preferences file = no hooks - assertEq(postUnitRules.length, 0, "no post-unit rules when no hooks configured"); - assertEq(preDispatchRules.length, 0, "no pre-dispatch rules when no hooks configured"); - assertEq(allRules.length, DISPATCH_RULES.length, "total rules equals dispatch rules only"); + assert.deepStrictEqual(postUnitRules.length, 0, "no post-unit rules when no hooks configured"); + assert.deepStrictEqual(preDispatchRules.length, 0, "no pre-dispatch rules when no hooks configured"); + assert.deepStrictEqual(allRules.length, DISPATCH_RULES.length, "total rules equals dispatch rules only"); }); test("listRules dispatch rules appear first, hooks after", () => { @@ -345,8 +343,8 @@ describe("RuleRegistry", () => { // Verify dispatch rules come first (indices 0..N-1) for (let i = 0; i < converted.length; i++) { - assertEq(allRules[i].when, "dispatch", `rule at index ${i} is a dispatch rule`); - assertEq(allRules[i].name, converted[i].name, `dispatch rule at index ${i} has correct name`); + assert.deepStrictEqual(allRules[i].when, "dispatch", `rule at index ${i} is a dispatch rule`); + assert.deepStrictEqual(allRules[i].name, converted[i].name, `dispatch rule at index ${i} has correct name`); } }); @@ -355,34 +353,34 @@ describe("RuleRegistry", () => { test("evaluatePostUnit returns null for hook-on-hook prevention", () => { const registry = new RuleRegistry([]); const result = registry.evaluatePostUnit("hook/code-review", "M001/S01/T01", "/tmp/test"); - assertEq(result, null, "hook units don't trigger other hooks"); + assert.deepStrictEqual(result, null, "hook units don't trigger other hooks"); }); test("evaluatePostUnit returns null for triage-captures", () => { const registry = new RuleRegistry([]); const result = registry.evaluatePostUnit("triage-captures", "M001/S01/T01", "/tmp/test"); - assertEq(result, null, "triage-captures skipped"); + assert.deepStrictEqual(result, null, "triage-captures skipped"); }); test("evaluatePostUnit returns null for quick-task", () => { const registry = new RuleRegistry([]); const result = registry.evaluatePostUnit("quick-task", "M001/S01/T01", "/tmp/test"); - assertEq(result, null, "quick-task skipped"); + assert.deepStrictEqual(result, null, "quick-task skipped"); }); test("evaluatePreDispatch bypasses hook units", () => { const registry = new RuleRegistry([]); const result = registry.evaluatePreDispatch("hook/review", "M001/S01/T01", "prompt", "/tmp/test"); - assertEq(result.action, "proceed", "hook units always proceed"); - assertEq(result.prompt, "prompt", "prompt unchanged"); - assertEq(result.firedHooks.length, 0, "no hooks fired"); + assert.deepStrictEqual(result.action, "proceed", "hook units always proceed"); + assert.deepStrictEqual(result.prompt, "prompt", "prompt unchanged"); + assert.deepStrictEqual(result.firedHooks.length, 0, "no hooks fired"); }); test("evaluatePreDispatch proceeds with empty hooks", () => { const registry = new RuleRegistry([]); const result = registry.evaluatePreDispatch("execute-task", "M001/S01/T01", "original prompt", "/tmp/test"); - assertEq(result.action, "proceed", "proceeds when no hooks"); - assertEq(result.prompt, "original prompt", "prompt unchanged"); + assert.deepStrictEqual(result.action, "proceed", "proceeds when no hooks"); + assert.deepStrictEqual(result.prompt, "original prompt", "prompt unchanged"); }); // ── matchedRule provenance (S02 journal support) ─────────────────── @@ -395,8 +393,8 @@ describe("RuleRegistry", () => { const ctx = makeContext("planning"); const result = await registry.evaluateDispatch(ctx); - assertEq(result.action, "dispatch", "result is a dispatch action"); - assertEq(result.matchedRule, "my-planning-rule", "matchedRule is the rule name"); + assert.deepStrictEqual(result.action, "dispatch", "result is a dispatch action"); + assert.deepStrictEqual(result.matchedRule, "my-planning-rule", "matchedRule is the rule name"); }); test("evaluateDispatch result includes matchedRule '' on fallback stop", async () => { @@ -407,7 +405,7 @@ describe("RuleRegistry", () => { const ctx = makeContext("some-unknown-phase"); const result = await registry.evaluateDispatch(ctx); - assertEq(result.action, "stop", "result is a stop action"); - assertEq(result.matchedRule, "", "matchedRule is '' on fallback"); + assert.deepStrictEqual(result.action, "stop", "result is a stop action"); + assert.deepStrictEqual(result.matchedRule, "", "matchedRule is '' on fallback"); }); }); diff --git a/src/resources/extensions/gsd/tests/run-uat.test.ts b/src/resources/extensions/gsd/tests/run-uat.test.ts index 9ba481465..e7c058fee 100644 --- a/src/resources/extensions/gsd/tests/run-uat.test.ts +++ b/src/resources/extensions/gsd/tests/run-uat.test.ts @@ -1,14 +1,5 @@ -// Tests for extractUatType — the core UAT classification primitive — plus -// prompt template loading and dispatch precondition assertions (via -// resolveSliceFile / extractUatType on real fixture files). -// -// Sections: -// (a)–(j) extractUatType classification (17 assertions from T01) -// (k) run-uat prompt template loading and content integrity (8 assertions) -// (l) dispatch precondition assertions via resolveSliceFile (4 assertions) -// (m) non-artifact UAT skip: human-experience UATs are not dispatched (1 assertion) -// (n) stale replay guard: existing UAT-RESULT never re-dispatches (1 assertion) - +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; @@ -17,11 +8,6 @@ import { fileURLToPath } from 'node:url'; import { extractUatType } from '../files.ts'; import { resolveSliceFile } from '../paths.ts'; import { checkNeedsRunUat } from '../auto-prompts.ts'; -import { createTestContext } from './test-helpers.ts'; - -// ─── Worktree-aware prompt loader ────────────────────────────────────────── -// Resolves prompts relative to this test file so the worktree copy is used -// instead of the main checkout copy (matches complete-milestone.test.ts pattern). const __dirname = dirname(fileURLToPath(import.meta.url)); const worktreePromptsDir = join(__dirname, '..', 'prompts'); @@ -39,10 +25,6 @@ function loadPromptFromWorktree(name: string, vars: Record = {}) return content.trim(); } - -const { assertEq, assertTrue, report } = createTestContext(); -// ─── Fixture helpers ─────────────────────────────────────────────────────── - function createFixtureBase(): string { const base = mkdtempSync(join(tmpdir(), 'gsd-run-uat-test-')); mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true }); @@ -69,154 +51,129 @@ function makeUatContent(mode: string): string { return `# UAT File\n\n## UAT Type\n\n- UAT mode: ${mode}\n- Some other bullet: value\n`; } -// ═══════════════════════════════════════════════════════════════════════════ -// Tests -// ═══════════════════════════════════════════════════════════════════════════ - -async function main(): Promise { - - // ─── (a) artifact-driven ────────────────────────────────────────────────── - console.log('\n── (a) artifact-driven'); - - assertEq( +describe('run-uat', () => { +test('(a) artifact-driven', () => { + assert.deepStrictEqual( extractUatType(makeUatContent('artifact-driven')), 'artifact-driven', 'plain artifact-driven → artifact-driven', ); - - assertEq( + assert.deepStrictEqual( extractUatType('## UAT Type\n\n- UAT mode: artifact-driven\n'), 'artifact-driven', 'minimal content, artifact-driven', ); +}); - // ─── (b) live-runtime ───────────────────────────────────────────────────── - console.log('\n── (b) live-runtime'); - - assertEq( +test('(b) live-runtime', () => { + assert.deepStrictEqual( extractUatType(makeUatContent('live-runtime')), 'live-runtime', 'plain live-runtime → live-runtime', ); +}); - // ─── (c) human-experience ───────────────────────────────────────────────── - console.log('\n── (c) human-experience'); - - assertEq( +test('(c) human-experience', () => { + assert.deepStrictEqual( extractUatType(makeUatContent('human-experience')), 'human-experience', 'plain human-experience → human-experience', ); +}); - // ─── (d) mixed standalone ───────────────────────────────────────────────── - console.log('\n── (d) mixed standalone'); - - assertEq( +test('(d) mixed standalone', () => { + assert.deepStrictEqual( extractUatType(makeUatContent('mixed')), 'mixed', 'plain mixed → mixed', ); +}); - // ─── (e) mixed with parenthetical ───────────────────────────────────────── - console.log('\n── (e) mixed parenthetical'); - - assertEq( +test('(e) mixed parenthetical', () => { + assert.deepStrictEqual( extractUatType(makeUatContent('mixed (artifact-driven + live-runtime)')), 'mixed', 'mixed (artifact-driven + live-runtime) → mixed (leading keyword only)', ); - - assertEq( + assert.deepStrictEqual( extractUatType(makeUatContent('mixed (some other description)')), 'mixed', 'mixed with arbitrary parenthetical → mixed', ); +}); - // ─── (f) missing ## UAT Type section ────────────────────────────────────── - console.log('\n── (f) missing UAT Type section'); - - assertEq( +test('(f) missing UAT Type section', () => { + assert.deepStrictEqual( extractUatType('# UAT File\n\n## Overview\n\nSome content.\n'), undefined, 'no ## UAT Type section → undefined', ); - - assertEq( + assert.deepStrictEqual( extractUatType(''), undefined, 'empty content → undefined', ); +}); - // ─── (g) ## UAT Type present but no UAT mode: bullet ───────────────────── - console.log('\n── (g) UAT Type section present, no UAT mode: bullet'); - - assertEq( +test('(g) UAT Type section present, no UAT mode: bullet', () => { + assert.deepStrictEqual( extractUatType('## UAT Type\n\n- Some other bullet: value\n- Another bullet\n'), undefined, 'section present but no UAT mode: bullet → undefined', ); - - assertEq( + assert.deepStrictEqual( extractUatType('## UAT Type\n\n'), undefined, 'section present but empty → undefined', ); +}); - // ─── (h) unknown keyword ────────────────────────────────────────────────── - console.log('\n── (h) unknown keyword'); - - assertEq( +test('(h) unknown keyword', () => { + assert.deepStrictEqual( extractUatType(makeUatContent('automated')), undefined, 'unknown keyword automated → undefined', ); - - assertEq( + assert.deepStrictEqual( extractUatType(makeUatContent('fully-automated')), undefined, 'unknown keyword fully-automated → undefined', ); +}); - // ─── (i) extra whitespace around value ──────────────────────────────────── - console.log('\n── (i) extra whitespace'); - - assertEq( +test('(i) extra whitespace', () => { + assert.deepStrictEqual( extractUatType('## UAT Type\n\n- UAT mode: artifact-driven \n'), 'artifact-driven', 'leading/trailing whitespace around value → still classified correctly', ); - - assertEq( + assert.deepStrictEqual( extractUatType('## UAT Type\n\n- UAT mode: mixed (artifact-driven + live-runtime) \n'), 'mixed', 'whitespace around mixed parenthetical → mixed', ); +}); - // ─── (j) case sensitivity ───────────────────────────────────────────────── - console.log('\n── (j) case sensitivity'); - - assertEq( +test('(j) case sensitivity', () => { + assert.deepStrictEqual( extractUatType(makeUatContent('Artifact-Driven')), 'artifact-driven', 'Artifact-Driven (title case) → artifact-driven (function lowercases before matching)', ); - - assertEq( + assert.deepStrictEqual( extractUatType(makeUatContent('MIXED')), 'mixed', 'MIXED (upper case) → mixed (function lowercases before matching)', ); +}); - // ─── (k) prompt template loading and content integrity ──────────────────── - console.log('\n── (k) run-uat prompt template'); - +test('(k) run-uat prompt template', () => { const milestoneId = 'M001'; const sliceId = 'S01'; const uatPath = '.gsd/milestones/M001/slices/S01/S01-UAT.md'; const uatResultPath = '.gsd/milestones/M001/slices/S01/S01-UAT-RESULT.md'; const uatType = 'live-runtime'; const inlinedContext = ''; - let promptResult: string | undefined; let promptThrew = false; try { @@ -232,71 +189,66 @@ async function main(): Promise { } catch { promptThrew = true; } - - assertTrue(!promptThrew, 'loadPromptFromWorktree("run-uat", vars) does not throw'); - assertTrue( + assert.ok(!promptThrew, 'loadPromptFromWorktree("run-uat", vars) does not throw'); + assert.ok( typeof promptResult === 'string' && promptResult.length > 0, 'run-uat prompt result is a non-empty string', ); - assertTrue( + assert.ok( promptResult?.includes(milestoneId) ?? false, `prompt contains milestoneId value "${milestoneId}" after substitution`, ); - assertTrue( + assert.ok( promptResult?.includes(sliceId) ?? false, `prompt contains sliceId value "${sliceId}" after substitution`, ); - assertTrue( + assert.ok( promptResult?.includes(uatResultPath) ?? false, `prompt contains uatResultPath value after substitution`, ); - assertTrue( + assert.ok( promptResult?.includes(`Detected UAT mode:** \`${uatType}\``) ?? false, `prompt contains detected dynamic uatType value "${uatType}" after substitution`, ); - assertTrue( + assert.ok( promptResult?.includes(`uatType: ${uatType}`) ?? false, `prompt contains dynamic uatType frontmatter value "${uatType}" after substitution`, ); - assertTrue( + assert.ok( !/\{\{[^}]+\}\}/.test(promptResult ?? ''), 'no unreplaced {{...}} tokens remain after variable substitution', ); - assertTrue( + assert.ok( /browser|runtime|execute|run/i.test(promptResult ?? ''), 'prompt contains runtime execution language (browser/runtime/execute/run)', ); - assertTrue( + assert.ok( !/surfaced for human review/i.test(promptResult ?? ''), 'prompt does not contain "surfaced for human review" (non-artifact UATs are skipped, not dispatched)', ); +}); - // ─── (l) dispatch precondition assertions via resolveSliceFile ──────────── - console.log('\n── (l) dispatch preconditions via resolveSliceFile'); - - // State A: UAT file exists, UAT-RESULT file does NOT — triggers dispatch - { +test('(l) dispatch preconditions via resolveSliceFile', () => { const base = createFixtureBase(); const uatContent = makeUatContent('artifact-driven'); try { writeSliceFile(base, 'M001', 'S01', 'UAT', uatContent); const uatFilePath = resolveSliceFile(base, 'M001', 'S01', 'UAT'); - assertTrue( + assert.ok( uatFilePath !== null, 'resolveSliceFile(..., "UAT") returns non-null when UAT file exists (dispatch trigger state)', ); const uatResultFilePath = resolveSliceFile(base, 'M001', 'S01', 'UAT-RESULT'); - assertEq( + assert.deepStrictEqual( uatResultFilePath, null, 'resolveSliceFile(..., "UAT-RESULT") returns null when result file missing (dispatch trigger state)', ); - // End-to-end: file content → parse → classify const rawContent = readFileSync(uatFilePath!, 'utf-8'); - assertEq( + assert.deepStrictEqual( extractUatType(rawContent), 'artifact-driven', 'extractUatType on fixture UAT file returns expected type (end-to-end data flow)', @@ -304,29 +256,25 @@ async function main(): Promise { } finally { cleanup(base); } - } +}); - // State B: UAT-RESULT file exists — dispatch is skipped (idempotent) - { +test('test block at line 307', () => { const base = createFixtureBase(); try { writeSliceFile(base, 'M001', 'S01', 'UAT', makeUatContent('artifact-driven')); writeSliceFile(base, 'M001', 'S01', 'UAT-RESULT', '# UAT Result\n\nverdict: PASS\n'); const uatResultFilePath = resolveSliceFile(base, 'M001', 'S01', 'UAT-RESULT'); - assertTrue( + assert.ok( uatResultFilePath !== null, 'resolveSliceFile(..., "UAT-RESULT") returns non-null when result file exists (idempotent skip state)', ); } finally { cleanup(base); } - } +}); - // ─── (m) non-artifact UATs are skipped (not dispatched) ───────────────── - console.log('\n── (m) non-artifact UAT skip'); - - { +test('(m) non-artifact UAT skip', async () => { const base = createFixtureBase(); try { const roadmapDir = join(base, '.gsd', 'milestones', 'M001'); @@ -346,7 +294,6 @@ async function main(): Promise { ].join('\n'), ); - // human-experience UAT still dispatches, but auto-mode later pauses for manual review writeSliceFile(base, 'M001', 'S01', 'UAT', makeUatContent('human-experience')); const state = { @@ -361,7 +308,7 @@ async function main(): Promise { } as const; const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any); - assertEq( + assert.deepStrictEqual( result, { sliceId: 'S01', uatType: 'human-experience' }, 'human-experience UAT dispatches so auto-mode can pause for manual review', @@ -369,12 +316,9 @@ async function main(): Promise { } finally { cleanup(base); } - } +}); - // ─── (n) existing UAT-RESULT never re-dispatches ────────────────────── - console.log('\n── (n) stale replay guard'); - - { +test('(n) stale replay guard', async () => { const base = createFixtureBase(); try { const roadmapDir = join(base, '.gsd', 'milestones', 'M001'); @@ -409,7 +353,7 @@ async function main(): Promise { } as const; const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any); - assertEq( + assert.deepStrictEqual( result, null, 'existing UAT-RESULT with FAIL verdict does not re-dispatch; verdict gate owns blocking', @@ -417,12 +361,6 @@ async function main(): Promise { } finally { cleanup(base); } - } - - report(); -} - -main().catch((error) => { - console.error(error); - process.exit(1); +}); + });