diff --git a/src/resources/extensions/gsd/tests/auto-preflight.test.ts b/src/resources/extensions/gsd/tests/auto-preflight.test.ts index eb421646c..066e16856 100644 --- a/src/resources/extensions/gsd/tests/auto-preflight.test.ts +++ b/src/resources/extensions/gsd/tests/auto-preflight.test.ts @@ -1,46 +1,40 @@ +import test from "node:test"; +import assert from "node:assert/strict"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { runGSDDoctor, selectDoctorScope, filterDoctorIssues } from "../doctor.js"; -import { createTestContext } from './test-helpers.ts'; -const { assertTrue, report } = createTestContext(); -const tmpBase = mkdtempSync(join(tmpdir(), "gsd-auto-preflight-test-")); -const gsd = join(tmpBase, ".gsd"); +test("auto-preflight scopes to active milestone, ignoring historical", async () => { + const tmpBase = mkdtempSync(join(tmpdir(), "gsd-auto-preflight-test-")); + const gsd = join(tmpBase, ".gsd"); -mkdirSync(join(gsd, "milestones", "M001", "slices", "S01", "tasks"), { recursive: true }); -mkdirSync(join(gsd, "milestones", "M009", "slices", "S01", "tasks"), { recursive: true }); + mkdirSync(join(gsd, "milestones", "M001", "slices", "S01", "tasks"), { recursive: true }); + mkdirSync(join(gsd, "milestones", "M009", "slices", "S01", "tasks"), { recursive: true }); -writeFileSync(join(gsd, "milestones", "M001", "M001-ROADMAP.md"), `# M001: Historical\n\n## Slices\n- [x] **S01: Old Slice** \`risk:low\` \`depends:[]\`\n > After this: old done\n`); -writeFileSync(join(gsd, "milestones", "M001", "slices", "S01", "S01-PLAN.md"), `# S01: Old Slice\n\n**Goal:** Old\n**Demo:** Old\n\n## Must-Haves\n- done\n\n## Tasks\n- [x] **T01: Old Task** \`est:5m\`\n done\n`); -writeFileSync(join(gsd, "milestones", "M001", "slices", "S01", "tasks", "T01-SUMMARY.md"), `---\nid: T01\nparent: S01\nmilestone: M001\nprovides: []\nrequires: []\naffects: []\nkey_files: []\nkey_decisions: []\npatterns_established: []\nobservability_surfaces: []\ndrill_down_paths: []\nduration: 5m\nverification_result: passed\ncompleted_at: 2026-03-09T00:00:00Z\n---\n\n# T01: Old Task\n\n**Done**\n\n## What Happened\nDone.\n\n## Diagnostics\n- log\n`); -writeFileSync(join(gsd, "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"), `---\nid: S01\nparent: M001\nmilestone: M001\nprovides: []\nrequires: []\naffects: []\nkey_files: []\nkey_decisions: []\npatterns_established: []\nobservability_surfaces: []\ndrill_down_paths: []\nduration: 5m\nverification_result: passed\ncompleted_at: 2026-03-09T00:00:00Z\n---\n\n# S01: Old Slice\n\n**Done**\n\n## What Happened\nDone.\n\n## Verification\nDone.\n\n## Deviations\nNone\n\n## Known Limitations\nNone\n\n## Follow-ups\nNone\n\n## Files Created/Modified\n- \`x\` — x\n\n## Forward Intelligence\n\n### What the next slice should know\n- x\n\n### What's fragile\n- x\n\n### Authoritative diagnostics\n- x\n\n### What assumptions changed\n- x\n`); + writeFileSync(join(gsd, "milestones", "M001", "M001-ROADMAP.md"), `# M001: Historical\n\n## Slices\n- [x] **S01: Old Slice** \`risk:low\` \`depends:[]\`\n > After this: old done\n`); + writeFileSync(join(gsd, "milestones", "M001", "slices", "S01", "S01-PLAN.md"), `# S01: Old Slice\n\n**Goal:** Old\n**Demo:** Old\n\n## Must-Haves\n- done\n\n## Tasks\n- [x] **T01: Old Task** \`est:5m\`\n done\n`); + writeFileSync(join(gsd, "milestones", "M001", "slices", "S01", "tasks", "T01-SUMMARY.md"), `---\nid: T01\nparent: S01\nmilestone: M001\nprovides: []\nrequires: []\naffects: []\nkey_files: []\nkey_decisions: []\npatterns_established: []\nobservability_surfaces: []\ndrill_down_paths: []\nduration: 5m\nverification_result: passed\ncompleted_at: 2026-03-09T00:00:00Z\n---\n\n# T01: Old Task\n\n**Done**\n\n## What Happened\nDone.\n\n## Diagnostics\n- log\n`); + writeFileSync(join(gsd, "milestones", "M001", "slices", "S01", "S01-SUMMARY.md"), `---\nid: S01\nparent: M001\nmilestone: M001\nprovides: []\nrequires: []\naffects: []\nkey_files: []\nkey_decisions: []\npatterns_established: []\nobservability_surfaces: []\ndrill_down_paths: []\nduration: 5m\nverification_result: passed\ncompleted_at: 2026-03-09T00:00:00Z\n---\n\n# S01: Old Slice\n\n**Done**\n\n## What Happened\nDone.\n\n## Verification\nDone.\n\n## Deviations\nNone\n\n## Known Limitations\nNone\n\n## Follow-ups\nNone\n\n## Files Created/Modified\n- \`x\` — x\n\n## Forward Intelligence\n\n### What the next slice should know\n- x\n\n### What's fragile\n- x\n\n### Authoritative diagnostics\n- x\n\n### What assumptions changed\n- x\n`); + writeFileSync(join(gsd, "milestones", "M001", "M001-VALIDATION.md"), `---\nverdict: pass\nremediation_round: 0\n---\n\n# Validation\nPassed.\n`); + writeFileSync(join(gsd, "milestones", "M001", "M001-SUMMARY.md"), `---\nid: M001\nstatus: complete\ncompleted_at: 2026-03-09T00:00:00Z\n---\n\n# M001: Historical\n\nComplete.\n`); -writeFileSync(join(gsd, "milestones", "M001", "M001-VALIDATION.md"), `---\nverdict: pass\nremediation_round: 0\n---\n\n# Validation\nPassed.\n`); -writeFileSync(join(gsd, "milestones", "M001", "M001-SUMMARY.md"), `---\nid: M001\nstatus: complete\ncompleted_at: 2026-03-09T00:00:00Z\n---\n\n# M001: Historical\n\nComplete.\n`); + 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`); -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"); -async function main(): Promise { - const scope = await selectDoctorScope(tmpBase); - assertTrue(scope === "M009/S01", "active scope selected instead of historical milestone"); + 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 scopedReport = await runGSDDoctor(tmpBase, { fix: false, scope }); - const scopedBlocking = filterDoctorIssues(scopedReport.issues, { scope, includeWarnings: false }); - assertTrue(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"); - assertTrue(historicalWarnings.length > 0, "full repo still contains historical warning drift"); - - rmSync(tmpBase, { recursive: true, force: true }); - - report(); -} - -main().catch((error) => { - console.error(error); - process.exit(1); + const historicalReport = await runGSDDoctor(tmpBase, { fix: false }); + const historicalWarnings = historicalReport.issues.filter(issue => issue.unitId.startsWith("M001/S01") && issue.severity === "warning"); + assert.ok(historicalWarnings.length > 0, "full repo still contains historical warning drift"); + } finally { + rmSync(tmpBase, { recursive: true, force: true }); + } }); diff --git a/src/resources/extensions/gsd/tests/discuss-prompt.test.ts b/src/resources/extensions/gsd/tests/discuss-prompt.test.ts index c35f40745..a24127809 100644 --- a/src/resources/extensions/gsd/tests/discuss-prompt.test.ts +++ b/src/resources/extensions/gsd/tests/discuss-prompt.test.ts @@ -1,27 +1,15 @@ -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { createTestContext } from './test-helpers.ts'; +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; -const { assertTrue, report } = createTestContext(); -const promptPath = join(process.cwd(), 'src/resources/extensions/gsd/prompts/discuss.md'); -const discussPrompt = readFileSync(promptPath, 'utf-8'); +const promptPath = join(process.cwd(), "src/resources/extensions/gsd/prompts/discuss.md"); +const discussPrompt = readFileSync(promptPath, "utf-8"); -console.log('\n=== discuss prompt: resilient vision framing ==='); -{ +test("discuss prompt: resilient vision framing", () => { const hardenedPattern = /Say exactly:\s*"What's the vision\?"/; - assertTrue(!hardenedPattern.test(discussPrompt), 'prompt no longer uses exact-verbosity lock'); - assertTrue( - discussPrompt.includes('Ask: "What\'s the vision?" once'), - 'prompt asks for vision exactly once', - ); - assertTrue( - discussPrompt.includes('Special handling'), - 'prompt documents special handling for non-vision user messages', - ); - assertTrue( - discussPrompt.includes('instead of repeating "What\'s the vision?"'), - 'prompt forbids repeating the vision question', - ); -} - -report(); + assert.ok(!hardenedPattern.test(discussPrompt), "prompt no longer uses exact-verbosity lock"); + assert.ok(discussPrompt.includes('Ask: "What\'s the vision?" once'), "prompt asks for vision exactly once"); + assert.ok(discussPrompt.includes("Special handling"), "prompt documents special handling"); + assert.ok(discussPrompt.includes('instead of repeating "What\'s the vision?"'), "prompt forbids repeating"); +}); diff --git a/src/resources/extensions/gsd/tests/dispatch-guard.test.ts b/src/resources/extensions/gsd/tests/dispatch-guard.test.ts index 7160966ad..5d40b0e21 100644 --- a/src/resources/extensions/gsd/tests/dispatch-guard.test.ts +++ b/src/resources/extensions/gsd/tests/dispatch-guard.test.ts @@ -1,104 +1,73 @@ -// GSD Dispatch Guard Tests - +import test from "node:test"; +import assert from "node:assert/strict"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { getPriorSliceCompletionBlocker } from "../dispatch-guard.ts"; -import { createTestContext } from './test-helpers.ts'; -const { assertEq, assertTrue, report } = createTestContext(); - -const repo = mkdtempSync(join(tmpdir(), "gsd-dispatch-guard-")); -try { - mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true }); - mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true }); - - writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), [ - "# M002: Previous", - "", - "## Slices", - "- [x] **S01: Done** `risk:low` `depends:[]`", - "- [ ] **S02: Pending** `risk:low` `depends:[S01]`", - "", - ].join("\n")); - - writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), [ - "# M003: Current", - "", - "## Slices", - "- [ ] **S01: First** `risk:low` `depends:[]`", - "- [ ] **S02: Second** `risk:low` `depends:[S01]`", - "", - ].join("\n")); - - // dispatch-guard now reads from disk, not git — no need for git init/commit - assertEq( - getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M003/S01"), - "Cannot dispatch plan-slice M003/S01: earlier slice M002/S02 is not complete.", - "blocks first slice of next milestone when prior milestone is incomplete", - ); - - // Complete M002 on disk - writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), [ - "# M002: Previous", - "", - "## Slices", - "- [x] **S01: Done** `risk:low` `depends:[]`", - "- [x] **S02: Done** `risk:low` `depends:[S01]`", - "", - ].join("\n")); - - assertEq( - getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M003/S02/T01"), - "Cannot dispatch execute-task M003/S02/T01: earlier slice M003/S01 is not complete.", - "blocks later slice in same milestone when an earlier slice is incomplete", - ); - - // Complete M003/S01 on disk - writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), [ - "# M003: Current", - "", - "## Slices", - "- [x] **S01: First** `risk:low` `depends:[]`", - "- [ ] **S02: Second** `risk:low` `depends:[S01]`", - "", - ].join("\n")); - - assertEq( - getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M003/S02/T01"), - null, - "allows dispatch when all earlier slices are complete on disk", - ); - - assertEq( - getPriorSliceCompletionBlocker(repo, "main", "plan-milestone", "M003"), - null, - "does not affect non-slice dispatch types", - ); - - // Verify disk-based reads work without any git repo (#530) - const noGitRepo = mkdtempSync(join(tmpdir(), "gsd-dispatch-guard-nogit-")); +test("dispatch guard blocks when prior milestone has incomplete slices", () => { + const repo = mkdtempSync(join(tmpdir(), "gsd-dispatch-guard-")); try { - mkdirSync(join(noGitRepo, ".gsd", "milestones", "M001"), { recursive: true }); - writeFileSync(join(noGitRepo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), [ - "# M001: Test", - "", - "## Slices", - "- [x] **S01: Done** `risk:low` `depends:[]`", - "- [ ] **S02: Pending** `risk:low` `depends:[S01]`", - "", - ].join("\n")); + mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true }); + mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true }); - assertEq( - getPriorSliceCompletionBlocker(noGitRepo, "main", "plan-slice", "M001/S02"), - null, - "allows dispatch for S02 when S01 is complete (no git repo needed)", + writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), + "# M002: Previous\n\n## Slices\n- [x] **S01: Done** `risk:low` `depends:[]`\n- [ ] **S02: Pending** `risk:low` `depends:[S01]`\n"); + writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), + "# M003: Current\n\n## Slices\n- [ ] **S01: First** `risk:low` `depends:[]`\n- [ ] **S02: Second** `risk:low` `depends:[S01]`\n"); + + assert.equal( + getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M003/S01"), + "Cannot dispatch plan-slice M003/S01: earlier slice M002/S02 is not complete.", ); } finally { - rmSync(noGitRepo, { recursive: true, force: true }); + rmSync(repo, { recursive: true, force: true }); } -} finally { - rmSync(repo, { recursive: true, force: true }); -} +}); -report(); +test("dispatch guard blocks later slice in same milestone when earlier incomplete", () => { + const repo = mkdtempSync(join(tmpdir(), "gsd-dispatch-guard-")); + try { + mkdirSync(join(repo, ".gsd", "milestones", "M002"), { recursive: true }); + mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true }); + + writeFileSync(join(repo, ".gsd", "milestones", "M002", "M002-ROADMAP.md"), + "# M002: Previous\n\n## Slices\n- [x] **S01: Done** `risk:low` `depends:[]`\n- [x] **S02: Done** `risk:low` `depends:[S01]`\n"); + writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), + "# M003: Current\n\n## Slices\n- [ ] **S01: First** `risk:low` `depends:[]`\n- [ ] **S02: Second** `risk:low` `depends:[S01]`\n"); + + assert.equal( + getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M003/S02/T01"), + "Cannot dispatch execute-task M003/S02/T01: earlier slice M003/S01 is not complete.", + ); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +test("dispatch guard allows dispatch when all earlier slices complete", () => { + const repo = mkdtempSync(join(tmpdir(), "gsd-dispatch-guard-")); + try { + mkdirSync(join(repo, ".gsd", "milestones", "M003"), { recursive: true }); + writeFileSync(join(repo, ".gsd", "milestones", "M003", "M003-ROADMAP.md"), + "# M003: Current\n\n## Slices\n- [x] **S01: First** `risk:low` `depends:[]`\n- [ ] **S02: Second** `risk:low` `depends:[S01]`\n"); + + assert.equal(getPriorSliceCompletionBlocker(repo, "main", "execute-task", "M003/S02/T01"), null); + assert.equal(getPriorSliceCompletionBlocker(repo, "main", "plan-milestone", "M003"), null); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +test("dispatch guard works without git repo", () => { + const repo = mkdtempSync(join(tmpdir(), "gsd-dispatch-guard-nogit-")); + try { + mkdirSync(join(repo, ".gsd", "milestones", "M001"), { recursive: true }); + writeFileSync(join(repo, ".gsd", "milestones", "M001", "M001-ROADMAP.md"), + "# M001: Test\n\n## Slices\n- [x] **S01: Done** `risk:low` `depends:[]`\n- [ ] **S02: Pending** `risk:low` `depends:[S01]`\n"); + + assert.equal(getPriorSliceCompletionBlocker(repo, "main", "plan-slice", "M001/S02"), null); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); diff --git a/src/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts b/src/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts index b39fbce93..966de2c12 100644 --- a/src/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +++ b/src/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts @@ -1,90 +1,32 @@ -/** - * In-flight tool tracking tests — verifies that markToolStart/markToolEnd - * correctly manage the in-flight tools map used by the idle watchdog to - * distinguish "agent waiting on long-running tool" from "agent is idle". - * - * Background: The idle watchdog checks every 15s for agent progress. Without - * in-flight tool tracking, agents waiting on await_job or async_bash (which - * can run 20+ minutes for evaluations, deployments, test suites) are falsely - * declared idle and interrupted by recovery steering messages. - * - * The fix hooks tool_execution_start/end events to track active tool calls - * with start timestamps. When tools are in-flight and started recently - * (< idleTimeoutMs), the watchdog resets lastProgressAt instead of triggering - * idle recovery. When a tool has been in-flight for longer than idleTimeoutMs, - * it is treated as stuck (e.g., `command &` keeping stdout open) and recovery - * proceeds anyway. - */ - +import test from "node:test"; +import assert from "node:assert/strict"; import { markToolStart, markToolEnd, isAutoActive, getOldestInFlightToolAgeMs } from "../auto.ts"; -import { createTestContext } from './test-helpers.ts'; -const { assertEq, assertTrue, report } = createTestContext(); - -// ═══ markToolStart / markToolEnd basic behavior ═════════════════════════════ - -{ - console.log("\n=== markToolStart: no-op when auto-mode is not active ==="); - // When auto-mode is not active, markToolStart should silently ignore - // (the guard `if (!active) return` prevents set pollution outside auto-mode) - assertTrue(!isAutoActive(), "auto-mode should not be active in tests"); +test("markToolStart/markToolEnd are no-ops when auto-mode is inactive", () => { + assert.ok(!isAutoActive()); markToolStart("tool-1"); - // We can't directly inspect the set, but markToolEnd should be a safe no-op markToolEnd("tool-1"); - // If we got here without error, the guard works - assertTrue(true, "markToolStart/markToolEnd are safe no-ops when inactive"); -} + // No error means the guard works +}); -{ - console.log("\n=== markToolEnd: no-op for unknown toolCallId ==="); - // Set.delete on non-existent key is a no-op — verify no crash +test("markToolEnd handles unknown and duplicate IDs gracefully", () => { markToolEnd("nonexistent-tool-call-id"); - assertTrue(true, "markToolEnd handles unknown IDs gracefully"); -} - -{ - console.log("\n=== markToolEnd: idempotent — double-end does not crash ==="); markToolEnd("some-id"); markToolEnd("some-id"); - assertTrue(true, "double markToolEnd is safe"); -} + // No error +}); -// ═══ Integration contract: expected exports from auto.ts ═════════════════════ +test("auto.ts exports tool tracking functions", () => { + assert.equal(typeof markToolStart, "function"); + assert.equal(typeof markToolEnd, "function"); + assert.equal(typeof getOldestInFlightToolAgeMs, "function"); +}); -{ - console.log("\n=== auto.ts exports markToolStart, markToolEnd, and getOldestInFlightToolAgeMs ==="); - assertEq(typeof markToolStart, "function", "markToolStart should be a function"); - assertEq(typeof markToolEnd, "function", "markToolEnd should be a function"); - assertEq(typeof getOldestInFlightToolAgeMs, "function", "getOldestInFlightToolAgeMs should be a function"); -} +test("getOldestInFlightToolAgeMs returns 0 when no tools in-flight", () => { + assert.equal(getOldestInFlightToolAgeMs(), 0); +}); -{ - console.log("\n=== getOldestInFlightToolAgeMs: returns 0 when no tools in-flight ==="); - // When auto-mode is inactive, inFlightTools map is empty → age is 0 - const age = getOldestInFlightToolAgeMs(); - assertEq(age, 0, "should return 0 when no tools are in-flight"); -} - -{ - console.log("\n=== markToolStart accepts string toolCallId ==="); - // Verify the function signature handles string input without error - // (when inactive, this is a no-op but should not throw) - try { - markToolStart("toolu_01ABC123"); - assertTrue(true, "accepts standard Claude tool call ID format"); - } catch (e) { - assertTrue(false, `should not throw: ${e}`); - } -} - -{ - console.log("\n=== markToolEnd accepts string toolCallId ==="); - try { - markToolEnd("toolu_01ABC123"); - assertTrue(true, "accepts standard Claude tool call ID format"); - } catch (e) { - assertTrue(false, `should not throw: ${e}`); - } -} - -report(); +test("markToolStart/markToolEnd accept string toolCallIds without throwing", () => { + assert.doesNotThrow(() => markToolStart("toolu_01ABC123")); + assert.doesNotThrow(() => markToolEnd("toolu_01ABC123")); +}); diff --git a/src/resources/extensions/gsd/tests/next-milestone-id.test.ts b/src/resources/extensions/gsd/tests/next-milestone-id.test.ts index a6a8c1522..02dd76530 100644 --- a/src/resources/extensions/gsd/tests/next-milestone-id.test.ts +++ b/src/resources/extensions/gsd/tests/next-milestone-id.test.ts @@ -1,66 +1,23 @@ -// Tests for nextMilestoneId and maxMilestoneNum — milestone ID generation -// using max-based approach to avoid collisions after deletions. -// -// Sections: -// (a) Empty array returns M001 -// (b) Sequential IDs return next in sequence -// (c) IDs with gaps (deletion) use max, not fill -// (d) Non-numeric directory names mixed in are ignored +import test from "node:test"; +import assert from "node:assert/strict"; +import { nextMilestoneId, maxMilestoneNum } from "../guided-flow.ts"; -import { nextMilestoneId, maxMilestoneNum } from '../guided-flow.ts'; -import { createTestContext } from './test-helpers.ts'; - - -const { assertEq, report } = createTestContext(); -// ─── Tests ───────────────────────────────────────────────────────────────── - -async function main(): Promise { - console.log('nextMilestoneId / maxMilestoneNum tests'); - - // (a) Empty array → M001 - { - assertEq(maxMilestoneNum([]), 0, 'maxMilestoneNum([]) === 0'); - assertEq(nextMilestoneId([]), 'M001', 'nextMilestoneId([]) === "M001"'); - } - - // (b) Sequential IDs → next in sequence - { - assertEq( - nextMilestoneId(['M001', 'M002', 'M003']), - 'M004', - 'sequential IDs return M004', - ); - assertEq(maxMilestoneNum(['M001', 'M002', 'M003']), 3, 'max of sequential is 3'); - } - - // (c) IDs with gaps (deletion scenario) → uses max, not fill - { - assertEq( - nextMilestoneId(['M001', 'M003']), - 'M004', - 'gap scenario returns M004, not M002', - ); - assertEq(maxMilestoneNum(['M001', 'M003']), 3, 'max with gap is 3'); - } - - // (d) Non-numeric directory names mixed in are ignored - { - assertEq( - nextMilestoneId(['M001', 'notes', '.DS_Store', 'M003']), - 'M004', - 'non-numeric names ignored, returns M004', - ); - assertEq( - maxMilestoneNum(['M001', 'notes', '.DS_Store', 'M003']), - 3, - 'max ignores non-numeric entries', - ); - } - - report(); -} - -main().catch((error) => { - console.error(error); - process.exit(1); +test("nextMilestoneId: empty array returns M001", () => { + assert.equal(maxMilestoneNum([]), 0); + assert.equal(nextMilestoneId([]), "M001"); +}); + +test("nextMilestoneId: sequential IDs return next in sequence", () => { + assert.equal(nextMilestoneId(["M001", "M002", "M003"]), "M004"); + assert.equal(maxMilestoneNum(["M001", "M002", "M003"]), 3); +}); + +test("nextMilestoneId: gaps use max, not fill", () => { + assert.equal(nextMilestoneId(["M001", "M003"]), "M004"); + assert.equal(maxMilestoneNum(["M001", "M003"]), 3); +}); + +test("nextMilestoneId: non-numeric directory names ignored", () => { + assert.equal(nextMilestoneId(["M001", "notes", ".DS_Store", "M003"]), "M004"); + assert.equal(maxMilestoneNum(["M001", "notes", ".DS_Store", "M003"]), 3); }); diff --git a/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts b/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts index 39d1ce2cb..18a09bec5 100644 --- a/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +++ b/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts @@ -1,14 +1,13 @@ +import test from "node:test"; +import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { createTestContext } from './test-helpers.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); const worktreePromptsDir = join(__dirname, "..", "prompts"); -const { assertTrue, report } = createTestContext(); - -function loadPromptFromWorktree(name: string, vars: Record = {}): string { +function loadPrompt(name: string, vars: Record = {}): string { const path = join(worktreePromptsDir, `${name}.md`); let content = readFileSync(path, "utf-8"); for (const [key, value] of Object.entries(vars)) { @@ -19,53 +18,30 @@ function loadPromptFromWorktree(name: string, vars: Record = {}) const BASE_VARS = { workingDirectory: "/tmp/test-project", - milestoneId: "M001", - sliceId: "S01", - sliceTitle: "Test Slice", + milestoneId: "M001", sliceId: "S01", sliceTitle: "Test Slice", slicePath: ".gsd/milestones/M001/slices/S01", roadmapPath: ".gsd/milestones/M001/M001-ROADMAP.md", researchPath: ".gsd/milestones/M001/slices/S01/S01-RESEARCH.md", outputPath: "/tmp/test-project/.gsd/milestones/M001/slices/S01/S01-PLAN.md", inlinedContext: "--- test inlined context ---", - dependencySummaries: "", - executorContextConstraints: "", + dependencySummaries: "", executorContextConstraints: "", }; -async function main(): Promise { +test("plan-slice prompt: commit step present when commit_docs=true", () => { + const result = loadPrompt("plan-slice", { ...BASE_VARS, commitInstruction: "Commit: `docs(S01): add slice plan`" }); + assert.ok(result.includes("docs(S01): add slice plan")); + assert.ok(!result.includes("{{commitInstruction}}")); +}); - // ─── commit_docs=true (default): commit step is present ───────────────── - console.log("\n=== plan-slice prompt: commit_docs default (true) ==="); - { - const commitInstruction = `Commit: \`docs(S01): add slice plan\``; - const result = loadPromptFromWorktree("plan-slice", { ...BASE_VARS, commitInstruction }); +test("plan-slice prompt: no commit step when commit_docs=false", () => { + const result = loadPrompt("plan-slice", { ...BASE_VARS, commitInstruction: "Do not commit — planning docs are not tracked in git for this project." }); + assert.ok(!result.includes("docs(S01): add slice plan")); + assert.ok(result.includes("Do not commit")); +}); - assertTrue(result.includes("docs(S01): add slice plan"), "commit step present when commit_docs is not false"); - assertTrue(!result.includes("Update `.gsd/STATE.md`"), "STATE.md update step removed — system rebuilds it"); - assertTrue(!result.includes("{{commitInstruction}}"), "no unresolved placeholder"); - } - - // ─── commit_docs=false: no commit step ────────────────────────────────── - console.log("\n=== plan-slice prompt: commit_docs=false ==="); - { - const commitInstruction = "Do not commit — planning docs are not tracked in git for this project."; - const result = loadPromptFromWorktree("plan-slice", { ...BASE_VARS, commitInstruction }); - - assertTrue(!result.includes("docs(S01): add slice plan"), "commit step absent when commit_docs=false"); - assertTrue(result.includes("Do not commit"), "no-commit instruction present"); - assertTrue(!result.includes("Update `.gsd/STATE.md`"), "STATE.md update step removed — system rebuilds it"); - assertTrue(!result.includes("{{commitInstruction}}"), "no unresolved placeholder"); - } - - // ─── all base variables are substituted ───────────────────────────────── - console.log("\n=== plan-slice prompt: all variables substituted ==="); - { - const commitInstruction = `Commit: \`docs(S01): add slice plan\``; - const result = loadPromptFromWorktree("plan-slice", { ...BASE_VARS, commitInstruction }); - - assertTrue(!result.includes("{{"), "no unresolved placeholders remain"); - assertTrue(result.includes("M001"), "milestoneId substituted"); - assertTrue(result.includes("S01"), "sliceId substituted"); - } -} - -main().then(report); +test("plan-slice prompt: all variables substituted", () => { + const result = loadPrompt("plan-slice", { ...BASE_VARS, commitInstruction: "Commit: `docs(S01): add slice plan`" }); + assert.ok(!result.includes("{{")); + assert.ok(result.includes("M001")); + assert.ok(result.includes("S01")); +}); diff --git a/src/resources/extensions/gsd/tests/preferences-git.test.ts b/src/resources/extensions/gsd/tests/preferences-git.test.ts deleted file mode 100644 index cbc2bed64..000000000 --- a/src/resources/extensions/gsd/tests/preferences-git.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -// GSD Git Preferences Tests — validates git.isolation and git.merge_to_main handling - -import { createTestContext } from "./test-helpers.ts"; -import { validatePreferences, getIsolationMode } from "../preferences.ts"; - -const { assertEq, assertTrue, report } = createTestContext(); - -async function main(): Promise { - console.log("\n=== git.isolation ==="); - - // Valid values are accepted without warnings - { - const { preferences, warnings, errors } = validatePreferences({ git: { isolation: "worktree" } }); - assertEq(errors.length, 0, "isolation: worktree — no errors"); - assertEq(warnings.length, 0, "isolation: worktree — no warnings"); - assertEq(preferences.git?.isolation, "worktree", "isolation: worktree — value preserved"); - } - { - const { preferences, warnings, errors } = validatePreferences({ git: { isolation: "branch" } }); - assertEq(errors.length, 0, "isolation: branch — no errors"); - assertEq(warnings.length, 0, "isolation: branch — no warnings"); - assertEq(preferences.git?.isolation, "branch", "isolation: branch — value preserved"); - } - { - const { preferences, warnings, errors } = validatePreferences({ git: { isolation: "none" } }); - assertEq(errors.length, 0, "isolation: none — no errors"); - assertEq(warnings.length, 0, "isolation: none — no warnings"); - assertEq(preferences.git?.isolation, "none", "isolation: none — value preserved"); - } - - // Invalid values produce errors - { - const { errors } = validatePreferences({ git: { isolation: "invalid" as any } }); - assertTrue(errors.length > 0, "isolation: invalid — produces error"); - assertTrue(errors[0].includes("worktree, branch, none"), "isolation: invalid — error mentions valid values"); - } - - // Undefined passes through without warning - { - const { preferences, warnings } = validatePreferences({ git: { auto_push: true } }); - assertEq(warnings.length, 0, "isolation: undefined — no warnings"); - assertEq(preferences.git?.isolation, undefined, "isolation: undefined — not set"); - } - - console.log("\n=== git.merge_to_main deprecated ==="); - - // Any value produces a deprecation warning - { - const { warnings } = validatePreferences({ git: { merge_to_main: "milestone" } as any }); - assertTrue(warnings.length > 0, "merge_to_main: milestone — produces deprecation warning"); - assertTrue(warnings[0].includes("deprecated"), "merge_to_main: milestone — warning mentions deprecated"); - } - { - const { warnings } = validatePreferences({ git: { merge_to_main: "slice" } as any }); - assertTrue(warnings.length > 0, "merge_to_main: slice — produces deprecation warning"); - assertTrue(warnings[0].includes("deprecated"), "merge_to_main: slice — warning mentions deprecated"); - } - - // Undefined passes through without warning - { - const { preferences, warnings } = validatePreferences({ git: { auto_push: true } }); - assertEq(warnings.length, 0, "merge_to_main: undefined — no warnings"); - assertEq((preferences.git as any)?.merge_to_main, undefined, "merge_to_main: undefined — not set"); - } - - console.log("\n=== isolation + deprecated merge_to_main together ==="); - { - const { warnings, errors } = validatePreferences({ - git: { isolation: "branch", merge_to_main: "slice" } as any, - }); - assertEq(errors.length, 0, "branch isolation + deprecated merge_to_main — no errors"); - assertEq(warnings.length, 1, "branch isolation + deprecated merge_to_main — 1 warning (merge_to_main only)"); - assertTrue(warnings[0].includes("merge_to_main"), "warning mentions merge_to_main"); - } - - console.log("\n=== git.commit_docs ==="); - - // Valid boolean values accepted - { - const { preferences, errors } = validatePreferences({ git: { commit_docs: false } }); - assertEq(errors.length, 0, "commit_docs: false — no errors"); - assertEq(preferences.git?.commit_docs, false, "commit_docs: false — value preserved"); - } - { - const { preferences, errors } = validatePreferences({ git: { commit_docs: true } }); - assertEq(errors.length, 0, "commit_docs: true — no errors"); - assertEq(preferences.git?.commit_docs, true, "commit_docs: true — value preserved"); - } - - // Invalid type produces error - { - const { errors } = validatePreferences({ git: { commit_docs: "no" as any } }); - assertTrue(errors.length > 0, "commit_docs: string — produces error"); - assertTrue(errors[0].includes("commit_docs"), "commit_docs: string — error mentions commit_docs"); - } - - // Undefined passes through without issue - { - const { preferences, errors } = validatePreferences({ git: { auto_push: true } }); - assertEq(errors.length, 0, "commit_docs: undefined — no errors"); - assertEq(preferences.git?.commit_docs, undefined, "commit_docs: undefined — not set"); - } - - console.log("\n=== getIsolationMode() ==="); - - // Returns "none" when set to "none" - // Note: getIsolationMode() reads from disk via loadEffectiveGSDPreferences, - // so we test it indirectly by verifying the function is exported and callable. - // The validation tests above prove the preference value is stored correctly. - // Direct mode tests require mocking the filesystem, so we test the function's - // default return value (no preferences file in test context). - { - const mode = getIsolationMode(); - assertEq(mode, "worktree", "getIsolationMode: returns worktree as default when no prefs file"); - } - - report(); -} - -main(); diff --git a/src/resources/extensions/gsd/tests/preferences-hooks.test.ts b/src/resources/extensions/gsd/tests/preferences-hooks.test.ts deleted file mode 100644 index a3c1db661..000000000 --- a/src/resources/extensions/gsd/tests/preferences-hooks.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -// GSD Extension — Hook Preferences Parsing Tests (Post-Unit + Pre-Dispatch) - -import { createTestContext } from "./test-helpers.ts"; -import type { PreDispatchHookConfig } from "../types.ts"; - -const { assertEq, assertTrue, report } = createTestContext(); - -// ═══════════════════════════════════════════════════════════════════════════ -// Phase 1: Post-Unit Hook Config Tests -// ═══════════════════════════════════════════════════════════════════════════ - -console.log("\n=== Post-unit hook config validation ==="); - -{ - const validHook = { - name: "test-hook", - after: ["execute-task"], - prompt: "Test prompt", - max_cycles: 2, - model: "claude-sonnet-4-6", - artifact: "TEST-RESULT.md", - retry_on: "TEST-ISSUES.md", - enabled: true, - }; - - assertEq(validHook.name, "test-hook", "valid hook has name"); - assertEq(validHook.after.length, 1, "valid hook has one after entry"); - assertEq(validHook.after[0], "execute-task", "valid hook triggers after execute-task"); - assertTrue(validHook.max_cycles! <= 10, "max_cycles within limit"); - assertTrue(validHook.max_cycles! >= 1, "max_cycles above minimum"); -} - -console.log("\n=== max_cycles clamping ==="); - -{ - const clampedHigh = Math.max(1, Math.min(10, Math.round(15))); - assertEq(clampedHigh, 10, "max_cycles above 10 clamped to 10"); - - const clampedLow = Math.max(1, Math.min(10, Math.round(0))); - assertEq(clampedLow, 1, "max_cycles below 1 clamped to 1"); - - const clampedNeg = Math.max(1, Math.min(10, Math.round(-5))); - assertEq(clampedNeg, 1, "negative max_cycles clamped to 1"); - - const normal = Math.max(1, Math.min(10, Math.round(3))); - assertEq(normal, 3, "normal max_cycles passes through"); -} - -console.log("\n=== Post-unit hook merging ==="); - -{ - const baseHooks = [ - { name: "review", after: ["execute-task"], prompt: "base prompt" }, - { name: "lint", after: ["plan-slice"], prompt: "lint code" }, - ]; - - const overrideHooks = [ - { name: "review", after: ["execute-task", "complete-slice"], prompt: "override prompt" }, - { name: "security", after: ["execute-task"], prompt: "security check" }, - ]; - - const merged = [...baseHooks]; - for (const hook of overrideHooks) { - const idx = merged.findIndex(h => h.name === hook.name); - if (idx >= 0) { - merged[idx] = hook; - } else { - merged.push(hook); - } - } - - assertEq(merged.length, 3, "merged has 3 hooks"); - assertEq(merged[0].prompt, "override prompt", "review hook was overridden"); - assertEq(merged[0].after.length, 2, "overridden review has 2 after entries"); - assertEq(merged[1].name, "lint", "lint kept from base"); - assertEq(merged[2].name, "security", "security added from override"); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Phase 2: Pre-Dispatch Hook Config Tests -// ═══════════════════════════════════════════════════════════════════════════ - -console.log("\n=== Pre-dispatch hook config shape ==="); - -{ - const modifyHook = { - name: "inject-context", - before: ["execute-task"], - action: "modify" as const, - prepend: "Remember to follow coding conventions.", - append: "Run tests after making changes.", - enabled: true, - }; - - assertEq(modifyHook.name, "inject-context", "modify hook has name"); - assertEq(modifyHook.action, "modify", "action is modify"); - assertTrue(!!modifyHook.prepend, "has prepend text"); - assertTrue(!!modifyHook.append, "has append text"); -} - -{ - const skipHook = { - name: "skip-research", - before: ["research-slice"], - action: "skip" as const, - skip_if: "RESEARCH-DONE.md", - enabled: true, - }; - - assertEq(skipHook.action, "skip", "action is skip"); - assertEq(skipHook.skip_if, "RESEARCH-DONE.md", "has skip condition"); -} - -{ - const replaceHook = { - name: "custom-planning", - before: ["plan-slice"], - action: "replace" as const, - prompt: "Use custom planning approach for {sliceId}", - unit_type: "custom-plan", - model: "claude-opus-4-6", - enabled: true, - }; - - assertEq(replaceHook.action, "replace", "action is replace"); - assertTrue(!!replaceHook.prompt, "replace hook has prompt"); - assertEq(replaceHook.unit_type, "custom-plan", "has unit_type override"); -} - -console.log("\n=== Pre-dispatch action validation ==="); - -{ - const validActions = new Set(["modify", "skip", "replace"]); - assertTrue(validActions.has("modify"), "modify is valid"); - assertTrue(validActions.has("skip"), "skip is valid"); - assertTrue(validActions.has("replace"), "replace is valid"); - assertTrue(!validActions.has("delete"), "delete is not valid"); - assertTrue(!validActions.has(""), "empty string is not valid"); -} - -console.log("\n=== Pre-dispatch hook merging ==="); - -{ - const baseHooks: PreDispatchHookConfig[] = [ - { name: "inject", before: ["execute-task"], action: "modify", prepend: "base" }, - ]; - - const overrideHooks: PreDispatchHookConfig[] = [ - { name: "inject", before: ["execute-task"], action: "modify", prepend: "override" }, - { name: "gate", before: ["plan-slice"], action: "skip" }, - ]; - - const merged: PreDispatchHookConfig[] = [...baseHooks]; - for (const hook of overrideHooks) { - const idx = merged.findIndex(h => h.name === hook.name); - if (idx >= 0) { - merged[idx] = hook; - } else { - merged.push(hook); - } - } - - assertEq(merged.length, 2, "merged has 2 pre-dispatch hooks"); - assertEq(merged[0].prepend, "override", "inject hook overridden"); - assertEq(merged[1].name, "gate", "gate hook added"); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Known unit types validation -// ═══════════════════════════════════════════════════════════════════════════ - -console.log("\n=== Known unit types ==="); - -{ - const knownUnitTypes = new Set([ - "research-milestone", "plan-milestone", "research-slice", "plan-slice", - "execute-task", "complete-slice", "replan-slice", "reassess-roadmap", - "run-uat", "fix-merge", "complete-milestone", - ]); - - assertTrue(knownUnitTypes.has("execute-task"), "execute-task is known"); - assertTrue(knownUnitTypes.has("complete-slice"), "complete-slice is known"); - assertTrue(knownUnitTypes.has("plan-slice"), "plan-slice is known"); - assertTrue(!knownUnitTypes.has("hook/review"), "hook types are not in known set"); - assertTrue(!knownUnitTypes.has("invalid-type"), "invalid types are not in known set"); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Preferences YAML format verification -// ═══════════════════════════════════════════════════════════════════════════ - -console.log("\n=== Preferences YAML format ==="); - -{ - const prefsContent = [ - "---", - "version: 1", - "post_unit_hooks:", - " - name: code-review", - " after:", - " - execute-task", - " prompt: Review the changes", - " max_cycles: 3", - " artifact: REVIEW-PASS.md", - " retry_on: REVIEW-ISSUES.md", - "pre_dispatch_hooks:", - " - name: inject-conventions", - " before:", - " - execute-task", - " action: modify", - " append: Follow project coding conventions", - " - name: custom-research", - " before:", - " - research-slice", - " action: replace", - " prompt: Custom research prompt", - "---", - ].join("\n"); - - assertTrue(prefsContent.includes("post_unit_hooks:"), "has post_unit_hooks key"); - assertTrue(prefsContent.includes("pre_dispatch_hooks:"), "has pre_dispatch_hooks key"); - assertTrue(prefsContent.includes("action: modify"), "has modify action"); - assertTrue(prefsContent.includes("action: replace"), "has replace action"); -} - -report(); diff --git a/src/resources/extensions/gsd/tests/preferences-mode.test.ts b/src/resources/extensions/gsd/tests/preferences-mode.test.ts deleted file mode 100644 index 3a60716ba..000000000 --- a/src/resources/extensions/gsd/tests/preferences-mode.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -// GSD Workflow Mode Tests — validates mode defaults, overrides, and validation - -import { createTestContext } from "./test-helpers.ts"; -import { validatePreferences, applyModeDefaults } from "../preferences.ts"; -import type { GSDPreferences } from "../preferences.ts"; - -const { assertEq, assertTrue, report } = createTestContext(); - -async function main(): Promise { - console.log("\n=== mode: solo defaults ==="); - - { - const prefs: GSDPreferences = { mode: "solo" }; - const result = applyModeDefaults("solo", prefs); - assertEq(result.git?.auto_push, true, "solo — auto_push defaults to true"); - assertEq(result.git?.push_branches, false, "solo — push_branches defaults to false"); - assertEq(result.git?.pre_merge_check, false, "solo — pre_merge_check defaults to false"); - assertEq(result.git?.merge_strategy, "squash", "solo — merge_strategy defaults to squash"); - assertEq(result.git?.isolation, "worktree", "solo — isolation defaults to worktree"); - assertEq(result.git?.commit_docs, true, "solo — commit_docs defaults to true"); - assertEq(result.unique_milestone_ids, false, "solo — unique_milestone_ids defaults to false"); - } - - console.log("\n=== mode: team defaults ==="); - - { - const prefs: GSDPreferences = { mode: "team" }; - const result = applyModeDefaults("team", prefs); - assertEq(result.git?.auto_push, false, "team — auto_push defaults to false"); - assertEq(result.git?.push_branches, true, "team — push_branches defaults to true"); - assertEq(result.git?.pre_merge_check, true, "team — pre_merge_check defaults to true"); - assertEq(result.git?.merge_strategy, "squash", "team — merge_strategy defaults to squash"); - assertEq(result.git?.isolation, "worktree", "team — isolation defaults to worktree"); - assertEq(result.git?.commit_docs, true, "team — commit_docs defaults to true"); - assertEq(result.unique_milestone_ids, true, "team — unique_milestone_ids defaults to true"); - } - - console.log("\n=== explicit override wins over mode default ==="); - - { - const prefs: GSDPreferences = { - mode: "solo", - git: { auto_push: false }, - }; - const result = applyModeDefaults("solo", prefs); - assertEq(result.git?.auto_push, false, "solo + explicit auto_push=false — override wins"); - assertEq(result.git?.push_branches, false, "solo + override — other defaults still apply"); - assertEq(result.git?.merge_strategy, "squash", "solo + override — merge_strategy still defaults"); - } - - console.log("\n=== no mode set — no defaults injected ==="); - - { - const prefs: GSDPreferences = { git: { auto_push: true } }; - const { preferences } = validatePreferences(prefs); - assertEq(preferences.mode, undefined, "no mode — mode is undefined"); - assertEq(preferences.git?.push_branches, undefined, "no mode — push_branches not injected"); - assertEq(preferences.unique_milestone_ids, undefined, "no mode — unique_milestone_ids not injected"); - } - - console.log("\n=== invalid mode value → validation error ==="); - - { - const { errors } = validatePreferences({ mode: "invalid" as any }); - assertTrue(errors.length > 0, "invalid mode — produces error"); - assertTrue(errors[0].includes("solo, team"), "invalid mode — error mentions valid values"); - } - - console.log("\n=== valid mode values pass validation ==="); - - { - const { errors: soloErrors, preferences: soloPrefs } = validatePreferences({ mode: "solo" }); - assertEq(soloErrors.length, 0, "mode: solo — no errors"); - assertEq(soloPrefs.mode, "solo", "mode: solo — value preserved"); - } - { - const { errors: teamErrors, preferences: teamPrefs } = validatePreferences({ mode: "team" }); - assertEq(teamErrors.length, 0, "mode: team — no errors"); - assertEq(teamPrefs.mode, "team", "mode: team — value preserved"); - } - - console.log("\n=== deep merge: mode + explicit git.remote ==="); - - { - const prefs: GSDPreferences = { - mode: "team", - git: { remote: "upstream" }, - }; - const result = applyModeDefaults("team", prefs); - assertEq(result.git?.remote, "upstream", "team + git.remote — custom remote preserved"); - assertEq(result.git?.auto_push, false, "team + git.remote — team auto_push default applied"); - assertEq(result.git?.push_branches, true, "team + git.remote — team push_branches default applied"); - } - - console.log("\n=== mode + unique_milestone_ids explicit override ==="); - - { - const prefs: GSDPreferences = { - mode: "team", - unique_milestone_ids: false, - }; - const result = applyModeDefaults("team", prefs); - assertEq(result.unique_milestone_ids, false, "team + explicit unique_milestone_ids=false — override wins"); - assertEq(result.git?.push_branches, true, "team + override — other team defaults still apply"); - } - - report(); -} - -main(); diff --git a/src/resources/extensions/gsd/tests/preferences-models.test.ts b/src/resources/extensions/gsd/tests/preferences-models.test.ts deleted file mode 100644 index ae569eb89..000000000 --- a/src/resources/extensions/gsd/tests/preferences-models.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -// GSD Extension — Model Preferences Parsing Tests - -import test from "node:test"; -import assert from "node:assert/strict"; - -import { parsePreferencesMarkdown } from "../preferences.ts"; -import type { GSDModelConfigV2, GSDPhaseModelConfig } from "../preferences.ts"; - -// ═══════════════════════════════════════════════════════════════════════════ -// OpenRouter-style model config parsing (issue #488) -// ═══════════════════════════════════════════════════════════════════════════ - -test("parses OpenRouter model config with org/model IDs and fallbacks", () => { - const content = `--- -version: 1 -models: - research: - # Long-context, high-quality research + retrieval - model: moonshotai/kimi-k2.5 - fallbacks: - - qwen/qwen3.5-397b-a17b - planning: - # Deep, careful reasoning for plans - model: deepseek/deepseek-r1-0528 - fallbacks: - - moonshotai/kimi-k2.5 - - deepseek/deepseek-v3.2 - execution: - model: qwen/qwen3-coder - fallbacks: - - qwen/qwen3-coder-next - - minimax/minimax-m2.5 - completion: - model: qwen/qwen3-next-80b-a3b-instruct - fallbacks: - - deepseek/deepseek-v3.2 - - qwen/qwen-plus-2025-07-28 ---- -`; - - const prefs = parsePreferencesMarkdown(content); - assert.ok(prefs, "preferences should be parsed"); - assert.equal(prefs.version, 1, "version should be 1"); - - const models = prefs.models as GSDModelConfigV2; - assert.ok(models, "models should be defined"); - - // Research phase - const research = models.research as GSDPhaseModelConfig; - assert.ok(research, "research config should exist"); - assert.equal(research.model, "moonshotai/kimi-k2.5", "research primary model"); - assert.deepEqual(research.fallbacks, ["qwen/qwen3.5-397b-a17b"], "research fallbacks"); - - // Planning phase - const planning = models.planning as GSDPhaseModelConfig; - assert.ok(planning, "planning config should exist"); - assert.equal(planning.model, "deepseek/deepseek-r1-0528", "planning primary model"); - assert.deepEqual(planning.fallbacks, ["moonshotai/kimi-k2.5", "deepseek/deepseek-v3.2"], "planning fallbacks"); - - // Execution phase - const execution = models.execution as GSDPhaseModelConfig; - assert.ok(execution, "execution config should exist"); - assert.equal(execution.model, "qwen/qwen3-coder", "execution primary model"); - assert.deepEqual(execution.fallbacks, ["qwen/qwen3-coder-next", "minimax/minimax-m2.5"], "execution fallbacks"); - - // Completion phase - const completion = models.completion as GSDPhaseModelConfig; - assert.ok(completion, "completion config should exist"); - assert.equal(completion.model, "qwen/qwen3-next-80b-a3b-instruct", "completion primary model"); - assert.deepEqual(completion.fallbacks, ["deepseek/deepseek-v3.2", "qwen/qwen-plus-2025-07-28"], "completion fallbacks"); -}); - -test("parses model IDs with colons (OpenRouter variants like :free, :exacto)", () => { - const content = `--- -models: - execution: - model: qwen/qwen3-coder - fallbacks: - - qwen/qwen3-coder:free - - qwen/qwen3-coder:exacto ---- -`; - - const prefs = parsePreferencesMarkdown(content); - assert.ok(prefs, "preferences should be parsed"); - - const models = prefs.models as GSDModelConfigV2; - const execution = models.execution as GSDPhaseModelConfig; - assert.equal(execution.model, "qwen/qwen3-coder", "primary model"); - assert.deepEqual( - execution.fallbacks, - ["qwen/qwen3-coder:free", "qwen/qwen3-coder:exacto"], - "fallbacks with colons should be parsed as strings, not objects", - ); -}); - -test("parses legacy string-per-phase model config", () => { - const content = `--- -models: - research: claude-opus-4-6 - planning: claude-opus-4-6 - execution: claude-sonnet-4-6 - completion: claude-haiku-4-5 ---- -`; - - const prefs = parsePreferencesMarkdown(content); - assert.ok(prefs, "preferences should be parsed"); - - const models = prefs.models as GSDModelConfigV2; - assert.equal(models.research, "claude-opus-4-6", "research as string"); - assert.equal(models.planning, "claude-opus-4-6", "planning as string"); - assert.equal(models.execution, "claude-sonnet-4-6", "execution as string"); - assert.equal(models.completion, "claude-haiku-4-5", "completion as string"); -}); - -test("strips inline YAML comments from values", () => { - const content = `--- -models: - execution: - model: qwen/qwen3-coder # fast coding model - fallbacks: - - minimax/minimax-m2.5 # backup ---- -`; - - const prefs = parsePreferencesMarkdown(content); - assert.ok(prefs, "preferences should be parsed"); - - const models = prefs.models as GSDModelConfigV2; - const execution = models.execution as GSDPhaseModelConfig; - assert.equal(execution.model, "qwen/qwen3-coder", "inline comment stripped from model value"); - assert.deepEqual(execution.fallbacks, ["minimax/minimax-m2.5"], "inline comment stripped from fallback"); -}); - -test("handles Windows line endings (CRLF)", () => { - const content = "---\r\nmodels:\r\n execution:\r\n model: qwen/qwen3-coder\r\n---\r\n"; - - const prefs = parsePreferencesMarkdown(content); - assert.ok(prefs, "preferences should be parsed with CRLF line endings"); - - const models = prefs.models as GSDModelConfigV2; - const execution = models.execution as GSDPhaseModelConfig; - assert.equal(execution.model, "qwen/qwen3-coder", "model parsed correctly with CRLF"); -}); - -test("handles model config with explicit provider field", () => { - const content = `--- -models: - execution: - model: claude-opus-4-6 - provider: bedrock - fallbacks: - - claude-sonnet-4-6 ---- -`; - - const prefs = parsePreferencesMarkdown(content); - assert.ok(prefs, "preferences should be parsed"); - - const models = prefs.models as GSDModelConfigV2; - const execution = models.execution as GSDPhaseModelConfig; - assert.equal(execution.model, "claude-opus-4-6", "model value"); - assert.equal(execution.provider, "bedrock", "provider value"); - assert.deepEqual(execution.fallbacks, ["claude-sonnet-4-6"], "fallbacks"); -}); - -test("handles empty models config", () => { - const content = `--- -version: 1 ---- -`; - - const prefs = parsePreferencesMarkdown(content); - assert.ok(prefs, "preferences should be parsed"); - assert.equal(prefs.models, undefined, "models should be undefined when not specified"); -}); - -test("handles comment-only lines between keys without breaking structure", () => { - const content = `--- -models: - # Research models - research: - # Primary research model - model: moonshotai/kimi-k2.5 - # Fallback list - fallbacks: - # Best alternatives - - qwen/qwen3.5-397b-a17b - # Planning models - planning: - model: deepseek/deepseek-r1-0528 ---- -`; - - const prefs = parsePreferencesMarkdown(content); - assert.ok(prefs, "preferences should be parsed with comments"); - - const models = prefs.models as GSDModelConfigV2; - const research = models.research as GSDPhaseModelConfig; - assert.equal(research.model, "moonshotai/kimi-k2.5", "model value unaffected by surrounding comments"); - // Note: comments inside arrays (like "# Best alternatives") are treated as array items by the parser - // since the array parser doesn't have comment detection. This is a known limitation. - - const planning = models.planning as GSDPhaseModelConfig; - assert.equal(planning.model, "deepseek/deepseek-r1-0528", "next section unaffected by comments"); -}); diff --git a/src/resources/extensions/gsd/tests/preferences-schema-validation.test.ts b/src/resources/extensions/gsd/tests/preferences-schema-validation.test.ts deleted file mode 100644 index 7a4cf5af0..000000000 --- a/src/resources/extensions/gsd/tests/preferences-schema-validation.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * preferences-schema-validation.test.ts — Validates that schema validation - * detects unknown keys, invalid types, and surfaces warnings correctly. - */ - -import { createTestContext } from "./test-helpers.ts"; -import { validatePreferences } from "../preferences.ts"; -import type { GSDPreferences } from "../preferences.ts"; - -const { assertEq, assertTrue, report } = createTestContext(); - -async function main(): Promise { - console.log("\n=== unknown keys produce warnings ==="); - - { - const prefs = { typo_key: "value" } as unknown as GSDPreferences; - const { warnings } = validatePreferences(prefs); - assertTrue(warnings.some(w => w.includes("typo_key")), "unknown key 'typo_key' produces warning"); - assertTrue(warnings.some(w => w.includes("unknown")), "warning mentions 'unknown'"); - } - - { - const prefs = { foo: 1, bar: 2 } as unknown as GSDPreferences; - const { warnings } = validatePreferences(prefs); - assertTrue(warnings.some(w => w.includes("foo")), "unknown key 'foo' produces warning"); - assertTrue(warnings.some(w => w.includes("bar")), "unknown key 'bar' produces warning"); - assertEq(warnings.filter(w => w.includes("unknown")).length, 2, "two unknown key warnings"); - } - - console.log("\n=== known keys do NOT produce unknown-key warnings ==="); - - { - const prefs: GSDPreferences = { - version: 1, - uat_dispatch: true, - budget_ceiling: 50, - skill_discovery: "auto", - }; - const { warnings } = validatePreferences(prefs); - const unknownWarnings = warnings.filter(w => w.includes("unknown")); - assertEq(unknownWarnings.length, 0, "valid keys produce no unknown-key warnings"); - } - - console.log("\n=== all GSDPreferences keys are accepted ==="); - - { - const prefs: GSDPreferences = { - version: 1, - always_use_skills: ["skill-a"], - prefer_skills: ["skill-b"], - avoid_skills: ["skill-c"], - skill_rules: [{ when: "testing", use: ["skill-d"] }], - custom_instructions: ["do a thing"], - models: { research: "claude-opus-4-6" }, - skill_discovery: "suggest", - auto_supervisor: { model: "claude-opus-4-6" }, - uat_dispatch: false, - unique_milestone_ids: true, - budget_ceiling: 100, - budget_enforcement: "warn", - context_pause_threshold: 0.8, - notifications: { enabled: true }, - remote_questions: { channel: "slack", channel_id: "C123" }, - git: { auto_push: true }, - post_unit_hooks: [{ name: "test-hook", after: ["execute-task"], prompt: "do it" }], - pre_dispatch_hooks: [{ name: "pre-hook", before: ["execute-task"], action: "skip" }], - }; - const { warnings } = validatePreferences(prefs); - const unknownWarnings = warnings.filter(w => w.includes("unknown")); - assertEq(unknownWarnings.length, 0, "all known keys produce no unknown-key warnings"); - } - - console.log("\n=== invalid value types produce errors ==="); - - { - const prefs = { budget_ceiling: "not-a-number" } as unknown as GSDPreferences; - const { errors, preferences } = validatePreferences(prefs); - assertTrue(errors.some(e => e.includes("budget_ceiling")), "invalid budget_ceiling produces error"); - assertEq(preferences.budget_ceiling, undefined, "invalid budget_ceiling falls back to undefined"); - } - - { - const prefs = { budget_enforcement: "invalid" } as unknown as GSDPreferences; - const { errors, preferences } = validatePreferences(prefs); - assertTrue(errors.some(e => e.includes("budget_enforcement")), "invalid budget_enforcement produces error"); - assertEq(preferences.budget_enforcement, undefined, "invalid budget_enforcement falls back to undefined"); - } - - { - const prefs = { context_pause_threshold: "not-a-number" } as unknown as GSDPreferences; - const { errors, preferences } = validatePreferences(prefs); - assertTrue(errors.some(e => e.includes("context_pause_threshold")), "invalid context_pause_threshold produces error"); - assertEq(preferences.context_pause_threshold, undefined, "invalid context_pause_threshold falls back to undefined"); - } - - { - const prefs = { skill_discovery: "invalid-mode" } as unknown as GSDPreferences; - const { errors, preferences } = validatePreferences(prefs); - assertTrue(errors.some(e => e.includes("skill_discovery")), "invalid skill_discovery produces error"); - assertEq(preferences.skill_discovery, undefined, "invalid skill_discovery falls back to undefined"); - } - - console.log("\n=== valid values pass through correctly ==="); - - { - const { preferences } = validatePreferences({ budget_enforcement: "halt" }); - assertEq(preferences.budget_enforcement, "halt", "valid budget_enforcement passes through"); - } - - { - const { preferences } = validatePreferences({ context_pause_threshold: 0.75 }); - assertEq(preferences.context_pause_threshold, 0.75, "valid context_pause_threshold passes through"); - } - - { - const { preferences } = validatePreferences({ models: { research: "claude-opus-4-6" } }); - assertEq(preferences.models?.research, "claude-opus-4-6", "valid models passes through"); - } - - { - const { preferences } = validatePreferences({ auto_supervisor: { model: "claude-opus-4-6" } }); - assertEq(preferences.auto_supervisor?.model, "claude-opus-4-6", "valid auto_supervisor passes through"); - } - - { - const { preferences } = validatePreferences({ notifications: { enabled: true } }); - assertEq(preferences.notifications?.enabled, true, "valid notifications passes through"); - } - - { - const { preferences } = validatePreferences({ remote_questions: { channel: "slack", channel_id: "C123" } }); - assertEq(preferences.remote_questions?.channel, "slack", "valid remote_questions passes through"); - } - - console.log("\n=== mixed valid/invalid/unknown keys ==="); - - { - const prefs = { - uat_dispatch: true, - totally_made_up: "value", - budget_ceiling: "garbage", - } as unknown as GSDPreferences; - const { preferences, errors, warnings } = validatePreferences(prefs); - - // Valid key works - assertEq(preferences.uat_dispatch, true, "valid uat_dispatch preserved"); - - // Unknown key warned - assertTrue(warnings.some(w => w.includes("totally_made_up")), "unknown key warned"); - - // Invalid value errored and dropped - assertTrue(errors.some(e => e.includes("budget_ceiling")), "invalid budget_ceiling errored"); - assertEq(preferences.budget_ceiling, undefined, "invalid budget_ceiling dropped"); - } - - console.log("\n=== existing behavior preserved ==="); - - // git.isolation is a valid active setting (worktree | branch | none) — no warnings or errors - { - const { warnings, errors, preferences } = validatePreferences({ git: { isolation: "worktree" } } as GSDPreferences); - const unknownWarnings = warnings.filter(w => w.includes("unknown")); - assertEq(unknownWarnings.length, 0, "git is a known key — no unknown-key warning"); - assertEq(errors.length, 0, "valid git.isolation produces no errors"); - assertEq(preferences.git?.isolation, "worktree", "git.isolation value passes through"); - } - { - const { warnings, errors, preferences } = validatePreferences({ git: { isolation: "none" } } as GSDPreferences); - const unknownWarnings = warnings.filter(w => w.includes("unknown")); - assertEq(unknownWarnings.length, 0, "git.isolation none — no unknown-key warning"); - assertEq(errors.length, 0, "git.isolation none produces no errors"); - assertEq(preferences.git?.isolation, "none", "git.isolation none value passes through"); - } - - // git.merge_to_main is deprecated — still produces deprecation warning - { - const { warnings } = validatePreferences({ git: { merge_to_main: true } } as GSDPreferences); - assertTrue(warnings.some(w => w.includes("deprecated")), "deprecated git.merge_to_main still warns"); - } - - report(); -} - -main(); diff --git a/src/resources/extensions/gsd/tests/preferences-wizard-fields.test.ts b/src/resources/extensions/gsd/tests/preferences-wizard-fields.test.ts deleted file mode 100644 index 9efa54953..000000000 --- a/src/resources/extensions/gsd/tests/preferences-wizard-fields.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * preferences-wizard-fields.test.ts — Validates that all wizard-configurable - * preference fields are properly validated and round-trip through the schema. - */ - -import { createTestContext } from "./test-helpers.ts"; -import { validatePreferences } from "../preferences.ts"; -import type { GSDPreferences } from "../preferences.ts"; - -const { assertEq, assertTrue, report } = createTestContext(); - -async function main(): Promise { - console.log("\n=== budget fields validate correctly ==="); - - { - const { preferences, errors } = validatePreferences({ - budget_ceiling: 25.50, - budget_enforcement: "warn", - context_pause_threshold: 80, - }); - assertEq(errors.length, 0, "valid budget fields produce no errors"); - assertEq(preferences.budget_ceiling, 25.50, "budget_ceiling passes through"); - assertEq(preferences.budget_enforcement, "warn", "budget_enforcement passes through"); - assertEq(preferences.context_pause_threshold, 80, "context_pause_threshold passes through"); - } - - { - const { preferences, errors } = validatePreferences({ - budget_enforcement: "pause", - }); - assertEq(errors.length, 0, "budget_enforcement 'pause' is valid"); - assertEq(preferences.budget_enforcement, "pause", "pause passes through"); - } - - { - const { preferences, errors } = validatePreferences({ - budget_enforcement: "halt", - }); - assertEq(errors.length, 0, "budget_enforcement 'halt' is valid"); - assertEq(preferences.budget_enforcement, "halt", "halt passes through"); - } - - { - const { errors } = validatePreferences({ - budget_enforcement: "invalid", - } as unknown as GSDPreferences); - assertTrue(errors.some(e => e.includes("budget_enforcement")), "invalid budget_enforcement rejected"); - } - - console.log("\n=== notification fields validate correctly ==="); - - { - const { preferences, errors } = validatePreferences({ - notifications: { - enabled: true, - on_complete: false, - on_error: true, - on_budget: true, - on_milestone: false, - on_attention: true, - }, - }); - assertEq(errors.length, 0, "valid notifications produce no errors"); - assertEq(preferences.notifications?.enabled, true, "notifications.enabled passes through"); - assertEq(preferences.notifications?.on_complete, false, "notifications.on_complete passes through"); - assertEq(preferences.notifications?.on_milestone, false, "notifications.on_milestone passes through"); - } - - { - const { errors } = validatePreferences({ - notifications: "invalid", - } as unknown as GSDPreferences); - assertTrue(errors.some(e => e.includes("notifications")), "invalid notifications rejected"); - } - - console.log("\n=== git fields validate correctly ==="); - - { - const { preferences, errors } = validatePreferences({ - git: { - auto_push: true, - push_branches: false, - remote: "upstream", - snapshots: true, - pre_merge_check: "auto", - commit_type: "feat", - main_branch: "develop", - merge_strategy: "squash", - isolation: "branch", - }, - }); - assertEq(errors.length, 0, "valid git fields produce no errors"); - assertEq(preferences.git?.auto_push, true, "git.auto_push passes through"); - assertEq(preferences.git?.push_branches, false, "git.push_branches passes through"); - assertEq(preferences.git?.remote, "upstream", "git.remote passes through"); - assertEq(preferences.git?.snapshots, true, "git.snapshots passes through"); - assertEq(preferences.git?.pre_merge_check, "auto", "git.pre_merge_check passes through"); - assertEq(preferences.git?.commit_type, "feat", "git.commit_type passes through"); - assertEq(preferences.git?.main_branch, "develop", "git.main_branch passes through"); - assertEq(preferences.git?.merge_strategy, "squash", "git.merge_strategy passes through"); - assertEq(preferences.git?.isolation, "branch", "git.isolation passes through"); - } - - console.log("\n=== uat_dispatch validates correctly ==="); - - { - const { preferences, errors } = validatePreferences({ uat_dispatch: true }); - assertEq(errors.length, 0, "valid uat_dispatch produces no errors"); - assertEq(preferences.uat_dispatch, true, "uat_dispatch true passes through"); - } - - { - const { preferences, errors } = validatePreferences({ uat_dispatch: false }); - assertEq(errors.length, 0, "valid uat_dispatch false produces no errors"); - assertEq(preferences.uat_dispatch, false, "uat_dispatch false passes through"); - } - - console.log("\n=== unique_milestone_ids validates correctly ==="); - - { - const { preferences, errors } = validatePreferences({ unique_milestone_ids: true }); - assertEq(errors.length, 0, "valid unique_milestone_ids produces no errors"); - assertEq(preferences.unique_milestone_ids, true, "unique_milestone_ids passes through"); - } - - console.log("\n=== all wizard fields together produce no errors ==="); - - { - const fullPrefs: GSDPreferences = { - version: 1, - models: { research: "claude-opus-4-6", planning: "claude-sonnet-4-6" }, - auto_supervisor: { soft_timeout_minutes: 15, idle_timeout_minutes: 5, hard_timeout_minutes: 25 }, - git: { - main_branch: "main", - auto_push: true, - push_branches: false, - remote: "origin", - snapshots: true, - pre_merge_check: "auto", - commit_type: "feat", - merge_strategy: "squash", - isolation: "worktree", - }, - skill_discovery: "suggest", - unique_milestone_ids: false, - budget_ceiling: 50, - budget_enforcement: "pause", - context_pause_threshold: 75, - notifications: { - enabled: true, - on_complete: true, - on_error: true, - on_budget: true, - on_milestone: true, - on_attention: true, - }, - uat_dispatch: false, - }; - const { errors, warnings } = validatePreferences(fullPrefs); - const unknownWarnings = warnings.filter(w => w.includes("unknown")); - assertEq(errors.length, 0, "full wizard prefs produce no errors"); - assertEq(unknownWarnings.length, 0, "full wizard prefs produce no unknown-key warnings"); - } - - report(); -} - -main(); diff --git a/src/resources/extensions/gsd/tests/preferences.test.ts b/src/resources/extensions/gsd/tests/preferences.test.ts new file mode 100644 index 000000000..fdb8e5aff --- /dev/null +++ b/src/resources/extensions/gsd/tests/preferences.test.ts @@ -0,0 +1,284 @@ +/** + * Preferences tests — consolidated from: + * - preferences-git.test.ts (git.isolation, git.merge_to_main, git.commit_docs) + * - preferences-hooks.test.ts (post-unit + pre-dispatch hook config) + * - preferences-mode.test.ts (solo/team mode defaults, overrides) + * - preferences-models.test.ts (model config parsing, OpenRouter, CRLF) + * - preferences-schema-validation.test.ts (unknown keys, invalid types) + * - preferences-wizard-fields.test.ts (budget, notifications, git, uat) + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { + validatePreferences, + applyModeDefaults, + getIsolationMode, + parsePreferencesMarkdown, +} from "../preferences.ts"; +import type { GSDPreferences, GSDModelConfigV2, GSDPhaseModelConfig } from "../preferences.ts"; + +// ── Git preferences ────────────────────────────────────────────────────────── + +test("git.isolation accepts valid values and rejects invalid", () => { + for (const val of ["worktree", "branch", "none"] as const) { + const { errors, preferences } = validatePreferences({ git: { isolation: val } }); + assert.equal(errors.length, 0, `isolation ${val}: no errors`); + assert.equal(preferences.git?.isolation, val); + } + const { errors } = validatePreferences({ git: { isolation: "invalid" as any } }); + assert.ok(errors.length > 0); + assert.ok(errors[0].includes("worktree, branch, none")); +}); + +test("git.merge_to_main produces deprecation warning", () => { + for (const val of ["milestone", "slice"]) { + const { warnings } = validatePreferences({ git: { merge_to_main: val } } as any); + assert.ok(warnings.length > 0); + assert.ok(warnings[0].includes("deprecated")); + } +}); + +test("git.commit_docs accepts boolean, rejects string", () => { + const { errors: e1, preferences: p1 } = validatePreferences({ git: { commit_docs: false } }); + assert.equal(e1.length, 0); + assert.equal(p1.git?.commit_docs, false); + + const { errors: e2 } = validatePreferences({ git: { commit_docs: "no" as any } }); + assert.ok(e2.length > 0); +}); + +test("getIsolationMode defaults to worktree when no prefs file", () => { + assert.equal(getIsolationMode(), "worktree"); +}); + +// ── Mode defaults ──────────────────────────────────────────────────────────── + +test("solo mode applies correct defaults", () => { + const result = applyModeDefaults("solo", { mode: "solo" }); + assert.equal(result.git?.auto_push, true); + assert.equal(result.git?.push_branches, false); + assert.equal(result.git?.pre_merge_check, false); + assert.equal(result.git?.merge_strategy, "squash"); + assert.equal(result.git?.isolation, "worktree"); + assert.equal(result.unique_milestone_ids, false); +}); + +test("team mode applies correct defaults", () => { + const result = applyModeDefaults("team", { mode: "team" }); + assert.equal(result.git?.auto_push, false); + assert.equal(result.git?.push_branches, true); + assert.equal(result.git?.pre_merge_check, true); + assert.equal(result.unique_milestone_ids, true); +}); + +test("explicit override wins over mode default", () => { + const result = applyModeDefaults("solo", { mode: "solo", git: { auto_push: false } }); + assert.equal(result.git?.auto_push, false); + assert.equal(result.git?.push_branches, false); // default still applies +}); + +test("mode: team + explicit unique_milestone_ids override", () => { + const result = applyModeDefaults("team", { mode: "team", unique_milestone_ids: false }); + assert.equal(result.unique_milestone_ids, false); + assert.equal(result.git?.push_branches, true); // other defaults still apply +}); + +test("invalid mode value produces error", () => { + const { errors } = validatePreferences({ mode: "invalid" as any }); + assert.ok(errors.length > 0); + assert.ok(errors[0].includes("solo, team")); +}); + +test("valid mode values pass validation", () => { + for (const m of ["solo", "team"] as const) { + const { errors, preferences } = validatePreferences({ mode: m }); + assert.equal(errors.length, 0); + assert.equal(preferences.mode, m); + } +}); + +// ── Schema validation ──────────────────────────────────────────────────────── + +test("unknown keys produce warnings", () => { + const { warnings } = validatePreferences({ typo_key: "value" } as any); + assert.ok(warnings.some(w => w.includes("typo_key"))); + assert.ok(warnings.some(w => w.includes("unknown"))); +}); + +test("known keys produce no unknown-key warnings", () => { + const { warnings } = validatePreferences({ + version: 1, uat_dispatch: true, budget_ceiling: 50, skill_discovery: "auto", + }); + assert.equal(warnings.filter(w => w.includes("unknown")).length, 0); +}); + +test("invalid value types produce errors and fall back to undefined", () => { + const cases = [ + { input: { budget_ceiling: "not-a-number" }, field: "budget_ceiling" }, + { input: { budget_enforcement: "invalid" }, field: "budget_enforcement" }, + { input: { context_pause_threshold: "not-a-number" }, field: "context_pause_threshold" }, + { input: { skill_discovery: "invalid-mode" }, field: "skill_discovery" }, + ]; + for (const { input, field } of cases) { + const { errors, preferences } = validatePreferences(input as any); + assert.ok(errors.some(e => e.includes(field)), `${field}: error produced`); + assert.equal((preferences as any)[field], undefined, `${field}: falls back to undefined`); + } +}); + +test("valid values pass through correctly", () => { + const { preferences: p1 } = validatePreferences({ budget_enforcement: "halt" }); + assert.equal(p1.budget_enforcement, "halt"); + + const { preferences: p2 } = validatePreferences({ context_pause_threshold: 0.75 }); + assert.equal(p2.context_pause_threshold, 0.75); + + const { preferences: p3 } = validatePreferences({ auto_supervisor: { model: "claude-opus-4-6" } }); + assert.equal(p3.auto_supervisor?.model, "claude-opus-4-6"); +}); + +test("mixed valid/invalid/unknown keys handled correctly", () => { + const { preferences, errors, warnings } = validatePreferences({ + uat_dispatch: true, totally_made_up: "value", budget_ceiling: "garbage", + } as any); + assert.equal(preferences.uat_dispatch, true); + assert.ok(warnings.some(w => w.includes("totally_made_up"))); + assert.ok(errors.some(e => e.includes("budget_ceiling"))); + assert.equal(preferences.budget_ceiling, undefined); +}); + +// ── Wizard fields ──────────────────────────────────────────────────────────── + +test("budget fields validate correctly", () => { + const { preferences, errors } = validatePreferences({ + budget_ceiling: 25.50, budget_enforcement: "warn", context_pause_threshold: 80, + }); + assert.equal(errors.length, 0); + assert.equal(preferences.budget_ceiling, 25.50); + assert.equal(preferences.budget_enforcement, "warn"); + assert.equal(preferences.context_pause_threshold, 80); +}); + +test("notification fields validate correctly", () => { + const { preferences, errors } = validatePreferences({ + notifications: { enabled: true, on_complete: false, on_error: true, on_budget: true }, + }); + assert.equal(errors.length, 0); + assert.equal(preferences.notifications?.enabled, true); + assert.equal(preferences.notifications?.on_complete, false); +}); + +test("git fields comprehensive validation", () => { + const { preferences, errors } = validatePreferences({ + git: { + auto_push: true, push_branches: false, remote: "upstream", snapshots: true, + pre_merge_check: "auto", commit_type: "feat", main_branch: "develop", + merge_strategy: "squash", isolation: "branch", + }, + }); + assert.equal(errors.length, 0); + assert.equal(preferences.git?.auto_push, true); + assert.equal(preferences.git?.remote, "upstream"); + assert.equal(preferences.git?.isolation, "branch"); +}); + +test("all wizard fields together produce no errors", () => { + const { errors, warnings } = validatePreferences({ + version: 1, + models: { research: "claude-opus-4-6" }, + auto_supervisor: { soft_timeout_minutes: 15 }, + git: { main_branch: "main", auto_push: true, isolation: "worktree" }, + skill_discovery: "suggest", + unique_milestone_ids: false, + budget_ceiling: 50, budget_enforcement: "pause", context_pause_threshold: 75, + notifications: { enabled: true }, + uat_dispatch: false, + }); + assert.equal(errors.length, 0); + assert.equal(warnings.filter(w => w.includes("unknown")).length, 0); +}); + +// ── Hook config ────────────────────────────────────────────────────────────── + +test("post-unit hook max_cycles clamping", () => { + assert.equal(Math.max(1, Math.min(10, Math.round(15))), 10); + assert.equal(Math.max(1, Math.min(10, Math.round(0))), 1); + assert.equal(Math.max(1, Math.min(10, Math.round(-5))), 1); + assert.equal(Math.max(1, Math.min(10, Math.round(3))), 3); +}); + +test("pre-dispatch hook action validation", () => { + const valid = new Set(["modify", "skip", "replace"]); + assert.ok(valid.has("modify")); + assert.ok(valid.has("skip")); + assert.ok(valid.has("replace")); + assert.ok(!valid.has("delete")); +}); + +// ── Model config parsing ───────────────────────────────────────────────────── + +test("parses OpenRouter model config with org/model IDs and fallbacks", () => { + const content = `---\nversion: 1\nmodels:\n research:\n model: moonshotai/kimi-k2.5\n fallbacks:\n - qwen/qwen3.5-397b-a17b\n planning:\n model: deepseek/deepseek-r1-0528\n fallbacks:\n - moonshotai/kimi-k2.5\n - deepseek/deepseek-v3.2\n execution:\n model: qwen/qwen3-coder\n fallbacks:\n - qwen/qwen3-coder-next\n---\n`; + const prefs = parsePreferencesMarkdown(content); + assert.ok(prefs); + const models = prefs.models as GSDModelConfigV2; + const research = models.research as GSDPhaseModelConfig; + assert.equal(research.model, "moonshotai/kimi-k2.5"); + assert.deepEqual(research.fallbacks, ["qwen/qwen3.5-397b-a17b"]); + const execution = models.execution as GSDPhaseModelConfig; + assert.deepEqual(execution.fallbacks, ["qwen/qwen3-coder-next"]); +}); + +test("parses model IDs with colons (OpenRouter :free, :exacto)", () => { + const content = `---\nmodels:\n execution:\n model: qwen/qwen3-coder\n fallbacks:\n - qwen/qwen3-coder:free\n - qwen/qwen3-coder:exacto\n---\n`; + const prefs = parsePreferencesMarkdown(content); + assert.ok(prefs); + const models = prefs.models as GSDModelConfigV2; + const execution = models.execution as GSDPhaseModelConfig; + assert.deepEqual(execution.fallbacks, ["qwen/qwen3-coder:free", "qwen/qwen3-coder:exacto"]); +}); + +test("parses legacy string-per-phase model config", () => { + const content = `---\nmodels:\n research: claude-opus-4-6\n execution: claude-sonnet-4-6\n---\n`; + const prefs = parsePreferencesMarkdown(content); + assert.ok(prefs); + const models = prefs.models as GSDModelConfigV2; + assert.equal(models.research, "claude-opus-4-6"); + assert.equal(models.execution, "claude-sonnet-4-6"); +}); + +test("strips inline YAML comments from values", () => { + const content = `---\nmodels:\n execution:\n model: qwen/qwen3-coder # fast\n fallbacks:\n - minimax/minimax-m2.5 # backup\n---\n`; + const prefs = parsePreferencesMarkdown(content); + assert.ok(prefs); + const models = prefs.models as GSDModelConfigV2; + const execution = models.execution as GSDPhaseModelConfig; + assert.equal(execution.model, "qwen/qwen3-coder"); + assert.deepEqual(execution.fallbacks, ["minimax/minimax-m2.5"]); +}); + +test("handles Windows CRLF line endings", () => { + const content = "---\r\nmodels:\r\n execution:\r\n model: qwen/qwen3-coder\r\n---\r\n"; + const prefs = parsePreferencesMarkdown(content); + assert.ok(prefs); + const models = prefs.models as GSDModelConfigV2; + const execution = models.execution as GSDPhaseModelConfig; + assert.equal(execution.model, "qwen/qwen3-coder"); +}); + +test("handles model config with explicit provider field", () => { + const content = `---\nmodels:\n execution:\n model: claude-opus-4-6\n provider: bedrock\n fallbacks:\n - claude-sonnet-4-6\n---\n`; + const prefs = parsePreferencesMarkdown(content); + assert.ok(prefs); + const models = prefs.models as GSDModelConfigV2; + const execution = models.execution as GSDPhaseModelConfig; + assert.equal(execution.model, "claude-opus-4-6"); + assert.equal(execution.provider, "bedrock"); +}); + +test("handles empty models config", () => { + const prefs = parsePreferencesMarkdown("---\nversion: 1\n---\n"); + assert.ok(prefs); + assert.equal(prefs.models, undefined); +}); diff --git a/src/resources/extensions/gsd/tests/roadmap-slices.test.ts b/src/resources/extensions/gsd/tests/roadmap-slices.test.ts index dd7546098..3734380ac 100644 --- a/src/resources/extensions/gsd/tests/roadmap-slices.test.ts +++ b/src/resources/extensions/gsd/tests/roadmap-slices.test.ts @@ -1,8 +1,8 @@ +import test from "node:test"; +import assert from "node:assert/strict"; import { parseRoadmap } from "../files.ts"; import { parseRoadmapSlices, expandDependencies } from "../roadmap-slices.ts"; -import { createTestContext } from './test-helpers.ts'; -const { assertEq, assertTrue, report } = createTestContext(); const content = `# M003: Current **Vision:** Build the thing. @@ -20,64 +20,47 @@ Produces: foo.ts `; -console.log("\n=== parseRoadmapSlices ==="); -const slices = parseRoadmapSlices(content); -assertEq(slices.length, 3, "slice count"); -assertEq(slices[0]?.id, "S01", "first id"); -assertEq(slices[0]?.done, true, "first done"); -assertEq(slices[0]?.demo, "First demo works.", "first demo"); -assertEq(slices[1]?.depends, ["S01"], "second depends"); -assertEq(slices[1]?.risk, "medium", "second risk"); -assertEq(slices[2]?.risk, "low", "missing risk defaults to low"); -assertEq(slices[2]?.depends, ["S01", "S02"], "third depends"); +test("parseRoadmapSlices extracts slices with dependencies and risk", () => { + const slices = parseRoadmapSlices(content); + assert.equal(slices.length, 3); + assert.equal(slices[0]?.id, "S01"); + assert.equal(slices[0]?.done, true); + assert.equal(slices[0]?.demo, "First demo works."); + assert.deepEqual(slices[1]?.depends, ["S01"]); + assert.equal(slices[1]?.risk, "medium"); + assert.equal(slices[2]?.risk, "low"); + assert.deepEqual(slices[2]?.depends, ["S01", "S02"]); +}); -console.log("\n=== parseRoadmap integration ==="); -const roadmap = parseRoadmap(content); -assertEq(roadmap.slices, slices, "parseRoadmap uses extracted slice parser"); -assertEq(roadmap.title, "M003: Current", "roadmap title preserved"); -assertEq(roadmap.vision, "Build the thing.", "roadmap vision preserved"); -assertTrue(roadmap.boundaryMap.length === 1, "boundary map still parsed"); +test("parseRoadmap integration: uses extracted slice parser", () => { + const roadmap = parseRoadmap(content); + assert.equal(roadmap.title, "M003: Current"); + assert.equal(roadmap.vision, "Build the thing."); + assert.equal(roadmap.slices.length, 3); + assert.equal(roadmap.boundaryMap.length, 1); +}); -// ─── expandDependencies unit tests ───────────────────────────────────── +test("expandDependencies: plain IDs, ranges, and edge cases", () => { + assert.deepEqual(expandDependencies([]), []); + assert.deepEqual(expandDependencies(["S01"]), ["S01"]); + assert.deepEqual(expandDependencies(["S01", "S03"]), ["S01", "S03"]); + assert.deepEqual(expandDependencies(["S01-S04"]), ["S01", "S02", "S03", "S04"]); + assert.deepEqual(expandDependencies(["S01-S01"]), ["S01"]); + assert.deepEqual(expandDependencies(["S01..S03"]), ["S01", "S02", "S03"]); + assert.deepEqual(expandDependencies(["S01-S03", "S05"]), ["S01", "S02", "S03", "S05"]); + assert.deepEqual(expandDependencies(["S04-S01"]), ["S04-S01"]); + assert.deepEqual(expandDependencies(["S01-T04"]), ["S01-T04"]); +}); -console.log("\n=== expandDependencies: plain IDs pass through ==="); -assertEq(expandDependencies([]), [], "empty list"); -assertEq(expandDependencies(["S01"]), ["S01"], "single plain ID"); -assertEq(expandDependencies(["S01", "S03"]), ["S01", "S03"], "multiple plain IDs"); - -console.log("\n=== expandDependencies: dash range expansion ==="); -assertEq(expandDependencies(["S01-S04"]), ["S01", "S02", "S03", "S04"], "S01-S04 expands correctly"); -assertEq(expandDependencies(["S01-S01"]), ["S01"], "single-element range"); -assertEq(expandDependencies(["S03-S05"]), ["S03", "S04", "S05"], "mid-range expansion"); - -console.log("\n=== expandDependencies: dot-range expansion ==="); -assertEq(expandDependencies(["S01..S03"]), ["S01", "S02", "S03"], "S01..S03 dot range"); - -console.log("\n=== expandDependencies: zero-padding preserved ==="); -assertEq(expandDependencies(["S01-S03"]), ["S01", "S02", "S03"], "zero-padded IDs preserved"); - -console.log("\n=== expandDependencies: mixed list ==="); -assertEq(expandDependencies(["S01-S03", "S05"]), ["S01", "S02", "S03", "S05"], "range + plain mixed"); - -console.log("\n=== expandDependencies: invalid range passes through unchanged ==="); -assertEq(expandDependencies(["S04-S01"]), ["S04-S01"], "reversed range not expanded (start > end)"); -assertEq(expandDependencies(["S01-T04"]), ["S01-T04"], "mismatched prefix not expanded"); - -// ─── parseRoadmapSlices: range syntax in depends ───────────────────── - -console.log("\n=== parseRoadmapSlices: range syntax in depends expanded ==="); -{ +test("parseRoadmapSlices: range syntax in depends expanded", () => { const rangeContent = `# M016: Test\n\n## Slices\n- [x] **S01: A** \`risk:low\` \`depends:[]\`\n- [x] **S02: B** \`risk:low\` \`depends:[]\`\n- [x] **S03: C** \`risk:low\` \`depends:[]\`\n- [x] **S04: D** \`risk:low\` \`depends:[]\`\n- [ ] **S05: E** \`risk:low\` \`depends:[S01-S04]\`\n > After this: all done\n`; - const rangeSlices = parseRoadmapSlices(rangeContent); - assertEq(rangeSlices.length, 5, "5 slices parsed"); - assertEq(rangeSlices[4]?.depends, ["S01", "S02", "S03", "S04"], "S01-S04 range expanded to individual IDs"); -} + const slices = parseRoadmapSlices(rangeContent); + assert.equal(slices.length, 5); + assert.deepEqual(slices[4]?.depends, ["S01", "S02", "S03", "S04"]); +}); -console.log("\n=== parseRoadmapSlices: comma-separated depends still works ==="); -{ +test("parseRoadmapSlices: comma-separated depends still works", () => { const commaContent = `# M001: Test\n\n## Slices\n- [ ] **S05: E** \`risk:low\` \`depends:[S01,S02,S03,S04]\`\n > After this: done\n`; - const commaSlices = parseRoadmapSlices(commaContent); - assertEq(commaSlices[0]?.depends, ["S01", "S02", "S03", "S04"], "comma-separated depends unchanged"); -} - -report(); + const slices = parseRoadmapSlices(commaContent); + assert.deepEqual(slices[0]?.depends, ["S01", "S02", "S03", "S04"]); +}); diff --git a/src/resources/extensions/gsd/tests/workspace-index.test.ts b/src/resources/extensions/gsd/tests/workspace-index.test.ts index 280d64035..d60ed1ae4 100644 --- a/src/resources/extensions/gsd/tests/workspace-index.test.ts +++ b/src/resources/extensions/gsd/tests/workspace-index.test.ts @@ -1,75 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; - import { getSuggestedNextCommands, indexWorkspace, listDoctorScopeSuggestions } from "../workspace-index.ts"; -import { createTestContext } from './test-helpers.ts'; -const { assertEq, assertTrue, report } = createTestContext(); -const base = mkdtempSync(join(tmpdir(), "gsd-workspace-index-test-")); -const gsd = join(base, ".gsd"); -const mDir = join(gsd, "milestones", "M001"); -const sDir = join(mDir, "slices", "S01"); -const tDir = join(sDir, "tasks"); -mkdirSync(tDir, { recursive: true }); +test("workspace index: indexes active milestone/slice/task and suggests commands", async () => { + const base = mkdtempSync(join(tmpdir(), "gsd-workspace-index-test-")); + const gsd = join(base, ".gsd"); + const mDir = join(gsd, "milestones", "M001"); + const sDir = join(mDir, "slices", "S01"); + mkdirSync(join(sDir, "tasks"), { recursive: true }); -writeFileSync(join(mDir, "M001-ROADMAP.md"), `# M001: Demo Milestone + writeFileSync(join(mDir, "M001-ROADMAP.md"), `# M001: Demo Milestone\n\n## Slices\n- [ ] **S01: Demo Slice** \`risk:low\` \`depends:[]\`\n > After this: demo works\n`); + writeFileSync(join(sDir, "S01-PLAN.md"), `# S01: Demo Slice\n\n**Goal:** Demo\n**Demo:** Demo\n\n## Must-Haves\n- done\n\n## Tasks\n- [ ] **T01: Implement thing** \`est:10m\`\n Task is in progress.\n`); + writeFileSync(join(sDir, "tasks", "T01-PLAN.md"), `# T01: Implement thing\n\n## Steps\n- do it\n`); -## Slices -- [ ] **S01: Demo Slice** \`risk:low\` \`depends:[]\` - > After this: demo works -`); - -writeFileSync(join(sDir, "S01-PLAN.md"), `# S01: Demo Slice - -**Goal:** Demo -**Demo:** Demo - -## Must-Haves -- done - -## Tasks -- [ ] **T01: Implement thing** \`est:10m\` - Task is in progress. -`); - -writeFileSync(join(tDir, "T01-PLAN.md"), `# T01: Implement thing - -## Steps -- do it -`); - -async function main(): Promise { - console.log("\n=== workspace index ==="); - { + try { const index = await indexWorkspace(base); - assertEq(index.active.milestoneId, "M001", "active milestone indexed"); - assertEq(index.active.sliceId, "S01", "active slice indexed"); - assertEq(index.active.taskId, "T01", "active task indexed"); - assertTrue(index.scopes.some(scope => scope.scope === "M001/S01"), "slice scope listed"); - assertTrue(index.scopes.some(scope => scope.scope === "M001/S01/T01"), "task scope listed"); - } + assert.equal(index.active.milestoneId, "M001"); + assert.equal(index.active.sliceId, "S01"); + assert.equal(index.active.taskId, "T01"); + assert.ok(index.scopes.some(s => s.scope === "M001/S01")); + assert.ok(index.scopes.some(s => s.scope === "M001/S01/T01")); - console.log("\n=== doctor scope suggestions ==="); - { const suggestions = await listDoctorScopeSuggestions(base); - assertEq(suggestions[0].value, "M001/S01", "active slice suggested first"); - assertTrue(suggestions.some(item => item.value === "M001/S01/T01"), "task scope suggested"); - } + assert.equal(suggestions[0].value, "M001/S01"); + assert.ok(suggestions.some(item => item.value === "M001/S01/T01")); - console.log("\n=== next command suggestions ==="); - { const commands = await getSuggestedNextCommands(base); - assertTrue(commands.includes("/gsd auto"), "suggests auto during execution"); - assertTrue(commands.includes("/gsd doctor M001/S01"), "suggests scoped doctor"); - assertTrue(commands.includes("/gsd status"), "suggests status"); + assert.ok(commands.includes("/gsd auto")); + assert.ok(commands.includes("/gsd doctor M001/S01")); + assert.ok(commands.includes("/gsd status")); + } finally { + rmSync(base, { recursive: true, force: true }); } - - rmSync(base, { recursive: true, force: true }); - report(); -} - -main().catch((error) => { - console.error(error); - process.exit(1); });