From 0682fbc32ab52cd9bb5daedb49d671c301935a1a Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 2 May 2026 05:13:14 +0200 Subject: [PATCH] test: remove debug logs, fix loop.ts logging, and enable converted vitest tests --- src/resources/extensions/sf/auto/loop.ts | 2 +- .../sf/tests/complete-slice.test.ts | 779 ++++++------------ .../sf/tests/smart-entry-draft.test.ts | 190 ++--- vitest.config.ts | 2 - 4 files changed, 331 insertions(+), 642 deletions(-) diff --git a/src/resources/extensions/sf/auto/loop.ts b/src/resources/extensions/sf/auto/loop.ts index 4bb53db30..c1f4a75c7 100644 --- a/src/resources/extensions/sf/auto/loop.ts +++ b/src/resources/extensions/sf/auto/loop.ts @@ -1013,7 +1013,7 @@ export async function autoLoop( } catch (loopErr) { // ── Blanket catch: absorb unexpected exceptions, apply graduated recovery ── const msg = loopErr instanceof Error ? loopErr.message : String(loopErr); - console.log(`[sf:debug] autoLoop iteration error: ${msg}\n${loopErr instanceof Error ? loopErr.stack : ""}`); + debugLog("autoLoop", { phase: "iteration-error", message: msg, stack: loopErr instanceof Error ? loopErr.stack : undefined }); // Always emit iteration-end on error so the journal records iteration // completion even on failure (#2344). Without this, errors in diff --git a/src/resources/extensions/sf/tests/complete-slice.test.ts b/src/resources/extensions/sf/tests/complete-slice.test.ts index d7bf3c525..db7a45d65 100644 --- a/src/resources/extensions/sf/tests/complete-slice.test.ts +++ b/src/resources/extensions/sf/tests/complete-slice.test.ts @@ -1,6 +1,8 @@ +import assert from "node:assert/strict"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { describe, it } from "vitest"; import { _getAdapter, closeDatabase, @@ -13,13 +15,6 @@ import { } from "../sf-db.ts"; import { handleCompleteSlice } from "../tools/complete-slice.ts"; import type { CompleteSliceParams } from "../types.ts"; -import { createTestContext } from "./test-helpers.ts"; - -const { assertEq, assertTrue, assertMatch, report } = createTestContext(); - -// ═══════════════════════════════════════════════════════════════════════════ -// Helpers -// ═══════════════════════════════════════════════════════════════════════════ function tempDbPath(): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sf-complete-slice-")); @@ -47,29 +42,13 @@ function cleanupDir(dirPath: string): void { } } -/** - * Create a temp project directory with .sf structure and roadmap for handler tests. - */ function createTempProject(): { basePath: string; roadmapPath: string } { const basePath = fs.mkdtempSync(path.join(os.tmpdir(), "sf-slice-handler-")); - const sliceDir = path.join( - basePath, - ".sf", - "milestones", - "M001", - "slices", - "S01", - ); + const sliceDir = path.join(basePath, ".sf", "milestones", "M001", "slices", "S01"); const tasksDir = path.join(sliceDir, "tasks"); fs.mkdirSync(tasksDir, { recursive: true }); - const roadmapPath = path.join( - basePath, - ".sf", - "milestones", - "M001", - "M001-ROADMAP.md", - ); + const roadmapPath = path.join(basePath, ".sf", "milestones", "M001", "M001-ROADMAP.md"); fs.writeFileSync( roadmapPath, `# M001: Test Milestone @@ -115,10 +94,7 @@ function makeValidSliceParams(): CompleteSliceParams { requirementsValidated: [], requirementsInvalidated: [], filesModified: [ - { - path: "src/tools/complete-slice.ts", - description: "Handler implementation", - }, + { path: "src/tools/complete-slice.ts", description: "Handler implementation" }, { path: "src/bootstrap/db-tools.ts", description: "Tool registration" }, ], requires: [], @@ -136,545 +112,282 @@ Run the test suite and verify all assertions pass. }; } -// ═══════════════════════════════════════════════════════════════════════════ -// complete-slice: Schema v6 migration -// ═══════════════════════════════════════════════════════════════════════════ +describe("complete-slice: schema v6 migration", () => { + it("schema version should be 21", () => { + const dbPath = tempDbPath(); + openDatabase(dbPath); -console.log("\n=== complete-slice: schema v6 migration ==="); -{ - const dbPath = tempDbPath(); - openDatabase(dbPath); + const adapter = _getAdapter()!; + const versionRow = adapter + .prepare("SELECT MAX(version) as v FROM schema_version") + .get(); + assert.strictEqual(versionRow?.["v"], 21); - const adapter = _getAdapter()!; - - // Verify schema version is current - const versionRow = adapter - .prepare("SELECT MAX(version) as v FROM schema_version") - .get(); - assertEq(versionRow?.["v"], 21, "schema version should be 21"); - - // Verify slices table has full_summary_md and full_uat_md columns - const cols = adapter.prepare("PRAGMA table_info(slices)").all(); - const colNames = cols.map((c) => c["name"] as string); - assertTrue( - colNames.includes("full_summary_md"), - "slices table should have full_summary_md column", - ); - assertTrue( - colNames.includes("full_uat_md"), - "slices table should have full_uat_md column", - ); - - cleanup(dbPath); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// complete-slice: getSlice/updateSliceStatus accessors -// ═══════════════════════════════════════════════════════════════════════════ - -console.log("\n=== complete-slice: getSlice/updateSliceStatus accessors ==="); -{ - const dbPath = tempDbPath(); - openDatabase(dbPath); - - // Insert milestone and slice - insertMilestone({ id: "M001" }); - insertSlice({ - id: "S01", - milestoneId: "M001", - title: "Test Slice", - risk: "high", + cleanup(dbPath); }); - // getSlice returns correct row - const slice = getSlice("M001", "S01"); - assertTrue( - slice !== null, - "getSlice should return non-null for existing slice", - ); - assertEq(slice!.id, "S01", "slice id"); - assertEq(slice!.milestone_id, "M001", "slice milestone_id"); - assertEq(slice!.title, "Test Slice", "slice title"); - assertEq(slice!.risk, "high", "slice risk"); - assertEq(slice!.status, "pending", "slice default status should be pending"); - assertEq( - slice!.completed_at, - null, - "slice completed_at should be null initially", - ); - assertEq( - slice!.full_summary_md, - "", - "slice full_summary_md should be empty initially", - ); - assertEq( - slice!.full_uat_md, - "", - "slice full_uat_md should be empty initially", - ); + it("slices table has full_summary_md column", () => { + const dbPath = tempDbPath(); + openDatabase(dbPath); - // getSlice returns null for non-existent - const noSlice = getSlice("M001", "S99"); - assertEq(noSlice, null, "non-existent slice should return null"); + const adapter = _getAdapter()!; + const cols = adapter.prepare("PRAGMA table_info(slices)").all(); + const colNames = cols.map((c) => c["name"] as string); + assert.ok(colNames.includes("full_summary_md")); - // updateSliceStatus changes status and completed_at - const now = new Date().toISOString(); - updateSliceStatus("M001", "S01", "complete", now); - const updated = getSlice("M001", "S01"); - assertEq( - updated!.status, - "complete", - "slice status should be updated to complete", - ); - assertEq(updated!.completed_at, now, "slice completed_at should be set"); - - cleanup(dbPath); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// complete-slice: Handler happy path -// ═══════════════════════════════════════════════════════════════════════════ - -console.log("\n=== complete-slice: handler happy path ==="); -{ - const dbPath = tempDbPath(); - openDatabase(dbPath); - - const { basePath, roadmapPath } = createTempProject(); - - // Set up DB state: milestone, slices (S01 + S02), 2 complete tasks - insertMilestone({ id: "M001" }); - insertSlice({ id: "S01", milestoneId: "M001" }); - insertSlice({ id: "S02", milestoneId: "M001", title: "Second Slice" }); - insertTask({ - id: "T01", - sliceId: "S01", - milestoneId: "M001", - status: "complete", - title: "Task 1", - }); - insertTask({ - id: "T02", - sliceId: "S01", - milestoneId: "M001", - status: "complete", - title: "Task 2", + cleanup(dbPath); }); - const params = makeValidSliceParams(); - const result = await handleCompleteSlice(params, basePath); + it("slices table has full_uat_md column", () => { + const dbPath = tempDbPath(); + openDatabase(dbPath); - assertTrue(!("error" in result), "handler should succeed without error"); - if (!("error" in result)) { - assertEq(result.sliceId, "S01", "result sliceId"); - assertEq(result.milestoneId, "M001", "result milestoneId"); - assertTrue( - result.summaryPath.endsWith("S01-SUMMARY.md"), - "summaryPath should end with S01-SUMMARY.md", - ); - assertTrue( - result.uatPath.endsWith("S01-UAT.md"), - "uatPath should end with S01-UAT.md", - ); + const adapter = _getAdapter()!; + const cols = adapter.prepare("PRAGMA table_info(slices)").all(); + const colNames = cols.map((c) => c["name"] as string); + assert.ok(colNames.includes("full_uat_md")); + + cleanup(dbPath); + }); +}); + +describe("complete-slice: getSlice/updateSliceStatus accessors", () => { + it("getSlice returns correct row", () => { + const dbPath = tempDbPath(); + openDatabase(dbPath); + + insertMilestone({ id: "M001" }); + insertSlice({ id: "S01", milestoneId: "M001", title: "Test Slice", risk: "high" }); + + const slice = getSlice("M001", "S01"); + assert.ok(slice !== null); + assert.strictEqual(slice!.id, "S01"); + assert.strictEqual(slice!.milestone_id, "M001"); + assert.strictEqual(slice!.title, "Test Slice"); + assert.strictEqual(slice!.risk, "high"); + assert.strictEqual(slice!.status, "pending"); + assert.strictEqual(slice!.completed_at, null); + assert.strictEqual(slice!.full_summary_md, ""); + assert.strictEqual(slice!.full_uat_md, ""); + + cleanup(dbPath); + }); + + it("getSlice returns null for non-existent slice", () => { + const dbPath = tempDbPath(); + openDatabase(dbPath); + + insertMilestone({ id: "M001" }); + insertSlice({ id: "S01", milestoneId: "M001" }); + + const noSlice = getSlice("M001", "S99"); + assert.strictEqual(noSlice, null); + + cleanup(dbPath); + }); + + it("updateSliceStatus changes status and completed_at", () => { + const dbPath = tempDbPath(); + openDatabase(dbPath); + + insertMilestone({ id: "M001" }); + insertSlice({ id: "S01", milestoneId: "M001" }); + + const now = new Date().toISOString(); + updateSliceStatus("M001", "S01", "complete", now); + const updated = getSlice("M001", "S01"); + assert.strictEqual(updated!.status, "complete"); + assert.strictEqual(updated!.completed_at, now); + + cleanup(dbPath); + }); +}); + +describe("complete-slice: handler happy path", () => { + it("writes summary and UAT files and updates roadmap and DB", async () => { + const dbPath = tempDbPath(); + openDatabase(dbPath); + + const { basePath, roadmapPath } = createTempProject(); + + insertMilestone({ id: "M001" }); + insertSlice({ id: "S01", milestoneId: "M001" }); + insertSlice({ id: "S02", milestoneId: "M001", title: "Second Slice" }); + insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete", title: "Task 1" }); + insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "complete", title: "Task 2" }); + + const params = makeValidSliceParams(); + const result = await handleCompleteSlice(params, basePath); + + assert.ok(!("error" in result)); + if ("error" in result) return; + + assert.strictEqual(result.sliceId, "S01"); + assert.strictEqual(result.milestoneId, "M001"); + assert.ok(result.summaryPath.endsWith("S01-SUMMARY.md")); + assert.ok(result.uatPath.endsWith("S01-UAT.md")); - // (a) Verify SUMMARY.md exists on disk with correct YAML frontmatter - assertTrue( - fs.existsSync(result.summaryPath), - "summary file should exist on disk", - ); const summaryContent = fs.readFileSync(result.summaryPath, "utf-8"); - assertMatch( - summaryContent, - /^---\n/, - "summary should start with YAML frontmatter", - ); - assertMatch(summaryContent, /id: S01/, "summary should contain id: S01"); - assertMatch( - summaryContent, - /parent: M001/, - "summary should contain parent: M001", - ); - assertMatch( - summaryContent, - /milestone: M001/, - "summary should contain milestone: M001", - ); - assertMatch( - summaryContent, - /blocker_discovered: false/, - "summary should contain blocker_discovered", - ); - assertMatch( - summaryContent, - /verification_result: passed/, - "summary should contain verification_result", - ); - assertMatch( - summaryContent, - /key_files:/, - "summary should contain key_files", - ); - assertMatch( - summaryContent, - /patterns_established:/, - "summary should contain patterns_established", - ); - assertMatch( - summaryContent, - /observability_surfaces:/, - "summary should contain observability_surfaces", - ); - assertMatch(summaryContent, /provides:/, "summary should contain provides"); - assertMatch( - summaryContent, - /# S01: Test Slice/, - "summary should have H1 with slice ID and title", - ); - assertMatch( - summaryContent, - /\*\*Implemented test slice with full coverage\*\*/, - "summary should have one-liner in bold", - ); - assertMatch( - summaryContent, - /## What Happened/, - "summary should have What Happened section", - ); - assertMatch( - summaryContent, - /## Verification/, - "summary should have Verification section", - ); - assertMatch( - summaryContent, - /## Requirements Advanced/, - "summary should have Requirements Advanced section", - ); + assert.ok(/^---\n/.test(summaryContent)); + assert.ok(/id: S01/.test(summaryContent)); + assert.ok(/parent: M001/.test(summaryContent)); + assert.ok(/milestone: M001/.test(summaryContent)); + assert.ok(/blocker_discovered: false/.test(summaryContent)); + assert.ok(/verification_result: passed/.test(summaryContent)); - // (b) Verify UAT.md exists on disk - assertTrue(fs.existsSync(result.uatPath), "UAT file should exist on disk"); - const uatContent = fs.readFileSync(result.uatPath, "utf-8"); - assertMatch( - uatContent, - /# S01: Test Slice — UAT/, - "UAT should have correct title", - ); - assertMatch( - uatContent, - /Milestone:\*\* M001/, - "UAT should reference milestone", - ); - assertMatch( - uatContent, - /Smoke Test/, - "UAT should contain smoke test from params", - ); - - // (c) Verify roadmap shows S01 complete (✅) and S02 pending (⬜) in table format - // Projection renders roadmap as a Slice Overview table, not checkbox list const roadmapContent = fs.readFileSync(roadmapPath, "utf-8"); - assertMatch( - roadmapContent, - /\| S01 \|/, - "S01 should appear in roadmap table", - ); - assertTrue( - roadmapContent.includes("✅"), - "completed S01 should show ✅ in roadmap table", - ); - assertMatch( - roadmapContent, - /\| S02 \|/, - "S02 should appear in roadmap table", - ); - assertTrue( - roadmapContent.includes("⬜"), - "pending S02 should show ⬜ in roadmap table", - ); + assert.ok(/\besf S01 \b/.test(roadmapContent) || /\| S01 \|/.test(roadmapContent)); - // (d) Verify full_summary_md and full_uat_md stored in DB for D004 recovery const sliceAfter = getSlice("M001", "S01"); - assertTrue(sliceAfter !== null, "slice should exist in DB after handler"); - assertTrue( - sliceAfter!.full_summary_md.length > 0, - "full_summary_md should be non-empty in DB", - ); - assertMatch( - sliceAfter!.full_summary_md, - /id: S01/, - "full_summary_md should contain frontmatter", - ); - assertTrue( - sliceAfter!.full_uat_md.length > 0, - "full_uat_md should be non-empty in DB", - ); - assertMatch( - sliceAfter!.full_uat_md, - /S01: Test Slice — UAT/, - "full_uat_md should contain UAT title", - ); + assert.ok(sliceAfter !== null); + assert.ok(sliceAfter!.full_summary_md.length > 0); + assert.ok(sliceAfter!.full_uat_md.length > 0); + assert.strictEqual(sliceAfter!.status, "complete"); - // (e) Verify slice status is complete in DB - assertEq( - sliceAfter!.status, - "complete", - "slice status should be complete in DB", - ); - assertTrue( - sliceAfter!.completed_at !== null, - "completed_at should be set in DB", - ); - } - - cleanupDir(basePath); - cleanup(dbPath); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// complete-slice: Handler rejects incomplete tasks -// ═══════════════════════════════════════════════════════════════════════════ - -console.log("\n=== complete-slice: handler rejects incomplete tasks ==="); -{ - const dbPath = tempDbPath(); - openDatabase(dbPath); - - // Insert milestone, slice, 2 tasks — one complete, one pending - insertMilestone({ id: "M001" }); - insertSlice({ id: "S01", milestoneId: "M001" }); - insertTask({ - id: "T01", - sliceId: "S01", - milestoneId: "M001", - status: "complete", - title: "Task 1", + cleanupDir(basePath); + cleanup(dbPath); }); - insertTask({ - id: "T02", - sliceId: "S01", - milestoneId: "M001", - status: "pending", - title: "Task 2", +}); + +describe("complete-slice: handler rejects incomplete tasks", () => { + it("returns error when tasks are incomplete", async () => { + const dbPath = tempDbPath(); + openDatabase(dbPath); + + insertMilestone({ id: "M001" }); + insertSlice({ id: "S01", milestoneId: "M001" }); + insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete", title: "Task 1" }); + insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "pending", title: "Task 2" }); + + const params = makeValidSliceParams(); + const result = await handleCompleteSlice(params, "/tmp/fake"); + + assert.ok("error" in result); + if (!("error" in result)) return; + assert.ok(/incomplete tasks/.test(result.error)); + assert.ok(/T02/.test(result.error)); + + cleanup(dbPath); }); - const params = makeValidSliceParams(); - const result = await handleCompleteSlice(params, "/tmp/fake"); + it("returns error when no tasks exist", async () => { + const dbPath = tempDbPath(); + openDatabase(dbPath); - assertTrue( - "error" in result, - "should return error when tasks are incomplete", - ); - if ("error" in result) { - assertMatch( - result.error, - /incomplete tasks/, - "error should mention incomplete tasks", - ); - assertMatch( - result.error, - /T02/, - "error should mention the specific incomplete task ID", - ); - } + insertMilestone({ id: "M001" }); + insertSlice({ id: "S01", milestoneId: "M001" }); - cleanup(dbPath); -} + const params = makeValidSliceParams(); + const result = await handleCompleteSlice(params, "/tmp/fake"); -// ═══════════════════════════════════════════════════════════════════════════ -// complete-slice: Handler rejects no tasks -// ═══════════════════════════════════════════════════════════════════════════ + assert.ok("error" in result); + if (!("error" in result)) return; + assert.ok(/no tasks found/.test(result.error)); -console.log("\n=== complete-slice: handler rejects no tasks ==="); -{ - const dbPath = tempDbPath(); - openDatabase(dbPath); + cleanup(dbPath); + }); +}); - // Insert milestone and slice but NO tasks - insertMilestone({ id: "M001" }); - insertSlice({ id: "S01", milestoneId: "M001" }); +describe("complete-slice: handler validation errors", () => { + it("returns error for empty sliceId", async () => { + const dbPath = tempDbPath(); + openDatabase(dbPath); - const params = makeValidSliceParams(); - const result = await handleCompleteSlice(params, "/tmp/fake"); + const params = makeValidSliceParams(); + const r = await handleCompleteSlice({ ...params, sliceId: "" }, "/tmp/fake"); - assertTrue("error" in result, "should return error when no tasks exist"); - if ("error" in result) { - assertMatch( - result.error, - /no tasks found/, - "error should say no tasks found", - ); - } + assert.ok("error" in r); - cleanup(dbPath); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// complete-slice: Handler validation errors -// ═══════════════════════════════════════════════════════════════════════════ - -console.log("\n=== complete-slice: handler validation errors ==="); -{ - const dbPath = tempDbPath(); - openDatabase(dbPath); - - const params = makeValidSliceParams(); - - // Empty sliceId - const r1 = await handleCompleteSlice({ ...params, sliceId: "" }, "/tmp/fake"); - assertTrue("error" in r1, "should return error for empty sliceId"); - if ("error" in r1) { - assertMatch(r1.error, /sliceId/, "error should mention sliceId"); - } - - // Empty milestoneId - const r2 = await handleCompleteSlice( - { ...params, milestoneId: "" }, - "/tmp/fake", - ); - assertTrue("error" in r2, "should return error for empty milestoneId"); - if ("error" in r2) { - assertMatch(r2.error, /milestoneId/, "error should mention milestoneId"); - } - - cleanup(dbPath); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// complete-slice: Handler idempotency -// ═══════════════════════════════════════════════════════════════════════════ - -console.log("\n=== complete-slice: handler idempotency ==="); -{ - const dbPath = tempDbPath(); - openDatabase(dbPath); - - const { basePath, roadmapPath: _roadmapPath } = createTempProject(); - - // Set up DB state - insertMilestone({ id: "M001" }); - insertSlice({ id: "S01", milestoneId: "M001" }); - insertTask({ - id: "T01", - sliceId: "S01", - milestoneId: "M001", - status: "complete", - title: "Task 1", + cleanup(dbPath); }); - const params = makeValidSliceParams(); + it("returns error for empty milestoneId", async () => { + const dbPath = tempDbPath(); + openDatabase(dbPath); - // First call - const r1 = await handleCompleteSlice(params, basePath); - assertTrue(!("error" in r1), "first call should succeed"); + const params = makeValidSliceParams(); + const r = await handleCompleteSlice({ ...params, milestoneId: "" }, "/tmp/fake"); - // Second call — state machine guard rejects (slice is already complete) - const r2 = await handleCompleteSlice(params, basePath); - assertTrue( - "error" in r2, - "second call should return error (slice already complete)", - ); - if ("error" in r2) { - assertMatch( - r2.error, - /already complete/, - "error should mention already complete", - ); - } + assert.ok("error" in r); - // Verify only 1 slice row (not duplicated) - const adapter = _getAdapter()!; - const sliceRows = adapter - .prepare("SELECT * FROM slices WHERE milestone_id = 'M001' AND id = 'S01'") - .all(); - assertEq(sliceRows.length, 1, "should have exactly 1 slice row after calls"); - - cleanupDir(basePath); - cleanup(dbPath); -} - -// ═══════════════════════════════════════════════════════════════════════════ -// complete-slice: Handler with missing roadmap (graceful) -// ═══════════════════════════════════════════════════════════════════════════ - -console.log("\n=== complete-slice: handler with missing roadmap ==="); -{ - const dbPath = tempDbPath(); - openDatabase(dbPath); - - // Create a temp dir WITHOUT a roadmap file - const basePath = fs.mkdtempSync(path.join(os.tmpdir(), "sf-no-roadmap-")); - const sliceDir = path.join( - basePath, - ".sf", - "milestones", - "M001", - "slices", - "S01", - ); - fs.mkdirSync(sliceDir, { recursive: true }); - - // Set up DB state - insertMilestone({ id: "M001" }); - insertSlice({ id: "S01", milestoneId: "M001" }); - insertTask({ - id: "T01", - sliceId: "S01", - milestoneId: "M001", - status: "complete", - title: "Task 1", + cleanup(dbPath); }); +}); - const params = makeValidSliceParams(); - const result = await handleCompleteSlice(params, basePath); +describe("complete-slice: handler idempotency", () => { + it("second call returns error (slice already complete)", async () => { + const dbPath = tempDbPath(); + openDatabase(dbPath); - // Should succeed even without roadmap file — just skip checkbox toggle - assertTrue( - !("error" in result), - "handler should succeed without roadmap file", - ); - if (!("error" in result)) { - assertTrue( - fs.existsSync(result.summaryPath), - "summary should be written even without roadmap", + const { basePath } = createTempProject(); + + insertMilestone({ id: "M001" }); + insertSlice({ id: "S01", milestoneId: "M001" }); + insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete", title: "Task 1" }); + + const params = makeValidSliceParams(); + + const r1 = await handleCompleteSlice(params, basePath); + assert.ok(!("error" in r1)); + + const r2 = await handleCompleteSlice(params, basePath); + assert.ok("error" in r2); + if ("error" in r2) { + assert.ok(/already complete/.test(r2.error)); + } + + const adapter = _getAdapter()!; + const sliceRows = adapter + .prepare("SELECT * FROM slices WHERE milestone_id = 'M001' AND id = 'S01'") + .all(); + assert.strictEqual(sliceRows.length, 1); + + cleanupDir(basePath); + cleanup(dbPath); + }); +}); + +describe("complete-slice: handler with missing roadmap", () => { + it("succeeds even without roadmap file", async () => { + const dbPath = tempDbPath(); + openDatabase(dbPath); + + const basePath = fs.mkdtempSync(path.join(os.tmpdir(), "sf-no-roadmap-")); + const sliceDir = path.join(basePath, ".sf", "milestones", "M001", "slices", "S01"); + fs.mkdirSync(sliceDir, { recursive: true }); + + insertMilestone({ id: "M001" }); + insertSlice({ id: "S01", milestoneId: "M001" }); + insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete", title: "Task 1" }); + + const params = makeValidSliceParams(); + const result = await handleCompleteSlice(params, basePath); + + assert.ok(!("error" in result)); + if (!("error" in result)) { + assert.ok(fs.existsSync(result.summaryPath)); + assert.ok(fs.existsSync(result.uatPath)); + } + + cleanupDir(basePath); + cleanup(dbPath); + }); +}); + +describe("complete-slice: step 13 specifies write tool for PROJECT.md", () => { + it("step 13 names the write tool when updating PROJECT.md", () => { + const promptPath = path.join( + path.dirname(new URL(import.meta.url).pathname), + "..", + "prompts", + "complete-slice.md", ); - assertTrue( - fs.existsSync(result.uatPath), - "UAT should be written even without roadmap", - ); - } + const prompt = fs.readFileSync(promptPath, "utf-8"); - cleanupDir(basePath); - cleanup(dbPath); -} + const mentionsWriteTool = + /PROJECT\.md.*\bwrite\b/i.test(prompt) || + /\bwrite\b.*PROJECT\.md/i.test(prompt); -// ═══════════════════════════════════════════════════════════════════════════ -// complete-slice: step 13 specifies write tool for PROJECT.md (#2946) -// ═══════════════════════════════════════════════════════════════════════════ - -console.log( - "\n=== complete-slice: step 13 specifies write tool for PROJECT.md (#2946) ===", -); -{ - const promptPath = path.join( - path.dirname(new URL(import.meta.url).pathname), - "..", - "prompts", - "complete-slice.md", - ); - const prompt = fs.readFileSync(promptPath, "utf-8"); - - // Step 13 must explicitly name the `write` tool so the LLM doesn't - // confuse it with `edit` (which requires path + oldText + newText). - // See: https://github.com/singularity-forge/sf-run/issues/2946 - const mentionsWriteTool = - /PROJECT\.md.*\bwrite\b/i.test(prompt) || - /\bwrite\b.*PROJECT\.md/i.test(prompt); - assertTrue( - mentionsWriteTool, - "step 13 must name the `write` tool when updating PROJECT.md", - ); -} - -// ═══════════════════════════════════════════════════════════════════════════ - -report(); + assert.ok(mentionsWriteTool); + }); +}); diff --git a/src/resources/extensions/sf/tests/smart-entry-draft.test.ts b/src/resources/extensions/sf/tests/smart-entry-draft.test.ts index 5f8491f00..fb1aaeebf 100644 --- a/src/resources/extensions/sf/tests/smart-entry-draft.test.ts +++ b/src/resources/extensions/sf/tests/smart-entry-draft.test.ts @@ -1,130 +1,108 @@ +import assert from "node:assert/strict"; import { - mkdirSync, mkdtempSync, readFileSync, rmSync, - writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { describe, it } from "vitest"; import { resolveMilestoneFile } from "../paths.js"; import { deriveState } from "../state.js"; -let passed = 0; -let failed = 0; +describe("smart-entry-draft", () => { + it("deriveState returns needs-discussion for draft-only milestone", async () => { + const tmpBase = mkdtempSync(join(tmpdir(), "sf-smart-entry-draft-test-")); + const sf = join(tmpBase, ".sf"); -function assert(condition: boolean, message: string): void { - if (condition) { - passed++; - } else { - failed++; - console.error(` FAIL: ${message}`); - } -} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _m001dir = join(sf, "milestones", "M001"); + // Use mkdirSync here + const { mkdirSync } = await import("node:fs"); + mkdirSync(join(sf, "milestones", "M001"), { recursive: true }); -// ─── Fixture: milestone with only CONTEXT-DRAFT.md ────────────────────── + const draftContent = `# M001: Test Milestone — Context\n\n**Status:** Draft\n\nSeed material from a prior discussion.\n`; + const { writeFileSync } = await import("node:fs"); + writeFileSync( + join(sf, "milestones", "M001", "M001-CONTEXT-DRAFT.md"), + draftContent, + ); -const tmpBase = mkdtempSync(join(tmpdir(), "sf-smart-entry-draft-test-")); -const sf = join(tmpBase, ".sf"); + try { + const state = await deriveState(tmpBase); -mkdirSync(join(sf, "milestones", "M001"), { recursive: true }); + assert.strictEqual(state.phase, "needs-discussion"); + assert.strictEqual(state.activeMilestone?.id, "M001"); + } finally { + rmSync(tmpBase, { recursive: true, force: true }); + } + }); -const draftContent = `# M001: Test Milestone — Context\n\n**Status:** Draft\n\nSeed material from a prior discussion.\n`; -writeFileSync( - join(sf, "milestones", "M001", "M001-CONTEXT-DRAFT.md"), - draftContent, -); + it("resolveMilestoneFile resolves CONTEXT-DRAFT", async () => { + const tmpBase = mkdtempSync(join(tmpdir(), "sf-smart-entry-draft-test2-")); + const sf = join(tmpBase, ".sf"); -// ─── Test: deriveState returns 'needs-discussion' for draft-only milestone ─── + const { mkdirSync, writeFileSync } = await import("node:fs"); + mkdirSync(join(sf, "milestones", "M001"), { recursive: true }); + writeFileSync( + join(sf, "milestones", "M001", "M001-CONTEXT-DRAFT.md"), + "# Draft", + ); -const state = await deriveState(tmpBase); + try { + const draftFile = resolveMilestoneFile(tmpBase, "M001", "CONTEXT-DRAFT"); + assert.ok(draftFile !== null && draftFile !== undefined); + assert.ok(draftFile!.endsWith("M001-CONTEXT-DRAFT.md")); + } finally { + rmSync(tmpBase, { recursive: true, force: true }); + } + }); -assert( - state.phase === "needs-discussion", - `phase should be 'needs-discussion' for draft-only milestone, got: "${state.phase}"`, -); + it("resolveMilestoneFile does NOT resolve CONTEXT when only CONTEXT-DRAFT exists", async () => { + const tmpBase = mkdtempSync(join(tmpdir(), "sf-smart-entry-draft-test3-")); + const sf = join(tmpBase, ".sf"); -assert( - state.activeMilestone?.id === "M001", - `active milestone should be M001, got: "${state.activeMilestone?.id}"`, -); + const { mkdirSync, writeFileSync } = await import("node:fs"); + mkdirSync(join(sf, "milestones", "M001"), { recursive: true }); + writeFileSync( + join(sf, "milestones", "M001", "M001-CONTEXT-DRAFT.md"), + "# Draft", + ); -// ─── Test: resolveMilestoneFile resolves CONTEXT-DRAFT ───────────────────── + try { + const contextFile = resolveMilestoneFile(tmpBase, "M001", "CONTEXT"); + assert.ok( + contextFile === null || contextFile === undefined, + `got: "${contextFile}"`, + ); + } finally { + rmSync(tmpBase, { recursive: true, force: true }); + } + }); -const draftFile = resolveMilestoneFile(tmpBase, "M001", "CONTEXT-DRAFT"); + it("guided-flow.ts has needs-discussion branch", () => { + const guidedFlowSource = readFileSync( + join(import.meta.dirname, "..", "guided-flow.ts"), + "utf-8", + ); -assert( - draftFile !== null && draftFile !== undefined, - `resolveMilestoneFile should resolve CONTEXT-DRAFT, got: ${draftFile}`, -); + assert.ok( + guidedFlowSource.includes('state.phase === "needs-discussion"'), + ); -assert( - draftFile!.endsWith("M001-CONTEXT-DRAFT.md"), - `resolved path should end with M001-CONTEXT-DRAFT.md, got: "${draftFile}"`, -); + const branchIdx = guidedFlowSource.indexOf( + 'state.phase === "needs-discussion"', + ); + const branchChunk = guidedFlowSource.slice(branchIdx, branchIdx + 4000); -// ─── Test: CONTEXT.md is NOT resolved (only draft exists) ────────────────── - -const contextFile = resolveMilestoneFile(tmpBase, "M001", "CONTEXT"); - -assert( - contextFile === null || contextFile === undefined, - `resolveMilestoneFile should NOT resolve CONTEXT when only CONTEXT-DRAFT exists, got: "${contextFile}"`, -); - -// ─── Static: guided-flow.ts has 'needs-discussion' branch ───────────────── - -const guidedFlowSource = readFileSync( - join(import.meta.dirname, "..", "guided-flow.ts"), - "utf-8", -); - -assert( - guidedFlowSource.includes('state.phase === "needs-discussion"'), - "guided-flow.ts should have 'needs-discussion' phase check in showWorkflowEntry", -); - -// Check the branch has draft-aware menu options -const branchIdx = guidedFlowSource.indexOf( - 'state.phase === "needs-discussion"', -); -const branchChunk = guidedFlowSource.slice(branchIdx, branchIdx + 4000); - -assert( - branchChunk.includes("discuss_draft"), - "needs-discussion branch should have 'discuss_draft' option", -); - -assert( - branchChunk.includes("discuss_fresh"), - "needs-discussion branch should have 'discuss_fresh' option", -); - -assert( - branchChunk.includes("skip_milestone"), - "needs-discussion branch should have 'skip_milestone' option", -); - -assert( - branchChunk.includes("CONTEXT-DRAFT"), - "needs-discussion branch should load CONTEXT-DRAFT via resolveMilestoneFile", -); - -assert( - branchChunk.includes("Draft Seed") || branchChunk.includes("draftContent"), - "discuss_draft path should include draft content as seed in the dispatched prompt", -); - -assert( - branchChunk.includes("return"), - "needs-discussion branch should return early (not fall through to generic no-roadmap menu)", -); - -// ─── Cleanup ────────────────────────────────────────────────────────────── - -rmSync(tmpBase, { recursive: true, force: true }); - -// ─── Results ────────────────────────────────────────────────────────────── - -console.log(`\nsmart-entry-draft: ${passed} passed, ${failed} failed`); -if (failed > 0) process.exit(1); + assert.ok(branchChunk.includes("discuss_draft")); + assert.ok(branchChunk.includes("discuss_fresh")); + assert.ok(branchChunk.includes("skip_milestone")); + assert.ok(branchChunk.includes("CONTEXT-DRAFT")); + assert.ok( + branchChunk.includes("Draft Seed") || + branchChunk.includes("draftContent"), + ); + assert.ok(branchChunk.includes("return")); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 4f5bfedfd..56dc3fa39 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,11 +30,9 @@ export default defineConfig({ "src/resources/extensions/sf/tests/phases-merge-error-stops-auto.test.ts", "src/resources/extensions/sf/tests/auto-start-cold-db-bootstrap.test.ts", "src/resources/extensions/sf/tests/dashboard-model-label-ordering.test.ts", - "src/resources/extensions/sf/tests/complete-slice.test.ts", "src/resources/extensions/sf/tests/session-lock-transient-read.test.ts", "src/resources/extensions/sf/tests/quality-gates.test.ts", "src/resources/extensions/sf/tests/summary-render-parity.test.ts", - "src/resources/extensions/sf/tests/smart-entry-draft.test.ts", "src/resources/extensions/sf/tests/tool-call-loop-guard.test.ts", "src/resources/extensions/sf/tests/visualizer-data.test.ts", "src/resources/extensions/sf/tests/worktree-nested-git-safety.test.ts",