refactor(test): migrate gsd/tests o-r from custom harness to node:test (#2401)
This commit is contained in:
parent
4498dcea32
commit
1fe52a2e8e
22 changed files with 1838 additions and 2319 deletions
|
|
@ -1,15 +1,14 @@
|
|||
// GSD Extension - Override Tests
|
||||
// Tests for parseOverrides, appendOverride, loadActiveOverrides, formatOverridesSection, resolveAllOverrides
|
||||
|
||||
import { describe, test, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
import { parseOverrides, appendOverride, loadActiveOverrides, formatOverridesSection, resolveAllOverrides } from '../files.ts';
|
||||
import type { Override } from '../files.ts';
|
||||
|
||||
const { assertEq, assertTrue, assertMatch, assertNoMatch, report } = createTestContext();
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir(prefix: string): string {
|
||||
|
|
@ -26,106 +25,100 @@ function cleanup(): void {
|
|||
tempDirs.length = 0;
|
||||
}
|
||||
|
||||
console.log('\n=== parseOverrides: empty content ===');
|
||||
{ const result = parseOverrides(""); assertEq(result.length, 0, "empty content returns no overrides"); }
|
||||
describe('overrides', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
console.log('\n=== parseOverrides: single active override ===');
|
||||
{
|
||||
const content = `# GSD Overrides\n\nUser-issued overrides that supersede plan document content.\n\n---\n\n## Override: 2026-03-14T10:00:00.000Z\n\n**Change:** Use Postgres instead of SQLite\n**Scope:** active\n**Applied-at:** M001/S02/T03\n\n---\n`;
|
||||
const result = parseOverrides(content);
|
||||
assertEq(result.length, 1, "parses one override");
|
||||
assertEq(result[0].timestamp, "2026-03-14T10:00:00.000Z", "correct timestamp");
|
||||
assertEq(result[0].change, "Use Postgres instead of SQLite", "correct change");
|
||||
assertEq(result[0].scope, "active", "correct scope");
|
||||
assertEq(result[0].appliedAt, "M001/S02/T03", "correct appliedAt");
|
||||
}
|
||||
test('parseOverrides: empty content', () => {
|
||||
const result = parseOverrides(""); assert.deepStrictEqual(result.length, 0, "empty content returns no overrides");
|
||||
});
|
||||
|
||||
console.log('\n=== parseOverrides: multiple overrides, mixed scopes ===');
|
||||
{
|
||||
const content = `# GSD Overrides\n\n---\n\n## Override: 2026-03-14T10:00:00.000Z\n\n**Change:** Use Postgres instead of SQLite\n**Scope:** resolved\n**Applied-at:** M001/S02/T03\n\n---\n\n## Override: 2026-03-14T11:00:00.000Z\n\n**Change:** Use JWT instead of session cookies\n**Scope:** active\n**Applied-at:** M001/S03/T01\n\n---\n`;
|
||||
const result = parseOverrides(content);
|
||||
assertEq(result.length, 2, "parses two overrides");
|
||||
assertEq(result[0].scope, "resolved", "first is resolved");
|
||||
assertEq(result[1].scope, "active", "second is active");
|
||||
assertEq(result[1].change, "Use JWT instead of session cookies", "second change text");
|
||||
}
|
||||
test('parseOverrides: single active override', () => {
|
||||
const content = `# GSD Overrides\n\nUser-issued overrides that supersede plan document content.\n\n---\n\n## Override: 2026-03-14T10:00:00.000Z\n\n**Change:** Use Postgres instead of SQLite\n**Scope:** active\n**Applied-at:** M001/S02/T03\n\n---\n`;
|
||||
const result = parseOverrides(content);
|
||||
assert.deepStrictEqual(result.length, 1, "parses one override");
|
||||
assert.deepStrictEqual(result[0].timestamp, "2026-03-14T10:00:00.000Z", "correct timestamp");
|
||||
assert.deepStrictEqual(result[0].change, "Use Postgres instead of SQLite", "correct change");
|
||||
assert.deepStrictEqual(result[0].scope, "active", "correct scope");
|
||||
assert.deepStrictEqual(result[0].appliedAt, "M001/S02/T03", "correct appliedAt");
|
||||
});
|
||||
|
||||
console.log('\n=== appendOverride: creates new file ===');
|
||||
{
|
||||
const tmp = makeTempDir("append-new");
|
||||
await appendOverride(tmp, "Use Postgres", "M001/S01/T01");
|
||||
const content = readFileSync(join(tmp, ".gsd", "OVERRIDES.md"), "utf-8");
|
||||
assertTrue(content.includes("# GSD Overrides"), "has header");
|
||||
assertTrue(content.includes("**Change:** Use Postgres"), "has change");
|
||||
assertTrue(content.includes("**Scope:** active"), "has active scope");
|
||||
assertTrue(content.includes("**Applied-at:** M001/S01/T01"), "has appliedAt");
|
||||
}
|
||||
test('parseOverrides: multiple overrides, mixed scopes', () => {
|
||||
const content = `# GSD Overrides\n\n---\n\n## Override: 2026-03-14T10:00:00.000Z\n\n**Change:** Use Postgres instead of SQLite\n**Scope:** resolved\n**Applied-at:** M001/S02/T03\n\n---\n\n## Override: 2026-03-14T11:00:00.000Z\n\n**Change:** Use JWT instead of session cookies\n**Scope:** active\n**Applied-at:** M001/S03/T01\n\n---\n`;
|
||||
const result = parseOverrides(content);
|
||||
assert.deepStrictEqual(result.length, 2, "parses two overrides");
|
||||
assert.deepStrictEqual(result[0].scope, "resolved", "first is resolved");
|
||||
assert.deepStrictEqual(result[1].scope, "active", "second is active");
|
||||
assert.deepStrictEqual(result[1].change, "Use JWT instead of session cookies", "second change text");
|
||||
});
|
||||
|
||||
console.log('\n=== appendOverride: appends to existing file ===');
|
||||
{
|
||||
const tmp = makeTempDir("append-existing");
|
||||
await appendOverride(tmp, "First override", "M001/S01/T01");
|
||||
await appendOverride(tmp, "Second override", "M001/S02/T02");
|
||||
const content = readFileSync(join(tmp, ".gsd", "OVERRIDES.md"), "utf-8");
|
||||
assertTrue(content.includes("**Change:** First override"), "has first override");
|
||||
assertTrue(content.includes("**Change:** Second override"), "has second override");
|
||||
const parsed = parseOverrides(content);
|
||||
assertEq(parsed.length, 2, "two overrides in file");
|
||||
}
|
||||
test('appendOverride: creates new file', async () => {
|
||||
const tmp = makeTempDir("append-new");
|
||||
await appendOverride(tmp, "Use Postgres", "M001/S01/T01");
|
||||
const content = readFileSync(join(tmp, ".gsd", "OVERRIDES.md"), "utf-8");
|
||||
assert.ok(content.includes("# GSD Overrides"), "has header");
|
||||
assert.ok(content.includes("**Change:** Use Postgres"), "has change");
|
||||
assert.ok(content.includes("**Scope:** active"), "has active scope");
|
||||
assert.ok(content.includes("**Applied-at:** M001/S01/T01"), "has appliedAt");
|
||||
});
|
||||
|
||||
console.log('\n=== loadActiveOverrides: no file ===');
|
||||
{
|
||||
const tmp = makeTempDir("load-no-file");
|
||||
const result = await loadActiveOverrides(tmp);
|
||||
assertEq(result.length, 0, "returns empty when no file");
|
||||
}
|
||||
test('appendOverride: appends to existing file', async () => {
|
||||
const tmp = makeTempDir("append-existing");
|
||||
await appendOverride(tmp, "First override", "M001/S01/T01");
|
||||
await appendOverride(tmp, "Second override", "M001/S02/T02");
|
||||
const content = readFileSync(join(tmp, ".gsd", "OVERRIDES.md"), "utf-8");
|
||||
assert.ok(content.includes("**Change:** First override"), "has first override");
|
||||
assert.ok(content.includes("**Change:** Second override"), "has second override");
|
||||
const parsed = parseOverrides(content);
|
||||
assert.deepStrictEqual(parsed.length, 2, "two overrides in file");
|
||||
});
|
||||
|
||||
console.log('\n=== loadActiveOverrides: filters to active only ===');
|
||||
{
|
||||
const tmp = makeTempDir("load-filter");
|
||||
const content = `# GSD Overrides\n\n---\n\n## Override: 2026-03-14T10:00:00.000Z\n\n**Change:** Resolved change\n**Scope:** resolved\n**Applied-at:** M001/S01/T01\n\n---\n\n## Override: 2026-03-14T11:00:00.000Z\n\n**Change:** Active change\n**Scope:** active\n**Applied-at:** M001/S02/T01\n\n---\n`;
|
||||
writeFileSync(join(tmp, ".gsd", "OVERRIDES.md"), content, "utf-8");
|
||||
const result = await loadActiveOverrides(tmp);
|
||||
assertEq(result.length, 1, "only one active override");
|
||||
assertEq(result[0].change, "Active change", "correct active change");
|
||||
}
|
||||
test('loadActiveOverrides: no file', async () => {
|
||||
const tmp = makeTempDir("load-no-file");
|
||||
const result = await loadActiveOverrides(tmp);
|
||||
assert.deepStrictEqual(result.length, 0, "returns empty when no file");
|
||||
});
|
||||
|
||||
console.log('\n=== formatOverridesSection: empty array ===');
|
||||
{ const result = formatOverridesSection([]); assertEq(result, "", "empty overrides returns empty string"); }
|
||||
test('loadActiveOverrides: filters to active only', async () => {
|
||||
const tmp = makeTempDir("load-filter");
|
||||
const content = `# GSD Overrides\n\n---\n\n## Override: 2026-03-14T10:00:00.000Z\n\n**Change:** Resolved change\n**Scope:** resolved\n**Applied-at:** M001/S01/T01\n\n---\n\n## Override: 2026-03-14T11:00:00.000Z\n\n**Change:** Active change\n**Scope:** active\n**Applied-at:** M001/S02/T01\n\n---\n`;
|
||||
writeFileSync(join(tmp, ".gsd", "OVERRIDES.md"), content, "utf-8");
|
||||
const result = await loadActiveOverrides(tmp);
|
||||
assert.deepStrictEqual(result.length, 1, "only one active override");
|
||||
assert.deepStrictEqual(result[0].change, "Active change", "correct active change");
|
||||
});
|
||||
|
||||
console.log('\n=== formatOverridesSection: formats section ===');
|
||||
{
|
||||
const overrides: Override[] = [
|
||||
{ timestamp: "2026-03-14T10:00:00.000Z", change: "Use Postgres", scope: "active", appliedAt: "M001/S01/T01" },
|
||||
];
|
||||
const result = formatOverridesSection(overrides);
|
||||
assertTrue(result.includes("## Active Overrides (supersede plan content)"), "has header");
|
||||
assertTrue(result.includes("**Use Postgres**"), "has change text");
|
||||
assertTrue(result.includes("supersede any conflicting content"), "has instruction");
|
||||
}
|
||||
test('formatOverridesSection: empty array', () => {
|
||||
const result = formatOverridesSection([]); assert.deepStrictEqual(result, "", "empty overrides returns empty string");
|
||||
});
|
||||
|
||||
console.log('\n=== resolveAllOverrides: marks all as resolved ===');
|
||||
{
|
||||
const tmp = makeTempDir("resolve-all");
|
||||
await appendOverride(tmp, "First", "M001/S01/T01");
|
||||
await appendOverride(tmp, "Second", "M001/S02/T01");
|
||||
let active = await loadActiveOverrides(tmp);
|
||||
assertEq(active.length, 2, "two active before resolve");
|
||||
await resolveAllOverrides(tmp);
|
||||
active = await loadActiveOverrides(tmp);
|
||||
assertEq(active.length, 0, "no active after resolve");
|
||||
const content = readFileSync(join(tmp, ".gsd", "OVERRIDES.md"), "utf-8");
|
||||
const allOverrides = parseOverrides(content);
|
||||
assertEq(allOverrides.length, 2, "still two overrides total");
|
||||
assertTrue(allOverrides.every(o => o.scope === "resolved"), "all resolved");
|
||||
}
|
||||
test('formatOverridesSection: formats section', () => {
|
||||
const overrides: Override[] = [
|
||||
{ timestamp: "2026-03-14T10:00:00.000Z", change: "Use Postgres", scope: "active", appliedAt: "M001/S01/T01" },
|
||||
];
|
||||
const result = formatOverridesSection(overrides);
|
||||
assert.ok(result.includes("## Active Overrides (supersede plan content)"), "has header");
|
||||
assert.ok(result.includes("**Use Postgres**"), "has change text");
|
||||
assert.ok(result.includes("supersede any conflicting content"), "has instruction");
|
||||
});
|
||||
|
||||
console.log('\n=== resolveAllOverrides: no file — no error ===');
|
||||
{
|
||||
const tmp = makeTempDir("resolve-no-file");
|
||||
await resolveAllOverrides(tmp);
|
||||
assertTrue(true, "resolveAllOverrides with no file does not throw");
|
||||
}
|
||||
test('resolveAllOverrides: marks all as resolved', async () => {
|
||||
const tmp = makeTempDir("resolve-all");
|
||||
await appendOverride(tmp, "First", "M001/S01/T01");
|
||||
await appendOverride(tmp, "Second", "M001/S02/T01");
|
||||
let active = await loadActiveOverrides(tmp);
|
||||
assert.deepStrictEqual(active.length, 2, "two active before resolve");
|
||||
await resolveAllOverrides(tmp);
|
||||
active = await loadActiveOverrides(tmp);
|
||||
assert.deepStrictEqual(active.length, 0, "no active after resolve");
|
||||
const content = readFileSync(join(tmp, ".gsd", "OVERRIDES.md"), "utf-8");
|
||||
const allOverrides = parseOverrides(content);
|
||||
assert.deepStrictEqual(allOverrides.length, 2, "still two overrides total");
|
||||
assert.ok(allOverrides.every(o => o.scope === "resolved"), "all resolved");
|
||||
});
|
||||
|
||||
cleanup();
|
||||
report();
|
||||
test('resolveAllOverrides: no file — no error', async () => {
|
||||
const tmp = makeTempDir("resolve-no-file");
|
||||
await resolveAllOverrides(tmp);
|
||||
assert.ok(true, "resolveAllOverrides with no file does not throw");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
* restored after a coordinator crash, with PID liveness filtering.
|
||||
*/
|
||||
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
|
|
@ -24,10 +26,6 @@ import {
|
|||
type PersistedState,
|
||||
} from "../parallel-orchestrator.ts";
|
||||
import { writeSessionStatus, readAllSessionStatuses, removeSessionStatus } from "../session-status-io.ts";
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeTempDir(): string {
|
||||
|
|
@ -57,8 +55,9 @@ function makePersistedState(overrides: Partial<PersistedState> = {}): PersistedS
|
|||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Test 1: persistState writes valid JSON
|
||||
{
|
||||
|
||||
describe('parallel-crash-recovery', () => {
|
||||
test('Test 1: persistState writes valid JSON', () => {
|
||||
const basePath = makeTempDir();
|
||||
try {
|
||||
// We can't call persistState directly without internal state set up,
|
||||
|
|
@ -82,29 +81,27 @@ function makePersistedState(overrides: Partial<PersistedState> = {}): PersistedS
|
|||
|
||||
const raw = readFileSync(stateFilePath(basePath), "utf-8");
|
||||
const parsed = JSON.parse(raw) as PersistedState;
|
||||
assertEq(parsed.active, true, "persistState: active field preserved");
|
||||
assertEq(parsed.workers.length, 1, "persistState: worker count preserved");
|
||||
assertEq(parsed.workers[0].milestoneId, "M001", "persistState: milestoneId preserved");
|
||||
assertEq(parsed.workers[0].cost, 0.15, "persistState: cost preserved");
|
||||
assertEq(parsed.totalCost, 0.15, "persistState: totalCost preserved");
|
||||
assert.deepStrictEqual(parsed.active, true, "persistState: active field preserved");
|
||||
assert.deepStrictEqual(parsed.workers.length, 1, "persistState: worker count preserved");
|
||||
assert.deepStrictEqual(parsed.workers[0].milestoneId, "M001", "persistState: milestoneId preserved");
|
||||
assert.deepStrictEqual(parsed.workers[0].cost, 0.15, "persistState: cost preserved");
|
||||
assert.deepStrictEqual(parsed.totalCost, 0.15, "persistState: totalCost preserved");
|
||||
} finally {
|
||||
rmSync(basePath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Test 2: restoreState returns null for missing file
|
||||
{
|
||||
test('Test 2: restoreState returns null for missing file', () => {
|
||||
const basePath = makeTempDir();
|
||||
try {
|
||||
const result = restoreState(basePath);
|
||||
assertEq(result, null, "restoreState: returns null when no state file");
|
||||
assert.deepStrictEqual(result, null, "restoreState: returns null when no state file");
|
||||
} finally {
|
||||
rmSync(basePath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Test 3: restoreState filters dead PIDs
|
||||
{
|
||||
test('Test 3: restoreState filters dead PIDs', () => {
|
||||
const basePath = makeTempDir();
|
||||
try {
|
||||
// PID 99999999 is almost certainly not alive
|
||||
|
|
@ -136,15 +133,14 @@ function makePersistedState(overrides: Partial<PersistedState> = {}): PersistedS
|
|||
|
||||
const result = restoreState(basePath);
|
||||
// Both PIDs are dead, so result should be null and file should be cleaned up
|
||||
assertEq(result, null, "restoreState: returns null when all PIDs dead");
|
||||
assertTrue(!existsSync(stateFilePath(basePath)), "restoreState: cleans up state file when all dead");
|
||||
assert.deepStrictEqual(result, null, "restoreState: returns null when all PIDs dead");
|
||||
assert.ok(!existsSync(stateFilePath(basePath)), "restoreState: cleans up state file when all dead");
|
||||
} finally {
|
||||
rmSync(basePath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Test 4: restoreState keeps alive PIDs
|
||||
{
|
||||
test('Test 4: restoreState keeps alive PIDs', () => {
|
||||
const basePath = makeTempDir();
|
||||
try {
|
||||
// Use current process PID (definitely alive)
|
||||
|
|
@ -176,18 +172,17 @@ function makePersistedState(overrides: Partial<PersistedState> = {}): PersistedS
|
|||
writeStateFile(basePath, state);
|
||||
|
||||
const result = restoreState(basePath);
|
||||
assertTrue(result !== null, "restoreState: returns state when alive PID exists");
|
||||
assertEq(result!.workers.length, 1, "restoreState: filters out dead PID");
|
||||
assertEq(result!.workers[0].milestoneId, "M001", "restoreState: keeps alive worker");
|
||||
assertEq(result!.workers[0].pid, process.pid, "restoreState: preserves PID");
|
||||
assertEq(result!.workers[0].completedUnits, 5, "restoreState: preserves progress");
|
||||
assert.ok(result !== null, "restoreState: returns state when alive PID exists");
|
||||
assert.deepStrictEqual(result!.workers.length, 1, "restoreState: filters out dead PID");
|
||||
assert.deepStrictEqual(result!.workers[0].milestoneId, "M001", "restoreState: keeps alive worker");
|
||||
assert.deepStrictEqual(result!.workers[0].pid, process.pid, "restoreState: preserves PID");
|
||||
assert.deepStrictEqual(result!.workers[0].completedUnits, 5, "restoreState: preserves progress");
|
||||
} finally {
|
||||
rmSync(basePath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Test 5: restoreState skips stopped/error workers even with alive PIDs
|
||||
{
|
||||
test('Test 5: restoreState skips stopped/error workers even with alive PIDs', () => {
|
||||
const basePath = makeTempDir();
|
||||
try {
|
||||
const state = makePersistedState({
|
||||
|
|
@ -207,14 +202,13 @@ function makePersistedState(overrides: Partial<PersistedState> = {}): PersistedS
|
|||
writeStateFile(basePath, state);
|
||||
|
||||
const result = restoreState(basePath);
|
||||
assertEq(result, null, "restoreState: skips stopped workers");
|
||||
assert.deepStrictEqual(result, null, "restoreState: skips stopped workers");
|
||||
} finally {
|
||||
rmSync(basePath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Test 6: orphan detection finds stale sessions
|
||||
{
|
||||
test('Test 6: orphan detection finds stale sessions', () => {
|
||||
const basePath = makeTempDir();
|
||||
try {
|
||||
// Write a session status with a dead PID
|
||||
|
|
@ -246,7 +240,7 @@ function makePersistedState(overrides: Partial<PersistedState> = {}): PersistedS
|
|||
|
||||
// Read all sessions — both should exist initially
|
||||
const before = readAllSessionStatuses(basePath);
|
||||
assertEq(before.length, 2, "orphan: both sessions exist before detection");
|
||||
assert.deepStrictEqual(before.length, 2, "orphan: both sessions exist before detection");
|
||||
|
||||
// Now simulate orphan detection logic (same as prepareParallelStart)
|
||||
const sessions = readAllSessionStatuses(basePath);
|
||||
|
|
@ -265,34 +259,33 @@ function makePersistedState(overrides: Partial<PersistedState> = {}): PersistedS
|
|||
}
|
||||
}
|
||||
|
||||
assertTrue(orphans.length === 2, "orphan: detected both sessions");
|
||||
assert.ok(orphans.length === 2, "orphan: detected both sessions");
|
||||
const deadOrphan = orphans.find(o => o.milestoneId === "M001");
|
||||
assertTrue(deadOrphan !== undefined && !deadOrphan.alive, "orphan: M001 detected as dead");
|
||||
assert.ok(deadOrphan !== undefined && !deadOrphan.alive, "orphan: M001 detected as dead");
|
||||
const aliveOrphan = orphans.find(o => o.milestoneId === "M002");
|
||||
assertTrue(aliveOrphan !== undefined && aliveOrphan.alive, "orphan: M002 detected as alive");
|
||||
assert.ok(aliveOrphan !== undefined && aliveOrphan.alive, "orphan: M002 detected as alive");
|
||||
|
||||
// Dead session should be cleaned up
|
||||
const after = readAllSessionStatuses(basePath);
|
||||
assertEq(after.length, 1, "orphan: dead session cleaned up");
|
||||
assertEq(after[0].milestoneId, "M002", "orphan: alive session remains");
|
||||
assert.deepStrictEqual(after.length, 1, "orphan: dead session cleaned up");
|
||||
assert.deepStrictEqual(after[0].milestoneId, "M002", "orphan: alive session remains");
|
||||
} finally {
|
||||
rmSync(basePath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Test 7: restoreState handles corrupt JSON gracefully
|
||||
{
|
||||
test('Test 7: restoreState handles corrupt JSON gracefully', () => {
|
||||
const basePath = makeTempDir();
|
||||
try {
|
||||
writeFileSync(stateFilePath(basePath), "{ not valid json !!!", "utf-8");
|
||||
const result = restoreState(basePath);
|
||||
assertEq(result, null, "restoreState: returns null for corrupt JSON");
|
||||
assert.deepStrictEqual(result, null, "restoreState: returns null for corrupt JSON");
|
||||
} finally {
|
||||
rmSync(basePath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up module state
|
||||
resetOrchestrator();
|
||||
|
||||
report();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,12 +10,11 @@
|
|||
* 6. completedUnits counter increments on assistant message_end
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it, after } from "node:test";
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
|
||||
// We test processWorkerLine indirectly via the module's exported state.
|
||||
// To test the internal function, we use the exported accessors.
|
||||
import {
|
||||
|
|
@ -27,8 +26,6 @@ import {
|
|||
refreshWorkerStatuses,
|
||||
} from "../parallel-orchestrator.ts";
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Create a minimal message_end NDJSON line with cost data. */
|
||||
|
|
@ -52,7 +49,7 @@ function makeMessageEndLine(cost: number, role = "assistant"): string {
|
|||
describe("parallel-worker-monitoring", () => {
|
||||
after(() => {
|
||||
resetOrchestrator();
|
||||
report();
|
||||
|
||||
});
|
||||
|
||||
// Note: processWorkerLine is not exported, so we test the observable effects
|
||||
|
|
@ -61,39 +58,39 @@ describe("parallel-worker-monitoring", () => {
|
|||
|
||||
it("isBudgetExceeded returns false when no state exists", () => {
|
||||
resetOrchestrator();
|
||||
assertTrue(!isBudgetExceeded(), "no state = not exceeded");
|
||||
assert.ok(!isBudgetExceeded(), "no state = not exceeded");
|
||||
});
|
||||
|
||||
it("isBudgetExceeded returns false when no ceiling configured", () => {
|
||||
resetOrchestrator();
|
||||
// Can't directly set state without startParallel, so test the accessor
|
||||
assertTrue(!isBudgetExceeded(), "no ceiling = not exceeded");
|
||||
assert.ok(!isBudgetExceeded(), "no ceiling = not exceeded");
|
||||
});
|
||||
|
||||
it("getAggregateCost returns 0 when no state exists", () => {
|
||||
resetOrchestrator();
|
||||
assertEq(getAggregateCost(), 0, "no state = zero cost");
|
||||
assert.deepStrictEqual(getAggregateCost(), 0, "no state = zero cost");
|
||||
});
|
||||
|
||||
it("isParallelActive returns false after reset", () => {
|
||||
resetOrchestrator();
|
||||
assertTrue(!isParallelActive(), "reset = not active");
|
||||
assert.ok(!isParallelActive(), "reset = not active");
|
||||
});
|
||||
|
||||
it("getWorkerStatuses returns empty array when no state", () => {
|
||||
resetOrchestrator();
|
||||
assertEq(getWorkerStatuses().length, 0, "no state = empty workers");
|
||||
assert.deepStrictEqual(getWorkerStatuses().length, 0, "no state = empty workers");
|
||||
});
|
||||
|
||||
it("NDJSON message_end format matches expected structure", () => {
|
||||
// Verify the NDJSON line format we expect from workers
|
||||
const line = makeMessageEndLine(0.05);
|
||||
const parsed = JSON.parse(line);
|
||||
assertEq(parsed.type, "message_end", "type is message_end");
|
||||
assertEq(parsed.message.role, "assistant", "role is assistant");
|
||||
assertEq(parsed.message.usage.cost.total, 0.05, "cost.total is 0.05");
|
||||
assertTrue(typeof parsed.message.usage.input === "number", "input is number");
|
||||
assertTrue(typeof parsed.message.usage.output === "number", "output is number");
|
||||
assert.deepStrictEqual(parsed.type, "message_end", "type is message_end");
|
||||
assert.deepStrictEqual(parsed.message.role, "assistant", "role is assistant");
|
||||
assert.deepStrictEqual(parsed.message.usage.cost.total, 0.05, "cost.total is 0.05");
|
||||
assert.ok(typeof parsed.message.usage.input === "number", "input is number");
|
||||
assert.ok(typeof parsed.message.usage.output === "number", "output is number");
|
||||
});
|
||||
|
||||
it("malformed JSON does not throw (tested via parse safety)", () => {
|
||||
|
|
@ -111,7 +108,7 @@ describe("parallel-worker-monitoring", () => {
|
|||
JSON.parse(line);
|
||||
} catch {
|
||||
// Expected — processWorkerLine catches this silently
|
||||
assertTrue(true, `malformed line "${line.slice(0, 20)}" handled`);
|
||||
assert.ok(true, `malformed line "${line.slice(0, 20)}" handled`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -122,25 +119,25 @@ describe("parallel-worker-monitoring", () => {
|
|||
let total = 0;
|
||||
for (const c of costs) total += c;
|
||||
// Floating point: round to 2 decimal places for comparison
|
||||
assertEq(Math.round(total * 100) / 100, 0.28, "cost sum is correct");
|
||||
assert.deepStrictEqual(Math.round(total * 100) / 100, 0.28, "cost sum is correct");
|
||||
});
|
||||
|
||||
it("budget ceiling comparison works with typical values", () => {
|
||||
// Test the ceiling check pattern
|
||||
const ceiling = 5.0;
|
||||
assertTrue(0 < ceiling, "0 is under ceiling");
|
||||
assertTrue(4.99 < ceiling, "4.99 is under ceiling");
|
||||
assertTrue(!(5.0 < ceiling), "5.0 is at ceiling");
|
||||
assertTrue(!(5.01 < ceiling), "5.01 is over ceiling");
|
||||
assert.ok(0 < ceiling, "0 is under ceiling");
|
||||
assert.ok(4.99 < ceiling, "4.99 is under ceiling");
|
||||
assert.ok(!(5.0 < ceiling), "5.0 is at ceiling");
|
||||
assert.ok(!(5.01 < ceiling), "5.01 is over ceiling");
|
||||
});
|
||||
|
||||
it("worker spawn args include --mode json", () => {
|
||||
// Verify the spawn command includes JSON mode for NDJSON output.
|
||||
// We can't easily test the actual spawn, but we verify the args pattern.
|
||||
const expectedArgs = ["--mode", "json", "--print", "/gsd auto"];
|
||||
assertTrue(expectedArgs.includes("--mode"), "args include --mode");
|
||||
assertTrue(expectedArgs.includes("json"), "args include json");
|
||||
assertTrue(expectedArgs.indexOf("--mode") < expectedArgs.indexOf("json"),
|
||||
assert.ok(expectedArgs.includes("--mode"), "args include --mode");
|
||||
assert.ok(expectedArgs.includes("json"), "args include json");
|
||||
assert.ok(expectedArgs.indexOf("--mode") < expectedArgs.indexOf("json"),
|
||||
"--mode comes before json");
|
||||
});
|
||||
|
||||
|
|
@ -168,8 +165,8 @@ describe("parallel-worker-monitoring", () => {
|
|||
}, null, 2));
|
||||
refreshWorkerStatuses(base, { restoreIfNeeded: true });
|
||||
const workers = getWorkerStatuses();
|
||||
assertEq(workers.length, 1, "restored one worker");
|
||||
assertEq(workers[0].milestoneId, "M001", "worker restored from persisted state");
|
||||
assert.deepStrictEqual(workers.length, 1, "restored one worker");
|
||||
assert.deepStrictEqual(workers[0].milestoneId, "M001", "worker restored from persisted state");
|
||||
} finally {
|
||||
resetOrchestrator();
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
|
|
@ -193,8 +190,8 @@ describe("parallel-worker-monitoring", () => {
|
|||
}, null, 2));
|
||||
refreshWorkerStatuses(base, { restoreIfNeeded: true });
|
||||
const workers = getWorkerStatuses();
|
||||
assertEq(workers[0].state, "running", "live session status restored");
|
||||
assertEq(workers[0].completedUnits, 3, "completed units restored from status file");
|
||||
assert.deepStrictEqual(workers[0].state, "running", "live session status restored");
|
||||
assert.deepStrictEqual(workers[0].completedUnits, 3, "completed units restored from status file");
|
||||
} finally {
|
||||
resetOrchestrator();
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
|
|
|
|||
|
|
@ -13,11 +13,12 @@
|
|||
* - Cost projection with budget ceiling awareness
|
||||
*/
|
||||
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
import {
|
||||
registerWorker,
|
||||
updateWorker,
|
||||
|
|
@ -43,8 +44,6 @@ import {
|
|||
predictRemainingCost,
|
||||
} from '../metrics.ts';
|
||||
|
||||
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
|
||||
|
||||
// ─── Fixture helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function createFixtureBase(): string {
|
||||
|
|
@ -83,9 +82,9 @@ function cleanup(base: string): void {
|
|||
|
||||
// ─── E2E: Parallel workers across M001 and M002 ──────────────────────────────
|
||||
|
||||
console.log("\n=== E2E: Parallel workers across milestones ===");
|
||||
|
||||
{
|
||||
describe('parallel-workers-multi-milestone-e2e', () => {
|
||||
test('E2E: Parallel workers across milestones', () => {
|
||||
resetWorkerRegistry();
|
||||
const base = createFixtureBase();
|
||||
|
||||
|
|
@ -99,52 +98,49 @@ console.log("\n=== E2E: Parallel workers across milestones ===");
|
|||
const w2 = registerWorker("researcher", "Research M001 APIs", 1, 3, batch1Id);
|
||||
const w3 = registerWorker("worker", "Implement M001 feature", 2, 3, batch1Id);
|
||||
|
||||
assertEq(getActiveWorkers().length, 3, "M001: 3 parallel workers registered");
|
||||
assertTrue(hasActiveWorkers(), "M001: has active workers");
|
||||
assert.deepStrictEqual(getActiveWorkers().length, 3, "M001: 3 parallel workers registered");
|
||||
assert.ok(hasActiveWorkers(), "M001: has active workers");
|
||||
|
||||
const batches1 = getWorkerBatches();
|
||||
assertEq(batches1.size, 1, "M001: single batch");
|
||||
assertEq(batches1.get(batch1Id)!.length, 3, "M001: batch has 3 workers");
|
||||
assert.deepStrictEqual(batches1.size, 1, "M001: single batch");
|
||||
assert.deepStrictEqual(batches1.get(batch1Id)!.length, 3, "M001: batch has 3 workers");
|
||||
|
||||
// Complete M001 workers
|
||||
updateWorker(w1, "completed");
|
||||
updateWorker(w2, "completed");
|
||||
updateWorker(w3, "completed");
|
||||
assertTrue(!hasActiveWorkers(), "M001: no active workers after completion");
|
||||
assert.ok(!hasActiveWorkers(), "M001: no active workers after completion");
|
||||
|
||||
// Simulate M002 parallel workers (batch 2) — overlapping with M001 cleanup
|
||||
const batch2Id = "batch-m002";
|
||||
const w4 = registerWorker("scout", "Explore M002 codebase", 0, 2, batch2Id);
|
||||
const w5 = registerWorker("worker", "Implement M002 feature", 1, 2, batch2Id);
|
||||
|
||||
assertTrue(hasActiveWorkers(), "M002: has active workers");
|
||||
assert.ok(hasActiveWorkers(), "M002: has active workers");
|
||||
const batches2 = getWorkerBatches();
|
||||
// M001 workers may still be in cleanup window (5s timeout), M002 workers are active
|
||||
assertTrue(batches2.has(batch2Id), "M002: batch exists");
|
||||
assertEq(batches2.get(batch2Id)!.length, 2, "M002: batch has 2 workers");
|
||||
assert.ok(batches2.has(batch2Id), "M002: batch exists");
|
||||
assert.deepStrictEqual(batches2.get(batch2Id)!.length, 2, "M002: batch has 2 workers");
|
||||
|
||||
// One worker fails in M002
|
||||
updateWorker(w4, "completed");
|
||||
updateWorker(w5, "failed");
|
||||
assertTrue(!hasActiveWorkers(), "M002: no active workers after all finish");
|
||||
assert.ok(!hasActiveWorkers(), "M002: no active workers after all finish");
|
||||
|
||||
// Verify worker statuses reflect correctly
|
||||
const allWorkers = getActiveWorkers();
|
||||
const m002Workers = allWorkers.filter(w => w.batchId === batch2Id);
|
||||
if (m002Workers.length > 0) {
|
||||
const failedWorker = m002Workers.find(w => w.status === "failed");
|
||||
assertTrue(failedWorker !== undefined, "M002: failed worker tracked");
|
||||
assertEq(failedWorker?.agent, "worker", "M002: failed worker is 'worker'");
|
||||
assert.ok(failedWorker !== undefined, "M002: failed worker tracked");
|
||||
assert.deepStrictEqual(failedWorker?.agent, "worker", "M002: failed worker is 'worker'");
|
||||
}
|
||||
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── E2E: Metrics accumulation across milestones ──────────────────────────────
|
||||
|
||||
console.log("\n=== E2E: Metrics across milestones ===");
|
||||
|
||||
{
|
||||
test('E2E: Metrics across milestones', () => {
|
||||
const base = createFixtureBase();
|
||||
|
||||
// Build a ledger spanning two milestones
|
||||
|
|
@ -175,90 +171,84 @@ console.log("\n=== E2E: Metrics across milestones ===");
|
|||
|
||||
// Verify totals
|
||||
const totals = getProjectTotals(loaded.units);
|
||||
assertEq(totals.units, 13, "metrics: 13 total units across M001+M002");
|
||||
assert.deepStrictEqual(totals.units, 13, "metrics: 13 total units across M001+M002");
|
||||
const totalCost = loaded.units.reduce((sum, u) => sum + u.cost, 0);
|
||||
assertTrue(Math.abs(totals.cost - totalCost) < 0.001, "metrics: total cost matches sum");
|
||||
assert.ok(Math.abs(totals.cost - totalCost) < 0.001, "metrics: total cost matches sum");
|
||||
|
||||
// Verify phase aggregation
|
||||
const phases = aggregateByPhase(loaded.units);
|
||||
const research = phases.find(p => p.phase === "research");
|
||||
assertTrue(research !== undefined, "metrics: research phase exists");
|
||||
assertEq(research!.units, 2, "metrics: 2 research units (M001 + M002)");
|
||||
assert.ok(research !== undefined, "metrics: research phase exists");
|
||||
assert.deepStrictEqual(research!.units, 2, "metrics: 2 research units (M001 + M002)");
|
||||
|
||||
const execution = phases.find(p => p.phase === "execution");
|
||||
assertTrue(execution !== undefined, "metrics: execution phase exists");
|
||||
assertEq(execution!.units, 4, "metrics: 4 execution units across both milestones");
|
||||
assert.ok(execution !== undefined, "metrics: execution phase exists");
|
||||
assert.deepStrictEqual(execution!.units, 4, "metrics: 4 execution units across both milestones");
|
||||
|
||||
// Verify slice aggregation
|
||||
const slices = aggregateBySlice(loaded.units);
|
||||
assertTrue(slices.length >= 4, "metrics: at least 4 slice aggregates (M001/S01, M001/S02, M002/S01, milestone-level)");
|
||||
assert.ok(slices.length >= 4, "metrics: at least 4 slice aggregates (M001/S01, M001/S02, M002/S01, milestone-level)");
|
||||
|
||||
const m001s01 = slices.find(s => s.sliceId === "M001/S01");
|
||||
assertTrue(m001s01 !== undefined, "metrics: M001/S01 slice aggregate exists");
|
||||
assert.ok(m001s01 !== undefined, "metrics: M001/S01 slice aggregate exists");
|
||||
// M001/S01 has: plan-slice + T01 + T02 + complete-slice = 4 units
|
||||
assertEq(m001s01!.units, 4, "metrics: M001/S01 has 4 units");
|
||||
assert.deepStrictEqual(m001s01!.units, 4, "metrics: M001/S01 has 4 units");
|
||||
|
||||
// Cost projection
|
||||
const projLines = formatCostProjection(slices, 3, 2.0);
|
||||
assertTrue(projLines.length >= 1, "metrics: cost projection generated");
|
||||
assertMatch(projLines[0], /Projected remaining/, "metrics: projection line text");
|
||||
assert.ok(projLines.length >= 1, "metrics: cost projection generated");
|
||||
assert.match(projLines[0], /Projected remaining/, "metrics: projection line text");
|
||||
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── E2E: Budget alert progression through all thresholds ─────────────────────
|
||||
|
||||
console.log("\n=== E2E: Budget alert progression 0→75→80→90→100 ===");
|
||||
|
||||
{
|
||||
test('E2E: Budget alert progression 0→75→80→90→100', () => {
|
||||
// Simulate spending progression against a $10 budget ceiling
|
||||
const ceiling = 10.0;
|
||||
|
||||
// Start: 50% spent
|
||||
let lastLevel = getBudgetAlertLevel(5.0 / ceiling);
|
||||
assertEq(lastLevel, 0, "budget: 50% → level 0");
|
||||
assertEq(getNewBudgetAlertLevel(0, 5.0 / ceiling), null, "budget: no alert at 50%");
|
||||
assert.deepStrictEqual(lastLevel, 0, "budget: 50% → level 0");
|
||||
assert.deepStrictEqual(getNewBudgetAlertLevel(0, 5.0 / ceiling), null, "budget: no alert at 50%");
|
||||
|
||||
// Spend to 75%
|
||||
let newLevel = getNewBudgetAlertLevel(lastLevel, 7.5 / ceiling);
|
||||
assertEq(newLevel, 75, "budget: alert fires at 75%");
|
||||
assert.deepStrictEqual(newLevel, 75, "budget: alert fires at 75%");
|
||||
lastLevel = newLevel!;
|
||||
|
||||
// Spend to 78% — no alert (between 75 and 80)
|
||||
assertEq(getNewBudgetAlertLevel(lastLevel, 7.8 / ceiling), null, "budget: no alert at 78%");
|
||||
assert.deepStrictEqual(getNewBudgetAlertLevel(lastLevel, 7.8 / ceiling), null, "budget: no alert at 78%");
|
||||
|
||||
// Spend to 80% — 80% approach alert
|
||||
newLevel = getNewBudgetAlertLevel(lastLevel, 8.0 / ceiling);
|
||||
assertEq(newLevel, 80, "budget: approach alert fires at 80%");
|
||||
assert.deepStrictEqual(newLevel, 80, "budget: approach alert fires at 80%");
|
||||
lastLevel = newLevel!;
|
||||
|
||||
// Spend to 85% — no alert (still at 80 level)
|
||||
assertEq(getNewBudgetAlertLevel(lastLevel, 8.5 / ceiling), null, "budget: no alert at 85%");
|
||||
assert.deepStrictEqual(getNewBudgetAlertLevel(lastLevel, 8.5 / ceiling), null, "budget: no alert at 85%");
|
||||
|
||||
// Spend to 90%
|
||||
newLevel = getNewBudgetAlertLevel(lastLevel, 9.0 / ceiling);
|
||||
assertEq(newLevel, 90, "budget: alert fires at 90%");
|
||||
assert.deepStrictEqual(newLevel, 90, "budget: alert fires at 90%");
|
||||
lastLevel = newLevel!;
|
||||
|
||||
// Spend to 100%
|
||||
newLevel = getNewBudgetAlertLevel(lastLevel, 10.0 / ceiling);
|
||||
assertEq(newLevel, 100, "budget: alert fires at 100%");
|
||||
assert.deepStrictEqual(newLevel, 100, "budget: alert fires at 100%");
|
||||
lastLevel = newLevel!;
|
||||
|
||||
// Over budget — no re-emission
|
||||
assertEq(getNewBudgetAlertLevel(lastLevel, 12.0 / ceiling), null, "budget: no re-alert over 100%");
|
||||
assert.deepStrictEqual(getNewBudgetAlertLevel(lastLevel, 12.0 / ceiling), null, "budget: no re-alert over 100%");
|
||||
|
||||
// Enforcement at 80% — still "none" (enforcement only at 100%)
|
||||
assertEq(getBudgetEnforcementAction("pause", 0.80), "none", "budget: no enforcement at 80%");
|
||||
assertEq(getBudgetEnforcementAction("halt", 0.80), "none", "budget: no enforcement at 80%");
|
||||
assertEq(getBudgetEnforcementAction("warn", 0.80), "none", "budget: no enforcement at 80%");
|
||||
}
|
||||
assert.deepStrictEqual(getBudgetEnforcementAction("pause", 0.80), "none", "budget: no enforcement at 80%");
|
||||
assert.deepStrictEqual(getBudgetEnforcementAction("halt", 0.80), "none", "budget: no enforcement at 80%");
|
||||
assert.deepStrictEqual(getBudgetEnforcementAction("warn", 0.80), "none", "budget: no enforcement at 80%");
|
||||
});
|
||||
|
||||
// ─── E2E: Budget prediction with multi-milestone cost data ────────────────────
|
||||
|
||||
console.log("\n=== E2E: Budget prediction across milestones ===");
|
||||
|
||||
{
|
||||
test('E2E: Budget prediction across milestones', () => {
|
||||
const units: UnitMetrics[] = [
|
||||
makeUnit({ type: "execute-task", id: "M001/S01/T01", cost: 0.10 }),
|
||||
makeUnit({ type: "execute-task", id: "M001/S01/T02", cost: 0.15 }),
|
||||
|
|
@ -268,30 +258,27 @@ console.log("\n=== E2E: Budget prediction across milestones ===");
|
|||
];
|
||||
|
||||
const avgCosts = getAverageCostPerUnitType(units);
|
||||
assertTrue(avgCosts.has("execute-task"), "prediction: has execute-task average");
|
||||
assertTrue(avgCosts.has("plan-slice"), "prediction: has plan-slice average");
|
||||
assert.ok(avgCosts.has("execute-task"), "prediction: has execute-task average");
|
||||
assert.ok(avgCosts.has("plan-slice"), "prediction: has plan-slice average");
|
||||
|
||||
// Average execute-task cost: (0.10 + 0.15 + 0.20) / 3 = 0.15
|
||||
const execAvg = avgCosts.get("execute-task")!;
|
||||
assertTrue(Math.abs(execAvg - 0.15) < 0.001, `prediction: execute-task avg is $0.15 (got ${execAvg})`);
|
||||
assert.ok(Math.abs(execAvg - 0.15) < 0.001, `prediction: execute-task avg is $0.15 (got ${execAvg})`);
|
||||
|
||||
// Average plan-slice cost: (0.05 + 0.08) / 2 = 0.065
|
||||
const planAvg = avgCosts.get("plan-slice")!;
|
||||
assertTrue(Math.abs(planAvg - 0.065) < 0.001, `prediction: plan-slice avg is $0.065 (got ${planAvg})`);
|
||||
assert.ok(Math.abs(planAvg - 0.065) < 0.001, `prediction: plan-slice avg is $0.065 (got ${planAvg})`);
|
||||
|
||||
// Predict remaining cost for 3 more execute-tasks and 1 plan-slice
|
||||
const remaining = predictRemainingCost(avgCosts, [
|
||||
"execute-task", "execute-task", "execute-task", "plan-slice",
|
||||
]);
|
||||
// Expected: 3 * 0.15 + 1 * 0.065 = 0.515
|
||||
assertTrue(Math.abs(remaining - 0.515) < 0.001, `prediction: remaining cost ~$0.515 (got ${remaining})`);
|
||||
}
|
||||
assert.ok(Math.abs(remaining - 0.515) < 0.001, `prediction: remaining cost ~$0.515 (got ${remaining})`);
|
||||
});
|
||||
|
||||
// ─── E2E: Parallel workers + budget alerts combined scenario ──────────────────
|
||||
|
||||
console.log("\n=== E2E: Combined parallel workers + budget monitoring ===");
|
||||
|
||||
{
|
||||
test('E2E: Combined parallel workers + budget monitoring', () => {
|
||||
resetWorkerRegistry();
|
||||
|
||||
// Simulate a scenario: 3 parallel workers running while budget is at 78%
|
||||
|
|
@ -303,34 +290,31 @@ console.log("\n=== E2E: Combined parallel workers + budget monitoring ===");
|
|||
// Budget is at 78% — no alert yet (between 75 and 80)
|
||||
const ceiling = 10.0;
|
||||
let lastLevel: ReturnType<typeof getBudgetAlertLevel> = 75; // already got 75% alert
|
||||
assertEq(getNewBudgetAlertLevel(lastLevel, 7.8 / ceiling), null, "combined: no alert at 78% with workers running");
|
||||
assertTrue(hasActiveWorkers(), "combined: workers running during budget check");
|
||||
assert.deepStrictEqual(getNewBudgetAlertLevel(lastLevel, 7.8 / ceiling), null, "combined: no alert at 78% with workers running");
|
||||
assert.ok(hasActiveWorkers(), "combined: workers running during budget check");
|
||||
|
||||
// First worker completes, cost rises to 80%
|
||||
updateWorker(w1, "completed");
|
||||
const level80 = getNewBudgetAlertLevel(lastLevel, 8.0 / ceiling);
|
||||
assertEq(level80, 80, "combined: 80% approach alert fires after worker completes");
|
||||
assert.deepStrictEqual(level80, 80, "combined: 80% approach alert fires after worker completes");
|
||||
lastLevel = level80!;
|
||||
|
||||
// Second worker completes, cost rises to 88%
|
||||
updateWorker(w2, "completed");
|
||||
assertEq(getNewBudgetAlertLevel(lastLevel, 8.8 / ceiling), null, "combined: no alert at 88%");
|
||||
assert.deepStrictEqual(getNewBudgetAlertLevel(lastLevel, 8.8 / ceiling), null, "combined: no alert at 88%");
|
||||
|
||||
// Third worker completes, cost reaches 90%
|
||||
updateWorker(w3, "completed");
|
||||
const level90 = getNewBudgetAlertLevel(lastLevel, 9.0 / ceiling);
|
||||
assertEq(level90, 90, "combined: 90% alert fires after all workers complete");
|
||||
assert.deepStrictEqual(level90, 90, "combined: 90% alert fires after all workers complete");
|
||||
|
||||
assertTrue(!hasActiveWorkers(), "combined: no active workers at end");
|
||||
assert.ok(!hasActiveWorkers(), "combined: no active workers at end");
|
||||
|
||||
resetWorkerRegistry();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── E2E: formatCostProjection with budget ceiling warnings ───────────────────
|
||||
|
||||
console.log("\n=== E2E: Cost projection ceiling warnings ===");
|
||||
|
||||
{
|
||||
test('E2E: Cost projection ceiling warnings', () => {
|
||||
const slices = [
|
||||
{ sliceId: "M001/S01", units: 4, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 3.0, duration: 10000 },
|
||||
{ sliceId: "M001/S02", units: 3, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 4.0, duration: 8000 },
|
||||
|
|
@ -339,16 +323,15 @@ console.log("\n=== E2E: Cost projection ceiling warnings ===");
|
|||
|
||||
// With ceiling NOT yet reached
|
||||
const proj1 = formatCostProjection(slices, 2, 20.0);
|
||||
assertTrue(proj1.length >= 1, "projection: has projection line");
|
||||
assertMatch(proj1[0], /Projected remaining/, "projection: shows projection");
|
||||
assertTrue(proj1.length === 1, "projection: no ceiling warning when under budget");
|
||||
assert.ok(proj1.length >= 1, "projection: has projection line");
|
||||
assert.match(proj1[0], /Projected remaining/, "projection: shows projection");
|
||||
assert.ok(proj1.length === 1, "projection: no ceiling warning when under budget");
|
||||
|
||||
// With ceiling reached (spent 12.0 >= ceiling 10.0)
|
||||
const proj2 = formatCostProjection(slices, 2, 10.0);
|
||||
assertTrue(proj2.length >= 2, "projection: has ceiling warning when over budget");
|
||||
assertMatch(proj2[1], /ceiling/, "projection: ceiling warning text");
|
||||
}
|
||||
assert.ok(proj2.length >= 2, "projection: has ceiling warning when over budget");
|
||||
assert.match(proj2[1], /ceiling/, "projection: ceiling warning text");
|
||||
});
|
||||
|
||||
// ─── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
report();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
* 8. Discard milestone that has depends_on on others
|
||||
*/
|
||||
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
|
@ -20,16 +22,6 @@ import { deriveState, invalidateStateCache } from '../state.ts';
|
|||
import { clearPathCache } from '../paths.ts';
|
||||
import { parkMilestone, unparkMilestone, discardMilestone } from '../milestone-actions.ts';
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (condition) { passed++; } else { failed++; console.error(` FAIL: ${message}`); }
|
||||
}
|
||||
function assertEq<T>(actual: T, expected: T, message: string): void {
|
||||
if (JSON.stringify(actual) === JSON.stringify(expected)) { passed++; }
|
||||
else { failed++; console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); }
|
||||
}
|
||||
|
||||
function createFixture(): string {
|
||||
const b = mkdtempSync(join(tmpdir(), 'gsd-edge-'));
|
||||
|
|
@ -61,11 +53,10 @@ function createM(b: string, mid: string, opts?: { roadmap?: boolean; summary?: b
|
|||
function clear(): void { clearPathCache(); invalidateStateCache(); }
|
||||
function cleanup(b: string): void { rmSync(b, { recursive: true, force: true }); }
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
// ─── EDGE 1: Discard breaks depends_on → downstream is BLOCKED ────────
|
||||
console.log('\n=== EDGE 1: Discard breaks depends_on chain ===');
|
||||
{
|
||||
|
||||
describe('park-edge-cases', () => {
|
||||
test('EDGE 1: Discard breaks depends_on chain', async () => {
|
||||
const b = createFixture();
|
||||
try {
|
||||
createM(b, 'M001', { roadmap: true, summary: true }); // complete
|
||||
|
|
@ -78,17 +69,16 @@ async function main(): Promise<void> {
|
|||
|
||||
// M003 depends on M002 which no longer exists.
|
||||
// M002 is not in completeMilestoneIds → dep is unmet → M003 stays pending
|
||||
assertEq(s.registry.find(e => e.id === 'M003')?.status, 'pending', 'M003 stays pending after dep discarded');
|
||||
assertEq(s.phase, 'blocked', 'system is blocked (unmet dep on deleted milestone)');
|
||||
assert(s.blockers.length > 0, 'blockers list is not empty');
|
||||
assert.deepStrictEqual(s.registry.find(e => e.id === 'M003')?.status, 'pending', 'M003 stays pending after dep discarded');
|
||||
assert.deepStrictEqual(s.phase, 'blocked', 'system is blocked (unmet dep on deleted milestone)');
|
||||
assert.ok(s.blockers.length > 0, 'blockers list is not empty');
|
||||
} finally {
|
||||
cleanup(b);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── EDGE 2: Park blocks depends_on chain ────────────────────────────
|
||||
console.log('\n=== EDGE 2: Park blocks depends_on chain ===');
|
||||
{
|
||||
test('EDGE 2: Park blocks depends_on chain', async () => {
|
||||
const b = createFixture();
|
||||
try {
|
||||
createM(b, 'M001', { roadmap: true, summary: true });
|
||||
|
|
@ -98,17 +88,16 @@ async function main(): Promise<void> {
|
|||
|
||||
parkMilestone(b, 'M002', 'testing');
|
||||
const s = await deriveState(b);
|
||||
assertEq(s.registry.find(e => e.id === 'M003')?.status, 'pending', 'M003 pending when M002 parked');
|
||||
assert.deepStrictEqual(s.registry.find(e => e.id === 'M003')?.status, 'pending', 'M003 pending when M002 parked');
|
||||
// System should be blocked since M003 deps unmet and M002 is parked
|
||||
assert(s.activeMilestone === null, 'no active milestone (M002 parked, M003 dep-blocked)');
|
||||
assert.ok(s.activeMilestone === null, 'no active milestone (M002 parked, M003 dep-blocked)');
|
||||
} finally {
|
||||
cleanup(b);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── EDGE 3: Discard active, next (no deps) activates ────────────────
|
||||
console.log('\n=== EDGE 3: Discard active → next activates ===');
|
||||
{
|
||||
test('EDGE 3: Discard active → next activates', async () => {
|
||||
const b = createFixture();
|
||||
try {
|
||||
createM(b, 'M001', { roadmap: true });
|
||||
|
|
@ -117,16 +106,15 @@ async function main(): Promise<void> {
|
|||
|
||||
discardMilestone(b, 'M001');
|
||||
const s = await deriveState(b);
|
||||
assertEq(s.activeMilestone?.id, 'M002', 'M002 becomes active');
|
||||
assert(s.phase !== 'blocked', 'not blocked');
|
||||
assert.deepStrictEqual(s.activeMilestone?.id, 'M002', 'M002 becomes active');
|
||||
assert.ok(s.phase !== 'blocked', 'not blocked');
|
||||
} finally {
|
||||
cleanup(b);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── EDGE 4: Park all + discard all → clean pre-planning ─────────────
|
||||
console.log('\n=== EDGE 4: Park all → discard all → clean state ===');
|
||||
{
|
||||
test('EDGE 4: Park all → discard all → clean state', async () => {
|
||||
const b = createFixture();
|
||||
try {
|
||||
createM(b, 'M001', { roadmap: true });
|
||||
|
|
@ -138,30 +126,28 @@ async function main(): Promise<void> {
|
|||
discardMilestone(b, 'M001');
|
||||
discardMilestone(b, 'M002');
|
||||
const s = await deriveState(b);
|
||||
assertEq(s.activeMilestone, null, 'no active milestone');
|
||||
assertEq(s.phase, 'pre-planning', 'phase is pre-planning');
|
||||
assertEq(s.registry.length, 0, 'empty registry');
|
||||
assert(s.nextAction.includes('No milestones'), 'nextAction mentions no milestones');
|
||||
assert.deepStrictEqual(s.activeMilestone, null, 'no active milestone');
|
||||
assert.deepStrictEqual(s.phase, 'pre-planning', 'phase is pre-planning');
|
||||
assert.deepStrictEqual(s.registry.length, 0, 'empty registry');
|
||||
assert.ok(s.nextAction.includes('No milestones'), 'nextAction mentions no milestones');
|
||||
} finally {
|
||||
cleanup(b);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── EDGE 5: Discard non-existent → graceful false ───────────────────
|
||||
console.log('\n=== EDGE 5: Discard non-existent ===');
|
||||
{
|
||||
test('EDGE 5: Discard non-existent', () => {
|
||||
const b = createFixture();
|
||||
try {
|
||||
const result = discardMilestone(b, 'M999');
|
||||
assert(!result, 'returns false for non-existent');
|
||||
assert.ok(!result, 'returns false for non-existent');
|
||||
} finally {
|
||||
cleanup(b);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── EDGE 6: Queue order survives discards ───────────────────────────
|
||||
console.log('\n=== EDGE 6: Queue order after discard ===');
|
||||
{
|
||||
test('EDGE 6: Queue order after discard', async () => {
|
||||
const b = createFixture();
|
||||
try {
|
||||
createM(b, 'M001', { roadmap: true });
|
||||
|
|
@ -176,24 +162,23 @@ async function main(): Promise<void> {
|
|||
|
||||
// With custom queue order, M003 should be active first
|
||||
let s = await deriveState(b);
|
||||
assertEq(s.activeMilestone?.id, 'M003', 'M003 active (custom queue order)');
|
||||
assert.deepStrictEqual(s.activeMilestone?.id, 'M003', 'M003 active (custom queue order)');
|
||||
|
||||
// Discard M003 → M001 should be next per queue order
|
||||
discardMilestone(b, 'M003');
|
||||
s = await deriveState(b);
|
||||
assertEq(s.activeMilestone?.id, 'M001', 'M001 active after M003 discarded');
|
||||
assert.deepStrictEqual(s.activeMilestone?.id, 'M001', 'M001 active after M003 discarded');
|
||||
|
||||
// Verify queue order file was updated
|
||||
const order = JSON.parse(readFileSync(join(b, '.gsd', 'QUEUE-ORDER.json'), 'utf-8'));
|
||||
assert(!order.order.includes('M003'), 'M003 removed from QUEUE-ORDER.json');
|
||||
assert.ok(!order.order.includes('M003'), 'M003 removed from QUEUE-ORDER.json');
|
||||
} finally {
|
||||
cleanup(b);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── EDGE 7: Discard milestone that has deps on others ───────────────
|
||||
console.log('\n=== EDGE 7: Discard a milestone that depends on others ===');
|
||||
{
|
||||
test('EDGE 7: Discard a milestone that depends on others', async () => {
|
||||
const b = createFixture();
|
||||
try {
|
||||
createM(b, 'M001', { roadmap: true });
|
||||
|
|
@ -203,23 +188,22 @@ async function main(): Promise<void> {
|
|||
|
||||
// M002 depends on M001, so M001 is active, M002 is pending
|
||||
let s = await deriveState(b);
|
||||
assertEq(s.activeMilestone?.id, 'M001', 'M001 is active');
|
||||
assertEq(s.registry.find(e => e.id === 'M002')?.status, 'pending', 'M002 pending (dep on M001)');
|
||||
assert.deepStrictEqual(s.activeMilestone?.id, 'M001', 'M001 is active');
|
||||
assert.deepStrictEqual(s.registry.find(e => e.id === 'M002')?.status, 'pending', 'M002 pending (dep on M001)');
|
||||
|
||||
// Discard M002 (the one WITH deps) — should be fine, M003 becomes pending
|
||||
discardMilestone(b, 'M002');
|
||||
s = await deriveState(b);
|
||||
assertEq(s.activeMilestone?.id, 'M001', 'M001 still active');
|
||||
assert(!s.registry.some(e => e.id === 'M002'), 'M002 gone from registry');
|
||||
assertEq(s.registry.find(e => e.id === 'M003')?.status, 'pending', 'M003 is pending (after M001)');
|
||||
assert.deepStrictEqual(s.activeMilestone?.id, 'M001', 'M001 still active');
|
||||
assert.ok(!s.registry.some(e => e.id === 'M002'), 'M002 gone from registry');
|
||||
assert.deepStrictEqual(s.registry.find(e => e.id === 'M003')?.status, 'pending', 'M003 is pending (after M001)');
|
||||
} finally {
|
||||
cleanup(b);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── EDGE 8: Park → Discard → state transitions ─────────────────────
|
||||
console.log('\n=== EDGE 8: Park then discard same milestone ===');
|
||||
{
|
||||
test('EDGE 8: Park then discard same milestone', async () => {
|
||||
const b = createFixture();
|
||||
try {
|
||||
createM(b, 'M001', { roadmap: true });
|
||||
|
|
@ -228,22 +212,21 @@ async function main(): Promise<void> {
|
|||
|
||||
parkMilestone(b, 'M001', 'temp');
|
||||
let s = await deriveState(b);
|
||||
assertEq(s.activeMilestone?.id, 'M002', 'M002 active while M001 parked');
|
||||
assert.deepStrictEqual(s.activeMilestone?.id, 'M002', 'M002 active while M001 parked');
|
||||
|
||||
// Now discard the parked milestone
|
||||
discardMilestone(b, 'M001');
|
||||
s = await deriveState(b);
|
||||
assertEq(s.activeMilestone?.id, 'M002', 'M002 still active');
|
||||
assert(!s.registry.some(e => e.id === 'M001'), 'M001 gone completely');
|
||||
assertEq(s.registry.length, 1, 'only M002 in registry');
|
||||
assert.deepStrictEqual(s.activeMilestone?.id, 'M002', 'M002 still active');
|
||||
assert.ok(!s.registry.some(e => e.id === 'M001'), 'M001 gone completely');
|
||||
assert.deepStrictEqual(s.registry.length, 1, 'only M002 in registry');
|
||||
} finally {
|
||||
cleanup(b);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── EDGE 9: Complete + parked + pending coexist ─────────────────────
|
||||
console.log('\n=== EDGE 9: Mixed states — complete + parked + active ===');
|
||||
{
|
||||
test('EDGE 9: Mixed states — complete + parked + active', async () => {
|
||||
const b = createFixture();
|
||||
try {
|
||||
createM(b, 'M001', { roadmap: true, summary: true }); // complete
|
||||
|
|
@ -254,23 +237,17 @@ async function main(): Promise<void> {
|
|||
|
||||
parkMilestone(b, 'M002', 'parked');
|
||||
const s = await deriveState(b);
|
||||
assertEq(s.registry.find(e => e.id === 'M001')?.status, 'complete', 'M001 complete');
|
||||
assertEq(s.registry.find(e => e.id === 'M002')?.status, 'parked', 'M002 parked');
|
||||
assertEq(s.registry.find(e => e.id === 'M003')?.status, 'active', 'M003 active');
|
||||
assertEq(s.registry.find(e => e.id === 'M004')?.status, 'pending', 'M004 pending');
|
||||
assertEq(s.activeMilestone?.id, 'M003', 'M003 is the active milestone');
|
||||
assertEq(s.progress?.milestones.done, 1, '1 done');
|
||||
assertEq(s.progress?.milestones.total, 4, '4 total');
|
||||
assert.deepStrictEqual(s.registry.find(e => e.id === 'M001')?.status, 'complete', 'M001 complete');
|
||||
assert.deepStrictEqual(s.registry.find(e => e.id === 'M002')?.status, 'parked', 'M002 parked');
|
||||
assert.deepStrictEqual(s.registry.find(e => e.id === 'M003')?.status, 'active', 'M003 active');
|
||||
assert.deepStrictEqual(s.registry.find(e => e.id === 'M004')?.status, 'pending', 'M004 pending');
|
||||
assert.deepStrictEqual(s.activeMilestone?.id, 'M003', 'M003 is the active milestone');
|
||||
assert.deepStrictEqual(s.progress?.milestones.done, 1, '1 done');
|
||||
assert.deepStrictEqual(s.progress?.milestones.total, 4, '4 total');
|
||||
} finally {
|
||||
cleanup(b);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
console.log(`\n${'='.repeat(50)}`);
|
||||
console.log(`Results: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
else console.log('All edge cases passed!');
|
||||
}
|
||||
});
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
|
@ -6,26 +8,7 @@ import { deriveState, invalidateStateCache, getActiveMilestoneId } from '../stat
|
|||
import { clearPathCache } from '../paths.ts';
|
||||
import { parkMilestone, unparkMilestone, discardMilestone, isParked, getParkedReason } from '../milestone-actions.ts';
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (condition) {
|
||||
passed++;
|
||||
} else {
|
||||
failed++;
|
||||
console.error(` FAIL: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEq<T>(actual: T, expected: T, message: string): void {
|
||||
if (JSON.stringify(actual) === JSON.stringify(expected)) {
|
||||
passed++;
|
||||
} else {
|
||||
failed++;
|
||||
console.error(` FAIL: ${message} — expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fixture Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -89,30 +72,28 @@ function clearCaches(): void {
|
|||
// Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
// ─── Test 1: parkMilestone creates PARKED.md ──────────────────────────
|
||||
console.log('\n=== parkMilestone creates PARKED.md ===');
|
||||
{
|
||||
|
||||
describe('park-milestone', () => {
|
||||
test('parkMilestone creates PARKED.md', () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
clearCaches();
|
||||
|
||||
const success = parkMilestone(base, 'M001', 'Priority shift');
|
||||
assert(success, 'parkMilestone returns true');
|
||||
assert(isParked(base, 'M001'), 'isParked returns true after parking');
|
||||
assert.ok(success, 'parkMilestone returns true');
|
||||
assert.ok(isParked(base, 'M001'), 'isParked returns true after parking');
|
||||
|
||||
const reason = getParkedReason(base, 'M001');
|
||||
assertEq(reason, 'Priority shift', 'reason matches');
|
||||
assert.deepStrictEqual(reason, 'Priority shift', 'reason matches');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Test 2: parkMilestone is idempotent — fails if already parked ────
|
||||
console.log('\n=== parkMilestone fails if already parked ===');
|
||||
{
|
||||
test('parkMilestone fails if already parked', () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
|
|
@ -120,50 +101,47 @@ async function main(): Promise<void> {
|
|||
|
||||
parkMilestone(base, 'M001', 'First park');
|
||||
const secondPark = parkMilestone(base, 'M001', 'Second park');
|
||||
assert(!secondPark, 'second parkMilestone returns false');
|
||||
assertEq(getParkedReason(base, 'M001'), 'First park', 'reason unchanged from first park');
|
||||
assert.ok(!secondPark, 'second parkMilestone returns false');
|
||||
assert.deepStrictEqual(getParkedReason(base, 'M001'), 'First park', 'reason unchanged from first park');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Test 3: unparkMilestone removes PARKED.md ────────────────────────
|
||||
console.log('\n=== unparkMilestone removes PARKED.md ===');
|
||||
{
|
||||
test('unparkMilestone removes PARKED.md', () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
clearCaches();
|
||||
|
||||
parkMilestone(base, 'M001', 'Test reason');
|
||||
assert(isParked(base, 'M001'), 'milestone is parked');
|
||||
assert.ok(isParked(base, 'M001'), 'milestone is parked');
|
||||
|
||||
const success = unparkMilestone(base, 'M001');
|
||||
assert(success, 'unparkMilestone returns true');
|
||||
assert(!isParked(base, 'M001'), 'isParked returns false after unpark');
|
||||
assert.ok(success, 'unparkMilestone returns true');
|
||||
assert.ok(!isParked(base, 'M001'), 'isParked returns false after unpark');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Test 4: unparkMilestone fails if not parked ──────────────────────
|
||||
console.log('\n=== unparkMilestone fails if not parked ===');
|
||||
{
|
||||
test('unparkMilestone fails if not parked', () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
clearCaches();
|
||||
|
||||
const result = unparkMilestone(base, 'M001');
|
||||
assert(!result, 'unparkMilestone returns false when not parked');
|
||||
assert.ok(!result, 'unparkMilestone returns false when not parked');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Test 5: deriveState returns 'parked' status ──────────────────────
|
||||
console.log('\n=== deriveState returns parked status ===');
|
||||
{
|
||||
test('deriveState returns parked status', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
|
|
@ -173,16 +151,15 @@ async function main(): Promise<void> {
|
|||
|
||||
const state = await deriveState(base);
|
||||
const entry = state.registry.find(e => e.id === 'M001');
|
||||
assert(!!entry, 'M001 in registry');
|
||||
assertEq(entry?.status, 'parked', 'status is parked');
|
||||
assert.ok(!!entry, 'M001 in registry');
|
||||
assert.deepStrictEqual(entry?.status, 'parked', 'status is parked');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Test 6: deriveState skips parked milestone for active ─────────────
|
||||
console.log('\n=== deriveState skips parked milestone ===');
|
||||
{
|
||||
test('deriveState skips parked milestone', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
|
|
@ -191,29 +168,28 @@ async function main(): Promise<void> {
|
|||
|
||||
// Before park: M001 is active
|
||||
const stateBefore = await deriveState(base);
|
||||
assertEq(stateBefore.activeMilestone?.id, 'M001', 'before park: M001 is active');
|
||||
assert.deepStrictEqual(stateBefore.activeMilestone?.id, 'M001', 'before park: M001 is active');
|
||||
|
||||
parkMilestone(base, 'M001', 'Testing');
|
||||
|
||||
// After park: M002 becomes active
|
||||
const stateAfter = await deriveState(base);
|
||||
assertEq(stateAfter.activeMilestone?.id, 'M002', 'after park: M002 is active');
|
||||
assert.deepStrictEqual(stateAfter.activeMilestone?.id, 'M002', 'after park: M002 is active');
|
||||
|
||||
// M001 still in registry as parked
|
||||
const m001 = stateAfter.registry.find(e => e.id === 'M001');
|
||||
assertEq(m001?.status, 'parked', 'M001 has parked status');
|
||||
assert.deepStrictEqual(m001?.status, 'parked', 'M001 has parked status');
|
||||
|
||||
// M002 is active
|
||||
const m002 = stateAfter.registry.find(e => e.id === 'M002');
|
||||
assertEq(m002?.status, 'active', 'M002 has active status');
|
||||
assert.deepStrictEqual(m002?.status, 'active', 'M002 has active status');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Test 7: getActiveMilestoneId skips parked ────────────────────────
|
||||
console.log('\n=== getActiveMilestoneId skips parked ===');
|
||||
{
|
||||
test('getActiveMilestoneId skips parked', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
|
|
@ -223,15 +199,14 @@ async function main(): Promise<void> {
|
|||
parkMilestone(base, 'M001', 'Testing');
|
||||
|
||||
const activeId = await getActiveMilestoneId(base);
|
||||
assertEq(activeId, 'M002', 'getActiveMilestoneId returns M002');
|
||||
assert.deepStrictEqual(activeId, 'M002', 'getActiveMilestoneId returns M002');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Test 8: Parked milestone does NOT satisfy depends_on ─────────────
|
||||
console.log('\n=== Parked milestone does not satisfy depends_on ===');
|
||||
{
|
||||
test('Parked milestone does not satisfy depends_on', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
|
|
@ -243,18 +218,17 @@ async function main(): Promise<void> {
|
|||
const state = await deriveState(base);
|
||||
// M001 is parked, M002 depends on M001 → M002 should be pending, not active
|
||||
const m002 = state.registry.find(e => e.id === 'M002');
|
||||
assertEq(m002?.status, 'pending', 'M002 stays pending when M001 is parked');
|
||||
assert.deepStrictEqual(m002?.status, 'pending', 'M002 stays pending when M001 is parked');
|
||||
|
||||
// No active milestone (both are blocked/parked)
|
||||
assertEq(state.activeMilestone, null, 'no active milestone');
|
||||
assert.deepStrictEqual(state.activeMilestone, null, 'no active milestone');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Test 9: Park then unpark restores correct status ─────────────────
|
||||
console.log('\n=== Park then unpark restores status ===');
|
||||
{
|
||||
test('Park then unpark restores status', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
|
|
@ -264,43 +238,41 @@ async function main(): Promise<void> {
|
|||
// Park M001
|
||||
parkMilestone(base, 'M001', 'Testing');
|
||||
const stateParked = await deriveState(base);
|
||||
assertEq(stateParked.activeMilestone?.id, 'M002', 'while parked: M002 is active');
|
||||
assert.deepStrictEqual(stateParked.activeMilestone?.id, 'M002', 'while parked: M002 is active');
|
||||
|
||||
// Unpark M001 — M001 should become active again (it's first in queue)
|
||||
unparkMilestone(base, 'M001');
|
||||
const stateUnparked = await deriveState(base);
|
||||
assertEq(stateUnparked.activeMilestone?.id, 'M001', 'after unpark: M001 is active again');
|
||||
assertEq(stateUnparked.registry.find(e => e.id === 'M001')?.status, 'active', 'M001 is active status');
|
||||
assert.deepStrictEqual(stateUnparked.activeMilestone?.id, 'M001', 'after unpark: M001 is active again');
|
||||
assert.deepStrictEqual(stateUnparked.registry.find(e => e.id === 'M001')?.status, 'active', 'M001 is active status');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Test 10: discardMilestone removes directory ──────────────────────
|
||||
console.log('\n=== discardMilestone removes directory ===');
|
||||
{
|
||||
test('discardMilestone removes directory', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
clearCaches();
|
||||
|
||||
const mDir = join(base, '.gsd', 'milestones', 'M001');
|
||||
assert(existsSync(mDir), 'milestone dir exists before discard');
|
||||
assert.ok(existsSync(mDir), 'milestone dir exists before discard');
|
||||
|
||||
const success = discardMilestone(base, 'M001');
|
||||
assert(success, 'discardMilestone returns true');
|
||||
assert(!existsSync(mDir), 'milestone dir removed after discard');
|
||||
assert.ok(success, 'discardMilestone returns true');
|
||||
assert.ok(!existsSync(mDir), 'milestone dir removed after discard');
|
||||
|
||||
const state = await deriveState(base);
|
||||
assert(!state.registry.some(e => e.id === 'M001'), 'M001 not in registry after discard');
|
||||
assert.ok(!state.registry.some(e => e.id === 'M001'), 'M001 not in registry after discard');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Test 11: discardMilestone updates queue order ────────────────────
|
||||
console.log('\n=== discardMilestone updates queue order ===');
|
||||
{
|
||||
test('discardMilestone updates queue order', () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
|
|
@ -315,16 +287,15 @@ async function main(): Promise<void> {
|
|||
|
||||
// Queue order should no longer include M001
|
||||
const queueContent = JSON.parse(readFileSync(queuePath, 'utf-8'));
|
||||
assert(!queueContent.order.includes('M001'), 'M001 removed from queue order');
|
||||
assert(queueContent.order.includes('M002'), 'M002 still in queue order');
|
||||
assert.ok(!queueContent.order.includes('M001'), 'M001 removed from queue order');
|
||||
assert.ok(queueContent.order.includes('M002'), 'M002 still in queue order');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Test 12: All milestones parked → no active milestone ─────────────
|
||||
console.log('\n=== All milestones parked → no active ===');
|
||||
{
|
||||
test('All milestones parked → no active', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true });
|
||||
|
|
@ -333,18 +304,17 @@ async function main(): Promise<void> {
|
|||
parkMilestone(base, 'M001', 'Testing');
|
||||
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.activeMilestone, null, 'no active milestone when all parked');
|
||||
assertEq(state.phase, 'pre-planning', 'phase is pre-planning');
|
||||
assert(state.registry.length === 1, 'registry still has 1 entry');
|
||||
assertEq(state.registry[0]?.status, 'parked', 'entry is parked');
|
||||
assert.deepStrictEqual(state.activeMilestone, null, 'no active milestone when all parked');
|
||||
assert.deepStrictEqual(state.phase, 'pre-planning', 'phase is pre-planning');
|
||||
assert.ok(state.registry.length === 1, 'registry still has 1 entry');
|
||||
assert.deepStrictEqual(state.registry[0]?.status, 'parked', 'entry is parked');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Test 13: Parked milestone without roadmap ────────────────────────
|
||||
console.log('\n=== Park milestone without roadmap ===');
|
||||
{
|
||||
test('Park milestone without roadmap', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001'); // No roadmap
|
||||
|
|
@ -354,16 +324,15 @@ async function main(): Promise<void> {
|
|||
parkMilestone(base, 'M001', 'Not ready yet');
|
||||
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.activeMilestone?.id, 'M002', 'M002 is active when M001 (no roadmap) is parked');
|
||||
assertEq(state.registry.find(e => e.id === 'M001')?.status, 'parked', 'M001 is parked');
|
||||
assert.deepStrictEqual(state.activeMilestone?.id, 'M002', 'M002 is active when M001 (no roadmap) is parked');
|
||||
assert.deepStrictEqual(state.registry.find(e => e.id === 'M001')?.status, 'parked', 'M001 is parked');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Test 14: Progress counts with parked milestone ───────────────────
|
||||
console.log('\n=== Progress counts with parked ===');
|
||||
{
|
||||
test('Progress counts with parked', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
createMilestone(base, 'M001', { withRoadmap: true, withSummary: true }); // complete
|
||||
|
|
@ -374,28 +343,12 @@ async function main(): Promise<void> {
|
|||
parkMilestone(base, 'M002', 'Parked');
|
||||
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.progress?.milestones.done, 1, '1 complete milestone');
|
||||
assertEq(state.progress?.milestones.total, 3, '3 total milestones (including parked)');
|
||||
assertEq(state.activeMilestone?.id, 'M003', 'M003 is active');
|
||||
assert.deepStrictEqual(state.progress?.milestones.done, 1, '1 complete milestone');
|
||||
assert.deepStrictEqual(state.progress?.milestones.total, 3, '3 total milestones (including parked)');
|
||||
assert.deepStrictEqual(state.activeMilestone?.id, 'M003', 'M003 is active');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Results
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log(`\n${'='.repeat(40)}`);
|
||||
console.log(`Results: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('All tests passed ✓');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,11 @@
|
|||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync, realpathSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import { gsdRoot, _clearGsdRootCache } from "../paths.ts";
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
/** Create a tmp dir and resolve symlinks + 8.3 short names (macOS /var→/private/var, Windows RUNNER~1→runneradmin). */
|
||||
function tmp(): string {
|
||||
const p = mkdtempSync(join(tmpdir(), "gsd-paths-test-"));
|
||||
|
|
@ -23,91 +21,78 @@ function initGit(dir: string): void {
|
|||
spawnSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: dir });
|
||||
}
|
||||
|
||||
// ── tests ──────────────────────────────────────────────────────────────────
|
||||
describe('paths', () => {
|
||||
test('Case 1: .gsd exists at basePath — fast path', () => {
|
||||
const root = tmp();
|
||||
try {
|
||||
mkdirSync(join(root, ".gsd"));
|
||||
_clearGsdRootCache();
|
||||
const result = gsdRoot(root);
|
||||
assert.deepStrictEqual(result, join(root, ".gsd"), "fast path: returns basePath/.gsd");
|
||||
} finally { cleanup(root); }
|
||||
});
|
||||
|
||||
{
|
||||
// Case 1: .gsd exists at basePath — fast path
|
||||
const root = tmp();
|
||||
try {
|
||||
mkdirSync(join(root, ".gsd"));
|
||||
_clearGsdRootCache();
|
||||
const result = gsdRoot(root);
|
||||
assertEq(result, join(root, ".gsd"), "fast path: returns basePath/.gsd");
|
||||
} finally { cleanup(root); }
|
||||
}
|
||||
test('Case 2: .gsd exists at git root, cwd is a subdirectory', () => {
|
||||
const root = tmp();
|
||||
try {
|
||||
initGit(root);
|
||||
mkdirSync(join(root, ".gsd"));
|
||||
const sub = join(root, "src", "deep");
|
||||
mkdirSync(sub, { recursive: true });
|
||||
_clearGsdRootCache();
|
||||
const result = gsdRoot(sub);
|
||||
assert.deepStrictEqual(result, join(root, ".gsd"), "git-root probe: finds .gsd at git root from subdirectory");
|
||||
} finally { cleanup(root); }
|
||||
});
|
||||
|
||||
{
|
||||
// Case 2: .gsd exists at git root, cwd is a subdirectory
|
||||
const root = tmp();
|
||||
try {
|
||||
initGit(root);
|
||||
mkdirSync(join(root, ".gsd"));
|
||||
const sub = join(root, "src", "deep");
|
||||
mkdirSync(sub, { recursive: true });
|
||||
_clearGsdRootCache();
|
||||
const result = gsdRoot(sub);
|
||||
assertEq(result, join(root, ".gsd"), "git-root probe: finds .gsd at git root from subdirectory");
|
||||
} finally { cleanup(root); }
|
||||
}
|
||||
test('Case 3: .gsd in an ancestor — walk-up finds it', () => {
|
||||
const root = tmp();
|
||||
try {
|
||||
initGit(root);
|
||||
const project = join(root, "project");
|
||||
mkdirSync(join(project, ".gsd"), { recursive: true });
|
||||
const deep = join(project, "src", "deep");
|
||||
mkdirSync(deep, { recursive: true });
|
||||
_clearGsdRootCache();
|
||||
const result = gsdRoot(deep);
|
||||
assert.deepStrictEqual(result, join(project, ".gsd"), "walk-up: finds .gsd in ancestor when git root has none");
|
||||
} finally { cleanup(root); }
|
||||
});
|
||||
|
||||
{
|
||||
// Case 3: .gsd in an ancestor — walk-up finds it (git repo with no .gsd at root)
|
||||
const root = tmp();
|
||||
try {
|
||||
// Init a git repo so git probe returns root — but put .gsd one level deeper
|
||||
// to force the walk-up path: root/project/.gsd, cwd = root/project/src/deep
|
||||
initGit(root);
|
||||
const project = join(root, "project");
|
||||
mkdirSync(join(project, ".gsd"), { recursive: true });
|
||||
const deep = join(project, "src", "deep");
|
||||
mkdirSync(deep, { recursive: true });
|
||||
_clearGsdRootCache();
|
||||
// git probe returns root (no .gsd there), so walk-up takes over and finds project/.gsd
|
||||
const result = gsdRoot(deep);
|
||||
assertEq(result, join(project, ".gsd"), "walk-up: finds .gsd in ancestor when git root has none");
|
||||
} finally { cleanup(root); }
|
||||
}
|
||||
test('Case 4: .gsd nowhere — fallback returns original basePath/.gsd', () => {
|
||||
const root = tmp();
|
||||
try {
|
||||
initGit(root);
|
||||
const sub = join(root, "src");
|
||||
mkdirSync(sub, { recursive: true });
|
||||
_clearGsdRootCache();
|
||||
const result = gsdRoot(sub);
|
||||
assert.deepStrictEqual(result, join(sub, ".gsd"), "fallback: returns basePath/.gsd when .gsd not found anywhere");
|
||||
} finally { cleanup(root); }
|
||||
});
|
||||
|
||||
{
|
||||
// Case 4: .gsd nowhere — fallback returns original basePath/.gsd
|
||||
// Use an isolated git repo so we fully control the environment above basePath
|
||||
const root = tmp();
|
||||
try {
|
||||
initGit(root); // git root = root, no .gsd anywhere
|
||||
const sub = join(root, "src");
|
||||
mkdirSync(sub, { recursive: true });
|
||||
_clearGsdRootCache();
|
||||
const result = gsdRoot(sub);
|
||||
// git probe finds root (no .gsd), walk-up finds nothing → fallback = sub/.gsd
|
||||
assertEq(result, join(sub, ".gsd"), "fallback: returns basePath/.gsd when .gsd not found anywhere");
|
||||
} finally { cleanup(root); }
|
||||
}
|
||||
test('Case 5: cache — second call returns same value without re-probing', () => {
|
||||
const root = tmp();
|
||||
try {
|
||||
mkdirSync(join(root, ".gsd"));
|
||||
_clearGsdRootCache();
|
||||
const first = gsdRoot(root);
|
||||
const second = gsdRoot(root);
|
||||
assert.deepStrictEqual(first, second, "cache: same result returned on second call");
|
||||
assert.ok(first === second, "cache: identity check (same string)");
|
||||
} finally { cleanup(root); }
|
||||
});
|
||||
|
||||
{
|
||||
// Case 5: cache — second call returns same value without re-probing
|
||||
const root = tmp();
|
||||
try {
|
||||
mkdirSync(join(root, ".gsd"));
|
||||
_clearGsdRootCache();
|
||||
const first = gsdRoot(root);
|
||||
const second = gsdRoot(root);
|
||||
assertEq(first, second, "cache: same result returned on second call");
|
||||
assertTrue(first === second, "cache: identity check (same string)");
|
||||
} finally { cleanup(root); }
|
||||
}
|
||||
|
||||
{
|
||||
// Case 6: .gsd at basePath takes precedence over ancestor .gsd
|
||||
const outer = tmp();
|
||||
try {
|
||||
initGit(outer);
|
||||
mkdirSync(join(outer, ".gsd"));
|
||||
const inner = join(outer, "nested");
|
||||
mkdirSync(join(inner, ".gsd"), { recursive: true });
|
||||
_clearGsdRootCache();
|
||||
const result = gsdRoot(inner);
|
||||
assertEq(result, join(inner, ".gsd"), "precedence: nearest .gsd wins over ancestor");
|
||||
} finally { cleanup(outer); }
|
||||
}
|
||||
|
||||
report();
|
||||
test('Case 6: .gsd at basePath takes precedence over ancestor .gsd', () => {
|
||||
const outer = tmp();
|
||||
try {
|
||||
initGit(outer);
|
||||
mkdirSync(join(outer, ".gsd"));
|
||||
const inner = join(outer, "nested");
|
||||
mkdirSync(join(inner, ".gsd"), { recursive: true });
|
||||
_clearGsdRootCache();
|
||||
const result = gsdRoot(inner);
|
||||
assert.deepStrictEqual(result, join(inner, ".gsd"), "precedence: nearest .gsd wins over ancestor");
|
||||
} finally { cleanup(outer); }
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
// GSD Extension — Hook Engine Tests (Post-Unit, Pre-Dispatch, State Persistence)
|
||||
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
import {
|
||||
checkPostUnitHooks,
|
||||
getActiveHook,
|
||||
|
|
@ -20,8 +21,6 @@ import {
|
|||
triggerHookManually,
|
||||
} from "../post-unit-hooks.ts";
|
||||
|
||||
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
|
||||
|
||||
// ─── Fixture Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function createFixtureBase(): string {
|
||||
|
|
@ -36,14 +35,14 @@ function createFixtureBase(): string {
|
|||
|
||||
// ─── resolveHookArtifactPath ───────────────────────────────────────────────
|
||||
|
||||
console.log("\n=== resolveHookArtifactPath ===");
|
||||
|
||||
{
|
||||
describe('post-unit-hooks', () => {
|
||||
test('resolveHookArtifactPath', () => {
|
||||
const base = "/project";
|
||||
|
||||
// Task-level
|
||||
const taskPath = resolveHookArtifactPath(base, "M001/S01/T01", "REVIEW-PASS.md");
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
taskPath,
|
||||
join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks", "T01-REVIEW-PASS.md"),
|
||||
"task-level artifact path",
|
||||
|
|
@ -51,7 +50,7 @@ console.log("\n=== resolveHookArtifactPath ===");
|
|||
|
||||
// Slice-level
|
||||
const slicePath = resolveHookArtifactPath(base, "M001/S01", "REVIEW-PASS.md");
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
slicePath,
|
||||
join(base, ".gsd", "milestones", "M001", "slices", "S01", "REVIEW-PASS.md"),
|
||||
"slice-level artifact path",
|
||||
|
|
@ -59,129 +58,106 @@ console.log("\n=== resolveHookArtifactPath ===");
|
|||
|
||||
// Milestone-level
|
||||
const milestonePath = resolveHookArtifactPath(base, "M001", "REVIEW-PASS.md");
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
milestonePath,
|
||||
join(base, ".gsd", "milestones", "M001", "REVIEW-PASS.md"),
|
||||
"milestone-level artifact path",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── resetHookState ────────────────────────────────────────────────────────
|
||||
|
||||
console.log("\n=== resetHookState ===");
|
||||
|
||||
{
|
||||
test('resetHookState', () => {
|
||||
resetHookState();
|
||||
assertEq(getActiveHook(), null, "no active hook after reset");
|
||||
assertTrue(!isRetryPending(), "no retry pending after reset");
|
||||
assertEq(consumeRetryTrigger(), null, "no retry trigger after reset");
|
||||
}
|
||||
assert.deepStrictEqual(getActiveHook(), null, "no active hook after reset");
|
||||
assert.ok(!isRetryPending(), "no retry pending after reset");
|
||||
assert.deepStrictEqual(consumeRetryTrigger(), null, "no retry trigger after reset");
|
||||
});
|
||||
|
||||
// ─── checkPostUnitHooks with no hooks configured ───────────────────────────
|
||||
|
||||
console.log("\n=== No hooks configured ===");
|
||||
|
||||
{
|
||||
test('No hooks configured', () => {
|
||||
resetHookState();
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
const result = checkPostUnitHooks("execute-task", "M001/S01/T01", base);
|
||||
assertEq(result, null, "returns null when no hooks configured");
|
||||
assert.deepStrictEqual(result, null, "returns null when no hooks configured");
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Hook units don't trigger hooks (no hook-on-hook) ──────────────────────
|
||||
|
||||
console.log("\n=== Hook-on-hook prevention ===");
|
||||
|
||||
{
|
||||
test('Hook-on-hook prevention', () => {
|
||||
resetHookState();
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
const result = checkPostUnitHooks("hook/code-review", "M001/S01/T01", base);
|
||||
assertEq(result, null, "hook units don't trigger other hooks");
|
||||
assert.deepStrictEqual(result, null, "hook units don't trigger other hooks");
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── consumeRetryTrigger clears state ──────────────────────────────────────
|
||||
|
||||
console.log("\n=== consumeRetryTrigger clears state ===");
|
||||
|
||||
{
|
||||
test('consumeRetryTrigger clears state', () => {
|
||||
resetHookState();
|
||||
assertEq(consumeRetryTrigger(), null, "no trigger initially");
|
||||
assertTrue(!isRetryPending(), "no retry initially");
|
||||
}
|
||||
assert.deepStrictEqual(consumeRetryTrigger(), null, "no trigger initially");
|
||||
assert.ok(!isRetryPending(), "no retry initially");
|
||||
});
|
||||
|
||||
// ─── Variable substitution in prompts ──────────────────────────────────────
|
||||
|
||||
console.log("\n=== Variable substitution ===");
|
||||
|
||||
{
|
||||
test('Variable substitution', () => {
|
||||
const base = "/project";
|
||||
|
||||
// 3-part ID
|
||||
const path3 = resolveHookArtifactPath(base, "M002/S03/T05", "result.md");
|
||||
assertTrue(path3.includes("M002"), "3-part ID extracts milestoneId");
|
||||
assertTrue(path3.includes("S03"), "3-part ID extracts sliceId");
|
||||
assertTrue(path3.includes("T05"), "3-part ID extracts taskId");
|
||||
assertTrue(path3.includes("milestones"), "3-part ID includes milestones/ segment");
|
||||
assert.ok(path3.includes("M002"), "3-part ID extracts milestoneId");
|
||||
assert.ok(path3.includes("S03"), "3-part ID extracts sliceId");
|
||||
assert.ok(path3.includes("T05"), "3-part ID extracts taskId");
|
||||
assert.ok(path3.includes("milestones"), "3-part ID includes milestones/ segment");
|
||||
|
||||
// 2-part ID
|
||||
const path2 = resolveHookArtifactPath(base, "M002/S03", "result.md");
|
||||
assertTrue(path2.includes("M002"), "2-part ID extracts milestoneId");
|
||||
assertTrue(path2.includes("S03"), "2-part ID extracts sliceId");
|
||||
assertTrue(path2.includes("milestones"), "2-part ID includes milestones/ segment");
|
||||
assert.ok(path2.includes("M002"), "2-part ID extracts milestoneId");
|
||||
assert.ok(path2.includes("S03"), "2-part ID extracts sliceId");
|
||||
assert.ok(path2.includes("milestones"), "2-part ID includes milestones/ segment");
|
||||
|
||||
// 1-part ID
|
||||
const path1 = resolveHookArtifactPath(base, "M002", "result.md");
|
||||
assertTrue(path1.includes("M002"), "1-part ID extracts milestoneId");
|
||||
assertTrue(path1.includes("milestones"), "1-part ID includes milestones/ segment");
|
||||
}
|
||||
assert.ok(path1.includes("M002"), "1-part ID extracts milestoneId");
|
||||
assert.ok(path1.includes("milestones"), "1-part ID includes milestones/ segment");
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Phase 2: Pre-Dispatch Hook Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n=== Pre-dispatch: no hooks configured ===");
|
||||
|
||||
{
|
||||
test('Pre-dispatch: no hooks configured', () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
const result = runPreDispatchHooks("execute-task", "M001/S01/T01", "original prompt", base);
|
||||
assertEq(result.action, "proceed", "proceeds when no hooks");
|
||||
assertEq(result.prompt, "original prompt", "prompt unchanged");
|
||||
assertEq(result.firedHooks.length, 0, "no hooks fired");
|
||||
assert.deepStrictEqual(result.action, "proceed", "proceeds when no hooks");
|
||||
assert.deepStrictEqual(result.prompt, "original prompt", "prompt unchanged");
|
||||
assert.deepStrictEqual(result.firedHooks.length, 0, "no hooks fired");
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("\n=== Pre-dispatch: hook units bypass ===");
|
||||
|
||||
{
|
||||
test('Pre-dispatch: hook units bypass', () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
const result = runPreDispatchHooks("hook/review", "M001/S01/T01", "hook prompt", base);
|
||||
assertEq(result.action, "proceed", "hook units always proceed");
|
||||
assertEq(result.prompt, "hook prompt", "hook prompt unchanged");
|
||||
assertEq(result.firedHooks.length, 0, "no hooks fired for hook units");
|
||||
assert.deepStrictEqual(result.action, "proceed", "hook units always proceed");
|
||||
assert.deepStrictEqual(result.prompt, "hook prompt", "hook prompt unchanged");
|
||||
assert.deepStrictEqual(result.firedHooks.length, 0, "no hooks fired for hook units");
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Phase 3: State Persistence Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n=== State persistence: persist and restore ===");
|
||||
|
||||
{
|
||||
test('State persistence: persist and restore', () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
resetHookState();
|
||||
|
|
@ -189,19 +165,17 @@ console.log("\n=== State persistence: persist and restore ===");
|
|||
// Persist empty state
|
||||
persistHookState(base);
|
||||
const filePath = join(base, ".gsd", "hook-state.json");
|
||||
assertTrue(existsSync(filePath), "hook-state.json created");
|
||||
assert.ok(existsSync(filePath), "hook-state.json created");
|
||||
|
||||
const content = JSON.parse(readFileSync(filePath, "utf-8"));
|
||||
assertEq(typeof content.savedAt, "string", "savedAt is a string");
|
||||
assertEq(Object.keys(content.cycleCounts).length, 0, "empty cycle counts");
|
||||
assert.deepStrictEqual(typeof content.savedAt, "string", "savedAt is a string");
|
||||
assert.deepStrictEqual(Object.keys(content.cycleCounts).length, 0, "empty cycle counts");
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("\n=== State persistence: restore from disk ===");
|
||||
|
||||
{
|
||||
test('State persistence: restore from disk', () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
resetHookState();
|
||||
|
|
@ -222,16 +196,14 @@ console.log("\n=== State persistence: restore from disk ===");
|
|||
// Verify by persisting and reading back
|
||||
persistHookState(base);
|
||||
const restored = JSON.parse(readFileSync(stateFile, "utf-8"));
|
||||
assertEq(restored.cycleCounts["review/execute-task/M001/S01/T01"], 2, "cycle count restored for review");
|
||||
assertEq(restored.cycleCounts["simplify/execute-task/M001/S01/T02"], 1, "cycle count restored for simplify");
|
||||
assert.deepStrictEqual(restored.cycleCounts["review/execute-task/M001/S01/T01"], 2, "cycle count restored for review");
|
||||
assert.deepStrictEqual(restored.cycleCounts["simplify/execute-task/M001/S01/T02"], 1, "cycle count restored for simplify");
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("\n=== State persistence: clear ===");
|
||||
|
||||
{
|
||||
test('State persistence: clear', () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
resetHookState();
|
||||
|
|
@ -246,77 +218,65 @@ console.log("\n=== State persistence: clear ===");
|
|||
clearPersistedHookState(base);
|
||||
|
||||
const cleared = JSON.parse(readFileSync(stateFile, "utf-8"));
|
||||
assertEq(Object.keys(cleared.cycleCounts).length, 0, "cycle counts cleared");
|
||||
assert.deepStrictEqual(Object.keys(cleared.cycleCounts).length, 0, "cycle counts cleared");
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("\n=== State persistence: restore handles missing file ===");
|
||||
|
||||
{
|
||||
test('State persistence: restore handles missing file', () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
resetHookState();
|
||||
// Should not throw
|
||||
restoreHookState(base);
|
||||
assertEq(getActiveHook(), null, "no active hook after restore from missing file");
|
||||
assert.deepStrictEqual(getActiveHook(), null, "no active hook after restore from missing file");
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("\n=== State persistence: restore handles corrupt file ===");
|
||||
|
||||
{
|
||||
test('State persistence: restore handles corrupt file', () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
resetHookState();
|
||||
writeFileSync(join(base, ".gsd", "hook-state.json"), "not json", "utf-8");
|
||||
// Should not throw
|
||||
restoreHookState(base);
|
||||
assertEq(getActiveHook(), null, "no active hook after corrupt restore");
|
||||
assert.deepStrictEqual(getActiveHook(), null, "no active hook after corrupt restore");
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Phase 3: Hook Status Reporting Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n=== Hook status: no hooks ===");
|
||||
|
||||
{
|
||||
test('Hook status: no hooks', () => {
|
||||
resetHookState();
|
||||
const entries = getHookStatus();
|
||||
// No preferences file = no hooks
|
||||
assertEq(entries.length, 0, "no entries when no hooks configured");
|
||||
assert.deepStrictEqual(entries.length, 0, "no entries when no hooks configured");
|
||||
|
||||
const formatted = formatHookStatus();
|
||||
assertMatch(formatted, /No hooks configured/, "status message says no hooks");
|
||||
}
|
||||
assert.match(formatted, /No hooks configured/, "status message says no hooks");
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Phase 4: Manual Hook Trigger Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n=== triggerHookManually: hook not found ===");
|
||||
|
||||
{
|
||||
test('triggerHookManually: hook not found', () => {
|
||||
resetHookState();
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
const result = triggerHookManually("nonexistent-hook", "execute-task", "M001/S01/T01", base);
|
||||
assertEq(result, null, "returns null when hook not found");
|
||||
assert.deepStrictEqual(result, null, "returns null when hook not found");
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log("\n=== triggerHookManually: with configured hook ===");
|
||||
|
||||
{
|
||||
test('triggerHookManually: with configured hook', () => {
|
||||
resetHookState();
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
|
|
@ -325,16 +285,16 @@ console.log("\n=== triggerHookManually: with configured hook ===");
|
|||
const result = triggerHookManually("code-review", "execute-task", "M001/S01/T01", base);
|
||||
// Result depends on whether code-review hook is configured in preferences
|
||||
// The function should either return null or a valid HookDispatchResult
|
||||
assertTrue(result === null || typeof result === "object", "returns null or object");
|
||||
assert.ok(result === null || typeof result === "object", "returns null or object");
|
||||
if (result) {
|
||||
assertEq(result.hookName, "code-review", "hook name in result");
|
||||
assertEq(result.unitType, "hook/code-review", "unit type is hook-prefixed");
|
||||
assertEq(result.unitId, "M001/S01/T01", "unit ID preserved");
|
||||
assertTrue(typeof result.prompt === "string", "prompt is a string");
|
||||
assert.deepStrictEqual(result.hookName, "code-review", "hook name in result");
|
||||
assert.deepStrictEqual(result.unitType, "hook/code-review", "unit type is hook-prefixed");
|
||||
assert.deepStrictEqual(result.unitId, "M001/S01/T01", "unit ID preserved");
|
||||
assert.ok(typeof result.prompt === "string", "prompt is a string");
|
||||
}
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
report();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
// (b) Helpers fall back to non-null output when DB unavailable
|
||||
// (c) Scoped filtering actually reduces content
|
||||
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
openDatabase,
|
||||
closeDatabase,
|
||||
|
|
@ -22,8 +23,6 @@ import {
|
|||
formatRequirementsForPrompt,
|
||||
} from '../context-store.ts';
|
||||
|
||||
const { assertEq, assertTrue, assertMatch, assertNoMatch, report } = createTestContext();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// prompt-db: DB-aware decisions helper returns scoped content
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -50,23 +49,23 @@ console.log('\n=== prompt-db: scoped decisions from DB ===');
|
|||
|
||||
// Query scoped to M001
|
||||
const m001Decisions = queryDecisions({ milestoneId: 'M001' });
|
||||
assertTrue(m001Decisions.length > 0, 'M001 decisions should exist');
|
||||
assertTrue(m001Decisions.length < 10, `scoped query should return fewer than 10 (got ${m001Decisions.length})`);
|
||||
assert.ok(m001Decisions.length > 0, 'M001 decisions should exist');
|
||||
assert.ok(m001Decisions.length < 10, `scoped query should return fewer than 10 (got ${m001Decisions.length})`);
|
||||
|
||||
// Verify all returned decisions are for M001
|
||||
for (const d of m001Decisions) {
|
||||
assertMatch(d.when_context, /M001/, `decision ${d.id} should be for M001`);
|
||||
assert.match(d.when_context, /M001/, `decision ${d.id} should be for M001`);
|
||||
}
|
||||
|
||||
// Format and verify wrapping
|
||||
const formatted = formatDecisionsForPrompt(m001Decisions);
|
||||
assertTrue(formatted.length > 0, 'formatted decisions should be non-empty');
|
||||
assertMatch(formatted, /\| # \| When \| Scope/, 'formatted decisions have table header');
|
||||
assert.ok(formatted.length > 0, 'formatted decisions should be non-empty');
|
||||
assert.match(formatted, /\| # \| When \| Scope/, 'formatted decisions have table header');
|
||||
|
||||
// Verify the expected wrapper format that inlineDecisionsFromDb would produce
|
||||
const wrapped = `### Decisions\nSource: \`.gsd/DECISIONS.md\`\n\n${formatted}`;
|
||||
assertMatch(wrapped, /^### Decisions/, 'wrapped decisions start with ### Decisions');
|
||||
assertMatch(wrapped, /Source:.*DECISIONS\.md/, 'wrapped decisions have source path');
|
||||
assert.match(wrapped, /^### Decisions/, 'wrapped decisions start with ### Decisions');
|
||||
assert.match(wrapped, /Source:.*DECISIONS\.md/, 'wrapped decisions have source path');
|
||||
|
||||
closeDatabase();
|
||||
}
|
||||
|
|
@ -101,25 +100,25 @@ console.log('\n=== prompt-db: scoped requirements from DB ===');
|
|||
|
||||
// Query scoped to S01 — should get R001 (primary) and R002 (supporting)
|
||||
const s01Reqs = queryRequirements({ sliceId: 'S01' });
|
||||
assertEq(s01Reqs.length, 2, 'S01 requirements should be 2 (primary + supporting)');
|
||||
assert.deepStrictEqual(s01Reqs.length, 2, 'S01 requirements should be 2 (primary + supporting)');
|
||||
const ids = s01Reqs.map(r => r.id).sort();
|
||||
assertEq(ids, ['R001', 'R002'], 'S01 owns R001 and supports R002');
|
||||
assert.deepStrictEqual(ids, ['R001', 'R002'], 'S01 owns R001 and supports R002');
|
||||
|
||||
// Unscoped query returns all 3
|
||||
const allReqs = queryRequirements();
|
||||
assertEq(allReqs.length, 3, 'unscoped requirements should return all 3');
|
||||
assert.deepStrictEqual(allReqs.length, 3, 'unscoped requirements should return all 3');
|
||||
|
||||
// Format and verify wrapping
|
||||
const formatted = formatRequirementsForPrompt(s01Reqs);
|
||||
assertTrue(formatted.length > 0, 'formatted requirements should be non-empty');
|
||||
assertMatch(formatted, /### R001/, 'formatted requirements include R001');
|
||||
assertMatch(formatted, /### R002/, 'formatted requirements include R002');
|
||||
assertNoMatch(formatted, /### R003/, 'formatted requirements exclude R003');
|
||||
assert.ok(formatted.length > 0, 'formatted requirements should be non-empty');
|
||||
assert.match(formatted, /### R001/, 'formatted requirements include R001');
|
||||
assert.match(formatted, /### R002/, 'formatted requirements include R002');
|
||||
assert.doesNotMatch(formatted, /### R003/, 'formatted requirements exclude R003');
|
||||
|
||||
// Verify the expected wrapper format that inlineRequirementsFromDb would produce
|
||||
const wrapped = `### Requirements\nSource: \`.gsd/REQUIREMENTS.md\`\n\n${formatted}`;
|
||||
assertMatch(wrapped, /^### Requirements/, 'wrapped requirements start with ### Requirements');
|
||||
assertMatch(wrapped, /Source:.*REQUIREMENTS\.md/, 'wrapped requirements have source path');
|
||||
assert.match(wrapped, /^### Requirements/, 'wrapped requirements start with ### Requirements');
|
||||
assert.match(wrapped, /Source:.*REQUIREMENTS\.md/, 'wrapped requirements have source path');
|
||||
|
||||
closeDatabase();
|
||||
}
|
||||
|
|
@ -142,13 +141,13 @@ console.log('\n=== prompt-db: project content from DB ===');
|
|||
});
|
||||
|
||||
const content = queryProject();
|
||||
assertEq(content, '# Test Project\n\nThis is the project description.', 'queryProject returns content');
|
||||
assert.deepStrictEqual(content, '# Test Project\n\nThis is the project description.', 'queryProject returns content');
|
||||
|
||||
// Verify the expected wrapper format that inlineProjectFromDb would produce
|
||||
const wrapped = `### Project\nSource: \`.gsd/PROJECT.md\`\n\n${content}`;
|
||||
assertMatch(wrapped, /^### Project/, 'wrapped project starts with ### Project');
|
||||
assertMatch(wrapped, /Source:.*PROJECT\.md/, 'wrapped project has source path');
|
||||
assertMatch(wrapped, /# Test Project/, 'wrapped project includes content');
|
||||
assert.match(wrapped, /^### Project/, 'wrapped project starts with ### Project');
|
||||
assert.match(wrapped, /Source:.*PROJECT\.md/, 'wrapped project has source path');
|
||||
assert.match(wrapped, /# Test Project/, 'wrapped project includes content');
|
||||
|
||||
closeDatabase();
|
||||
}
|
||||
|
|
@ -160,27 +159,27 @@ console.log('\n=== prompt-db: project content from DB ===');
|
|||
console.log('\n=== prompt-db: fallback when DB unavailable ===');
|
||||
{
|
||||
closeDatabase();
|
||||
assertTrue(!isDbAvailable(), 'DB should not be available');
|
||||
assert.ok(!isDbAvailable(), 'DB should not be available');
|
||||
|
||||
// queryDecisions returns [] when DB closed — helper would fall back
|
||||
const decisions = queryDecisions({ milestoneId: 'M001' });
|
||||
assertEq(decisions, [], 'queryDecisions returns [] when DB closed');
|
||||
assert.deepStrictEqual(decisions, [], 'queryDecisions returns [] when DB closed');
|
||||
|
||||
// queryRequirements returns [] when DB closed — helper would fall back
|
||||
const requirements = queryRequirements({ sliceId: 'S01' });
|
||||
assertEq(requirements, [], 'queryRequirements returns [] when DB closed');
|
||||
assert.deepStrictEqual(requirements, [], 'queryRequirements returns [] when DB closed');
|
||||
|
||||
// queryProject returns null when DB closed — helper would fall back
|
||||
const project = queryProject();
|
||||
assertEq(project, null, 'queryProject returns null when DB closed');
|
||||
assert.deepStrictEqual(project, null, 'queryProject returns null when DB closed');
|
||||
|
||||
// formatDecisionsForPrompt returns '' for empty input
|
||||
const formatted = formatDecisionsForPrompt([]);
|
||||
assertEq(formatted, '', 'formatDecisionsForPrompt returns empty for empty input');
|
||||
assert.deepStrictEqual(formatted, '', 'formatDecisionsForPrompt returns empty for empty input');
|
||||
|
||||
// formatRequirementsForPrompt returns '' for empty input
|
||||
const formattedReqs = formatRequirementsForPrompt([]);
|
||||
assertEq(formattedReqs, '', 'formatRequirementsForPrompt returns empty for empty input');
|
||||
assert.deepStrictEqual(formattedReqs, '', 'formatRequirementsForPrompt returns empty for empty input');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -210,15 +209,15 @@ console.log('\n=== prompt-db: scoped filtering reduces content ===');
|
|||
const allDecisions = queryDecisions();
|
||||
const m001Decisions = queryDecisions({ milestoneId: 'M001' });
|
||||
|
||||
assertEq(allDecisions.length, 10, 'unscoped returns all 10 decisions');
|
||||
assertTrue(m001Decisions.length < 10, `M001-scoped returns fewer than 10 (got ${m001Decisions.length})`);
|
||||
assertTrue(m001Decisions.length > 0, 'M001-scoped returns at least 1');
|
||||
assert.deepStrictEqual(allDecisions.length, 10, 'unscoped returns all 10 decisions');
|
||||
assert.ok(m001Decisions.length < 10, `M001-scoped returns fewer than 10 (got ${m001Decisions.length})`);
|
||||
assert.ok(m001Decisions.length > 0, 'M001-scoped returns at least 1');
|
||||
|
||||
// Format both and compare sizes — scoped should be shorter
|
||||
const allFormatted = formatDecisionsForPrompt(allDecisions);
|
||||
const scopedFormatted = formatDecisionsForPrompt(m001Decisions);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
scopedFormatted.length < allFormatted.length,
|
||||
`scoped content (${scopedFormatted.length} chars) should be shorter than unscoped (${allFormatted.length} chars)`,
|
||||
);
|
||||
|
|
@ -245,14 +244,14 @@ console.log('\n=== prompt-db: scoped filtering reduces content ===');
|
|||
const allReqs = queryRequirements();
|
||||
const s01Reqs = queryRequirements({ sliceId: 'S01' });
|
||||
|
||||
assertEq(allReqs.length, 8, 'unscoped returns all 8 requirements');
|
||||
assertTrue(s01Reqs.length < 8, `S01-scoped returns fewer than 8 (got ${s01Reqs.length})`);
|
||||
assertTrue(s01Reqs.length > 0, 'S01-scoped returns at least 1');
|
||||
assert.deepStrictEqual(allReqs.length, 8, 'unscoped returns all 8 requirements');
|
||||
assert.ok(s01Reqs.length < 8, `S01-scoped returns fewer than 8 (got ${s01Reqs.length})`);
|
||||
assert.ok(s01Reqs.length > 0, 'S01-scoped returns at least 1');
|
||||
|
||||
const allReqsFormatted = formatRequirementsForPrompt(allReqs);
|
||||
const scopedReqsFormatted = formatRequirementsForPrompt(s01Reqs);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
scopedReqsFormatted.length < allReqsFormatted.length,
|
||||
`scoped requirements (${scopedReqsFormatted.length} chars) should be shorter than unscoped (${allReqsFormatted.length} chars)`,
|
||||
);
|
||||
|
|
@ -292,23 +291,23 @@ console.log('\n=== prompt-db: DB helpers wrapper format matches expected pattern
|
|||
|
||||
// Simulate what inlineDecisionsFromDb does
|
||||
const decisions = queryDecisions({ milestoneId: 'M001' });
|
||||
assertTrue(decisions.length === 1, 'got 1 decision for M001');
|
||||
assert.ok(decisions.length === 1, 'got 1 decision for M001');
|
||||
const dFormatted = formatDecisionsForPrompt(decisions);
|
||||
const dWrapped = `### Decisions\nSource: \`.gsd/DECISIONS.md\`\n\n${dFormatted}`;
|
||||
assertMatch(dWrapped, /^### Decisions\nSource: `.gsd\/DECISIONS\.md`\n\n\| #/, 'decisions wrapper format correct');
|
||||
assert.match(dWrapped, /^### Decisions\nSource: `.gsd\/DECISIONS\.md`\n\n\| #/, 'decisions wrapper format correct');
|
||||
|
||||
// Simulate what inlineRequirementsFromDb does
|
||||
const reqs = queryRequirements({ sliceId: 'S01' });
|
||||
assertTrue(reqs.length === 1, 'got 1 requirement for S01');
|
||||
assert.ok(reqs.length === 1, 'got 1 requirement for S01');
|
||||
const rFormatted = formatRequirementsForPrompt(reqs);
|
||||
const rWrapped = `### Requirements\nSource: \`.gsd/REQUIREMENTS.md\`\n\n${rFormatted}`;
|
||||
assertMatch(rWrapped, /^### Requirements\nSource: `.gsd\/REQUIREMENTS\.md`\n\n### R001/, 'requirements wrapper format correct');
|
||||
assert.match(rWrapped, /^### Requirements\nSource: `.gsd\/REQUIREMENTS\.md`\n\n### R001/, 'requirements wrapper format correct');
|
||||
|
||||
// Simulate what inlineProjectFromDb does
|
||||
const project = queryProject();
|
||||
assertTrue(project !== null, 'project content exists');
|
||||
assert.ok(project !== null, 'project content exists');
|
||||
const pWrapped = `### Project\nSource: \`.gsd/PROJECT.md\`\n\n${project}`;
|
||||
assertMatch(pWrapped, /^### Project\nSource: `.gsd\/PROJECT\.md`\n\n# Project Name/, 'project wrapper format correct');
|
||||
assert.match(pWrapped, /^### Project\nSource: `.gsd\/PROJECT\.md`\n\n# Project Name/, 'project wrapper format correct');
|
||||
|
||||
closeDatabase();
|
||||
}
|
||||
|
|
@ -322,8 +321,9 @@ import { join } from 'node:path';
|
|||
import { tmpdir } from 'node:os';
|
||||
import { migrateFromMarkdown } from '../md-importer.ts';
|
||||
|
||||
console.log('\n=== prompt-db: re-import updates DB when source markdown changes ===');
|
||||
{
|
||||
|
||||
describe('prompt-db', () => {
|
||||
test('prompt-db: re-import updates DB when source markdown changes', () => {
|
||||
// Create a temp dir simulating a project with .gsd/DECISIONS.md
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), 'prompt-db-reimport-'));
|
||||
const gsdDir = join(tmpDir, '.gsd');
|
||||
|
|
@ -345,9 +345,9 @@ console.log('\n=== prompt-db: re-import updates DB when source markdown changes
|
|||
|
||||
// Verify initial state: 2 decisions
|
||||
const initial = queryDecisions();
|
||||
assertEq(initial.length, 2, 're-import: initial import has 2 decisions');
|
||||
assert.deepStrictEqual(initial.length, 2, 're-import: initial import has 2 decisions');
|
||||
const initialIds = initial.map(d => d.id).sort();
|
||||
assertEq(initialIds, ['D001', 'D002'], 're-import: initial decisions are D001, D002');
|
||||
assert.deepStrictEqual(initialIds, ['D001', 'D002'], 're-import: initial decisions are D001, D002');
|
||||
|
||||
// Now "the LLM modifies DECISIONS.md" — add a third decision
|
||||
const updatedDecisions = `# Decisions Register
|
||||
|
|
@ -365,23 +365,23 @@ console.log('\n=== prompt-db: re-import updates DB when source markdown changes
|
|||
|
||||
// Verify DB now has 3 decisions
|
||||
const afterReimport = queryDecisions();
|
||||
assertEq(afterReimport.length, 3, 're-import: after re-import has 3 decisions');
|
||||
assert.deepStrictEqual(afterReimport.length, 3, 're-import: after re-import has 3 decisions');
|
||||
const afterIds = afterReimport.map(d => d.id).sort();
|
||||
assertEq(afterIds, ['D001', 'D002', 'D003'], 're-import: decisions are D001, D002, D003');
|
||||
assert.deepStrictEqual(afterIds, ['D001', 'D002', 'D003'], 're-import: decisions are D001, D002, D003');
|
||||
|
||||
// Verify the new decision has correct data
|
||||
const d003 = afterReimport.find(d => d.id === 'D003');
|
||||
assertTrue(d003 !== undefined, 're-import: D003 exists');
|
||||
assertEq(d003!.when_context, 'M001/S02', 're-import: D003 when_context is M001/S02');
|
||||
assertEq(d003!.scope, 'runtime', 're-import: D003 scope is runtime');
|
||||
assertEq(d003!.choice, 'D014 pattern', 're-import: D003 choice is D014 pattern');
|
||||
assert.ok(d003 !== undefined, 're-import: D003 exists');
|
||||
assert.deepStrictEqual(d003!.when_context, 'M001/S02', 're-import: D003 when_context is M001/S02');
|
||||
assert.deepStrictEqual(d003!.scope, 'runtime', 're-import: D003 scope is runtime');
|
||||
assert.deepStrictEqual(d003!.choice, 'D014 pattern', 're-import: D003 choice is D014 pattern');
|
||||
|
||||
// Verify scoped query picks up the new decision
|
||||
const m001Scoped = queryDecisions({ milestoneId: 'M001' });
|
||||
assertTrue(m001Scoped.length === 3, 're-import: all 3 decisions are for M001');
|
||||
assert.ok(m001Scoped.length === 3, 're-import: all 3 decisions are for M001');
|
||||
|
||||
closeDatabase();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Final Report ──────────────────────────────────────────────────────────
|
||||
report();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
|
@ -5,122 +7,94 @@ import { tmpdir } from "node:os";
|
|||
import { deriveState } from "../state.js";
|
||||
import { buildExistingMilestonesContext } from "../guided-flow.js";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
describe('queue-draft-detection', () => {
|
||||
test('draft and context milestone detection', async () => {
|
||||
const tmpBase = mkdtempSync(join(tmpdir(), "gsd-queue-draft-test-"));
|
||||
const gsd = join(tmpBase, ".gsd");
|
||||
|
||||
function assert(condition: boolean, message: string): void {
|
||||
if (condition) {
|
||||
passed++;
|
||||
} else {
|
||||
failed++;
|
||||
console.error(` FAIL: ${message}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
// M001: has only CONTEXT-DRAFT.md (draft milestone)
|
||||
mkdirSync(join(gsd, "milestones", "M001"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(gsd, "milestones", "M001", "M001-CONTEXT-DRAFT.md"),
|
||||
"# M001: Draft Milestone\n\nSeed material from prior discussion.\n",
|
||||
);
|
||||
|
||||
// ─── Fixture setup ──────────────────────────────────────────────────────
|
||||
// M002: has full CONTEXT.md (ready milestone)
|
||||
mkdirSync(join(gsd, "milestones", "M002"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(gsd, "milestones", "M002", "M002-CONTEXT.md"),
|
||||
"# M002: Ready Milestone\n\nFull context from deep discussion.\n",
|
||||
);
|
||||
|
||||
const tmpBase = mkdtempSync(join(tmpdir(), "gsd-queue-draft-test-"));
|
||||
const gsd = join(tmpBase, ".gsd");
|
||||
// M003: has both CONTEXT.md and CONTEXT-DRAFT.md (CONTEXT wins)
|
||||
mkdirSync(join(gsd, "milestones", "M003"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(gsd, "milestones", "M003", "M003-CONTEXT.md"),
|
||||
"# M003: Full Context\n\nThis is the real context.\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(gsd, "milestones", "M003", "M003-CONTEXT-DRAFT.md"),
|
||||
"# M003: Draft\n\nThis should be ignored.\n",
|
||||
);
|
||||
|
||||
// M001: has only CONTEXT-DRAFT.md (draft milestone)
|
||||
mkdirSync(join(gsd, "milestones", "M001"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(gsd, "milestones", "M001", "M001-CONTEXT-DRAFT.md"),
|
||||
"# M001: Draft Milestone\n\nSeed material from prior discussion.\n",
|
||||
);
|
||||
// M004: has neither (empty milestone dir)
|
||||
mkdirSync(join(gsd, "milestones", "M004"), { recursive: true });
|
||||
|
||||
// M002: has full CONTEXT.md (ready milestone)
|
||||
mkdirSync(join(gsd, "milestones", "M002"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(gsd, "milestones", "M002", "M002-CONTEXT.md"),
|
||||
"# M002: Ready Milestone\n\nFull context from deep discussion.\n",
|
||||
);
|
||||
// Build context
|
||||
const state = await deriveState(tmpBase);
|
||||
const milestoneIds = ["M001", "M002", "M003", "M004"];
|
||||
const context = await buildExistingMilestonesContext(tmpBase, milestoneIds, state);
|
||||
|
||||
// M003: has both CONTEXT.md and CONTEXT-DRAFT.md (CONTEXT wins)
|
||||
mkdirSync(join(gsd, "milestones", "M003"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(gsd, "milestones", "M003", "M003-CONTEXT.md"),
|
||||
"# M003: Full Context\n\nThis is the real context.\n",
|
||||
);
|
||||
writeFileSync(
|
||||
join(gsd, "milestones", "M003", "M003-CONTEXT-DRAFT.md"),
|
||||
"# M003: Draft\n\nThis should be ignored.\n",
|
||||
);
|
||||
// draft-only milestone includes "Draft context available"
|
||||
assert.ok(
|
||||
context.includes("Draft context available"),
|
||||
"M001 (draft-only) should include 'Draft context available' label",
|
||||
);
|
||||
assert.ok(
|
||||
context.includes("Seed material from prior discussion"),
|
||||
"M001 draft content should be included in context output",
|
||||
);
|
||||
|
||||
// M004: has neither (empty milestone dir)
|
||||
mkdirSync(join(gsd, "milestones", "M004"), { recursive: true });
|
||||
// full-context milestone uses "Context:" label
|
||||
assert.ok(
|
||||
context.includes("**Context:**"),
|
||||
"M002 (full context) should use 'Context:' label",
|
||||
);
|
||||
assert.ok(
|
||||
context.includes("Full context from deep discussion"),
|
||||
"M002 context content should be included",
|
||||
);
|
||||
|
||||
// ─── Build context ──────────────────────────────────────────────────────
|
||||
// both files: CONTEXT.md wins, no draft label
|
||||
const m003Idx = context.indexOf("M003:");
|
||||
const m003Section = context.slice(m003Idx, m003Idx + 500);
|
||||
assert.ok(
|
||||
m003Section.includes("**Context:**"),
|
||||
"M003 (both files) should use 'Context:' label (CONTEXT.md wins)",
|
||||
);
|
||||
assert.ok(
|
||||
!m003Section.includes("Draft context available"),
|
||||
"M003 (both files) should NOT show draft label — CONTEXT.md takes precedence",
|
||||
);
|
||||
assert.ok(
|
||||
m003Section.includes("This is the real context"),
|
||||
"M003 should show CONTEXT.md content, not draft content",
|
||||
);
|
||||
|
||||
const state = await deriveState(tmpBase);
|
||||
const milestoneIds = ["M001", "M002", "M003", "M004"];
|
||||
const context = await buildExistingMilestonesContext(tmpBase, milestoneIds, state);
|
||||
|
||||
// ─── Test: draft-only milestone includes "Draft context available" ──────
|
||||
|
||||
assert(
|
||||
context.includes("Draft context available"),
|
||||
"M001 (draft-only) should include 'Draft context available' label",
|
||||
);
|
||||
|
||||
assert(
|
||||
context.includes("Seed material from prior discussion"),
|
||||
"M001 draft content should be included in context output",
|
||||
);
|
||||
|
||||
// ─── Test: full-context milestone uses "Context:" label ────────────────
|
||||
|
||||
assert(
|
||||
context.includes("**Context:**"),
|
||||
"M002 (full context) should use 'Context:' label",
|
||||
);
|
||||
|
||||
assert(
|
||||
context.includes("Full context from deep discussion"),
|
||||
"M002 context content should be included",
|
||||
);
|
||||
|
||||
// ─── Test: both files → CONTEXT.md wins, no draft label ────────────────
|
||||
|
||||
// Find M003's section and check it has Context: but not Draft
|
||||
const m003Idx = context.indexOf("M003:");
|
||||
const m003Section = context.slice(m003Idx, m003Idx + 500);
|
||||
|
||||
assert(
|
||||
m003Section.includes("**Context:**"),
|
||||
"M003 (both files) should use 'Context:' label (CONTEXT.md wins)",
|
||||
);
|
||||
|
||||
assert(
|
||||
!m003Section.includes("Draft context available"),
|
||||
"M003 (both files) should NOT show draft label — CONTEXT.md takes precedence",
|
||||
);
|
||||
|
||||
assert(
|
||||
m003Section.includes("This is the real context"),
|
||||
"M003 should show CONTEXT.md content, not draft content",
|
||||
);
|
||||
|
||||
// ─── Test: neither file → no context section ───────────────────────────
|
||||
|
||||
const m004Idx = context.indexOf("M004:");
|
||||
const m004Section = context.slice(m004Idx, m004Idx + 500);
|
||||
|
||||
assert(
|
||||
!m004Section.includes("**Context:**"),
|
||||
"M004 (neither file) should not have Context: label",
|
||||
);
|
||||
|
||||
assert(
|
||||
!m004Section.includes("Draft context available"),
|
||||
"M004 (neither file) should not have Draft label",
|
||||
);
|
||||
|
||||
// ─── Cleanup ──────────────────────────────────────────────────────────
|
||||
|
||||
rmSync(tmpBase, { recursive: true, force: true });
|
||||
|
||||
// ─── Results ──────────────────────────────────────────────────────────
|
||||
|
||||
console.log(`\nqueue-draft-detection: ${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
// neither file: no context section
|
||||
const m004Idx = context.indexOf("M004:");
|
||||
const m004Section = context.slice(m004Idx, m004Idx + 500);
|
||||
assert.ok(
|
||||
!m004Section.includes("**Context:**"),
|
||||
"M004 (neither file) should not have Context: label",
|
||||
);
|
||||
assert.ok(
|
||||
!m004Section.includes("Draft context available"),
|
||||
"M004 (neither file) should not have Draft label",
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmpBase, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
|
@ -9,10 +11,6 @@ import {
|
|||
pruneQueueOrder,
|
||||
validateQueueOrder,
|
||||
} from '../queue-order.ts';
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
// ─── Fixture Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function createFixtureBase(): string {
|
||||
|
|
@ -29,176 +27,166 @@ function cleanup(base: string): void {
|
|||
// sortByQueueOrder
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== sortByQueueOrder ===');
|
||||
|
||||
describe('queue-order', () => {
|
||||
test('sortByQueueOrder', () => {
|
||||
// Null order → default milestoneIdSort
|
||||
{
|
||||
const result = sortByQueueOrder(['M003', 'M001', 'M002'], null);
|
||||
assertEq(result, ['M001', 'M002', 'M003'], 'null order falls back to numeric sort');
|
||||
}
|
||||
assert.deepStrictEqual(result, ['M001', 'M002', 'M003'], 'null order falls back to numeric sort');
|
||||
});
|
||||
|
||||
// Custom order → exact sequence
|
||||
{
|
||||
test('test block at line 39', () => {
|
||||
const result = sortByQueueOrder(['M001', 'M002', 'M003'], ['M003', 'M001', 'M002']);
|
||||
assertEq(result, ['M003', 'M001', 'M002'], 'custom order produces exact sequence');
|
||||
}
|
||||
assert.deepStrictEqual(result, ['M003', 'M001', 'M002'], 'custom order produces exact sequence');
|
||||
});
|
||||
|
||||
// Custom order with new IDs → appended at end in numeric order
|
||||
{
|
||||
test('test block at line 45', () => {
|
||||
const result = sortByQueueOrder(['M001', 'M002', 'M003', 'M004'], ['M003', 'M001']);
|
||||
assertEq(result, ['M003', 'M001', 'M002', 'M004'], 'new IDs appended in numeric order');
|
||||
}
|
||||
assert.deepStrictEqual(result, ['M003', 'M001', 'M002', 'M004'], 'new IDs appended in numeric order');
|
||||
});
|
||||
|
||||
// Custom order with deleted IDs → silently skipped
|
||||
{
|
||||
test('test block at line 51', () => {
|
||||
const result = sortByQueueOrder(['M001', 'M003'], ['M003', 'M002', 'M001']);
|
||||
assertEq(result, ['M003', 'M001'], 'deleted IDs in order are skipped');
|
||||
}
|
||||
assert.deepStrictEqual(result, ['M003', 'M001'], 'deleted IDs in order are skipped');
|
||||
});
|
||||
|
||||
// Empty custom order → all IDs in numeric order
|
||||
{
|
||||
test('test block at line 57', () => {
|
||||
const result = sortByQueueOrder(['M002', 'M001'], []);
|
||||
assertEq(result, ['M001', 'M002'], 'empty custom order falls back to numeric sort');
|
||||
}
|
||||
assert.deepStrictEqual(result, ['M001', 'M002'], 'empty custom order falls back to numeric sort');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// loadQueueOrder / saveQueueOrder
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== loadQueueOrder / saveQueueOrder ===');
|
||||
|
||||
test('loadQueueOrder / saveQueueOrder', () => {
|
||||
// Load returns null when file doesn't exist
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
assertEq(loadQueueOrder(base), null, 'returns null when file missing');
|
||||
assert.deepStrictEqual(loadQueueOrder(base), null, 'returns null when file missing');
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// Save then load round-trip
|
||||
{
|
||||
test('test block at line 76', () => {
|
||||
const base = createFixtureBase();
|
||||
saveQueueOrder(base, ['M003', 'M001', 'M002']);
|
||||
const loaded = loadQueueOrder(base);
|
||||
assertEq(loaded, ['M003', 'M001', 'M002'], 'round-trip preserves order');
|
||||
assert.deepStrictEqual(loaded, ['M003', 'M001', 'M002'], 'round-trip preserves order');
|
||||
|
||||
// Verify file contains updatedAt
|
||||
const raw = JSON.parse(readFileSync(join(base, '.gsd', 'QUEUE-ORDER.json'), 'utf-8'));
|
||||
assertTrue(typeof raw.updatedAt === 'string' && raw.updatedAt.length > 0, 'file contains updatedAt');
|
||||
assert.ok(typeof raw.updatedAt === 'string' && raw.updatedAt.length > 0, 'file contains updatedAt');
|
||||
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// Load returns null on corrupt JSON
|
||||
{
|
||||
test('test block at line 90', () => {
|
||||
const base = createFixtureBase();
|
||||
writeFileSync(join(base, '.gsd', 'QUEUE-ORDER.json'), 'not json');
|
||||
assertEq(loadQueueOrder(base), null, 'returns null on corrupt JSON');
|
||||
assert.deepStrictEqual(loadQueueOrder(base), null, 'returns null on corrupt JSON');
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// Load returns null when order field is not an array
|
||||
{
|
||||
test('test block at line 98', () => {
|
||||
const base = createFixtureBase();
|
||||
writeFileSync(join(base, '.gsd', 'QUEUE-ORDER.json'), '{"order": "invalid"}');
|
||||
assertEq(loadQueueOrder(base), null, 'returns null when order is not array');
|
||||
assert.deepStrictEqual(loadQueueOrder(base), null, 'returns null when order is not array');
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// pruneQueueOrder
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== pruneQueueOrder ===');
|
||||
|
||||
test('pruneQueueOrder', () => {
|
||||
// Prune removes invalid IDs
|
||||
{
|
||||
const base = createFixtureBase();
|
||||
saveQueueOrder(base, ['M001', 'M002', 'M003']);
|
||||
pruneQueueOrder(base, ['M001', 'M003']);
|
||||
assertEq(loadQueueOrder(base), ['M001', 'M003'], 'prune removes invalid IDs');
|
||||
assert.deepStrictEqual(loadQueueOrder(base), ['M001', 'M003'], 'prune removes invalid IDs');
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// Prune no-ops when file doesn't exist
|
||||
{
|
||||
test('test block at line 121', () => {
|
||||
const base = createFixtureBase();
|
||||
pruneQueueOrder(base, ['M001']); // should not throw
|
||||
assertTrue(!existsSync(join(base, '.gsd', 'QUEUE-ORDER.json')), 'prune does not create file');
|
||||
assert.ok(!existsSync(join(base, '.gsd', 'QUEUE-ORDER.json')), 'prune does not create file');
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// Prune no-ops when all IDs are valid
|
||||
{
|
||||
test('test block at line 129', () => {
|
||||
const base = createFixtureBase();
|
||||
saveQueueOrder(base, ['M001', 'M002']);
|
||||
pruneQueueOrder(base, ['M001', 'M002', 'M003']);
|
||||
assertEq(loadQueueOrder(base), ['M001', 'M002'], 'prune is no-op when all valid');
|
||||
assert.deepStrictEqual(loadQueueOrder(base), ['M001', 'M002'], 'prune is no-op when all valid');
|
||||
cleanup(base);
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// validateQueueOrder
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== validateQueueOrder ===');
|
||||
|
||||
test('validateQueueOrder', () => {
|
||||
// Valid order with no dependencies
|
||||
{
|
||||
const depsMap = new Map<string, string[]>();
|
||||
const result = validateQueueOrder(['M001', 'M002'], depsMap, new Set());
|
||||
assertTrue(result.valid, 'valid when no dependencies');
|
||||
assertEq(result.violations.length, 0, 'no violations');
|
||||
assertEq(result.redundant.length, 0, 'no redundancies');
|
||||
}
|
||||
assert.ok(result.valid, 'valid when no dependencies');
|
||||
assert.deepStrictEqual(result.violations.length, 0, 'no violations');
|
||||
assert.deepStrictEqual(result.redundant.length, 0, 'no redundancies');
|
||||
});
|
||||
|
||||
// Dependency violation: M002 before M001, but M002 depends on M001
|
||||
{
|
||||
test('test block at line 153', () => {
|
||||
const depsMap = new Map<string, string[]>([['M002', ['M001']]]);
|
||||
const result = validateQueueOrder(['M002', 'M001'], depsMap, new Set());
|
||||
assertTrue(!result.valid, 'invalid when dep violated');
|
||||
assertEq(result.violations.length, 1, 'one violation');
|
||||
assertEq(result.violations[0].type, 'would_block', 'violation type is would_block');
|
||||
assertEq(result.violations[0].milestone, 'M002', 'violation milestone is M002');
|
||||
assertEq(result.violations[0].dependsOn, 'M001', 'violation dep is M001');
|
||||
}
|
||||
assert.ok(!result.valid, 'invalid when dep violated');
|
||||
assert.deepStrictEqual(result.violations.length, 1, 'one violation');
|
||||
assert.deepStrictEqual(result.violations[0].type, 'would_block', 'violation type is would_block');
|
||||
assert.deepStrictEqual(result.violations[0].milestone, 'M002', 'violation milestone is M002');
|
||||
assert.deepStrictEqual(result.violations[0].dependsOn, 'M001', 'violation dep is M001');
|
||||
});
|
||||
|
||||
// Redundant dependency: M002 depends on M001, M001 comes first in order
|
||||
{
|
||||
test('test block at line 164', () => {
|
||||
const depsMap = new Map<string, string[]>([['M002', ['M001']]]);
|
||||
const result = validateQueueOrder(['M001', 'M002'], depsMap, new Set());
|
||||
assertTrue(result.valid, 'valid when dep satisfied by position');
|
||||
assertEq(result.redundant.length, 1, 'one redundancy');
|
||||
assertEq(result.redundant[0].milestone, 'M002', 'redundant milestone is M002');
|
||||
}
|
||||
assert.ok(result.valid, 'valid when dep satisfied by position');
|
||||
assert.deepStrictEqual(result.redundant.length, 1, 'one redundancy');
|
||||
assert.deepStrictEqual(result.redundant[0].milestone, 'M002', 'redundant milestone is M002');
|
||||
});
|
||||
|
||||
// Completed dep is always satisfied
|
||||
{
|
||||
test('test block at line 173', () => {
|
||||
const depsMap = new Map<string, string[]>([['M002', ['M001']]]);
|
||||
const result = validateQueueOrder(['M002'], depsMap, new Set(['M001']));
|
||||
assertTrue(result.valid, 'valid when dep is already completed');
|
||||
assertEq(result.violations.length, 0, 'no violations for completed dep');
|
||||
}
|
||||
assert.ok(result.valid, 'valid when dep is already completed');
|
||||
assert.deepStrictEqual(result.violations.length, 0, 'no violations for completed dep');
|
||||
});
|
||||
|
||||
// Missing dependency
|
||||
{
|
||||
test('test block at line 181', () => {
|
||||
const depsMap = new Map<string, string[]>([['M002', ['M099']]]);
|
||||
const result = validateQueueOrder(['M001', 'M002'], depsMap, new Set());
|
||||
assertTrue(!result.valid, 'invalid when dep does not exist');
|
||||
assertEq(result.violations[0].type, 'missing_dep', 'violation type is missing_dep');
|
||||
}
|
||||
assert.ok(!result.valid, 'invalid when dep does not exist');
|
||||
assert.deepStrictEqual(result.violations[0].type, 'missing_dep', 'violation type is missing_dep');
|
||||
});
|
||||
|
||||
// Circular dependency
|
||||
{
|
||||
test('test block at line 189', () => {
|
||||
const depsMap = new Map<string, string[]>([
|
||||
['M001', ['M002']],
|
||||
['M002', ['M001']],
|
||||
]);
|
||||
const result = validateQueueOrder(['M001', 'M002'], depsMap, new Set());
|
||||
assertTrue(!result.valid, 'invalid on circular dependency');
|
||||
assert.ok(!result.valid, 'invalid on circular dependency');
|
||||
const circularViolation = result.violations.find(v => v.type === 'circular');
|
||||
assertTrue(!!circularViolation, 'circular violation detected');
|
||||
}
|
||||
assert.ok(!!circularViolation, 'circular violation detected');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
report();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
* 4. A fresh deriveState() call (simulating new session) also works
|
||||
*/
|
||||
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
|
@ -19,10 +21,6 @@ import { deriveState, invalidateStateCache } from '../state.ts';
|
|||
import { findMilestoneIds } from '../guided-flow.ts';
|
||||
import { saveQueueOrder, loadQueueOrder } from '../queue-order.ts';
|
||||
import { parseContextDependsOn } from '../files.ts';
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
// ─── Fixture Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function createFixtureBase(): string {
|
||||
|
|
@ -70,8 +68,9 @@ function readContextFile(base: string, mid: string): string {
|
|||
// Test: Queue order changes milestone activation
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== E2E: queue-order changes active milestone ===');
|
||||
{
|
||||
|
||||
describe('queue-reorder-e2e', () => {
|
||||
test('E2E: queue-order changes active milestone', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
// Setup: M007 complete, M008 and M009 pending (no context, no roadmap)
|
||||
|
|
@ -84,7 +83,7 @@ console.log('\n=== E2E: queue-order changes active milestone ===');
|
|||
// Without custom order: M008 comes first (numeric sort)
|
||||
invalidateStateCache();
|
||||
const stateBefore = await deriveState(base);
|
||||
assertEq(stateBefore.activeMilestone?.id, 'M008', 'before reorder: M008 is active');
|
||||
assert.deepStrictEqual(stateBefore.activeMilestone?.id, 'M008', 'before reorder: M008 is active');
|
||||
|
||||
// Save custom order: M009 before M008
|
||||
saveQueueOrder(base, ['M009', 'M008']);
|
||||
|
|
@ -92,25 +91,23 @@ console.log('\n=== E2E: queue-order changes active milestone ===');
|
|||
// With custom order: M009 should be active
|
||||
invalidateStateCache();
|
||||
const stateAfter = await deriveState(base);
|
||||
assertEq(stateAfter.activeMilestone?.id, 'M009', 'after reorder: M009 is active');
|
||||
assert.deepStrictEqual(stateAfter.activeMilestone?.id, 'M009', 'after reorder: M009 is active');
|
||||
|
||||
// findMilestoneIds respects the order
|
||||
const ids = findMilestoneIds(base);
|
||||
const m008Idx = ids.indexOf('M008');
|
||||
const m009Idx = ids.indexOf('M009');
|
||||
assertTrue(m009Idx < m008Idx, 'findMilestoneIds: M009 comes before M008');
|
||||
assert.ok(m009Idx < m008Idx, 'findMilestoneIds: M009 comes before M008');
|
||||
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test: Reorder + depends_on removal = correct state
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== E2E: reorder with depends_on removal ===');
|
||||
{
|
||||
test('E2E: reorder with depends_on removal', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
// Setup: M007 complete, M008 depends_on M009, M009 no deps
|
||||
|
|
@ -121,7 +118,7 @@ console.log('\n=== E2E: reorder with depends_on removal ===');
|
|||
// Before: M008 depends on M009, so deriveState skips M008, M009 is active
|
||||
invalidateStateCache();
|
||||
const stateBefore = await deriveState(base);
|
||||
assertEq(stateBefore.activeMilestone?.id, 'M009', 'before: M009 active (M008 dep-blocked)');
|
||||
assert.deepStrictEqual(stateBefore.activeMilestone?.id, 'M009', 'before: M009 active (M008 dep-blocked)');
|
||||
|
||||
// Simulate reorder confirm: save order M009→M008, remove depends_on from M008
|
||||
saveQueueOrder(base, ['M009', 'M008']);
|
||||
|
|
@ -134,29 +131,27 @@ console.log('\n=== E2E: reorder with depends_on removal ===');
|
|||
// Verify: depends_on is gone
|
||||
const updatedContent = readContextFile(base, 'M008');
|
||||
const deps = parseContextDependsOn(updatedContent);
|
||||
assertEq(deps.length, 0, 'depends_on removed from M008-CONTEXT.md');
|
||||
assert.deepStrictEqual(deps.length, 0, 'depends_on removed from M008-CONTEXT.md');
|
||||
|
||||
// Verify: deriveState still picks M009 (it's first in queue order)
|
||||
invalidateStateCache();
|
||||
const stateAfter = await deriveState(base);
|
||||
assertEq(stateAfter.activeMilestone?.id, 'M009', 'after: M009 still active (first in queue)');
|
||||
assert.deepStrictEqual(stateAfter.activeMilestone?.id, 'M009', 'after: M009 still active (first in queue)');
|
||||
|
||||
// Verify: M008 is now pending (not dep-blocked)
|
||||
const m008Entry = stateAfter.registry.find(m => m.id === 'M008');
|
||||
assertEq(m008Entry?.status, 'pending', 'M008 is pending (not dep-blocked)');
|
||||
assertTrue(!m008Entry?.dependsOn || m008Entry.dependsOn.length === 0, 'M008 has no dependsOn');
|
||||
assert.deepStrictEqual(m008Entry?.status, 'pending', 'M008 is pending (not dep-blocked)');
|
||||
assert.ok(!m008Entry?.dependsOn || m008Entry.dependsOn.length === 0, 'M008 has no dependsOn');
|
||||
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test: Fresh deriveState (simulating new session) respects queue order
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== E2E: fresh session respects queue order ===');
|
||||
{
|
||||
test('E2E: fresh session respects queue order', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
writeCompleteMilestone(base, 'M007');
|
||||
|
|
@ -171,23 +166,21 @@ console.log('\n=== E2E: fresh session respects queue order ===');
|
|||
|
||||
// Derive state — should read QUEUE-ORDER.json from disk
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.activeMilestone?.id, 'M009', 'fresh session: M009 is active');
|
||||
assert.deepStrictEqual(state.activeMilestone?.id, 'M009', 'fresh session: M009 is active');
|
||||
|
||||
// Verify queue order persisted
|
||||
const order = loadQueueOrder(base);
|
||||
assertEq(order, ['M009', 'M008'], 'QUEUE-ORDER.json persisted correctly');
|
||||
assert.deepStrictEqual(order, ['M009', 'M008'], 'QUEUE-ORDER.json persisted correctly');
|
||||
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test: Queue order with newly added milestones
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== E2E: new milestones appended to queue ===');
|
||||
{
|
||||
test('E2E: new milestones appended to queue', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
writeCompleteMilestone(base, 'M007');
|
||||
|
|
@ -207,24 +200,22 @@ console.log('\n=== E2E: new milestones appended to queue ===');
|
|||
const m009Idx = ids.indexOf('M009');
|
||||
const m008Idx = ids.indexOf('M008');
|
||||
const m010Idx = ids.indexOf('M010');
|
||||
assertTrue(m009Idx < m008Idx, 'M009 before M008');
|
||||
assertTrue(m008Idx < m010Idx, 'M008 before M010 (new milestone appended)');
|
||||
assert.ok(m009Idx < m008Idx, 'M009 before M008');
|
||||
assert.ok(m008Idx < m010Idx, 'M008 before M010 (new milestone appended)');
|
||||
|
||||
// M009 is still active (first non-complete in queue order)
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.activeMilestone?.id, 'M009', 'M009 still active after M010 added');
|
||||
assert.deepStrictEqual(state.activeMilestone?.id, 'M009', 'M009 still active after M010 added');
|
||||
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test: No queue order file = default numeric sort (backward compat)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== E2E: backward compat without QUEUE-ORDER.json ===');
|
||||
{
|
||||
test('E2E: backward compat without QUEUE-ORDER.json', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
writeCompleteMilestone(base, 'M007');
|
||||
|
|
@ -234,22 +225,20 @@ console.log('\n=== E2E: backward compat without QUEUE-ORDER.json ===');
|
|||
// No QUEUE-ORDER.json — default numeric sort
|
||||
invalidateStateCache();
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.activeMilestone?.id, 'M008', 'no queue order: M008 active (numeric)');
|
||||
assert.deepStrictEqual(state.activeMilestone?.id, 'M008', 'no queue order: M008 active (numeric)');
|
||||
|
||||
const ids = findMilestoneIds(base);
|
||||
assertTrue(ids.indexOf('M008') < ids.indexOf('M009'), 'default sort: M008 before M009');
|
||||
assert.ok(ids.indexOf('M008') < ids.indexOf('M009'), 'default sort: M008 before M009');
|
||||
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test: non-milestone directories are filtered out (#1494)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== E2E: non-milestone directories filtered from findMilestoneIds (#1494) ===');
|
||||
{
|
||||
test('E2E: non-milestone directories filtered from findMilestoneIds (#1494)', () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
writeContext(base, 'M001', '', 'First');
|
||||
|
|
@ -260,22 +249,20 @@ console.log('\n=== E2E: non-milestone directories filtered from findMilestoneIds
|
|||
|
||||
invalidateStateCache();
|
||||
const ids = findMilestoneIds(base);
|
||||
assertEq(ids.length, 2, 'only M001 and M002 returned');
|
||||
assertTrue(!ids.includes('slices'), 'slices directory excluded');
|
||||
assertTrue(!ids.includes('temp-backup'), 'temp-backup directory excluded');
|
||||
assertTrue(ids.includes('M001'), 'M001 included');
|
||||
assertTrue(ids.includes('M002'), 'M002 included');
|
||||
assert.deepStrictEqual(ids.length, 2, 'only M001 and M002 returned');
|
||||
assert.ok(!ids.includes('slices'), 'slices directory excluded');
|
||||
assert.ok(!ids.includes('temp-backup'), 'temp-backup directory excluded');
|
||||
assert.ok(ids.includes('M001'), 'M001 included');
|
||||
assert.ok(ids.includes('M002'), 'M002 included');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test: depends_on inline array format removal
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== E2E: depends_on inline format preserved after partial removal ===');
|
||||
{
|
||||
test('E2E: depends_on inline format preserved after partial removal', () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
writeCompleteMilestone(base, 'M007');
|
||||
|
|
@ -287,7 +274,7 @@ console.log('\n=== E2E: depends_on inline format preserved after partial removal
|
|||
// Verify both deps are parsed
|
||||
const contentBefore = readContextFile(base, 'M008');
|
||||
const depsBefore = parseContextDependsOn(contentBefore);
|
||||
assertEq(depsBefore.length, 2, 'M008 has 2 deps before');
|
||||
assert.deepStrictEqual(depsBefore.length, 2, 'M008 has 2 deps before');
|
||||
|
||||
// Simulate removing only M009 dep (keep M010)
|
||||
const content = readContextFile(base, 'M008');
|
||||
|
|
@ -297,12 +284,12 @@ console.log('\n=== E2E: depends_on inline format preserved after partial removal
|
|||
// Verify only M010 remains
|
||||
const contentAfter = readContextFile(base, 'M008');
|
||||
const depsAfter = parseContextDependsOn(contentAfter);
|
||||
assertEq(depsAfter.length, 1, 'M008 has 1 dep after removal');
|
||||
assertEq(depsAfter[0], 'M010', 'remaining dep is M010');
|
||||
assert.deepStrictEqual(depsAfter.length, 1, 'M008 has 1 dep after removal');
|
||||
assert.deepStrictEqual(depsAfter[0], 'M010', 'remaining dep is M010');
|
||||
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
report();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,17 +7,16 @@
|
|||
* Relates to #1269, #1293.
|
||||
*/
|
||||
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
import { captureIntegrationBranch, getCurrentBranch } from "../worktree.ts";
|
||||
import { readIntegrationBranch, QUICK_BRANCH_RE } from "../git-service.ts";
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
function run(command: string, cwd: string): string {
|
||||
return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
|
||||
}
|
||||
|
|
@ -35,68 +34,59 @@ function createTestRepo(): string {
|
|||
return repo;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// QUICK_BRANCH_RE
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n=== QUICK_BRANCH_RE: matches quick-task branches ===");
|
||||
|
||||
assertTrue(QUICK_BRANCH_RE.test("gsd/quick/1-fix-typo"), "matches standard quick branch");
|
||||
assertTrue(QUICK_BRANCH_RE.test("gsd/quick/42-some-long-slug-name"), "matches multi-digit quick branch");
|
||||
assertTrue(!QUICK_BRANCH_RE.test("main"), "rejects main");
|
||||
assertTrue(!QUICK_BRANCH_RE.test("gsd/M001/S01"), "rejects slice branch");
|
||||
assertTrue(!QUICK_BRANCH_RE.test("gsd/quickly-something"), "rejects non-quick prefix");
|
||||
assertTrue(!QUICK_BRANCH_RE.test("feature/gsd/quick/1"), "rejects nested prefix");
|
||||
describe('quick-branch-lifecycle', () => {
|
||||
test('QUICK_BRANCH_RE: matches quick-task branches', () => {
|
||||
assert.ok(QUICK_BRANCH_RE.test("gsd/quick/1-fix-typo"), "matches standard quick branch");
|
||||
});
|
||||
|
||||
assert.ok(QUICK_BRANCH_RE.test("gsd/quick/42-some-long-slug-name"), "matches multi-digit quick branch");
|
||||
assert.ok(!QUICK_BRANCH_RE.test("main"), "rejects main");
|
||||
assert.ok(!QUICK_BRANCH_RE.test("gsd/M001/S01"), "rejects slice branch");
|
||||
assert.ok(!QUICK_BRANCH_RE.test("gsd/quickly-something"), "rejects non-quick prefix");
|
||||
assert.ok(!QUICK_BRANCH_RE.test("feature/gsd/quick/1"), "rejects nested prefix");
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// captureIntegrationBranch: guard against quick-task branches
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n=== captureIntegrationBranch: skips quick-task branches ===");
|
||||
|
||||
{
|
||||
test('captureIntegrationBranch: skips quick-task branches', () => {
|
||||
const repo = createTestRepo();
|
||||
|
||||
// Create and checkout a quick-task branch
|
||||
run("git checkout -b gsd/quick/1-fix-typo", repo);
|
||||
assertEq(getCurrentBranch(repo), "gsd/quick/1-fix-typo", "on quick branch");
|
||||
assert.deepStrictEqual(getCurrentBranch(repo), "gsd/quick/1-fix-typo", "on quick branch");
|
||||
|
||||
captureIntegrationBranch(repo, "M001");
|
||||
|
||||
assertEq(readIntegrationBranch(repo, "M001"), null,
|
||||
assert.deepStrictEqual(readIntegrationBranch(repo, "M001"), null,
|
||||
"captureIntegrationBranch is a no-op on quick-task branches");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Verify main is still recorded correctly ─────────────────────────
|
||||
|
||||
console.log("\n=== captureIntegrationBranch: records main correctly ===");
|
||||
|
||||
{
|
||||
test('captureIntegrationBranch: records main correctly', () => {
|
||||
const repo = createTestRepo();
|
||||
|
||||
// Capture from main — should work normally
|
||||
captureIntegrationBranch(repo, "M001");
|
||||
assertEq(readIntegrationBranch(repo, "M001"), "main",
|
||||
assert.deepStrictEqual(readIntegrationBranch(repo, "M001"), "main",
|
||||
"main is recorded as integration branch");
|
||||
|
||||
// Switch to quick branch — capture should be no-op (doesn't overwrite main)
|
||||
run("git checkout -b gsd/quick/1-fix-typo", repo);
|
||||
captureIntegrationBranch(repo, "M001");
|
||||
assertEq(readIntegrationBranch(repo, "M001"), "main",
|
||||
assert.deepStrictEqual(readIntegrationBranch(repo, "M001"), "main",
|
||||
"quick branch does not overwrite existing integration branch");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Sequence: main → quick → back to main → capture ────────────────
|
||||
|
||||
console.log("\n=== captureIntegrationBranch: correct after quick branch round-trip ===");
|
||||
|
||||
{
|
||||
test('captureIntegrationBranch: correct after quick branch round-trip', () => {
|
||||
const repo = createTestRepo();
|
||||
|
||||
// Simulate quick-task lifecycle: branch off, do work, return to main
|
||||
|
|
@ -111,19 +101,16 @@ async function main(): Promise<void> {
|
|||
|
||||
// Now capture — should get main, not the deleted quick branch
|
||||
captureIntegrationBranch(repo, "M002");
|
||||
assertEq(readIntegrationBranch(repo, "M002"), "main",
|
||||
assert.deepStrictEqual(readIntegrationBranch(repo, "M002"), "main",
|
||||
"after quick round-trip, main is captured correctly");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// cleanupQuickBranch: in-memory path (same session)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n=== cleanupQuickBranch: merges back and cleans up (same session) ===");
|
||||
|
||||
{
|
||||
test('cleanupQuickBranch: merges back and cleans up (same session)', async () => {
|
||||
const repo = createTestRepo();
|
||||
const origCwd = process.cwd();
|
||||
|
||||
|
|
@ -155,30 +142,27 @@ async function main(): Promise<void> {
|
|||
const { cleanupQuickBranch } = await import("../quick.ts");
|
||||
const result = cleanupQuickBranch();
|
||||
|
||||
assertTrue(result, "cleanupQuickBranch returns true");
|
||||
assertEq(getCurrentBranch(repo), "main", "back on main after cleanup");
|
||||
assert.ok(result, "cleanupQuickBranch returns true");
|
||||
assert.deepStrictEqual(getCurrentBranch(repo), "main", "back on main after cleanup");
|
||||
|
||||
// Verify merge happened — fix.txt should exist on main
|
||||
assertTrue(existsSync(join(repo, "fix.txt")), "fix.txt merged to main");
|
||||
assert.ok(existsSync(join(repo, "fix.txt")), "fix.txt merged to main");
|
||||
|
||||
// Verify quick branch deleted
|
||||
const branches = run("git branch", repo);
|
||||
assertTrue(!branches.includes("gsd/quick/1-fix-typo"), "quick branch deleted");
|
||||
assert.ok(!branches.includes("gsd/quick/1-fix-typo"), "quick branch deleted");
|
||||
|
||||
// Verify disk state cleaned up
|
||||
assertTrue(!existsSync(join(runtimeDir, "quick-return.json")), "quick-return.json removed");
|
||||
assert.ok(!existsSync(join(runtimeDir, "quick-return.json")), "quick-return.json removed");
|
||||
|
||||
process.chdir(origCwd);
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// cleanupQuickBranch: cross-session recovery from disk
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n=== cleanupQuickBranch: recovers from disk state (cross-session) ===");
|
||||
|
||||
{
|
||||
test('cleanupQuickBranch: recovers from disk state (cross-session)', async () => {
|
||||
const repo = createTestRepo();
|
||||
const origCwd = process.cwd();
|
||||
|
||||
|
|
@ -206,22 +190,19 @@ async function main(): Promise<void> {
|
|||
const { cleanupQuickBranch } = await import("../quick.ts");
|
||||
const result = cleanupQuickBranch();
|
||||
|
||||
assertTrue(result, "cross-session recovery returns true");
|
||||
assertEq(getCurrentBranch(repo), "main", "back on main after cross-session recovery");
|
||||
assertTrue(existsSync(join(repo, "docs.md")), "docs.md merged to main");
|
||||
assertTrue(!existsSync(join(runtimeDir, "quick-return.json")), "disk state cleaned up");
|
||||
assert.ok(result, "cross-session recovery returns true");
|
||||
assert.deepStrictEqual(getCurrentBranch(repo), "main", "back on main after cross-session recovery");
|
||||
assert.ok(existsSync(join(repo, "docs.md")), "docs.md merged to main");
|
||||
assert.ok(!existsSync(join(runtimeDir, "quick-return.json")), "disk state cleaned up");
|
||||
|
||||
process.chdir(origCwd);
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// cleanupQuickBranch: no-op when no pending state
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n=== cleanupQuickBranch: no-op without pending state ===");
|
||||
|
||||
{
|
||||
test('cleanupQuickBranch: no-op without pending state', async () => {
|
||||
const repo = createTestRepo();
|
||||
const origCwd = process.cwd();
|
||||
process.chdir(repo);
|
||||
|
|
@ -229,32 +210,29 @@ async function main(): Promise<void> {
|
|||
const { cleanupQuickBranch } = await import("../quick.ts");
|
||||
const result = cleanupQuickBranch();
|
||||
|
||||
assertTrue(!result, "returns false when no pending state");
|
||||
assertEq(getCurrentBranch(repo), "main", "stays on main");
|
||||
assert.ok(!result, "returns false when no pending state");
|
||||
assert.deepStrictEqual(getCurrentBranch(repo), "main", "stays on main");
|
||||
|
||||
process.chdir(origCwd);
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// End-to-end: quick branch does NOT contaminate integration branch
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n=== E2E: quick branch does not contaminate integration branch ===");
|
||||
|
||||
{
|
||||
test('E2E: quick branch does not contaminate integration branch', () => {
|
||||
const repo = createTestRepo();
|
||||
|
||||
// 1. Record main as integration branch for M001
|
||||
captureIntegrationBranch(repo, "M001");
|
||||
assertEq(readIntegrationBranch(repo, "M001"), "main", "M001 integration = main");
|
||||
assert.deepStrictEqual(readIntegrationBranch(repo, "M001"), "main", "M001 integration = main");
|
||||
|
||||
// 2. Start a quick task (branch off)
|
||||
run("git checkout -b gsd/quick/1-fix-typo", repo);
|
||||
|
||||
// 3. Try to capture integration branch for M002 while on quick branch
|
||||
captureIntegrationBranch(repo, "M002");
|
||||
assertEq(readIntegrationBranch(repo, "M002"), null,
|
||||
assert.deepStrictEqual(readIntegrationBranch(repo, "M002"), null,
|
||||
"M002 integration NOT recorded from quick branch");
|
||||
|
||||
// 4. Return to main (simulate cleanupQuickBranch)
|
||||
|
|
@ -262,20 +240,14 @@ async function main(): Promise<void> {
|
|||
|
||||
// 5. Now capture M002 from main — should work
|
||||
captureIntegrationBranch(repo, "M002");
|
||||
assertEq(readIntegrationBranch(repo, "M002"), "main",
|
||||
assert.deepStrictEqual(readIntegrationBranch(repo, "M002"), "main",
|
||||
"M002 integration = main after returning from quick branch");
|
||||
|
||||
// 6. Verify M001 still intact
|
||||
assertEq(readIntegrationBranch(repo, "M001"), "main",
|
||||
assert.deepStrictEqual(readIntegrationBranch(repo, "M001"), "main",
|
||||
"M001 integration unchanged");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
|
||||
// loadPrompt reads from ~/.gsd/agent/extensions/gsd/prompts/ (main checkout).
|
||||
// In a worktree the file may not exist there yet, so we resolve prompts
|
||||
// relative to this test file's location (the worktree copy).
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const worktreePromptsDir = join(__dirname, "..", "prompts");
|
||||
|
||||
const { assertTrue, report } = createTestContext();
|
||||
/**
|
||||
* Load a prompt template from the worktree prompts directory
|
||||
* and apply variable substitution (mirrors loadPrompt logic).
|
||||
|
|
@ -27,11 +26,10 @@ function loadPromptFromWorktree(name: string, vars: Record<string, string> = {})
|
|||
// Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
// ─── reassess-roadmap prompt loads and substitutes ─────────────────────
|
||||
console.log("\n=== reassess-roadmap prompt loads and substitutes ===");
|
||||
{
|
||||
|
||||
describe('reassess-prompt', () => {
|
||||
test('reassess-roadmap prompt loads and substitutes', () => {
|
||||
const testVars = {
|
||||
workingDirectory: "/tmp/test-project",
|
||||
milestoneId: "M099",
|
||||
|
|
@ -51,27 +49,26 @@ async function main(): Promise<void> {
|
|||
console.error(` ERROR: loadPrompt threw: ${err}`);
|
||||
}
|
||||
|
||||
assertTrue(!threw, "loadPrompt does not throw for reassess-roadmap");
|
||||
assertTrue(typeof result === "string" && result.length > 0, "loadPrompt returns a non-empty string");
|
||||
assert.ok(!threw, "loadPrompt does not throw for reassess-roadmap");
|
||||
assert.ok(typeof result === "string" && result.length > 0, "loadPrompt returns a non-empty string");
|
||||
|
||||
// Verify all test variables were substituted into the output
|
||||
assertTrue(result.includes("M099"), "prompt contains milestoneId 'M099'");
|
||||
assertTrue(result.includes("S03"), "prompt contains completedSliceId 'S03'");
|
||||
assertTrue(result.includes(".gsd/milestones/M099/slices/S03/S03-ASSESSMENT.md"), "prompt contains assessmentPath");
|
||||
assertTrue(result.includes(".gsd/milestones/M099/M099-ROADMAP.md"), "prompt contains roadmapPath");
|
||||
assertTrue(result.includes("--- test inlined context block ---"), "prompt contains inlinedContext");
|
||||
assert.ok(result.includes("M099"), "prompt contains milestoneId 'M099'");
|
||||
assert.ok(result.includes("S03"), "prompt contains completedSliceId 'S03'");
|
||||
assert.ok(result.includes(".gsd/milestones/M099/slices/S03/S03-ASSESSMENT.md"), "prompt contains assessmentPath");
|
||||
assert.ok(result.includes(".gsd/milestones/M099/M099-ROADMAP.md"), "prompt contains roadmapPath");
|
||||
assert.ok(result.includes("--- test inlined context block ---"), "prompt contains inlinedContext");
|
||||
|
||||
// Verify no un-substituted variables remain
|
||||
assertTrue(!result.includes("{{milestoneId}}"), "no un-substituted {{milestoneId}}");
|
||||
assertTrue(!result.includes("{{completedSliceId}}"), "no un-substituted {{completedSliceId}}");
|
||||
assertTrue(!result.includes("{{assessmentPath}}"), "no un-substituted {{assessmentPath}}");
|
||||
assertTrue(!result.includes("{{roadmapPath}}"), "no un-substituted {{roadmapPath}}");
|
||||
assertTrue(!result.includes("{{inlinedContext}}"), "no un-substituted {{inlinedContext}}");
|
||||
}
|
||||
assert.ok(!result.includes("{{milestoneId}}"), "no un-substituted {{milestoneId}}");
|
||||
assert.ok(!result.includes("{{completedSliceId}}"), "no un-substituted {{completedSliceId}}");
|
||||
assert.ok(!result.includes("{{assessmentPath}}"), "no un-substituted {{assessmentPath}}");
|
||||
assert.ok(!result.includes("{{roadmapPath}}"), "no un-substituted {{roadmapPath}}");
|
||||
assert.ok(!result.includes("{{inlinedContext}}"), "no un-substituted {{inlinedContext}}");
|
||||
});
|
||||
|
||||
// ─── reassess-roadmap contains coverage-check instruction ─────────────
|
||||
console.log("\n=== reassess-roadmap contains coverage-check instruction ===");
|
||||
{
|
||||
test('reassess-roadmap contains coverage-check instruction', () => {
|
||||
const prompt = loadPromptFromWorktree("reassess-roadmap", {
|
||||
workingDirectory: "/tmp/test-project",
|
||||
milestoneId: "M001",
|
||||
|
|
@ -85,33 +82,32 @@ async function main(): Promise<void> {
|
|||
const lower = prompt.toLowerCase();
|
||||
|
||||
// The prompt must mention "each success criterion" or "every success criterion"
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
lower.includes("each success criterion") || lower.includes("every success criterion"),
|
||||
"prompt contains 'each success criterion' or 'every success criterion'"
|
||||
);
|
||||
|
||||
// The prompt must mention "owning slice" or "remaining slice"
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
lower.includes("owning slice") || lower.includes("remaining slice"),
|
||||
"prompt contains 'owning slice' or 'remaining slice'"
|
||||
);
|
||||
|
||||
// The prompt must mention "no remaining owner" or "no owner" or "no slice"
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
lower.includes("no remaining owner") || lower.includes("no owner") || lower.includes("no slice"),
|
||||
"prompt contains 'no remaining owner', 'no owner', or 'no slice'"
|
||||
);
|
||||
|
||||
// The prompt must mention "blocking issue" or "blocking"
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
lower.includes("blocking issue") || lower.includes("blocking"),
|
||||
"prompt contains 'blocking issue' or 'blocking'"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── coverage-check requires at-least-one semantics ───────────────────
|
||||
console.log("\n=== coverage-check requires at-least-one semantics ===");
|
||||
{
|
||||
test('coverage-check requires at-least-one semantics', () => {
|
||||
const prompt = loadPromptFromWorktree("reassess-roadmap", {
|
||||
workingDirectory: "/tmp/test-project",
|
||||
milestoneId: "M001",
|
||||
|
|
@ -124,22 +120,16 @@ async function main(): Promise<void> {
|
|||
const lower = prompt.toLowerCase();
|
||||
|
||||
// The instruction must use "at least one" or equivalent inclusive language
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
lower.includes("at least one") || lower.includes("at-least-one") || lower.includes("one or more"),
|
||||
"prompt uses 'at least one' or equivalent inclusive language for slice ownership"
|
||||
);
|
||||
|
||||
// The instruction must NOT require "exactly one" — that would be too rigid
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
!lower.includes("exactly one owner") && !lower.includes("exactly one slice"),
|
||||
"prompt does NOT use 'exactly one' for slice ownership (would be too rigid)"
|
||||
);
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
|
@ -22,7 +24,6 @@ function loadPromptFromWorktree(name: string, vars: Record<string, string> = {})
|
|||
return content.trim();
|
||||
}
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
// ─── Fixture Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function createFixtureBase(): string {
|
||||
|
|
@ -161,7 +162,7 @@ Found a blocker.
|
|||
`;
|
||||
|
||||
const s = parseSummary(content);
|
||||
assertEq(s.frontmatter.blocker_discovered, true, 'blocker_discovered: true (string) extracts as true');
|
||||
assert.deepStrictEqual(s.frontmatter.blocker_discovered, true, 'blocker_discovered: true (string) extracts as true');
|
||||
}
|
||||
|
||||
console.log('\n=== parseSummary: blocker_discovered false (string) ===');
|
||||
|
|
@ -184,7 +185,7 @@ No blocker.
|
|||
`;
|
||||
|
||||
const s = parseSummary(content);
|
||||
assertEq(s.frontmatter.blocker_discovered, false, 'blocker_discovered: false extracts as false');
|
||||
assert.deepStrictEqual(s.frontmatter.blocker_discovered, false, 'blocker_discovered: false extracts as false');
|
||||
}
|
||||
|
||||
console.log('\n=== parseSummary: blocker_discovered missing (defaults to false) ===');
|
||||
|
|
@ -206,7 +207,7 @@ No blocker field at all.
|
|||
`;
|
||||
|
||||
const s = parseSummary(content);
|
||||
assertEq(s.frontmatter.blocker_discovered, false, 'blocker_discovered missing defaults to false');
|
||||
assert.deepStrictEqual(s.frontmatter.blocker_discovered, false, 'blocker_discovered missing defaults to false');
|
||||
}
|
||||
|
||||
console.log('\n=== parseSummary: blocker_discovered true (boolean from YAML) ===');
|
||||
|
|
@ -232,7 +233,7 @@ Blocker as boolean.
|
|||
`;
|
||||
|
||||
const s = parseSummary(content);
|
||||
assertEq(s.frontmatter.blocker_discovered, true, 'blocker_discovered: true (YAML boolean) extracts as true');
|
||||
assert.deepStrictEqual(s.frontmatter.blocker_discovered, true, 'blocker_discovered: true (YAML boolean) extracts as true');
|
||||
}
|
||||
|
||||
console.log('\n=== parseSummary: blocker_discovered with full frontmatter ===');
|
||||
|
|
@ -275,10 +276,10 @@ Major deviation from plan.
|
|||
`;
|
||||
|
||||
const s = parseSummary(content);
|
||||
assertEq(s.frontmatter.blocker_discovered, true, 'blocker_discovered true with full frontmatter');
|
||||
assertEq(s.frontmatter.id, 'T05', 'other fields still parse correctly alongside blocker_discovered');
|
||||
assertEq(s.frontmatter.duration, '15min', 'duration still parsed');
|
||||
assertEq(s.frontmatter.provides[0], 'something', 'provides still parsed');
|
||||
assert.deepStrictEqual(s.frontmatter.blocker_discovered, true, 'blocker_discovered true with full frontmatter');
|
||||
assert.deepStrictEqual(s.frontmatter.id, 'T05', 'other fields still parse correctly alongside blocker_discovered');
|
||||
assert.deepStrictEqual(s.frontmatter.duration, '15min', 'duration still parsed');
|
||||
assert.deepStrictEqual(s.frontmatter.provides[0], 'something', 'provides still parsed');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -294,11 +295,11 @@ console.log('\n=== deriveState: blocker found, no REPLAN → replanning-slice ==
|
|||
writeTaskSummary(base, 'M001', 'S01', 'T01', makeTaskSummary('T01', true));
|
||||
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.phase, 'replanning-slice', 'phase is replanning-slice when blocker found and no REPLAN.md');
|
||||
assertTrue(state.nextAction.includes('T01'), 'nextAction mentions blocker task T01');
|
||||
assertTrue(state.nextAction.includes('blocker_discovered'), 'nextAction mentions blocker_discovered');
|
||||
assertEq(state.activeTask?.id, 'T02', 'activeTask is still T02 (the next incomplete task)');
|
||||
assertTrue(state.blockers.length > 0, 'blockers array is non-empty');
|
||||
assert.deepStrictEqual(state.phase, 'replanning-slice', 'phase is replanning-slice when blocker found and no REPLAN.md');
|
||||
assert.ok(state.nextAction.includes('T01'), 'nextAction mentions blocker task T01');
|
||||
assert.ok(state.nextAction.includes('blocker_discovered'), 'nextAction mentions blocker_discovered');
|
||||
assert.deepStrictEqual(state.activeTask?.id, 'T02', 'activeTask is still T02 (the next incomplete task)');
|
||||
assert.ok(state.blockers.length > 0, 'blockers array is non-empty');
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
|
|
@ -312,8 +313,8 @@ console.log('\n=== deriveState: blocker found + REPLAN exists → executing (loo
|
|||
writeReplanFile(base, 'M001', 'S01', '# Replan\n\nAlready replanned.');
|
||||
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.phase, 'executing', 'phase is executing when REPLAN.md exists (loop protection)');
|
||||
assertEq(state.activeTask?.id, 'T02', 'activeTask is T02');
|
||||
assert.deepStrictEqual(state.phase, 'executing', 'phase is executing when REPLAN.md exists (loop protection)');
|
||||
assert.deepStrictEqual(state.activeTask?.id, 'T02', 'activeTask is T02');
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
|
|
@ -326,8 +327,8 @@ console.log('\n=== deriveState: no blocker in completed tasks → executing ==='
|
|||
writeTaskSummary(base, 'M001', 'S01', 'T01', makeTaskSummary('T01', false));
|
||||
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.phase, 'executing', 'phase is executing when no blocker found');
|
||||
assertEq(state.activeTask?.id, 'T02', 'activeTask is T02');
|
||||
assert.deepStrictEqual(state.phase, 'executing', 'phase is executing when no blocker found');
|
||||
assert.deepStrictEqual(state.activeTask?.id, 'T02', 'activeTask is T02');
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
|
|
@ -341,9 +342,9 @@ console.log('\n=== deriveState: multiple completed tasks, one blocker → replan
|
|||
writeTaskSummary(base, 'M001', 'S01', 'T02', makeTaskSummary('T02', true));
|
||||
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.phase, 'replanning-slice', 'phase is replanning-slice when T02 has blocker');
|
||||
assertTrue(state.nextAction.includes('T02'), 'nextAction mentions blocker task T02');
|
||||
assertEq(state.activeTask?.id, 'T03', 'activeTask is T03 (next incomplete)');
|
||||
assert.deepStrictEqual(state.phase, 'replanning-slice', 'phase is replanning-slice when T02 has blocker');
|
||||
assert.ok(state.nextAction.includes('T02'), 'nextAction mentions blocker task T02');
|
||||
assert.deepStrictEqual(state.activeTask?.id, 'T03', 'activeTask is T03 (next incomplete)');
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
|
|
@ -356,7 +357,7 @@ console.log('\n=== deriveState: completed task with no summary file → executin
|
|||
// No summary file written for T01
|
||||
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.phase, 'executing', 'phase is executing when completed task has no summary');
|
||||
assert.deepStrictEqual(state.phase, 'executing', 'phase is executing when completed task has no summary');
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
|
|
@ -376,11 +377,11 @@ console.log('\n=== prompt: replan-slice template loads and substitutes variables
|
|||
inlinedContext: '## Inlined Context\n\nTest context here.',
|
||||
});
|
||||
|
||||
assertTrue(prompt.includes('M001'), 'prompt contains milestoneId');
|
||||
assertTrue(prompt.includes('S01'), 'prompt contains sliceId');
|
||||
assertTrue(prompt.includes('Test Slice'), 'prompt contains sliceTitle');
|
||||
assertTrue(prompt.includes('.gsd/milestones/M001/slices/S01/S01-PLAN.md'), 'prompt contains planPath');
|
||||
assertTrue(prompt.includes('Test context here'), 'prompt contains inlined context');
|
||||
assert.ok(prompt.includes('M001'), 'prompt contains milestoneId');
|
||||
assert.ok(prompt.includes('S01'), 'prompt contains sliceId');
|
||||
assert.ok(prompt.includes('Test Slice'), 'prompt contains sliceTitle');
|
||||
assert.ok(prompt.includes('.gsd/milestones/M001/slices/S01/S01-PLAN.md'), 'prompt contains planPath');
|
||||
assert.ok(prompt.includes('Test context here'), 'prompt contains inlined context');
|
||||
}
|
||||
|
||||
console.log('\n=== prompt: replan-slice contains preserve-completed-tasks instruction ===');
|
||||
|
|
@ -397,10 +398,10 @@ console.log('\n=== prompt: replan-slice contains preserve-completed-tasks instru
|
|||
inlinedContext: '',
|
||||
});
|
||||
|
||||
assertTrue(prompt.includes('Do NOT renumber or remove completed tasks'), 'prompt contains preserve-completed-tasks instruction');
|
||||
assertTrue(prompt.includes('[x]'), 'prompt mentions [x] checkmarks');
|
||||
assertTrue(prompt.includes('REPLAN'), 'prompt references replan output path');
|
||||
assertTrue(prompt.includes('blocker_discovered'), 'prompt mentions blocker_discovered');
|
||||
assert.ok(prompt.includes('Do NOT renumber or remove completed tasks'), 'prompt contains preserve-completed-tasks instruction');
|
||||
assert.ok(prompt.includes('[x]'), 'prompt mentions [x] checkmarks');
|
||||
assert.ok(prompt.includes('REPLAN'), 'prompt references replan output path');
|
||||
assert.ok(prompt.includes('blocker_discovered'), 'prompt mentions blocker_discovered');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -421,8 +422,8 @@ console.log('\n=== dispatch: diagnoseExpectedArtifact returns REPLAN.md path ===
|
|||
writeTaskSummary(base, 'M001', 'S01', 'T01', makeTaskSummary('T01', true));
|
||||
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.phase, 'replanning-slice', 'dispatch: state routes to replanning-slice when blocker found');
|
||||
assertTrue(state.activeSlice?.id === 'S01', 'dispatch: activeSlice is S01');
|
||||
assert.deepStrictEqual(state.phase, 'replanning-slice', 'dispatch: state routes to replanning-slice when blocker found');
|
||||
assert.ok(state.activeSlice?.id === 'S01', 'dispatch: activeSlice is S01');
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
|
|
@ -443,8 +444,8 @@ console.log('\n=== display: replan-slice prompt template has correct unit header
|
|||
inlinedContext: '',
|
||||
});
|
||||
|
||||
assertTrue(prompt.includes('UNIT: Replan Slice'), 'prompt has Replan Slice unit header');
|
||||
assertTrue(prompt.includes('Slice S01 replanned'), 'prompt has completion message');
|
||||
assert.ok(prompt.includes('UNIT: Replan Slice'), 'prompt has Replan Slice unit header');
|
||||
assert.ok(prompt.includes('Slice S01 replanned'), 'prompt has completion message');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -452,8 +453,6 @@ console.log('\n=== display: replan-slice prompt template has correct unit header
|
|||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { runGSDDoctor } from '../doctor.ts';
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
|
||||
// (a) blocker + no REPLAN.md → issue emitted
|
||||
console.log('\n=== doctor: blocker + no REPLAN.md → blocker_discovered_no_replan issue ===');
|
||||
{
|
||||
|
|
@ -464,10 +463,10 @@ console.log('\n=== doctor: blocker + no REPLAN.md → blocker_discovered_no_repl
|
|||
|
||||
const report = await runGSDDoctor(base, { fix: false, scope: 'M001/S01' });
|
||||
const blockerIssues = report.issues.filter(i => i.code === 'blocker_discovered_no_replan');
|
||||
assertTrue(blockerIssues.length > 0, 'doctor emits blocker_discovered_no_replan when blocker + no REPLAN');
|
||||
assertTrue(blockerIssues[0]?.message.includes('T01'), 'issue message mentions the blocker task T01');
|
||||
assertEq(blockerIssues[0]?.severity, 'warning', 'blocker_discovered_no_replan is warning severity');
|
||||
assertEq(blockerIssues[0]?.scope, 'slice', 'blocker_discovered_no_replan has slice scope');
|
||||
assert.ok(blockerIssues.length > 0, 'doctor emits blocker_discovered_no_replan when blocker + no REPLAN');
|
||||
assert.ok(blockerIssues[0]?.message.includes('T01'), 'issue message mentions the blocker task T01');
|
||||
assert.deepStrictEqual(blockerIssues[0]?.severity, 'warning', 'blocker_discovered_no_replan is warning severity');
|
||||
assert.deepStrictEqual(blockerIssues[0]?.scope, 'slice', 'blocker_discovered_no_replan has slice scope');
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
|
|
@ -482,7 +481,7 @@ console.log('\n=== doctor: blocker + REPLAN.md exists → no blocker_discovered_
|
|||
|
||||
const report = await runGSDDoctor(base, { fix: false, scope: 'M001/S01' });
|
||||
const blockerIssues = report.issues.filter(i => i.code === 'blocker_discovered_no_replan');
|
||||
assertEq(blockerIssues.length, 0, 'no blocker_discovered_no_replan when REPLAN.md exists');
|
||||
assert.deepStrictEqual(blockerIssues.length, 0, 'no blocker_discovered_no_replan when REPLAN.md exists');
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
|
|
@ -496,7 +495,7 @@ console.log('\n=== doctor: no blocker → no blocker_discovered_no_replan issue
|
|||
|
||||
const report = await runGSDDoctor(base, { fix: false, scope: 'M001/S01' });
|
||||
const blockerIssues = report.issues.filter(i => i.code === 'blocker_discovered_no_replan');
|
||||
assertEq(blockerIssues.length, 0, 'no blocker_discovered_no_replan when no blocker');
|
||||
assert.deepStrictEqual(blockerIssues.length, 0, 'no blocker_discovered_no_replan when no blocker');
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
|
|
@ -506,48 +505,45 @@ console.log('\n=== doctor: no blocker → no blocker_discovered_no_replan issue
|
|||
|
||||
import { resolveExpectedArtifactPath, verifyExpectedArtifact } from '../auto-recovery.ts';
|
||||
|
||||
console.log('\n=== artifact: resolveExpectedArtifactPath returns REPLAN.md path for replan-slice ===');
|
||||
{
|
||||
|
||||
describe('replan-slice', () => {
|
||||
test('artifact: resolveExpectedArtifactPath returns REPLAN.md path for replan-slice', () => {
|
||||
const base = createFixtureBase();
|
||||
writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE);
|
||||
writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending());
|
||||
|
||||
const path = resolveExpectedArtifactPath('replan-slice', 'M001/S01', base);
|
||||
assertTrue(path !== null, 'resolveExpectedArtifactPath returns non-null for replan-slice');
|
||||
assertTrue(path!.endsWith('S01-REPLAN.md'), 'path ends with S01-REPLAN.md');
|
||||
assert.ok(path !== null, 'resolveExpectedArtifactPath returns non-null for replan-slice');
|
||||
assert.ok(path!.endsWith('S01-REPLAN.md'), 'path ends with S01-REPLAN.md');
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n=== artifact: verifyExpectedArtifact fails when REPLAN.md missing (#858) ===');
|
||||
{
|
||||
test('artifact: verifyExpectedArtifact fails when REPLAN.md missing (#858)', () => {
|
||||
const base = createFixtureBase();
|
||||
writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE);
|
||||
writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending());
|
||||
|
||||
const result = verifyExpectedArtifact('replan-slice', 'M001/S01', base);
|
||||
assertEq(result, false, 'verifyExpectedArtifact returns false when REPLAN.md is missing');
|
||||
assert.deepStrictEqual(result, false, 'verifyExpectedArtifact returns false when REPLAN.md is missing');
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n=== artifact: verifyExpectedArtifact passes when REPLAN.md exists (#858) ===');
|
||||
{
|
||||
test('artifact: verifyExpectedArtifact passes when REPLAN.md exists (#858)', () => {
|
||||
const base = createFixtureBase();
|
||||
writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE);
|
||||
writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending());
|
||||
writeReplanFile(base, 'M001', 'S01', '# Replan\n\nBlocker addressed.');
|
||||
|
||||
const result = verifyExpectedArtifact('replan-slice', 'M001/S01', base);
|
||||
assertEq(result, true, 'verifyExpectedArtifact returns true when REPLAN.md exists');
|
||||
assert.deepStrictEqual(result, true, 'verifyExpectedArtifact returns true when REPLAN.md exists');
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// REPLAN-TRIGGER.md detection (triage-initiated replan, #1701)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// (a) REPLAN-TRIGGER.md exists + no REPLAN.md → replanning-slice
|
||||
console.log('\n=== deriveState: REPLAN-TRIGGER.md exists, no REPLAN → replanning-slice (#1701) ===');
|
||||
{
|
||||
test('deriveState: REPLAN-TRIGGER.md exists, no REPLAN → replanning-slice (#1701)', async () => {
|
||||
const base = createFixtureBase();
|
||||
writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE);
|
||||
writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending());
|
||||
|
|
@ -556,17 +552,16 @@ console.log('\n=== deriveState: REPLAN-TRIGGER.md exists, no REPLAN → replanni
|
|||
writeReplanTrigger(base, 'M001', 'S01', '# Replan Trigger\n\n**Source:** Capture C001\n');
|
||||
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.phase, 'replanning-slice', 'phase is replanning-slice when REPLAN-TRIGGER.md exists');
|
||||
assertTrue(state.blockers.length > 0, 'blockers array is non-empty for triage replan trigger');
|
||||
assertTrue(state.nextAction.includes('Triage replan'), 'nextAction mentions triage replan');
|
||||
assertEq(state.activeSlice?.id, 'S01', 'activeSlice is S01');
|
||||
assertEq(state.activeTask?.id, 'T02', 'activeTask is T02 (next incomplete task)');
|
||||
assert.deepStrictEqual(state.phase, 'replanning-slice', 'phase is replanning-slice when REPLAN-TRIGGER.md exists');
|
||||
assert.ok(state.blockers.length > 0, 'blockers array is non-empty for triage replan trigger');
|
||||
assert.ok(state.nextAction.includes('Triage replan'), 'nextAction mentions triage replan');
|
||||
assert.deepStrictEqual(state.activeSlice?.id, 'S01', 'activeSlice is S01');
|
||||
assert.deepStrictEqual(state.activeTask?.id, 'T02', 'activeTask is T02 (next incomplete task)');
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// (b) REPLAN-TRIGGER.md + REPLAN.md both exist → executing (loop protection)
|
||||
console.log('\n=== deriveState: REPLAN-TRIGGER.md + REPLAN.md → executing (loop protection, #1701) ===');
|
||||
{
|
||||
test('deriveState: REPLAN-TRIGGER.md + REPLAN.md → executing (loop protection, #1701)', async () => {
|
||||
const base = createFixtureBase();
|
||||
writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE);
|
||||
writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending());
|
||||
|
|
@ -575,27 +570,25 @@ console.log('\n=== deriveState: REPLAN-TRIGGER.md + REPLAN.md → executing (loo
|
|||
writeReplanFile(base, 'M001', 'S01', '# Replan\n\nAlready replanned.');
|
||||
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.phase, 'executing', 'phase is executing when REPLAN.md exists (loop protection)');
|
||||
assertEq(state.activeTask?.id, 'T02', 'activeTask is T02');
|
||||
assert.deepStrictEqual(state.phase, 'executing', 'phase is executing when REPLAN.md exists (loop protection)');
|
||||
assert.deepStrictEqual(state.activeTask?.id, 'T02', 'activeTask is T02');
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// (c) No REPLAN-TRIGGER.md, no blocker → executing (no false positive)
|
||||
console.log('\n=== deriveState: no REPLAN-TRIGGER.md, no blocker → executing (#1701) ===');
|
||||
{
|
||||
test('deriveState: no REPLAN-TRIGGER.md, no blocker → executing (#1701)', async () => {
|
||||
const base = createFixtureBase();
|
||||
writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE);
|
||||
writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending());
|
||||
writeTaskSummary(base, 'M001', 'S01', 'T01', makeTaskSummary('T01', false));
|
||||
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.phase, 'executing', 'phase is executing when no trigger and no blocker');
|
||||
assert.deepStrictEqual(state.phase, 'executing', 'phase is executing when no trigger and no blocker');
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// (d) blocker_discovered takes priority over REPLAN-TRIGGER.md
|
||||
console.log('\n=== deriveState: blocker_discovered takes priority over REPLAN-TRIGGER.md (#1701) ===');
|
||||
{
|
||||
test('deriveState: blocker_discovered takes priority over REPLAN-TRIGGER.md (#1701)', async () => {
|
||||
const base = createFixtureBase();
|
||||
writeRoadmap(base, 'M001', ROADMAP_ONE_SLICE);
|
||||
writePlan(base, 'M001', 'S01', makePlanT01DoneT02Pending());
|
||||
|
|
@ -603,10 +596,10 @@ console.log('\n=== deriveState: blocker_discovered takes priority over REPLAN-TR
|
|||
writeReplanTrigger(base, 'M001', 'S01', '# Replan Trigger\n\n**Source:** Capture C001\n');
|
||||
|
||||
const state = await deriveState(base);
|
||||
assertEq(state.phase, 'replanning-slice', 'phase is replanning-slice');
|
||||
assert.deepStrictEqual(state.phase, 'replanning-slice', 'phase is replanning-slice');
|
||||
// blocker_discovered path should fire first (blockerTaskId is set, so REPLAN-TRIGGER check is skipped)
|
||||
assertTrue(state.nextAction.includes('T01'), 'nextAction mentions blocker task T01 (blocker path, not trigger path)');
|
||||
assert.ok(state.nextAction.includes('T01'), 'nextAction mentions blocker task T01 (blocker path, not trigger path)');
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
report();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
import { describe, test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, writeFileSync, existsSync, lstatSync, realpathSync, mkdirSync, symlinkSync, renameSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
import { repoIdentity, externalGsdRoot, ensureGsdSymlink, validateProjectId, readRepoMeta, isInheritedRepo } from "../repo-identity.ts";
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
/**
|
||||
* Normalize a path for reliable comparison on Windows CI runners.
|
||||
* `os.tmpdir()` may return the 8.3 short-path form (e.g. `C:\Users\RUNNER~1`)
|
||||
|
|
@ -23,11 +21,15 @@ function run(command: string, cwd: string): string {
|
|||
return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-repo-identity-")));
|
||||
const stateDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-state-")));
|
||||
describe('repo-identity-worktree', () => {
|
||||
let base: string;
|
||||
let stateDir: string;
|
||||
let worktreePath: string;
|
||||
let expectedExternalState: string;
|
||||
|
||||
try {
|
||||
before(() => {
|
||||
base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-repo-identity-")));
|
||||
stateDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-state-")));
|
||||
process.env.GSD_STATE_DIR = stateDir;
|
||||
|
||||
run("git init -b main", base);
|
||||
|
|
@ -38,57 +40,69 @@ async function main(): Promise<void> {
|
|||
run("git add README.md", base);
|
||||
run('git commit -m "chore: init"', base);
|
||||
|
||||
const worktreePath = join(base, ".gsd", "worktrees", "M001");
|
||||
worktreePath = join(base, ".gsd", "worktrees", "M001");
|
||||
run(`git worktree add -b milestone/M001 ${worktreePath}`, base);
|
||||
|
||||
console.log("\n=== ensureGsdSymlink points worktree at main repo external state dir ===");
|
||||
const expectedExternalState = externalGsdRoot(base);
|
||||
const mainState = ensureGsdSymlink(base);
|
||||
assertEq(mainState, realpathSync(join(base, ".gsd")), "ensureGsdSymlink(base) returns the current main repo .gsd target");
|
||||
const worktreeState = ensureGsdSymlink(worktreePath);
|
||||
assertEq(worktreeState, expectedExternalState, "worktree symlink target matches main repo external state dir");
|
||||
assertTrue(existsSync(join(worktreePath, ".gsd")), "worktree .gsd exists");
|
||||
assertTrue(lstatSync(join(worktreePath, ".gsd")).isSymbolicLink(), "worktree .gsd is a symlink");
|
||||
assertEq(realpathSync(join(worktreePath, ".gsd")), realpathSync(expectedExternalState), "worktree .gsd symlink resolves to main repo external state dir");
|
||||
expectedExternalState = externalGsdRoot(base);
|
||||
});
|
||||
|
||||
console.log("\n=== ensureGsdSymlink heals stale worktree symlinks ===");
|
||||
after(() => {
|
||||
delete process.env.GSD_PROJECT_ID;
|
||||
delete process.env.GSD_STATE_DIR;
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('ensureGsdSymlink points worktree at main repo external state dir', () => {
|
||||
const mainState = ensureGsdSymlink(base);
|
||||
assert.deepStrictEqual(mainState, realpathSync(join(base, ".gsd")), "ensureGsdSymlink(base) returns the current main repo .gsd target");
|
||||
const worktreeState = ensureGsdSymlink(worktreePath);
|
||||
assert.deepStrictEqual(worktreeState, expectedExternalState, "worktree symlink target matches main repo external state dir");
|
||||
assert.ok(existsSync(join(worktreePath, ".gsd")), "worktree .gsd exists");
|
||||
assert.ok(lstatSync(join(worktreePath, ".gsd")).isSymbolicLink(), "worktree .gsd is a symlink");
|
||||
assert.deepStrictEqual(realpathSync(join(worktreePath, ".gsd")), realpathSync(expectedExternalState), "worktree .gsd symlink resolves to main repo external state dir");
|
||||
});
|
||||
|
||||
test('ensureGsdSymlink heals stale worktree symlinks', () => {
|
||||
const staleState = join(stateDir, "projects", "stale-worktree-state");
|
||||
mkdirSync(staleState, { recursive: true });
|
||||
rmSync(join(worktreePath, ".gsd"), { recursive: true, force: true });
|
||||
symlinkSync(staleState, join(worktreePath, ".gsd"), "junction");
|
||||
const healedState = ensureGsdSymlink(worktreePath);
|
||||
assertEq(healedState, expectedExternalState, "stale worktree symlink is repaired to canonical external state dir");
|
||||
assertEq(realpathSync(join(worktreePath, ".gsd")), realpathSync(expectedExternalState), "healed worktree symlink resolves to canonical external state dir");
|
||||
assert.deepStrictEqual(healedState, expectedExternalState, "stale worktree symlink is repaired to canonical external state dir");
|
||||
assert.deepStrictEqual(realpathSync(join(worktreePath, ".gsd")), realpathSync(expectedExternalState), "healed worktree symlink resolves to canonical external state dir");
|
||||
});
|
||||
|
||||
console.log("\n=== ensureGsdSymlink preserves worktree .gsd directories ===");
|
||||
test('ensureGsdSymlink preserves worktree .gsd directories', () => {
|
||||
rmSync(join(worktreePath, ".gsd"), { recursive: true, force: true });
|
||||
mkdirSync(join(worktreePath, ".gsd", "milestones"), { recursive: true });
|
||||
writeFileSync(join(worktreePath, ".gsd", "milestones", "stale.txt"), "stale\n", "utf-8");
|
||||
const preservedDirState = ensureGsdSymlink(worktreePath);
|
||||
assertEq(preservedDirState, join(worktreePath, ".gsd"), "worktree .gsd directory is left in place for sync-based refresh");
|
||||
assertTrue(lstatSync(join(worktreePath, ".gsd")).isDirectory(), "worktree .gsd directory remains a directory");
|
||||
assertTrue(existsSync(join(worktreePath, ".gsd", "milestones", "stale.txt")), "existing worktree .gsd directory contents remain available for sync logic");
|
||||
assert.deepStrictEqual(preservedDirState, join(worktreePath, ".gsd"), "worktree .gsd directory is left in place for sync-based refresh");
|
||||
assert.ok(lstatSync(join(worktreePath, ".gsd")).isDirectory(), "worktree .gsd directory remains a directory");
|
||||
assert.ok(existsSync(join(worktreePath, ".gsd", "milestones", "stale.txt")), "existing worktree .gsd directory contents remain available for sync logic");
|
||||
});
|
||||
|
||||
console.log("\n=== GSD_PROJECT_ID overrides computed repo hash ===");
|
||||
test('GSD_PROJECT_ID overrides computed repo hash', () => {
|
||||
process.env.GSD_PROJECT_ID = "my-project";
|
||||
assertEq(repoIdentity(base), "my-project", "repoIdentity returns GSD_PROJECT_ID when set");
|
||||
assertEq(externalGsdRoot(base), join(stateDir, "projects", "my-project"), "externalGsdRoot uses GSD_PROJECT_ID");
|
||||
assert.deepStrictEqual(repoIdentity(base), "my-project", "repoIdentity returns GSD_PROJECT_ID when set");
|
||||
assert.deepStrictEqual(externalGsdRoot(base), join(stateDir, "projects", "my-project"), "externalGsdRoot uses GSD_PROJECT_ID");
|
||||
delete process.env.GSD_PROJECT_ID;
|
||||
});
|
||||
|
||||
console.log("\n=== GSD_PROJECT_ID falls back to hash when unset ===");
|
||||
test('GSD_PROJECT_ID falls back to hash when unset', () => {
|
||||
const hashIdentity = repoIdentity(base);
|
||||
assertTrue(/^[0-9a-f]{12}$/.test(hashIdentity), "repoIdentity returns 12-char hex hash when GSD_PROJECT_ID is unset");
|
||||
assert.ok(/^[0-9a-f]{12}$/.test(hashIdentity), "repoIdentity returns 12-char hex hash when GSD_PROJECT_ID is unset");
|
||||
});
|
||||
|
||||
console.log("\n=== readRepoMeta returns null for malformed metadata ===");
|
||||
{
|
||||
test('readRepoMeta returns null for malformed metadata', () => {
|
||||
const malformedPath = join(stateDir, "projects", "malformed");
|
||||
mkdirSync(malformedPath, { recursive: true });
|
||||
writeFileSync(join(malformedPath, "repo-meta.json"), JSON.stringify({ version: 1 }) + "\n", "utf-8");
|
||||
assertEq(readRepoMeta(malformedPath), null, "malformed repo-meta.json is treated as unknown metadata");
|
||||
}
|
||||
assert.deepStrictEqual(readRepoMeta(malformedPath), null, "malformed repo-meta.json is treated as unknown metadata");
|
||||
});
|
||||
|
||||
console.log("\n=== ensureGsdSymlink refreshes repo-meta gitRoot after repo move with fixed project id ===");
|
||||
{
|
||||
test('ensureGsdSymlink refreshes repo-meta gitRoot after repo move with fixed project id', () => {
|
||||
const moveRepo = realpathSync(mkdtempSync(join(tmpdir(), "gsd-repo-identity-move-")));
|
||||
run("git init -b main", moveRepo);
|
||||
run('git config user.name "Pi Test"', moveRepo);
|
||||
|
|
@ -100,26 +114,25 @@ async function main(): Promise<void> {
|
|||
process.env.GSD_PROJECT_ID = "fixed-project";
|
||||
const fixedExternal = ensureGsdSymlink(moveRepo);
|
||||
const before = readRepoMeta(fixedExternal);
|
||||
assertTrue(before !== null, "repo metadata exists before repo move");
|
||||
assertEq(normalizePath(before!.gitRoot), normalizePath(moveRepo), "repo metadata tracks current git root before move");
|
||||
assert.ok(before !== null, "repo metadata exists before repo move");
|
||||
assert.deepStrictEqual(normalizePath(before!.gitRoot), normalizePath(moveRepo), "repo metadata tracks current git root before move");
|
||||
|
||||
const movedBaseRaw = join(tmpdir(), `gsd-repo-identity-moved-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
renameSync(moveRepo, movedBaseRaw);
|
||||
const movedBase = realpathSync(movedBaseRaw);
|
||||
const movedExternal = ensureGsdSymlink(movedBase);
|
||||
assertEq(realpathSync(movedExternal), realpathSync(fixedExternal), "fixed project id keeps the same external state dir");
|
||||
assert.deepStrictEqual(realpathSync(movedExternal), realpathSync(fixedExternal), "fixed project id keeps the same external state dir");
|
||||
|
||||
const after = readRepoMeta(movedExternal);
|
||||
assertTrue(after !== null, "repo metadata exists after repo move");
|
||||
assertEq(normalizePath(after!.gitRoot), normalizePath(movedBase), "repo metadata gitRoot is refreshed to moved repo path");
|
||||
assertEq(after!.createdAt, before!.createdAt, "repo metadata preserves createdAt on refresh");
|
||||
assert.ok(after !== null, "repo metadata exists after repo move");
|
||||
assert.deepStrictEqual(normalizePath(after!.gitRoot), normalizePath(movedBase), "repo metadata gitRoot is refreshed to moved repo path");
|
||||
assert.deepStrictEqual(after!.createdAt, before!.createdAt, "repo metadata preserves createdAt on refresh");
|
||||
|
||||
rmSync(movedBase, { recursive: true, force: true });
|
||||
delete process.env.GSD_PROJECT_ID;
|
||||
}
|
||||
});
|
||||
|
||||
console.log("\n=== isInheritedRepo detects subdirectory of parent repo without .gsd (#1639) ===");
|
||||
{
|
||||
test('isInheritedRepo detects subdirectory of parent repo without .gsd (#1639)', () => {
|
||||
const parentRepo = realpathSync(mkdtempSync(join(tmpdir(), "gsd-inherited-parent-")));
|
||||
run("git init -b main", parentRepo);
|
||||
run('git config user.name "Pi Test"', parentRepo);
|
||||
|
|
@ -128,31 +141,26 @@ async function main(): Promise<void> {
|
|||
run("git add README.md", parentRepo);
|
||||
run('git commit -m "init"', parentRepo);
|
||||
|
||||
// Create a subdirectory — no .gsd at parent
|
||||
const subdir = join(parentRepo, "newproject");
|
||||
mkdirSync(subdir, { recursive: true });
|
||||
assertTrue(isInheritedRepo(subdir), "subdirectory of parent repo without .gsd is inherited");
|
||||
assert.ok(isInheritedRepo(subdir), "subdirectory of parent repo without .gsd is inherited");
|
||||
|
||||
// After adding .gsd at parent, subdirectory is a legitimate child
|
||||
mkdirSync(join(parentRepo, ".gsd"), { recursive: true });
|
||||
assertTrue(!isInheritedRepo(subdir), "subdirectory of parent repo WITH .gsd is NOT inherited");
|
||||
assert.ok(!isInheritedRepo(subdir), "subdirectory of parent repo WITH .gsd is NOT inherited");
|
||||
|
||||
// The git root itself is never inherited
|
||||
assertTrue(!isInheritedRepo(parentRepo), "git root is not inherited");
|
||||
assert.ok(!isInheritedRepo(parentRepo), "git root is not inherited");
|
||||
|
||||
// A standalone repo (not a subdir) is not inherited
|
||||
const standaloneRepo = realpathSync(mkdtempSync(join(tmpdir(), "gsd-inherited-standalone-")));
|
||||
run("git init -b main", standaloneRepo);
|
||||
run('git config user.name "Pi Test"', standaloneRepo);
|
||||
run('git config user.email "pi@example.com"', standaloneRepo);
|
||||
assertTrue(!isInheritedRepo(standaloneRepo), "standalone repo is not inherited");
|
||||
assert.ok(!isInheritedRepo(standaloneRepo), "standalone repo is not inherited");
|
||||
|
||||
rmSync(parentRepo, { recursive: true, force: true });
|
||||
rmSync(standaloneRepo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
console.log("\n=== subdirectory of parent repo gets unique identity after git init (#1639) ===");
|
||||
{
|
||||
test('subdirectory of parent repo gets unique identity after git init (#1639)', () => {
|
||||
const parentRepo = realpathSync(mkdtempSync(join(tmpdir(), "gsd-identity-parent-")));
|
||||
run("git init -b main", parentRepo);
|
||||
run('git config user.name "Pi Test"', parentRepo);
|
||||
|
|
@ -165,38 +173,27 @@ async function main(): Promise<void> {
|
|||
const subdir = join(parentRepo, "childproject");
|
||||
mkdirSync(subdir, { recursive: true });
|
||||
|
||||
// Before git init, subdirectory shares parent's identity
|
||||
const parentIdentity = repoIdentity(parentRepo);
|
||||
const subdirIdentityBefore = repoIdentity(subdir);
|
||||
assertEq(subdirIdentityBefore, parentIdentity, "subdirectory shares parent identity before its own git init");
|
||||
assert.deepStrictEqual(subdirIdentityBefore, parentIdentity, "subdirectory shares parent identity before its own git init");
|
||||
|
||||
// After git init, subdirectory gets its own identity
|
||||
run("git init -b main", subdir);
|
||||
const subdirIdentityAfter = repoIdentity(subdir);
|
||||
assertTrue(subdirIdentityAfter !== parentIdentity, "subdirectory gets unique identity after git init");
|
||||
assert.ok(subdirIdentityAfter !== parentIdentity, "subdirectory gets unique identity after git init");
|
||||
|
||||
rmSync(parentRepo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log("\n=== validateProjectId rejects invalid values ===");
|
||||
for (const invalid of ["has spaces", "path/traversal", "dot..dot", "back\\slash"]) {
|
||||
assertTrue(!validateProjectId(invalid), `validateProjectId rejects invalid value: "${invalid}"`);
|
||||
}
|
||||
|
||||
console.log("\n=== validateProjectId accepts valid values ===");
|
||||
for (const valid of ["my-project", "foo_bar", "abc123", "A-Z_0-9"]) {
|
||||
assertTrue(validateProjectId(valid), `validateProjectId accepts valid value: "${valid}"`);
|
||||
}
|
||||
} finally {
|
||||
delete process.env.GSD_PROJECT_ID;
|
||||
delete process.env.GSD_STATE_DIR;
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
rmSync(stateDir, { recursive: true, force: true });
|
||||
report();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
test('validateProjectId rejects invalid values', () => {
|
||||
for (const invalid of ["has spaces", "path/traversal", "dot..dot", "back\\slash"]) {
|
||||
assert.ok(!validateProjectId(invalid), `validateProjectId rejects invalid value: "${invalid}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test('validateProjectId accepts valid values', () => {
|
||||
for (const valid of ["my-project", "foo_bar", "abc123", "A-Z_0-9"]) {
|
||||
assert.ok(validateProjectId(valid), `validateProjectId accepts valid value: "${valid}"`);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { describe, test, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseRequirementCounts } from "../files.ts";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { deriveState } from "../state.ts";
|
||||
import { runGSDDoctor } from "../doctor.ts";
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
console.log("\n=== requirement counts parser ===");
|
||||
{
|
||||
const counts = parseRequirementCounts(`# Requirements
|
||||
describe('requirements', () => {
|
||||
test('requirement counts parser', () => {
|
||||
const counts = parseRequirementCounts(`# Requirements
|
||||
|
||||
## Active
|
||||
|
||||
|
|
@ -34,73 +34,68 @@ console.log("\n=== requirement counts parser ===");
|
|||
### R030 — No
|
||||
- Status: out-of-scope
|
||||
`);
|
||||
assertEq(counts.active, 2, "counts active requirements by section");
|
||||
assertEq(counts.validated, 1, "counts validated requirements");
|
||||
assertEq(counts.deferred, 1, "counts deferred requirements");
|
||||
assertEq(counts.outOfScope, 1, "counts out of scope requirements");
|
||||
assertEq(counts.blocked, 1, "counts blocked statuses");
|
||||
}
|
||||
assert.deepStrictEqual(counts.active, 2, "counts active requirements by section");
|
||||
assert.deepStrictEqual(counts.validated, 1, "counts validated requirements");
|
||||
assert.deepStrictEqual(counts.deferred, 1, "counts deferred requirements");
|
||||
assert.deepStrictEqual(counts.outOfScope, 1, "counts out of scope requirements");
|
||||
assert.deepStrictEqual(counts.blocked, 1, "counts blocked statuses");
|
||||
});
|
||||
|
||||
const base = mkdtempSync(join(tmpdir(), "gsd-requirements-test-"));
|
||||
const gsd = join(base, ".gsd");
|
||||
const mDir = join(gsd, "milestones", "M001");
|
||||
const sDir = join(mDir, "slices", "S01");
|
||||
const tDir = join(sDir, "tasks");
|
||||
mkdirSync(tDir, { recursive: true });
|
||||
writeFileSync(join(gsd, "REQUIREMENTS.md"), `# Requirements
|
||||
const base = mkdtempSync(join(tmpdir(), "gsd-requirements-test-"));
|
||||
const gsd = join(base, ".gsd");
|
||||
const mDir = join(gsd, "milestones", "M001");
|
||||
const sDir = join(mDir, "slices", "S01");
|
||||
const tDir = join(sDir, "tasks");
|
||||
mkdirSync(tDir, { recursive: true });
|
||||
writeFileSync(join(gsd, "REQUIREMENTS.md"), [
|
||||
"# Requirements",
|
||||
"## Active",
|
||||
"### R001 — Missing owner",
|
||||
"- Class: core-capability",
|
||||
"- Status: active",
|
||||
"- Description: thing",
|
||||
"- Why it matters: thing",
|
||||
"- Source: user",
|
||||
"- Primary owning slice: none yet",
|
||||
"- Supporting slices: none",
|
||||
"- Validation: unmapped",
|
||||
"- Notes: none",
|
||||
"## Validated",
|
||||
"## Deferred",
|
||||
"## Out of Scope",
|
||||
"## Traceability",
|
||||
"",
|
||||
].join("\n"), "utf-8");
|
||||
writeFileSync(join(mDir, "M001-ROADMAP.md"), [
|
||||
"# M001: Demo",
|
||||
"## Slices",
|
||||
"- [ ] **S01: Demo Slice** `risk:low` `depends:[]`",
|
||||
" > After this: demo works",
|
||||
"",
|
||||
].join("\n"), "utf-8");
|
||||
writeFileSync(join(sDir, "S01-PLAN.md"), [
|
||||
"# S01: Demo Slice",
|
||||
"**Goal:** Demo",
|
||||
"**Demo:** Demo",
|
||||
"## Must-Haves",
|
||||
"- done",
|
||||
"## Tasks",
|
||||
"- [ ] **T01: Implement thing** `est:10m`",
|
||||
" Task is in progress.",
|
||||
"",
|
||||
].join("\n"), "utf-8");
|
||||
test('deriveState includes requirements counts', async () => {
|
||||
const state = await deriveState(base);
|
||||
assert.ok(state.requirements !== undefined, "state includes requirements summary");
|
||||
assert.deepStrictEqual(state.requirements?.active, 1, "state reports active requirement count");
|
||||
});
|
||||
|
||||
## Active
|
||||
test('doctor flags orphaned active requirement', async () => {
|
||||
const report = await runGSDDoctor(base);
|
||||
assert.ok(report.issues.some(issue => issue.code === "active_requirement_missing_owner"), "doctor flags missing owner");
|
||||
});
|
||||
|
||||
### R001 — Missing owner
|
||||
- Class: core-capability
|
||||
- Status: active
|
||||
- Description: thing
|
||||
- Why it matters: thing
|
||||
- Source: user
|
||||
- Primary owning slice: none yet
|
||||
- Supporting slices: none
|
||||
- Validation: unmapped
|
||||
- Notes: none
|
||||
|
||||
## Validated
|
||||
|
||||
## Deferred
|
||||
|
||||
## Out of Scope
|
||||
|
||||
## Traceability
|
||||
`, "utf-8");
|
||||
writeFileSync(join(mDir, "M001-ROADMAP.md"), `# M001: Demo
|
||||
|
||||
## Slices
|
||||
- [ ] **S01: Demo Slice** \`risk:low\` \`depends:[]\`
|
||||
> After this: demo works
|
||||
`, "utf-8");
|
||||
writeFileSync(join(sDir, "S01-PLAN.md"), `# S01: Demo Slice
|
||||
|
||||
**Goal:** Demo
|
||||
**Demo:** Demo
|
||||
|
||||
## Must-Haves
|
||||
- done
|
||||
|
||||
## Tasks
|
||||
- [ ] **T01: Implement thing** \`est:10m\`
|
||||
Task is in progress.
|
||||
`, "utf-8");
|
||||
|
||||
console.log("\n=== deriveState includes requirements counts ===");
|
||||
{
|
||||
const state = await deriveState(base);
|
||||
assertTrue(state.requirements !== undefined, "state includes requirements summary");
|
||||
assertEq(state.requirements?.active, 1, "state reports active requirement count");
|
||||
}
|
||||
|
||||
console.log("\n=== doctor flags orphaned active requirement ===");
|
||||
{
|
||||
const report = await runGSDDoctor(base);
|
||||
assertTrue(report.issues.some(issue => issue.code === "active_requirement_missing_owner"), "doctor flags missing owner");
|
||||
}
|
||||
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
report();
|
||||
after(() => {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@
|
|||
// consuming code properly resets all completion state so deriveState
|
||||
// re-derives the task on the next loop iteration.
|
||||
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readFileSync, unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
import {
|
||||
resetHookState,
|
||||
consumeRetryTrigger,
|
||||
|
|
@ -16,8 +17,6 @@ import {
|
|||
} from "../post-unit-hooks.ts";
|
||||
import { uncheckTaskInPlan } from "../undo.ts";
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
// ─── Fixture Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function createRetryFixture(): { base: string; cleanup: () => void } {
|
||||
|
|
@ -65,74 +64,65 @@ function createRetryFixture(): { base: string; cleanup: () => void } {
|
|||
// Test: consumeRetryTrigger returns retryArtifact field
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n=== consumeRetryTrigger: returns null when no retry pending ===");
|
||||
|
||||
{
|
||||
describe('retry-state-reset', () => {
|
||||
test('consumeRetryTrigger: returns null when no retry pending', () => {
|
||||
resetHookState();
|
||||
const trigger = consumeRetryTrigger();
|
||||
assertEq(trigger, null, "returns null when no retry pending");
|
||||
}
|
||||
assert.deepStrictEqual(trigger, null, "returns null when no retry pending");
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test: uncheckTaskInPlan reverses doctor's [x] mark
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n=== Retry reset step 1: uncheck [x] → [ ] in PLAN.md ===");
|
||||
|
||||
{
|
||||
test('Retry reset step 1: uncheck [x] → [ ] in PLAN.md', () => {
|
||||
const { base, cleanup } = createRetryFixture();
|
||||
try {
|
||||
const planFile = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md");
|
||||
|
||||
// Precondition: T01 is checked
|
||||
const before = readFileSync(planFile, "utf-8");
|
||||
assertTrue(before.includes("- [x] **T01:"), "precondition: T01 is checked [x]");
|
||||
assert.ok(before.includes("- [x] **T01:"), "precondition: T01 is checked [x]");
|
||||
|
||||
// Step 1: Uncheck T01
|
||||
const result = uncheckTaskInPlan(base, "M001", "S01", "T01");
|
||||
assertTrue(result, "uncheckTaskInPlan returns true");
|
||||
assert.ok(result, "uncheckTaskInPlan returns true");
|
||||
|
||||
// Verify T01 is now unchecked
|
||||
const after = readFileSync(planFile, "utf-8");
|
||||
assertTrue(after.includes("- [ ] **T01:"), "T01 is now unchecked [ ]");
|
||||
assertTrue(!after.includes("- [x] **T01:"), "T01 no longer has [x]");
|
||||
assert.ok(after.includes("- [ ] **T01:"), "T01 is now unchecked [ ]");
|
||||
assert.ok(!after.includes("- [x] **T01:"), "T01 no longer has [x]");
|
||||
|
||||
// T02 is unaffected
|
||||
assertTrue(after.includes("- [ ] **T02:"), "T02 remains unchanged");
|
||||
assert.ok(after.includes("- [ ] **T02:"), "T02 remains unchanged");
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test: Delete SUMMARY.md for the task
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n=== Retry reset step 2: delete SUMMARY.md ===");
|
||||
|
||||
{
|
||||
test('Retry reset step 2: delete SUMMARY.md', () => {
|
||||
const { base, cleanup } = createRetryFixture();
|
||||
try {
|
||||
const summaryFile = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks", "T01-SUMMARY.md");
|
||||
|
||||
// Precondition: SUMMARY exists
|
||||
assertTrue(existsSync(summaryFile), "precondition: SUMMARY.md exists");
|
||||
assert.ok(existsSync(summaryFile), "precondition: SUMMARY.md exists");
|
||||
|
||||
// Step 2: Delete SUMMARY.md
|
||||
unlinkSync(summaryFile);
|
||||
assertTrue(!existsSync(summaryFile), "SUMMARY.md deleted");
|
||||
assert.ok(!existsSync(summaryFile), "SUMMARY.md deleted");
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test: Remove from completedUnits array and flush
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n=== Retry reset step 3: remove from completedUnits ===");
|
||||
|
||||
{
|
||||
test('Retry reset step 3: remove from completedUnits', () => {
|
||||
const { base, cleanup } = createRetryFixture();
|
||||
try {
|
||||
// Simulate the completedUnits array (as AutoSession would have it)
|
||||
|
|
@ -146,8 +136,8 @@ console.log("\n=== Retry reset step 3: remove from completedUnits ===");
|
|||
u => !(u.type === "execute-task" && u.id === "M001/S01/T01"),
|
||||
);
|
||||
|
||||
assertEq(filtered.length, 1, "one unit removed from completedUnits");
|
||||
assertEq(filtered[0].id, "M001/S01/T02", "T02 still in completedUnits");
|
||||
assert.deepStrictEqual(filtered.length, 1, "one unit removed from completedUnits");
|
||||
assert.deepStrictEqual(filtered[0].id, "M001/S01/T02", "T02 still in completedUnits");
|
||||
|
||||
// Flush to completed-units.json
|
||||
const completedKeysPath = join(base, ".gsd", "completed-units.json");
|
||||
|
|
@ -155,42 +145,36 @@ console.log("\n=== Retry reset step 3: remove from completedUnits ===");
|
|||
writeFileSync(completedKeysPath, JSON.stringify(keys, null, 2), "utf-8");
|
||||
|
||||
const onDisk = JSON.parse(readFileSync(completedKeysPath, "utf-8"));
|
||||
assertEq(onDisk.length, 1, "completed-units.json has one entry");
|
||||
assertEq(onDisk[0], "execute-task/M001/S01/T02", "only T02 remains in completed-units.json");
|
||||
assert.deepStrictEqual(onDisk.length, 1, "completed-units.json has one entry");
|
||||
assert.deepStrictEqual(onDisk[0], "execute-task/M001/S01/T02", "only T02 remains in completed-units.json");
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test: Delete the retry_on artifact
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n=== Retry reset step 4: delete retry_on artifact ===");
|
||||
|
||||
{
|
||||
test('Retry reset step 4: delete retry_on artifact', () => {
|
||||
const { base, cleanup } = createRetryFixture();
|
||||
try {
|
||||
const retryArtifactPath = resolveHookArtifactPath(base, "M001/S01/T01", "NEEDS-REWORK.md");
|
||||
|
||||
// Precondition: artifact exists
|
||||
assertTrue(existsSync(retryArtifactPath), "precondition: retry artifact exists");
|
||||
assert.ok(existsSync(retryArtifactPath), "precondition: retry artifact exists");
|
||||
|
||||
// Step 4: Delete retry artifact
|
||||
unlinkSync(retryArtifactPath);
|
||||
assertTrue(!existsSync(retryArtifactPath), "retry artifact deleted");
|
||||
assert.ok(!existsSync(retryArtifactPath), "retry artifact deleted");
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test: Full retry reset sequence (all steps together)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n=== Full retry reset: all steps combined ===");
|
||||
|
||||
{
|
||||
test('Full retry reset: all steps combined', () => {
|
||||
const { base, cleanup } = createRetryFixture();
|
||||
try {
|
||||
const trigger = {
|
||||
|
|
@ -242,30 +226,27 @@ console.log("\n=== Full retry reset: all steps combined ===");
|
|||
// PLAN.md: T01 unchecked
|
||||
const planFile = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md");
|
||||
const planContent = readFileSync(planFile, "utf-8");
|
||||
assertTrue(planContent.includes("- [ ] **T01:"), "after reset: T01 unchecked in PLAN");
|
||||
assertTrue(!planContent.includes("- [x] **T01:"), "after reset: T01 not checked in PLAN");
|
||||
assert.ok(planContent.includes("- [ ] **T01:"), "after reset: T01 unchecked in PLAN");
|
||||
assert.ok(!planContent.includes("- [x] **T01:"), "after reset: T01 not checked in PLAN");
|
||||
|
||||
// SUMMARY.md: deleted
|
||||
assertTrue(!existsSync(summaryFile), "after reset: SUMMARY.md deleted");
|
||||
assert.ok(!existsSync(summaryFile), "after reset: SUMMARY.md deleted");
|
||||
|
||||
// completed-units.json: empty
|
||||
const onDisk = JSON.parse(readFileSync(completedKeysPath, "utf-8"));
|
||||
assertEq(onDisk.length, 0, "after reset: completed-units.json is empty");
|
||||
assert.deepStrictEqual(onDisk.length, 0, "after reset: completed-units.json is empty");
|
||||
|
||||
// Retry artifact: deleted
|
||||
assertTrue(!existsSync(retryArtifactPath), "after reset: retry artifact deleted");
|
||||
assert.ok(!existsSync(retryArtifactPath), "after reset: retry artifact deleted");
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test: Reset is idempotent — no crash when artifacts are already missing
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n=== Retry reset: idempotent when artifacts already missing ===");
|
||||
|
||||
{
|
||||
test('Retry reset: idempotent when artifacts already missing', () => {
|
||||
const base = mkdtempSync(join(tmpdir(), "gsd-retry-idempotent-"));
|
||||
try {
|
||||
// Create minimal structure — NO summary, NO retry artifact, NO plan
|
||||
|
|
@ -288,41 +269,38 @@ console.log("\n=== Retry reset: idempotent when artifacts already missing ===");
|
|||
|
||||
// Uncheck — returns false because no PLAN file
|
||||
const uncheckResult = uncheckTaskInPlan(base, mid, sid, tid);
|
||||
assertTrue(!uncheckResult, "uncheck returns false when no PLAN exists");
|
||||
assert.ok(!uncheckResult, "uncheck returns false when no PLAN exists");
|
||||
|
||||
// Summary does not exist — no crash
|
||||
const summaryFile = join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks", `${tid}-SUMMARY.md`);
|
||||
assertTrue(!existsSync(summaryFile), "no summary to delete — safe");
|
||||
assert.ok(!existsSync(summaryFile), "no summary to delete — safe");
|
||||
|
||||
// Retry artifact does not exist — no crash
|
||||
const retryPath = resolveHookArtifactPath(base, trigger.unitId, trigger.retryArtifact);
|
||||
assertTrue(!existsSync(retryPath), "no retry artifact to delete — safe");
|
||||
assert.ok(!existsSync(retryPath), "no retry artifact to delete — safe");
|
||||
|
||||
// completed-units.json filter on empty array — safe
|
||||
const completedUnits: Array<{ type: string; id: string }> = [];
|
||||
const filtered = completedUnits.filter(
|
||||
u => !(u.type === trigger.unitType && u.id === trigger.unitId),
|
||||
);
|
||||
assertEq(filtered.length, 0, "filter on empty array is safe");
|
||||
assert.deepStrictEqual(filtered.length, 0, "filter on empty array is safe");
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Test: resolveHookArtifactPath produces correct path for retry artifacts
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log("\n=== resolveHookArtifactPath: correct path for retry artifacts ===");
|
||||
|
||||
{
|
||||
test('resolveHookArtifactPath: correct path for retry artifacts', () => {
|
||||
const base = "/project";
|
||||
const path = resolveHookArtifactPath(base, "M001/S01/T01", "NEEDS-REWORK.md");
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
path,
|
||||
join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks", "T01-NEEDS-REWORK.md"),
|
||||
"retry artifact path resolves to task directory with task prefix",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
report();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,20 +12,16 @@
|
|||
* Also covers dependency expansion (range syntax) and edge cases.
|
||||
*/
|
||||
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseRoadmapSlices, expandDependencies } from '../roadmap-slices.ts';
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// A. Standard machine-readable format (should always work)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== A. Standard checkbox format ===');
|
||||
|
||||
{
|
||||
describe('roadmap-parse-regression', () => {
|
||||
test('A. Standard checkbox format', () => {
|
||||
const content = [
|
||||
'# M001: Test Project',
|
||||
'',
|
||||
|
|
@ -40,30 +36,27 @@ async function main(): Promise<void> {
|
|||
].join('\n');
|
||||
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 3, 'standard format: 3 slices');
|
||||
assertEq(slices[0].id, 'S01', 'S01 id');
|
||||
assertEq(slices[0].title, 'First Slice', 'S01 title');
|
||||
assertEq(slices[0].done, false, 'S01 not done');
|
||||
assertEq(slices[0].risk, 'low', 'S01 risk');
|
||||
assertEq(slices[0].depends.length, 0, 'S01 no deps');
|
||||
assert.deepStrictEqual(slices.length, 3, 'standard format: 3 slices');
|
||||
assert.deepStrictEqual(slices[0].id, 'S01', 'S01 id');
|
||||
assert.deepStrictEqual(slices[0].title, 'First Slice', 'S01 title');
|
||||
assert.deepStrictEqual(slices[0].done, false, 'S01 not done');
|
||||
assert.deepStrictEqual(slices[0].risk, 'low', 'S01 risk');
|
||||
assert.deepStrictEqual(slices[0].depends.length, 0, 'S01 no deps');
|
||||
|
||||
assertEq(slices[1].id, 'S02', 'S02 id');
|
||||
assertEq(slices[1].depends.length, 1, 'S02 has 1 dep');
|
||||
assertEq(slices[1].depends[0], 'S01', 'S02 depends on S01');
|
||||
assert.deepStrictEqual(slices[1].id, 'S02', 'S02 id');
|
||||
assert.deepStrictEqual(slices[1].depends.length, 1, 'S02 has 1 dep');
|
||||
assert.deepStrictEqual(slices[1].depends[0], 'S01', 'S02 depends on S01');
|
||||
|
||||
assertEq(slices[2].id, 'S03', 'S03 id');
|
||||
assertEq(slices[2].done, true, 'S03 is done');
|
||||
assertEq(slices[2].risk, 'high', 'S03 risk');
|
||||
assertEq(slices[2].depends.length, 2, 'S03 has 2 deps');
|
||||
}
|
||||
assert.deepStrictEqual(slices[2].id, 'S03', 'S03 id');
|
||||
assert.deepStrictEqual(slices[2].done, true, 'S03 is done');
|
||||
assert.deepStrictEqual(slices[2].risk, 'high', 'S03 risk');
|
||||
assert.deepStrictEqual(slices[2].depends.length, 2, 'S03 has 2 deps');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// B. Prose fallback: H2 with colon (the only format the old regex matched)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== B. Prose fallback: H2 with colon ===');
|
||||
|
||||
{
|
||||
test('B. Prose fallback: H2 with colon', () => {
|
||||
const content = [
|
||||
'# M001: Test',
|
||||
'',
|
||||
|
|
@ -78,20 +71,17 @@ async function main(): Promise<void> {
|
|||
].join('\n');
|
||||
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, 'prose H2 colon: 2 slices');
|
||||
assertEq(slices[0].id, 'S01', 'S01 id');
|
||||
assertEq(slices[0].title, 'Setup Foundation', 'S01 title');
|
||||
assertEq(slices[1].id, 'S02', 'S02 id');
|
||||
assertEq(slices[1].title, 'Core Features', 'S02 title');
|
||||
}
|
||||
assert.deepStrictEqual(slices.length, 2, 'prose H2 colon: 2 slices');
|
||||
assert.deepStrictEqual(slices[0].id, 'S01', 'S01 id');
|
||||
assert.deepStrictEqual(slices[0].title, 'Setup Foundation', 'S01 title');
|
||||
assert.deepStrictEqual(slices[1].id, 'S02', 'S02 id');
|
||||
assert.deepStrictEqual(slices[1].title, 'Core Features', 'S02 title');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// C. Regression #1248: H3 headers (the old regex only matched ##)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== C. #1248: H3 headers ===');
|
||||
|
||||
{
|
||||
test('C. #1248: H3 headers', () => {
|
||||
const content = [
|
||||
'# M001: Test',
|
||||
'',
|
||||
|
|
@ -106,18 +96,15 @@ async function main(): Promise<void> {
|
|||
].join('\n');
|
||||
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, '#1248 H3: 2 slices parsed');
|
||||
assertEq(slices[0].id, 'S01', 'S01 from H3');
|
||||
assertEq(slices[1].id, 'S02', 'S02 from H3');
|
||||
}
|
||||
assert.deepStrictEqual(slices.length, 2, '#1248 H3: 2 slices parsed');
|
||||
assert.deepStrictEqual(slices[0].id, 'S01', 'S01 from H3');
|
||||
assert.deepStrictEqual(slices[1].id, 'S02', 'S02 from H3');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// D. Regression #1248: H4 headers
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== D. #1248: H4 headers ===');
|
||||
|
||||
{
|
||||
test('D. #1248: H4 headers', () => {
|
||||
const content = [
|
||||
'# M001: Test',
|
||||
'',
|
||||
|
|
@ -128,16 +115,13 @@ async function main(): Promise<void> {
|
|||
].join('\n');
|
||||
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, '#1248 H4: 2 slices parsed');
|
||||
}
|
||||
assert.deepStrictEqual(slices.length, 2, '#1248 H4: 2 slices parsed');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// E. Regression #1248: H1 header (unusual but LLMs produce it)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== E. #1248: H1 headers ===');
|
||||
|
||||
{
|
||||
test('E. #1248: H1 headers', () => {
|
||||
const content = [
|
||||
'# S01: Setup Foundation',
|
||||
'',
|
||||
|
|
@ -150,97 +134,76 @@ async function main(): Promise<void> {
|
|||
].join('\n');
|
||||
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, '#1248 H1: 2 slices parsed');
|
||||
}
|
||||
assert.deepStrictEqual(slices.length, 2, '#1248 H1: 2 slices parsed');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// F. Regression #1248: Bold-wrapped IDs
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== F. #1248: Bold-wrapped ===');
|
||||
|
||||
{
|
||||
test('F. #1248: Bold-wrapped', () => {
|
||||
const content1 = '## **S01: Setup Foundation**\n\nDo stuff.\n\n## **S02: Features**\n\nMore stuff.\n';
|
||||
const slices1 = parseRoadmapSlices(content1);
|
||||
assertEq(slices1.length, 2, 'bold-wrapped: 2 slices');
|
||||
assertEq(slices1[0].title, 'Setup Foundation', 'bold-wrapped: title extracted without bold');
|
||||
assert.deepStrictEqual(slices1.length, 2, 'bold-wrapped: 2 slices');
|
||||
assert.deepStrictEqual(slices1[0].title, 'Setup Foundation', 'bold-wrapped: title extracted without bold');
|
||||
|
||||
const content2 = '## **S01**: Setup Foundation\n\n## **S02**: Features\n';
|
||||
const slices2 = parseRoadmapSlices(content2);
|
||||
assertEq(slices2.length, 2, 'bold ID only: 2 slices');
|
||||
}
|
||||
assert.deepStrictEqual(slices2.length, 2, 'bold ID only: 2 slices');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// G. Regression #1248: Dot separator
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== G. #1248: Dot separator ===');
|
||||
|
||||
{
|
||||
test('G. #1248: Dot separator', () => {
|
||||
const content = '## S01. Setup Foundation\n\n## S02. Core Features\n';
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, 'dot separator: 2 slices');
|
||||
assertEq(slices[0].title, 'Setup Foundation', 'dot separator: title');
|
||||
}
|
||||
assert.deepStrictEqual(slices.length, 2, 'dot separator: 2 slices');
|
||||
assert.deepStrictEqual(slices[0].title, 'Setup Foundation', 'dot separator: title');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// H. Regression #1248: Em dash separator
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== H. #1248: Em/en dash separators ===');
|
||||
|
||||
{
|
||||
test('H. #1248: Em/en dash separators', () => {
|
||||
const content = '## S01 — Setup Foundation\n\n## S02 – Core Features\n';
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, 'em/en dash: 2 slices');
|
||||
}
|
||||
assert.deepStrictEqual(slices.length, 2, 'em/en dash: 2 slices');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// I. Regression #1248: Space-only separator (no punctuation)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== I. #1248: Space-only separator ===');
|
||||
|
||||
{
|
||||
test('I. #1248: Space-only separator', () => {
|
||||
const content = '## S01 Setup Foundation\n\n## S02 Core Features\n';
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, 'space-only: 2 slices');
|
||||
assertEq(slices[0].title, 'Setup Foundation', 'space-only: title');
|
||||
}
|
||||
assert.deepStrictEqual(slices.length, 2, 'space-only: 2 slices');
|
||||
assert.deepStrictEqual(slices[0].title, 'Setup Foundation', 'space-only: title');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// J. Regression #1248: Non-zero-padded IDs
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== J. #1248: Non-zero-padded IDs ===');
|
||||
|
||||
{
|
||||
test('J. #1248: Non-zero-padded IDs', () => {
|
||||
const content = '## S1: Setup\n\n## S2: Features\n';
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, 'non-padded: 2 slices');
|
||||
assertEq(slices[0].id, 'S1', 'non-padded: S1');
|
||||
}
|
||||
assert.deepStrictEqual(slices.length, 2, 'non-padded: 2 slices');
|
||||
assert.deepStrictEqual(slices[0].id, 'S1', 'non-padded: S1');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// K. Regression #1248: "Slice" prefix
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== K. #1248: "Slice" prefix ===');
|
||||
|
||||
{
|
||||
test('K. #1248: "Slice" prefix', () => {
|
||||
const content = '## Slice S01: Setup Foundation\n\n## Slice S02: Core Features\n';
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, 'Slice prefix: 2 slices');
|
||||
assertEq(slices[0].id, 'S01', 'Slice prefix: S01');
|
||||
}
|
||||
assert.deepStrictEqual(slices.length, 2, 'Slice prefix: 2 slices');
|
||||
assert.deepStrictEqual(slices[0].id, 'S01', 'Slice prefix: S01');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// L. Prose with "Depends on:" line
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== L. Prose with Depends on: ===');
|
||||
|
||||
{
|
||||
test('L. Prose with Depends on:', () => {
|
||||
const content = [
|
||||
'## S01: Foundation',
|
||||
'',
|
||||
|
|
@ -254,20 +217,17 @@ async function main(): Promise<void> {
|
|||
].join('\n');
|
||||
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, 'prose deps: 2 slices');
|
||||
assertEq(slices[1].depends.length, 1, 'S02 has 1 dep');
|
||||
assertEq(slices[1].depends[0], 'S01', 'S02 depends on S01');
|
||||
}
|
||||
assert.deepStrictEqual(slices.length, 2, 'prose deps: 2 slices');
|
||||
assert.deepStrictEqual(slices[1].depends.length, 1, 'S02 has 1 dep');
|
||||
assert.deepStrictEqual(slices[1].depends[0], 'S01', 'S02 depends on S01');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// M. Empty / edge cases
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== M. Edge cases ===');
|
||||
|
||||
{
|
||||
assertEq(parseRoadmapSlices('').length, 0, 'empty content → 0 slices');
|
||||
assertEq(parseRoadmapSlices('# Just a title\n\nSome text.').length, 0, 'no slices at all → 0');
|
||||
test('M. Edge cases', () => {
|
||||
assert.deepStrictEqual(parseRoadmapSlices('').length, 0, 'empty content → 0 slices');
|
||||
assert.deepStrictEqual(parseRoadmapSlices('# Just a title\n\nSome text.').length, 0, 'no slices at all → 0');
|
||||
|
||||
// Mixed format: ## Slices section with one checkbox + prose below
|
||||
const mixed = [
|
||||
|
|
@ -281,81 +241,69 @@ async function main(): Promise<void> {
|
|||
].join('\n');
|
||||
const mixedSlices = parseRoadmapSlices(mixed);
|
||||
// The ## Slices section takes priority — prose headers outside it aren't picked up
|
||||
assertEq(mixedSlices.length, 1, 'mixed: only 1 slice from ## Slices section');
|
||||
assertEq(mixedSlices[0].id, 'S01', 'mixed: S01 from checkbox');
|
||||
}
|
||||
assert.deepStrictEqual(mixedSlices.length, 1, 'mixed: only 1 slice from ## Slices section');
|
||||
assert.deepStrictEqual(mixedSlices[0].id, 'S01', 'mixed: S01 from checkbox');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// N. Dependency range expansion
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== N. Dependency range expansion ===');
|
||||
|
||||
{
|
||||
assertEq(
|
||||
test('N. Dependency range expansion', () => {
|
||||
assert.deepStrictEqual(
|
||||
expandDependencies(['S01-S04']),
|
||||
['S01', 'S02', 'S03', 'S04'],
|
||||
'S01-S04 → 4 individual deps',
|
||||
);
|
||||
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
expandDependencies(['S01..S03']),
|
||||
['S01', 'S02', 'S03'],
|
||||
'S01..S03 → 3 individual deps',
|
||||
);
|
||||
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
expandDependencies(['S01']),
|
||||
['S01'],
|
||||
'single dep passes through',
|
||||
);
|
||||
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
expandDependencies(['S01', 'S03-S05']),
|
||||
['S01', 'S03', 'S04', 'S05'],
|
||||
'mixed single + range',
|
||||
);
|
||||
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
expandDependencies(['']),
|
||||
[],
|
||||
'empty string filtered out',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// O. No-separator colon-less: "S01:Title" (no space after colon)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== O. No space after colon ===');
|
||||
|
||||
{
|
||||
test('O. No space after colon', () => {
|
||||
const content = '## S01:Foundation\n\n## S02:Features\n';
|
||||
const slices = parseRoadmapSlices(content);
|
||||
// The regex uses [:\s.—–-]* which allows colon with no space
|
||||
assertEq(slices.length, 2, 'no-space-colon: 2 slices');
|
||||
}
|
||||
assert.deepStrictEqual(slices.length, 2, 'no-space-colon: 2 slices');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// P. Three-digit padded IDs
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== P. Three-digit padded IDs ===');
|
||||
|
||||
{
|
||||
test('P. Three-digit padded IDs', () => {
|
||||
const content = '## S001: Foundation\n\n## S002: Features\n';
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, 'three-digit: 2 slices');
|
||||
assertEq(slices[0].id, 'S001', 'three-digit: S001');
|
||||
}
|
||||
assert.deepStrictEqual(slices.length, 2, 'three-digit: 2 slices');
|
||||
assert.deepStrictEqual(slices[0].id, 'S001', 'three-digit: S001');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Q. Regression #1736: Table format under ## Slices
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== Q. #1736: Table format under ## Slices ===');
|
||||
|
||||
{
|
||||
test('Q. #1736: Table format under ## Slices', () => {
|
||||
const content = [
|
||||
'# M001: Test',
|
||||
'',
|
||||
|
|
@ -371,22 +319,19 @@ async function main(): Promise<void> {
|
|||
].join('\n');
|
||||
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 3, '#1736 table: 3 slices');
|
||||
assertEq(slices[0].id, 'S01', '#1736 table: S01 id');
|
||||
assertEq(slices[0].title, 'Setup Foundation', '#1736 table: S01 title');
|
||||
assertEq(slices[0].done, true, '#1736 table: S01 done');
|
||||
assertEq(slices[0].risk, 'low', '#1736 table: S01 risk');
|
||||
assertEq(slices[1].done, false, '#1736 table: S02 not done');
|
||||
assertEq(slices[2].done, true, '#1736 table: S03 done');
|
||||
}
|
||||
assert.deepStrictEqual(slices.length, 3, '#1736 table: 3 slices');
|
||||
assert.deepStrictEqual(slices[0].id, 'S01', '#1736 table: S01 id');
|
||||
assert.deepStrictEqual(slices[0].title, 'Setup Foundation', '#1736 table: S01 title');
|
||||
assert.deepStrictEqual(slices[0].done, true, '#1736 table: S01 done');
|
||||
assert.deepStrictEqual(slices[0].risk, 'low', '#1736 table: S01 risk');
|
||||
assert.deepStrictEqual(slices[1].done, false, '#1736 table: S02 not done');
|
||||
assert.deepStrictEqual(slices[2].done, true, '#1736 table: S03 done');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// R. Regression #1736: Table format under ## Slice Overview
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== R. #1736: Table format under ## Slice Overview ===');
|
||||
|
||||
{
|
||||
test('R. #1736: Table format under ## Slice Overview', () => {
|
||||
const content = [
|
||||
'# M002: Overview Heading',
|
||||
'',
|
||||
|
|
@ -400,18 +345,15 @@ async function main(): Promise<void> {
|
|||
].join('\n');
|
||||
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, '#1736 overview: 2 slices');
|
||||
assertEq(slices[0].done, true, '#1736 overview: S01 done');
|
||||
assertEq(slices[1].done, false, '#1736 overview: S02 not done');
|
||||
}
|
||||
assert.deepStrictEqual(slices.length, 2, '#1736 overview: 2 slices');
|
||||
assert.deepStrictEqual(slices[0].done, true, '#1736 overview: S01 done');
|
||||
assert.deepStrictEqual(slices[1].done, false, '#1736 overview: S02 not done');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// S. Regression #1736: Table with Done/Complete text status
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== S. #1736: Table with text status ===');
|
||||
|
||||
{
|
||||
test('S. #1736: Table with text status', () => {
|
||||
const content = [
|
||||
'# M003: Status Text',
|
||||
'',
|
||||
|
|
@ -426,19 +368,16 @@ async function main(): Promise<void> {
|
|||
].join('\n');
|
||||
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 3, '#1736 text status: 3 slices');
|
||||
assertTrue(slices[0].done, '#1736 text status: Done = true');
|
||||
assertTrue(!slices[1].done, '#1736 text status: Pending = false');
|
||||
assertTrue(slices[2].done, '#1736 text status: Completed = true');
|
||||
}
|
||||
assert.deepStrictEqual(slices.length, 3, '#1736 text status: 3 slices');
|
||||
assert.ok(slices[0].done, '#1736 text status: Done = true');
|
||||
assert.ok(!slices[1].done, '#1736 text status: Pending = false');
|
||||
assert.ok(slices[2].done, '#1736 text status: Completed = true');
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// T. Regression #1736: Checkbox format still works after table support
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
console.log('\n=== T. #1736: Checkbox format unchanged ===');
|
||||
|
||||
{
|
||||
test('T. #1736: Checkbox format unchanged', () => {
|
||||
const content = [
|
||||
'# M005: Unchanged',
|
||||
'',
|
||||
|
|
@ -451,16 +390,10 @@ async function main(): Promise<void> {
|
|||
].join('\n');
|
||||
|
||||
const slices = parseRoadmapSlices(content);
|
||||
assertEq(slices.length, 2, '#1736 checkbox compat: 2 slices');
|
||||
assertEq(slices[0].done, true, '#1736 checkbox compat: S01 done');
|
||||
assertEq(slices[0].demo, 'demo works.', '#1736 checkbox compat: demo');
|
||||
assertEq(slices[1].done, false, '#1736 checkbox compat: S02 not done');
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
assert.deepStrictEqual(slices.length, 2, '#1736 checkbox compat: 2 slices');
|
||||
assert.deepStrictEqual(slices[0].done, true, '#1736 checkbox compat: S01 done');
|
||||
assert.deepStrictEqual(slices[0].demo, 'demo works.', '#1736 checkbox compat: demo');
|
||||
assert.deepStrictEqual(slices[1].done, false, '#1736 checkbox compat: S02 not done');
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
// Tests the RuleRegistry class, UnifiedRule types, singleton accessors,
|
||||
// and evaluation methods using mock rules.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { test, describe, beforeEach } from "node:test";
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
import {
|
||||
RuleRegistry,
|
||||
getRegistry,
|
||||
|
|
@ -64,9 +64,7 @@ function makeContext(phase: string): DispatchContext {
|
|||
// ─── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("RuleRegistry", () => {
|
||||
const { assertEq, assertTrue } = createTestContext();
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(() => {
|
||||
resetRegistry();
|
||||
});
|
||||
|
||||
|
|
@ -81,10 +79,10 @@ describe("RuleRegistry", () => {
|
|||
|
||||
// At minimum, dispatch rules are returned (hook rules depend on prefs)
|
||||
const dispatchRules = listed.filter(r => r.when === "dispatch");
|
||||
assertEq(dispatchRules.length, 3, "listRules returns 3 dispatch rules");
|
||||
assertEq(dispatchRules[0].name, "rule-a", "first rule name is rule-a");
|
||||
assertEq(dispatchRules[1].name, "rule-b", "second rule name is rule-b");
|
||||
assertEq(dispatchRules[2].name, "rule-c", "third rule name is rule-c");
|
||||
assert.deepStrictEqual(dispatchRules.length, 3, "listRules returns 3 dispatch rules");
|
||||
assert.deepStrictEqual(dispatchRules[0].name, "rule-a", "first rule name is rule-a");
|
||||
assert.deepStrictEqual(dispatchRules[1].name, "rule-b", "second rule name is rule-b");
|
||||
assert.deepStrictEqual(dispatchRules[2].name, "rule-c", "third rule name is rule-c");
|
||||
});
|
||||
|
||||
test("listRules returns correct fields on each rule", () => {
|
||||
|
|
@ -95,12 +93,12 @@ describe("RuleRegistry", () => {
|
|||
const listed = registry.listRules();
|
||||
const rule = listed.find(r => r.name === "check-fields")!;
|
||||
|
||||
assertTrue(rule !== undefined, "rule found by name");
|
||||
assertEq(rule.when, "dispatch", "when field is dispatch");
|
||||
assertEq(rule.evaluation, "first-match", "evaluation is first-match");
|
||||
assertTrue(typeof rule.where === "function", "where is a function");
|
||||
assertTrue(typeof rule.then === "function", "then is a function");
|
||||
assertEq(rule.description, "Mock rule for planning", "description is set");
|
||||
assert.ok(rule !== undefined, "rule found by name");
|
||||
assert.deepStrictEqual(rule.when, "dispatch", "when field is dispatch");
|
||||
assert.deepStrictEqual(rule.evaluation, "first-match", "evaluation is first-match");
|
||||
assert.ok(typeof rule.where === "function", "where is a function");
|
||||
assert.ok(typeof rule.then === "function", "then is a function");
|
||||
assert.deepStrictEqual(rule.description, "Mock rule for planning", "description is set");
|
||||
});
|
||||
|
||||
test("evaluateDispatch returns first matching rule", async () => {
|
||||
|
|
@ -113,10 +111,10 @@ describe("RuleRegistry", () => {
|
|||
const ctx = makeContext("executing");
|
||||
const result = await registry.evaluateDispatch(ctx);
|
||||
|
||||
assertEq(result.action, "dispatch", "result is a dispatch action");
|
||||
assert.deepStrictEqual(result.action, "dispatch", "result is a dispatch action");
|
||||
if (result.action === "dispatch") {
|
||||
assertEq(result.unitType, "test-executing", "matched the executing rule");
|
||||
assertEq(result.prompt, "Prompt for executing", "prompt from matched rule");
|
||||
assert.deepStrictEqual(result.unitType, "test-executing", "matched the executing rule");
|
||||
assert.deepStrictEqual(result.prompt, "Prompt for executing", "prompt from matched rule");
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -128,9 +126,9 @@ describe("RuleRegistry", () => {
|
|||
const ctx = makeContext("blocked");
|
||||
const result = await registry.evaluateDispatch(ctx);
|
||||
|
||||
assertEq(result.action, "stop", "result is a stop action");
|
||||
assert.deepStrictEqual(result.action, "stop", "result is a stop action");
|
||||
if (result.action === "stop") {
|
||||
assertTrue(result.reason.includes("blocked"), "stop reason mentions phase");
|
||||
assert.ok(result.reason.includes("blocked"), "stop reason mentions phase");
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -159,9 +157,9 @@ describe("RuleRegistry", () => {
|
|||
const ctx = makeContext("planning");
|
||||
const result = await registry.evaluateDispatch(ctx);
|
||||
|
||||
assertEq(result.action, "dispatch", "async dispatch resolved");
|
||||
assert.deepStrictEqual(result.action, "dispatch", "async dispatch resolved");
|
||||
if (result.action === "dispatch") {
|
||||
assertEq(result.unitType, "async-test", "async rule matched");
|
||||
assert.deepStrictEqual(result.unitType, "async-test", "async rule matched");
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -188,11 +186,11 @@ describe("RuleRegistry", () => {
|
|||
// Reset
|
||||
registry.resetState();
|
||||
|
||||
assertEq(registry.getActiveHook(), null, "activeHook cleared");
|
||||
assertEq(registry.hookQueue.length, 0, "hookQueue cleared");
|
||||
assertEq(registry.cycleCounts.size, 0, "cycleCounts cleared");
|
||||
assertEq(registry.isRetryPending(), false, "retryPending cleared");
|
||||
assertEq(registry.consumeRetryTrigger(), null, "retryTrigger cleared");
|
||||
assert.deepStrictEqual(registry.getActiveHook(), null, "activeHook cleared");
|
||||
assert.deepStrictEqual(registry.hookQueue.length, 0, "hookQueue cleared");
|
||||
assert.deepStrictEqual(registry.cycleCounts.size, 0, "cycleCounts cleared");
|
||||
assert.deepStrictEqual(registry.isRetryPending(), false, "retryPending cleared");
|
||||
assert.deepStrictEqual(registry.consumeRetryTrigger(), null, "retryTrigger cleared");
|
||||
});
|
||||
|
||||
test("singleton getRegistry throws when not initialized", () => {
|
||||
|
|
@ -201,9 +199,9 @@ describe("RuleRegistry", () => {
|
|||
getRegistry();
|
||||
} catch (e: any) {
|
||||
threw = true;
|
||||
assertTrue(e.message.includes("not initialized"), "error mentions not initialized");
|
||||
assert.ok(e.message.includes("not initialized"), "error mentions not initialized");
|
||||
}
|
||||
assertTrue(threw, "getRegistry threw");
|
||||
assert.ok(threw, "getRegistry threw");
|
||||
});
|
||||
|
||||
test("setRegistry / getRegistry round-trips", () => {
|
||||
|
|
@ -211,20 +209,20 @@ describe("RuleRegistry", () => {
|
|||
setRegistry(registry);
|
||||
|
||||
const retrieved = getRegistry();
|
||||
assertEq(retrieved, registry, "getRegistry returns the same instance");
|
||||
assert.deepStrictEqual(retrieved, registry, "getRegistry returns the same instance");
|
||||
|
||||
const listed = retrieved.listRules().filter(r => r.when === "dispatch");
|
||||
assertEq(listed.length, 1, "singleton has 1 dispatch rule");
|
||||
assertEq(listed[0].name, "singleton-test", "rule name matches");
|
||||
assert.deepStrictEqual(listed.length, 1, "singleton has 1 dispatch rule");
|
||||
assert.deepStrictEqual(listed[0].name, "singleton-test", "rule name matches");
|
||||
});
|
||||
|
||||
test("initRegistry creates and sets singleton", () => {
|
||||
const rules = [mockDispatchRule("init-test", "executing")];
|
||||
const registry = initRegistry(rules);
|
||||
|
||||
assertEq(getRegistry(), registry, "initRegistry sets the singleton");
|
||||
assert.deepStrictEqual(getRegistry(), registry, "initRegistry sets the singleton");
|
||||
const listed = getRegistry().listRules().filter(r => r.when === "dispatch");
|
||||
assertEq(listed.length, 1, "singleton has the rule");
|
||||
assert.deepStrictEqual(listed.length, 1, "singleton has the rule");
|
||||
});
|
||||
|
||||
test("evaluateDispatch respects rule order (first match wins)", async () => {
|
||||
|
|
@ -258,9 +256,9 @@ describe("RuleRegistry", () => {
|
|||
const ctx = makeContext("planning");
|
||||
const result = await registry.evaluateDispatch(ctx);
|
||||
|
||||
assertEq(result.action, "dispatch", "dispatch action returned");
|
||||
assert.deepStrictEqual(result.action, "dispatch", "dispatch action returned");
|
||||
if (result.action === "dispatch") {
|
||||
assertEq(result.unitType, "first-wins", "first rule won over second");
|
||||
assert.deepStrictEqual(result.unitType, "first-wins", "first rule won over second");
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -268,18 +266,18 @@ describe("RuleRegistry", () => {
|
|||
|
||||
test("convertDispatchRules produces correct count of UnifiedRule objects", () => {
|
||||
const converted = convertDispatchRules(DISPATCH_RULES);
|
||||
assertEq(converted.length, DISPATCH_RULES.length, `convertDispatchRules produces ${DISPATCH_RULES.length} rules`);
|
||||
assert.deepStrictEqual(converted.length, DISPATCH_RULES.length, `convertDispatchRules produces ${DISPATCH_RULES.length} rules`);
|
||||
});
|
||||
|
||||
test("each converted rule has correct when, evaluation, and original name", () => {
|
||||
const converted = convertDispatchRules(DISPATCH_RULES);
|
||||
for (let i = 0; i < converted.length; i++) {
|
||||
const rule = converted[i];
|
||||
assertEq(rule.when, "dispatch", `rule ${i} has when:"dispatch"`);
|
||||
assertEq(rule.evaluation, "first-match", `rule ${i} has evaluation:"first-match"`);
|
||||
assertEq(rule.name, DISPATCH_RULES[i].name, `rule ${i} preserves name "${DISPATCH_RULES[i].name}"`);
|
||||
assertTrue(typeof rule.where === "function", `rule ${i} has a where function`);
|
||||
assertTrue(typeof rule.then === "function", `rule ${i} has a then function`);
|
||||
assert.deepStrictEqual(rule.when, "dispatch", `rule ${i} has when:"dispatch"`);
|
||||
assert.deepStrictEqual(rule.evaluation, "first-match", `rule ${i} has evaluation:"first-match"`);
|
||||
assert.deepStrictEqual(rule.name, DISPATCH_RULES[i].name, `rule ${i} preserves name "${DISPATCH_RULES[i].name}"`);
|
||||
assert.ok(typeof rule.where === "function", `rule ${i} has a where function`);
|
||||
assert.ok(typeof rule.then === "function", `rule ${i} has a then function`);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -287,7 +285,7 @@ describe("RuleRegistry", () => {
|
|||
const converted = convertDispatchRules(DISPATCH_RULES);
|
||||
const registry = new RuleRegistry(converted);
|
||||
const listed = registry.listRules().filter(r => r.when === "dispatch");
|
||||
assertEq(listed.length, DISPATCH_RULES.length, `listRules returns ${DISPATCH_RULES.length} dispatch rules`);
|
||||
assert.deepStrictEqual(listed.length, DISPATCH_RULES.length, `listRules returns ${DISPATCH_RULES.length} dispatch rules`);
|
||||
});
|
||||
|
||||
test("rule names from listRules match getDispatchRuleNames in exact order", () => {
|
||||
|
|
@ -298,9 +296,9 @@ describe("RuleRegistry", () => {
|
|||
.map(r => r.name);
|
||||
const originalNames = getDispatchRuleNames();
|
||||
|
||||
assertEq(listedNames.length, originalNames.length, "same number of names");
|
||||
assert.deepStrictEqual(listedNames.length, originalNames.length, "same number of names");
|
||||
for (let i = 0; i < originalNames.length; i++) {
|
||||
assertEq(listedNames[i], originalNames[i], `name at index ${i} matches: "${originalNames[i]}"`);
|
||||
assert.deepStrictEqual(listedNames[i], originalNames[i], `name at index ${i} matches: "${originalNames[i]}"`);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -309,18 +307,18 @@ describe("RuleRegistry", () => {
|
|||
test("getOrCreateRegistry lazily creates a registry with empty dispatch rules", () => {
|
||||
// After resetRegistry(), getRegistry() would throw. getOrCreateRegistry() should not.
|
||||
const registry = getOrCreateRegistry();
|
||||
assertTrue(registry instanceof RuleRegistry, "returns a RuleRegistry instance");
|
||||
assert.ok(registry instanceof RuleRegistry, "returns a RuleRegistry instance");
|
||||
const dispatchRules = registry.listRules().filter(r => r.when === "dispatch");
|
||||
assertEq(dispatchRules.length, 0, "lazily-created registry has 0 dispatch rules");
|
||||
assert.deepStrictEqual(dispatchRules.length, 0, "lazily-created registry has 0 dispatch rules");
|
||||
});
|
||||
|
||||
test("getOrCreateRegistry returns existing registry when initialized", () => {
|
||||
const rules = [mockDispatchRule("explicit-init", "planning")];
|
||||
const explicit = initRegistry(rules);
|
||||
const lazy = getOrCreateRegistry();
|
||||
assertEq(lazy, explicit, "getOrCreateRegistry returns the same singleton as initRegistry");
|
||||
assert.deepStrictEqual(lazy, explicit, "getOrCreateRegistry returns the same singleton as initRegistry");
|
||||
const dispatchRules = lazy.listRules().filter(r => r.when === "dispatch");
|
||||
assertEq(dispatchRules.length, 1, "singleton has the explicitly initialized dispatch rule");
|
||||
assert.deepStrictEqual(dispatchRules.length, 1, "singleton has the explicitly initialized dispatch rule");
|
||||
});
|
||||
|
||||
// ── Hook-derived rules in listRules ────────────────────────────────
|
||||
|
|
@ -333,9 +331,9 @@ describe("RuleRegistry", () => {
|
|||
const preDispatchRules = allRules.filter(r => r.when === "pre-dispatch");
|
||||
|
||||
// No preferences file = no hooks
|
||||
assertEq(postUnitRules.length, 0, "no post-unit rules when no hooks configured");
|
||||
assertEq(preDispatchRules.length, 0, "no pre-dispatch rules when no hooks configured");
|
||||
assertEq(allRules.length, DISPATCH_RULES.length, "total rules equals dispatch rules only");
|
||||
assert.deepStrictEqual(postUnitRules.length, 0, "no post-unit rules when no hooks configured");
|
||||
assert.deepStrictEqual(preDispatchRules.length, 0, "no pre-dispatch rules when no hooks configured");
|
||||
assert.deepStrictEqual(allRules.length, DISPATCH_RULES.length, "total rules equals dispatch rules only");
|
||||
});
|
||||
|
||||
test("listRules dispatch rules appear first, hooks after", () => {
|
||||
|
|
@ -345,8 +343,8 @@ describe("RuleRegistry", () => {
|
|||
|
||||
// Verify dispatch rules come first (indices 0..N-1)
|
||||
for (let i = 0; i < converted.length; i++) {
|
||||
assertEq(allRules[i].when, "dispatch", `rule at index ${i} is a dispatch rule`);
|
||||
assertEq(allRules[i].name, converted[i].name, `dispatch rule at index ${i} has correct name`);
|
||||
assert.deepStrictEqual(allRules[i].when, "dispatch", `rule at index ${i} is a dispatch rule`);
|
||||
assert.deepStrictEqual(allRules[i].name, converted[i].name, `dispatch rule at index ${i} has correct name`);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -355,34 +353,34 @@ describe("RuleRegistry", () => {
|
|||
test("evaluatePostUnit returns null for hook-on-hook prevention", () => {
|
||||
const registry = new RuleRegistry([]);
|
||||
const result = registry.evaluatePostUnit("hook/code-review", "M001/S01/T01", "/tmp/test");
|
||||
assertEq(result, null, "hook units don't trigger other hooks");
|
||||
assert.deepStrictEqual(result, null, "hook units don't trigger other hooks");
|
||||
});
|
||||
|
||||
test("evaluatePostUnit returns null for triage-captures", () => {
|
||||
const registry = new RuleRegistry([]);
|
||||
const result = registry.evaluatePostUnit("triage-captures", "M001/S01/T01", "/tmp/test");
|
||||
assertEq(result, null, "triage-captures skipped");
|
||||
assert.deepStrictEqual(result, null, "triage-captures skipped");
|
||||
});
|
||||
|
||||
test("evaluatePostUnit returns null for quick-task", () => {
|
||||
const registry = new RuleRegistry([]);
|
||||
const result = registry.evaluatePostUnit("quick-task", "M001/S01/T01", "/tmp/test");
|
||||
assertEq(result, null, "quick-task skipped");
|
||||
assert.deepStrictEqual(result, null, "quick-task skipped");
|
||||
});
|
||||
|
||||
test("evaluatePreDispatch bypasses hook units", () => {
|
||||
const registry = new RuleRegistry([]);
|
||||
const result = registry.evaluatePreDispatch("hook/review", "M001/S01/T01", "prompt", "/tmp/test");
|
||||
assertEq(result.action, "proceed", "hook units always proceed");
|
||||
assertEq(result.prompt, "prompt", "prompt unchanged");
|
||||
assertEq(result.firedHooks.length, 0, "no hooks fired");
|
||||
assert.deepStrictEqual(result.action, "proceed", "hook units always proceed");
|
||||
assert.deepStrictEqual(result.prompt, "prompt", "prompt unchanged");
|
||||
assert.deepStrictEqual(result.firedHooks.length, 0, "no hooks fired");
|
||||
});
|
||||
|
||||
test("evaluatePreDispatch proceeds with empty hooks", () => {
|
||||
const registry = new RuleRegistry([]);
|
||||
const result = registry.evaluatePreDispatch("execute-task", "M001/S01/T01", "original prompt", "/tmp/test");
|
||||
assertEq(result.action, "proceed", "proceeds when no hooks");
|
||||
assertEq(result.prompt, "original prompt", "prompt unchanged");
|
||||
assert.deepStrictEqual(result.action, "proceed", "proceeds when no hooks");
|
||||
assert.deepStrictEqual(result.prompt, "original prompt", "prompt unchanged");
|
||||
});
|
||||
|
||||
// ── matchedRule provenance (S02 journal support) ───────────────────
|
||||
|
|
@ -395,8 +393,8 @@ describe("RuleRegistry", () => {
|
|||
const ctx = makeContext("planning");
|
||||
const result = await registry.evaluateDispatch(ctx);
|
||||
|
||||
assertEq(result.action, "dispatch", "result is a dispatch action");
|
||||
assertEq(result.matchedRule, "my-planning-rule", "matchedRule is the rule name");
|
||||
assert.deepStrictEqual(result.action, "dispatch", "result is a dispatch action");
|
||||
assert.deepStrictEqual(result.matchedRule, "my-planning-rule", "matchedRule is the rule name");
|
||||
});
|
||||
|
||||
test("evaluateDispatch result includes matchedRule '<no-match>' on fallback stop", async () => {
|
||||
|
|
@ -407,7 +405,7 @@ describe("RuleRegistry", () => {
|
|||
const ctx = makeContext("some-unknown-phase");
|
||||
const result = await registry.evaluateDispatch(ctx);
|
||||
|
||||
assertEq(result.action, "stop", "result is a stop action");
|
||||
assertEq(result.matchedRule, "<no-match>", "matchedRule is '<no-match>' on fallback");
|
||||
assert.deepStrictEqual(result.action, "stop", "result is a stop action");
|
||||
assert.deepStrictEqual(result.matchedRule, "<no-match>", "matchedRule is '<no-match>' on fallback");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,5 @@
|
|||
// Tests for extractUatType — the core UAT classification primitive — plus
|
||||
// prompt template loading and dispatch precondition assertions (via
|
||||
// resolveSliceFile / extractUatType on real fixture files).
|
||||
//
|
||||
// Sections:
|
||||
// (a)–(j) extractUatType classification (17 assertions from T01)
|
||||
// (k) run-uat prompt template loading and content integrity (8 assertions)
|
||||
// (l) dispatch precondition assertions via resolveSliceFile (4 assertions)
|
||||
// (m) non-artifact UAT skip: human-experience UATs are not dispatched (1 assertion)
|
||||
// (n) stale replay guard: existing UAT-RESULT never re-dispatches (1 assertion)
|
||||
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
|
@ -17,11 +8,6 @@ import { fileURLToPath } from 'node:url';
|
|||
import { extractUatType } from '../files.ts';
|
||||
import { resolveSliceFile } from '../paths.ts';
|
||||
import { checkNeedsRunUat } from '../auto-prompts.ts';
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
|
||||
// ─── Worktree-aware prompt loader ──────────────────────────────────────────
|
||||
// Resolves prompts relative to this test file so the worktree copy is used
|
||||
// instead of the main checkout copy (matches complete-milestone.test.ts pattern).
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const worktreePromptsDir = join(__dirname, '..', 'prompts');
|
||||
|
|
@ -39,10 +25,6 @@ function loadPromptFromWorktree(name: string, vars: Record<string, string> = {})
|
|||
return content.trim();
|
||||
}
|
||||
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
// ─── Fixture helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function createFixtureBase(): string {
|
||||
const base = mkdtempSync(join(tmpdir(), 'gsd-run-uat-test-'));
|
||||
mkdirSync(join(base, '.gsd', 'milestones'), { recursive: true });
|
||||
|
|
@ -69,154 +51,129 @@ function makeUatContent(mode: string): string {
|
|||
return `# UAT File\n\n## UAT Type\n\n- UAT mode: ${mode}\n- Some other bullet: value\n`;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
// ─── (a) artifact-driven ──────────────────────────────────────────────────
|
||||
console.log('\n── (a) artifact-driven');
|
||||
|
||||
assertEq(
|
||||
describe('run-uat', () => {
|
||||
test('(a) artifact-driven', () => {
|
||||
assert.deepStrictEqual(
|
||||
extractUatType(makeUatContent('artifact-driven')),
|
||||
'artifact-driven',
|
||||
'plain artifact-driven → artifact-driven',
|
||||
);
|
||||
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
extractUatType('## UAT Type\n\n- UAT mode: artifact-driven\n'),
|
||||
'artifact-driven',
|
||||
'minimal content, artifact-driven',
|
||||
);
|
||||
});
|
||||
|
||||
// ─── (b) live-runtime ─────────────────────────────────────────────────────
|
||||
console.log('\n── (b) live-runtime');
|
||||
|
||||
assertEq(
|
||||
test('(b) live-runtime', () => {
|
||||
assert.deepStrictEqual(
|
||||
extractUatType(makeUatContent('live-runtime')),
|
||||
'live-runtime',
|
||||
'plain live-runtime → live-runtime',
|
||||
);
|
||||
});
|
||||
|
||||
// ─── (c) human-experience ─────────────────────────────────────────────────
|
||||
console.log('\n── (c) human-experience');
|
||||
|
||||
assertEq(
|
||||
test('(c) human-experience', () => {
|
||||
assert.deepStrictEqual(
|
||||
extractUatType(makeUatContent('human-experience')),
|
||||
'human-experience',
|
||||
'plain human-experience → human-experience',
|
||||
);
|
||||
});
|
||||
|
||||
// ─── (d) mixed standalone ─────────────────────────────────────────────────
|
||||
console.log('\n── (d) mixed standalone');
|
||||
|
||||
assertEq(
|
||||
test('(d) mixed standalone', () => {
|
||||
assert.deepStrictEqual(
|
||||
extractUatType(makeUatContent('mixed')),
|
||||
'mixed',
|
||||
'plain mixed → mixed',
|
||||
);
|
||||
});
|
||||
|
||||
// ─── (e) mixed with parenthetical ─────────────────────────────────────────
|
||||
console.log('\n── (e) mixed parenthetical');
|
||||
|
||||
assertEq(
|
||||
test('(e) mixed parenthetical', () => {
|
||||
assert.deepStrictEqual(
|
||||
extractUatType(makeUatContent('mixed (artifact-driven + live-runtime)')),
|
||||
'mixed',
|
||||
'mixed (artifact-driven + live-runtime) → mixed (leading keyword only)',
|
||||
);
|
||||
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
extractUatType(makeUatContent('mixed (some other description)')),
|
||||
'mixed',
|
||||
'mixed with arbitrary parenthetical → mixed',
|
||||
);
|
||||
});
|
||||
|
||||
// ─── (f) missing ## UAT Type section ──────────────────────────────────────
|
||||
console.log('\n── (f) missing UAT Type section');
|
||||
|
||||
assertEq(
|
||||
test('(f) missing UAT Type section', () => {
|
||||
assert.deepStrictEqual(
|
||||
extractUatType('# UAT File\n\n## Overview\n\nSome content.\n'),
|
||||
undefined,
|
||||
'no ## UAT Type section → undefined',
|
||||
);
|
||||
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
extractUatType(''),
|
||||
undefined,
|
||||
'empty content → undefined',
|
||||
);
|
||||
});
|
||||
|
||||
// ─── (g) ## UAT Type present but no UAT mode: bullet ─────────────────────
|
||||
console.log('\n── (g) UAT Type section present, no UAT mode: bullet');
|
||||
|
||||
assertEq(
|
||||
test('(g) UAT Type section present, no UAT mode: bullet', () => {
|
||||
assert.deepStrictEqual(
|
||||
extractUatType('## UAT Type\n\n- Some other bullet: value\n- Another bullet\n'),
|
||||
undefined,
|
||||
'section present but no UAT mode: bullet → undefined',
|
||||
);
|
||||
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
extractUatType('## UAT Type\n\n'),
|
||||
undefined,
|
||||
'section present but empty → undefined',
|
||||
);
|
||||
});
|
||||
|
||||
// ─── (h) unknown keyword ──────────────────────────────────────────────────
|
||||
console.log('\n── (h) unknown keyword');
|
||||
|
||||
assertEq(
|
||||
test('(h) unknown keyword', () => {
|
||||
assert.deepStrictEqual(
|
||||
extractUatType(makeUatContent('automated')),
|
||||
undefined,
|
||||
'unknown keyword automated → undefined',
|
||||
);
|
||||
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
extractUatType(makeUatContent('fully-automated')),
|
||||
undefined,
|
||||
'unknown keyword fully-automated → undefined',
|
||||
);
|
||||
});
|
||||
|
||||
// ─── (i) extra whitespace around value ────────────────────────────────────
|
||||
console.log('\n── (i) extra whitespace');
|
||||
|
||||
assertEq(
|
||||
test('(i) extra whitespace', () => {
|
||||
assert.deepStrictEqual(
|
||||
extractUatType('## UAT Type\n\n- UAT mode: artifact-driven \n'),
|
||||
'artifact-driven',
|
||||
'leading/trailing whitespace around value → still classified correctly',
|
||||
);
|
||||
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
extractUatType('## UAT Type\n\n- UAT mode: mixed (artifact-driven + live-runtime) \n'),
|
||||
'mixed',
|
||||
'whitespace around mixed parenthetical → mixed',
|
||||
);
|
||||
});
|
||||
|
||||
// ─── (j) case sensitivity ─────────────────────────────────────────────────
|
||||
console.log('\n── (j) case sensitivity');
|
||||
|
||||
assertEq(
|
||||
test('(j) case sensitivity', () => {
|
||||
assert.deepStrictEqual(
|
||||
extractUatType(makeUatContent('Artifact-Driven')),
|
||||
'artifact-driven',
|
||||
'Artifact-Driven (title case) → artifact-driven (function lowercases before matching)',
|
||||
);
|
||||
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
extractUatType(makeUatContent('MIXED')),
|
||||
'mixed',
|
||||
'MIXED (upper case) → mixed (function lowercases before matching)',
|
||||
);
|
||||
});
|
||||
|
||||
// ─── (k) prompt template loading and content integrity ────────────────────
|
||||
console.log('\n── (k) run-uat prompt template');
|
||||
|
||||
test('(k) run-uat prompt template', () => {
|
||||
const milestoneId = 'M001';
|
||||
const sliceId = 'S01';
|
||||
const uatPath = '.gsd/milestones/M001/slices/S01/S01-UAT.md';
|
||||
const uatResultPath = '.gsd/milestones/M001/slices/S01/S01-UAT-RESULT.md';
|
||||
const uatType = 'live-runtime';
|
||||
const inlinedContext = '<!-- no context -->';
|
||||
|
||||
let promptResult: string | undefined;
|
||||
let promptThrew = false;
|
||||
try {
|
||||
|
|
@ -232,71 +189,66 @@ async function main(): Promise<void> {
|
|||
} catch {
|
||||
promptThrew = true;
|
||||
}
|
||||
|
||||
assertTrue(!promptThrew, 'loadPromptFromWorktree("run-uat", vars) does not throw');
|
||||
assertTrue(
|
||||
assert.ok(!promptThrew, 'loadPromptFromWorktree("run-uat", vars) does not throw');
|
||||
assert.ok(
|
||||
typeof promptResult === 'string' && promptResult.length > 0,
|
||||
'run-uat prompt result is a non-empty string',
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
promptResult?.includes(milestoneId) ?? false,
|
||||
`prompt contains milestoneId value "${milestoneId}" after substitution`,
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
promptResult?.includes(sliceId) ?? false,
|
||||
`prompt contains sliceId value "${sliceId}" after substitution`,
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
promptResult?.includes(uatResultPath) ?? false,
|
||||
`prompt contains uatResultPath value after substitution`,
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
promptResult?.includes(`Detected UAT mode:** \`${uatType}\``) ?? false,
|
||||
`prompt contains detected dynamic uatType value "${uatType}" after substitution`,
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
promptResult?.includes(`uatType: ${uatType}`) ?? false,
|
||||
`prompt contains dynamic uatType frontmatter value "${uatType}" after substitution`,
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
!/\{\{[^}]+\}\}/.test(promptResult ?? ''),
|
||||
'no unreplaced {{...}} tokens remain after variable substitution',
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
/browser|runtime|execute|run/i.test(promptResult ?? ''),
|
||||
'prompt contains runtime execution language (browser/runtime/execute/run)',
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
!/surfaced for human review/i.test(promptResult ?? ''),
|
||||
'prompt does not contain "surfaced for human review" (non-artifact UATs are skipped, not dispatched)',
|
||||
);
|
||||
});
|
||||
|
||||
// ─── (l) dispatch precondition assertions via resolveSliceFile ────────────
|
||||
console.log('\n── (l) dispatch preconditions via resolveSliceFile');
|
||||
|
||||
// State A: UAT file exists, UAT-RESULT file does NOT — triggers dispatch
|
||||
{
|
||||
test('(l) dispatch preconditions via resolveSliceFile', () => {
|
||||
const base = createFixtureBase();
|
||||
const uatContent = makeUatContent('artifact-driven');
|
||||
try {
|
||||
writeSliceFile(base, 'M001', 'S01', 'UAT', uatContent);
|
||||
|
||||
const uatFilePath = resolveSliceFile(base, 'M001', 'S01', 'UAT');
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
uatFilePath !== null,
|
||||
'resolveSliceFile(..., "UAT") returns non-null when UAT file exists (dispatch trigger state)',
|
||||
);
|
||||
|
||||
const uatResultFilePath = resolveSliceFile(base, 'M001', 'S01', 'UAT-RESULT');
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
uatResultFilePath,
|
||||
null,
|
||||
'resolveSliceFile(..., "UAT-RESULT") returns null when result file missing (dispatch trigger state)',
|
||||
);
|
||||
|
||||
// End-to-end: file content → parse → classify
|
||||
const rawContent = readFileSync(uatFilePath!, 'utf-8');
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
extractUatType(rawContent),
|
||||
'artifact-driven',
|
||||
'extractUatType on fixture UAT file returns expected type (end-to-end data flow)',
|
||||
|
|
@ -304,29 +256,25 @@ async function main(): Promise<void> {
|
|||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// State B: UAT-RESULT file exists — dispatch is skipped (idempotent)
|
||||
{
|
||||
test('test block at line 307', () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
writeSliceFile(base, 'M001', 'S01', 'UAT', makeUatContent('artifact-driven'));
|
||||
writeSliceFile(base, 'M001', 'S01', 'UAT-RESULT', '# UAT Result\n\nverdict: PASS\n');
|
||||
|
||||
const uatResultFilePath = resolveSliceFile(base, 'M001', 'S01', 'UAT-RESULT');
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
uatResultFilePath !== null,
|
||||
'resolveSliceFile(..., "UAT-RESULT") returns non-null when result file exists (idempotent skip state)',
|
||||
);
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── (m) non-artifact UATs are skipped (not dispatched) ─────────────────
|
||||
console.log('\n── (m) non-artifact UAT skip');
|
||||
|
||||
{
|
||||
test('(m) non-artifact UAT skip', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
const roadmapDir = join(base, '.gsd', 'milestones', 'M001');
|
||||
|
|
@ -346,7 +294,6 @@ async function main(): Promise<void> {
|
|||
].join('\n'),
|
||||
);
|
||||
|
||||
// human-experience UAT still dispatches, but auto-mode later pauses for manual review
|
||||
writeSliceFile(base, 'M001', 'S01', 'UAT', makeUatContent('human-experience'));
|
||||
|
||||
const state = {
|
||||
|
|
@ -361,7 +308,7 @@ async function main(): Promise<void> {
|
|||
} as const;
|
||||
|
||||
const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any);
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
result,
|
||||
{ sliceId: 'S01', uatType: 'human-experience' },
|
||||
'human-experience UAT dispatches so auto-mode can pause for manual review',
|
||||
|
|
@ -369,12 +316,9 @@ async function main(): Promise<void> {
|
|||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── (n) existing UAT-RESULT never re-dispatches ──────────────────────
|
||||
console.log('\n── (n) stale replay guard');
|
||||
|
||||
{
|
||||
test('(n) stale replay guard', async () => {
|
||||
const base = createFixtureBase();
|
||||
try {
|
||||
const roadmapDir = join(base, '.gsd', 'milestones', 'M001');
|
||||
|
|
@ -409,7 +353,7 @@ async function main(): Promise<void> {
|
|||
} as const;
|
||||
|
||||
const result = await checkNeedsRunUat(base, 'M001', state as any, { uat_dispatch: true } as any);
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
result,
|
||||
null,
|
||||
'existing UAT-RESULT with FAIL verdict does not re-dispatch; verdict gate owns blocking',
|
||||
|
|
@ -417,12 +361,6 @@ async function main(): Promise<void> {
|
|||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue