refactor: batch 2 — consolidate preferences, convert 8 more files to node:test (#1061)
* docs: add Node LTS pinning guide for macOS Homebrew users
New doc (docs/node-lts-macos.md) explains how to pin Node 24 LTS
via Homebrew to avoid running on odd-numbered development releases.
Covers brew install/link/pin, version managers as alternatives,
and verification steps.
Added notice banner in README linking to the guide.
* refactor: batch 2 — consolidate preferences tests, convert 7 more files to node:test
Preferences (6 files → 1):
preferences-{git,hooks,mode,models,schema-validation,wizard-fields}.test.ts
→ preferences.test.ts (28 tests)
Converted to node:test (custom runner → node:test):
- discuss-prompt.test.ts (1 test)
- auto-preflight.test.ts (1 test)
- next-milestone-id.test.ts (4 tests)
- plan-slice-prompt.test.ts (3 tests)
- workspace-index.test.ts (1 test)
- roadmap-slices.test.ts (5 tests)
- in-flight-tool-tracking.test.ts (5 tests)
Net: -933 lines, -6 files. Full suite: 1325 pass, 0 fail.
* refactor: convert dispatch-guard.test.ts to node:test
Net: 1 more file converted. Total this branch: 14 files converted/consolidated, 6 deleted.
* fix: add null guards for parsePreferencesMarkdown in tests
Add assert.ok(prefs) after each parsePreferencesMarkdown() call to
narrow the GSDPreferences | null return type before property access.
Fixes TS18047 errors in CI typecheck.
This commit is contained in:
parent
8dfa7d058c
commit
55769392af
15 changed files with 512 additions and 1470 deletions
|
|
@ -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<void> {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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, string> = {}): string {
|
||||
function loadPrompt(name: string, vars: Record<string, string> = {}): 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<string, string> = {})
|
|||
|
||||
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<void> {
|
||||
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"));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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<void> {
|
||||
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();
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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<void> {
|
||||
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();
|
||||
|
|
@ -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<void> {
|
||||
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();
|
||||
284
src/resources/extensions/gsd/tests/preferences.test.ts
Normal file
284
src/resources/extensions/gsd/tests/preferences.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue