diff --git a/src/resources/extensions/gsd/tests/auto-dashboard.test.ts b/src/resources/extensions/gsd/tests/auto-dashboard.test.ts new file mode 100644 index 000000000..614ecc8a3 --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-dashboard.test.ts @@ -0,0 +1,153 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + unitVerb, + unitPhaseLabel, + describeNextUnit, + formatAutoElapsed, + formatWidgetTokens, +} from "../auto-dashboard.ts"; + +// ─── unitVerb ───────────────────────────────────────────────────────────── + +test("unitVerb maps known unit types to verbs", () => { + assert.equal(unitVerb("research-milestone"), "researching"); + assert.equal(unitVerb("research-slice"), "researching"); + assert.equal(unitVerb("plan-milestone"), "planning"); + assert.equal(unitVerb("plan-slice"), "planning"); + assert.equal(unitVerb("execute-task"), "executing"); + assert.equal(unitVerb("complete-slice"), "completing"); + assert.equal(unitVerb("replan-slice"), "replanning"); + assert.equal(unitVerb("reassess-roadmap"), "reassessing"); + assert.equal(unitVerb("run-uat"), "running UAT"); +}); + +test("unitVerb returns raw type for unknown types", () => { + assert.equal(unitVerb("custom-thing"), "custom-thing"); +}); + +test("unitVerb handles hook types", () => { + assert.equal(unitVerb("hook/verify-code"), "hook: verify-code"); + assert.equal(unitVerb("hook/"), "hook: "); +}); + +// ─── unitPhaseLabel ─────────────────────────────────────────────────────── + +test("unitPhaseLabel maps known types to labels", () => { + assert.equal(unitPhaseLabel("research-milestone"), "RESEARCH"); + assert.equal(unitPhaseLabel("research-slice"), "RESEARCH"); + assert.equal(unitPhaseLabel("plan-milestone"), "PLAN"); + assert.equal(unitPhaseLabel("plan-slice"), "PLAN"); + assert.equal(unitPhaseLabel("execute-task"), "EXECUTE"); + assert.equal(unitPhaseLabel("complete-slice"), "COMPLETE"); + assert.equal(unitPhaseLabel("replan-slice"), "REPLAN"); + assert.equal(unitPhaseLabel("reassess-roadmap"), "REASSESS"); + assert.equal(unitPhaseLabel("run-uat"), "UAT"); +}); + +test("unitPhaseLabel uppercases unknown types", () => { + assert.equal(unitPhaseLabel("custom-thing"), "CUSTOM-THING"); +}); + +test("unitPhaseLabel returns HOOK for hook types", () => { + assert.equal(unitPhaseLabel("hook/verify"), "HOOK"); +}); + +// ─── describeNextUnit ───────────────────────────────────────────────────── + +test("describeNextUnit handles pre-planning phase", () => { + const result = describeNextUnit({ + phase: "pre-planning", + activeMilestone: { id: "M001", title: "Test" }, + } as any); + assert.equal(result.label, "Research & plan milestone"); +}); + +test("describeNextUnit handles executing phase", () => { + const result = describeNextUnit({ + phase: "executing", + activeMilestone: { id: "M001", title: "Test" }, + activeSlice: { id: "S01", title: "Slice" }, + activeTask: { id: "T01", title: "Task One" }, + } as any); + assert.ok(result.label.includes("T01")); + assert.ok(result.label.includes("Task One")); +}); + +test("describeNextUnit handles summarizing phase", () => { + const result = describeNextUnit({ + phase: "summarizing", + activeMilestone: { id: "M001", title: "Test" }, + activeSlice: { id: "S01", title: "First Slice" }, + } as any); + assert.ok(result.label.includes("S01")); +}); + +test("describeNextUnit handles needs-discussion phase", () => { + const result = describeNextUnit({ + phase: "needs-discussion", + activeMilestone: { id: "M001", title: "Test" }, + } as any); + assert.ok( + result.label.toLowerCase().includes("discuss") || result.label.toLowerCase().includes("draft"), + ); +}); + +test("describeNextUnit handles completing-milestone phase", () => { + const result = describeNextUnit({ + phase: "completing-milestone", + activeMilestone: { id: "M001", title: "Test" }, + } as any); + assert.ok(result.label.toLowerCase().includes("milestone")); +}); + +test("describeNextUnit returns fallback for unknown phase", () => { + const result = describeNextUnit({ + phase: "some-future-phase" as any, + activeMilestone: { id: "M001", title: "Test" }, + } as any); + assert.equal(result.label, "Continue"); +}); + +// ─── formatAutoElapsed ──────────────────────────────────────────────────── + +test("formatAutoElapsed returns empty for zero startTime", () => { + assert.equal(formatAutoElapsed(0), ""); +}); + +test("formatAutoElapsed formats seconds", () => { + const result = formatAutoElapsed(Date.now() - 30_000); + assert.match(result, /^\d+s$/); +}); + +test("formatAutoElapsed formats minutes", () => { + const result = formatAutoElapsed(Date.now() - 180_000); // 3 min + assert.match(result, /^3m/); +}); + +test("formatAutoElapsed formats hours", () => { + const result = formatAutoElapsed(Date.now() - 3_700_000); // ~1h + assert.match(result, /^1h/); +}); + +// ─── formatWidgetTokens ────────────────────────────────────────────────── + +test("formatWidgetTokens formats small numbers directly", () => { + assert.equal(formatWidgetTokens(0), "0"); + assert.equal(formatWidgetTokens(500), "500"); + assert.equal(formatWidgetTokens(999), "999"); +}); + +test("formatWidgetTokens formats thousands with k", () => { + assert.equal(formatWidgetTokens(1000), "1.0k"); + assert.equal(formatWidgetTokens(5500), "5.5k"); + assert.equal(formatWidgetTokens(10000), "10k"); + assert.equal(formatWidgetTokens(99999), "100k"); +}); + +test("formatWidgetTokens formats millions with M", () => { + assert.equal(formatWidgetTokens(1_000_000), "1.0M"); + assert.equal(formatWidgetTokens(10_000_000), "10M"); + assert.equal(formatWidgetTokens(25_000_000), "25M"); +}); diff --git a/src/resources/extensions/gsd/tests/auto-recovery.test.ts b/src/resources/extensions/gsd/tests/auto-recovery.test.ts new file mode 100644 index 000000000..c0a5b7478 --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-recovery.test.ts @@ -0,0 +1,272 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; + +import { + resolveExpectedArtifactPath, + diagnoseExpectedArtifact, + buildLoopRemediationSteps, + completedKeysPath, + persistCompletedKey, + removePersistedKey, + loadPersistedKeys, +} from "../auto-recovery.ts"; + +function makeTmpBase(): string { + const base = join(tmpdir(), `gsd-test-${randomUUID()}`); + // Create .gsd/milestones/M001/slices/S01/tasks/ structure + mkdirSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks"), { recursive: true }); + return base; +} + +function cleanup(base: string): void { + try { rmSync(base, { recursive: true, force: true }); } catch { /* */ } +} + +// ─── resolveExpectedArtifactPath ────────────────────────────────────────── + +test("resolveExpectedArtifactPath returns correct path for research-milestone", () => { + const base = makeTmpBase(); + try { + const result = resolveExpectedArtifactPath("research-milestone", "M001", base); + assert.ok(result); + assert.ok(result!.includes("M001")); + assert.ok(result!.includes("RESEARCH")); + } finally { + cleanup(base); + } +}); + +test("resolveExpectedArtifactPath returns correct path for execute-task", () => { + const base = makeTmpBase(); + try { + const result = resolveExpectedArtifactPath("execute-task", "M001/S01/T01", base); + assert.ok(result); + assert.ok(result!.includes("tasks")); + assert.ok(result!.includes("SUMMARY")); + } finally { + cleanup(base); + } +}); + +test("resolveExpectedArtifactPath returns correct path for complete-slice", () => { + const base = makeTmpBase(); + try { + const result = resolveExpectedArtifactPath("complete-slice", "M001/S01", base); + assert.ok(result); + assert.ok(result!.includes("SUMMARY")); + } finally { + cleanup(base); + } +}); + +test("resolveExpectedArtifactPath returns correct path for plan-slice", () => { + const base = makeTmpBase(); + try { + const result = resolveExpectedArtifactPath("plan-slice", "M001/S01", base); + assert.ok(result); + assert.ok(result!.includes("PLAN")); + } finally { + cleanup(base); + } +}); + +test("resolveExpectedArtifactPath returns null for unknown type", () => { + const base = makeTmpBase(); + try { + const result = resolveExpectedArtifactPath("unknown-type", "M001", base); + assert.equal(result, null); + } finally { + cleanup(base); + } +}); + +test("resolveExpectedArtifactPath returns correct path for all milestone-level types", () => { + const base = makeTmpBase(); + try { + const planResult = resolveExpectedArtifactPath("plan-milestone", "M001", base); + assert.ok(planResult); + assert.ok(planResult!.includes("ROADMAP")); + + const completeResult = resolveExpectedArtifactPath("complete-milestone", "M001", base); + assert.ok(completeResult); + assert.ok(completeResult!.includes("SUMMARY")); + } finally { + cleanup(base); + } +}); + +test("resolveExpectedArtifactPath returns correct path for all slice-level types", () => { + const base = makeTmpBase(); + try { + const researchResult = resolveExpectedArtifactPath("research-slice", "M001/S01", base); + assert.ok(researchResult); + assert.ok(researchResult!.includes("RESEARCH")); + + const assessResult = resolveExpectedArtifactPath("reassess-roadmap", "M001/S01", base); + assert.ok(assessResult); + assert.ok(assessResult!.includes("ASSESSMENT")); + + const uatResult = resolveExpectedArtifactPath("run-uat", "M001/S01", base); + assert.ok(uatResult); + assert.ok(uatResult!.includes("UAT-RESULT")); + } finally { + cleanup(base); + } +}); + +// ─── diagnoseExpectedArtifact ───────────────────────────────────────────── + +test("diagnoseExpectedArtifact returns description for known types", () => { + const base = makeTmpBase(); + try { + const research = diagnoseExpectedArtifact("research-milestone", "M001", base); + assert.ok(research); + assert.ok(research!.includes("research")); + + const plan = diagnoseExpectedArtifact("plan-slice", "M001/S01", base); + assert.ok(plan); + assert.ok(plan!.includes("plan")); + + const task = diagnoseExpectedArtifact("execute-task", "M001/S01/T01", base); + assert.ok(task); + assert.ok(task!.includes("T01")); + } finally { + cleanup(base); + } +}); + +test("diagnoseExpectedArtifact returns null for unknown type", () => { + const base = makeTmpBase(); + try { + assert.equal(diagnoseExpectedArtifact("unknown", "M001", base), null); + } finally { + cleanup(base); + } +}); + +// ─── buildLoopRemediationSteps ──────────────────────────────────────────── + +test("buildLoopRemediationSteps returns steps for execute-task", () => { + const base = makeTmpBase(); + try { + const steps = buildLoopRemediationSteps("execute-task", "M001/S01/T01", base); + assert.ok(steps); + assert.ok(steps!.includes("T01")); + assert.ok(steps!.includes("gsd doctor")); + assert.ok(steps!.includes("[x]")); + } finally { + cleanup(base); + } +}); + +test("buildLoopRemediationSteps returns steps for plan-slice", () => { + const base = makeTmpBase(); + try { + const steps = buildLoopRemediationSteps("plan-slice", "M001/S01", base); + assert.ok(steps); + assert.ok(steps!.includes("PLAN")); + assert.ok(steps!.includes("gsd doctor")); + } finally { + cleanup(base); + } +}); + +test("buildLoopRemediationSteps returns steps for complete-slice", () => { + const base = makeTmpBase(); + try { + const steps = buildLoopRemediationSteps("complete-slice", "M001/S01", base); + assert.ok(steps); + assert.ok(steps!.includes("S01")); + assert.ok(steps!.includes("ROADMAP")); + } finally { + cleanup(base); + } +}); + +test("buildLoopRemediationSteps returns null for unknown type", () => { + const base = makeTmpBase(); + try { + assert.equal(buildLoopRemediationSteps("unknown", "M001", base), null); + } finally { + cleanup(base); + } +}); + +// ─── Completed-unit key persistence ─────────────────────────────────────── + +test("completedKeysPath returns path inside .gsd", () => { + const path = completedKeysPath("/project"); + assert.ok(path.includes(".gsd")); + assert.ok(path.includes("completed-units.json")); +}); + +test("persistCompletedKey and loadPersistedKeys round-trip", () => { + const base = makeTmpBase(); + try { + persistCompletedKey(base, "execute-task/M001/S01/T01"); + persistCompletedKey(base, "plan-slice/M001/S02"); + + const keys = new Set(); + loadPersistedKeys(base, keys); + + assert.ok(keys.has("execute-task/M001/S01/T01")); + assert.ok(keys.has("plan-slice/M001/S02")); + assert.equal(keys.size, 2); + } finally { + cleanup(base); + } +}); + +test("persistCompletedKey is idempotent", () => { + const base = makeTmpBase(); + try { + persistCompletedKey(base, "execute-task/M001/S01/T01"); + persistCompletedKey(base, "execute-task/M001/S01/T01"); + + const keys = new Set(); + loadPersistedKeys(base, keys); + assert.equal(keys.size, 1); + } finally { + cleanup(base); + } +}); + +test("removePersistedKey removes a key", () => { + const base = makeTmpBase(); + try { + persistCompletedKey(base, "a"); + persistCompletedKey(base, "b"); + removePersistedKey(base, "a"); + + const keys = new Set(); + loadPersistedKeys(base, keys); + assert.ok(!keys.has("a")); + assert.ok(keys.has("b")); + } finally { + cleanup(base); + } +}); + +test("loadPersistedKeys handles missing file gracefully", () => { + const base = makeTmpBase(); + try { + const keys = new Set(); + assert.doesNotThrow(() => loadPersistedKeys(base, keys)); + assert.equal(keys.size, 0); + } finally { + cleanup(base); + } +}); + +test("removePersistedKey is safe when file doesn't exist", () => { + const base = makeTmpBase(); + try { + assert.doesNotThrow(() => removePersistedKey(base, "nonexistent")); + } finally { + cleanup(base); + } +}); diff --git a/src/resources/extensions/gsd/tests/crash-recovery.test.ts b/src/resources/extensions/gsd/tests/crash-recovery.test.ts new file mode 100644 index 000000000..bce69cc7a --- /dev/null +++ b/src/resources/extensions/gsd/tests/crash-recovery.test.ts @@ -0,0 +1,134 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, existsSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; + +import { + writeLock, + clearLock, + readCrashLock, + isLockProcessAlive, + formatCrashInfo, + type LockData, +} from "../crash-recovery.ts"; + +function makeTmpBase(): string { + const base = join(tmpdir(), `gsd-test-${randomUUID()}`); + mkdirSync(join(base, ".gsd"), { recursive: true }); + return base; +} + +function cleanup(base: string): void { + try { rmSync(base, { recursive: true, force: true }); } catch { /* */ } +} + +// ─── writeLock / readCrashLock ──────────────────────────────────────────── + +test("writeLock creates lock file and readCrashLock reads it", () => { + const base = makeTmpBase(); + try { + writeLock(base, "execute-task", "M001/S01/T01", 3, "/tmp/session.jsonl"); + const lock = readCrashLock(base); + assert.ok(lock, "lock should exist"); + assert.equal(lock!.unitType, "execute-task"); + assert.equal(lock!.unitId, "M001/S01/T01"); + assert.equal(lock!.completedUnits, 3); + assert.equal(lock!.sessionFile, "/tmp/session.jsonl"); + assert.equal(lock!.pid, process.pid); + } finally { + cleanup(base); + } +}); + +test("readCrashLock returns null when no lock exists", () => { + const base = makeTmpBase(); + try { + const lock = readCrashLock(base); + assert.equal(lock, null); + } finally { + cleanup(base); + } +}); + +// ─── clearLock ──────────────────────────────────────────────────────────── + +test("clearLock removes existing lock file", () => { + const base = makeTmpBase(); + try { + writeLock(base, "plan-slice", "M001/S01", 0); + assert.ok(readCrashLock(base), "lock should exist before clear"); + clearLock(base); + assert.equal(readCrashLock(base), null, "lock should be gone after clear"); + } finally { + cleanup(base); + } +}); + +test("clearLock is safe when no lock exists", () => { + const base = makeTmpBase(); + try { + assert.doesNotThrow(() => clearLock(base)); + } finally { + cleanup(base); + } +}); + +// ─── isLockProcessAlive ────────────────────────────────────────────────── + +test("isLockProcessAlive returns true for current process (different pid)", () => { + // Our own PID is explicitly excluded (recycled PID guard) + const lock: LockData = { + pid: process.pid, + startedAt: new Date().toISOString(), + unitType: "execute-task", + unitId: "M001/S01/T01", + unitStartedAt: new Date().toISOString(), + completedUnits: 0, + }; + assert.equal(isLockProcessAlive(lock), false, "own PID should return false"); +}); + +test("isLockProcessAlive returns false for dead PID", () => { + const lock: LockData = { + pid: 999999999, // almost certainly not running + startedAt: new Date().toISOString(), + unitType: "execute-task", + unitId: "M001/S01/T01", + unitStartedAt: new Date().toISOString(), + completedUnits: 0, + }; + assert.equal(isLockProcessAlive(lock), false); +}); + +test("isLockProcessAlive returns false for invalid PIDs", () => { + const base: Omit = { + startedAt: new Date().toISOString(), + unitType: "x", + unitId: "x", + unitStartedAt: new Date().toISOString(), + completedUnits: 0, + }; + assert.equal(isLockProcessAlive({ ...base, pid: 0 } as LockData), false); + assert.equal(isLockProcessAlive({ ...base, pid: -1 } as LockData), false); + assert.equal(isLockProcessAlive({ ...base, pid: 1.5 } as LockData), false); +}); + +// ─── formatCrashInfo ───────────────────────────────────────────────────── + +test("formatCrashInfo includes unit type, id, and PID", () => { + const lock: LockData = { + pid: 12345, + startedAt: "2025-01-01T00:00:00.000Z", + unitType: "complete-slice", + unitId: "M002/S03", + unitStartedAt: "2025-01-01T00:01:00.000Z", + completedUnits: 7, + }; + const info = formatCrashInfo(lock); + assert.ok(info.includes("complete-slice")); + assert.ok(info.includes("M002/S03")); + assert.ok(info.includes("12345")); + assert.ok(info.includes("7")); +});