test: replace try/finally cleanup with beforeEach/afterEach hooks in 6 test files (#2234)

Move temp directory creation and cleanup from try/finally blocks inside
test bodies into beforeEach/afterEach hooks on describe blocks. For tests
that also save/restore env vars (manifest-status), those are handled in
the hooks as well. Tests that don't need cleanup (pure assertions, no
temp dirs) remain as standalone test() calls.

Closes #2064

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-23 09:03:48 -06:00 committed by GitHub
parent d63d11b86a
commit 6c876db69a
6 changed files with 473 additions and 708 deletions

View file

@ -4,7 +4,7 @@
* - activity-log-save.test.ts (caching, dedup, collision recovery)
*/
import test from "node:test";
import { describe, test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, mkdirSync, readdirSync, realpathSync, rmSync, utimesSync, writeFileSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
@ -48,9 +48,12 @@ function createCtx(entries: unknown[]) {
// ── Pruning ──────────────────────────────────────────────────────────────────
test("pruneActivityLogs deletes old files, keeps recent and highest-seq", () => {
const dir = createTmpDir();
try {
describe("pruneActivityLogs", () => {
let dir: string;
beforeEach(() => { dir = createTmpDir(); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
test("deletes old files, keeps recent and highest-seq", () => {
const f001 = writeActivityFile(dir, "001", "execute-task-M001-S01-T01");
writeActivityFile(dir, "002", "execute-task-M001-S01-T02");
writeActivityFile(dir, "003", "execute-task-M001-S01-T03");
@ -61,14 +64,9 @@ test("pruneActivityLogs deletes old files, keeps recent and highest-seq", () =>
assert.ok(!remaining.includes("001-execute-task-M001-S01-T01.jsonl"));
assert.ok(remaining.includes("002-execute-task-M001-S01-T02.jsonl"));
assert.ok(remaining.includes("003-execute-task-M001-S01-T03.jsonl"));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("pruneActivityLogs preserves highest-seq even when all files are old", () => {
const dir = createTmpDir();
try {
test("preserves highest-seq even when all files are old", () => {
const f001 = writeActivityFile(dir, "001", "t1");
const f002 = writeActivityFile(dir, "002", "t2");
const f003 = writeActivityFile(dir, "003", "t3");
@ -78,14 +76,9 @@ test("pruneActivityLogs preserves highest-seq even when all files are old", () =
const remaining = listFiles(dir);
assert.equal(remaining.length, 1);
assert.ok(remaining[0].startsWith("003-"));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("pruneActivityLogs with retentionDays=0 keeps only highest-seq", () => {
const dir = createTmpDir();
try {
test("with retentionDays=0 keeps only highest-seq", () => {
writeActivityFile(dir, "001", "t1");
writeActivityFile(dir, "002", "t2");
writeActivityFile(dir, "003", "t3");
@ -94,51 +87,31 @@ test("pruneActivityLogs with retentionDays=0 keeps only highest-seq", () => {
const remaining = listFiles(dir);
assert.equal(remaining.length, 1);
assert.ok(remaining[0].startsWith("003-"));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("pruneActivityLogs no-op when all files are recent", () => {
const dir = createTmpDir();
try {
test("no-op when all files are recent", () => {
writeActivityFile(dir, "001", "t1");
writeActivityFile(dir, "002", "t2");
writeActivityFile(dir, "003", "t3");
pruneActivityLogs(dir, 30);
assert.equal(listFiles(dir).length, 3);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("pruneActivityLogs handles empty directory", () => {
const dir = createTmpDir();
try {
test("handles empty directory", () => {
assert.doesNotThrow(() => pruneActivityLogs(dir, 30));
assert.equal(readdirSync(dir).length, 0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("pruneActivityLogs preserves single old file (it is highest-seq)", () => {
const dir = createTmpDir();
try {
test("preserves single old file (it is highest-seq)", () => {
const f = writeActivityFile(dir, "001", "t1");
backdateFile(f, 100);
pruneActivityLogs(dir, 30);
assert.equal(listFiles(dir).length, 1);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("pruneActivityLogs ignores non-matching filenames", () => {
const dir = createTmpDir();
try {
test("ignores non-matching filenames", () => {
const f001 = writeActivityFile(dir, "001", "t1");
writeFileSync(join(dir, "notes.txt"), "some notes\n", "utf-8");
backdateFile(f001, 40);
@ -148,16 +121,17 @@ test("pruneActivityLogs ignores non-matching filenames", () => {
assert.ok(remaining.includes("notes.txt"));
// 001 is the only seq file, so it's highest-seq and survives
assert.ok(remaining.includes("001-t1.jsonl"));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
// ── Save: caching, dedup, collision recovery ─────────────────────────────────
test("saveActivityLog caches sequence instead of rescanning", () => {
const baseDir = createTmpDir();
try {
describe("saveActivityLog", () => {
let baseDir: string;
beforeEach(() => { baseDir = createTmpDir(); });
afterEach(() => { rmSync(baseDir, { recursive: true, force: true }); });
test("caches sequence instead of rescanning", () => {
saveActivityLog(createCtx([{ kind: "first", n: 1 }]) as any, baseDir, "execute-task", "M001/S01/T01");
writeFileSync(join(activityDir(baseDir), "999-external.jsonl"), '{"x":1}\n', "utf-8");
saveActivityLog(createCtx([{ kind: "second", n: 2 }]) as any, baseDir, "execute-task", "M001/S01/T02");
@ -166,14 +140,9 @@ test("saveActivityLog caches sequence instead of rescanning", () => {
assert.ok(files.includes("001-execute-task-M001-S01-T01.jsonl"));
assert.ok(files.includes("002-execute-task-M001-S01-T02.jsonl"));
assert.ok(!files.some(f => f.startsWith("1000-")));
} finally {
rmSync(baseDir, { recursive: true, force: true });
}
});
});
test("saveActivityLog deduplicates identical snapshots for same unit", () => {
const baseDir = createTmpDir();
try {
test("deduplicates identical snapshots for same unit", () => {
const ctx = createCtx([{ role: "assistant", content: "same" }]);
saveActivityLog(ctx as any, baseDir, "plan-slice", "M002/S01");
saveActivityLog(ctx as any, baseDir, "plan-slice", "M002/S01");
@ -184,14 +153,9 @@ test("saveActivityLog deduplicates identical snapshots for same unit", () => {
saveActivityLog(createCtx([{ role: "assistant", content: "changed" }]) as any, baseDir, "plan-slice", "M002/S01");
files = listFiles(activityDir(baseDir));
assert.equal(files.length, 2);
} finally {
rmSync(baseDir, { recursive: true, force: true });
}
});
});
test("saveActivityLog recovers on sequence collision", () => {
const baseDir = createTmpDir();
try {
test("recovers on sequence collision", () => {
saveActivityLog(createCtx([{ turn: 1 }]) as any, baseDir, "execute-task", "M003/S02/T01");
writeFileSync(join(activityDir(baseDir), "002-execute-task-M003-S02-T02.jsonl"), '{"collision":true}\n', "utf-8");
saveActivityLog(createCtx([{ turn: 2 }]) as any, baseDir, "execute-task", "M003/S02/T02");
@ -199,9 +163,7 @@ test("saveActivityLog recovers on sequence collision", () => {
const files = listFiles(activityDir(baseDir));
assert.ok(files.includes("002-execute-task-M003-S02-T02.jsonl"));
assert.ok(files.includes("003-execute-task-M003-S02-T02.jsonl"));
} finally {
rmSync(baseDir, { recursive: true, force: true });
}
});
});
// ── Prompt text assertion ────────────────────────────────────────────────────

View file

@ -1,4 +1,4 @@
import test from "node:test";
import { describe, test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import {
mkdirSync,
@ -46,9 +46,12 @@ function makeEntry(overrides: Partial<JournalEntry> = {}): JournalEntry {
// ─── emitJournalEvent ─────────────────────────────────────────────────────────
test("emitJournalEvent creates journal directory and JSONL file", () => {
const base = makeTmpBase();
try {
describe("emitJournalEvent", () => {
let base: string;
beforeEach(() => { base = makeTmpBase(); });
afterEach(() => { cleanup(base); });
test("creates journal directory and JSONL file", () => {
const entry = makeEntry();
emitJournalEvent(base, entry);
@ -61,14 +64,9 @@ test("emitJournalEvent creates journal directory and JSONL file", () => {
assert.equal(parsed.flowId, entry.flowId);
assert.equal(parsed.seq, entry.seq);
assert.equal(parsed.eventType, entry.eventType);
} finally {
cleanup(base);
}
});
});
test("emitJournalEvent appends multiple lines to the same file", () => {
const base = makeTmpBase();
try {
test("appends multiple lines to the same file", () => {
emitJournalEvent(base, makeEntry({ seq: 0 }));
emitJournalEvent(base, makeEntry({ seq: 1, eventType: "dispatch-match" }));
emitJournalEvent(base, makeEntry({ seq: 2, eventType: "unit-start" }));
@ -82,26 +80,9 @@ test("emitJournalEvent appends multiple lines to the same file", () => {
assert.equal(parsed[1].seq, 1);
assert.equal(parsed[2].seq, 2);
assert.equal(parsed[1].eventType, "dispatch-match");
} finally {
cleanup(base);
}
});
});
test("emitJournalEvent auto-creates nonexistent parent directory", () => {
const base = join(tmpdir(), `gsd-journal-test-${randomUUID()}`);
// Don't create .gsd/ — emitJournalEvent should handle it via mkdirSync recursive
try {
emitJournalEvent(base, makeEntry());
const filePath = join(base, ".gsd", "journal", "2025-03-21.jsonl");
assert.ok(existsSync(filePath), "File should exist even when parent dirs did not");
} finally {
cleanup(base);
}
});
test("emitJournalEvent preserves optional fields (rule, causedBy, data)", () => {
const base = makeTmpBase();
try {
test("preserves optional fields (rule, causedBy, data)", () => {
const entry = makeEntry({
rule: "my-dispatch-rule",
causedBy: { flowId: "flow-prior", seq: 3 },
@ -115,9 +96,42 @@ test("emitJournalEvent preserves optional fields (rule, causedBy, data)", () =>
assert.deepEqual(parsed.causedBy, { flowId: "flow-prior", seq: 3 });
assert.equal(parsed.data.unitId, "M001/S01/T01");
assert.equal(parsed.data.status, "ok");
} finally {
cleanup(base);
}
});
test("silently catches read-only directory errors", () => {
const journalDir = join(base, ".gsd", "journal");
mkdirSync(journalDir, { recursive: true });
// Make the journal directory read-only
chmodSync(journalDir, 0o444);
// Should not throw
assert.doesNotThrow(() => {
emitJournalEvent(base, makeEntry());
});
// Restore permissions for cleanup
try {
chmodSync(journalDir, 0o755);
} catch {
/* */
}
});
});
describe("emitJournalEvent — auto-creates parent directory", () => {
let base: string;
beforeEach(() => {
base = join(tmpdir(), `gsd-journal-test-${randomUUID()}`);
// Don't create .gsd/ — emitJournalEvent should handle it via mkdirSync recursive
});
afterEach(() => { cleanup(base); });
test("auto-creates nonexistent parent directory", () => {
emitJournalEvent(base, makeEntry());
const filePath = join(base, ".gsd", "journal", "2025-03-21.jsonl");
assert.ok(existsSync(filePath), "File should exist even when parent dirs did not");
});
});
test("emitJournalEvent silently catches write errors (no throw)", () => {
@ -127,35 +141,14 @@ test("emitJournalEvent silently catches write errors (no throw)", () => {
});
});
test("emitJournalEvent silently catches read-only directory errors", () => {
const base = makeTmpBase();
const journalDir = join(base, ".gsd", "journal");
mkdirSync(journalDir, { recursive: true });
try {
// Make the journal directory read-only
chmodSync(journalDir, 0o444);
// Should not throw
assert.doesNotThrow(() => {
emitJournalEvent(base, makeEntry());
});
} finally {
// Restore permissions for cleanup
try {
chmodSync(journalDir, 0o755);
} catch {
/* */
}
cleanup(base);
}
});
// ─── Daily Rotation ───────────────────────────────────────────────────────────
test("daily rotation: events with different dates go to different files", () => {
const base = makeTmpBase();
try {
describe("daily rotation", () => {
let base: string;
beforeEach(() => { base = makeTmpBase(); });
afterEach(() => { cleanup(base); });
test("events with different dates go to different files", () => {
emitJournalEvent(base, makeEntry({ ts: "2025-03-20T23:59:59.000Z" }));
emitJournalEvent(base, makeEntry({ ts: "2025-03-21T00:00:01.000Z" }));
emitJournalEvent(base, makeEntry({ ts: "2025-03-22T12:00:00.000Z" }));
@ -172,16 +165,17 @@ test("daily rotation: events with different dates go to different files", () =>
.split("\n");
assert.equal(lines.length, 1, `${date}.jsonl should have 1 line`);
}
} finally {
cleanup(base);
}
});
});
// ─── queryJournal ─────────────────────────────────────────────────────────────
test("queryJournal returns all entries when no filters provided", () => {
const base = makeTmpBase();
try {
describe("queryJournal", () => {
let base: string;
beforeEach(() => { base = makeTmpBase(); });
afterEach(() => { cleanup(base); });
test("returns all entries when no filters provided", () => {
emitJournalEvent(base, makeEntry({ seq: 0 }));
emitJournalEvent(base, makeEntry({ seq: 1, eventType: "dispatch-match" }));
@ -189,14 +183,9 @@ test("queryJournal returns all entries when no filters provided", () => {
assert.equal(results.length, 2);
assert.equal(results[0].seq, 0);
assert.equal(results[1].seq, 1);
} finally {
cleanup(base);
}
});
});
test("queryJournal filters by flowId", () => {
const base = makeTmpBase();
try {
test("filters by flowId", () => {
emitJournalEvent(base, makeEntry({ flowId: "flow-aaa", seq: 0 }));
emitJournalEvent(base, makeEntry({ flowId: "flow-bbb", seq: 1 }));
emitJournalEvent(base, makeEntry({ flowId: "flow-aaa", seq: 2 }));
@ -204,14 +193,9 @@ test("queryJournal filters by flowId", () => {
const results = queryJournal(base, { flowId: "flow-aaa" });
assert.equal(results.length, 2);
assert.ok(results.every(e => e.flowId === "flow-aaa"));
} finally {
cleanup(base);
}
});
});
test("queryJournal filters by eventType", () => {
const base = makeTmpBase();
try {
test("filters by eventType", () => {
emitJournalEvent(base, makeEntry({ eventType: "iteration-start", seq: 0 }));
emitJournalEvent(base, makeEntry({ eventType: "dispatch-match", seq: 1 }));
emitJournalEvent(base, makeEntry({ eventType: "unit-start", seq: 2 }));
@ -220,14 +204,9 @@ test("queryJournal filters by eventType", () => {
const results = queryJournal(base, { eventType: "dispatch-match" });
assert.equal(results.length, 2);
assert.ok(results.every(e => e.eventType === "dispatch-match"));
} finally {
cleanup(base);
}
});
});
test("queryJournal filters by unitId (from data.unitId)", () => {
const base = makeTmpBase();
try {
test("filters by unitId (from data.unitId)", () => {
emitJournalEvent(
base,
makeEntry({ seq: 0, data: { unitId: "M001/S01/T01" } }),
@ -249,14 +228,9 @@ test("queryJournal filters by unitId (from data.unitId)", () => {
e => (e.data as Record<string, unknown>)?.unitId === "M001/S01/T01",
),
);
} finally {
cleanup(base);
}
});
});
test("queryJournal filters by time range (after/before)", () => {
const base = makeTmpBase();
try {
test("filters by time range (after/before)", () => {
emitJournalEvent(base, makeEntry({ ts: "2025-03-20T08:00:00.000Z", seq: 0 }));
emitJournalEvent(base, makeEntry({ ts: "2025-03-21T10:00:00.000Z", seq: 1 }));
emitJournalEvent(base, makeEntry({ ts: "2025-03-21T15:00:00.000Z", seq: 2 }));
@ -276,14 +250,9 @@ test("queryJournal filters by time range (after/before)", () => {
before: "2025-03-21T23:59:59.000Z",
});
assert.equal(rangeResults.length, 2, "2 entries within 2025-03-21");
} finally {
cleanup(base);
}
});
});
test("queryJournal combines multiple filters", () => {
const base = makeTmpBase();
try {
test("combines multiple filters", () => {
emitJournalEvent(
base,
makeEntry({ flowId: "flow-aaa", eventType: "unit-start", seq: 0 }),
@ -304,25 +273,9 @@ test("queryJournal combines multiple filters", () => {
assert.equal(results.length, 1);
assert.equal(results[0].flowId, "flow-aaa");
assert.equal(results[0].eventType, "unit-start");
} finally {
cleanup(base);
}
});
});
test("queryJournal on nonexistent directory returns empty array", () => {
const base = join(tmpdir(), `gsd-journal-test-${randomUUID()}`);
// Don't create anything
try {
const results = queryJournal(base);
assert.deepEqual(results, []);
} finally {
cleanup(base);
}
});
test("queryJournal skips malformed JSON lines gracefully", () => {
const base = makeTmpBase();
try {
test("skips malformed JSON lines gracefully", () => {
const journalDir = join(base, ".gsd", "journal");
mkdirSync(journalDir, { recursive: true });
@ -335,14 +288,9 @@ test("queryJournal skips malformed JSON lines gracefully", () => {
assert.equal(results.length, 2, "Should skip the malformed line");
assert.equal(results[0].seq, 0);
assert.equal(results[1].seq, 1);
} finally {
cleanup(base);
}
});
});
test("queryJournal reads across multiple daily files", () => {
const base = makeTmpBase();
try {
test("reads across multiple daily files", () => {
emitJournalEvent(base, makeEntry({ ts: "2025-03-20T12:00:00.000Z", seq: 0 }));
emitJournalEvent(base, makeEntry({ ts: "2025-03-21T12:00:00.000Z", seq: 1 }));
emitJournalEvent(base, makeEntry({ ts: "2025-03-22T12:00:00.000Z", seq: 2 }));
@ -353,14 +301,9 @@ test("queryJournal reads across multiple daily files", () => {
assert.equal(results[0].ts, "2025-03-20T12:00:00.000Z");
assert.equal(results[1].ts, "2025-03-21T12:00:00.000Z");
assert.equal(results[2].ts, "2025-03-22T12:00:00.000Z");
} finally {
cleanup(base);
}
});
});
test("queryJournal filters by rule", () => {
const base = makeTmpBase();
try {
test("filters by rule", () => {
emitJournalEvent(
base,
makeEntry({ seq: 0, eventType: "dispatch-match", rule: "dispatch-task" }),
@ -380,7 +323,19 @@ test("queryJournal filters by rule", () => {
results.every(e => e.rule === "dispatch-task"),
"All results should have rule === 'dispatch-task'",
);
} finally {
cleanup(base);
}
});
});
describe("queryJournal — nonexistent directory", () => {
let base: string;
beforeEach(() => {
base = join(tmpdir(), `gsd-journal-test-${randomUUID()}`);
// Don't create anything
});
afterEach(() => { cleanup(base); });
test("on nonexistent directory returns empty array", () => {
const results = queryJournal(base);
assert.deepEqual(results, []);
});
});

View file

@ -8,7 +8,7 @@
* Uses temp directories with real .gsd/milestones/M001/ structure.
*/
import test from 'node:test';
import { describe, test, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
@ -30,12 +30,21 @@ function writeManifest(base: string, content: string): void {
// ─── Mixed statuses ──────────────────────────────────────────────────────────
test('getManifestStatus: mixed statuses — categorizes entries correctly', async () => {
const tmp = makeTempDir('manifest-mixed');
const savedVal = process.env.GSD_TEST_EXISTING_KEY_001;
try {
describe('getManifestStatus: mixed statuses', () => {
let tmp: string;
let savedVal: string | undefined;
beforeEach(() => {
tmp = makeTempDir('manifest-mixed');
savedVal = process.env.GSD_TEST_EXISTING_KEY_001;
process.env.GSD_TEST_EXISTING_KEY_001 = 'some-value';
});
afterEach(() => {
delete process.env.GSD_TEST_EXISTING_KEY_001;
if (savedVal !== undefined) process.env.GSD_TEST_EXISTING_KEY_001 = savedVal;
rmSync(tmp, { recursive: true, force: true });
});
test('categorizes entries correctly', async () => {
writeManifest(tmp, `# Secrets Manifest
**Milestone:** M001
@ -80,18 +89,17 @@ test('getManifestStatus: mixed statuses — categorizes entries correctly', asyn
assert.deepStrictEqual(result!.collected, ['COLLECTED_KEY']);
assert.deepStrictEqual(result!.skipped, ['SKIPPED_KEY']);
assert.deepStrictEqual(result!.existing, ['GSD_TEST_EXISTING_KEY_001']);
} finally {
delete process.env.GSD_TEST_EXISTING_KEY_001;
if (savedVal !== undefined) process.env.GSD_TEST_EXISTING_KEY_001 = savedVal;
rmSync(tmp, { recursive: true, force: true });
}
});
});
// ─── All pending ─────────────────────────────────────────────────────────────
test('getManifestStatus: all pending — 3 pending entries, none in env', async () => {
const tmp = makeTempDir('manifest-pending');
try {
describe('getManifestStatus: simple temp dir tests', () => {
let tmp: string;
beforeEach(() => { tmp = makeTempDir('manifest-test'); });
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
test('all pending — 3 pending entries, none in env', async () => {
// Ensure none of these are in process.env
delete process.env.PEND_A;
delete process.env.PEND_B;
@ -133,16 +141,11 @@ test('getManifestStatus: all pending — 3 pending entries, none in env', async
assert.deepStrictEqual(result!.collected, []);
assert.deepStrictEqual(result!.skipped, []);
assert.deepStrictEqual(result!.existing, []);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
// ─── All collected ───────────────────────────────────────────────────────────
// ─── All collected ───────────────────────────────────────────────────────────
test('getManifestStatus: all collected — 2 collected entries, none in env', async () => {
const tmp = makeTempDir('manifest-collected');
try {
test('all collected — 2 collected entries, none in env', async () => {
delete process.env.COLL_X;
delete process.env.COLL_Y;
@ -174,64 +177,19 @@ test('getManifestStatus: all collected — 2 collected entries, none in env', as
assert.deepStrictEqual(result!.collected, ['COLL_X', 'COLL_Y']);
assert.deepStrictEqual(result!.skipped, []);
assert.deepStrictEqual(result!.existing, []);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
// ─── Key in env overrides manifest status ────────────────────────────────────
// ─── Missing manifest ────────────────────────────────────────────────────────
test('getManifestStatus: key in env overrides manifest status — collected key in env goes to existing', async () => {
const tmp = makeTempDir('manifest-override');
const savedVal = process.env.GSD_TEST_OVERRIDE_KEY;
try {
process.env.GSD_TEST_OVERRIDE_KEY = 'already-here';
writeManifest(tmp, `# Secrets Manifest
**Milestone:** M001
**Generated:** 2025-06-20T10:00:00Z
### GSD_TEST_OVERRIDE_KEY
**Service:** Override
**Status:** collected
**Destination:** dotenv
1. Was collected but now in env
`);
const result = await getManifestStatus(tmp, 'M001');
assert.notStrictEqual(result, null);
assert.deepStrictEqual(result!.pending, []);
assert.deepStrictEqual(result!.collected, []);
assert.deepStrictEqual(result!.skipped, []);
assert.deepStrictEqual(result!.existing, ['GSD_TEST_OVERRIDE_KEY']);
} finally {
delete process.env.GSD_TEST_OVERRIDE_KEY;
if (savedVal !== undefined) process.env.GSD_TEST_OVERRIDE_KEY = savedVal;
rmSync(tmp, { recursive: true, force: true });
}
});
// ─── Missing manifest ────────────────────────────────────────────────────────
test('getManifestStatus: missing manifest — returns null', async () => {
const tmp = makeTempDir('manifest-missing');
try {
test('missing manifest — returns null', async () => {
// No .gsd directory at all
const result = await getManifestStatus(tmp, 'M001');
assert.strictEqual(result, null);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
// ─── Empty manifest (no entries) ─────────────────────────────────────────────
// ─── Empty manifest (no entries) ─────────────────────────────────────────────
test('getManifestStatus: empty manifest — exists but no H3 sections', async () => {
const tmp = makeTempDir('manifest-empty');
try {
test('empty manifest — exists but no H3 sections', async () => {
writeManifest(tmp, `# Secrets Manifest
**Milestone:** M001
@ -244,16 +202,11 @@ test('getManifestStatus: empty manifest — exists but no H3 sections', async ()
assert.deepStrictEqual(result!.collected, []);
assert.deepStrictEqual(result!.skipped, []);
assert.deepStrictEqual(result!.existing, []);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
// ─── Env via .env file (not just process.env) ────────────────────────────────
// ─── Env via .env file (not just process.env) ────────────────────────────────
test('getManifestStatus: key in .env file counts as existing', async () => {
const tmp = makeTempDir('manifest-dotenv');
try {
test('key in .env file counts as existing', async () => {
delete process.env.DOTENV_ONLY_KEY;
writeManifest(tmp, `# Secrets Manifest
@ -277,7 +230,45 @@ test('getManifestStatus: key in .env file counts as existing', async () => {
assert.notStrictEqual(result, null);
assert.deepStrictEqual(result!.existing, ['DOTENV_ONLY_KEY']);
assert.deepStrictEqual(result!.pending, []);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
// ─── Key in env overrides manifest status ────────────────────────────────────
describe('getManifestStatus: key in env overrides manifest status', () => {
let tmp: string;
let savedVal: string | undefined;
beforeEach(() => {
tmp = makeTempDir('manifest-override');
savedVal = process.env.GSD_TEST_OVERRIDE_KEY;
process.env.GSD_TEST_OVERRIDE_KEY = 'already-here';
});
afterEach(() => {
delete process.env.GSD_TEST_OVERRIDE_KEY;
if (savedVal !== undefined) process.env.GSD_TEST_OVERRIDE_KEY = savedVal;
rmSync(tmp, { recursive: true, force: true });
});
test('collected key in env goes to existing', async () => {
writeManifest(tmp, `# Secrets Manifest
**Milestone:** M001
**Generated:** 2025-06-20T10:00:00Z
### GSD_TEST_OVERRIDE_KEY
**Service:** Override
**Status:** collected
**Destination:** dotenv
1. Was collected but now in env
`);
const result = await getManifestStatus(tmp, 'M001');
assert.notStrictEqual(result, null);
assert.deepStrictEqual(result!.pending, []);
assert.deepStrictEqual(result!.collected, []);
assert.deepStrictEqual(result!.skipped, []);
assert.deepStrictEqual(result!.existing, ['GSD_TEST_OVERRIDE_KEY']);
});
});

View file

@ -15,7 +15,7 @@
* 11. Dependency audit git diff detection, npm audit parsing, graceful failures
*/
import test from "node:test";
import { describe, test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join, dirname } from "node:path";
@ -37,37 +37,30 @@ function makeTempDir(prefix: string): string {
// ─── Discovery Tests ─────────────────────────────────────────────────────────
test("verification-gate: discoverCommands from preference commands", () => {
const tmp = makeTempDir("vg-pref");
try {
describe("verification-gate: discovery", () => {
let tmp: string;
beforeEach(() => { tmp = makeTempDir("vg-discovery"); });
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
test("discoverCommands from preference commands", () => {
const result = discoverCommands({
preferenceCommands: ["npm run lint", "npm run test"],
cwd: tmp,
});
assert.deepStrictEqual(result.commands, ["npm run lint", "npm run test"]);
assert.equal(result.source, "preference");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
test("verification-gate: discoverCommands from task plan verify field", () => {
const tmp = makeTempDir("vg-taskplan");
try {
test("discoverCommands from task plan verify field", () => {
const result = discoverCommands({
taskPlanVerify: "npm run lint && npm run test",
cwd: tmp,
});
assert.deepStrictEqual(result.commands, ["npm run lint", "npm run test"]);
assert.equal(result.source, "task-plan");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
test("verification-gate: discoverCommands from package.json scripts", () => {
const tmp = makeTempDir("vg-pkg");
try {
test("discoverCommands from package.json scripts", () => {
writeFileSync(
join(tmp, "package.json"),
JSON.stringify({
@ -86,14 +79,9 @@ test("verification-gate: discoverCommands from package.json scripts", () => {
"npm run test",
]);
assert.equal(result.source, "package-json");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
test("verification-gate: first-non-empty-wins — preference beats task plan and package.json", () => {
const tmp = makeTempDir("vg-precedence");
try {
test("first-non-empty-wins — preference beats task plan and package.json", () => {
writeFileSync(
join(tmp, "package.json"),
JSON.stringify({ scripts: { lint: "eslint ." } }),
@ -105,14 +93,9 @@ test("verification-gate: first-non-empty-wins — preference beats task plan and
});
assert.deepStrictEqual(result.commands, ["custom-check"]);
assert.equal(result.source, "preference");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
test("verification-gate: task plan verify beats package.json", () => {
const tmp = makeTempDir("vg-tp-beats-pkg");
try {
test("task plan verify beats package.json", () => {
writeFileSync(
join(tmp, "package.json"),
JSON.stringify({ scripts: { lint: "eslint ." } }),
@ -123,25 +106,15 @@ test("verification-gate: task plan verify beats package.json", () => {
});
assert.deepStrictEqual(result.commands, ["custom-verify"]);
assert.equal(result.source, "task-plan");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
test("verification-gate: missing package.json → 0 checks, source none", () => {
const tmp = makeTempDir("vg-no-pkg");
try {
test("missing package.json → 0 checks, source none", () => {
const result = discoverCommands({ cwd: tmp });
assert.deepStrictEqual(result.commands, []);
assert.equal(result.source, "none");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
test("verification-gate: package.json with no matching scripts → 0 checks", () => {
const tmp = makeTempDir("vg-no-scripts");
try {
test("package.json with no matching scripts → 0 checks", () => {
writeFileSync(
join(tmp, "package.json"),
JSON.stringify({ scripts: { build: "tsc", start: "node index.js" } }),
@ -149,14 +122,9 @@ test("verification-gate: package.json with no matching scripts → 0 checks", ()
const result = discoverCommands({ cwd: tmp });
assert.deepStrictEqual(result.commands, []);
assert.equal(result.source, "none");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
test("verification-gate: empty preference array falls through to task plan", () => {
const tmp = makeTempDir("vg-empty-pref");
try {
test("empty preference array falls through to task plan", () => {
const result = discoverCommands({
preferenceCommands: [],
taskPlanVerify: "echo ok",
@ -164,16 +132,99 @@ test("verification-gate: empty preference array falls through to task plan", ()
});
assert.deepStrictEqual(result.commands, ["echo ok"]);
assert.equal(result.source, "task-plan");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("package.json with only test script → returns only npm run test", () => {
writeFileSync(
join(tmp, "package.json"),
JSON.stringify({
scripts: {
test: "vitest",
build: "tsc",
start: "node index.js",
},
}),
);
const result = discoverCommands({ cwd: tmp });
assert.deepStrictEqual(result.commands, ["npm run test"]);
assert.equal(result.source, "package-json");
});
test("taskPlanVerify with single command (no &&)", () => {
const result = discoverCommands({
taskPlanVerify: "npm test",
cwd: tmp,
});
assert.deepStrictEqual(result.commands, ["npm test"]);
assert.equal(result.source, "task-plan");
});
test("whitespace-only preference commands fall through", () => {
writeFileSync(
join(tmp, "package.json"),
JSON.stringify({ scripts: { lint: "eslint ." } }),
);
const result = discoverCommands({
preferenceCommands: [" ", ""],
cwd: tmp,
});
// Whitespace-only strings are trimmed to empty and filtered out
assert.equal(result.source, "package-json");
assert.deepStrictEqual(result.commands, ["npm run lint"]);
});
test("prose taskPlanVerify is rejected, falls through to package.json", () => {
writeFileSync(
join(tmp, "package.json"),
JSON.stringify({ scripts: { test: "vitest" } }),
);
const result = discoverCommands({
taskPlanVerify: "Document exists, contains all 5 scale names, all 14 semantic tokens",
cwd: tmp,
});
// Prose should be rejected, so it falls through to package.json
assert.equal(result.source, "package-json");
assert.deepStrictEqual(result.commands, ["npm run test"]);
});
test("prose taskPlanVerify with no package.json → source none", () => {
const result = discoverCommands({
taskPlanVerify: "Verify the output matches expected format and all fields are present",
cwd: tmp,
});
assert.equal(result.source, "none");
assert.deepStrictEqual(result.commands, []);
});
test("valid command in taskPlanVerify still works", () => {
const result = discoverCommands({
taskPlanVerify: "npm run lint && npm run test",
cwd: tmp,
});
assert.equal(result.source, "task-plan");
assert.deepStrictEqual(result.commands, ["npm run lint", "npm run test"]);
});
test("mixed prose and commands in taskPlanVerify — only commands kept", () => {
const result = discoverCommands({
taskPlanVerify: "Check that everything works && npm run test",
cwd: tmp,
});
// "Check that everything works" is prose (starts with capital, 4+ words)
// "npm run test" is a valid command
assert.equal(result.source, "task-plan");
assert.deepStrictEqual(result.commands, ["npm run test"]);
});
});
// ─── Execution Tests ─────────────────────────────────────────────────────────
test("verification-gate: all commands pass → gate passes", () => {
const tmp = makeTempDir("vg-pass");
try {
describe("verification-gate: execution", () => {
let tmp: string;
beforeEach(() => { tmp = makeTempDir("vg-exec"); });
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
test("all commands pass → gate passes", () => {
const result = runVerificationGate({
basePath: tmp,
unitId: "T01",
@ -188,14 +239,9 @@ test("verification-gate: all commands pass → gate passes", () => {
assert.ok(result.checks[0].stdout.includes("hello"));
assert.ok(result.checks[1].stdout.includes("world"));
assert.equal(typeof result.timestamp, "number");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
test("verification-gate: one command fails → gate fails with exit code + stderr", () => {
const tmp = makeTempDir("vg-fail");
try {
test("one command fails → gate fails with exit code + stderr", () => {
const result = runVerificationGate({
basePath: tmp,
unitId: "T01",
@ -207,14 +253,9 @@ test("verification-gate: one command fails → gate fails with exit code + stder
assert.equal(result.checks[0].exitCode, 0);
assert.equal(result.checks[1].exitCode, 1);
assert.ok(result.checks[1].stderr.includes("err"));
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
test("verification-gate: no commands discovered → gate passes with 0 checks", () => {
const tmp = makeTempDir("vg-empty");
try {
test("no commands discovered → gate passes with 0 checks", () => {
const result = runVerificationGate({
basePath: tmp,
unitId: "T01",
@ -223,14 +264,9 @@ test("verification-gate: no commands discovered → gate passes with 0 checks",
assert.equal(result.passed, true);
assert.equal(result.checks.length, 0);
assert.equal(result.discoverySource, "none");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
test("verification-gate: command not found → exit code 127", () => {
const tmp = makeTempDir("vg-notfound");
try {
test("command not found → exit code 127", () => {
const result = runVerificationGate({
basePath: tmp,
unitId: "T01",
@ -241,14 +277,9 @@ test("verification-gate: command not found → exit code 127", () => {
assert.equal(result.checks.length, 1);
assert.ok(result.checks[0].exitCode !== 0, "should have non-zero exit code");
assert.ok(result.checks[0].durationMs >= 0);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
test("verification-gate: no DEP0190 deprecation warning when running commands", () => {
const tmp = makeTempDir("vg-dep0190");
try {
test("no DEP0190 deprecation warning when running commands", () => {
// Run a subprocess with --throw-deprecation so any DeprecationWarning
// becomes a thrown error (non-zero exit). The fix passes the command
// string to sh -c explicitly instead of using spawnSync(cmd, {shell:true}).
@ -282,14 +313,9 @@ test("verification-gate: no DEP0190 deprecation warning when running commands",
0,
`Expected exit 0 (no deprecation) but got ${child.status}. stderr: ${child.stderr}`,
);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
});
test("verification-gate: each check has durationMs", () => {
const tmp = makeTempDir("vg-duration");
try {
test("each check has durationMs", () => {
const result = runVerificationGate({
basePath: tmp,
unitId: "T01",
@ -299,9 +325,42 @@ test("verification-gate: each check has durationMs", () => {
assert.equal(result.checks.length, 1);
assert.equal(typeof result.checks[0].durationMs, "number");
assert.ok(result.checks[0].durationMs >= 0);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("one command fails — remaining commands still run (non-short-circuit)", () => {
// First fails, second and third should still execute
const result = runVerificationGate({
basePath: tmp,
unitId: "T02",
cwd: tmp,
preferenceCommands: [
"sh -c 'exit 1'",
"echo second",
"echo third",
],
});
assert.equal(result.passed, false);
assert.equal(result.checks.length, 3, "all 3 commands should run");
assert.equal(result.checks[0].exitCode, 1, "first command fails");
assert.equal(result.checks[1].exitCode, 0, "second command runs and passes");
assert.ok(result.checks[1].stdout.includes("second"));
assert.equal(result.checks[2].exitCode, 0, "third command runs and passes");
assert.ok(result.checks[2].stdout.includes("third"));
});
test("gate execution uses cwd for spawnSync", () => {
// pwd should report the temp dir
const result = runVerificationGate({
basePath: tmp,
unitId: "T02",
cwd: tmp,
preferenceCommands: ["pwd"],
});
assert.equal(result.passed, true);
assert.equal(result.checks.length, 1);
// The stdout should contain the tmp dir path (resolving symlinks)
assert.ok(result.checks[0].stdout.trim().length > 0, "pwd should produce output");
});
});
// ─── Preference Validation Tests ─────────────────────────────────────────────
@ -361,62 +420,6 @@ test("verification-gate: validatePreferences floors verification_max_retries", (
assert.equal(result.errors.length, 0);
});
// ─── Additional Discovery Tests (T02) ───────────────────────────────────────
test("verification-gate: package.json with only test script → returns only npm run test", () => {
const tmp = makeTempDir("vg-only-test");
try {
writeFileSync(
join(tmp, "package.json"),
JSON.stringify({
scripts: {
test: "vitest",
build: "tsc",
start: "node index.js",
},
}),
);
const result = discoverCommands({ cwd: tmp });
assert.deepStrictEqual(result.commands, ["npm run test"]);
assert.equal(result.source, "package-json");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("verification-gate: taskPlanVerify with single command (no &&)", () => {
const tmp = makeTempDir("vg-tp-single");
try {
const result = discoverCommands({
taskPlanVerify: "npm test",
cwd: tmp,
});
assert.deepStrictEqual(result.commands, ["npm test"]);
assert.equal(result.source, "task-plan");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("verification-gate: whitespace-only preference commands fall through", () => {
const tmp = makeTempDir("vg-ws-pref");
try {
writeFileSync(
join(tmp, "package.json"),
JSON.stringify({ scripts: { lint: "eslint ." } }),
);
const result = discoverCommands({
preferenceCommands: [" ", ""],
cwd: tmp,
});
// Whitespace-only strings are trimmed to empty and filtered out
assert.equal(result.source, "package-json");
assert.deepStrictEqual(result.commands, ["npm run lint"]);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
// ─── isLikelyCommand Tests (issue #1066) ────────────────────────────────────
test("isLikelyCommand: known command prefixes are accepted", () => {
@ -468,116 +471,6 @@ test("isLikelyCommand: short lowercase tokens without flags are accepted (could
assert.equal(isLikelyCommand("mycheck"), true);
});
test("verification-gate: prose taskPlanVerify is rejected, falls through to package.json", () => {
const tmp = makeTempDir("vg-prose-reject");
try {
writeFileSync(
join(tmp, "package.json"),
JSON.stringify({ scripts: { test: "vitest" } }),
);
const result = discoverCommands({
taskPlanVerify: "Document exists, contains all 5 scale names, all 14 semantic tokens",
cwd: tmp,
});
// Prose should be rejected, so it falls through to package.json
assert.equal(result.source, "package-json");
assert.deepStrictEqual(result.commands, ["npm run test"]);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("verification-gate: prose taskPlanVerify with no package.json → source none", () => {
const tmp = makeTempDir("vg-prose-none");
try {
const result = discoverCommands({
taskPlanVerify: "Verify the output matches expected format and all fields are present",
cwd: tmp,
});
assert.equal(result.source, "none");
assert.deepStrictEqual(result.commands, []);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("verification-gate: valid command in taskPlanVerify still works", () => {
const tmp = makeTempDir("vg-valid-cmd");
try {
const result = discoverCommands({
taskPlanVerify: "npm run lint && npm run test",
cwd: tmp,
});
assert.equal(result.source, "task-plan");
assert.deepStrictEqual(result.commands, ["npm run lint", "npm run test"]);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("verification-gate: mixed prose and commands in taskPlanVerify — only commands kept", () => {
const tmp = makeTempDir("vg-mixed");
try {
const result = discoverCommands({
taskPlanVerify: "Check that everything works && npm run test",
cwd: tmp,
});
// "Check that everything works" is prose (starts with capital, 4+ words)
// "npm run test" is a valid command
assert.equal(result.source, "task-plan");
assert.deepStrictEqual(result.commands, ["npm run test"]);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
// ─── Additional Execution Tests (T02) ───────────────────────────────────────
test("verification-gate: one command fails — remaining commands still run (non-short-circuit)", () => {
const tmp = makeTempDir("vg-no-short-circuit");
try {
// First fails, second and third should still execute
const result = runVerificationGate({
basePath: tmp,
unitId: "T02",
cwd: tmp,
preferenceCommands: [
"sh -c 'exit 1'",
"echo second",
"echo third",
],
});
assert.equal(result.passed, false);
assert.equal(result.checks.length, 3, "all 3 commands should run");
assert.equal(result.checks[0].exitCode, 1, "first command fails");
assert.equal(result.checks[1].exitCode, 0, "second command runs and passes");
assert.ok(result.checks[1].stdout.includes("second"));
assert.equal(result.checks[2].exitCode, 0, "third command runs and passes");
assert.ok(result.checks[2].stdout.includes("third"));
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
test("verification-gate: gate execution uses cwd for spawnSync", () => {
const tmp = makeTempDir("vg-cwd");
try {
// pwd should report the temp dir
const result = runVerificationGate({
basePath: tmp,
unitId: "T02",
cwd: tmp,
preferenceCommands: ["pwd"],
});
assert.equal(result.passed, true);
assert.equal(result.checks.length, 1);
// The stdout should contain the tmp dir path (resolving symlinks)
assert.ok(result.checks[0].stdout.trim().length > 0, "pwd should produce output");
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
// ─── Additional Preference Validation Tests (T02) ──────────────────────────
test("verification-gate: verification_commands produces no unknown-key warnings", () => {

View file

@ -7,7 +7,7 @@
* rather than hard-coding package.json / src/ only.
*/
import test from "node:test";
import { describe, test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
@ -67,112 +67,69 @@ test("PROJECT_FILES is exported and contains expected multi-ecosystem entries",
assert.ok(PROJECT_FILES.includes("Package.swift"), "includes Swift marker");
});
test("health check passes for Rust project (Cargo.toml, no package.json)", () => {
const dir = createGitRepo();
try {
describe("health check with git repo", () => {
let dir: string;
beforeEach(() => { dir = createGitRepo(); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
test("health check passes for Rust project (Cargo.toml, no package.json)", () => {
writeFileSync(join(dir, "Cargo.toml"), "[package]\nname = \"test\"\n");
mkdirSync(join(dir, "crates"), { recursive: true });
assert.ok(wouldPassHealthCheck(dir, existsSync), "Rust project should pass health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("health check passes for Go project (go.mod, no package.json)", () => {
const dir = createGitRepo();
try {
test("health check passes for Go project (go.mod, no package.json)", () => {
writeFileSync(join(dir, "go.mod"), "module example.com/test\n\ngo 1.21\n");
assert.ok(wouldPassHealthCheck(dir, existsSync), "Go project should pass health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("health check passes for Python project (pyproject.toml, no package.json)", () => {
const dir = createGitRepo();
try {
test("health check passes for Python project (pyproject.toml, no package.json)", () => {
writeFileSync(join(dir, "pyproject.toml"), "[project]\nname = \"test\"\n");
assert.ok(wouldPassHealthCheck(dir, existsSync), "Python project should pass health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("health check passes for Java project (pom.xml, no package.json)", () => {
const dir = createGitRepo();
try {
test("health check passes for Java project (pom.xml, no package.json)", () => {
writeFileSync(join(dir, "pom.xml"), "<project></project>\n");
assert.ok(wouldPassHealthCheck(dir, existsSync), "Java project should pass health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("health check passes for Swift project (Package.swift, no package.json)", () => {
const dir = createGitRepo();
try {
test("health check passes for Swift project (Package.swift, no package.json)", () => {
writeFileSync(join(dir, "Package.swift"), "// swift-tools-version:5.7\n");
assert.ok(wouldPassHealthCheck(dir, existsSync), "Swift project should pass health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("health check passes for C/C++ project (CMakeLists.txt, no package.json)", () => {
const dir = createGitRepo();
try {
test("health check passes for C/C++ project (CMakeLists.txt, no package.json)", () => {
writeFileSync(join(dir, "CMakeLists.txt"), "cmake_minimum_required(VERSION 3.20)\n");
assert.ok(wouldPassHealthCheck(dir, existsSync), "C/C++ project should pass health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("health check passes for Elixir project (mix.exs, no package.json)", () => {
const dir = createGitRepo();
try {
test("health check passes for Elixir project (mix.exs, no package.json)", () => {
writeFileSync(join(dir, "mix.exs"), "defmodule Test.MixProject do\nend\n");
assert.ok(wouldPassHealthCheck(dir, existsSync), "Elixir project should pass health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("health check passes for JS project (package.json, backward compat)", () => {
const dir = createGitRepo();
try {
test("health check passes for JS project (package.json, backward compat)", () => {
writeFileSync(join(dir, "package.json"), '{"name":"test"}\n');
assert.ok(wouldPassHealthCheck(dir, existsSync), "JS project should pass health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
test("health check passes for src/-only project (backward compat)", () => {
const dir = createGitRepo();
try {
test("health check passes for src/-only project (backward compat)", () => {
mkdirSync(join(dir, "src"), { recursive: true });
assert.ok(wouldPassHealthCheck(dir, existsSync), "src/-only project should pass health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("health check fails for empty git repo with no project files", () => {
assert.ok(!wouldPassHealthCheck(dir, existsSync), "empty git repo should fail health check");
});
});
test("health check fails for directory with no .git", () => {
const dir = mkdtempSync(join(tmpdir(), "wt-dispatch-test-nogit-"));
try {
describe("health check without git repo", () => {
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "wt-dispatch-test-nogit-")); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
test("health check fails for directory with no .git", () => {
writeFileSync(join(dir, "Cargo.toml"), "[package]\nname = \"test\"\n");
assert.ok(!wouldPassHealthCheck(dir, existsSync), "no-git directory should fail health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("health check fails for empty git repo with no project files", () => {
const dir = createGitRepo();
try {
assert.ok(!wouldPassHealthCheck(dir, existsSync), "empty git repo should fail health check");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

View file

@ -1,4 +1,4 @@
import test from "node:test";
import { describe, test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
@ -73,9 +73,12 @@ test("worktreeBranchName formats branch name", () => {
// ─── createWorktree ───────────────────────────────────────────────────────────
test("createWorktree creates worktree with correct metadata", () => {
const base = makeBaseRepo();
try {
describe("createWorktree", () => {
let base: string;
beforeEach(() => { base = makeBaseRepo(); });
afterEach(() => { rmSync(base, { recursive: true, force: true }); });
test("creates worktree with correct metadata", () => {
const info = createWorktree(base, "feature-x");
assert.strictEqual(info.name, "feature-x", "name should match");
assert.strictEqual(info.branch, "worktree/feature-x", "branch should be prefixed");
@ -88,33 +91,9 @@ test("createWorktree creates worktree with correct metadata", () => {
);
const branches = run("git branch", base);
assert.ok(branches.includes("worktree/feature-x"), "branch should be created in base repo");
} finally {
rmSync(base, { recursive: true, force: true });
}
});
});
test("createWorktree rejects duplicate name", () => {
const { base } = makeRepoWithWorktree("feature-x");
try {
assert.throws(
() => createWorktree(base, "feature-x"),
(err: Error) => {
assert.ok(
err.message.includes("already exists"),
`expected "already exists" in error, got: ${err.message}`,
);
return true;
},
"should throw on duplicate worktree name",
);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test("createWorktree rejects invalid name", () => {
const base = makeBaseRepo();
try {
test("rejects invalid name", () => {
assert.throws(
() => createWorktree(base, "bad name!"),
(err: Error) => {
@ -126,42 +105,68 @@ test("createWorktree rejects invalid name", () => {
},
"should throw on invalid worktree name",
);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
});
describe("createWorktree — duplicate rejection", () => {
let base: string;
beforeEach(() => {
const repo = makeRepoWithWorktree("feature-x");
base = repo.base;
});
afterEach(() => { rmSync(base, { recursive: true, force: true }); });
test("rejects duplicate name", () => {
assert.throws(
() => createWorktree(base, "feature-x"),
(err: Error) => {
assert.ok(
err.message.includes("already exists"),
`expected "already exists" in error, got: ${err.message}`,
);
return true;
},
"should throw on duplicate worktree name",
);
});
});
// ─── listWorktrees ────────────────────────────────────────────────────────────
test("listWorktrees returns active worktrees", () => {
const { base } = makeRepoWithWorktree("feature-x");
try {
describe("listWorktrees", () => {
let base: string;
beforeEach(() => {
const repo = makeRepoWithWorktree("feature-x");
base = repo.base;
});
afterEach(() => { rmSync(base, { recursive: true, force: true }); });
test("returns active worktrees", () => {
const list = listWorktrees(base);
assert.strictEqual(list.length, 1, "should list exactly one worktree");
assert.strictEqual(list[0]!.name, "feature-x", "name should match");
assert.strictEqual(list[0]!.branch, "worktree/feature-x", "branch should match");
assert.ok(list[0]!.exists, "exists flag should be true");
} finally {
rmSync(base, { recursive: true, force: true });
}
});
});
test("listWorktrees returns empty after removal", () => {
const { base } = makeRepoWithWorktree("feature-x");
try {
test("returns empty after removal", () => {
removeWorktree(base, "feature-x");
const list = listWorktrees(base);
assert.strictEqual(list.length, 0, "should have no worktrees after removal");
} finally {
rmSync(base, { recursive: true, force: true });
}
});
});
// ─── diffWorktreeGSD ─────────────────────────────────────────────────────────
test("diffWorktreeGSD detects added and modified GSD files", () => {
const { base } = makeRepoWithChanges("feature-x");
try {
describe("diffWorktreeGSD and getWorktreeGSDDiff", () => {
let base: string;
beforeEach(() => {
const repo = makeRepoWithChanges("feature-x");
base = repo.base;
});
afterEach(() => { rmSync(base, { recursive: true, force: true }); });
test("detects added and modified GSD files", () => {
const diff = diffWorktreeGSD(base, "feature-x");
assert.ok(diff.added.length > 0, "should have added files");
assert.ok(
@ -174,58 +179,60 @@ test("diffWorktreeGSD detects added and modified GSD files", () => {
"M001 roadmap should be in modified files",
);
assert.strictEqual(diff.removed.length, 0, "should have no removed files");
} finally {
rmSync(base, { recursive: true, force: true });
}
});
});
// ─── getWorktreeGSDDiff ───────────────────────────────────────────────────────
test("getWorktreeGSDDiff returns patch content", () => {
const { base } = makeRepoWithChanges("feature-x");
try {
test("returns patch content", () => {
const fullDiff = getWorktreeGSDDiff(base, "feature-x");
assert.ok(fullDiff.includes("M002"), "diff should mention M002");
assert.ok(fullDiff.includes("updated"), "diff should mention the update");
} finally {
rmSync(base, { recursive: true, force: true });
}
});
});
// ─── getWorktreeLog ───────────────────────────────────────────────────────────
test("getWorktreeLog shows commits", () => {
const { base } = makeRepoWithChanges("feature-x");
try {
describe("getWorktreeLog", () => {
let base: string;
beforeEach(() => {
const repo = makeRepoWithChanges("feature-x");
base = repo.base;
});
afterEach(() => { rmSync(base, { recursive: true, force: true }); });
test("shows commits", () => {
const log = getWorktreeLog(base, "feature-x");
assert.ok(log.includes("add M002"), "log should include the commit message");
} finally {
rmSync(base, { recursive: true, force: true });
}
});
});
// ─── removeWorktree ───────────────────────────────────────────────────────────
test("removeWorktree removes directory and branch", () => {
const { base, wtPath } = makeRepoWithWorktree("feature-x");
try {
describe("removeWorktree", () => {
let base: string;
let wtPath: string;
beforeEach(() => {
const repo = makeRepoWithWorktree("feature-x");
base = repo.base;
wtPath = repo.wtPath;
});
afterEach(() => { rmSync(base, { recursive: true, force: true }); });
test("removes directory and branch", () => {
removeWorktree(base, "feature-x", { deleteBranch: true });
assert.ok(!existsSync(wtPath), "worktree directory should be gone");
const branches = run("git branch", base);
assert.ok(!branches.includes("worktree/feature-x"), "branch should be deleted");
} finally {
rmSync(base, { recursive: true, force: true });
}
});
});
test("removeWorktree on missing worktree does not throw", () => {
const base = makeBaseRepo();
try {
describe("removeWorktree — missing worktree", () => {
let base: string;
beforeEach(() => { base = makeBaseRepo(); });
afterEach(() => { rmSync(base, { recursive: true, force: true }); });
test("on missing worktree does not throw", () => {
assert.doesNotThrow(
() => removeWorktree(base, "nonexistent"),
"should not throw when worktree does not exist",
);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
});