singularity-forge/src/resources/extensions/sf/tests/draft-promotion.test.ts
Mikael Hugo 302888e3d3 chore: test fixes, dep updates, lockfile sync
💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-05-02 06:20:44 +02:00

186 lines
No EOL
6 KiB
TypeScript

import {
import { test } from "vitest";
test("draft promotion.test", () => {
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { invalidateAllCaches } from "../cache.js";
import { resolveMilestoneFile } from "../paths.js";
import { deriveState } from "../state.js";
let passed = 0;
let failed = 0;
function assert(condition: boolean, message: string): void {
if (condition) {
passed++;
} else {
failed++;
console.error(` FAIL: ${message}`);
}
}
// ─── Full state transition: needs-discussion → pre-planning ─────────────
console.log("=== Draft promotion: full state transition ===");
const tmpBase = mkdtempSync(join(tmpdir(), "sf-draft-promotion-test-"));
const sf = join(tmpBase, ".sf");
mkdirSync(join(sf, "milestones", "M001"), { recursive: true });
// Step 1: Create CONTEXT-DRAFT.md only → needs-discussion
const draftPath = join(sf, "milestones", "M001", "M001-CONTEXT-DRAFT.md");
writeFileSync(draftPath, "# M001: Draft\n\nSeed material.\n");
const state1 = await deriveState(tmpBase);
assert(
state1.phase === "needs-discussion",
`draft-only should be 'needs-discussion', got: "${state1.phase}"`,
);
// Step 2: Write CONTEXT.md (simulating discussion output) → pre-planning
const contextPath = join(sf, "milestones", "M001", "M001-CONTEXT.md");
writeFileSync(contextPath, "# M001: Full Context\n\nDeep discussion output.\n");
invalidateAllCaches();
const state2 = await deriveState(tmpBase);
assert(
state2.phase === "pre-planning",
`after CONTEXT.md written, should be 'pre-planning', got: "${state2.phase}"`,
);
// Step 3: Simulate draft cleanup (what checkAutoStartAfterDiscuss does)
const resolvedDraft = resolveMilestoneFile(tmpBase, "M001", "CONTEXT-DRAFT");
assert(
resolvedDraft !== null && resolvedDraft !== undefined,
"CONTEXT-DRAFT.md should still exist before cleanup",
);
// Delete the draft (simulating the cleanup in checkAutoStartAfterDiscuss)
const { unlinkSync } = await import("node:fs");
try {
if (resolvedDraft) unlinkSync(resolvedDraft);
} catch {
/* non-fatal */
}
assert(
!existsSync(draftPath),
"CONTEXT-DRAFT.md should be deleted after promotion cleanup",
);
// Step 4: After cleanup, state is still pre-planning (CONTEXT.md exists)
invalidateAllCaches();
const state3 = await deriveState(tmpBase);
assert(
state3.phase === "pre-planning",
`after cleanup, should still be 'pre-planning', got: "${state3.phase}"`,
);
// ─── No-draft case: cleanup is a no-op ──────────────────────────────────
console.log("=== No-draft cleanup: no-op ===");
const tmpBase2 = mkdtempSync(join(tmpdir(), "sf-draft-promotion-noop-"));
const sf2 = join(tmpBase2, ".sf");
mkdirSync(join(sf2, "milestones", "M001"), { recursive: true });
writeFileSync(
join(sf2, "milestones", "M001", "M001-CONTEXT.md"),
"# M001: Normal\n\nStandard discussion output.\n",
);
// No CONTEXT-DRAFT.md exists — cleanup should be a no-op
const noDraft = resolveMilestoneFile(tmpBase2, "M001", "CONTEXT-DRAFT");
assert(
noDraft === null || noDraft === undefined,
"no CONTEXT-DRAFT.md should exist for standard discussion milestone",
);
// deriveState should return pre-planning normally
const state4 = await deriveState(tmpBase2);
assert(
state4.phase === "pre-planning",
`standard discussion milestone should be 'pre-planning', got: "${state4.phase}"`,
);
// ─── Both files exist → CONTEXT.md wins, draft cleanup works ───────────
console.log("=== Both files: CONTEXT wins, draft cleanable ===");
const tmpBase3 = mkdtempSync(join(tmpdir(), "sf-draft-promotion-both-"));
const sf3 = join(tmpBase3, ".sf");
mkdirSync(join(sf3, "milestones", "M001"), { recursive: true });
writeFileSync(
join(sf3, "milestones", "M001", "M001-CONTEXT.md"),
"# M001: Full\n\nFull context.\n",
);
const bothDraftPath = join(sf3, "milestones", "M001", "M001-CONTEXT-DRAFT.md");
writeFileSync(bothDraftPath, "# M001: Draft\n\nStale draft.\n");
const state5 = await deriveState(tmpBase3);
assert(
state5.phase === "pre-planning",
`both files: CONTEXT.md wins, should be 'pre-planning', got: "${state5.phase}"`,
);
// Cleanup the stale draft
const bothDraft = resolveMilestoneFile(tmpBase3, "M001", "CONTEXT-DRAFT");
try {
if (bothDraft) unlinkSync(bothDraft);
} catch {
/* non-fatal */
}
assert(
!existsSync(bothDraftPath),
"stale CONTEXT-DRAFT.md should be deleted in both-files case",
);
// ─── Static: guided-flow.ts has cleanup code ───────────────────────────
console.log("=== Static: cleanup code in guided-flow.ts ===");
const { readFileSync } = await import("node:fs");
const guidedFlowSource = readFileSync(
join(import.meta.dirname, "..", "guided-flow.ts"),
"utf-8",
);
const checkFnIdx = guidedFlowSource.indexOf("checkAutoStartAfterDiscuss");
const checkFnEnd = guidedFlowSource.indexOf("\nexport ", checkFnIdx + 1);
const checkFnChunk = guidedFlowSource.slice(
checkFnIdx,
checkFnEnd > checkFnIdx ? checkFnEnd : checkFnIdx + 5000,
);
assert(
checkFnChunk.includes("CONTEXT-DRAFT"),
"checkAutoStartAfterDiscuss should reference CONTEXT-DRAFT for cleanup",
);
assert(
checkFnChunk.includes("unlinkSync"),
"checkAutoStartAfterDiscuss should use unlinkSync to delete the draft",
);
// ─── Cleanup ──────────────────────────────────────────────────────────
rmSync(tmpBase, { recursive: true, force: true });
rmSync(tmpBase2, { recursive: true, force: true });
rmSync(tmpBase3, { recursive: true, force: true });
// ─── Results ──────────────────────────────────────────────────────────
console.log(`\ndraft-promotion: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
});