From 2223298f767ad5ba01489fa86cb52b28d205d629 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 24 Mar 2026 23:31:29 -0400 Subject: [PATCH] refactor(test): replace try/finally with t.after() in gsd/tests (a-d) (#2395) --- .../all-milestones-complete-merge.test.ts | 198 ++-- .../gsd/tests/auto-lock-creation.test.ts | 28 +- .../auto-paused-session-validation.test.ts | 100 +- .../gsd/tests/auto-preflight.test.ts | 24 +- .../gsd/tests/auto-recovery.test.ts | 952 ++++++++---------- .../gsd/tests/auto-secrets-gate.test.ts | 140 ++- .../extensions/gsd/tests/captures.test.ts | 306 +++--- .../gsd/tests/claude-import-tui.test.ts | 65 +- .../gsd/tests/collect-from-manifest.test.ts | 288 +++--- .../tests/commands-inspect-open-db.test.ts | 56 +- .../gsd/tests/commands-logs.test.ts | 162 +-- .../gsd/tests/continue-here.test.ts | 116 ++- .../gsd/tests/crash-recovery.test.ts | 62 +- .../gsd/tests/definition-loader.test.ts | 156 ++- .../extensions/gsd/tests/detection.test.ts | 508 +++++----- .../gsd/tests/dev-engine-wrapper.test.ts | 62 +- .../gsd/tests/dispatch-guard.test.ts | 296 +++--- .../tests/dispatch-missing-task-plans.test.ts | 80 +- .../tests/dispatch-uat-last-completed.test.ts | 56 +- .../tests/doctor-completion-deferral.test.ts | 48 +- .../gsd/tests/doctor-delimiter-fix.test.ts | 64 +- .../gsd/tests/doctor-fixlevel.test.ts | 82 +- .../doctor-roadmap-summary-atomicity.test.ts | 96 +- 23 files changed, 1857 insertions(+), 2088 deletions(-) diff --git a/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts b/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts index 58cc118e0..61319f2a2 100644 --- a/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts +++ b/src/resources/extensions/gsd/tests/all-milestones-complete-merge.test.ts @@ -130,119 +130,119 @@ test("auto-loop 'all milestones complete' path merges before stopping (#962)", ( // ─── Integration: single milestone completes → merged to main ──────────────── -test("single milestone worktree is merged to main when all complete (#962)", () => { +test("single milestone worktree is merged to main when all complete (#962)", (t) => { const savedCwd = process.cwd(); let tempDir = ""; - try { - tempDir = createTempRepo(); - - // Set up a single milestone - createMilestoneArtifacts(tempDir, "M001"); - run("git add .", tempDir); - run('git commit -m "add milestone"', tempDir); - - // Create worktree and simulate work - const wt = createAutoWorktree(tempDir, "M001"); - assert.ok(isInAutoWorktree(tempDir), "should be in auto-worktree"); - - writeFileSync(join(wt, "feature.ts"), "export const feature = true;\n"); - run("git add .", wt); - run('git commit -m "feat(M001): add feature"', wt); - - // Simulate the fix: merge before stopping (what the "all complete" path now does) - const roadmapPath = join( - tempDir, - ".gsd", - "milestones", - "M001", - "M001-ROADMAP.md", - ); - const roadmapContent = readFileSync(roadmapPath, "utf-8"); - const mergeResult = mergeMilestoneToMain(tempDir, "M001", roadmapContent); - - // Verify work is on main - assert.ok( - existsSync(join(tempDir, "feature.ts")), - "feature.ts should be on main after merge", - ); - assert.equal(process.cwd(), tempDir, "cwd restored to project root"); - assert.ok(!isInAutoWorktree(tempDir), "no longer in auto-worktree"); - assert.equal(getAutoWorktreeOriginalBase(), null, "originalBase cleared"); - - // Verify milestone branch was cleaned up - const branches = run("git branch", tempDir); - assert.ok( - !branches.includes("milestone/M001"), - "milestone branch should be deleted", - ); - - // Verify squash commit on main - const log = run("git log --oneline -3", tempDir); - assert.ok( - log.includes("M001"), - "squash commit on main should reference M001", - ); - - assert.ok(mergeResult.commitMessage.length > 0, "commit message returned"); - } finally { + t.after(() => { process.chdir(savedCwd); if (tempDir && existsSync(tempDir)) { - rmSync(tempDir, { recursive: true, force: true }); + rmSync(tempDir, { recursive: true, force: true }); } - } + }); + + tempDir = createTempRepo(); + + // Set up a single milestone + createMilestoneArtifacts(tempDir, "M001"); + run("git add .", tempDir); + run('git commit -m "add milestone"', tempDir); + + // Create worktree and simulate work + const wt = createAutoWorktree(tempDir, "M001"); + assert.ok(isInAutoWorktree(tempDir), "should be in auto-worktree"); + + writeFileSync(join(wt, "feature.ts"), "export const feature = true;\n"); + run("git add .", wt); + run('git commit -m "feat(M001): add feature"', wt); + + // Simulate the fix: merge before stopping (what the "all complete" path now does) + const roadmapPath = join( + tempDir, + ".gsd", + "milestones", + "M001", + "M001-ROADMAP.md", + ); + const roadmapContent = readFileSync(roadmapPath, "utf-8"); + const mergeResult = mergeMilestoneToMain(tempDir, "M001", roadmapContent); + + // Verify work is on main + assert.ok( + existsSync(join(tempDir, "feature.ts")), + "feature.ts should be on main after merge", + ); + assert.equal(process.cwd(), tempDir, "cwd restored to project root"); + assert.ok(!isInAutoWorktree(tempDir), "no longer in auto-worktree"); + assert.equal(getAutoWorktreeOriginalBase(), null, "originalBase cleared"); + + // Verify milestone branch was cleaned up + const branches = run("git branch", tempDir); + assert.ok( + !branches.includes("milestone/M001"), + "milestone branch should be deleted", + ); + + // Verify squash commit on main + const log = run("git log --oneline -3", tempDir); + assert.ok( + log.includes("M001"), + "squash commit on main should reference M001", + ); + + assert.ok(mergeResult.commitMessage.length > 0, "commit message returned"); }); // ─── Integration: last of multiple milestones completes → merged ───────────── -test("last milestone worktree is merged when it's the final one (#962)", () => { +test("last milestone worktree is merged when it's the final one (#962)", (t) => { const savedCwd = process.cwd(); let tempDir = ""; - try { - tempDir = createTempRepo(); - - // Set up two milestones - createMilestoneArtifacts(tempDir, "M001"); - createMilestoneArtifacts(tempDir, "M002"); - run("git add .", tempDir); - run('git commit -m "add milestones"', tempDir); - - // Complete M001 first (merge it) - const wt1 = createAutoWorktree(tempDir, "M001"); - writeFileSync(join(wt1, "m001-work.ts"), "export const m001 = true;\n"); - run("git add .", wt1); - run('git commit -m "feat(M001): m001 work"', wt1); - const roadmap1 = readFileSync( - join(tempDir, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), - "utf-8", - ); - mergeMilestoneToMain(tempDir, "M001", roadmap1); - - // Now complete M002 (the LAST milestone — this is the #962 scenario) - const wt2 = createAutoWorktree(tempDir, "M002"); - writeFileSync(join(wt2, "m002-work.ts"), "export const m002 = true;\n"); - run("git add .", wt2); - run('git commit -m "feat(M002): m002 work"', wt2); - const roadmap2 = readFileSync( - join(tempDir, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), - "utf-8", - ); - mergeMilestoneToMain(tempDir, "M002", roadmap2); - - // Both features should now be on main - assert.ok(existsSync(join(tempDir, "m001-work.ts")), "M001 work on main"); - assert.ok(existsSync(join(tempDir, "m002-work.ts")), "M002 work on main"); - assert.ok(!isInAutoWorktree(tempDir), "not in worktree after final merge"); - - // Both milestone branches should be cleaned up - const branches = run("git branch", tempDir); - assert.ok(!branches.includes("milestone/M001"), "M001 branch deleted"); - assert.ok(!branches.includes("milestone/M002"), "M002 branch deleted"); - } finally { + t.after(() => { process.chdir(savedCwd); if (tempDir && existsSync(tempDir)) { - rmSync(tempDir, { recursive: true, force: true }); + rmSync(tempDir, { recursive: true, force: true }); } - } + }); + + tempDir = createTempRepo(); + + // Set up two milestones + createMilestoneArtifacts(tempDir, "M001"); + createMilestoneArtifacts(tempDir, "M002"); + run("git add .", tempDir); + run('git commit -m "add milestones"', tempDir); + + // Complete M001 first (merge it) + const wt1 = createAutoWorktree(tempDir, "M001"); + writeFileSync(join(wt1, "m001-work.ts"), "export const m001 = true;\n"); + run("git add .", wt1); + run('git commit -m "feat(M001): m001 work"', wt1); + const roadmap1 = readFileSync( + join(tempDir, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), + "utf-8", + ); + mergeMilestoneToMain(tempDir, "M001", roadmap1); + + // Now complete M002 (the LAST milestone — this is the #962 scenario) + const wt2 = createAutoWorktree(tempDir, "M002"); + writeFileSync(join(wt2, "m002-work.ts"), "export const m002 = true;\n"); + run("git add .", wt2); + run('git commit -m "feat(M002): m002 work"', wt2); + const roadmap2 = readFileSync( + join(tempDir, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), + "utf-8", + ); + mergeMilestoneToMain(tempDir, "M002", roadmap2); + + // Both features should now be on main + assert.ok(existsSync(join(tempDir, "m001-work.ts")), "M001 work on main"); + assert.ok(existsSync(join(tempDir, "m002-work.ts")), "M002 work on main"); + assert.ok(!isInAutoWorktree(tempDir), "not in worktree after final merge"); + + // Both milestone branches should be cleaned up + const branches = run("git branch", tempDir); + assert.ok(!branches.includes("milestone/M001"), "M001 branch deleted"); + assert.ok(!branches.includes("milestone/M002"), "M002 branch deleted"); }); diff --git a/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts b/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts index e18bc2b6b..1f5c379a5 100644 --- a/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts +++ b/src/resources/extensions/gsd/tests/auto-lock-creation.test.ts @@ -110,26 +110,24 @@ test("clearLock is safe when no lock file exists", () => { rmSync(dir, { recursive: true, force: true }); }); -test("bootstrap cleanup releases session lock artifacts", () => { +test("bootstrap cleanup releases session lock artifacts", (t) => { const dir = mkdtempSync(join(tmpdir(), "gsd-lock-test-")); mkdirSync(join(dir, ".gsd"), { recursive: true }); - try { - const result = acquireSessionLock(dir); - assert.equal(result.acquired, true, "session lock should be acquired"); - assert.ok(existsSync(join(dir, ".gsd", "auto.lock")), "auto.lock should exist while lock is held"); - if (properLockfileAvailable) { - assert.ok(existsSync(join(dir, ".gsd.lock")), ".gsd.lock should exist while lock is held"); - } + t.after(() => rmSync(dir, { recursive: true, force: true })); - releaseSessionLock(dir); - clearLock(dir); - - assert.ok(!existsSync(join(dir, ".gsd", "auto.lock")), "auto.lock should be removed by bootstrap cleanup"); - assert.ok(!existsSync(join(dir, ".gsd.lock")), ".gsd.lock should be removed by bootstrap cleanup"); - } finally { - rmSync(dir, { recursive: true, force: true }); + const result = acquireSessionLock(dir); + assert.equal(result.acquired, true, "session lock should be acquired"); + assert.ok(existsSync(join(dir, ".gsd", "auto.lock")), "auto.lock should exist while lock is held"); + if (properLockfileAvailable) { + assert.ok(existsSync(join(dir, ".gsd.lock")), ".gsd.lock should exist while lock is held"); } + + releaseSessionLock(dir); + clearLock(dir); + + assert.ok(!existsSync(join(dir, ".gsd", "auto.lock")), "auto.lock should be removed by bootstrap cleanup"); + assert.ok(!existsSync(join(dir, ".gsd.lock")), ".gsd.lock should be removed by bootstrap cleanup"); }); // ─── isLockProcessAlive detects live vs dead PIDs ──────────────────────── diff --git a/src/resources/extensions/gsd/tests/auto-paused-session-validation.test.ts b/src/resources/extensions/gsd/tests/auto-paused-session-validation.test.ts index addbefa22..0b24f2a3f 100644 --- a/src/resources/extensions/gsd/tests/auto-paused-session-validation.test.ts +++ b/src/resources/extensions/gsd/tests/auto-paused-session-validation.test.ts @@ -51,93 +51,79 @@ function cleanup(base: string): void { try { rmSync(base, { recursive: true, force: true }); } catch { /* */ } } -test("resolveMilestonePath returns null for missing milestone", () => { +test("resolveMilestonePath returns null for missing milestone", (t) => { const base = makeTmpBase(); mkdirSync(join(base, ".gsd", "milestones"), { recursive: true }); - try { - const result = resolveMilestonePath(base, "M999"); - assert.equal(result, null, "should return null for non-existent milestone"); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const result = resolveMilestonePath(base, "M999"); + assert.equal(result, null, "should return null for non-existent milestone"); }); -test("resolveMilestonePath returns path for existing milestone", () => { +test("resolveMilestonePath returns path for existing milestone", (t) => { const base = makeTmpBase(); mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); - try { - const result = resolveMilestonePath(base, "M001"); - assert.ok(result, "should return a path for existing milestone"); - assert.ok(result.includes("M001"), "path should contain the milestone ID"); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const result = resolveMilestonePath(base, "M001"); + assert.ok(result, "should return a path for existing milestone"); + assert.ok(result.includes("M001"), "path should contain the milestone ID"); }); -test("resolveMilestoneFile returns null when no SUMMARY exists", () => { +test("resolveMilestoneFile returns null when no SUMMARY exists", (t) => { const base = makeTmpBase(); mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); - try { - const result = resolveMilestoneFile(base, "M001", "SUMMARY"); - assert.equal(result, null, "should return null when no SUMMARY file"); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const result = resolveMilestoneFile(base, "M001", "SUMMARY"); + assert.equal(result, null, "should return null when no SUMMARY file"); }); -test("resolveMilestoneFile returns path when SUMMARY exists (completed)", () => { +test("resolveMilestoneFile returns path when SUMMARY exists (completed)", (t) => { const base = makeTmpBase(); const mDir = join(base, ".gsd", "milestones", "M001"); mkdirSync(mDir, { recursive: true }); writeFileSync(join(mDir, "M001-SUMMARY.md"), "# Summary\nDone."); - try { - const result = resolveMilestoneFile(base, "M001", "SUMMARY"); - assert.ok(result, "should return a path when SUMMARY exists"); - assert.ok(result.includes("SUMMARY"), "path should reference SUMMARY"); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const result = resolveMilestoneFile(base, "M001", "SUMMARY"); + assert.ok(result, "should return a path when SUMMARY exists"); + assert.ok(result.includes("SUMMARY"), "path should reference SUMMARY"); }); // ─── Combined validation logic (mirrors auto.ts resume guard) ─────────────── -test("stale milestone: missing dir means paused session should be discarded", () => { +test("stale milestone: missing dir means paused session should be discarded", (t) => { const base = makeTmpBase(); mkdirSync(join(base, ".gsd", "milestones"), { recursive: true }); - try { - const mDir = resolveMilestonePath(base, "M999"); - const summaryFile = resolveMilestoneFile(base, "M999", "SUMMARY"); - const isStale = !mDir || !!summaryFile; - assert.ok(isStale, "milestone that doesn't exist should be detected as stale"); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const mDir = resolveMilestonePath(base, "M999"); + const summaryFile = resolveMilestoneFile(base, "M999", "SUMMARY"); + const isStale = !mDir || !!summaryFile; + assert.ok(isStale, "milestone that doesn't exist should be detected as stale"); }); -test("stale milestone: completed (has SUMMARY) means paused session should be discarded", () => { +test("stale milestone: completed (has SUMMARY) means paused session should be discarded", (t) => { const base = makeTmpBase(); const mDir = join(base, ".gsd", "milestones", "M001"); mkdirSync(mDir, { recursive: true }); writeFileSync(join(mDir, "M001-SUMMARY.md"), "# Summary\nDone."); - try { - const dir = resolveMilestonePath(base, "M001"); - const summaryFile = resolveMilestoneFile(base, "M001", "SUMMARY"); - const isStale = !dir || !!summaryFile; - assert.ok(isStale, "milestone with SUMMARY should be detected as stale"); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const dir = resolveMilestonePath(base, "M001"); + const summaryFile = resolveMilestoneFile(base, "M001", "SUMMARY"); + const isStale = !dir || !!summaryFile; + assert.ok(isStale, "milestone with SUMMARY should be detected as stale"); }); -test("valid milestone: exists and has no SUMMARY means paused session is valid", () => { +test("valid milestone: exists and has no SUMMARY means paused session is valid", (t) => { const base = makeTmpBase(); mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); - try { - const dir = resolveMilestonePath(base, "M001"); - const summaryFile = resolveMilestoneFile(base, "M001", "SUMMARY"); - const isStale = !dir || !!summaryFile; - assert.ok(!isStale, "active milestone should not be detected as stale"); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const dir = resolveMilestonePath(base, "M001"); + const summaryFile = resolveMilestoneFile(base, "M001", "SUMMARY"); + const isStale = !dir || !!summaryFile; + assert.ok(!isStale, "active milestone should not be detected as stale"); }); diff --git a/src/resources/extensions/gsd/tests/auto-preflight.test.ts b/src/resources/extensions/gsd/tests/auto-preflight.test.ts index 2581ce5da..63eb7e60a 100644 --- a/src/resources/extensions/gsd/tests/auto-preflight.test.ts +++ b/src/resources/extensions/gsd/tests/auto-preflight.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { runGSDDoctor, selectDoctorScope, filterDoctorIssues } from "../doctor.js"; -test("auto-preflight scopes to active milestone, ignoring historical", async () => { +test("auto-preflight scopes to active milestone, ignoring historical", async (t) => { const tmpBase = mkdtempSync(join(tmpdir(), "gsd-auto-preflight-test-")); const gsd = join(tmpBase, ".gsd"); @@ -23,18 +23,16 @@ test("auto-preflight scopes to active milestone, ignoring historical", async () writeFileSync(join(gsd, "milestones", "M009", "M009-ROADMAP.md"), `# M009: Active\n\n## Slices\n- [ ] **S01: Active Slice** \`risk:low\` \`depends:[]\`\n > After this: active works\n`); writeFileSync(join(gsd, "milestones", "M009", "slices", "S01", "S01-PLAN.md"), `# S01: Active Slice\n\n**Goal:** Active\n**Demo:** Active\n\n## Must-Haves\n- done\n\n## Tasks\n- [ ] **T01: Active Task** \`est:5m\`\n todo\n`); - try { - const scope = await selectDoctorScope(tmpBase); - assert.equal(scope, "M009/S01", "active scope selected instead of historical milestone"); + t.after(() => rmSync(tmpBase, { recursive: true, force: true })); - const scopedReport = await runGSDDoctor(tmpBase, { fix: false, scope }); - const scopedBlocking = filterDoctorIssues(scopedReport.issues, { scope, includeWarnings: false }); - assert.equal(scopedBlocking.length, 0, "no blocking issues in active scope"); + const scope = await selectDoctorScope(tmpBase); + assert.equal(scope, "M009/S01", "active scope selected instead of historical milestone"); - const historicalReport = await runGSDDoctor(tmpBase, { fix: false }); - const historicalWarnings = historicalReport.issues.filter(issue => issue.unitId.startsWith("M001/S01") && issue.severity === "warning"); - assert.equal(historicalWarnings.length, 0, "completed historical milestone produces no checkbox/file-mismatch warnings"); - } finally { - rmSync(tmpBase, { recursive: true, force: true }); - } + const scopedReport = await runGSDDoctor(tmpBase, { fix: false, scope }); + const scopedBlocking = filterDoctorIssues(scopedReport.issues, { scope, includeWarnings: false }); + assert.equal(scopedBlocking.length, 0, "no blocking issues in active scope"); + + const historicalReport = await runGSDDoctor(tmpBase, { fix: false }); + const historicalWarnings = historicalReport.issues.filter(issue => issue.unitId.startsWith("M001/S01") && issue.severity === "warning"); + assert.equal(historicalWarnings.length, 0, "completed historical milestone produces no checkbox/file-mismatch warnings"); }); diff --git a/src/resources/extensions/gsd/tests/auto-recovery.test.ts b/src/resources/extensions/gsd/tests/auto-recovery.test.ts index a216c8a8d..4dc67b702 100644 --- a/src/resources/extensions/gsd/tests/auto-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/auto-recovery.test.ts @@ -39,444 +39,400 @@ function cleanup(base: string): void { // ─── resolveExpectedArtifactPath ────────────────────────────────────────── -test("resolveExpectedArtifactPath returns correct path for research-milestone", () => { +test("resolveExpectedArtifactPath returns correct path for research-milestone", (t) => { const base = makeTmpBase(); - try { - const result = resolveExpectedArtifactPath("research-milestone", "M001", base); - assert.ok(result); - assert.ok(result!.includes("M001")); - assert.ok(result!.includes("RESEARCH")); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const result = resolveExpectedArtifactPath("research-milestone", "M001", base); + assert.ok(result); + assert.ok(result!.includes("M001")); + assert.ok(result!.includes("RESEARCH")); }); -test("resolveExpectedArtifactPath returns correct path for execute-task", () => { +test("resolveExpectedArtifactPath returns correct path for execute-task", (t) => { const base = makeTmpBase(); - try { - const result = resolveExpectedArtifactPath("execute-task", "M001/S01/T01", base); - assert.ok(result); - assert.ok(result!.includes("tasks")); - assert.ok(result!.includes("SUMMARY")); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const result = resolveExpectedArtifactPath("execute-task", "M001/S01/T01", base); + assert.ok(result); + assert.ok(result!.includes("tasks")); + assert.ok(result!.includes("SUMMARY")); }); -test("resolveExpectedArtifactPath returns correct path for complete-slice", () => { +test("resolveExpectedArtifactPath returns correct path for complete-slice", (t) => { const base = makeTmpBase(); - try { - const result = resolveExpectedArtifactPath("complete-slice", "M001/S01", base); - assert.ok(result); - assert.ok(result!.includes("SUMMARY")); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const result = resolveExpectedArtifactPath("complete-slice", "M001/S01", base); + assert.ok(result); + assert.ok(result!.includes("SUMMARY")); }); -test("resolveExpectedArtifactPath returns correct path for plan-slice", () => { +test("resolveExpectedArtifactPath returns correct path for plan-slice", (t) => { const base = makeTmpBase(); - try { - const result = resolveExpectedArtifactPath("plan-slice", "M001/S01", base); - assert.ok(result); - assert.ok(result!.includes("PLAN")); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const result = resolveExpectedArtifactPath("plan-slice", "M001/S01", base); + assert.ok(result); + assert.ok(result!.includes("PLAN")); }); -test("resolveExpectedArtifactPath returns null for unknown type", () => { +test("resolveExpectedArtifactPath returns null for unknown type", (t) => { const base = makeTmpBase(); - try { - const result = resolveExpectedArtifactPath("unknown-type", "M001", base); - assert.equal(result, null); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const result = resolveExpectedArtifactPath("unknown-type", "M001", base); + assert.equal(result, null); }); -test("resolveExpectedArtifactPath returns correct path for all milestone-level types", () => { +test("resolveExpectedArtifactPath returns correct path for all milestone-level types", (t) => { const base = makeTmpBase(); - try { - const planResult = resolveExpectedArtifactPath("plan-milestone", "M001", base); - assert.ok(planResult); - assert.ok(planResult!.includes("ROADMAP")); + t.after(() => cleanup(base)); - const completeResult = resolveExpectedArtifactPath("complete-milestone", "M001", base); - assert.ok(completeResult); - assert.ok(completeResult!.includes("SUMMARY")); - } finally { - cleanup(base); - } + const planResult = resolveExpectedArtifactPath("plan-milestone", "M001", base); + assert.ok(planResult); + assert.ok(planResult!.includes("ROADMAP")); + + const completeResult = resolveExpectedArtifactPath("complete-milestone", "M001", base); + assert.ok(completeResult); + assert.ok(completeResult!.includes("SUMMARY")); }); -test("resolveExpectedArtifactPath returns correct path for all slice-level types", () => { +test("resolveExpectedArtifactPath returns correct path for all slice-level types", (t) => { const base = makeTmpBase(); - try { - const researchResult = resolveExpectedArtifactPath("research-slice", "M001/S01", base); - assert.ok(researchResult); - assert.ok(researchResult!.includes("RESEARCH")); + t.after(() => cleanup(base)); - const assessResult = resolveExpectedArtifactPath("reassess-roadmap", "M001/S01", base); - assert.ok(assessResult); - assert.ok(assessResult!.includes("ASSESSMENT")); + const researchResult = resolveExpectedArtifactPath("research-slice", "M001/S01", base); + assert.ok(researchResult); + assert.ok(researchResult!.includes("RESEARCH")); - const uatResult = resolveExpectedArtifactPath("run-uat", "M001/S01", base); - assert.ok(uatResult); - assert.ok(uatResult!.includes("UAT-RESULT")); - } finally { - cleanup(base); - } + const assessResult = resolveExpectedArtifactPath("reassess-roadmap", "M001/S01", base); + assert.ok(assessResult); + assert.ok(assessResult!.includes("ASSESSMENT")); + + const uatResult = resolveExpectedArtifactPath("run-uat", "M001/S01", base); + assert.ok(uatResult); + assert.ok(uatResult!.includes("UAT-RESULT")); }); // ─── diagnoseExpectedArtifact ───────────────────────────────────────────── -test("diagnoseExpectedArtifact returns description for known types", () => { +test("diagnoseExpectedArtifact returns description for known types", (t) => { const base = makeTmpBase(); - try { - const research = diagnoseExpectedArtifact("research-milestone", "M001", base); - assert.ok(research); - assert.ok(research!.includes("research")); + t.after(() => cleanup(base)); - const plan = diagnoseExpectedArtifact("plan-slice", "M001/S01", base); - assert.ok(plan); - assert.ok(plan!.includes("plan")); + const research = diagnoseExpectedArtifact("research-milestone", "M001", base); + assert.ok(research); + assert.ok(research!.includes("research")); - const task = diagnoseExpectedArtifact("execute-task", "M001/S01/T01", base); - assert.ok(task); - assert.ok(task!.includes("T01")); - } finally { - cleanup(base); - } + const plan = diagnoseExpectedArtifact("plan-slice", "M001/S01", base); + assert.ok(plan); + assert.ok(plan!.includes("plan")); + + const task = diagnoseExpectedArtifact("execute-task", "M001/S01/T01", base); + assert.ok(task); + assert.ok(task!.includes("T01")); }); -test("diagnoseExpectedArtifact returns null for unknown type", () => { +test("diagnoseExpectedArtifact returns null for unknown type", (t) => { const base = makeTmpBase(); - try { - assert.equal(diagnoseExpectedArtifact("unknown", "M001", base), null); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + assert.equal(diagnoseExpectedArtifact("unknown", "M001", base), null); }); // ─── buildLoopRemediationSteps ──────────────────────────────────────────── -test("buildLoopRemediationSteps returns steps for execute-task", () => { +test("buildLoopRemediationSteps returns steps for execute-task", (t) => { const base = makeTmpBase(); - try { - const steps = buildLoopRemediationSteps("execute-task", "M001/S01/T01", base); - assert.ok(steps); - assert.ok(steps!.includes("T01")); - assert.ok(steps!.includes("gsd undo-task")); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const steps = buildLoopRemediationSteps("execute-task", "M001/S01/T01", base); + assert.ok(steps); + assert.ok(steps!.includes("T01")); + assert.ok(steps!.includes("gsd undo-task")); }); -test("buildLoopRemediationSteps returns steps for plan-slice", () => { +test("buildLoopRemediationSteps returns steps for plan-slice", (t) => { const base = makeTmpBase(); - try { - const steps = buildLoopRemediationSteps("plan-slice", "M001/S01", base); - assert.ok(steps); - assert.ok(steps!.includes("PLAN")); - assert.ok(steps!.includes("gsd recover")); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const steps = buildLoopRemediationSteps("plan-slice", "M001/S01", base); + assert.ok(steps); + assert.ok(steps!.includes("PLAN")); + assert.ok(steps!.includes("gsd recover")); }); -test("buildLoopRemediationSteps returns steps for complete-slice", () => { +test("buildLoopRemediationSteps returns steps for complete-slice", (t) => { const base = makeTmpBase(); - try { - const steps = buildLoopRemediationSteps("complete-slice", "M001/S01", base); - assert.ok(steps); - assert.ok(steps!.includes("S01")); - assert.ok(steps!.includes("gsd reset-slice")); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const steps = buildLoopRemediationSteps("complete-slice", "M001/S01", base); + assert.ok(steps); + assert.ok(steps!.includes("S01")); + assert.ok(steps!.includes("gsd reset-slice")); }); -test("buildLoopRemediationSteps returns null for unknown type", () => { +test("buildLoopRemediationSteps returns null for unknown type", (t) => { const base = makeTmpBase(); - try { - assert.equal(buildLoopRemediationSteps("unknown", "M001", base), null); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + assert.equal(buildLoopRemediationSteps("unknown", "M001", base), null); }); // ─── verifyExpectedArtifact: parse cache collision regression ───────────── -test("verifyExpectedArtifact detects roadmap [x] change despite parse cache", () => { +test("verifyExpectedArtifact detects roadmap [x] change despite parse cache", (t) => { // Regression test: cacheKey collision when [ ] → [x] doesn't change // file length or first/last 100 chars. Without the fix, parseRoadmap // returns stale cached data with done=false even though the file has [x]. const base = makeTmpBase(); - try { - // Build a roadmap long enough that the [x] change is outside the first/last 100 chars - const padding = "A".repeat(200); - const roadmapBefore = [ - `# M001: Test Milestone ${padding}`, - "", - "## Slices", - "", - "- [ ] **S01: First slice** `risk:low`", - "", - `## Footer ${padding}`, - ].join("\n"); - const roadmapAfter = roadmapBefore.replace("- [ ] **S01:", "- [x] **S01:"); - - // Verify lengths are identical (the key collision condition) - assert.equal(roadmapBefore.length, roadmapAfter.length); - - // Populate parse cache with the pre-edit roadmap - const before = parseRoadmap(roadmapBefore); - const sliceBefore = before.slices.find(s => s.id === "S01"); - assert.ok(sliceBefore); - assert.equal(sliceBefore!.done, false); - - // Now write the post-edit roadmap to disk and create required artifacts - const roadmapPath = join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"); - writeFileSync(roadmapPath, roadmapAfter); - const summaryPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"); - writeFileSync(summaryPath, "# Summary\nDone."); - const uatPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT.md"); - writeFileSync(uatPath, "# UAT\nPassed."); - - // verifyExpectedArtifact should see the [x] despite the parse cache - // having the [ ] version. The fix clears the parse cache inside verify. - const verified = verifyExpectedArtifact("complete-slice", "M001/S01", base); - assert.equal(verified, true, "verifyExpectedArtifact should return true when roadmap has [x]"); - } finally { + t.after(() => { clearParseCache(); cleanup(base); - } + }); + + // Build a roadmap long enough that the [x] change is outside the first/last 100 chars + const padding = "A".repeat(200); + const roadmapBefore = [ + `# M001: Test Milestone ${padding}`, + "", + "## Slices", + "", + "- [ ] **S01: First slice** `risk:low`", + "", + `## Footer ${padding}`, + ].join("\n"); + const roadmapAfter = roadmapBefore.replace("- [ ] **S01:", "- [x] **S01:"); + + // Verify lengths are identical (the key collision condition) + assert.equal(roadmapBefore.length, roadmapAfter.length); + + // Populate parse cache with the pre-edit roadmap + const before = parseRoadmap(roadmapBefore); + const sliceBefore = before.slices.find(s => s.id === "S01"); + assert.ok(sliceBefore); + assert.equal(sliceBefore!.done, false); + + // Now write the post-edit roadmap to disk and create required artifacts + const roadmapPath = join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"); + writeFileSync(roadmapPath, roadmapAfter); + const summaryPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"); + writeFileSync(summaryPath, "# Summary\nDone."); + const uatPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT.md"); + writeFileSync(uatPath, "# UAT\nPassed."); + + // verifyExpectedArtifact should see the [x] despite the parse cache + // having the [ ] version. The fix clears the parse cache inside verify. + const verified = verifyExpectedArtifact("complete-slice", "M001/S01", base); + assert.equal(verified, true, "verifyExpectedArtifact should return true when roadmap has [x]"); }); // ─── verifyExpectedArtifact: plan-slice empty scaffold regression (#699) ── -test("verifyExpectedArtifact rejects plan-slice with empty scaffold", () => { +test("verifyExpectedArtifact rejects plan-slice with empty scaffold", (t) => { const base = makeTmpBase(); - try { - const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01"); - mkdirSync(sliceDir, { recursive: true }); - writeFileSync(join(sliceDir, "S01-PLAN.md"), "# S01: Test Slice\n\n## Tasks\n\n"); - assert.strictEqual( - verifyExpectedArtifact("plan-slice", "M001/S01", base), - false, - "Empty scaffold should not be treated as completed artifact", - ); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01"); + mkdirSync(sliceDir, { recursive: true }); + writeFileSync(join(sliceDir, "S01-PLAN.md"), "# S01: Test Slice\n\n## Tasks\n\n"); + assert.strictEqual( + verifyExpectedArtifact("plan-slice", "M001/S01", base), + false, + "Empty scaffold should not be treated as completed artifact", + ); }); -test("verifyExpectedArtifact accepts plan-slice with actual tasks", () => { +test("verifyExpectedArtifact accepts plan-slice with actual tasks", (t) => { const base = makeTmpBase(); - try { - const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01"); - const tasksDir = join(sliceDir, "tasks"); - mkdirSync(tasksDir, { recursive: true }); - writeFileSync(join(sliceDir, "S01-PLAN.md"), [ - "# S01: Test Slice", - "", - "## Tasks", - "", - "- [ ] **T01: Implement feature** `est:2h`", - "- [ ] **T02: Write tests** `est:1h`", - ].join("\n")); - writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan"); - writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan"); - assert.strictEqual( - verifyExpectedArtifact("plan-slice", "M001/S01", base), - true, - "Plan with task entries should be treated as completed artifact", - ); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01"); + const tasksDir = join(sliceDir, "tasks"); + mkdirSync(tasksDir, { recursive: true }); + writeFileSync(join(sliceDir, "S01-PLAN.md"), [ + "# S01: Test Slice", + "", + "## Tasks", + "", + "- [ ] **T01: Implement feature** `est:2h`", + "- [ ] **T02: Write tests** `est:1h`", + ].join("\n")); + writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan"); + writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan"); + assert.strictEqual( + verifyExpectedArtifact("plan-slice", "M001/S01", base), + true, + "Plan with task entries should be treated as completed artifact", + ); }); -test("verifyExpectedArtifact accepts plan-slice with completed tasks", () => { +test("verifyExpectedArtifact accepts plan-slice with completed tasks", (t) => { const base = makeTmpBase(); - try { - const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01"); - const tasksDir = join(sliceDir, "tasks"); - mkdirSync(tasksDir, { recursive: true }); - writeFileSync(join(sliceDir, "S01-PLAN.md"), [ - "# S01: Test Slice", - "", - "## Tasks", - "", - "- [x] **T01: Implement feature** `est:2h`", - "- [ ] **T02: Write tests** `est:1h`", - ].join("\n")); - writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan"); - writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan"); - assert.strictEqual( - verifyExpectedArtifact("plan-slice", "M001/S01", base), - true, - "Plan with completed task entries should be treated as completed artifact", - ); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01"); + const tasksDir = join(sliceDir, "tasks"); + mkdirSync(tasksDir, { recursive: true }); + writeFileSync(join(sliceDir, "S01-PLAN.md"), [ + "# S01: Test Slice", + "", + "## Tasks", + "", + "- [x] **T01: Implement feature** `est:2h`", + "- [ ] **T02: Write tests** `est:1h`", + ].join("\n")); + writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan"); + writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan"); + assert.strictEqual( + verifyExpectedArtifact("plan-slice", "M001/S01", base), + true, + "Plan with completed task entries should be treated as completed artifact", + ); }); // ─── verifyExpectedArtifact: plan-slice task plan check (#739) ──────────── -test("verifyExpectedArtifact plan-slice passes when all task plan files exist", () => { +test("verifyExpectedArtifact plan-slice passes when all task plan files exist", (t) => { const base = makeTmpBase(); - try { - const tasksDir = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"); - const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"); - const planContent = [ - "# S01: Test Slice", - "", - "## Tasks", - "", - "- [ ] **T01: First task** `est:1h`", - "- [ ] **T02: Second task** `est:2h`", - ].join("\n"); - writeFileSync(planPath, planContent); - writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan\n\nDo the thing."); - writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan\n\nDo the other thing."); + t.after(() => cleanup(base)); - const result = verifyExpectedArtifact("plan-slice", "M001/S01", base); - assert.equal(result, true, "should pass when all task plan files exist"); - } finally { - cleanup(base); - } + const tasksDir = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"); + const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"); + const planContent = [ + "# S01: Test Slice", + "", + "## Tasks", + "", + "- [ ] **T01: First task** `est:1h`", + "- [ ] **T02: Second task** `est:2h`", + ].join("\n"); + writeFileSync(planPath, planContent); + writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan\n\nDo the thing."); + writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan\n\nDo the other thing."); + + const result = verifyExpectedArtifact("plan-slice", "M001/S01", base); + assert.equal(result, true, "should pass when all task plan files exist"); }); -test("verifyExpectedArtifact plan-slice fails when a task plan file is missing (#739)", () => { +test("verifyExpectedArtifact plan-slice fails when a task plan file is missing (#739)", (t) => { const base = makeTmpBase(); - try { - const tasksDir = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"); - const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"); - const planContent = [ - "# S01: Test Slice", - "", - "## Tasks", - "", - "- [ ] **T01: First task** `est:1h`", - "- [ ] **T02: Second task** `est:2h`", - ].join("\n"); - writeFileSync(planPath, planContent); - // Only write T01-PLAN.md — T02 is missing - writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan\n\nDo the thing."); + t.after(() => cleanup(base)); - const result = verifyExpectedArtifact("plan-slice", "M001/S01", base); - assert.equal(result, false, "should fail when T02-PLAN.md is missing"); - } finally { - cleanup(base); - } + const tasksDir = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"); + const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"); + const planContent = [ + "# S01: Test Slice", + "", + "## Tasks", + "", + "- [ ] **T01: First task** `est:1h`", + "- [ ] **T02: Second task** `est:2h`", + ].join("\n"); + writeFileSync(planPath, planContent); + // Only write T01-PLAN.md — T02 is missing + writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan\n\nDo the thing."); + + const result = verifyExpectedArtifact("plan-slice", "M001/S01", base); + assert.equal(result, false, "should fail when T02-PLAN.md is missing"); }); -test("verifyExpectedArtifact plan-slice fails for plan with no tasks (#699)", () => { +test("verifyExpectedArtifact plan-slice fails for plan with no tasks (#699)", (t) => { const base = makeTmpBase(); - try { - const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"); - const planContent = [ - "# S01: Test Slice", - "", - "## Goal", - "", - "Just some documentation updates, no tasks.", - ].join("\n"); - writeFileSync(planPath, planContent); + t.after(() => cleanup(base)); - const result = verifyExpectedArtifact("plan-slice", "M001/S01", base); - assert.equal(result, false, "should fail when plan has no task entries (empty scaffold, #699)"); - } finally { - cleanup(base); - } + const planPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md"); + const planContent = [ + "# S01: Test Slice", + "", + "## Goal", + "", + "Just some documentation updates, no tasks.", + ].join("\n"); + writeFileSync(planPath, planContent); + + const result = verifyExpectedArtifact("plan-slice", "M001/S01", base); + assert.equal(result, false, "should fail when plan has no task entries (empty scaffold, #699)"); }); // ─── verifyExpectedArtifact: heading-style plan tasks (#1691) ───────────── -test("verifyExpectedArtifact accepts plan-slice with heading-style tasks (### T01 --)", () => { +test("verifyExpectedArtifact accepts plan-slice with heading-style tasks (### T01 --)", (t) => { const base = makeTmpBase(); - try { - const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01"); - const tasksDir = join(sliceDir, "tasks"); - mkdirSync(tasksDir, { recursive: true }); - writeFileSync(join(sliceDir, "S01-PLAN.md"), [ - "# S01: Test Slice", - "", - "## Tasks", - "", - "### T01 -- Implement feature", - "", - "Feature description.", - "", - "### T02 -- Write tests", - "", - "Test description.", - ].join("\n")); - writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan"); - writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan"); - assert.strictEqual( - verifyExpectedArtifact("plan-slice", "M001/S01", base), - true, - "Heading-style plan with task entries should be treated as completed artifact", - ); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01"); + const tasksDir = join(sliceDir, "tasks"); + mkdirSync(tasksDir, { recursive: true }); + writeFileSync(join(sliceDir, "S01-PLAN.md"), [ + "# S01: Test Slice", + "", + "## Tasks", + "", + "### T01 -- Implement feature", + "", + "Feature description.", + "", + "### T02 -- Write tests", + "", + "Test description.", + ].join("\n")); + writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan"); + writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02 Plan"); + assert.strictEqual( + verifyExpectedArtifact("plan-slice", "M001/S01", base), + true, + "Heading-style plan with task entries should be treated as completed artifact", + ); }); -test("verifyExpectedArtifact accepts plan-slice with colon-style heading tasks (### T01:)", () => { +test("verifyExpectedArtifact accepts plan-slice with colon-style heading tasks (### T01:)", (t) => { const base = makeTmpBase(); - try { - const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01"); - const tasksDir = join(sliceDir, "tasks"); - mkdirSync(tasksDir, { recursive: true }); - writeFileSync(join(sliceDir, "S01-PLAN.md"), [ - "# S01: Test Slice", - "", - "## Tasks", - "", - "### T01: Implement feature", - "", - "Feature description.", - ].join("\n")); - writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan"); - assert.strictEqual( - verifyExpectedArtifact("plan-slice", "M001/S01", base), - true, - "Colon heading-style plan should be treated as completed artifact", - ); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01"); + const tasksDir = join(sliceDir, "tasks"); + mkdirSync(tasksDir, { recursive: true }); + writeFileSync(join(sliceDir, "S01-PLAN.md"), [ + "# S01: Test Slice", + "", + "## Tasks", + "", + "### T01: Implement feature", + "", + "Feature description.", + ].join("\n")); + writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01 Plan"); + assert.strictEqual( + verifyExpectedArtifact("plan-slice", "M001/S01", base), + true, + "Colon heading-style plan should be treated as completed artifact", + ); }); -test("verifyExpectedArtifact execute-task passes for heading-style plan entry (#1691)", () => { +test("verifyExpectedArtifact execute-task passes for heading-style plan entry (#1691)", (t) => { const base = makeTmpBase(); - try { - const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01"); - const tasksDir = join(sliceDir, "tasks"); - mkdirSync(tasksDir, { recursive: true }); - writeFileSync(join(sliceDir, "S01-PLAN.md"), [ - "# S01: Test Slice", - "", - "## Tasks", - "", - "### T01 -- Implement feature", - "", - "Feature description.", - ].join("\n")); - writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "# T01 Summary\n\nDone."); - assert.strictEqual( - verifyExpectedArtifact("execute-task", "M001/S01/T01", base), - true, - "execute-task should pass for heading-style plan entry when summary exists", - ); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const sliceDir = join(base, ".gsd", "milestones", "M001", "slices", "S01"); + const tasksDir = join(sliceDir, "tasks"); + mkdirSync(tasksDir, { recursive: true }); + writeFileSync(join(sliceDir, "S01-PLAN.md"), [ + "# S01: Test Slice", + "", + "## Tasks", + "", + "### T01 -- Implement feature", + "", + "Feature description.", + ].join("\n")); + writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "# T01 Summary\n\nDone."); + assert.strictEqual( + verifyExpectedArtifact("execute-task", "M001/S01/T01", base), + true, + "execute-task should pass for heading-style plan entry when summary exists", + ); }); test("verifyExpectedArtifact plan-slice passes for rendered slice/task plan artifacts from DB", async () => { @@ -618,83 +574,81 @@ test("verifyExpectedArtifact plan-slice fails after deleting a rendered task pla // ─── selfHealRuntimeRecords — worktree base path (#769) ────────────────── -test("selfHealRuntimeRecords clears stale dispatched records (#769)", async () => { +test("selfHealRuntimeRecords clears stale dispatched records (#769)", async (t) => { // selfHealRuntimeRecords now only clears stale dispatched records (>1h). // No completedKeySet parameter — deriveState is sole authority. const worktreeBase = makeTmpBase(); const mainBase = makeTmpBase(); - try { - const { writeUnitRuntimeRecord, readUnitRuntimeRecord } = await import("../unit-runtime.ts"); - - // Write a stale runtime record in the worktree .gsd/runtime/units/ - writeUnitRuntimeRecord(worktreeBase, "run-uat", "M001/S01", Date.now() - 7200_000, { - phase: "dispatched", - }); - - // Verify the runtime record exists before heal - const before = readUnitRuntimeRecord(worktreeBase, "run-uat", "M001/S01"); - assert.ok(before, "runtime record should exist before heal"); - - // Mock ExtensionContext with minimal notify - const notifications: string[] = []; - const mockCtx = { - ui: { notify: (msg: string) => { notifications.push(msg); } }, - } as any; - - // Call selfHeal with worktreeBase — should clear the stale record - await selfHealRuntimeRecords(worktreeBase, mockCtx); - - // The stale record should be cleared - const after = readUnitRuntimeRecord(worktreeBase, "run-uat", "M001/S01"); - assert.equal(after, null, "runtime record should be cleared after heal"); - assert.ok(notifications.some(n => n.includes("Self-heal")), "should emit self-heal notification"); - - // Write a stale record at mainBase - writeUnitRuntimeRecord(mainBase, "run-uat", "M001/S01", Date.now() - 7200_000, { - phase: "dispatched", - }); - await selfHealRuntimeRecords(mainBase, mockCtx); - - // The record at mainBase should also be cleared by the stale timeout (>1h) - const afterMain = readUnitRuntimeRecord(mainBase, "run-uat", "M001/S01"); - assert.equal(afterMain, null, "stale record at main base should be cleared by timeout"); - } finally { + t.after(() => { cleanup(worktreeBase); cleanup(mainBase); - } + }); + + const { writeUnitRuntimeRecord, readUnitRuntimeRecord } = await import("../unit-runtime.ts"); + + // Write a stale runtime record in the worktree .gsd/runtime/units/ + writeUnitRuntimeRecord(worktreeBase, "run-uat", "M001/S01", Date.now() - 7200_000, { + phase: "dispatched", + }); + + // Verify the runtime record exists before heal + const before = readUnitRuntimeRecord(worktreeBase, "run-uat", "M001/S01"); + assert.ok(before, "runtime record should exist before heal"); + + // Mock ExtensionContext with minimal notify + const notifications: string[] = []; + const mockCtx = { + ui: { notify: (msg: string) => { notifications.push(msg); } }, + } as any; + + // Call selfHeal with worktreeBase — should clear the stale record + await selfHealRuntimeRecords(worktreeBase, mockCtx); + + // The stale record should be cleared + const after = readUnitRuntimeRecord(worktreeBase, "run-uat", "M001/S01"); + assert.equal(after, null, "runtime record should be cleared after heal"); + assert.ok(notifications.some(n => n.includes("Self-heal")), "should emit self-heal notification"); + + // Write a stale record at mainBase + writeUnitRuntimeRecord(mainBase, "run-uat", "M001/S01", Date.now() - 7200_000, { + phase: "dispatched", + }); + await selfHealRuntimeRecords(mainBase, mockCtx); + + // The record at mainBase should also be cleared by the stale timeout (>1h) + const afterMain = readUnitRuntimeRecord(mainBase, "run-uat", "M001/S01"); + assert.equal(afterMain, null, "stale record at main base should be cleared by timeout"); }); // ─── #1625: selfHealRuntimeRecords on resume clears paused-session leftovers ── -test("selfHealRuntimeRecords clears recently-paused dispatched records on resume (#1625)", async () => { +test("selfHealRuntimeRecords clears recently-paused dispatched records on resume (#1625)", async (t) => { // When pauseAuto closes out a unit but clearUnitRuntimeRecord silently fails // (e.g. permission error), selfHealRuntimeRecords on resume should still // clean up stale dispatched records that are >1h old. const base = makeTmpBase(); - try { - const { writeUnitRuntimeRecord, readUnitRuntimeRecord } = await import("../unit-runtime.ts"); + t.after(() => cleanup(base)); - // Simulate a record left behind after a pause — aged >1h to be considered stale - writeUnitRuntimeRecord(base, "execute-task", "M001/S01/T01", Date.now() - 3700_000, { - phase: "dispatched", - }); + const { writeUnitRuntimeRecord, readUnitRuntimeRecord } = await import("../unit-runtime.ts"); - const before = readUnitRuntimeRecord(base, "execute-task", "M001/S01/T01"); - assert.ok(before, "dispatched record should exist before resume heal"); - assert.equal(before!.phase, "dispatched"); + // Simulate a record left behind after a pause — aged >1h to be considered stale + writeUnitRuntimeRecord(base, "execute-task", "M001/S01/T01", Date.now() - 3700_000, { + phase: "dispatched", + }); - const notifications: string[] = []; - const mockCtx = { - ui: { notify: (msg: string) => { notifications.push(msg); } }, - } as any; + const before = readUnitRuntimeRecord(base, "execute-task", "M001/S01/T01"); + assert.ok(before, "dispatched record should exist before resume heal"); + assert.equal(before!.phase, "dispatched"); - await selfHealRuntimeRecords(base, mockCtx); + const notifications: string[] = []; + const mockCtx = { + ui: { notify: (msg: string) => { notifications.push(msg); } }, + } as any; - const after = readUnitRuntimeRecord(base, "execute-task", "M001/S01/T01"); - assert.equal(after, null, "stale dispatched record should be cleared on resume (#1625)"); - } finally { - cleanup(base); - } + await selfHealRuntimeRecords(base, mockCtx); + + const after = readUnitRuntimeRecord(base, "execute-task", "M001/S01/T01"); + assert.equal(after, null, "stale dispatched record should be cleared on resume (#1625)"); }); // ─── #793: invalidateAllCaches unblocks skip-loop ───────────────────────── @@ -702,51 +656,49 @@ test("selfHealRuntimeRecords clears recently-paused dispatched records on resume // just invalidateStateCache()) to clear path/parse caches that deriveState // depends on. Without this, even after cache invalidation, deriveState reads // stale directory listings and returns the same unit, looping forever. -test("#793: invalidateAllCaches clears all caches so deriveState sees fresh disk state", async () => { +test("#793: invalidateAllCaches clears all caches so deriveState sees fresh disk state", async (t) => { const base = makeTmpBase(); - try { - const mid = "M001"; - const sid = "S01"; - const planDir = join(base, ".gsd", "milestones", mid, "slices", sid); - const tasksDir = join(planDir, "tasks"); - mkdirSync(tasksDir, { recursive: true }); - mkdirSync(join(base, ".gsd", "milestones", mid), { recursive: true }); + t.after(() => cleanup(base)); - writeFileSync( - join(base, ".gsd", "milestones", mid, `${mid}-ROADMAP.md`), - `# M001: Test Milestone\n\n**Vision:** test.\n\n## Slices\n\n- [ ] **${sid}: Slice One** \`risk:low\` \`depends:[]\`\n > After this: done.\n`, - ); - const planUnchecked = `# ${sid}: Slice One\n\n**Goal:** test.\n\n## Tasks\n\n- [ ] **T01: Task One** \`est:10m\`\n- [ ] **T02: Task Two** \`est:10m\`\n`; - writeFileSync(join(planDir, `${sid}-PLAN.md`), planUnchecked); - writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01: Task One\n\n**Goal:** t\n\n## Steps\n- step\n\n## Verification\n- v\n"); - writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02: Task Two\n\n**Goal:** t\n\n## Steps\n- step\n\n## Verification\n- v\n"); + const mid = "M001"; + const sid = "S01"; + const planDir = join(base, ".gsd", "milestones", mid, "slices", sid); + const tasksDir = join(planDir, "tasks"); + mkdirSync(tasksDir, { recursive: true }); + mkdirSync(join(base, ".gsd", "milestones", mid), { recursive: true }); - // Warm all caches - const state1 = await deriveState(base); - assert.equal(state1.activeTask?.id, "T01", "initial: T01 is active"); + writeFileSync( + join(base, ".gsd", "milestones", mid, `${mid}-ROADMAP.md`), + `# M001: Test Milestone\n\n**Vision:** test.\n\n## Slices\n\n- [ ] **${sid}: Slice One** \`risk:low\` \`depends:[]\`\n > After this: done.\n`, + ); + const planUnchecked = `# ${sid}: Slice One\n\n**Goal:** test.\n\n## Tasks\n\n- [ ] **T01: Task One** \`est:10m\`\n- [ ] **T02: Task Two** \`est:10m\`\n`; + writeFileSync(join(planDir, `${sid}-PLAN.md`), planUnchecked); + writeFileSync(join(tasksDir, "T01-PLAN.md"), "# T01: Task One\n\n**Goal:** t\n\n## Steps\n- step\n\n## Verification\n- v\n"); + writeFileSync(join(tasksDir, "T02-PLAN.md"), "# T02: Task Two\n\n**Goal:** t\n\n## Steps\n- step\n\n## Verification\n- v\n"); - // Simulate task completion on disk (what the LLM does) - const planChecked = `# ${sid}: Slice One\n\n**Goal:** test.\n\n## Tasks\n\n- [x] **T01: Task One** \`est:10m\`\n- [ ] **T02: Task Two** \`est:10m\`\n`; - writeFileSync(join(planDir, `${sid}-PLAN.md`), planChecked); - writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "---\nid: T01\n---\n# Summary\n"); + // Warm all caches + const state1 = await deriveState(base); + assert.equal(state1.activeTask?.id, "T01", "initial: T01 is active"); - // invalidateStateCache alone: _stateCache cleared but path/parse caches warm - invalidateStateCache(); + // Simulate task completion on disk (what the LLM does) + const planChecked = `# ${sid}: Slice One\n\n**Goal:** test.\n\n## Tasks\n\n- [x] **T01: Task One** \`est:10m\`\n- [ ] **T02: Task Two** \`est:10m\`\n`; + writeFileSync(join(planDir, `${sid}-PLAN.md`), planChecked); + writeFileSync(join(tasksDir, "T01-SUMMARY.md"), "---\nid: T01\n---\n# Summary\n"); - // invalidateAllCaches: all caches cleared — deriveState must re-read disk - invalidateAllCaches(); - const state2 = await deriveState(base); + // invalidateStateCache alone: _stateCache cleared but path/parse caches warm + invalidateStateCache(); - // After full invalidation, T01 should be complete and T02 should be next - assert.notEqual(state2.activeTask?.id, "T01", "#793: T01 not re-dispatched after full invalidation"); + // invalidateAllCaches: all caches cleared — deriveState must re-read disk + invalidateAllCaches(); + const state2 = await deriveState(base); - // Verify the caches are truly cleared by calling clearParseCache and clearPathCache - // do not throw (they should be no-ops after invalidateAllCaches already cleared them) - clearParseCache(); // no-op, but should not throw - assert.ok(true, "clearParseCache after invalidateAllCaches is safe"); - } finally { - cleanup(base); - } + // After full invalidation, T01 should be complete and T02 should be next + assert.notEqual(state2.activeTask?.id, "T01", "#793: T01 not re-dispatched after full invalidation"); + + // Verify the caches are truly cleared by calling clearParseCache and clearPathCache + // do not throw (they should be no-ops after invalidateAllCaches already cleared them) + clearParseCache(); // no-op, but should not throw + assert.ok(true, "clearParseCache after invalidateAllCaches is safe"); }); // ─── hasImplementationArtifacts (#1703) ─────────────────────────────────── @@ -766,88 +718,78 @@ function makeGitBase(): string { return base; } -test("hasImplementationArtifacts returns false when only .gsd/ files committed (#1703)", () => { +test("hasImplementationArtifacts returns false when only .gsd/ files committed (#1703)", (t) => { const base = makeGitBase(); - try { - // Create a feature branch and commit only .gsd/ files - execFileSync("git", ["checkout", "-b", "feat/test-milestone"], { cwd: base, stdio: "ignore" }); - mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); - writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# Roadmap"); - writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-SUMMARY.md"), "# Summary"); - execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" }); - execFileSync("git", ["commit", "-m", "chore: add plan files"], { cwd: base, stdio: "ignore" }); + t.after(() => cleanup(base)); - const result = hasImplementationArtifacts(base); - assert.equal(result, false, "should return false when only .gsd/ files were committed"); - } finally { - cleanup(base); - } + // Create a feature branch and commit only .gsd/ files + execFileSync("git", ["checkout", "-b", "feat/test-milestone"], { cwd: base, stdio: "ignore" }); + mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); + writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# Roadmap"); + writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-SUMMARY.md"), "# Summary"); + execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "chore: add plan files"], { cwd: base, stdio: "ignore" }); + + const result = hasImplementationArtifacts(base); + assert.equal(result, false, "should return false when only .gsd/ files were committed"); }); -test("hasImplementationArtifacts returns true when implementation files committed (#1703)", () => { +test("hasImplementationArtifacts returns true when implementation files committed (#1703)", (t) => { const base = makeGitBase(); - try { - // Create a feature branch with both .gsd/ and implementation files - execFileSync("git", ["checkout", "-b", "feat/test-impl"], { cwd: base, stdio: "ignore" }); - mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); - writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# Roadmap"); - mkdirSync(join(base, "src"), { recursive: true }); - writeFileSync(join(base, "src", "feature.ts"), "export function feature() {}"); - execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" }); - execFileSync("git", ["commit", "-m", "feat: add feature"], { cwd: base, stdio: "ignore" }); + t.after(() => cleanup(base)); - const result = hasImplementationArtifacts(base); - assert.equal(result, true, "should return true when implementation files are present"); - } finally { - cleanup(base); - } + // Create a feature branch with both .gsd/ and implementation files + execFileSync("git", ["checkout", "-b", "feat/test-impl"], { cwd: base, stdio: "ignore" }); + mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); + writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# Roadmap"); + mkdirSync(join(base, "src"), { recursive: true }); + writeFileSync(join(base, "src", "feature.ts"), "export function feature() {}"); + execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "feat: add feature"], { cwd: base, stdio: "ignore" }); + + const result = hasImplementationArtifacts(base); + assert.equal(result, true, "should return true when implementation files are present"); }); -test("hasImplementationArtifacts returns true on non-git directory (fail-open)", () => { +test("hasImplementationArtifacts returns true on non-git directory (fail-open)", (t) => { const base = join(tmpdir(), `gsd-test-nogit-${randomUUID()}`); mkdirSync(base, { recursive: true }); - try { - const result = hasImplementationArtifacts(base); - assert.equal(result, true, "should return true (fail-open) in non-git directory"); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const result = hasImplementationArtifacts(base); + assert.equal(result, true, "should return true (fail-open) in non-git directory"); }); // ─── verifyExpectedArtifact: complete-milestone requires impl artifacts (#1703) ── -test("verifyExpectedArtifact complete-milestone fails with only .gsd/ files (#1703)", () => { +test("verifyExpectedArtifact complete-milestone fails with only .gsd/ files (#1703)", (t) => { const base = makeGitBase(); - try { - // Create feature branch with only .gsd/ files - execFileSync("git", ["checkout", "-b", "feat/ms-only-gsd"], { cwd: base, stdio: "ignore" }); - mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); - writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-SUMMARY.md"), "# Milestone Summary\nDone."); - execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" }); - execFileSync("git", ["commit", "-m", "chore: milestone plan files"], { cwd: base, stdio: "ignore" }); + t.after(() => cleanup(base)); - const result = verifyExpectedArtifact("complete-milestone", "M001", base); - assert.equal(result, false, "complete-milestone should fail verification when only .gsd/ files present"); - } finally { - cleanup(base); - } + // Create feature branch with only .gsd/ files + execFileSync("git", ["checkout", "-b", "feat/ms-only-gsd"], { cwd: base, stdio: "ignore" }); + mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); + writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-SUMMARY.md"), "# Milestone Summary\nDone."); + execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "chore: milestone plan files"], { cwd: base, stdio: "ignore" }); + + const result = verifyExpectedArtifact("complete-milestone", "M001", base); + assert.equal(result, false, "complete-milestone should fail verification when only .gsd/ files present"); }); -test("verifyExpectedArtifact complete-milestone passes with impl files (#1703)", () => { +test("verifyExpectedArtifact complete-milestone passes with impl files (#1703)", (t) => { const base = makeGitBase(); - try { - // Create feature branch with implementation files AND milestone summary - execFileSync("git", ["checkout", "-b", "feat/ms-with-impl"], { cwd: base, stdio: "ignore" }); - mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); - writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-SUMMARY.md"), "# Milestone Summary\nDone."); - mkdirSync(join(base, "src"), { recursive: true }); - writeFileSync(join(base, "src", "app.ts"), "console.log('hello');"); - execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" }); - execFileSync("git", ["commit", "-m", "feat: implementation"], { cwd: base, stdio: "ignore" }); + t.after(() => cleanup(base)); - const result = verifyExpectedArtifact("complete-milestone", "M001", base); - assert.equal(result, true, "complete-milestone should pass verification with implementation files"); - } finally { - cleanup(base); - } + // Create feature branch with implementation files AND milestone summary + execFileSync("git", ["checkout", "-b", "feat/ms-with-impl"], { cwd: base, stdio: "ignore" }); + mkdirSync(join(base, ".gsd", "milestones", "M001"), { recursive: true }); + writeFileSync(join(base, ".gsd", "milestones", "M001", "M001-SUMMARY.md"), "# Milestone Summary\nDone."); + mkdirSync(join(base, "src"), { recursive: true }); + writeFileSync(join(base, "src", "app.ts"), "console.log('hello');"); + execFileSync("git", ["add", "."], { cwd: base, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "feat: implementation"], { cwd: base, stdio: "ignore" }); + + const result = verifyExpectedArtifact("complete-milestone", "M001", base); + assert.equal(result, true, "complete-milestone should pass verification with implementation files"); }); diff --git a/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts b/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts index a7512634f..1c970123d 100644 --- a/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts +++ b/src/resources/extensions/gsd/tests/auto-secrets-gate.test.ts @@ -43,31 +43,36 @@ function makeNoUICtx(cwd: string) { // ─── Scenario 1: No manifest exists ────────────────────────────────────────── -test('secrets gate: no manifest exists — getManifestStatus returns null', async () => { +test('secrets gate: no manifest exists — getManifestStatus returns null', async (t) => { const tmp = makeTempDir('gate-no-manifest'); - try { - // No .gsd directory at all - const result = await getManifestStatus(tmp, 'M001'); - assert.strictEqual(result, null, 'should return null when no manifest file exists'); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + t.after(() => rmSync(tmp, { recursive: true, force: true })); + + // No .gsd directory at all + const result = await getManifestStatus(tmp, 'M001'); + assert.strictEqual(result, null, 'should return null when no manifest file exists'); }); // ─── Scenario 2: Pending keys exist ───────────────────────────────────────── -test('secrets gate: pending keys exist — gate triggers collection, manifest updated on disk', async () => { +test('secrets gate: pending keys exist — gate triggers collection, manifest updated on disk', async (t) => { const tmp = makeTempDir('gate-pending'); const savedA = process.env.GSD_GATE_TEST_EXISTING; - try { - // Simulate one key already in env - process.env.GSD_GATE_TEST_EXISTING = 'already-here'; - - // Ensure pending keys are NOT in env + t.after(() => { + delete process.env.GSD_GATE_TEST_EXISTING; + if (savedA !== undefined) process.env.GSD_GATE_TEST_EXISTING = savedA; delete process.env.GSD_GATE_TEST_PEND_A; delete process.env.GSD_GATE_TEST_PEND_B; + rmSync(tmp, { recursive: true, force: true }); + }); - writeManifest(tmp, `# Secrets Manifest + // Simulate one key already in env + process.env.GSD_GATE_TEST_EXISTING = 'already-here'; + + // Ensure pending keys are NOT in env + delete process.env.GSD_GATE_TEST_PEND_A; + delete process.env.GSD_GATE_TEST_PEND_B; + + writeManifest(tmp, `# Secrets Manifest **Milestone:** M001 **Generated:** 2025-06-20T10:00:00Z @@ -97,62 +102,60 @@ test('secrets gate: pending keys exist — gate triggers collection, manifest up 1. Already in env `); - // (a) Verify getManifestStatus shows pending keys - const status = await getManifestStatus(tmp, 'M001'); - assert.notStrictEqual(status, null, 'manifest should exist'); - assert.ok(status!.pending.length > 0, 'should have pending keys'); - assert.deepStrictEqual(status!.pending, ['GSD_GATE_TEST_PEND_A', 'GSD_GATE_TEST_PEND_B'], 'pending keys'); - assert.deepStrictEqual(status!.existing, ['GSD_GATE_TEST_EXISTING'], 'existing keys'); + // (a) Verify getManifestStatus shows pending keys + const status = await getManifestStatus(tmp, 'M001'); + assert.notStrictEqual(status, null, 'manifest should exist'); + assert.ok(status!.pending.length > 0, 'should have pending keys'); + assert.deepStrictEqual(status!.pending, ['GSD_GATE_TEST_PEND_A', 'GSD_GATE_TEST_PEND_B'], 'pending keys'); + assert.deepStrictEqual(status!.existing, ['GSD_GATE_TEST_EXISTING'], 'existing keys'); - // (b) Call collectSecretsFromManifest with no-UI context - // With hasUI: false, collectOneSecret returns null → pending keys become "skipped" - const result = await collectSecretsFromManifest(tmp, 'M001', makeNoUICtx(tmp)); + // (b) Call collectSecretsFromManifest with no-UI context + // With hasUI: false, collectOneSecret returns null → pending keys become "skipped" + const result = await collectSecretsFromManifest(tmp, 'M001', makeNoUICtx(tmp)); - // (c) Verify return shape - assert.deepStrictEqual(result.applied, [], 'no keys applied (no UI to enter values)'); - assert.ok(result.skipped.includes('GSD_GATE_TEST_PEND_A'), 'PEND_A should be skipped'); - assert.ok(result.skipped.includes('GSD_GATE_TEST_PEND_B'), 'PEND_B should be skipped'); - assert.deepStrictEqual(result.existingSkipped, ['GSD_GATE_TEST_EXISTING']); + // (c) Verify return shape + assert.deepStrictEqual(result.applied, [], 'no keys applied (no UI to enter values)'); + assert.ok(result.skipped.includes('GSD_GATE_TEST_PEND_A'), 'PEND_A should be skipped'); + assert.ok(result.skipped.includes('GSD_GATE_TEST_PEND_B'), 'PEND_B should be skipped'); + assert.deepStrictEqual(result.existingSkipped, ['GSD_GATE_TEST_EXISTING']); - // (d) Verify manifest on disk was updated — pending entries that went through - // collection are now "skipped". The existing-in-env entry retains its manifest - // status ("pending") because collectSecretsFromManifest only updates entries - // that flow through collectOneSecret. At runtime, getManifestStatus overrides - // env-present entries to "existing" regardless of manifest status. - const manifestPath = join(tmp, '.gsd', 'milestones', 'M001', 'M001-SECRETS.md'); - const updatedContent = readFileSync(manifestPath, 'utf8'); - assert.ok( - updatedContent.includes('**Status:** skipped'), - 'formerly-pending entries should now have status "skipped" in the manifest file', - ); - // Count: PEND_A → skipped, PEND_B → skipped, EXISTING stays pending on disk - const skippedMatches = updatedContent.match(/\*\*Status:\*\* skipped/g); - assert.strictEqual(skippedMatches?.length, 2, 'two entries should have status "skipped"'); - const pendingMatches = updatedContent.match(/\*\*Status:\*\* pending/g); - assert.strictEqual(pendingMatches?.length, 1, 'one entry (existing-in-env) retains pending on disk'); + // (d) Verify manifest on disk was updated — pending entries that went through + // collection are now "skipped". The existing-in-env entry retains its manifest + // status ("pending") because collectSecretsFromManifest only updates entries + // that flow through collectOneSecret. At runtime, getManifestStatus overrides + // env-present entries to "existing" regardless of manifest status. + const manifestPath = join(tmp, '.gsd', 'milestones', 'M001', 'M001-SECRETS.md'); + const updatedContent = readFileSync(manifestPath, 'utf8'); + assert.ok( + updatedContent.includes('**Status:** skipped'), + 'formerly-pending entries should now have status "skipped" in the manifest file', + ); + // Count: PEND_A → skipped, PEND_B → skipped, EXISTING stays pending on disk + const skippedMatches = updatedContent.match(/\*\*Status:\*\* skipped/g); + assert.strictEqual(skippedMatches?.length, 2, 'two entries should have status "skipped"'); + const pendingMatches = updatedContent.match(/\*\*Status:\*\* pending/g); + assert.strictEqual(pendingMatches?.length, 1, 'one entry (existing-in-env) retains pending on disk'); - // (e) Verify getManifestStatus now shows no pending - const statusAfter = await getManifestStatus(tmp, 'M001'); - assert.notStrictEqual(statusAfter, null); - assert.deepStrictEqual(statusAfter!.pending, [], 'no pending keys after collection'); - } finally { - delete process.env.GSD_GATE_TEST_EXISTING; - if (savedA !== undefined) process.env.GSD_GATE_TEST_EXISTING = savedA; - delete process.env.GSD_GATE_TEST_PEND_A; - delete process.env.GSD_GATE_TEST_PEND_B; - rmSync(tmp, { recursive: true, force: true }); - } + // (e) Verify getManifestStatus now shows no pending + const statusAfter = await getManifestStatus(tmp, 'M001'); + assert.notStrictEqual(statusAfter, null); + assert.deepStrictEqual(statusAfter!.pending, [], 'no pending keys after collection'); }); // ─── Scenario 3: No pending keys — all collected or in env ────────────────── -test('secrets gate: no pending keys — getManifestStatus shows pending.length === 0', async () => { +test('secrets gate: no pending keys — getManifestStatus shows pending.length === 0', async (t) => { const tmp = makeTempDir('gate-no-pending'); const savedKey = process.env.GSD_GATE_TEST_ENVKEY; - try { - process.env.GSD_GATE_TEST_ENVKEY = 'some-value'; + t.after(() => { + delete process.env.GSD_GATE_TEST_ENVKEY; + if (savedKey !== undefined) process.env.GSD_GATE_TEST_ENVKEY = savedKey; + rmSync(tmp, { recursive: true, force: true }); + }); - writeManifest(tmp, `# Secrets Manifest + process.env.GSD_GATE_TEST_ENVKEY = 'some-value'; + + writeManifest(tmp, `# Secrets Manifest **Milestone:** M001 **Generated:** 2025-06-20T10:00:00Z @@ -182,15 +185,10 @@ test('secrets gate: no pending keys — getManifestStatus shows pending.length = 1. In env already `); - const result = await getManifestStatus(tmp, 'M001'); - assert.notStrictEqual(result, null, 'manifest should exist'); - assert.deepStrictEqual(result!.pending, [], 'no pending keys — gate would skip'); - assert.deepStrictEqual(result!.collected, ['ALREADY_COLLECTED']); - assert.deepStrictEqual(result!.skipped, ['ALREADY_SKIPPED']); - assert.deepStrictEqual(result!.existing, ['GSD_GATE_TEST_ENVKEY']); - } finally { - delete process.env.GSD_GATE_TEST_ENVKEY; - if (savedKey !== undefined) process.env.GSD_GATE_TEST_ENVKEY = savedKey; - rmSync(tmp, { recursive: true, force: true }); - } + const result = await getManifestStatus(tmp, 'M001'); + assert.notStrictEqual(result, null, 'manifest should exist'); + assert.deepStrictEqual(result!.pending, [], 'no pending keys — gate would skip'); + assert.deepStrictEqual(result!.collected, ['ALREADY_COLLECTED']); + assert.deepStrictEqual(result!.skipped, ['ALREADY_SKIPPED']); + assert.deepStrictEqual(result!.existing, ['GSD_GATE_TEST_ENVKEY']); }); diff --git a/src/resources/extensions/gsd/tests/captures.test.ts b/src/resources/extensions/gsd/tests/captures.test.ts index f18e7c49c..2e6618604 100644 --- a/src/resources/extensions/gsd/tests/captures.test.ts +++ b/src/resources/extensions/gsd/tests/captures.test.ts @@ -36,176 +36,156 @@ function makeTempDir(prefix: string): string { // ─── appendCapture ──────────────────────────────────────────────────────────── -test("captures: appendCapture creates CAPTURES.md on first call", () => { +test("captures: appendCapture creates CAPTURES.md on first call", (t) => { const tmp = makeTempDir("cap-create"); - try { - const id = appendCapture(tmp, "first thought"); - assert.ok(id.startsWith("CAP-"), "ID should start with CAP-"); - assert.ok( - existsSync(join(tmp, ".gsd", "CAPTURES.md")), - "CAPTURES.md should exist", - ); - const content = readFileSync(join(tmp, ".gsd", "CAPTURES.md"), "utf-8"); - assert.ok(content.includes("# Captures"), "should have header"); - assert.ok(content.includes(`### ${id}`), "should have entry heading"); - assert.ok( - content.includes("**Text:** first thought"), - "should have text field", - ); - assert.ok( - content.includes("**Status:** pending"), - "should have pending status", - ); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + t.after(() => rmSync(tmp, { recursive: true, force: true })); + + const id = appendCapture(tmp, "first thought"); + assert.ok(id.startsWith("CAP-"), "ID should start with CAP-"); + assert.ok( + existsSync(join(tmp, ".gsd", "CAPTURES.md")), + "CAPTURES.md should exist", + ); + const content = readFileSync(join(tmp, ".gsd", "CAPTURES.md"), "utf-8"); + assert.ok(content.includes("# Captures"), "should have header"); + assert.ok(content.includes(`### ${id}`), "should have entry heading"); + assert.ok( + content.includes("**Text:** first thought"), + "should have text field", + ); + assert.ok( + content.includes("**Status:** pending"), + "should have pending status", + ); }); -test("captures: appendCapture appends to existing file", () => { +test("captures: appendCapture appends to existing file", (t) => { const tmp = makeTempDir("cap-append"); - try { - const id1 = appendCapture(tmp, "thought one"); - const id2 = appendCapture(tmp, "thought two"); - assert.notStrictEqual(id1, id2, "IDs should be unique"); + t.after(() => rmSync(tmp, { recursive: true, force: true })); - const content = readFileSync(join(tmp, ".gsd", "CAPTURES.md"), "utf-8"); - assert.ok(content.includes(`### ${id1}`), "should have first entry"); - assert.ok(content.includes(`### ${id2}`), "should have second entry"); - assert.ok( - content.includes("**Text:** thought one"), - "should have first text", - ); - assert.ok( - content.includes("**Text:** thought two"), - "should have second text", - ); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + const id1 = appendCapture(tmp, "thought one"); + const id2 = appendCapture(tmp, "thought two"); + assert.notStrictEqual(id1, id2, "IDs should be unique"); + + const content = readFileSync(join(tmp, ".gsd", "CAPTURES.md"), "utf-8"); + assert.ok(content.includes(`### ${id1}`), "should have first entry"); + assert.ok(content.includes(`### ${id2}`), "should have second entry"); + assert.ok( + content.includes("**Text:** thought one"), + "should have first text", + ); + assert.ok( + content.includes("**Text:** thought two"), + "should have second text", + ); }); // ─── loadAllCaptures / loadPendingCaptures ──────────────────────────────────── -test("captures: loadAllCaptures parses entries correctly", () => { +test("captures: loadAllCaptures parses entries correctly", (t) => { const tmp = makeTempDir("cap-load"); - try { - appendCapture(tmp, "alpha"); - appendCapture(tmp, "beta"); + t.after(() => rmSync(tmp, { recursive: true, force: true })); - const all = loadAllCaptures(tmp); - assert.strictEqual(all.length, 2, "should have 2 entries"); - assert.strictEqual(all[0].text, "alpha"); - assert.strictEqual(all[1].text, "beta"); - assert.strictEqual(all[0].status, "pending"); - assert.strictEqual(all[1].status, "pending"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + appendCapture(tmp, "alpha"); + appendCapture(tmp, "beta"); + + const all = loadAllCaptures(tmp); + assert.strictEqual(all.length, 2, "should have 2 entries"); + assert.strictEqual(all[0].text, "alpha"); + assert.strictEqual(all[1].text, "beta"); + assert.strictEqual(all[0].status, "pending"); + assert.strictEqual(all[1].status, "pending"); }); -test("captures: loadAllCaptures returns empty array when no file", () => { +test("captures: loadAllCaptures returns empty array when no file", (t) => { const tmp = makeTempDir("cap-nofile"); - try { - const all = loadAllCaptures(tmp); - assert.strictEqual(all.length, 0); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + t.after(() => rmSync(tmp, { recursive: true, force: true })); + + const all = loadAllCaptures(tmp); + assert.strictEqual(all.length, 0); }); -test("captures: loadPendingCaptures filters resolved entries", () => { +test("captures: loadPendingCaptures filters resolved entries", (t) => { const tmp = makeTempDir("cap-pending"); - try { - const id1 = appendCapture(tmp, "pending one"); - appendCapture(tmp, "pending two"); + t.after(() => rmSync(tmp, { recursive: true, force: true })); - markCaptureResolved(tmp, id1, "note", "acknowledged", "just a note"); + const id1 = appendCapture(tmp, "pending one"); + appendCapture(tmp, "pending two"); - const pending = loadPendingCaptures(tmp); - assert.strictEqual(pending.length, 1, "should have 1 pending"); - assert.strictEqual(pending[0].text, "pending two"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + markCaptureResolved(tmp, id1, "note", "acknowledged", "just a note"); + + const pending = loadPendingCaptures(tmp); + assert.strictEqual(pending.length, 1, "should have 1 pending"); + assert.strictEqual(pending[0].text, "pending two"); }); -test("captures: loadAllCaptures preserves resolved entries", () => { +test("captures: loadAllCaptures preserves resolved entries", (t) => { const tmp = makeTempDir("cap-all-resolved"); - try { - const id1 = appendCapture(tmp, "pending one"); - appendCapture(tmp, "pending two"); + t.after(() => rmSync(tmp, { recursive: true, force: true })); - markCaptureResolved(tmp, id1, "note", "acknowledged", "just a note"); + const id1 = appendCapture(tmp, "pending one"); + appendCapture(tmp, "pending two"); - const all = loadAllCaptures(tmp); - assert.strictEqual(all.length, 2, "all should still have 2"); - assert.strictEqual(all[0].status, "resolved"); - assert.strictEqual(all[1].status, "pending"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + markCaptureResolved(tmp, id1, "note", "acknowledged", "just a note"); + + const all = loadAllCaptures(tmp); + assert.strictEqual(all.length, 2, "all should still have 2"); + assert.strictEqual(all[0].status, "resolved"); + assert.strictEqual(all[1].status, "pending"); }); // ─── hasPendingCaptures ─────────────────────────────────────────────────────── -test("captures: hasPendingCaptures returns false when no file", () => { +test("captures: hasPendingCaptures returns false when no file", (t) => { const tmp = makeTempDir("cap-has-nofile"); - try { - assert.strictEqual(hasPendingCaptures(tmp), false); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + t.after(() => rmSync(tmp, { recursive: true, force: true })); + + assert.strictEqual(hasPendingCaptures(tmp), false); }); -test("captures: hasPendingCaptures returns true with pending entries", () => { +test("captures: hasPendingCaptures returns true with pending entries", (t) => { const tmp = makeTempDir("cap-has-true"); - try { - appendCapture(tmp, "something"); - assert.strictEqual(hasPendingCaptures(tmp), true); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + t.after(() => rmSync(tmp, { recursive: true, force: true })); + + appendCapture(tmp, "something"); + assert.strictEqual(hasPendingCaptures(tmp), true); }); -test("captures: hasPendingCaptures returns false when all resolved", () => { +test("captures: hasPendingCaptures returns false when all resolved", (t) => { const tmp = makeTempDir("cap-has-false"); - try { - const id = appendCapture(tmp, "will resolve"); - markCaptureResolved(tmp, id, "note", "done", "resolved it"); - assert.strictEqual(hasPendingCaptures(tmp), false); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + t.after(() => rmSync(tmp, { recursive: true, force: true })); + + const id = appendCapture(tmp, "will resolve"); + markCaptureResolved(tmp, id, "note", "done", "resolved it"); + assert.strictEqual(hasPendingCaptures(tmp), false); }); // ─── markCaptureResolved ────────────────────────────────────────────────────── -test("captures: markCaptureResolved updates entry in place", () => { +test("captures: markCaptureResolved updates entry in place", (t) => { const tmp = makeTempDir("cap-resolve"); - try { - const id1 = appendCapture(tmp, "keep pending"); - const id2 = appendCapture(tmp, "will resolve"); - appendCapture(tmp, "also pending"); + t.after(() => rmSync(tmp, { recursive: true, force: true })); - markCaptureResolved(tmp, id2, "quick-task", "executed inline", "small fix"); + const id1 = appendCapture(tmp, "keep pending"); + const id2 = appendCapture(tmp, "will resolve"); + appendCapture(tmp, "also pending"); - const all = loadAllCaptures(tmp); - assert.strictEqual(all.length, 3, "should still have 3 entries"); + markCaptureResolved(tmp, id2, "quick-task", "executed inline", "small fix"); - const resolved = all.find((c) => c.id === id2)!; - assert.strictEqual(resolved.status, "resolved"); - assert.strictEqual(resolved.classification, "quick-task"); - assert.strictEqual(resolved.resolution, "executed inline"); - assert.strictEqual(resolved.rationale, "small fix"); - assert.ok(resolved.resolvedAt, "should have resolved timestamp"); + const all = loadAllCaptures(tmp); + assert.strictEqual(all.length, 3, "should still have 3 entries"); - // Others should be unaffected - const kept = all.find((c) => c.id === id1)!; - assert.strictEqual(kept.status, "pending"); - assert.strictEqual(kept.classification, undefined); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + const resolved = all.find((c) => c.id === id2)!; + assert.strictEqual(resolved.status, "resolved"); + assert.strictEqual(resolved.classification, "quick-task"); + assert.strictEqual(resolved.resolution, "executed inline"); + assert.strictEqual(resolved.rationale, "small fix"); + assert.ok(resolved.resolvedAt, "should have resolved timestamp"); + + // Others should be unaffected + const kept = all.find((c) => c.id === id1)!; + assert.strictEqual(kept.status, "pending"); + assert.strictEqual(kept.classification, undefined); }); // ─── resolveCapturesPath ────────────────────────────────────────────────────── @@ -371,58 +351,50 @@ test("triage: parseTriageOutput handles all five classification types", () => { // ─── Edge Cases ─────────────────────────────────────────────────────────────── -test("captures: appendCapture handles special characters in text", () => { +test("captures: appendCapture handles special characters in text", (t) => { const tmp = makeTempDir("cap-special"); - try { - const id = appendCapture(tmp, 'text with "quotes" and **bold** and `code`'); - const all = loadAllCaptures(tmp); - assert.strictEqual(all.length, 1); - assert.ok(all[0].text.includes('"quotes"'), "should preserve quotes"); - assert.ok(all[0].text.includes("**bold**"), "should preserve bold"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + t.after(() => rmSync(tmp, { recursive: true, force: true })); + + const id = appendCapture(tmp, 'text with "quotes" and **bold** and `code`'); + const all = loadAllCaptures(tmp); + assert.strictEqual(all.length, 1); + assert.ok(all[0].text.includes('"quotes"'), "should preserve quotes"); + assert.ok(all[0].text.includes("**bold**"), "should preserve bold"); }); -test("captures: markCaptureResolved is no-op for non-existent ID", () => { +test("captures: markCaptureResolved is no-op for non-existent ID", (t) => { const tmp = makeTempDir("cap-noop"); - try { - appendCapture(tmp, "real capture"); - // Should not throw - markCaptureResolved(tmp, "CAP-nonexistent", "note", "test", "test"); - const all = loadAllCaptures(tmp); - assert.strictEqual(all.length, 1); - assert.strictEqual(all[0].status, "pending", "original should be unchanged"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + t.after(() => rmSync(tmp, { recursive: true, force: true })); + + appendCapture(tmp, "real capture"); + // Should not throw + markCaptureResolved(tmp, "CAP-nonexistent", "note", "test", "test"); + const all = loadAllCaptures(tmp); + assert.strictEqual(all.length, 1); + assert.strictEqual(all[0].status, "pending", "original should be unchanged"); }); -test("captures: markCaptureResolved is no-op when no file exists", () => { +test("captures: markCaptureResolved is no-op when no file exists", (t) => { const tmp = makeTempDir("cap-nofile-resolve"); - try { - // Should not throw - markCaptureResolved(tmp, "CAP-abc", "note", "test", "test"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + t.after(() => rmSync(tmp, { recursive: true, force: true })); + + // Should not throw + markCaptureResolved(tmp, "CAP-abc", "note", "test", "test"); }); -test("captures: re-resolving a capture overwrites previous resolution", () => { +test("captures: re-resolving a capture overwrites previous resolution", (t) => { const tmp = makeTempDir("cap-reresolve"); - try { - const id = appendCapture(tmp, "will re-resolve"); - markCaptureResolved(tmp, id, "note", "first resolution", "first rationale"); - markCaptureResolved(tmp, id, "inject", "second resolution", "second rationale"); + t.after(() => rmSync(tmp, { recursive: true, force: true })); - const all = loadAllCaptures(tmp); - assert.strictEqual(all.length, 1); - assert.strictEqual(all[0].classification, "inject", "should have updated classification"); - assert.strictEqual(all[0].resolution, "second resolution"); - assert.strictEqual(all[0].rationale, "second rationale"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + const id = appendCapture(tmp, "will re-resolve"); + markCaptureResolved(tmp, id, "note", "first resolution", "first rationale"); + markCaptureResolved(tmp, id, "inject", "second resolution", "second rationale"); + + const all = loadAllCaptures(tmp); + assert.strictEqual(all.length, 1); + assert.strictEqual(all[0].classification, "inject", "should have updated classification"); + assert.strictEqual(all[0].resolution, "second resolution"); + assert.strictEqual(all[0].rationale, "second rationale"); }); test("triage: parseTriageOutput preserves affectedFiles and targetSlice", () => { diff --git a/src/resources/extensions/gsd/tests/claude-import-tui.test.ts b/src/resources/extensions/gsd/tests/claude-import-tui.test.ts index 12d64f99a..c3728cbce 100644 --- a/src/resources/extensions/gsd/tests/claude-import-tui.test.ts +++ b/src/resources/extensions/gsd/tests/claude-import-tui.test.ts @@ -8,7 +8,6 @@ * `/plugin marketplace add ...` source model. */ - import { describe, it, before, after, mock } from 'node:test'; import assert from 'node:assert'; import { existsSync, mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs'; @@ -306,45 +305,45 @@ describe( }); }); - it('should not persist marketplace agent directories into package sources', async () => { + it('should not persist marketplace agent directories into package sources', async (t) => { const isolatedAgentDir = join(tempDir, '.gsd', 'agent'); const settingsPath = join(isolatedAgentDir, 'settings.json'); rmSync(isolatedAgentDir, { recursive: true, force: true }); process.env.GSD_CODING_AGENT_DIR = isolatedAgentDir; - try { - mkdirSync(isolatedAgentDir, { recursive: true }); - const tempSettings: Record = { packages: [] }; - writeFileSync(settingsPath, JSON.stringify(tempSettings, null, 2)); - - const { ctx } = createMockContext([ - 'Plugins only', - 'Yes - discover plugins and select components', - 'Import all components', - 'Yes, continue', - ]); - - const readPrefs = () => ({ ...prefs }); - const writePrefs = async (p: Record) => { - Object.assign(prefs, p); - }; - - await runClaudeImportFlow(ctx, 'global', readPrefs, writePrefs); - - const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as { packages?: unknown[] }; - const packageEntries = Array.isArray(settings.packages) ? settings.packages : []; - const hasAgentsDirPackage = packageEntries.some((entry) => { - const source = typeof entry === 'string' - ? entry - : (entry && typeof entry === 'object' ? (entry as { source?: unknown }).source : undefined); - return typeof source === 'string' && source.endsWith('/agents'); - }); - - assert.strictEqual(hasAgentsDirPackage, false, 'Marketplace agent directories should not be persisted as package sources'); - } finally { + t.after(() => { delete process.env.GSD_CODING_AGENT_DIR; rmSync(isolatedAgentDir, { recursive: true, force: true }); - } + }); + + mkdirSync(isolatedAgentDir, { recursive: true }); + const tempSettings: Record = { packages: [] }; + writeFileSync(settingsPath, JSON.stringify(tempSettings, null, 2)); + + const { ctx } = createMockContext([ + 'Plugins only', + 'Yes - discover plugins and select components', + 'Import all components', + 'Yes, continue', + ]); + + const readPrefs = () => ({ ...prefs }); + const writePrefs = async (p: Record) => { + Object.assign(prefs, p); + }; + + await runClaudeImportFlow(ctx, 'global', readPrefs, writePrefs); + + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as { packages?: unknown[] }; + const packageEntries = Array.isArray(settings.packages) ? settings.packages : []; + const hasAgentsDirPackage = packageEntries.some((entry) => { + const source = typeof entry === 'string' + ? entry + : (entry && typeof entry === 'object' ? (entry as { source?: unknown }).source : undefined); + return typeof source === 'string' && source.endsWith('/agents'); + }); + + assert.strictEqual(hasAgentsDirPackage, false, 'Marketplace agent directories should not be persisted as package sources'); }); }); } diff --git a/src/resources/extensions/gsd/tests/collect-from-manifest.test.ts b/src/resources/extensions/gsd/tests/collect-from-manifest.test.ts index 3ac66bba9..c0a62946f 100644 --- a/src/resources/extensions/gsd/tests/collect-from-manifest.test.ts +++ b/src/resources/extensions/gsd/tests/collect-from-manifest.test.ts @@ -91,142 +91,140 @@ async function loadGuidanceExport(): Promise<{ collectOneSecretWithGuidance: Fun // ─── collectSecretsFromManifest: categorization ─────────────────────────────── -test("collectSecretsFromManifest: categorizes entries — pending keys need collection, existing keys are skipped", async () => { +test("collectSecretsFromManifest: categorizes entries — pending keys need collection, existing keys are skipped", async (t) => { const { collectSecretsFromManifest } = await loadOrchestrator(); const tmp = makeTempDir("manifest-collect"); const savedA = process.env.EXISTING_KEY_A; - try { - process.env.EXISTING_KEY_A = "already-set"; - - const manifest = makeManifest([ - { key: "EXISTING_KEY_A", status: "pending" }, - { key: "PENDING_KEY_B", status: "pending", guidance: ["Step 1: Go to dashboard", "Step 2: Click create key"] }, - { key: "SKIPPED_KEY_C", status: "skipped" }, - ]); - await writeManifestFile(tmp, manifest); - - let callIndex = 0; - const mockCtx = { - cwd: tmp, - hasUI: true, - ui: { - custom: async (_factory: any) => { - callIndex++; - if (callIndex <= 1) return null; // summary screen dismiss - return "mock-secret-value"; // collect pending key - }, - }, - }; - - const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any); - - // EXISTING_KEY_A should be in existingSkipped (it's in process.env) - assert.ok(result.existingSkipped?.includes("EXISTING_KEY_A"), - "EXISTING_KEY_A should be in existingSkipped"); - - // PENDING_KEY_B should have been collected (applied) - assert.ok(result.applied.includes("PENDING_KEY_B"), - "PENDING_KEY_B should be in applied"); - - // SKIPPED_KEY_C should remain skipped - assert.ok(result.skipped.includes("SKIPPED_KEY_C"), - "SKIPPED_KEY_C should be in skipped"); - } finally { + t.after(() => { delete process.env.EXISTING_KEY_A; if (savedA !== undefined) process.env.EXISTING_KEY_A = savedA; rmSync(tmp, { recursive: true, force: true }); - } + }); + + process.env.EXISTING_KEY_A = "already-set"; + + const manifest = makeManifest([ + { key: "EXISTING_KEY_A", status: "pending" }, + { key: "PENDING_KEY_B", status: "pending", guidance: ["Step 1: Go to dashboard", "Step 2: Click create key"] }, + { key: "SKIPPED_KEY_C", status: "skipped" }, + ]); + await writeManifestFile(tmp, manifest); + + let callIndex = 0; + const mockCtx = { + cwd: tmp, + hasUI: true, + ui: { + custom: async (_factory: any) => { + callIndex++; + if (callIndex <= 1) return null; // summary screen dismiss + return "mock-secret-value"; // collect pending key + }, + }, + }; + + const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any); + + // EXISTING_KEY_A should be in existingSkipped (it's in process.env) + assert.ok(result.existingSkipped?.includes("EXISTING_KEY_A"), + "EXISTING_KEY_A should be in existingSkipped"); + + // PENDING_KEY_B should have been collected (applied) + assert.ok(result.applied.includes("PENDING_KEY_B"), + "PENDING_KEY_B should be in applied"); + + // SKIPPED_KEY_C should remain skipped + assert.ok(result.skipped.includes("SKIPPED_KEY_C"), + "SKIPPED_KEY_C should be in skipped"); }); -test("collectSecretsFromManifest: existing keys are excluded from the collection list — not prompted", async () => { +test("collectSecretsFromManifest: existing keys are excluded from the collection list — not prompted", async (t) => { const { collectSecretsFromManifest } = await loadOrchestrator(); const tmp = makeTempDir("manifest-collect-skip"); const savedA = process.env.ALREADY_SET_KEY; - try { - process.env.ALREADY_SET_KEY = "present"; - - const manifest = makeManifest([ - { key: "ALREADY_SET_KEY", status: "pending" }, - { key: "NEEDS_COLLECTION", status: "pending" }, - ]); - await writeManifestFile(tmp, manifest); - - const collectedKeyNames: string[] = []; - let summaryShown = false; - const mockCtx = { - cwd: tmp, - hasUI: true, - ui: { - custom: async (factory: any) => { - // Intercept the factory to check what key is being collected - if (!summaryShown) { - summaryShown = true; - return null; // dismiss summary - } - collectedKeyNames.push("prompted"); - return "mock-value"; - }, - }, - }; - - const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any); - - // ALREADY_SET_KEY should not have been prompted — only NEEDS_COLLECTION should - assert.ok(!result.applied.includes("ALREADY_SET_KEY"), - "ALREADY_SET_KEY should not be in applied (it was auto-skipped)"); - assert.ok(result.existingSkipped?.includes("ALREADY_SET_KEY"), - "ALREADY_SET_KEY should be in existingSkipped"); - } finally { + t.after(() => { delete process.env.ALREADY_SET_KEY; if (savedA !== undefined) process.env.ALREADY_SET_KEY = savedA; rmSync(tmp, { recursive: true, force: true }); - } + }); + + process.env.ALREADY_SET_KEY = "present"; + + const manifest = makeManifest([ + { key: "ALREADY_SET_KEY", status: "pending" }, + { key: "NEEDS_COLLECTION", status: "pending" }, + ]); + await writeManifestFile(tmp, manifest); + + const collectedKeyNames: string[] = []; + let summaryShown = false; + const mockCtx = { + cwd: tmp, + hasUI: true, + ui: { + custom: async (factory: any) => { + // Intercept the factory to check what key is being collected + if (!summaryShown) { + summaryShown = true; + return null; // dismiss summary + } + collectedKeyNames.push("prompted"); + return "mock-value"; + }, + }, + }; + + const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any); + + // ALREADY_SET_KEY should not have been prompted — only NEEDS_COLLECTION should + assert.ok(!result.applied.includes("ALREADY_SET_KEY"), + "ALREADY_SET_KEY should not be in applied (it was auto-skipped)"); + assert.ok(result.existingSkipped?.includes("ALREADY_SET_KEY"), + "ALREADY_SET_KEY should be in existingSkipped"); }); -test("collectSecretsFromManifest: manifest statuses are updated after collection", async () => { +test("collectSecretsFromManifest: manifest statuses are updated after collection", async (t) => { const { collectSecretsFromManifest } = await loadOrchestrator(); const tmp = makeTempDir("manifest-update"); - try { - const manifest = makeManifest([ - { key: "KEY_TO_COLLECT", status: "pending" }, - { key: "KEY_TO_SKIP", status: "pending" }, - ]); - const manifestPath = await writeManifestFile(tmp, manifest); + t.after(() => rmSync(tmp, { recursive: true, force: true })); - let callIndex = 0; - const mockCtx = { - cwd: tmp, - hasUI: true, - ui: { - custom: async (_factory: any) => { - callIndex++; - if (callIndex <= 1) return null; // summary screen dismiss - if (callIndex === 2) return "secret-value"; // KEY_TO_COLLECT - return null; // KEY_TO_SKIP — user skips - }, + const manifest = makeManifest([ + { key: "KEY_TO_COLLECT", status: "pending" }, + { key: "KEY_TO_SKIP", status: "pending" }, + ]); + const manifestPath = await writeManifestFile(tmp, manifest); + + let callIndex = 0; + const mockCtx = { + cwd: tmp, + hasUI: true, + ui: { + custom: async (_factory: any) => { + callIndex++; + if (callIndex <= 1) return null; // summary screen dismiss + if (callIndex === 2) return "secret-value"; // KEY_TO_COLLECT + return null; // KEY_TO_SKIP — user skips }, - }; + }, + }; - await collectSecretsFromManifest(tmp, "M001", mockCtx as any); + await collectSecretsFromManifest(tmp, "M001", mockCtx as any); - // Read back the manifest file and verify statuses were updated - const { parseSecretsManifest } = await loadFilesExports(); - const updatedContent = readFileSync(manifestPath, "utf8"); - const updatedManifest = parseSecretsManifest(updatedContent); + // Read back the manifest file and verify statuses were updated + const { parseSecretsManifest } = await loadFilesExports(); + const updatedContent = readFileSync(manifestPath, "utf8"); + const updatedManifest = parseSecretsManifest(updatedContent); - const keyToCollect = updatedManifest.entries.find(e => e.key === "KEY_TO_COLLECT"); - const keyToSkip = updatedManifest.entries.find(e => e.key === "KEY_TO_SKIP"); + const keyToCollect = updatedManifest.entries.find(e => e.key === "KEY_TO_COLLECT"); + const keyToSkip = updatedManifest.entries.find(e => e.key === "KEY_TO_SKIP"); - assert.equal(keyToCollect?.status, "collected", - "KEY_TO_COLLECT should have status 'collected' after providing a value"); - assert.equal(keyToSkip?.status, "skipped", - "KEY_TO_SKIP should have status 'skipped' after user skipped it"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + assert.equal(keyToCollect?.status, "collected", + "KEY_TO_COLLECT should have status 'collected' after providing a value"); + assert.equal(keyToSkip?.status, "skipped", + "KEY_TO_SKIP should have status 'skipped' after user skipped it"); }); // ─── showSecretsSummary: render output ──────────────────────────────────────── @@ -423,47 +421,47 @@ test("collectOneSecret: no guidance provided — render output has no guidance s // ─── collectSecretsFromManifest: returns structured result ──────────────────── -test("collectSecretsFromManifest: returns result with applied, skipped, and existingSkipped arrays", async () => { +test("collectSecretsFromManifest: returns result with applied, skipped, and existingSkipped arrays", async (t) => { const { collectSecretsFromManifest } = await loadOrchestrator(); const tmp = makeTempDir("manifest-result"); const savedKey = process.env.RESULT_TEST_EXISTING; - try { - process.env.RESULT_TEST_EXISTING = "already-here"; - - const manifest = makeManifest([ - { key: "RESULT_TEST_EXISTING", status: "pending" }, - { key: "RESULT_TEST_NEW", status: "pending" }, - ]); - await writeManifestFile(tmp, manifest); - - let callIndex = 0; - const mockCtx = { - cwd: tmp, - hasUI: true, - ui: { - custom: async (_factory: any) => { - callIndex++; - if (callIndex <= 1) return null; // summary dismiss - return "secret-value"; // collect the pending key - }, - }, - }; - - const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any); - - // Verify result shape - assert.ok(Array.isArray(result.applied), "result should have applied array"); - assert.ok(Array.isArray(result.skipped), "result should have skipped array"); - assert.ok(Array.isArray(result.existingSkipped), "result should have existingSkipped array"); - - assert.ok(result.existingSkipped.includes("RESULT_TEST_EXISTING"), - "existing key should be in existingSkipped"); - assert.ok(result.applied.includes("RESULT_TEST_NEW"), - "collected key should be in applied"); - } finally { + t.after(() => { delete process.env.RESULT_TEST_EXISTING; if (savedKey !== undefined) process.env.RESULT_TEST_EXISTING = savedKey; rmSync(tmp, { recursive: true, force: true }); - } + }); + + process.env.RESULT_TEST_EXISTING = "already-here"; + + const manifest = makeManifest([ + { key: "RESULT_TEST_EXISTING", status: "pending" }, + { key: "RESULT_TEST_NEW", status: "pending" }, + ]); + await writeManifestFile(tmp, manifest); + + let callIndex = 0; + const mockCtx = { + cwd: tmp, + hasUI: true, + ui: { + custom: async (_factory: any) => { + callIndex++; + if (callIndex <= 1) return null; // summary dismiss + return "secret-value"; // collect the pending key + }, + }, + }; + + const result = await collectSecretsFromManifest(tmp, "M001", mockCtx as any); + + // Verify result shape + assert.ok(Array.isArray(result.applied), "result should have applied array"); + assert.ok(Array.isArray(result.skipped), "result should have skipped array"); + assert.ok(Array.isArray(result.existingSkipped), "result should have existingSkipped array"); + + assert.ok(result.existingSkipped.includes("RESULT_TEST_EXISTING"), + "existing key should be in existingSkipped"); + assert.ok(result.applied.includes("RESULT_TEST_NEW"), + "collected key should be in applied"); }); diff --git a/src/resources/extensions/gsd/tests/commands-inspect-open-db.test.ts b/src/resources/extensions/gsd/tests/commands-inspect-open-db.test.ts index e83c07b67..3252a65d9 100644 --- a/src/resources/extensions/gsd/tests/commands-inspect-open-db.test.ts +++ b/src/resources/extensions/gsd/tests/commands-inspect-open-db.test.ts @@ -7,40 +7,40 @@ import fs from "node:fs"; import { handleInspect } from "../commands-inspect.ts"; import { closeDatabase, openDatabase } from "../gsd-db.ts"; -test("/gsd inspect opens existing database when it was not yet opened in session", async () => { +test("/gsd inspect opens existing database when it was not yet opened in session", async (t) => { closeDatabase(); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gsd-inspect-db-")); const prevCwd = process.cwd(); - try { - const gsdDir = path.join(tmp, ".gsd"); - fs.mkdirSync(gsdDir, { recursive: true }); - const dbPath = path.join(gsdDir, "gsd.db"); - - assert.equal(openDatabase(dbPath), true); - closeDatabase(); - - process.chdir(tmp); - - const notifications: Array<{ message: string; level: string }> = []; - const ctx = { - ui: { - notify(message: string, level: string) { - notifications.push({ message, level }); - }, - }, - } as any; - - await handleInspect(ctx); - - assert.equal(notifications.length, 1); - assert.equal(notifications[0].level, "info"); - assert.match(notifications[0].message, /=== GSD Database Inspect ===/); - assert.doesNotMatch(notifications[0].message, /No GSD database available/); - } finally { + t.after(() => { process.chdir(prevCwd); closeDatabase(); fs.rmSync(tmp, { recursive: true, force: true }); - } + }); + + const gsdDir = path.join(tmp, ".gsd"); + fs.mkdirSync(gsdDir, { recursive: true }); + const dbPath = path.join(gsdDir, "gsd.db"); + + assert.equal(openDatabase(dbPath), true); + closeDatabase(); + + process.chdir(tmp); + + const notifications: Array<{ message: string; level: string }> = []; + const ctx = { + ui: { + notify(message: string, level: string) { + notifications.push({ message, level }); + }, + }, + } as any; + + await handleInspect(ctx); + + assert.equal(notifications.length, 1); + assert.equal(notifications[0].level, "info"); + assert.match(notifications[0].message, /=== GSD Database Inspect ===/); + assert.doesNotMatch(notifications[0].message, /No GSD database available/); }); diff --git a/src/resources/extensions/gsd/tests/commands-logs.test.ts b/src/resources/extensions/gsd/tests/commands-logs.test.ts index e48744aea..5ebba97ab 100644 --- a/src/resources/extensions/gsd/tests/commands-logs.test.ts +++ b/src/resources/extensions/gsd/tests/commands-logs.test.ts @@ -42,22 +42,22 @@ function writeDebugLog(dir: string, name: string, entries: Record { +test("logs shows empty state message when no logs exist", async (t) => { const dir = createTestDir(); const ctx = createMockCtx(); const origCwd = process.cwd(); process.chdir(dir); - try { - await handleLogs("", ctx as any); - assert.equal(ctx.notifications.length, 1); - assert.ok(ctx.notifications[0].msg.includes("No logs found")); - } finally { + t.after(() => { process.chdir(origCwd); rmSync(dir, { recursive: true, force: true }); - } + }); + + await handleLogs("", ctx as any); + assert.equal(ctx.notifications.length, 1); + assert.ok(ctx.notifications[0].msg.includes("No logs found")); }); -test("logs lists activity logs", async () => { +test("logs lists activity logs", async (t) => { const dir = createTestDir(); const ctx = createMockCtx(); const origCwd = process.cwd(); @@ -71,21 +71,21 @@ test("logs lists activity logs", async () => { { role: "assistant", content: "Completing slice S01" }, ]); - try { - await handleLogs("", ctx as any); - assert.equal(ctx.notifications.length, 1); - const msg = ctx.notifications[0].msg; - assert.ok(msg.includes("Activity Logs"), "should show activity logs header"); - assert.ok(msg.includes("execute-task"), "should show unit type"); - assert.ok(msg.includes("complete-slice"), "should show second log"); - assert.ok(msg.includes("/gsd logs <#>"), "should show usage hint"); - } finally { + t.after(() => { process.chdir(origCwd); rmSync(dir, { recursive: true, force: true }); - } + }); + + await handleLogs("", ctx as any); + assert.equal(ctx.notifications.length, 1); + const msg = ctx.notifications[0].msg; + assert.ok(msg.includes("Activity Logs"), "should show activity logs header"); + assert.ok(msg.includes("execute-task"), "should show unit type"); + assert.ok(msg.includes("complete-slice"), "should show second log"); + assert.ok(msg.includes("/gsd logs <#>"), "should show usage hint"); }); -test("logs shows activity log details", async () => { +test("logs shows activity log details", async (t) => { const dir = createTestDir(); const ctx = createMockCtx(); const origCwd = process.cwd(); @@ -99,40 +99,40 @@ test("logs shows activity log details", async () => { { role: "assistant", content: "I ran the tests and wrote a file" }, ]); - try { - await handleLogs("1", ctx as any); - assert.equal(ctx.notifications.length, 1); - const msg = ctx.notifications[0].msg; - assert.ok(msg.includes("Activity Log #1"), "should show log number"); - assert.ok(msg.includes("execute-task"), "should show unit type"); - assert.ok(msg.includes("Tool calls: 2"), "should count tool calls"); - assert.ok(msg.includes("Errors: 1"), "should count errors"); - assert.ok(msg.includes("/tmp/test.ts"), "should show files written"); - assert.ok(msg.includes("npm test"), "should show commands run"); - } finally { + t.after(() => { process.chdir(origCwd); rmSync(dir, { recursive: true, force: true }); - } + }); + + await handleLogs("1", ctx as any); + assert.equal(ctx.notifications.length, 1); + const msg = ctx.notifications[0].msg; + assert.ok(msg.includes("Activity Log #1"), "should show log number"); + assert.ok(msg.includes("execute-task"), "should show unit type"); + assert.ok(msg.includes("Tool calls: 2"), "should count tool calls"); + assert.ok(msg.includes("Errors: 1"), "should count errors"); + assert.ok(msg.includes("/tmp/test.ts"), "should show files written"); + assert.ok(msg.includes("npm test"), "should show commands run"); }); -test("logs shows not found for invalid seq", async () => { +test("logs shows not found for invalid seq", async (t) => { const dir = createTestDir(); const ctx = createMockCtx(); const origCwd = process.cwd(); process.chdir(dir); - try { - await handleLogs("999", ctx as any); - assert.equal(ctx.notifications.length, 1); - assert.ok(ctx.notifications[0].msg.includes("not found")); - assert.equal(ctx.notifications[0].level, "warning"); - } finally { + t.after(() => { process.chdir(origCwd); rmSync(dir, { recursive: true, force: true }); - } + }); + + await handleLogs("999", ctx as any); + assert.equal(ctx.notifications.length, 1); + assert.ok(ctx.notifications[0].msg.includes("not found")); + assert.equal(ctx.notifications[0].level, "warning"); }); -test("logs debug lists debug logs", async () => { +test("logs debug lists debug logs", async (t) => { const dir = createTestDir(); const ctx = createMockCtx(); const origCwd = process.cwd(); @@ -143,19 +143,19 @@ test("logs debug lists debug logs", async () => { { ts: "2026-03-18T10:35:00Z", event: "debug-summary", dispatches: 5 }, ]); - try { - await handleLogs("debug", ctx as any); - assert.equal(ctx.notifications.length, 1); - const msg = ctx.notifications[0].msg; - assert.ok(msg.includes("Debug Logs"), "should show debug logs header"); - assert.ok(msg.includes("debug-2026-03-18T10-30-00.log"), "should show filename"); - } finally { + t.after(() => { process.chdir(origCwd); rmSync(dir, { recursive: true, force: true }); - } + }); + + await handleLogs("debug", ctx as any); + assert.equal(ctx.notifications.length, 1); + const msg = ctx.notifications[0].msg; + assert.ok(msg.includes("Debug Logs"), "should show debug logs header"); + assert.ok(msg.includes("debug-2026-03-18T10-30-00.log"), "should show filename"); }); -test("logs debug shows debug log summary", async () => { +test("logs debug shows debug log summary", async (t) => { const dir = createTestDir(); const ctx = createMockCtx(); const origCwd = process.cwd(); @@ -167,21 +167,21 @@ test("logs debug shows debug log summary", async () => { { ts: "2026-03-18T10:35:00Z", event: "debug-summary", dispatches: 5 }, ]); - try { - await handleLogs("debug 1", ctx as any); - assert.equal(ctx.notifications.length, 1); - const msg = ctx.notifications[0].msg; - assert.ok(msg.includes("Debug Log:"), "should show debug log header"); - assert.ok(msg.includes("Events: 3"), "should count events"); - assert.ok(msg.includes("Dispatches: 5"), "should show dispatch count"); - assert.ok(msg.includes("dispatch-error"), "should show errors"); - } finally { + t.after(() => { process.chdir(origCwd); rmSync(dir, { recursive: true, force: true }); - } + }); + + await handleLogs("debug 1", ctx as any); + assert.equal(ctx.notifications.length, 1); + const msg = ctx.notifications[0].msg; + assert.ok(msg.includes("Debug Log:"), "should show debug log header"); + assert.ok(msg.includes("Events: 3"), "should count events"); + assert.ok(msg.includes("Dispatches: 5"), "should show dispatch count"); + assert.ok(msg.includes("dispatch-error"), "should show errors"); }); -test("logs tail shows recent activity summaries", async () => { +test("logs tail shows recent activity summaries", async (t) => { const dir = createTestDir(); const ctx = createMockCtx(); const origCwd = process.cwd(); @@ -195,20 +195,20 @@ test("logs tail shows recent activity summaries", async () => { { role: "toolResult", toolCallId: "1", toolName: "bash", isError: true }, ]); - try { - await handleLogs("tail 2", ctx as any); - assert.equal(ctx.notifications.length, 1); - const msg = ctx.notifications[0].msg; - assert.ok(msg.includes("Last 2 activity log(s)"), "should show count"); - assert.ok(msg.includes("#1"), "should show first log"); - assert.ok(msg.includes("#2"), "should show second log"); - } finally { + t.after(() => { process.chdir(origCwd); rmSync(dir, { recursive: true, force: true }); - } + }); + + await handleLogs("tail 2", ctx as any); + assert.equal(ctx.notifications.length, 1); + const msg = ctx.notifications[0].msg; + assert.ok(msg.includes("Last 2 activity log(s)"), "should show count"); + assert.ok(msg.includes("#1"), "should show first log"); + assert.ok(msg.includes("#2"), "should show second log"); }); -test("logs clear removes old logs", async () => { +test("logs clear removes old logs", async (t) => { const dir = createTestDir(); const ctx = createMockCtx(); const origCwd = process.cwd(); @@ -225,17 +225,17 @@ test("logs clear removes old logs", async () => { writeActivityLog(dir, i, "execute-task", `M001/S01/T0${i}`, [{ type: "toolCall" }]); } - try { - await handleLogs("clear", ctx as any); - assert.equal(ctx.notifications.length, 1); - // Old log should be removed, recent ones kept - assert.ok(!existsSync(oldFile), "old log should be removed"); - assert.ok( - existsSync(join(dir, ".gsd", "activity", "007-execute-task-M001-S01-T07.jsonl")), - "most recent log should be kept", - ); - } finally { + t.after(() => { process.chdir(origCwd); rmSync(dir, { recursive: true, force: true }); - } + }); + + await handleLogs("clear", ctx as any); + assert.equal(ctx.notifications.length, 1); + // Old log should be removed, recent ones kept + assert.ok(!existsSync(oldFile), "old log should be removed"); + assert.ok( + existsSync(join(dir, ".gsd", "activity", "007-execute-task-M001-S01-T07.jsonl")), + "most recent log should be kept", + ); }); diff --git a/src/resources/extensions/gsd/tests/continue-here.test.ts b/src/resources/extensions/gsd/tests/continue-here.test.ts index 08bd595c3..ac28629fa 100644 --- a/src/resources/extensions/gsd/tests/continue-here.test.ts +++ b/src/resources/extensions/gsd/tests/continue-here.test.ts @@ -162,7 +162,7 @@ describe("continue-here", () => { }); describe("continueHereFired runtime record field", () => { - it("AutoUnitRuntimeRecord includes continueHereFired with default false", async () => { + it("AutoUnitRuntimeRecord includes continueHereFired with default false", async (t) => { // Import writeUnitRuntimeRecord to verify the field is present and defaults const { writeUnitRuntimeRecord, readUnitRuntimeRecord, clearUnitRuntimeRecord } = await import("../unit-runtime.js"); const fs = await import("node:fs"); @@ -171,87 +171,83 @@ describe("continue-here", () => { // Use a temp directory as basePath const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "continue-here-test-")); - try { - const record = writeUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02", Date.now(), { - phase: "dispatched", - wrapupWarningSent: false, - }); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); - assert.equal(record.continueHereFired, false, "default continueHereFired should be false"); + const record = writeUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02", Date.now(), { + phase: "dispatched", + wrapupWarningSent: false, + }); - // Verify it persists to disk - const read = readUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02"); - assert.ok(read, "record should be readable"); - assert.equal(read!.continueHereFired, false); + assert.equal(record.continueHereFired, false, "default continueHereFired should be false"); - // Update to true - const updated = writeUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02", Date.now(), { - continueHereFired: true, - }); - assert.equal(updated.continueHereFired, true, "updated continueHereFired should be true"); + // Verify it persists to disk + const read = readUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02"); + assert.ok(read, "record should be readable"); + assert.equal(read!.continueHereFired, false); - // Verify persistence - const readUpdated = readUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02"); - assert.equal(readUpdated!.continueHereFired, true, "persisted continueHereFired should be true"); + // Update to true + const updated = writeUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02", Date.now(), { + continueHereFired: true, + }); + assert.equal(updated.continueHereFired, true, "updated continueHereFired should be true"); - // Clean up - clearUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02"); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } + // Verify persistence + const readUpdated = readUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02"); + assert.equal(readUpdated!.continueHereFired, true, "persisted continueHereFired should be true"); + + // Clean up + clearUnitRuntimeRecord(tmpDir, "execute-task", "M007/S02/T02"); }); }); describe("context-pressure monitor integration", () => { - it("should fire wrap-up when context >= threshold and mark continueHereFired", async () => { + it("should fire wrap-up when context >= threshold and mark continueHereFired", async (t) => { const { writeUnitRuntimeRecord, readUnitRuntimeRecord, clearUnitRuntimeRecord } = await import("../unit-runtime.js"); const fs = await import("node:fs"); const path = await import("node:path"); const os = await import("node:os"); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "continue-here-monitor-")); - try { - // Simulate the monitor's one-shot logic: - // 1. Write initial runtime record (continueHereFired=false) - const startedAt = Date.now(); - writeUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01", startedAt, { - phase: "dispatched", - wrapupWarningSent: false, - }); + t.after(() => fs.rmSync(tmpDir, { recursive: true, force: true })); - const budget = computeBudgets(128_000); - const threshold = budget.continueThresholdPercent; + // Simulate the monitor's one-shot logic: + // 1. Write initial runtime record (continueHereFired=false) + const startedAt = Date.now(); + writeUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01", startedAt, { + phase: "dispatched", + wrapupWarningSent: false, + }); - // Simulate the monitor poll: context at 75% (above threshold) - const contextPercent = 75; - const runtime = readUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01"); - assert.ok(runtime, "runtime record should exist"); - assert.equal(runtime!.continueHereFired, false, "initially false"); + const budget = computeBudgets(128_000); + const threshold = budget.continueThresholdPercent; - // Check: should fire - const shouldFire = !runtime!.continueHereFired - && contextPercent >= threshold; - assert.ok(shouldFire, "should fire when context >= threshold and not yet fired"); + // Simulate the monitor poll: context at 75% (above threshold) + const contextPercent = 75; + const runtime = readUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01"); + assert.ok(runtime, "runtime record should exist"); + assert.equal(runtime!.continueHereFired, false, "initially false"); - // Mark as fired (what the monitor does) - writeUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01", startedAt, { - continueHereFired: true, - }); + // Check: should fire + const shouldFire = !runtime!.continueHereFired + && contextPercent >= threshold; + assert.ok(shouldFire, "should fire when context >= threshold and not yet fired"); - // Verify one-shot: second poll should NOT fire - const runtime2 = readUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01"); - assert.ok(runtime2, "runtime record should still exist"); - assert.equal(runtime2!.continueHereFired, true, "should be marked as fired"); + // Mark as fired (what the monitor does) + writeUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01", startedAt, { + continueHereFired: true, + }); - const shouldFireAgain = !runtime2!.continueHereFired - && contextPercent >= threshold; - assert.equal(shouldFireAgain, false, "must not fire again — one-shot guard"); + // Verify one-shot: second poll should NOT fire + const runtime2 = readUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01"); + assert.ok(runtime2, "runtime record should still exist"); + assert.equal(runtime2!.continueHereFired, true, "should be marked as fired"); - // Clean up - clearUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01"); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } + const shouldFireAgain = !runtime2!.continueHereFired + && contextPercent >= threshold; + assert.equal(shouldFireAgain, false, "must not fire again — one-shot guard"); + + // Clean up + clearUnitRuntimeRecord(tmpDir, "execute-task", "M001/S01/T01"); }); it("should not fire when context is below threshold", () => { diff --git a/src/resources/extensions/gsd/tests/crash-recovery.test.ts b/src/resources/extensions/gsd/tests/crash-recovery.test.ts index bce69cc7a..43326c99f 100644 --- a/src/resources/extensions/gsd/tests/crash-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/crash-recovery.test.ts @@ -26,53 +26,45 @@ function cleanup(base: string): void { // ─── writeLock / readCrashLock ──────────────────────────────────────────── -test("writeLock creates lock file and readCrashLock reads it", () => { +test("writeLock creates lock file and readCrashLock reads it", (t) => { const base = makeTmpBase(); - try { - writeLock(base, "execute-task", "M001/S01/T01", 3, "/tmp/session.jsonl"); - const lock = readCrashLock(base); - assert.ok(lock, "lock should exist"); - assert.equal(lock!.unitType, "execute-task"); - assert.equal(lock!.unitId, "M001/S01/T01"); - assert.equal(lock!.completedUnits, 3); - assert.equal(lock!.sessionFile, "/tmp/session.jsonl"); - assert.equal(lock!.pid, process.pid); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + writeLock(base, "execute-task", "M001/S01/T01", 3, "/tmp/session.jsonl"); + const lock = readCrashLock(base); + assert.ok(lock, "lock should exist"); + assert.equal(lock!.unitType, "execute-task"); + assert.equal(lock!.unitId, "M001/S01/T01"); + assert.equal(lock!.completedUnits, 3); + assert.equal(lock!.sessionFile, "/tmp/session.jsonl"); + assert.equal(lock!.pid, process.pid); }); -test("readCrashLock returns null when no lock exists", () => { +test("readCrashLock returns null when no lock exists", (t) => { const base = makeTmpBase(); - try { - const lock = readCrashLock(base); - assert.equal(lock, null); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + const lock = readCrashLock(base); + assert.equal(lock, null); }); // ─── clearLock ──────────────────────────────────────────────────────────── -test("clearLock removes existing lock file", () => { +test("clearLock removes existing lock file", (t) => { const base = makeTmpBase(); - try { - writeLock(base, "plan-slice", "M001/S01", 0); - assert.ok(readCrashLock(base), "lock should exist before clear"); - clearLock(base); - assert.equal(readCrashLock(base), null, "lock should be gone after clear"); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + writeLock(base, "plan-slice", "M001/S01", 0); + assert.ok(readCrashLock(base), "lock should exist before clear"); + clearLock(base); + assert.equal(readCrashLock(base), null, "lock should be gone after clear"); }); -test("clearLock is safe when no lock exists", () => { +test("clearLock is safe when no lock exists", (t) => { const base = makeTmpBase(); - try { - assert.doesNotThrow(() => clearLock(base)); - } finally { - cleanup(base); - } + t.after(() => cleanup(base)); + + assert.doesNotThrow(() => clearLock(base)); }); // ─── isLockProcessAlive ────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/tests/definition-loader.test.ts b/src/resources/extensions/gsd/tests/definition-loader.test.ts index 55d3d9dfc..b1a90626c 100644 --- a/src/resources/extensions/gsd/tests/definition-loader.test.ts +++ b/src/resources/extensions/gsd/tests/definition-loader.test.ts @@ -63,35 +63,33 @@ steps: // ─── loadDefinition: valid YAML ────────────────────────────────────────── -test("loadDefinition: valid 3-step YAML returns correct structure", () => { +test("loadDefinition: valid 3-step YAML returns correct structure", (t) => { const dir = writeDefYaml(VALID_3STEP_YAML); - try { - const def = loadDefinition(dir, "test-workflow"); + t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } }); - assert.equal(def.version, 1); - assert.equal(def.name, "test-workflow"); - assert.equal(def.description, "A test workflow"); - assert.deepEqual(def.params, { topic: "AI" }); - assert.equal(def.steps.length, 3); + const def = loadDefinition(dir, "test-workflow"); - // Step 1: research - assert.equal(def.steps[0].id, "research"); - assert.equal(def.steps[0].name, "Research the topic"); - assert.equal(def.steps[0].prompt, "Research {{topic}} and write findings to research.md"); - assert.deepEqual(def.steps[0].requires, []); - assert.deepEqual(def.steps[0].produces, ["research.md"]); + assert.equal(def.version, 1); + assert.equal(def.name, "test-workflow"); + assert.equal(def.description, "A test workflow"); + assert.deepEqual(def.params, { topic: "AI" }); + assert.equal(def.steps.length, 3); - // Step 2: outline — depends on research - assert.equal(def.steps[1].id, "outline"); - assert.deepEqual(def.steps[1].requires, ["research"]); + // Step 1: research + assert.equal(def.steps[0].id, "research"); + assert.equal(def.steps[0].name, "Research the topic"); + assert.equal(def.steps[0].prompt, "Research {{topic}} and write findings to research.md"); + assert.deepEqual(def.steps[0].requires, []); + assert.deepEqual(def.steps[0].produces, ["research.md"]); - // Step 3: draft — depends on outline - assert.equal(def.steps[2].id, "draft"); - assert.deepEqual(def.steps[2].requires, ["outline"]); - assert.deepEqual(def.steps[2].produces, ["draft.md"]); - } finally { - try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } - } + // Step 2: outline — depends on research + assert.equal(def.steps[1].id, "outline"); + assert.deepEqual(def.steps[1].requires, ["research"]); + + // Step 3: draft — depends on outline + assert.equal(def.steps[2].id, "draft"); + assert.deepEqual(def.steps[2].requires, ["outline"]); + assert.deepEqual(def.steps[2].produces, ["draft.md"]); }); // ─── validateDefinition: rejection cases ───────────────────────────────── @@ -223,23 +221,21 @@ test("validateDefinition: missing step name → error", () => { // ─── loadDefinition: error cases ───────────────────────────────────────── -test("loadDefinition: missing file → descriptive error", () => { +test("loadDefinition: missing file → descriptive error", (t) => { const dir = makeTmpDir(); - try { - assert.throws( - () => loadDefinition(dir, "nonexistent"), - (err: Error) => { - assert.ok(err.message.includes("not found")); - assert.ok(err.message.includes("nonexistent.yaml")); - return true; - }, - ); - } finally { - try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } - } + t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } }); + + assert.throws( + () => loadDefinition(dir, "nonexistent"), + (err: Error) => { + assert.ok(err.message.includes("not found")); + assert.ok(err.message.includes("nonexistent.yaml")); + return true; + }, + ); }); -test("loadDefinition: invalid YAML schema → descriptive error", () => { +test("loadDefinition: invalid YAML schema → descriptive error", (t) => { const dir = writeDefYaml(` version: 2 name: "bad" @@ -248,23 +244,21 @@ steps: name: "A" prompt: "do A" `); - try { - assert.throws( - () => loadDefinition(dir, "test-workflow"), - (err: Error) => { - assert.ok(err.message.includes("Invalid workflow definition")); - assert.ok(err.message.includes("Unsupported version")); - return true; - }, - ); - } finally { - try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } - } + t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } }); + + assert.throws( + () => loadDefinition(dir, "test-workflow"), + (err: Error) => { + assert.ok(err.message.includes("Invalid workflow definition")); + assert.ok(err.message.includes("Unsupported version")); + return true; + }, + ); }); // ─── loadDefinition: snake_case → camelCase conversion ─────────────────── -test("loadDefinition: depends_on in YAML maps to requires in TypeScript", () => { +test("loadDefinition: depends_on in YAML maps to requires in TypeScript", (t) => { const dir = writeDefYaml(` version: 1 name: "dep-test" @@ -277,15 +271,13 @@ steps: prompt: "do second" depends_on: [first] `); - try { - const def = loadDefinition(dir, "test-workflow"); - assert.deepEqual(def.steps[1].requires, ["first"]); - } finally { - try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } - } + t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } }); + + const def = loadDefinition(dir, "test-workflow"); + assert.deepEqual(def.steps[1].requires, ["first"]); }); -test("loadDefinition: context_from in YAML maps to contextFrom in TypeScript", () => { +test("loadDefinition: context_from in YAML maps to contextFrom in TypeScript", (t) => { const dir = writeDefYaml(` version: 1 name: "ctx-test" @@ -298,12 +290,10 @@ steps: prompt: "do second" context_from: [first] `); - try { - const def = loadDefinition(dir, "test-workflow"); - assert.deepEqual(def.steps[1].contextFrom, ["first"]); - } finally { - try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } - } + t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } }); + + const def = loadDefinition(dir, "test-workflow"); + assert.deepEqual(def.steps[1].contextFrom, ["first"]); }); // ─── validateDefinition: iterate field validation ──────────────────────── @@ -725,7 +715,7 @@ test("validateDefinition: valid minimal step (no requires/produces) → accepted assert.equal(result.errors.length, 0); }); -test("loadDefinition: loads without params field → params is undefined", () => { +test("loadDefinition: loads without params field → params is undefined", (t) => { const dir = writeDefYaml(` version: 1 name: "no-params" @@ -734,15 +724,13 @@ steps: name: "A" prompt: "do A" `); - try { - const def = loadDefinition(dir, "test-workflow"); - assert.equal(def.params, undefined); - } finally { - try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } - } + t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } }); + + const def = loadDefinition(dir, "test-workflow"); + assert.equal(def.params, undefined); }); -test("loadDefinition: loads without description → description is undefined", () => { +test("loadDefinition: loads without description → description is undefined", (t) => { const dir = writeDefYaml(` version: 1 name: "no-desc" @@ -751,15 +739,13 @@ steps: name: "A" prompt: "do A" `); - try { - const def = loadDefinition(dir, "test-workflow"); - assert.equal(def.description, undefined); - } finally { - try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } - } + t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } }); + + const def = loadDefinition(dir, "test-workflow"); + assert.equal(def.description, undefined); }); -test("loadDefinition: step with no requires/produces defaults to empty arrays", () => { +test("loadDefinition: step with no requires/produces defaults to empty arrays", (t) => { const dir = writeDefYaml(` version: 1 name: "defaults" @@ -768,11 +754,9 @@ steps: name: "A" prompt: "do A" `); - try { - const def = loadDefinition(dir, "test-workflow"); - assert.deepEqual(def.steps[0].requires, []); - assert.deepEqual(def.steps[0].produces, []); - } finally { - try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } - } + t.after(() => { try { rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch { /* Windows EPERM */ } }); + + const def = loadDefinition(dir, "test-workflow"); + assert.deepEqual(def.steps[0].requires, []); + assert.deepEqual(def.steps[0].produces, []); }); diff --git a/src/resources/extensions/gsd/tests/detection.test.ts b/src/resources/extensions/gsd/tests/detection.test.ts index 8e68524e1..1f363b72d 100644 --- a/src/resources/extensions/gsd/tests/detection.test.ts +++ b/src/resources/extensions/gsd/tests/detection.test.ts @@ -38,361 +38,315 @@ function cleanup(dir: string): void { // ─── detectProjectState ───────────────────────────────────────────────────────── -test("detectProjectState: empty directory returns state=none", () => { +test("detectProjectState: empty directory returns state=none", (t) => { const dir = makeTempDir("empty"); - try { - const result = detectProjectState(dir); - assert.equal(result.state, "none"); - assert.equal(result.v1, undefined); - assert.equal(result.v2, undefined); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + const result = detectProjectState(dir); + assert.equal(result.state, "none"); + assert.equal(result.v1, undefined); + assert.equal(result.v2, undefined); }); -test("detectProjectState: directory with .gsd/milestones/M001 returns v2-gsd", () => { +test("detectProjectState: directory with .gsd/milestones/M001 returns v2-gsd", (t) => { const dir = makeTempDir("v2-gsd"); - try { - mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true }); - const result = detectProjectState(dir); - assert.equal(result.state, "v2-gsd"); - assert.ok(result.v2); - assert.equal(result.v2!.milestoneCount, 1); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true }); + const result = detectProjectState(dir); + assert.equal(result.state, "v2-gsd"); + assert.ok(result.v2); + assert.equal(result.v2!.milestoneCount, 1); }); -test("detectProjectState: directory with empty .gsd/milestones returns v2-gsd-empty", () => { +test("detectProjectState: directory with empty .gsd/milestones returns v2-gsd-empty", (t) => { const dir = makeTempDir("v2-empty"); - try { - mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true }); - const result = detectProjectState(dir); - assert.equal(result.state, "v2-gsd-empty"); - assert.ok(result.v2); - assert.equal(result.v2!.milestoneCount, 0); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true }); + const result = detectProjectState(dir); + assert.equal(result.state, "v2-gsd-empty"); + assert.ok(result.v2); + assert.equal(result.v2!.milestoneCount, 0); }); -test("detectProjectState: directory with .planning/ returns v1-planning", () => { +test("detectProjectState: directory with .planning/ returns v1-planning", (t) => { const dir = makeTempDir("v1-planning"); - try { - mkdirSync(join(dir, ".planning", "phases", "01-setup"), { recursive: true }); - writeFileSync(join(dir, ".planning", "ROADMAP.md"), "# Roadmap\n", "utf-8"); - const result = detectProjectState(dir); - assert.equal(result.state, "v1-planning"); - assert.ok(result.v1); - assert.equal(result.v1!.hasRoadmap, true); - assert.equal(result.v1!.hasPhasesDir, true); - assert.equal(result.v1!.phaseCount, 1); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, ".planning", "phases", "01-setup"), { recursive: true }); + writeFileSync(join(dir, ".planning", "ROADMAP.md"), "# Roadmap\n", "utf-8"); + const result = detectProjectState(dir); + assert.equal(result.state, "v1-planning"); + assert.ok(result.v1); + assert.equal(result.v1!.hasRoadmap, true); + assert.equal(result.v1!.hasPhasesDir, true); + assert.equal(result.v1!.phaseCount, 1); }); -test("detectProjectState: v2 takes priority over v1 when both exist", () => { +test("detectProjectState: v2 takes priority over v1 when both exist", (t) => { const dir = makeTempDir("both"); - try { - mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true }); - mkdirSync(join(dir, ".planning"), { recursive: true }); - const result = detectProjectState(dir); - assert.equal(result.state, "v2-gsd"); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, ".gsd", "milestones", "M001"), { recursive: true }); + mkdirSync(join(dir, ".planning"), { recursive: true }); + const result = detectProjectState(dir); + assert.equal(result.state, "v2-gsd"); }); -test("detectProjectState: detects preferences in .gsd/", () => { +test("detectProjectState: detects preferences in .gsd/", (t) => { const dir = makeTempDir("prefs"); - try { - mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true }); - writeFileSync(join(dir, ".gsd", "preferences.md"), "---\nversion: 1\n---\n", "utf-8"); - const result = detectProjectState(dir); - assert.ok(result.v2); - assert.equal(result.v2!.hasPreferences, true); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, ".gsd", "milestones"), { recursive: true }); + writeFileSync(join(dir, ".gsd", "preferences.md"), "---\nversion: 1\n---\n", "utf-8"); + const result = detectProjectState(dir); + assert.ok(result.v2); + assert.equal(result.v2!.hasPreferences, true); }); // ─── detectV1Planning ─────────────────────────────────────────────────────────── -test("detectV1Planning: returns null for missing .planning/", () => { +test("detectV1Planning: returns null for missing .planning/", (t) => { const dir = makeTempDir("no-v1"); - try { - assert.equal(detectV1Planning(dir), null); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + assert.equal(detectV1Planning(dir), null); }); -test("detectV1Planning: returns null when .planning is a file", () => { +test("detectV1Planning: returns null when .planning is a file", (t) => { const dir = makeTempDir("v1-file"); - try { - writeFileSync(join(dir, ".planning"), "not a directory", "utf-8"); - assert.equal(detectV1Planning(dir), null); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + writeFileSync(join(dir, ".planning"), "not a directory", "utf-8"); + assert.equal(detectV1Planning(dir), null); }); -test("detectV1Planning: detects phases directory with multiple phases", () => { +test("detectV1Planning: detects phases directory with multiple phases", (t) => { const dir = makeTempDir("v1-phases"); - try { - mkdirSync(join(dir, ".planning", "phases", "01-setup"), { recursive: true }); - mkdirSync(join(dir, ".planning", "phases", "02-core"), { recursive: true }); - mkdirSync(join(dir, ".planning", "phases", "03-deploy"), { recursive: true }); - const result = detectV1Planning(dir); - assert.ok(result); - assert.equal(result!.phaseCount, 3); - assert.equal(result!.hasPhasesDir, true); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, ".planning", "phases", "01-setup"), { recursive: true }); + mkdirSync(join(dir, ".planning", "phases", "02-core"), { recursive: true }); + mkdirSync(join(dir, ".planning", "phases", "03-deploy"), { recursive: true }); + const result = detectV1Planning(dir); + assert.ok(result); + assert.equal(result!.phaseCount, 3); + assert.equal(result!.hasPhasesDir, true); }); -test("detectV1Planning: detects ROADMAP.md", () => { +test("detectV1Planning: detects ROADMAP.md", (t) => { const dir = makeTempDir("v1-roadmap"); - try { - mkdirSync(join(dir, ".planning"), { recursive: true }); - writeFileSync(join(dir, ".planning", "ROADMAP.md"), "# Roadmap", "utf-8"); - const result = detectV1Planning(dir); - assert.ok(result); - assert.equal(result!.hasRoadmap, true); - assert.equal(result!.hasPhasesDir, false); - assert.equal(result!.phaseCount, 0); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, ".planning"), { recursive: true }); + writeFileSync(join(dir, ".planning", "ROADMAP.md"), "# Roadmap", "utf-8"); + const result = detectV1Planning(dir); + assert.ok(result); + assert.equal(result!.hasRoadmap, true); + assert.equal(result!.hasPhasesDir, false); + assert.equal(result!.phaseCount, 0); }); // ─── detectProjectSignals ─────────────────────────────────────────────────────── -test("detectProjectSignals: empty directory", () => { +test("detectProjectSignals: empty directory", (t) => { const dir = makeTempDir("signals-empty"); - try { - const signals = detectProjectSignals(dir); - assert.deepEqual(signals.detectedFiles, []); - assert.equal(signals.isGitRepo, false); - assert.equal(signals.isMonorepo, false); - assert.equal(signals.primaryLanguage, undefined); - assert.equal(signals.hasCI, false); - assert.equal(signals.hasTests, false); - assert.deepEqual(signals.verificationCommands, []); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + const signals = detectProjectSignals(dir); + assert.deepEqual(signals.detectedFiles, []); + assert.equal(signals.isGitRepo, false); + assert.equal(signals.isMonorepo, false); + assert.equal(signals.primaryLanguage, undefined); + assert.equal(signals.hasCI, false); + assert.equal(signals.hasTests, false); + assert.deepEqual(signals.verificationCommands, []); }); -test("detectProjectSignals: Node.js project", () => { +test("detectProjectSignals: Node.js project", (t) => { const dir = makeTempDir("signals-node"); - try { - writeFileSync( - join(dir, "package.json"), - JSON.stringify({ - name: "test-project", - scripts: { - test: "jest", - build: "tsc", - lint: "eslint .", - }, - }), - "utf-8", - ); - writeFileSync(join(dir, "package-lock.json"), "{}", "utf-8"); - mkdirSync(join(dir, ".git"), { recursive: true }); + t.after(() => cleanup(dir)); - const signals = detectProjectSignals(dir); - assert.ok(signals.detectedFiles.includes("package.json")); - assert.equal(signals.primaryLanguage, "javascript/typescript"); - assert.equal(signals.isGitRepo, true); - assert.equal(signals.packageManager, "npm"); - assert.ok(signals.verificationCommands.includes("npm test")); - assert.ok(signals.verificationCommands.some(c => c.includes("build"))); - assert.ok(signals.verificationCommands.some(c => c.includes("lint"))); - } finally { - cleanup(dir); - } + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "test-project", + scripts: { + test: "jest", + build: "tsc", + lint: "eslint .", + }, + }), + "utf-8", + ); + writeFileSync(join(dir, "package-lock.json"), "{}", "utf-8"); + mkdirSync(join(dir, ".git"), { recursive: true }); + + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("package.json")); + assert.equal(signals.primaryLanguage, "javascript/typescript"); + assert.equal(signals.isGitRepo, true); + assert.equal(signals.packageManager, "npm"); + assert.ok(signals.verificationCommands.includes("npm test")); + assert.ok(signals.verificationCommands.some(c => c.includes("build"))); + assert.ok(signals.verificationCommands.some(c => c.includes("lint"))); }); -test("detectProjectSignals: Rust project", () => { +test("detectProjectSignals: Rust project", (t) => { const dir = makeTempDir("signals-rust"); - try { - writeFileSync(join(dir, "Cargo.toml"), '[package]\nname = "test"\n', "utf-8"); - const signals = detectProjectSignals(dir); - assert.ok(signals.detectedFiles.includes("Cargo.toml")); - assert.equal(signals.primaryLanguage, "rust"); - assert.ok(signals.verificationCommands.includes("cargo test")); - assert.ok(signals.verificationCommands.includes("cargo clippy")); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + writeFileSync(join(dir, "Cargo.toml"), '[package]\nname = "test"\n', "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("Cargo.toml")); + assert.equal(signals.primaryLanguage, "rust"); + assert.ok(signals.verificationCommands.includes("cargo test")); + assert.ok(signals.verificationCommands.includes("cargo clippy")); }); -test("detectProjectSignals: Go project", () => { +test("detectProjectSignals: Go project", (t) => { const dir = makeTempDir("signals-go"); - try { - writeFileSync(join(dir, "go.mod"), "module example.com/test\n", "utf-8"); - const signals = detectProjectSignals(dir); - assert.ok(signals.detectedFiles.includes("go.mod")); - assert.equal(signals.primaryLanguage, "go"); - assert.ok(signals.verificationCommands.includes("go test ./...")); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + writeFileSync(join(dir, "go.mod"), "module example.com/test\n", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("go.mod")); + assert.equal(signals.primaryLanguage, "go"); + assert.ok(signals.verificationCommands.includes("go test ./...")); }); -test("detectProjectSignals: Python project", () => { +test("detectProjectSignals: Python project", (t) => { const dir = makeTempDir("signals-python"); - try { - writeFileSync(join(dir, "pyproject.toml"), "[tool.poetry]\n", "utf-8"); - const signals = detectProjectSignals(dir); - assert.ok(signals.detectedFiles.includes("pyproject.toml")); - assert.equal(signals.primaryLanguage, "python"); - assert.ok(signals.verificationCommands.includes("pytest")); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + writeFileSync(join(dir, "pyproject.toml"), "[tool.poetry]\n", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("pyproject.toml")); + assert.equal(signals.primaryLanguage, "python"); + assert.ok(signals.verificationCommands.includes("pytest")); }); -test("detectProjectSignals: monorepo detection via workspaces", () => { +test("detectProjectSignals: monorepo detection via workspaces", (t) => { const dir = makeTempDir("signals-monorepo"); - try { - writeFileSync( - join(dir, "package.json"), - JSON.stringify({ name: "mono", workspaces: ["packages/*"] }), - "utf-8", - ); - const signals = detectProjectSignals(dir); - assert.equal(signals.isMonorepo, true); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ name: "mono", workspaces: ["packages/*"] }), + "utf-8", + ); + const signals = detectProjectSignals(dir); + assert.equal(signals.isMonorepo, true); }); -test("detectProjectSignals: monorepo detection via turbo.json", () => { +test("detectProjectSignals: monorepo detection via turbo.json", (t) => { const dir = makeTempDir("signals-turbo"); - try { - writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "test" }), "utf-8"); - writeFileSync(join(dir, "turbo.json"), "{}", "utf-8"); - const signals = detectProjectSignals(dir); - assert.equal(signals.isMonorepo, true); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "test" }), "utf-8"); + writeFileSync(join(dir, "turbo.json"), "{}", "utf-8"); + const signals = detectProjectSignals(dir); + assert.equal(signals.isMonorepo, true); }); -test("detectProjectSignals: CI detection", () => { +test("detectProjectSignals: CI detection", (t) => { const dir = makeTempDir("signals-ci"); - try { - mkdirSync(join(dir, ".github", "workflows"), { recursive: true }); - const signals = detectProjectSignals(dir); - assert.equal(signals.hasCI, true); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + mkdirSync(join(dir, ".github", "workflows"), { recursive: true }); + const signals = detectProjectSignals(dir); + assert.equal(signals.hasCI, true); }); -test("detectProjectSignals: test detection via jest config", () => { +test("detectProjectSignals: test detection via jest config", (t) => { const dir = makeTempDir("signals-tests"); - try { - writeFileSync(join(dir, "jest.config.ts"), "export default {}", "utf-8"); - const signals = detectProjectSignals(dir); - assert.equal(signals.hasTests, true); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + writeFileSync(join(dir, "jest.config.ts"), "export default {}", "utf-8"); + const signals = detectProjectSignals(dir); + assert.equal(signals.hasTests, true); }); -test("detectProjectSignals: package manager detection", () => { +test("detectProjectSignals: package manager detection", (t) => { const dir1 = makeTempDir("pm-pnpm"); const dir2 = makeTempDir("pm-yarn"); const dir3 = makeTempDir("pm-bun"); - try { - writeFileSync(join(dir1, "pnpm-lock.yaml"), "", "utf-8"); - writeFileSync(join(dir1, "package.json"), "{}", "utf-8"); - assert.equal(detectProjectSignals(dir1).packageManager, "pnpm"); - - writeFileSync(join(dir2, "yarn.lock"), "", "utf-8"); - writeFileSync(join(dir2, "package.json"), "{}", "utf-8"); - assert.equal(detectProjectSignals(dir2).packageManager, "yarn"); - - writeFileSync(join(dir3, "bun.lockb"), "", "utf-8"); - writeFileSync(join(dir3, "package.json"), "{}", "utf-8"); - assert.equal(detectProjectSignals(dir3).packageManager, "bun"); - } finally { + t.after(() => { cleanup(dir1); cleanup(dir2); cleanup(dir3); - } + }); + + writeFileSync(join(dir1, "pnpm-lock.yaml"), "", "utf-8"); + writeFileSync(join(dir1, "package.json"), "{}", "utf-8"); + assert.equal(detectProjectSignals(dir1).packageManager, "pnpm"); + + writeFileSync(join(dir2, "yarn.lock"), "", "utf-8"); + writeFileSync(join(dir2, "package.json"), "{}", "utf-8"); + assert.equal(detectProjectSignals(dir2).packageManager, "yarn"); + + writeFileSync(join(dir3, "bun.lockb"), "", "utf-8"); + writeFileSync(join(dir3, "package.json"), "{}", "utf-8"); + assert.equal(detectProjectSignals(dir3).packageManager, "bun"); }); -test("detectProjectSignals: skips default npm test script", () => { +test("detectProjectSignals: skips default npm test script", (t) => { const dir = makeTempDir("signals-default-test"); - try { - writeFileSync( - join(dir, "package.json"), - JSON.stringify({ - name: "test", - scripts: { test: 'echo "Error: no test specified" && exit 1' }, - }), - "utf-8", - ); - const signals = detectProjectSignals(dir); - // Should NOT include the default npm test script - assert.equal( - signals.verificationCommands.some(c => c.includes("test")), - false, - ); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "test", + scripts: { test: 'echo "Error: no test specified" && exit 1' }, + }), + "utf-8", + ); + const signals = detectProjectSignals(dir); + // Should NOT include the default npm test script + assert.equal( + signals.verificationCommands.some(c => c.includes("test")), + false, + ); }); -test("detectProjectSignals: pnpm uses pnpm commands", () => { +test("detectProjectSignals: pnpm uses pnpm commands", (t) => { const dir = makeTempDir("signals-pnpm-cmds"); - try { - writeFileSync( - join(dir, "package.json"), - JSON.stringify({ - name: "test", - scripts: { test: "vitest", build: "tsc" }, - }), - "utf-8", - ); - writeFileSync(join(dir, "pnpm-lock.yaml"), "", "utf-8"); - const signals = detectProjectSignals(dir); - assert.ok(signals.verificationCommands.includes("pnpm test")); - assert.ok(signals.verificationCommands.includes("pnpm run build")); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "test", + scripts: { test: "vitest", build: "tsc" }, + }), + "utf-8", + ); + writeFileSync(join(dir, "pnpm-lock.yaml"), "", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.verificationCommands.includes("pnpm test")); + assert.ok(signals.verificationCommands.includes("pnpm run build")); }); -test("detectProjectSignals: Ruby project with rspec", () => { +test("detectProjectSignals: Ruby project with rspec", (t) => { const dir = makeTempDir("signals-ruby"); - try { - writeFileSync(join(dir, "Gemfile"), 'source "https://rubygems.org"\n', "utf-8"); - mkdirSync(join(dir, "spec"), { recursive: true }); - const signals = detectProjectSignals(dir); - assert.ok(signals.detectedFiles.includes("Gemfile")); - assert.equal(signals.primaryLanguage, "ruby"); - assert.ok(signals.verificationCommands.includes("bundle exec rspec")); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + writeFileSync(join(dir, "Gemfile"), 'source "https://rubygems.org"\n', "utf-8"); + mkdirSync(join(dir, "spec"), { recursive: true }); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("Gemfile")); + assert.equal(signals.primaryLanguage, "ruby"); + assert.ok(signals.verificationCommands.includes("bundle exec rspec")); }); -test("detectProjectSignals: Makefile with test target", () => { +test("detectProjectSignals: Makefile with test target", (t) => { const dir = makeTempDir("signals-make"); - try { - writeFileSync(join(dir, "Makefile"), "test:\n\tgo test ./...\n\nbuild:\n\tgo build\n", "utf-8"); - const signals = detectProjectSignals(dir); - assert.ok(signals.detectedFiles.includes("Makefile")); - assert.ok(signals.verificationCommands.includes("make test")); - } finally { - cleanup(dir); - } + t.after(() => cleanup(dir)); + + writeFileSync(join(dir, "Makefile"), "test:\n\tgo test ./...\n\nbuild:\n\tgo build\n", "utf-8"); + const signals = detectProjectSignals(dir); + assert.ok(signals.detectedFiles.includes("Makefile")); + assert.ok(signals.verificationCommands.includes("make test")); }); diff --git a/src/resources/extensions/gsd/tests/dev-engine-wrapper.test.ts b/src/resources/extensions/gsd/tests/dev-engine-wrapper.test.ts index 32e909629..e2d845962 100644 --- a/src/resources/extensions/gsd/tests/dev-engine-wrapper.test.ts +++ b/src/resources/extensions/gsd/tests/dev-engine-wrapper.test.ts @@ -73,7 +73,7 @@ describe("DevWorkflowEngine", () => { assert.equal(engine.engineId, "dev"); }); - test("deriveState returns EngineState with expected fields", async () => { + test("deriveState returns EngineState with expected fields", async (t) => { const { DevWorkflowEngine } = await import("../dev-workflow-engine.ts"); const engine = new DevWorkflowEngine(); @@ -81,31 +81,29 @@ describe("DevWorkflowEngine", () => { const tempDir = mkdtempSync(join(tmpdir(), "gsd-engine-test-")); mkdirSync(join(tempDir, ".gsd", "milestones"), { recursive: true }); - try { - const state = await engine.deriveState(tempDir); + t.after(() => rmSync(tempDir, { recursive: true, force: true })); - assert.equal(typeof state.phase, "string", "phase should be a string"); - assert.ok( - "currentMilestoneId" in state, - "state should have currentMilestoneId", - ); - assert.ok( - "activeSliceId" in state, - "state should have activeSliceId", - ); - assert.ok( - "activeTaskId" in state, - "state should have activeTaskId", - ); - assert.equal( - typeof state.isComplete, - "boolean", - "isComplete should be boolean", - ); - assert.ok("raw" in state, "state should have raw field"); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } + const state = await engine.deriveState(tempDir); + + assert.equal(typeof state.phase, "string", "phase should be a string"); + assert.ok( + "currentMilestoneId" in state, + "state should have currentMilestoneId", + ); + assert.ok( + "activeSliceId" in state, + "state should have activeSliceId", + ); + assert.ok( + "activeTaskId" in state, + "state should have activeTaskId", + ); + assert.equal( + typeof state.isComplete, + "boolean", + "isComplete should be boolean", + ); + assert.ok("raw" in state, "state should have raw field"); }); test("reconcile returns continue for non-complete state", async () => { @@ -280,16 +278,14 @@ describe("Kill switch (GSD_ENGINE_BYPASS)", () => { } }); - test("GSD_ENGINE_BYPASS=1 does not affect resolveEngine (bypass checked in autoLoop)", async () => { + test("GSD_ENGINE_BYPASS=1 does not affect resolveEngine (bypass checked in autoLoop)", async (t) => { const { resolveEngine } = await import("../engine-resolver.ts"); process.env.GSD_ENGINE_BYPASS = "1"; - try { - // resolveEngine should still resolve normally — bypass is checked in autoLoop - const { engine } = resolveEngine({ activeEngineId: null }); - assert.ok(engine, "should return an engine even with bypass set"); - } finally { - delete process.env.GSD_ENGINE_BYPASS; - } + t.after(() => delete process.env.GSD_ENGINE_BYPASS); + + // resolveEngine should still resolve normally — bypass is checked in autoLoop + const { engine } = resolveEngine({ activeEngineId: null }); + assert.ok(engine, "should return an engine even with bypass set"); }); }); diff --git a/src/resources/extensions/gsd/tests/dispatch-guard.test.ts b/src/resources/extensions/gsd/tests/dispatch-guard.test.ts index 01845433c..39900caaa 100644 --- a/src/resources/extensions/gsd/tests/dispatch-guard.test.ts +++ b/src/resources/extensions/gsd/tests/dispatch-guard.test.ts @@ -20,215 +20,199 @@ function teardownRepo(repo: string): void { rmSync(repo, { recursive: true, force: true }); } -test("dispatch guard blocks when prior milestone has incomplete slices", () => { +test("dispatch guard blocks when prior milestone has incomplete slices", (t) => { const repo = setupRepo(); - try { - mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true }); - mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true }); + t.after(() => teardownRepo(repo)); - // Seed DB: M002 with S01 complete, S02 pending - insertMilestone({ id: "M002", title: "Previous" }); - insertSlice({ id: "S01", milestoneId: "M002", title: "Done", status: "complete", depends: [], sequence: 1 }); - insertSlice({ id: "S02", milestoneId: "M002", title: "Pending", status: "pending", depends: ["S01"], sequence: 2 }); + mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true }); + mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true }); - // M003 with two pending slices - insertMilestone({ id: "M003", title: "Current" }); - insertSlice({ id: "S01", milestoneId: "M003", title: "First", status: "pending", depends: [], sequence: 1 }); - insertSlice({ id: "S02", milestoneId: "M003", title: "Second", status: "pending", depends: ["S01"], sequence: 2 }); + // Seed DB: M002 with S01 complete, S02 pending + insertMilestone({ id: "M002", title: "Previous" }); + insertSlice({ id: "S01", milestoneId: "M002", title: "Done", status: "complete", depends: [], sequence: 1 }); + insertSlice({ id: "S02", milestoneId: "M002", title: "Pending", status: "pending", depends: ["S01"], sequence: 2 }); - // Need ROADMAP files for milestone discovery (findMilestoneIds reads disk) - writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "# M002\n"); - writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), "# M003\n"); + // M003 with two pending slices + insertMilestone({ id: "M003", title: "Current" }); + insertSlice({ id: "S01", milestoneId: "M003", title: "First", status: "pending", depends: [], sequence: 1 }); + insertSlice({ id: "S02", milestoneId: "M003", title: "Second", status: "pending", depends: ["S01"], sequence: 2 }); - assert.equal( - getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M003/S01"), - "Cannot dispatch plan-slice M003/S01: earlier slice M002/S02 is not complete.", - ); - } finally { - teardownRepo(repo); - } + // Need ROADMAP files for milestone discovery (findMilestoneIds reads disk) + writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "# M002\n"); + writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), "# M003\n"); + + assert.equal( + getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M003/S01"), + "Cannot dispatch plan-slice M003/S01: earlier slice M002/S02 is not complete.", + ); }); -test("dispatch guard blocks later slice in same milestone when earlier incomplete", () => { +test("dispatch guard blocks later slice in same milestone when earlier incomplete", (t) => { const repo = setupRepo(); - try { - mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true }); - mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true }); + t.after(() => teardownRepo(repo)); - insertMilestone({ id: "M002", title: "Previous" }); - insertSlice({ id: "S01", milestoneId: "M002", title: "Done", status: "complete", depends: [], sequence: 1 }); - insertSlice({ id: "S02", milestoneId: "M002", title: "Done", status: "complete", depends: ["S01"], sequence: 2 }); + mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true }); + mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true }); - insertMilestone({ id: "M003", title: "Current" }); - insertSlice({ id: "S01", milestoneId: "M003", title: "First", status: "pending", depends: [], sequence: 1 }); - insertSlice({ id: "S02", milestoneId: "M003", title: "Second", status: "pending", depends: ["S01"], sequence: 2 }); + insertMilestone({ id: "M002", title: "Previous" }); + insertSlice({ id: "S01", milestoneId: "M002", title: "Done", status: "complete", depends: [], sequence: 1 }); + insertSlice({ id: "S02", milestoneId: "M002", title: "Done", status: "complete", depends: ["S01"], sequence: 2 }); - writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "# M002\n"); - writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), "# M003\n"); + insertMilestone({ id: "M003", title: "Current" }); + insertSlice({ id: "S01", milestoneId: "M003", title: "First", status: "pending", depends: [], sequence: 1 }); + insertSlice({ id: "S02", milestoneId: "M003", title: "Second", status: "pending", depends: ["S01"], sequence: 2 }); - assert.equal( - getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M003/S02/T01"), - "Cannot dispatch execute-task M003/S02/T01: dependency slice M003/S01 is not complete.", - ); - } finally { - teardownRepo(repo); - } + writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "# M002\n"); + writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), "# M003\n"); + + assert.equal( + getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M003/S02/T01"), + "Cannot dispatch execute-task M003/S02/T01: dependency slice M003/S01 is not complete.", + ); }); -test("dispatch guard allows dispatch when all earlier slices complete", () => { +test("dispatch guard allows dispatch when all earlier slices complete", (t) => { const repo = setupRepo(); - try { - mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true }); + t.after(() => teardownRepo(repo)); - insertMilestone({ id: "M003", title: "Current" }); - insertSlice({ id: "S01", milestoneId: "M003", title: "First", status: "complete", depends: [], sequence: 1 }); - insertSlice({ id: "S02", milestoneId: "M003", title: "Second", status: "pending", depends: ["S01"], sequence: 2 }); + mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true }); - writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), "# M003\n"); + insertMilestone({ id: "M003", title: "Current" }); + insertSlice({ id: "S01", milestoneId: "M003", title: "First", status: "complete", depends: [], sequence: 1 }); + insertSlice({ id: "S02", milestoneId: "M003", title: "Second", status: "pending", depends: ["S01"], sequence: 2 }); - assert.equal(getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M003/S02/T01"), null); - assert.equal(getPriorSliceCompletionBlocker(repo, "main", "plan-milestone", "M003"), null); - } finally { - teardownRepo(repo); - } + writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), "# M003\n"); + + assert.equal(getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M003/S02/T01"), null); + assert.equal(getPriorSliceCompletionBlocker(repo, "main", "plan-milestone", "M003"), null); }); -test("dispatch guard unblocks slice when positionally-earlier slice depends on it (#1638)", () => { +test("dispatch guard unblocks slice when positionally-earlier slice depends on it (#1638)", (t) => { // S05 depends on S06, but S05 appears first positionally. // Old behavior: S06 blocked because S05 (positionally earlier) is incomplete. // Fixed behavior: S06 has no unmet dependencies, so it can dispatch. const repo = setupRepo(); - try { - mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true }); + t.after(() => teardownRepo(repo)); - insertMilestone({ id: "M001", title: "Test" }); - insertSlice({ id: "S01", milestoneId: "M001", title: "Setup", status: "complete", depends: [], sequence: 1 }); - insertSlice({ id: "S02", milestoneId: "M001", title: "Core", status: "complete", depends: ["S01"], sequence: 2 }); - insertSlice({ id: "S03", milestoneId: "M001", title: "API", status: "complete", depends: ["S02"], sequence: 3 }); - insertSlice({ id: "S04", milestoneId: "M001", title: "Auth", status: "complete", depends: ["S03"], sequence: 4 }); - insertSlice({ id: "S05", milestoneId: "M001", title: "Integration", status: "pending", depends: ["S04", "S06"], sequence: 5 }); - insertSlice({ id: "S06", milestoneId: "M001", title: "Data Layer", status: "pending", depends: ["S04"], sequence: 6 }); + mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true }); - writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n"); + insertMilestone({ id: "M001", title: "Test" }); + insertSlice({ id: "S01", milestoneId: "M001", title: "Setup", status: "complete", depends: [], sequence: 1 }); + insertSlice({ id: "S02", milestoneId: "M001", title: "Core", status: "complete", depends: ["S01"], sequence: 2 }); + insertSlice({ id: "S03", milestoneId: "M001", title: "API", status: "complete", depends: ["S02"], sequence: 3 }); + insertSlice({ id: "S04", milestoneId: "M001", title: "Auth", status: "complete", depends: ["S03"], sequence: 4 }); + insertSlice({ id: "S05", milestoneId: "M001", title: "Integration", status: "pending", depends: ["S04", "S06"], sequence: 5 }); + insertSlice({ id: "S06", milestoneId: "M001", title: "Data Layer", status: "pending", depends: ["S04"], sequence: 6 }); - // S06 depends only on S04 (complete) — should be unblocked - assert.equal( - getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S06"), - null, - ); + writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n"); - // S05 depends on S04 (complete) and S06 (incomplete) — should be blocked - assert.equal( - getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S05"), - "Cannot dispatch plan-slice M001/S05: dependency slice M001/S06 is not complete.", - ); - } finally { - teardownRepo(repo); - } + // S06 depends only on S04 (complete) — should be unblocked + assert.equal( + getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S06"), + null, + ); + + // S05 depends on S04 (complete) and S06 (incomplete) — should be blocked + assert.equal( + getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S05"), + "Cannot dispatch plan-slice M001/S05: dependency slice M001/S06 is not complete.", + ); }); -test("dispatch guard falls back to positional ordering when no dependencies declared", () => { +test("dispatch guard falls back to positional ordering when no dependencies declared", (t) => { const repo = setupRepo(); - try { - mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true }); + t.after(() => teardownRepo(repo)); - insertMilestone({ id: "M001", title: "Test" }); - insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete", depends: [], sequence: 1 }); - insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "pending", depends: [], sequence: 2 }); - insertSlice({ id: "S03", milestoneId: "M001", title: "Third", status: "pending", depends: [], sequence: 3 }); + mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true }); - writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n"); + insertMilestone({ id: "M001", title: "Test" }); + insertSlice({ id: "S01", milestoneId: "M001", title: "First", status: "complete", depends: [], sequence: 1 }); + insertSlice({ id: "S02", milestoneId: "M001", title: "Second", status: "pending", depends: [], sequence: 2 }); + insertSlice({ id: "S03", milestoneId: "M001", title: "Third", status: "pending", depends: [], sequence: 3 }); - // S03 has no dependencies — positional fallback blocks on S02 - assert.equal( - getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S03"), - "Cannot dispatch plan-slice M001/S03: earlier slice M001/S02 is not complete.", - ); + writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n"); - // S02 has no dependencies — positional fallback: S01 is done, so unblocked - assert.equal( - getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S02"), - null, - ); - } finally { - teardownRepo(repo); - } + // S03 has no dependencies — positional fallback blocks on S02 + assert.equal( + getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S03"), + "Cannot dispatch plan-slice M001/S03: earlier slice M001/S02 is not complete.", + ); + + // S02 has no dependencies — positional fallback: S01 is done, so unblocked + assert.equal( + getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S02"), + null, + ); }); -test("dispatch guard allows slice with all declared dependencies complete", () => { +test("dispatch guard allows slice with all declared dependencies complete", (t) => { const repo = setupRepo(); - try { - mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true }); + t.after(() => teardownRepo(repo)); - insertMilestone({ id: "M001", title: "Test" }); - insertSlice({ id: "S01", milestoneId: "M001", title: "Setup", status: "complete", depends: [], sequence: 1 }); - insertSlice({ id: "S02", milestoneId: "M001", title: "Core", status: "complete", depends: ["S01"], sequence: 2 }); - insertSlice({ id: "S03", milestoneId: "M001", title: "Feature A", status: "pending", depends: ["S01", "S02"], sequence: 3 }); - insertSlice({ id: "S04", milestoneId: "M001", title: "Feature B", status: "pending", depends: ["S01"], sequence: 4 }); + mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true }); - writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n"); + insertMilestone({ id: "M001", title: "Test" }); + insertSlice({ id: "S01", milestoneId: "M001", title: "Setup", status: "complete", depends: [], sequence: 1 }); + insertSlice({ id: "S02", milestoneId: "M001", title: "Core", status: "complete", depends: ["S01"], sequence: 2 }); + insertSlice({ id: "S03", milestoneId: "M001", title: "Feature A", status: "pending", depends: ["S01", "S02"], sequence: 3 }); + insertSlice({ id: "S04", milestoneId: "M001", title: "Feature B", status: "pending", depends: ["S01"], sequence: 4 }); - // S03 depends on S01 (done) and S02 (done) — unblocked - assert.equal( - getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S03"), - null, - ); + writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n"); - // S04 depends only on S01 (done) — unblocked even though S03 is incomplete - assert.equal( - getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S04"), - null, - ); - } finally { - teardownRepo(repo); - } + // S03 depends on S01 (done) and S02 (done) — unblocked + assert.equal( + getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S03"), + null, + ); + + // S04 depends only on S01 (done) — unblocked even though S03 is incomplete + assert.equal( + getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S04"), + null, + ); }); -test("dispatch guard skips completed milestone with SUMMARY even if it has unchecked remediation slices (#1716)", () => { +test("dispatch guard skips completed milestone with SUMMARY even if it has unchecked remediation slices (#1716)", (t) => { const repo = setupRepo(); - try { - mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true }); - mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true }); + t.after(() => teardownRepo(repo)); - // M001 is complete (has SUMMARY) but has unchecked remediation slices in DB - insertMilestone({ id: "M001", title: "Previous" }); - insertSlice({ id: "S01", milestoneId: "M001", title: "Core", status: "complete", depends: [], sequence: 1 }); - insertSlice({ id: "S02", milestoneId: "M001", title: "Tests", status: "complete", depends: ["S01"], sequence: 2 }); - insertSlice({ id: "S03-R", milestoneId: "M001", title: "Remediation", status: "pending", depends: ["S02"], sequence: 3 }); - insertSlice({ id: "S04-R", milestoneId: "M001", title: "Remediation 2", status: "pending", depends: ["S02"], sequence: 4 }); + mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true }); + mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true }); - insertMilestone({ id: "M002", title: "Current" }); - insertSlice({ id: "S01", milestoneId: "M002", title: "Start", status: "pending", depends: [], sequence: 1 }); + // M001 is complete (has SUMMARY) but has unchecked remediation slices in DB + insertMilestone({ id: "M001", title: "Previous" }); + insertSlice({ id: "S01", milestoneId: "M001", title: "Core", status: "complete", depends: [], sequence: 1 }); + insertSlice({ id: "S02", milestoneId: "M001", title: "Tests", status: "complete", depends: ["S01"], sequence: 2 }); + insertSlice({ id: "S03-R", milestoneId: "M001", title: "Remediation", status: "pending", depends: ["S02"], sequence: 3 }); + insertSlice({ id: "S04-R", milestoneId: "M001", title: "Remediation 2", status: "pending", depends: ["S02"], sequence: 4 }); - // M001 SUMMARY on disk triggers skip - writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n"); - writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-SUMMARY.md"), - "---\nstatus: complete\n---\n# M001 Summary\nDone.\n"); - writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "# M002\n"); + insertMilestone({ id: "M002", title: "Current" }); + insertSlice({ id: "S01", milestoneId: "M002", title: "Start", status: "pending", depends: [], sequence: 1 }); - // M001 has SUMMARY — should be skipped, not block M002/S01 - assert.equal( - getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M002/S01"), - null, - ); - } finally { - teardownRepo(repo); - } + // M001 SUMMARY on disk triggers skip + writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n"); + writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-SUMMARY.md"), + "---\nstatus: complete\n---\n# M001 Summary\nDone.\n"); + writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), "# M002\n"); + + // M001 has SUMMARY — should be skipped, not block M002/S01 + assert.equal( + getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M002/S01"), + null, + ); }); -test("dispatch guard works without git repo", () => { +test("dispatch guard works without git repo", (t) => { const repo = setupRepo(); - try { - mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true }); + t.after(() => teardownRepo(repo)); - insertMilestone({ id: "M001", title: "Test" }); - insertSlice({ id: "S01", milestoneId: "M001", title: "Done", status: "complete", depends: [], sequence: 1 }); - insertSlice({ id: "S02", milestoneId: "M001", title: "Pending", status: "pending", depends: ["S01"], sequence: 2 }); + mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true }); - writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n"); + insertMilestone({ id: "M001", title: "Test" }); + insertSlice({ id: "S01", milestoneId: "M001", title: "Done", status: "complete", depends: [], sequence: 1 }); + insertSlice({ id: "S02", milestoneId: "M001", title: "Pending", status: "pending", depends: ["S01"], sequence: 2 }); - assert.equal(getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S02"), null); - } finally { - teardownRepo(repo); - } + writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "# M001\n"); + + assert.equal(getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S02"), null); }); diff --git a/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts b/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts index 1c92b64a0..d169ba6c2 100644 --- a/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +++ b/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts @@ -71,62 +71,56 @@ function scaffoldTaskPlan(basePath: string, mid: string, sid: string, tid: strin // ─── Tests ───────────────────────────────────────────────────────────────── -test("dispatch: missing task plan triggers plan-slice (not stop) — issue #909", async () => { +test("dispatch: missing task plan triggers plan-slice (not stop) — issue #909", async (t) => { const tmp = mkdtempSync(join(tmpdir(), "gsd-909-")); - try { - // Slice plan exists with tasks, but tasks/ directory is empty - scaffoldSlicePlan(tmp, "M002", "S03"); + t.after(() => rmSync(tmp, { recursive: true, force: true })); - const ctx = makeContext(tmp); - const result = await resolveDispatch(ctx); + // Slice plan exists with tasks, but tasks/ directory is empty + scaffoldSlicePlan(tmp, "M002", "S03"); - assert.equal(result.action, "dispatch", "should dispatch, not stop"); - assert.ok(result.action === "dispatch" && result.unitType === "plan-slice", - `unitType should be plan-slice, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`); - assert.ok(result.action === "dispatch" && result.unitId === "M002/S03", - `unitId should be M002/S03, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + const ctx = makeContext(tmp); + const result = await resolveDispatch(ctx); + + assert.equal(result.action, "dispatch", "should dispatch, not stop"); + assert.ok(result.action === "dispatch" && result.unitType === "plan-slice", + `unitType should be plan-slice, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`); + assert.ok(result.action === "dispatch" && result.unitId === "M002/S03", + `unitId should be M002/S03, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`); }); -test("dispatch: present task plan proceeds to execute-task normally", async () => { +test("dispatch: present task plan proceeds to execute-task normally", async (t) => { const tmp = mkdtempSync(join(tmpdir(), "gsd-909-ok-")); - try { - scaffoldSlicePlan(tmp, "M002", "S03"); - scaffoldTaskPlan(tmp, "M002", "S03", "T01"); + t.after(() => rmSync(tmp, { recursive: true, force: true })); - const ctx = makeContext(tmp); - const result = await resolveDispatch(ctx); + scaffoldSlicePlan(tmp, "M002", "S03"); + scaffoldTaskPlan(tmp, "M002", "S03", "T01"); - assert.equal(result.action, "dispatch"); - assert.ok(result.action === "dispatch" && result.unitType === "execute-task", - `unitType should be execute-task, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`); - assert.ok(result.action === "dispatch" && result.unitId === "M002/S03/T01", - `unitId should be M002/S03/T01, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + const ctx = makeContext(tmp); + const result = await resolveDispatch(ctx); + + assert.equal(result.action, "dispatch"); + assert.ok(result.action === "dispatch" && result.unitType === "execute-task", + `unitType should be execute-task, got: ${result.action === "dispatch" ? result.unitType : "(stop)"}`); + assert.ok(result.action === "dispatch" && result.unitId === "M002/S03/T01", + `unitId should be M002/S03/T01, got: ${result.action === "dispatch" ? result.unitId : "(stop)"}`); }); -test("dispatch: plan-slice recovery loop — second call after plan-slice still recovers cleanly", async () => { +test("dispatch: plan-slice recovery loop — second call after plan-slice still recovers cleanly", async (t) => { // Simulate: plan-slice ran but T01-PLAN.md is still missing (e.g. agent crashed mid-write). // Dispatch should still re-dispatch plan-slice, not hard-stop. const tmp = mkdtempSync(join(tmpdir(), "gsd-909-loop-")); - try { - scaffoldSlicePlan(tmp, "M002", "S03"); + t.after(() => rmSync(tmp, { recursive: true, force: true })); - const ctx = makeContext(tmp); - const r1 = await resolveDispatch(ctx); - assert.equal(r1.action, "dispatch"); - assert.ok(r1.action === "dispatch" && r1.unitType === "plan-slice"); + scaffoldSlicePlan(tmp, "M002", "S03"); - // Still no task plan written — dispatch again - const r2 = await resolveDispatch(ctx); - assert.equal(r2.action, "dispatch"); - assert.ok(r2.action === "dispatch" && r2.unitType === "plan-slice", - "should keep dispatching plan-slice until task plans appear"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + const ctx = makeContext(tmp); + const r1 = await resolveDispatch(ctx); + assert.equal(r1.action, "dispatch"); + assert.ok(r1.action === "dispatch" && r1.unitType === "plan-slice"); + + // Still no task plan written — dispatch again + const r2 = await resolveDispatch(ctx); + assert.equal(r2.action, "dispatch"); + assert.ok(r2.action === "dispatch" && r2.unitType === "plan-slice", + "should keep dispatching plan-slice until task plans appear"); }); diff --git a/src/resources/extensions/gsd/tests/dispatch-uat-last-completed.test.ts b/src/resources/extensions/gsd/tests/dispatch-uat-last-completed.test.ts index d64c3f683..4a014d4ae 100644 --- a/src/resources/extensions/gsd/tests/dispatch-uat-last-completed.test.ts +++ b/src/resources/extensions/gsd/tests/dispatch-uat-last-completed.test.ts @@ -66,7 +66,7 @@ function createFixture(): string { return base; } -test("dispatch uat targets last completed slice, not activeSlice (#1693)", async () => { +test("dispatch uat targets last completed slice, not activeSlice (#1693)", async (t) => { const base = createFixture(); invalidateStateCache(); @@ -88,31 +88,29 @@ test("dispatch uat targets last completed slice, not activeSlice (#1693)", async }, } as any; - try { - await dispatchDirectPhase(ctx, pi, "uat", base); + t.after(() => rmSync(base, { recursive: true, force: true })); - // Should have dispatched (sendMessage called) - assert.ok(sentPrompt, "sendMessage should have been called with a prompt"); + await dispatchDirectPhase(ctx, pi, "uat", base); - // The dispatch notification should reference M001/S01 (completed), not M001/S02 (active) - const dispatchNotification = notifications.find(n => n.message.startsWith("Dispatching")); - assert.ok(dispatchNotification, "dispatch notification should be present"); - assert.match( - dispatchNotification.message, - /M001\/S01/, - "dispatch should target completed slice S01, not active slice S02", - ); - assert.doesNotMatch( - dispatchNotification.message, - /M001\/S02/, - "dispatch should NOT target active (next incomplete) slice S02", - ); - } finally { - rmSync(base, { recursive: true, force: true }); - } + // Should have dispatched (sendMessage called) + assert.ok(sentPrompt, "sendMessage should have been called with a prompt"); + + // The dispatch notification should reference M001/S01 (completed), not M001/S02 (active) + const dispatchNotification = notifications.find(n => n.message.startsWith("Dispatching")); + assert.ok(dispatchNotification, "dispatch notification should be present"); + assert.match( + dispatchNotification.message, + /M001\/S01/, + "dispatch should target completed slice S01, not active slice S02", + ); + assert.doesNotMatch( + dispatchNotification.message, + /M001\/S02/, + "dispatch should NOT target active (next incomplete) slice S02", + ); }); -test("dispatch uat warns when no completed slices exist", async () => { +test("dispatch uat warns when no completed slices exist", async (t) => { const base = mkdtempSync(join(tmpdir(), "gsd-dispatch-uat-none-")); invalidateStateCache(); @@ -164,13 +162,11 @@ test("dispatch uat warns when no completed slices exist", async () => { }, } as any; - try { - await dispatchDirectPhase(ctx, pi, "uat", base); + t.after(() => rmSync(base, { recursive: true, force: true })); - const warning = notifications.find(n => n.level === "warning"); - assert.ok(warning, "should show a warning notification"); - assert.match(warning.message, /no completed slices/, "warning should mention no completed slices"); - } finally { - rmSync(base, { recursive: true, force: true }); - } + await dispatchDirectPhase(ctx, pi, "uat", base); + + const warning = notifications.find(n => n.level === "warning"); + assert.ok(warning, "should show a warning notification"); + assert.match(warning.message, /no completed slices/, "warning should mention no completed slices"); }); diff --git a/src/resources/extensions/gsd/tests/doctor-completion-deferral.test.ts b/src/resources/extensions/gsd/tests/doctor-completion-deferral.test.ts index 78d22368f..35623e2e3 100644 --- a/src/resources/extensions/gsd/tests/doctor-completion-deferral.test.ts +++ b/src/resources/extensions/gsd/tests/doctor-completion-deferral.test.ts @@ -56,35 +56,33 @@ Done. `); } -test("doctor does not report any reconciliation issue codes", async () => { +test("doctor does not report any reconciliation issue codes", async (t) => { const tmp = makeTmp("no-reconciliation"); - try { - buildScaffold(tmp); + t.after(() => rmSync(tmp, { recursive: true, force: true })); - const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" }); + buildScaffold(tmp); - const REMOVED_CODES = [ - "task_done_missing_summary", - "task_summary_without_done_checkbox", - "all_tasks_done_missing_slice_summary", - "all_tasks_done_missing_slice_uat", - "all_tasks_done_roadmap_not_checked", - "slice_checked_missing_summary", - "slice_checked_missing_uat", - ]; + const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" }); - const codes = report.issues.map(i => i.code); - for (const removed of REMOVED_CODES) { - assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`); - } + const REMOVED_CODES = [ + "task_done_missing_summary", + "task_summary_without_done_checkbox", + "all_tasks_done_missing_slice_summary", + "all_tasks_done_missing_slice_uat", + "all_tasks_done_roadmap_not_checked", + "slice_checked_missing_summary", + "slice_checked_missing_uat", + ]; - // No summary or UAT stubs should be created - const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"); - assert.ok(!existsSync(sliceSummaryPath), "should NOT have created summary stub"); - - const sliceUatPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT.md"); - assert.ok(!existsSync(sliceUatPath), "should NOT have created UAT stub"); - } finally { - rmSync(tmp, { recursive: true, force: true }); + const codes = report.issues.map(i => i.code); + for (const removed of REMOVED_CODES) { + assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`); } + + // No summary or UAT stubs should be created + const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"); + assert.ok(!existsSync(sliceSummaryPath), "should NOT have created summary stub"); + + const sliceUatPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT.md"); + assert.ok(!existsSync(sliceUatPath), "should NOT have created UAT stub"); }); diff --git a/src/resources/extensions/gsd/tests/doctor-delimiter-fix.test.ts b/src/resources/extensions/gsd/tests/doctor-delimiter-fix.test.ts index afd9332fa..47b75723a 100644 --- a/src/resources/extensions/gsd/tests/doctor-delimiter-fix.test.ts +++ b/src/resources/extensions/gsd/tests/doctor-delimiter-fix.test.ts @@ -12,7 +12,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { runGSDDoctor } from "../doctor.js"; -test("doctor fix=true sanitizes em-dash in milestone title", async () => { +test("doctor fix=true sanitizes em-dash in milestone title", async (t) => { const tmpBase = mkdtempSync(join(tmpdir(), "gsd-doctor-delim-")); const gsd = join(tmpBase, ".gsd"); const mDir = join(gsd, "milestones", "M001"); @@ -34,33 +34,31 @@ test("doctor fix=true sanitizes em-dash in milestone title", async () => { writeFileSync(join(sDir, "S01-PLAN.md"), `# S01: Initial Setup\n\n## Tasks\n- [ ] **T01: Scaffold** \`est:15m\`\n`); writeFileSync(join(tDir, "T01-PLAN.md"), "# T01: Scaffold\n"); - try { - // Run doctor with fix=true - const report = await runGSDDoctor(tmpBase, { fix: true }); + t.after(() => rmSync(tmpBase, { recursive: true, force: true })); - // The em-dash should have been replaced - const fixed = readFileSync(join(mDir, "M001-ROADMAP.md"), "utf-8"); - const h1 = fixed.split("\n").find(l => l.startsWith("# "))!; - assert.ok(h1, "H1 line should exist"); - assert.ok(!h1.includes("\u2014"), "em-dash should be replaced"); - assert.ok(!h1.includes("\u2013"), "en-dash should be replaced"); - assert.ok(h1.includes("-"), "should contain ASCII hyphen as replacement"); + // Run doctor with fix=true + const report = await runGSDDoctor(tmpBase, { fix: true }); - // Should have recorded the fix - assert.ok( - report.fixesApplied.some(f => f.includes("sanitized")), - `fixesApplied should mention sanitization, got: ${JSON.stringify(report.fixesApplied)}`, - ); + // The em-dash should have been replaced + const fixed = readFileSync(join(mDir, "M001-ROADMAP.md"), "utf-8"); + const h1 = fixed.split("\n").find(l => l.startsWith("# "))!; + assert.ok(h1, "H1 line should exist"); + assert.ok(!h1.includes("\u2014"), "em-dash should be replaced"); + assert.ok(!h1.includes("\u2013"), "en-dash should be replaced"); + assert.ok(h1.includes("-"), "should contain ASCII hyphen as replacement"); - // The issue should NOT appear in the report (it was fixed) - const delimIssues = report.issues.filter(i => i.code === "delimiter_in_title" && i.unitId === "M001"); - assert.equal(delimIssues.length, 0, "fixed issue should not appear in issues list"); - } finally { - rmSync(tmpBase, { recursive: true, force: true }); - } + // Should have recorded the fix + assert.ok( + report.fixesApplied.some(f => f.includes("sanitized")), + `fixesApplied should mention sanitization, got: ${JSON.stringify(report.fixesApplied)}`, + ); + + // The issue should NOT appear in the report (it was fixed) + const delimIssues = report.issues.filter(i => i.code === "delimiter_in_title" && i.unitId === "M001"); + assert.equal(delimIssues.length, 0, "fixed issue should not appear in issues list"); }); -test("doctor fix=false still reports delimiter_in_title as warning", async () => { +test("doctor fix=false still reports delimiter_in_title as warning", async (t) => { const tmpBase = mkdtempSync(join(tmpdir(), "gsd-doctor-delim-nf-")); const gsd = join(tmpBase, ".gsd"); const mDir = join(gsd, "milestones", "M001"); @@ -72,16 +70,14 @@ test("doctor fix=false still reports delimiter_in_title as warning", async () => writeFileSync(join(sDir, "S01-PLAN.md"), `# S01: Setup\n\n## Tasks\n- [ ] **T01: Init** \`est:10m\`\n`); writeFileSync(join(tDir, "T01-PLAN.md"), "# T01: Init\n"); - try { - const report = await runGSDDoctor(tmpBase, { fix: false }); - const delimIssues = report.issues.filter(i => i.code === "delimiter_in_title"); - assert.ok(delimIssues.length > 0, "should report delimiter_in_title as issue when fix=false"); - assert.equal(delimIssues[0].severity, "warning"); + t.after(() => rmSync(tmpBase, { recursive: true, force: true })); - // File should be unchanged - const content = readFileSync(join(mDir, "M001-ROADMAP.md"), "utf-8"); - assert.ok(content.includes("\u2014"), "file should not be modified when fix=false"); - } finally { - rmSync(tmpBase, { recursive: true, force: true }); - } + const report = await runGSDDoctor(tmpBase, { fix: false }); + const delimIssues = report.issues.filter(i => i.code === "delimiter_in_title"); + assert.ok(delimIssues.length > 0, "should report delimiter_in_title as issue when fix=false"); + assert.equal(delimIssues[0].severity, "warning"); + + // File should be unchanged + const content = readFileSync(join(mDir, "M001-ROADMAP.md"), "utf-8"); + assert.ok(content.includes("\u2014"), "file should not be modified when fix=false"); }); diff --git a/src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts b/src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts index 3510c14c1..21f15cdbc 100644 --- a/src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts +++ b/src/resources/extensions/gsd/tests/doctor-fixlevel.test.ts @@ -76,56 +76,53 @@ const REMOVED_CODES = [ "slice_checked_missing_uat", ]; -test("fixLevel:task — no reconciliation issue codes are reported", async () => { +test("fixLevel:task — no reconciliation issue codes are reported", async (t) => { const tmp = makeTmp("task-level"); - try { - buildScaffold(tmp); + t.after(() => rmSync(tmp, { recursive: true, force: true })); - const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" }); + buildScaffold(tmp); - const codes = report.issues.map(i => i.code); - for (const removed of REMOVED_CODES) { - assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`); - } - } finally { - rmSync(tmp, { recursive: true, force: true }); + const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" }); + + const codes = report.issues.map(i => i.code); + for (const removed of REMOVED_CODES) { + assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`); } }); -test("fixLevel:all — no reconciliation issue codes are reported", async () => { +test("fixLevel:all — no reconciliation issue codes are reported", async (t) => { const tmp = makeTmp("all-level"); - try { - buildScaffold(tmp); + t.after(() => rmSync(tmp, { recursive: true, force: true })); - const report = await runGSDDoctor(tmp, { fix: true }); + buildScaffold(tmp); - const codes = report.issues.map(i => i.code); - for (const removed of REMOVED_CODES) { - assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`); - } + const report = await runGSDDoctor(tmp, { fix: true }); - // Summary and UAT stubs should NOT be created (no reconciliation) - const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"); - assert.ok(!existsSync(sliceSummaryPath), "should NOT have created summary stub"); - - // Roadmap should remain unchecked (no reconciliation) - const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8"); - assert.ok(roadmapContent.includes("- [ ] **S01"), "roadmap should remain unchecked"); - } finally { - rmSync(tmp, { recursive: true, force: true }); + const codes = report.issues.map(i => i.code); + for (const removed of REMOVED_CODES) { + assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`); } + + // Summary and UAT stubs should NOT be created (no reconciliation) + const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"); + assert.ok(!existsSync(sliceSummaryPath), "should NOT have created summary stub"); + + // Roadmap should remain unchecked (no reconciliation) + const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8"); + assert.ok(roadmapContent.includes("- [ ] **S01"), "roadmap should remain unchecked"); }); -test("fixLevel:all — delimiter_in_title still fixable", async () => { +test("fixLevel:all — delimiter_in_title still fixable", async (t) => { const tmp = makeTmp("delimiter-fix"); - try { - const gsd = join(tmp, ".gsd"); - const m = join(gsd, "milestones", "M001"); - const s = join(m, "slices", "S01", "tasks"); - mkdirSync(s, { recursive: true }); + t.after(() => rmSync(tmp, { recursive: true, force: true })); - // Roadmap with em dash in milestone title (should still be fixable) - writeFileSync(join(m, "M001-ROADMAP.md"), `# M001: Foundation \u2014 Build Core + const gsd = join(tmp, ".gsd"); + const m = join(gsd, "milestones", "M001"); + const s = join(m, "slices", "S01", "tasks"); + mkdirSync(s, { recursive: true }); + + // Roadmap with em dash in milestone title (should still be fixable) + writeFileSync(join(m, "M001-ROADMAP.md"), `# M001: Foundation \u2014 Build Core ## Slices @@ -133,7 +130,7 @@ test("fixLevel:all — delimiter_in_title still fixable", async () => { > Demo `); - writeFileSync(join(m, "slices", "S01", "S01-PLAN.md"), `# S01: Test Slice + writeFileSync(join(m, "slices", "S01", "S01-PLAN.md"), `# S01: Test Slice **Goal:** test @@ -142,13 +139,10 @@ test("fixLevel:all — delimiter_in_title still fixable", async () => { - [ ] **T01: Do stuff** \`est:5m\` `); - const report = await runGSDDoctor(tmp, { fix: true }); + const report = await runGSDDoctor(tmp, { fix: true }); - const delimiterIssues = report.issues.filter(i => i.code === "delimiter_in_title"); - // The milestone-level delimiter is auto-fixed, but the report may or may not include it - // depending on whether it was fixed successfully. Just verify it ran without crashing. - assert.ok(report.issues !== undefined, "doctor produces a report"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + const delimiterIssues = report.issues.filter(i => i.code === "delimiter_in_title"); + // The milestone-level delimiter is auto-fixed, but the report may or may not include it + // depending on whether it was fixed successfully. Just verify it ran without crashing. + assert.ok(report.issues !== undefined, "doctor produces a report"); }); diff --git a/src/resources/extensions/gsd/tests/doctor-roadmap-summary-atomicity.test.ts b/src/resources/extensions/gsd/tests/doctor-roadmap-summary-atomicity.test.ts index 959cbe382..140db7f0c 100644 --- a/src/resources/extensions/gsd/tests/doctor-roadmap-summary-atomicity.test.ts +++ b/src/resources/extensions/gsd/tests/doctor-roadmap-summary-atomicity.test.ts @@ -58,72 +58,66 @@ Done. `); } -test("fixLevel:task — roadmap checkbox is never toggled by doctor (reconciliation removed)", async () => { +test("fixLevel:task — roadmap checkbox is never toggled by doctor (reconciliation removed)", async (t) => { const tmp = makeTmp("no-roadmap-toggle"); - try { - buildScaffold(tmp); + t.after(() => rmSync(tmp, { recursive: true, force: true })); - const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" }); + buildScaffold(tmp); - // Roadmap must remain unchecked — doctor no longer touches checkboxes - const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8"); - assert.ok( - roadmapContent.includes("- [ ] **S01"), - "roadmap should remain unchecked — doctor no longer toggles checkboxes" - ); + const report = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" }); - // No summary or UAT stubs created - const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"); - assert.ok(!existsSync(sliceSummaryPath), "summary should NOT be created"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + // Roadmap must remain unchecked — doctor no longer touches checkboxes + const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8"); + assert.ok( + roadmapContent.includes("- [ ] **S01"), + "roadmap should remain unchecked — doctor no longer toggles checkboxes" + ); + + // No summary or UAT stubs created + const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"); + assert.ok(!existsSync(sliceSummaryPath), "summary should NOT be created"); }); -test("fixLevel:all — roadmap checkbox is never toggled by doctor (reconciliation removed)", async () => { +test("fixLevel:all — roadmap checkbox is never toggled by doctor (reconciliation removed)", async (t) => { const tmp = makeTmp("all-no-toggle"); - try { - buildScaffold(tmp); + t.after(() => rmSync(tmp, { recursive: true, force: true })); - const report = await runGSDDoctor(tmp, { fix: true }); + buildScaffold(tmp); - // Even at fixLevel:all, doctor no longer creates stubs or toggles checkboxes - const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8"); - assert.ok( - roadmapContent.includes("- [ ] **S01"), - "roadmap should remain unchecked — reconciliation removed" - ); + const report = await runGSDDoctor(tmp, { fix: true }); - const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"); - assert.ok(!existsSync(sliceSummaryPath), "summary should NOT be created"); - } finally { - rmSync(tmp, { recursive: true, force: true }); - } + // Even at fixLevel:all, doctor no longer creates stubs or toggles checkboxes + const roadmapContent = readFileSync(join(tmp, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), "utf8"); + assert.ok( + roadmapContent.includes("- [ ] **S01"), + "roadmap should remain unchecked — reconciliation removed" + ); + + const sliceSummaryPath = join(tmp, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"); + assert.ok(!existsSync(sliceSummaryPath), "summary should NOT be created"); }); -test("consecutive doctor runs produce no reconciliation codes", async () => { +test("consecutive doctor runs produce no reconciliation codes", async (t) => { const tmp = makeTmp("consecutive-clean"); - try { - buildScaffold(tmp); + t.after(() => rmSync(tmp, { recursive: true, force: true })); - await runGSDDoctor(tmp, { fix: true, fixLevel: "task" }); - const report2 = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" }); + buildScaffold(tmp); - const REMOVED_CODES = [ - "task_done_missing_summary", - "task_summary_without_done_checkbox", - "all_tasks_done_missing_slice_summary", - "all_tasks_done_missing_slice_uat", - "all_tasks_done_roadmap_not_checked", - "slice_checked_missing_summary", - "slice_checked_missing_uat", - ]; + await runGSDDoctor(tmp, { fix: true, fixLevel: "task" }); + const report2 = await runGSDDoctor(tmp, { fix: true, fixLevel: "task" }); - const codes = report2.issues.map(i => i.code); - for (const removed of REMOVED_CODES) { - assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`); - } - } finally { - rmSync(tmp, { recursive: true, force: true }); + const REMOVED_CODES = [ + "task_done_missing_summary", + "task_summary_without_done_checkbox", + "all_tasks_done_missing_slice_summary", + "all_tasks_done_missing_slice_uat", + "all_tasks_done_roadmap_not_checked", + "slice_checked_missing_summary", + "slice_checked_missing_uat", + ]; + + const codes = report2.issues.map(i => i.code); + for (const removed of REMOVED_CODES) { + assert.ok(!codes.includes(removed as any), `should NOT report removed code: ${removed}`); } });