test: remove debug logs, fix loop.ts logging, and enable converted vitest tests
This commit is contained in:
parent
3ddb8c84e0
commit
0682fbc32a
4 changed files with 331 additions and 642 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue