diff --git a/src/resources/extensions/gsd/tests/activity-log.test.ts b/src/resources/extensions/gsd/tests/activity-log.test.ts index 423701723..8ae1bba4b 100644 --- a/src/resources/extensions/gsd/tests/activity-log.test.ts +++ b/src/resources/extensions/gsd/tests/activity-log.test.ts @@ -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 ──────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/tests/journal.test.ts b/src/resources/extensions/gsd/tests/journal.test.ts index 5808b67bb..96a39e064 100644 --- a/src/resources/extensions/gsd/tests/journal.test.ts +++ b/src/resources/extensions/gsd/tests/journal.test.ts @@ -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 { // ─── 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)?.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, []); + }); }); diff --git a/src/resources/extensions/gsd/tests/manifest-status.test.ts b/src/resources/extensions/gsd/tests/manifest-status.test.ts index 3020caa87..646eccec0 100644 --- a/src/resources/extensions/gsd/tests/manifest-status.test.ts +++ b/src/resources/extensions/gsd/tests/manifest-status.test.ts @@ -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']); + }); }); diff --git a/src/resources/extensions/gsd/tests/verification-gate.test.ts b/src/resources/extensions/gsd/tests/verification-gate.test.ts index 05a96fcd5..c87f07a6b 100644 --- a/src/resources/extensions/gsd/tests/verification-gate.test.ts +++ b/src/resources/extensions/gsd/tests/verification-gate.test.ts @@ -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", () => { diff --git a/src/resources/extensions/gsd/tests/worktree-health-dispatch.test.ts b/src/resources/extensions/gsd/tests/worktree-health-dispatch.test.ts index cd5d72f46..c26913fdc 100644 --- a/src/resources/extensions/gsd/tests/worktree-health-dispatch.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-health-dispatch.test.ts @@ -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"), "\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 }); - } + }); }); diff --git a/src/resources/extensions/gsd/tests/worktree-manager.test.ts b/src/resources/extensions/gsd/tests/worktree-manager.test.ts index 9b836ad30..68b038d81 100644 --- a/src/resources/extensions/gsd/tests/worktree-manager.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-manager.test.ts @@ -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 }); - } + }); });