test: add unit tests for auto-dashboard, auto-recovery, crash-recovery (#526)

46 new tests covering 3 previously untested modules:

- auto-dashboard.test.ts (18 tests): unitVerb, unitPhaseLabel,
  describeNextUnit phase mapping, formatAutoElapsed, formatWidgetTokens
- crash-recovery.test.ts (10 tests): writeLock/readCrashLock round-trip,
  clearLock, isLockProcessAlive (own PID, dead PID, invalid PIDs),
  formatCrashInfo
- auto-recovery.test.ts (18 tests): resolveExpectedArtifactPath for all
  unit types, diagnoseExpectedArtifact, buildLoopRemediationSteps,
  completed-unit key persistence (persist, load, remove, idempotency)

Total test count: 123 → 169

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson 2026-03-15 17:25:56 -06:00
parent c09dcfc380
commit f70ddea074
3 changed files with 559 additions and 0 deletions

View file

@ -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");
});

View file

@ -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<string>();
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<string>();
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<string>();
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<string>();
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);
}
});

View file

@ -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<LockData, "pid"> = {
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"));
});