refactor(test): migrate gsd/tests s-z from custom harness to node:test (#2397)
This commit is contained in:
parent
1fe52a2e8e
commit
77460942ac
26 changed files with 879 additions and 989 deletions
|
|
@ -20,11 +20,11 @@ import {
|
|||
_getRegisteredLockDirs,
|
||||
} from '../session-lock.ts';
|
||||
import { gsdRoot } from '../paths.ts';
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
async function main(): Promise<void> {
|
||||
describe('session-lock-multipath', async () => {
|
||||
|
||||
// ─── 1. Lock dir registry tracks gsdDir on acquisition ──────────────────
|
||||
console.log('\n=== 1. Lock dir registry tracks gsdDir on acquisition ===');
|
||||
|
|
@ -34,17 +34,17 @@ async function main(): Promise<void> {
|
|||
|
||||
try {
|
||||
const result = acquireSessionLock(base);
|
||||
assertTrue(result.acquired, 'lock acquired');
|
||||
assert.ok(result.acquired, 'lock acquired');
|
||||
|
||||
const registered = _getRegisteredLockDirs();
|
||||
const gsdDir = gsdRoot(base);
|
||||
assertTrue(registered.includes(gsdDir), 'gsdDir is registered in lock dir registry');
|
||||
assert.ok(registered.includes(gsdDir), 'gsdDir is registered in lock dir registry');
|
||||
|
||||
releaseSessionLock(base);
|
||||
|
||||
// After release, registry should be cleared
|
||||
const afterRelease = _getRegisteredLockDirs();
|
||||
assertEq(afterRelease.length, 0, 'lock dir registry cleared after release');
|
||||
assert.deepStrictEqual(afterRelease.length, 0, 'lock dir registry cleared after release');
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -62,7 +62,7 @@ async function main(): Promise<void> {
|
|||
|
||||
try {
|
||||
const result = acquireSessionLock(base);
|
||||
assertTrue(result.acquired, 'lock acquired');
|
||||
assert.ok(result.acquired, 'lock acquired');
|
||||
|
||||
// Manually plant a stale lock file at the secondary path to simulate
|
||||
// multi-path lock accumulation
|
||||
|
|
@ -72,8 +72,8 @@ async function main(): Promise<void> {
|
|||
mkdirSync(secondaryLockDir, { recursive: true });
|
||||
|
||||
// Verify they exist before release
|
||||
assertTrue(existsSync(secondaryLockFile), 'secondary lock file exists before release');
|
||||
assertTrue(existsSync(secondaryLockDir), 'secondary lock dir exists before release');
|
||||
assert.ok(existsSync(secondaryLockFile), 'secondary lock file exists before release');
|
||||
assert.ok(existsSync(secondaryLockDir), 'secondary lock dir exists before release');
|
||||
|
||||
// Manually add the secondary dir to the registry (simulating ensureExitHandler call)
|
||||
// We do this by acquiring knowledge of internals — the registry is populated
|
||||
|
|
@ -83,10 +83,10 @@ async function main(): Promise<void> {
|
|||
|
||||
// Primary lock artifacts should be cleaned
|
||||
const primaryLockFile = join(gsdRoot(base), 'auto.lock');
|
||||
assertTrue(!existsSync(primaryLockFile), 'primary auto.lock removed after release');
|
||||
assert.ok(!existsSync(primaryLockFile), 'primary auto.lock removed after release');
|
||||
|
||||
const primaryLockDir = gsdRoot(base) + '.lock';
|
||||
assertTrue(!existsSync(primaryLockDir), 'primary .gsd.lock/ removed after release');
|
||||
assert.ok(!existsSync(primaryLockDir), 'primary .gsd.lock/ removed after release');
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -106,7 +106,7 @@ async function main(): Promise<void> {
|
|||
const gsdDir = gsdRoot(base);
|
||||
// Should only appear once (Set deduplication)
|
||||
const count = registered.filter(d => d === gsdDir).length;
|
||||
assertEq(count, 1, 'gsdDir registered exactly once after re-entrant acquisition');
|
||||
assert.deepStrictEqual(count, 1, 'gsdDir registered exactly once after re-entrant acquisition');
|
||||
|
||||
releaseSessionLock(base);
|
||||
} finally {
|
||||
|
|
@ -124,17 +124,17 @@ async function main(): Promise<void> {
|
|||
|
||||
try {
|
||||
const r1 = acquireSessionLock(base1);
|
||||
assertTrue(r1.acquired, 'first base lock acquired');
|
||||
assert.ok(r1.acquired, 'first base lock acquired');
|
||||
|
||||
// Release first to acquire second (module state is single-lock)
|
||||
releaseSessionLock(base1);
|
||||
|
||||
const r2 = acquireSessionLock(base2);
|
||||
assertTrue(r2.acquired, 'second base lock acquired');
|
||||
assert.ok(r2.acquired, 'second base lock acquired');
|
||||
|
||||
const registered = _getRegisteredLockDirs();
|
||||
const gsd2 = gsdRoot(base2);
|
||||
assertTrue(registered.includes(gsd2), 'second gsdDir is registered');
|
||||
assert.ok(registered.includes(gsd2), 'second gsdDir is registered');
|
||||
|
||||
releaseSessionLock(base2);
|
||||
} finally {
|
||||
|
|
@ -156,18 +156,11 @@ async function main(): Promise<void> {
|
|||
// Verify everything is clean
|
||||
const lockFile = join(gsdRoot(base), 'auto.lock');
|
||||
const lockDir = gsdRoot(base) + '.lock';
|
||||
assertTrue(!existsSync(lockFile), 'auto.lock cleaned');
|
||||
assertTrue(!existsSync(lockDir), '.gsd.lock/ cleaned');
|
||||
assertEq(_getRegisteredLockDirs().length, 0, 'registry empty');
|
||||
assert.ok(!existsSync(lockFile), 'auto.lock cleaned');
|
||||
assert.ok(!existsSync(lockDir), '.gsd.lock/ cleaned');
|
||||
assert.deepStrictEqual(_getRegisteredLockDirs().length, 0, 'registry empty');
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,9 +25,9 @@ import {
|
|||
isSessionLockHeld,
|
||||
} from '../session-lock.ts';
|
||||
import { gsdRoot } from '../paths.ts';
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
function hasProperLockfile(): boolean {
|
||||
|
|
@ -41,7 +41,7 @@ function hasProperLockfile(): boolean {
|
|||
|
||||
const properLockfileAvailable = hasProperLockfile();
|
||||
|
||||
async function main(): Promise<void> {
|
||||
describe('session-lock-regression', async () => {
|
||||
|
||||
// ─── 1. Basic acquire/release lifecycle ───────────────────────────────
|
||||
console.log('\n=== 1. acquire → validate → release lifecycle ===');
|
||||
|
|
@ -51,22 +51,22 @@ async function main(): Promise<void> {
|
|||
|
||||
try {
|
||||
const result = acquireSessionLock(base);
|
||||
assertTrue(result.acquired, 'lock acquired successfully');
|
||||
assert.ok(result.acquired, 'lock acquired successfully');
|
||||
|
||||
const valid = validateSessionLock(base);
|
||||
assertTrue(valid, 'lock validates after acquisition');
|
||||
assert.ok(valid, 'lock validates after acquisition');
|
||||
|
||||
assertTrue(isSessionLockHeld(base), 'isSessionLockHeld returns true');
|
||||
assert.ok(isSessionLockHeld(base), 'isSessionLockHeld returns true');
|
||||
|
||||
releaseSessionLock(base);
|
||||
|
||||
// After release, the lock file should be cleaned up
|
||||
const lockFile = join(gsdRoot(base), 'auto.lock');
|
||||
assertTrue(!existsSync(lockFile), 'lock file removed after release');
|
||||
assert.ok(!existsSync(lockFile), 'lock file removed after release');
|
||||
|
||||
// The .gsd.lock/ directory should be cleaned up
|
||||
const lockDir = gsdRoot(base) + '.lock';
|
||||
assertTrue(!existsSync(lockDir), '.gsd.lock/ directory removed after release (#1245)');
|
||||
assert.ok(!existsSync(lockDir), '.gsd.lock/ directory removed after release (#1245)');
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -88,7 +88,7 @@ async function main(): Promise<void> {
|
|||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
assertTrue(!threw, 'double release does not throw');
|
||||
assert.ok(!threw, 'double release does not throw');
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -106,13 +106,13 @@ async function main(): Promise<void> {
|
|||
updateSessionLock(base, 'execute-task', 'M001/S01/T01', 5, '/tmp/session.json');
|
||||
|
||||
const data = readSessionLockData(base);
|
||||
assertTrue(data !== null, 'lock data readable after update');
|
||||
assert.ok(data !== null, 'lock data readable after update');
|
||||
if (data) {
|
||||
assertEq(data.pid, process.pid, 'lock data has correct PID');
|
||||
assertEq(data.unitType, 'execute-task', 'lock data has correct unit type');
|
||||
assertEq(data.unitId, 'M001/S01/T01', 'lock data has correct unit ID');
|
||||
assertEq(data.completedUnits, 5, 'lock data has correct completed count');
|
||||
assertEq(data.sessionFile, '/tmp/session.json', 'lock data has session file');
|
||||
assert.deepStrictEqual(data.pid, process.pid, 'lock data has correct PID');
|
||||
assert.deepStrictEqual(data.unitType, 'execute-task', 'lock data has correct unit type');
|
||||
assert.deepStrictEqual(data.unitId, 'M001/S01/T01', 'lock data has correct unit ID');
|
||||
assert.deepStrictEqual(data.completedUnits, 5, 'lock data has correct completed count');
|
||||
assert.deepStrictEqual(data.sessionFile, '/tmp/session.json', 'lock data has session file');
|
||||
}
|
||||
|
||||
releaseSessionLock(base);
|
||||
|
|
@ -142,7 +142,7 @@ async function main(): Promise<void> {
|
|||
|
||||
// Should be able to acquire despite the stale lock
|
||||
const result = acquireSessionLock(base);
|
||||
assertTrue(result.acquired, '#1245: stale lock from dead PID → re-acquirable');
|
||||
assert.ok(result.acquired, '#1245: stale lock from dead PID → re-acquirable');
|
||||
|
||||
releaseSessionLock(base);
|
||||
} finally {
|
||||
|
|
@ -158,7 +158,7 @@ async function main(): Promise<void> {
|
|||
|
||||
try {
|
||||
const data = readSessionLockData(base);
|
||||
assertEq(data, null, 'no lock file → null');
|
||||
assert.deepStrictEqual(data, null, 'no lock file → null');
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -176,7 +176,7 @@ async function main(): Promise<void> {
|
|||
// Multiple validations should all return true (regression for #1257)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const valid = validateSessionLock(base);
|
||||
assertTrue(valid, `#1257: validation ${i + 1} returns true for own lock`);
|
||||
assert.ok(valid, `#1257: validation ${i + 1} returns true for own lock`);
|
||||
}
|
||||
|
||||
releaseSessionLock(base);
|
||||
|
|
@ -196,7 +196,7 @@ async function main(): Promise<void> {
|
|||
writeFileSync(lockFile, 'NOT VALID JSON {{{');
|
||||
|
||||
const data = readSessionLockData(base);
|
||||
assertEq(data, null, 'corrupt JSON → null');
|
||||
assert.deepStrictEqual(data, null, 'corrupt JSON → null');
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -210,9 +210,9 @@ async function main(): Promise<void> {
|
|||
|
||||
try {
|
||||
const status = getSessionLockStatus(base);
|
||||
assertEq(status.valid, false, 'missing lock metadata is invalid');
|
||||
assertEq(status.failureReason, 'missing-metadata', 'missing metadata reason is surfaced');
|
||||
assertEq(status.expectedPid, process.pid, 'expected PID is included');
|
||||
assert.deepStrictEqual(status.valid, false, 'missing lock metadata is invalid');
|
||||
assert.deepStrictEqual(status.failureReason, 'missing-metadata', 'missing metadata reason is surfaced');
|
||||
assert.deepStrictEqual(status.expectedPid, process.pid, 'expected PID is included');
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -237,10 +237,10 @@ async function main(): Promise<void> {
|
|||
}, null, 2));
|
||||
|
||||
const status = getSessionLockStatus(base);
|
||||
assertEq(status.valid, false, 'foreign PID lock is invalid');
|
||||
assertEq(status.failureReason, 'pid-mismatch', 'PID mismatch reason is surfaced');
|
||||
assertEq(status.existingPid, foreignPid, 'existing PID is included');
|
||||
assertEq(status.expectedPid, process.pid, 'expected PID is included');
|
||||
assert.deepStrictEqual(status.valid, false, 'foreign PID lock is invalid');
|
||||
assert.deepStrictEqual(status.failureReason, 'pid-mismatch', 'PID mismatch reason is surfaced');
|
||||
assert.deepStrictEqual(status.existingPid, foreignPid, 'existing PID is included');
|
||||
assert.deepStrictEqual(status.expectedPid, process.pid, 'expected PID is included');
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -254,11 +254,11 @@ async function main(): Promise<void> {
|
|||
|
||||
try {
|
||||
const r1 = acquireSessionLock(base);
|
||||
assertTrue(r1.acquired, 'first acquisition');
|
||||
assert.ok(r1.acquired, 'first acquisition');
|
||||
releaseSessionLock(base);
|
||||
|
||||
const r2 = acquireSessionLock(base);
|
||||
assertTrue(r2.acquired, 're-acquisition after release');
|
||||
assert.ok(r2.acquired, 're-acquisition after release');
|
||||
releaseSessionLock(base);
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
|
|
@ -273,13 +273,13 @@ async function main(): Promise<void> {
|
|||
|
||||
try {
|
||||
const r1 = acquireSessionLock(base);
|
||||
assertTrue(r1.acquired, 'first acquisition succeeds');
|
||||
assert.ok(r1.acquired, 'first acquisition succeeds');
|
||||
|
||||
const r2 = acquireSessionLock(base);
|
||||
assertTrue(r2.acquired, 're-entrant acquisition succeeds');
|
||||
assert.ok(r2.acquired, 're-entrant acquisition succeeds');
|
||||
|
||||
const valid = validateSessionLock(base);
|
||||
assertTrue(valid, 're-entrant acquisition does not corrupt validation state');
|
||||
assert.ok(valid, 're-entrant acquisition does not corrupt validation state');
|
||||
|
||||
releaseSessionLock(base);
|
||||
} finally {
|
||||
|
|
@ -295,31 +295,24 @@ async function main(): Promise<void> {
|
|||
|
||||
try {
|
||||
const r1 = acquireSessionLock(base);
|
||||
assertTrue(r1.acquired, 'first acquisition succeeds');
|
||||
assert.ok(r1.acquired, 'first acquisition succeeds');
|
||||
|
||||
const lockDir = gsdRoot(base) + '.lock';
|
||||
if (properLockfileAvailable) {
|
||||
assertTrue(existsSync(lockDir), '.gsd.lock/ exists after first acquisition');
|
||||
assert.ok(existsSync(lockDir), '.gsd.lock/ exists after first acquisition');
|
||||
}
|
||||
|
||||
const r2 = acquireSessionLock(base);
|
||||
assertTrue(r2.acquired, 'second acquisition succeeds');
|
||||
assert.ok(r2.acquired, 'second acquisition succeeds');
|
||||
if (properLockfileAvailable) {
|
||||
assertTrue(existsSync(lockDir), '.gsd.lock/ exists after re-entrant acquisition');
|
||||
assert.ok(existsSync(lockDir), '.gsd.lock/ exists after re-entrant acquisition');
|
||||
}
|
||||
assertTrue(validateSessionLock(base), 'lock remains valid after re-entrant acquisition');
|
||||
assert.ok(validateSessionLock(base), 'lock remains valid after re-entrant acquisition');
|
||||
|
||||
releaseSessionLock(base);
|
||||
assertTrue(!existsSync(lockDir), '.gsd.lock/ is removed after release');
|
||||
assert.ok(!existsSync(lockDir), '.gsd.lock/ is removed after release');
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ import {
|
|||
getAllMilestones,
|
||||
_getAdapter,
|
||||
} from '../gsd-db.ts';
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -30,14 +30,14 @@ function cleanup(dir: string): void {
|
|||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
describe('shared-wal', async () => {
|
||||
// ─── Test (a): resolveProjectRootDbPath returns project root DB for worktree path ───
|
||||
console.log('\n=== shared-wal: resolve worktree path to project root DB ===');
|
||||
{
|
||||
const projectRoot = '/home/user/myproject';
|
||||
const worktreePath = join(projectRoot, '.gsd', 'worktrees', 'M001');
|
||||
const result = resolveProjectRootDbPath(worktreePath);
|
||||
assertEq(result, join(projectRoot, '.gsd', 'gsd.db'),
|
||||
assert.deepStrictEqual(result, join(projectRoot, '.gsd', 'gsd.db'),
|
||||
'worktree path resolves to project root DB');
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +46,7 @@ async function main() {
|
|||
{
|
||||
const projectRoot = '/home/user/myproject';
|
||||
const result = resolveProjectRootDbPath(projectRoot);
|
||||
assertEq(result, join(projectRoot, '.gsd', 'gsd.db'),
|
||||
assert.deepStrictEqual(result, join(projectRoot, '.gsd', 'gsd.db'),
|
||||
'project root path stays at project root DB');
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ async function main() {
|
|||
const projectRoot = '/home/user/myproject';
|
||||
const nestedPath = join(projectRoot, '.gsd', 'worktrees', 'M002', 'src', 'lib');
|
||||
const result = resolveProjectRootDbPath(nestedPath);
|
||||
assertEq(result, join(projectRoot, '.gsd', 'gsd.db'),
|
||||
assert.deepStrictEqual(result, join(projectRoot, '.gsd', 'gsd.db'),
|
||||
'nested worktree subdir resolves to project root DB');
|
||||
}
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ async function main() {
|
|||
console.log('\n=== shared-wal: resolve forward-slash path ===');
|
||||
{
|
||||
const result = resolveProjectRootDbPath('/proj/.gsd/worktrees/M001');
|
||||
assertEq(result, join('/proj', '.gsd', 'gsd.db'),
|
||||
assert.deepStrictEqual(result, join('/proj', '.gsd', 'gsd.db'),
|
||||
'forward-slash worktree path resolves correctly');
|
||||
}
|
||||
|
||||
|
|
@ -99,9 +99,9 @@ async function main() {
|
|||
|
||||
// Verify all 3 milestones are visible
|
||||
const all = getAllMilestones();
|
||||
assertEq(all.length, 3, 'concurrent: all 3 milestones visible');
|
||||
assert.deepStrictEqual(all.length, 3, 'concurrent: all 3 milestones visible');
|
||||
const ids = all.map(m => m.id).sort();
|
||||
assertEq(ids, ['M001', 'M002', 'M003'], 'concurrent: correct IDs');
|
||||
assert.deepStrictEqual(ids, ['M001', 'M002', 'M003'], 'concurrent: correct IDs');
|
||||
|
||||
closeDatabase();
|
||||
} finally {
|
||||
|
|
@ -132,7 +132,7 @@ async function main() {
|
|||
// Connection 2: write M002, verify sees M001
|
||||
openDatabase(dbPath);
|
||||
const afterConn2Before = getAllMilestones();
|
||||
assertTrue(afterConn2Before.some(m => m.id === 'M001'),
|
||||
assert.ok(afterConn2Before.some(m => m.id === 'M001'),
|
||||
'rawconc: conn2 sees M001 from conn1');
|
||||
insertMilestone({ id: 'M002', title: 'Writer 2', status: 'active' });
|
||||
closeDatabase();
|
||||
|
|
@ -140,16 +140,16 @@ async function main() {
|
|||
// Connection 3: write M003, verify sees M001 + M002
|
||||
openDatabase(dbPath);
|
||||
const afterConn3Before = getAllMilestones();
|
||||
assertTrue(afterConn3Before.some(m => m.id === 'M001'),
|
||||
assert.ok(afterConn3Before.some(m => m.id === 'M001'),
|
||||
'rawconc: conn3 sees M001');
|
||||
assertTrue(afterConn3Before.some(m => m.id === 'M002'),
|
||||
assert.ok(afterConn3Before.some(m => m.id === 'M002'),
|
||||
'rawconc: conn3 sees M002');
|
||||
insertMilestone({ id: 'M003', title: 'Writer 3', status: 'active' });
|
||||
|
||||
// Final read: all 3 visible
|
||||
const finalAll = getAllMilestones();
|
||||
assertEq(finalAll.length, 3, 'rawconc: all 3 milestones visible');
|
||||
assertEq(
|
||||
assert.deepStrictEqual(finalAll.length, 3, 'rawconc: all 3 milestones visible');
|
||||
assert.deepStrictEqual(
|
||||
finalAll.map(m => m.id).sort(),
|
||||
['M001', 'M002', 'M003'],
|
||||
'rawconc: all IDs present',
|
||||
|
|
@ -177,7 +177,7 @@ async function main() {
|
|||
|
||||
// Verify it committed
|
||||
const all = getAllMilestones();
|
||||
assertEq(all.length, 1, 'busy: M001 committed via transaction');
|
||||
assert.deepStrictEqual(all.length, 1, 'busy: M001 committed via transaction');
|
||||
|
||||
// Verify transaction rolls back on error
|
||||
let errorCaught = false;
|
||||
|
|
@ -188,17 +188,17 @@ async function main() {
|
|||
});
|
||||
} catch (err) {
|
||||
errorCaught = true;
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
(err as Error).message.includes('Simulated failure'),
|
||||
'busy: error propagated from transaction',
|
||||
);
|
||||
}
|
||||
assertTrue(errorCaught, 'busy: transaction threw on error');
|
||||
assert.ok(errorCaught, 'busy: transaction threw on error');
|
||||
|
||||
// M002 should NOT be visible (rolled back)
|
||||
const afterRollback = getAllMilestones();
|
||||
assertEq(afterRollback.length, 1, 'busy: M002 rolled back — still only 1 milestone');
|
||||
assertEq(afterRollback[0]!.id, 'M001', 'busy: only M001 survives');
|
||||
assert.deepStrictEqual(afterRollback.length, 1, 'busy: M002 rolled back — still only 1 milestone');
|
||||
assert.deepStrictEqual(afterRollback[0]!.id, 'M001', 'busy: only M001 survives');
|
||||
|
||||
closeDatabase();
|
||||
} finally {
|
||||
|
|
@ -206,11 +206,4 @@ async function main() {
|
|||
cleanup(tmp);
|
||||
}
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
|||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { recoverTimedOutUnit, type RecoveryContext } from "../auto-timeout-recovery.ts";
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { assertTrue, report } = createTestContext();
|
||||
|
||||
// Minimal mock for ExtensionContext — only the fields recoverTimedOutUnit touches.
|
||||
function makeMockCtx() {
|
||||
|
|
@ -55,12 +55,12 @@ function makeMockPi() {
|
|||
await recoverTimedOutUnit(ctx, pi, "execute-task", "M001/S01/T01", "idle", emptyRctx);
|
||||
} catch (err: any) {
|
||||
crashed = true;
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
err.message.includes("path") || err.message.includes("string") || err.code === "ERR_INVALID_ARG_TYPE",
|
||||
`should crash with path/type error, got: ${err.message}`,
|
||||
);
|
||||
}
|
||||
assertTrue(crashed, "should crash when basePath is undefined (reproduces #1855)");
|
||||
assert.ok(crashed, "should crash when basePath is undefined (reproduces #1855)");
|
||||
}
|
||||
|
||||
// ═══ #1855: valid RecoveryContext does not crash ═════════════════════════════
|
||||
|
|
@ -90,13 +90,11 @@ function makeMockPi() {
|
|||
crashed = true;
|
||||
console.error(` Unexpected crash: ${err.message}`);
|
||||
}
|
||||
assertTrue(!crashed, "should not crash with valid basePath");
|
||||
assert.ok(!crashed, "should not crash with valid basePath");
|
||||
// With no runtime record on disk and recoveryAttempts=0, the function
|
||||
// should attempt steering recovery (sendMessage) and return "recovered".
|
||||
assertTrue(result === "recovered", `should return 'recovered', got '${result}'`);
|
||||
assert.ok(result === "recovered", `should return 'recovered', got '${result}'`);
|
||||
} finally {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
report();
|
||||
|
|
|
|||
|
|
@ -23,15 +23,15 @@ import { tmpdir } from "node:os";
|
|||
import { execSync } from "node:child_process";
|
||||
|
||||
import { ensureGsdSymlink, externalGsdRoot } from "../repo-identity.ts";
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
function run(command: string, cwd: string): string {
|
||||
return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
describe('symlink-numbered-variants', async () => {
|
||||
const base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-symlink-variants-")));
|
||||
const stateDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-state-variants-")));
|
||||
|
||||
|
|
@ -58,14 +58,14 @@ async function main(): Promise<void> {
|
|||
mkdirSync(join(base, ".gsd 4"), { recursive: true });
|
||||
|
||||
const result = ensureGsdSymlink(base);
|
||||
assertEq(result, externalPath, "ensureGsdSymlink returns external path");
|
||||
assertTrue(existsSync(join(base, ".gsd")), ".gsd exists after ensureGsdSymlink");
|
||||
assertTrue(lstatSync(join(base, ".gsd")).isSymbolicLink(), ".gsd is a symlink");
|
||||
assert.deepStrictEqual(result, externalPath, "ensureGsdSymlink returns external path");
|
||||
assert.ok(existsSync(join(base, ".gsd")), ".gsd exists after ensureGsdSymlink");
|
||||
assert.ok(lstatSync(join(base, ".gsd")).isSymbolicLink(), ".gsd is a symlink");
|
||||
|
||||
// The numbered variants must have been removed
|
||||
assertTrue(!existsSync(join(base, ".gsd 2")), '".gsd 2" directory was cleaned up');
|
||||
assertTrue(!existsSync(join(base, ".gsd 3")), '".gsd 3" directory was cleaned up');
|
||||
assertTrue(!existsSync(join(base, ".gsd 4")), '".gsd 4" directory was cleaned up');
|
||||
assert.ok(!existsSync(join(base, ".gsd 2")), '".gsd 2" directory was cleaned up');
|
||||
assert.ok(!existsSync(join(base, ".gsd 3")), '".gsd 3" directory was cleaned up');
|
||||
assert.ok(!existsSync(join(base, ".gsd 4")), '".gsd 4" directory was cleaned up');
|
||||
}
|
||||
|
||||
// ── Test: numbered variant symlinks are cleaned up ─────────────────
|
||||
|
|
@ -82,12 +82,12 @@ async function main(): Promise<void> {
|
|||
symlinkSync(staleTarget, join(base, ".gsd 3"), "junction");
|
||||
|
||||
const result = ensureGsdSymlink(base);
|
||||
assertEq(result, externalPath, "ensureGsdSymlink returns external path when variants exist");
|
||||
assertTrue(existsSync(join(base, ".gsd")), ".gsd exists");
|
||||
assertTrue(lstatSync(join(base, ".gsd")).isSymbolicLink(), ".gsd is a symlink");
|
||||
assert.deepStrictEqual(result, externalPath, "ensureGsdSymlink returns external path when variants exist");
|
||||
assert.ok(existsSync(join(base, ".gsd")), ".gsd exists");
|
||||
assert.ok(lstatSync(join(base, ".gsd")).isSymbolicLink(), ".gsd is a symlink");
|
||||
|
||||
assertTrue(!existsSync(join(base, ".gsd 2")), '".gsd 2" symlink variant was cleaned up');
|
||||
assertTrue(!existsSync(join(base, ".gsd 3")), '".gsd 3" symlink variant was cleaned up');
|
||||
assert.ok(!existsSync(join(base, ".gsd 2")), '".gsd 2" symlink variant was cleaned up');
|
||||
assert.ok(!existsSync(join(base, ".gsd 3")), '".gsd 3" symlink variant was cleaned up');
|
||||
}
|
||||
|
||||
// ── Test: real .gsd directory blocks symlink, but variants still cleaned ──
|
||||
|
|
@ -104,12 +104,12 @@ async function main(): Promise<void> {
|
|||
|
||||
const result = ensureGsdSymlink(base);
|
||||
// When .gsd is a real directory, ensureGsdSymlink preserves it
|
||||
assertEq(result, join(base, ".gsd"), "real .gsd directory preserved");
|
||||
assertTrue(lstatSync(join(base, ".gsd")).isDirectory(), ".gsd remains a directory");
|
||||
assert.deepStrictEqual(result, join(base, ".gsd"), "real .gsd directory preserved");
|
||||
assert.ok(lstatSync(join(base, ".gsd")).isDirectory(), ".gsd remains a directory");
|
||||
|
||||
// But the numbered variants should still be cleaned up
|
||||
assertTrue(!existsSync(join(base, ".gsd 2")), '".gsd 2" cleaned even when .gsd is a directory');
|
||||
assertTrue(!existsSync(join(base, ".gsd 3")), '".gsd 3" cleaned even when .gsd is a directory');
|
||||
assert.ok(!existsSync(join(base, ".gsd 2")), '".gsd 2" cleaned even when .gsd is a directory');
|
||||
assert.ok(!existsSync(join(base, ".gsd 3")), '".gsd 3" cleaned even when .gsd is a directory');
|
||||
}
|
||||
|
||||
// ── Test: only numeric-suffixed variants are removed ───────────────
|
||||
|
|
@ -127,10 +127,10 @@ async function main(): Promise<void> {
|
|||
|
||||
ensureGsdSymlink(base);
|
||||
|
||||
assertTrue(existsSync(join(base, ".gsd-backup")), ".gsd-backup is NOT removed");
|
||||
assertTrue(existsSync(join(base, ".gsd_old")), ".gsd_old is NOT removed");
|
||||
assertTrue(!existsSync(join(base, ".gsd 2")), '".gsd 2" removed');
|
||||
assertTrue(!existsSync(join(base, ".gsd 10")), '".gsd 10" removed');
|
||||
assert.ok(existsSync(join(base, ".gsd-backup")), ".gsd-backup is NOT removed");
|
||||
assert.ok(existsSync(join(base, ".gsd_old")), ".gsd_old is NOT removed");
|
||||
assert.ok(!existsSync(join(base, ".gsd 2")), '".gsd 2" removed');
|
||||
assert.ok(!existsSync(join(base, ".gsd 10")), '".gsd 10" removed');
|
||||
|
||||
// Cleanup non-variant dirs
|
||||
rmSync(join(base, ".gsd-backup"), { recursive: true, force: true });
|
||||
|
|
@ -141,11 +141,5 @@ async function main(): Promise<void> {
|
|||
delete process.env.GSD_STATE_DIR;
|
||||
try { rmSync(base, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
try { rmSync(stateDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
report();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ import {
|
|||
formatDecisionsForPrompt,
|
||||
formatRequirementsForPrompt,
|
||||
} from '../context-store.ts';
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { assertEq, assertTrue, assertMatch, assertNoMatch, report } = createTestContext();
|
||||
|
||||
// ─── Fixture Generators ────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -154,8 +154,8 @@ console.log('\n=== token-savings: plan-slice prompt ≥30% character savings ===
|
|||
openDatabase(':memory:');
|
||||
const result = migrateFromMarkdown(base);
|
||||
|
||||
assertTrue(result.decisions === DECISIONS_COUNT, `imported ${result.decisions} decisions, expected ${DECISIONS_COUNT}`);
|
||||
assertTrue(result.requirements === REQUIREMENTS_COUNT, `imported ${result.requirements} requirements, expected ${REQUIREMENTS_COUNT}`);
|
||||
assert.ok(result.decisions === DECISIONS_COUNT, `imported ${result.decisions} decisions, expected ${DECISIONS_COUNT}`);
|
||||
assert.ok(result.requirements === REQUIREMENTS_COUNT, `imported ${result.requirements} requirements, expected ${REQUIREMENTS_COUNT}`);
|
||||
|
||||
// ── DB-scoped content for plan-slice (M001 decisions + S01 requirements) ──
|
||||
const scopedDecisions = queryDecisions({ milestoneId: 'M001' });
|
||||
|
|
@ -174,31 +174,31 @@ console.log('\n=== token-savings: plan-slice prompt ≥30% character savings ===
|
|||
const savingsPercent = ((fullTotal - dbTotal) / fullTotal) * 100;
|
||||
console.log(` Plan-slice savings: ${savingsPercent.toFixed(1)}% (DB: ${dbTotal} chars, full: ${fullTotal} chars)`);
|
||||
|
||||
assertTrue(dbTotal > 0, 'DB-scoped content is non-empty');
|
||||
assertTrue(dbDecisionsContent.length > 0, 'DB-scoped decisions content is non-empty');
|
||||
assertTrue(dbRequirementsContent.length > 0, 'DB-scoped requirements content is non-empty');
|
||||
assertTrue(savingsPercent >= 30, `plan-slice savings ≥30% (actual: ${savingsPercent.toFixed(1)}%)`);
|
||||
assertTrue(dbTotal < fullTotal * 0.70, `DB total (${dbTotal}) < 70% of full total (${fullTotal})`);
|
||||
assert.ok(dbTotal > 0, 'DB-scoped content is non-empty');
|
||||
assert.ok(dbDecisionsContent.length > 0, 'DB-scoped decisions content is non-empty');
|
||||
assert.ok(dbRequirementsContent.length > 0, 'DB-scoped requirements content is non-empty');
|
||||
assert.ok(savingsPercent >= 30, `plan-slice savings ≥30% (actual: ${savingsPercent.toFixed(1)}%)`);
|
||||
assert.ok(dbTotal < fullTotal * 0.70, `DB total (${dbTotal}) < 70% of full total (${fullTotal})`);
|
||||
|
||||
// ── Verify correct scoping: decisions ──
|
||||
// M001 decisions: those with when_context containing 'M001' — indices 1,4,7,10,13,16,19,22
|
||||
// (24 decisions round-robin across M001/M002/M003 → 8 for M001)
|
||||
assertTrue(scopedDecisions.length === 8, `M001 decisions: expected 8, got ${scopedDecisions.length}`);
|
||||
assert.ok(scopedDecisions.length === 8, `M001 decisions: expected 8, got ${scopedDecisions.length}`);
|
||||
for (const d of scopedDecisions) {
|
||||
assertTrue(d.when_context.includes('M001'), `decision ${d.id} should have M001 in when_context, got "${d.when_context}"`);
|
||||
assert.ok(d.when_context.includes('M001'), `decision ${d.id} should have M001 in when_context, got "${d.when_context}"`);
|
||||
}
|
||||
|
||||
// Verify NO decisions from other milestones leak in
|
||||
for (const d of scopedDecisions) {
|
||||
assertNoMatch(d.when_context, /M002|M003/, `decision ${d.id} should not contain M002 or M003`);
|
||||
assert.doesNotMatch(d.when_context, /M002|M003/, `decision ${d.id} should not contain M002 or M003`);
|
||||
}
|
||||
|
||||
// ── Verify correct scoping: requirements ──
|
||||
// S01 requirements: those assigned to S01 as primary_owner
|
||||
// S01 appears in positions 1,6,11,16,21 (5 assignments cycling, 21 reqs → indices 0,5,10,15,20)
|
||||
assertTrue(scopedRequirements.length > 0, 'S01 requirements non-empty');
|
||||
assert.ok(scopedRequirements.length > 0, 'S01 requirements non-empty');
|
||||
for (const r of scopedRequirements) {
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
r.primary_owner.includes('S01') || r.supporting_slices.includes('S01'),
|
||||
`requirement ${r.id} should be owned by or support S01`,
|
||||
);
|
||||
|
|
@ -206,13 +206,13 @@ console.log('\n=== token-savings: plan-slice prompt ≥30% character savings ===
|
|||
|
||||
// Verify specific expected IDs are present
|
||||
const scopedDecisionIds = scopedDecisions.map(d => d.id);
|
||||
assertTrue(scopedDecisionIds.includes('D001'), 'M001 scoped decisions includes D001');
|
||||
assertTrue(scopedDecisionIds.includes('D004'), 'M001 scoped decisions includes D004');
|
||||
assertTrue(!scopedDecisionIds.includes('D002'), 'M001 scoped decisions excludes D002 (M002)');
|
||||
assertTrue(!scopedDecisionIds.includes('D003'), 'M001 scoped decisions excludes D003 (M003)');
|
||||
assert.ok(scopedDecisionIds.includes('D001'), 'M001 scoped decisions includes D001');
|
||||
assert.ok(scopedDecisionIds.includes('D004'), 'M001 scoped decisions includes D004');
|
||||
assert.ok(!scopedDecisionIds.includes('D002'), 'M001 scoped decisions excludes D002 (M002)');
|
||||
assert.ok(!scopedDecisionIds.includes('D003'), 'M001 scoped decisions excludes D003 (M003)');
|
||||
|
||||
const scopedReqIds = scopedRequirements.map(r => r.id);
|
||||
assertTrue(scopedReqIds.includes('R001'), 'S01 scoped requirements includes R001');
|
||||
assert.ok(scopedReqIds.includes('R001'), 'S01 scoped requirements includes R001');
|
||||
|
||||
closeDatabase();
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
|
|
@ -246,9 +246,9 @@ console.log('\n=== token-savings: research-milestone prompt shows meaningful sav
|
|||
const decisionsSavings = ((fullDecisionsContent.length - dbDecisionsContent.length) / fullDecisionsContent.length) * 100;
|
||||
console.log(` Decisions savings (M001): ${decisionsSavings.toFixed(1)}% (DB: ${dbDecisionsContent.length}, full: ${fullDecisionsContent.length})`);
|
||||
|
||||
assertTrue(decisionsSavings > 0, `decisions savings > 0% (actual: ${decisionsSavings.toFixed(1)}%)`);
|
||||
assertTrue(scopedDecisions.length === 8, `M001 decisions: 8 of 24 total`);
|
||||
assertTrue(allRequirements.length === REQUIREMENTS_COUNT, `all requirements returned: ${allRequirements.length}`);
|
||||
assert.ok(decisionsSavings > 0, `decisions savings > 0% (actual: ${decisionsSavings.toFixed(1)}%)`);
|
||||
assert.ok(scopedDecisions.length === 8, `M001 decisions: 8 of 24 total`);
|
||||
assert.ok(allRequirements.length === REQUIREMENTS_COUNT, `all requirements returned: ${allRequirements.length}`);
|
||||
|
||||
// Requirements: DB-formatted vs raw markdown — formatted output may differ in size
|
||||
// but decisions savings alone should make the composite meaningful
|
||||
|
|
@ -259,8 +259,8 @@ console.log('\n=== token-savings: research-milestone prompt shows meaningful sav
|
|||
|
||||
// With 8/24 decisions = 66% reduction in decisions, even if requirements are equal,
|
||||
// the composite should show meaningful savings
|
||||
assertTrue(compositeSavings > 10, `research-milestone shows >10% composite savings (actual: ${compositeSavings.toFixed(1)}%)`);
|
||||
assertTrue(decisionsSavings >= 30, `decisions-only savings ≥30% for M001 scope (actual: ${decisionsSavings.toFixed(1)}%)`);
|
||||
assert.ok(compositeSavings > 10, `research-milestone shows >10% composite savings (actual: ${compositeSavings.toFixed(1)}%)`);
|
||||
assert.ok(decisionsSavings >= 30, `decisions-only savings ≥30% for M001 scope (actual: ${decisionsSavings.toFixed(1)}%)`);
|
||||
|
||||
closeDatabase();
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
|
|
@ -283,17 +283,17 @@ console.log('\n=== token-savings: quality — correct scoping, no cross-contamin
|
|||
|
||||
// ── M002-scoped decisions should not contain M001/M003 items ──
|
||||
const m002Decisions = queryDecisions({ milestoneId: 'M002' });
|
||||
assertTrue(m002Decisions.length === 8, `M002 decisions: expected 8, got ${m002Decisions.length}`);
|
||||
assert.ok(m002Decisions.length === 8, `M002 decisions: expected 8, got ${m002Decisions.length}`);
|
||||
for (const d of m002Decisions) {
|
||||
assertTrue(d.when_context.includes('M002'), `M002 decision ${d.id} has M002 in when_context`);
|
||||
assertNoMatch(d.when_context, /M001|M003/, `M002 decision ${d.id} should not contain M001/M003`);
|
||||
assert.ok(d.when_context.includes('M002'), `M002 decision ${d.id} has M002 in when_context`);
|
||||
assert.doesNotMatch(d.when_context, /M001|M003/, `M002 decision ${d.id} should not contain M001/M003`);
|
||||
}
|
||||
|
||||
// ── S04-scoped requirements should only include S04-related items ──
|
||||
const s04Requirements = queryRequirements({ sliceId: 'S04' });
|
||||
assertTrue(s04Requirements.length > 0, 'S04 requirements non-empty');
|
||||
assert.ok(s04Requirements.length > 0, 'S04 requirements non-empty');
|
||||
for (const r of s04Requirements) {
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
r.primary_owner.includes('S04') || r.supporting_slices.includes('S04'),
|
||||
`S04 requirement ${r.id} should be owned by or support S04`,
|
||||
);
|
||||
|
|
@ -301,13 +301,13 @@ console.log('\n=== token-savings: quality — correct scoping, no cross-contamin
|
|||
|
||||
// ── Verify formatted output is well-formed and non-empty ──
|
||||
const formattedDecisions = formatDecisionsForPrompt(m002Decisions);
|
||||
assertTrue(formattedDecisions.length > 0, 'formatted M002 decisions is non-empty');
|
||||
assertMatch(formattedDecisions, /\| D/, 'formatted decisions contains decision rows');
|
||||
assertMatch(formattedDecisions, /\| # \|/, 'formatted decisions has table header');
|
||||
assert.ok(formattedDecisions.length > 0, 'formatted M002 decisions is non-empty');
|
||||
assert.match(formattedDecisions, /\| D/, 'formatted decisions contains decision rows');
|
||||
assert.match(formattedDecisions, /\| # \|/, 'formatted decisions has table header');
|
||||
|
||||
const formattedReqs = formatRequirementsForPrompt(s04Requirements);
|
||||
assertTrue(formattedReqs.length > 0, 'formatted S04 requirements is non-empty');
|
||||
assertMatch(formattedReqs, /### R\d+/, 'formatted requirements has requirement headings');
|
||||
assert.ok(formattedReqs.length > 0, 'formatted S04 requirements is non-empty');
|
||||
assert.match(formattedReqs, /### R\d+/, 'formatted requirements has requirement headings');
|
||||
|
||||
// ── Verify all milestones have decisions and counts add up ──
|
||||
const m001Count = queryDecisions({ milestoneId: 'M001' }).length;
|
||||
|
|
@ -315,11 +315,11 @@ console.log('\n=== token-savings: quality — correct scoping, no cross-contamin
|
|||
const m003Count = queryDecisions({ milestoneId: 'M003' }).length;
|
||||
const allCount = queryDecisions().length;
|
||||
|
||||
assertTrue(m001Count === 8, `M001: 8 decisions (got ${m001Count})`);
|
||||
assertTrue(m002Count === 8, `M002: 8 decisions (got ${m002Count})`);
|
||||
assertTrue(m003Count === 8, `M003: 8 decisions (got ${m003Count})`);
|
||||
assertTrue(allCount === DECISIONS_COUNT, `all: ${DECISIONS_COUNT} decisions (got ${allCount})`);
|
||||
assertTrue(m001Count + m002Count + m003Count === allCount, 'milestone decision counts sum to total');
|
||||
assert.ok(m001Count === 8, `M001: 8 decisions (got ${m001Count})`);
|
||||
assert.ok(m002Count === 8, `M002: 8 decisions (got ${m002Count})`);
|
||||
assert.ok(m003Count === 8, `M003: 8 decisions (got ${m003Count})`);
|
||||
assert.ok(allCount === DECISIONS_COUNT, `all: ${DECISIONS_COUNT} decisions (got ${allCount})`);
|
||||
assert.ok(m001Count + m002Count + m003Count === allCount, 'milestone decision counts sum to total');
|
||||
|
||||
// ── Verify all slices have requirements ──
|
||||
const s01Reqs = queryRequirements({ sliceId: 'S01' });
|
||||
|
|
@ -328,11 +328,11 @@ console.log('\n=== token-savings: quality — correct scoping, no cross-contamin
|
|||
const s04Reqs = queryRequirements({ sliceId: 'S04' });
|
||||
const s05Reqs = queryRequirements({ sliceId: 'S05' });
|
||||
|
||||
assertTrue(s01Reqs.length > 0, 'S01 has requirements');
|
||||
assertTrue(s02Reqs.length > 0, 'S02 has requirements');
|
||||
assertTrue(s03Reqs.length > 0, 'S03 has requirements');
|
||||
assertTrue(s04Reqs.length > 0, 'S04 has requirements');
|
||||
assertTrue(s05Reqs.length > 0, 'S05 has requirements');
|
||||
assert.ok(s01Reqs.length > 0, 'S01 has requirements');
|
||||
assert.ok(s02Reqs.length > 0, 'S02 has requirements');
|
||||
assert.ok(s03Reqs.length > 0, 'S03 has requirements');
|
||||
assert.ok(s04Reqs.length > 0, 'S04 has requirements');
|
||||
assert.ok(s05Reqs.length > 0, 'S05 has requirements');
|
||||
|
||||
closeDatabase();
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
|
|
@ -345,22 +345,20 @@ console.log('\n=== token-savings: quality — correct scoping, no cross-contamin
|
|||
console.log('\n=== token-savings: fixture data realism ===');
|
||||
{
|
||||
// Verify fixture generators produce sufficient volume
|
||||
assertTrue(DECISIONS_COUNT >= 20, `decisions count ≥ 20 (actual: ${DECISIONS_COUNT})`);
|
||||
assertTrue(REQUIREMENTS_COUNT >= 20, `requirements count ≥ 20 (actual: ${REQUIREMENTS_COUNT})`);
|
||||
assertTrue(MILESTONES.length >= 3, `milestones ≥ 3 (actual: ${MILESTONES.length})`);
|
||||
assertTrue(SLICE_ASSIGNMENTS.length >= 5, `slice assignments ≥ 5 (actual: ${SLICE_ASSIGNMENTS.length})`);
|
||||
assert.ok(DECISIONS_COUNT >= 20, `decisions count ≥ 20 (actual: ${DECISIONS_COUNT})`);
|
||||
assert.ok(REQUIREMENTS_COUNT >= 20, `requirements count ≥ 20 (actual: ${REQUIREMENTS_COUNT})`);
|
||||
assert.ok(MILESTONES.length >= 3, `milestones ≥ 3 (actual: ${MILESTONES.length})`);
|
||||
assert.ok(SLICE_ASSIGNMENTS.length >= 5, `slice assignments ≥ 5 (actual: ${SLICE_ASSIGNMENTS.length})`);
|
||||
|
||||
// Verify markdown content is substantial
|
||||
assertTrue(decisionsMarkdown.length > 1000, `decisions markdown > 1000 chars (actual: ${decisionsMarkdown.length})`);
|
||||
assertTrue(requirementsMarkdown.length > 1000, `requirements markdown > 1000 chars (actual: ${requirementsMarkdown.length})`);
|
||||
assert.ok(decisionsMarkdown.length > 1000, `decisions markdown > 1000 chars (actual: ${decisionsMarkdown.length})`);
|
||||
assert.ok(requirementsMarkdown.length > 1000, `requirements markdown > 1000 chars (actual: ${requirementsMarkdown.length})`);
|
||||
|
||||
// Verify content structure
|
||||
assertMatch(decisionsMarkdown, /\| D001 \|/, 'decisions markdown has D001');
|
||||
assertMatch(decisionsMarkdown, /\| D024 \|/, 'decisions markdown has D024');
|
||||
assertMatch(requirementsMarkdown, /### R001/, 'requirements markdown has R001');
|
||||
assertMatch(requirementsMarkdown, /### R021/, 'requirements markdown has R021');
|
||||
assert.match(decisionsMarkdown, /\| D001 \|/, 'decisions markdown has D001');
|
||||
assert.match(decisionsMarkdown, /\| D024 \|/, 'decisions markdown has D024');
|
||||
assert.match(requirementsMarkdown, /### R001/, 'requirements markdown has R001');
|
||||
assert.match(requirementsMarkdown, /### R021/, 'requirements markdown has R021');
|
||||
}
|
||||
|
||||
// ─── Report ────────────────────────────────────────────────────────────────
|
||||
|
||||
report();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
// Verifies that identical consecutive tool calls are detected and blocked
|
||||
// after exceeding the threshold, and that the guard resets properly.
|
||||
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
checkToolCallLoop,
|
||||
resetToolCallLoopGuard,
|
||||
|
|
@ -11,7 +12,6 @@ import {
|
|||
getToolCallLoopCount,
|
||||
} from '../bootstrap/tool-call-loop-guard.ts';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Allows first N calls, blocks after threshold
|
||||
|
|
@ -25,15 +25,15 @@ console.log('\n── Loop guard: blocks after threshold ──');
|
|||
// First 4 identical calls should be allowed (threshold is 4)
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const result = checkToolCallLoop('web_search', { query: 'same query' });
|
||||
assertTrue(result.block === false, `Call ${i} should be allowed`);
|
||||
assertEq(result.count, i, `Count should be ${i} after call ${i}`);
|
||||
assert.ok(result.block === false, `Call ${i} should be allowed`);
|
||||
assert.deepStrictEqual(result.count, i, `Count should be ${i} after call ${i}`);
|
||||
}
|
||||
|
||||
// 5th identical call should be blocked
|
||||
const blocked = checkToolCallLoop('web_search', { query: 'same query' });
|
||||
assertTrue(blocked.block === true, '5th identical call should be blocked');
|
||||
assertTrue(blocked.reason!.includes('web_search'), 'Reason should mention tool name');
|
||||
assertTrue(blocked.reason!.includes('5'), 'Reason should mention count');
|
||||
assert.ok(blocked.block === true, '5th identical call should be blocked');
|
||||
assert.ok(blocked.reason!.includes('web_search'), 'Reason should mention tool name');
|
||||
assert.ok(blocked.reason!.includes('5'), 'Reason should mention count');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -48,17 +48,17 @@ console.log('\n── Loop guard: different calls reset streak ──');
|
|||
checkToolCallLoop('web_search', { query: 'query A' });
|
||||
checkToolCallLoop('web_search', { query: 'query A' });
|
||||
checkToolCallLoop('web_search', { query: 'query A' });
|
||||
assertEq(getToolCallLoopCount(), 3, 'Count should be 3 after 3 identical calls');
|
||||
assert.deepStrictEqual(getToolCallLoopCount(), 3, 'Count should be 3 after 3 identical calls');
|
||||
|
||||
// A different call resets the streak
|
||||
const different = checkToolCallLoop('bash', { command: 'ls' });
|
||||
assertTrue(different.block === false, 'Different tool call should be allowed');
|
||||
assertEq(getToolCallLoopCount(), 1, 'Count should reset to 1 after different call');
|
||||
assert.ok(different.block === false, 'Different tool call should be allowed');
|
||||
assert.deepStrictEqual(getToolCallLoopCount(), 1, 'Count should reset to 1 after different call');
|
||||
|
||||
// Same tool but different args also resets
|
||||
checkToolCallLoop('web_search', { query: 'query A' });
|
||||
checkToolCallLoop('web_search', { query: 'query B' }); // different args
|
||||
assertEq(getToolCallLoopCount(), 1, 'Different args should reset count');
|
||||
assert.deepStrictEqual(getToolCallLoopCount(), 1, 'Different args should reset count');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -72,15 +72,15 @@ console.log('\n── Loop guard: reset clears state ──');
|
|||
checkToolCallLoop('web_search', { query: 'q' });
|
||||
checkToolCallLoop('web_search', { query: 'q' });
|
||||
checkToolCallLoop('web_search', { query: 'q' });
|
||||
assertEq(getToolCallLoopCount(), 3, 'Count should be 3 before reset');
|
||||
assert.deepStrictEqual(getToolCallLoopCount(), 3, 'Count should be 3 before reset');
|
||||
|
||||
resetToolCallLoopGuard();
|
||||
assertEq(getToolCallLoopCount(), 0, 'Count should be 0 after reset');
|
||||
assert.deepStrictEqual(getToolCallLoopCount(), 0, 'Count should be 0 after reset');
|
||||
|
||||
// After reset, the same call starts fresh
|
||||
const result = checkToolCallLoop('web_search', { query: 'q' });
|
||||
assertTrue(result.block === false, 'Call after reset should be allowed');
|
||||
assertEq(getToolCallLoopCount(), 1, 'Count should be 1 after first call post-reset');
|
||||
assert.ok(result.block === false, 'Call after reset should be allowed');
|
||||
assert.deepStrictEqual(getToolCallLoopCount(), 1, 'Count should be 1 after first call post-reset');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -94,13 +94,13 @@ console.log('\n── Loop guard: disable allows everything ──');
|
|||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const result = checkToolCallLoop('web_search', { query: 'same' });
|
||||
assertTrue(result.block === false, `Call ${i + 1} should be allowed when disabled`);
|
||||
assert.ok(result.block === false, `Call ${i + 1} should be allowed when disabled`);
|
||||
}
|
||||
|
||||
// Re-enable via reset
|
||||
resetToolCallLoopGuard();
|
||||
checkToolCallLoop('web_search', { query: 'q' });
|
||||
assertEq(getToolCallLoopCount(), 1, 'Guard should be active again after reset');
|
||||
assert.deepStrictEqual(getToolCallLoopCount(), 1, 'Guard should be active again after reset');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -114,8 +114,8 @@ console.log('\n── Loop guard: arg order is normalized ──');
|
|||
|
||||
checkToolCallLoop('web_search', { query: 'test', limit: 5 });
|
||||
const result = checkToolCallLoop('web_search', { limit: 5, query: 'test' }); // same args, different order
|
||||
assertTrue(result.block === false, 'Same args in different order should count as consecutive');
|
||||
assertEq(getToolCallLoopCount(), 2, 'Should detect as same call regardless of key order');
|
||||
assert.ok(result.block === false, 'Same args in different order should count as consecutive');
|
||||
assert.deepStrictEqual(getToolCallLoopCount(), 2, 'Should detect as same call regardless of key order');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -132,8 +132,8 @@ console.log('\n── Loop guard: nested args are not stripped ──');
|
|||
const result = checkToolCallLoop('ask_user_questions', {
|
||||
questions: [{ id: `q${i}`, question: `Question ${i}?` }],
|
||||
});
|
||||
assertTrue(result.block === false, `Nested call ${i} with unique content should be allowed`);
|
||||
assertEq(getToolCallLoopCount(), 1, `Each unique nested call should reset count to 1`);
|
||||
assert.ok(result.block === false, `Nested call ${i} with unique content should be allowed`);
|
||||
assert.deepStrictEqual(getToolCallLoopCount(), 1, `Each unique nested call should reset count to 1`);
|
||||
}
|
||||
|
||||
// Truly identical nested calls should still be detected
|
||||
|
|
@ -146,7 +146,7 @@ console.log('\n── Loop guard: nested args are not stripped ──');
|
|||
const blocked = checkToolCallLoop('ask_user_questions', {
|
||||
questions: [{ id: 'same', question: 'Same?' }],
|
||||
});
|
||||
assertTrue(blocked.block === true, 'Identical nested calls should still be blocked');
|
||||
assert.ok(blocked.block === true, 'Identical nested calls should still be blocked');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -160,9 +160,7 @@ console.log('\n── Loop guard: nested key order is normalized ──');
|
|||
|
||||
checkToolCallLoop('tool', { outer: { b: 2, a: 1 } });
|
||||
const result = checkToolCallLoop('tool', { outer: { a: 1, b: 2 } });
|
||||
assertEq(getToolCallLoopCount(), 2, 'Same nested args in different key order should match');
|
||||
assert.deepStrictEqual(getToolCallLoopCount(), 2, 'Same nested args in different key order should match');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
report();
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@
|
|||
// AND under a backward-compatible alias name.
|
||||
// The alias must share the exact same execute function reference as the canonical tool.
|
||||
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerDbTools } from '../bootstrap/db-tools.ts';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
// ─── Mock PI ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ console.log('\n── Tool naming: registration count ──');
|
|||
const pi = makeMockPi();
|
||||
registerDbTools(pi);
|
||||
|
||||
assertEq(pi.tools.length, 24, 'Should register exactly 24 tools (12 canonical + 12 aliases)');
|
||||
assert.deepStrictEqual(pi.tools.length, 24, 'Should register exactly 24 tools (12 canonical + 12 aliases)');
|
||||
|
||||
// ─── Both names exist for each pair ──────────────────────────────────────────
|
||||
|
||||
|
|
@ -53,8 +53,8 @@ for (const { canonical, alias } of RENAME_MAP) {
|
|||
const canonicalTool = pi.tools.find((t: any) => t.name === canonical);
|
||||
const aliasTool = pi.tools.find((t: any) => t.name === alias);
|
||||
|
||||
assertTrue(canonicalTool !== undefined, `Canonical tool "${canonical}" should be registered`);
|
||||
assertTrue(aliasTool !== undefined, `Alias tool "${alias}" should be registered`);
|
||||
assert.ok(canonicalTool !== undefined, `Canonical tool "${canonical}" should be registered`);
|
||||
assert.ok(aliasTool !== undefined, `Alias tool "${alias}" should be registered`);
|
||||
}
|
||||
|
||||
// ─── Execute function identity ───────────────────────────────────────────────
|
||||
|
|
@ -66,7 +66,7 @@ for (const { canonical, alias } of RENAME_MAP) {
|
|||
const aliasTool = pi.tools.find((t: any) => t.name === alias);
|
||||
|
||||
if (canonicalTool && aliasTool) {
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
canonicalTool.execute === aliasTool.execute,
|
||||
`"${canonical}" and "${alias}" should share the same execute function reference`,
|
||||
);
|
||||
|
|
@ -81,7 +81,7 @@ for (const { canonical, alias } of RENAME_MAP) {
|
|||
const aliasTool = pi.tools.find((t: any) => t.name === alias);
|
||||
|
||||
if (aliasTool) {
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
aliasTool.description.includes(`alias for ${canonical}`),
|
||||
`Alias "${alias}" description should include "alias for ${canonical}"`,
|
||||
);
|
||||
|
|
@ -97,7 +97,7 @@ for (const { canonical } of RENAME_MAP) {
|
|||
|
||||
if (canonicalTool) {
|
||||
const guidelinesText = canonicalTool.promptGuidelines.join(' ');
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
guidelinesText.includes(canonical),
|
||||
`Canonical tool "${canonical}" promptGuidelines should reference its own name`,
|
||||
);
|
||||
|
|
@ -113,7 +113,7 @@ for (const { canonical, alias } of RENAME_MAP) {
|
|||
|
||||
if (aliasTool) {
|
||||
const guidelinesText = aliasTool.promptGuidelines.join(' ');
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
guidelinesText.includes(`Alias for ${canonical}`),
|
||||
`Alias "${alias}" promptGuidelines should say "Alias for ${canonical}"`,
|
||||
);
|
||||
|
|
@ -121,5 +121,3 @@ for (const { canonical, alias } of RENAME_MAP) {
|
|||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
report();
|
||||
|
|
|
|||
|
|
@ -22,72 +22,72 @@ import {
|
|||
|
||||
import { renderPreferencesForSystemPrompt } from '../preferences.ts';
|
||||
import type { GSDPreferences } from '../preferences.ts';
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
|
||||
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
describe('unique-milestone-ids', async () => {
|
||||
console.log('unique-milestone-ids tests');
|
||||
|
||||
// (a) MILESTONE_ID_RE
|
||||
{
|
||||
console.log(' (a) MILESTONE_ID_RE');
|
||||
// Should match
|
||||
assertTrue(MILESTONE_ID_RE.test('M001'), 'matches M001');
|
||||
assertTrue(MILESTONE_ID_RE.test('M999'), 'matches M999');
|
||||
assertTrue(MILESTONE_ID_RE.test('M001-abc123'), 'matches M001-abc123');
|
||||
assertTrue(MILESTONE_ID_RE.test('M042-z9a8b7'), 'matches M042-z9a8b7');
|
||||
assert.ok(MILESTONE_ID_RE.test('M001'), 'matches M001');
|
||||
assert.ok(MILESTONE_ID_RE.test('M999'), 'matches M999');
|
||||
assert.ok(MILESTONE_ID_RE.test('M001-abc123'), 'matches M001-abc123');
|
||||
assert.ok(MILESTONE_ID_RE.test('M042-z9a8b7'), 'matches M042-z9a8b7');
|
||||
|
||||
// Should reject
|
||||
assertTrue(!MILESTONE_ID_RE.test('M1'), 'rejects M1 (too few digits)');
|
||||
assertTrue(!MILESTONE_ID_RE.test('M0001'), 'rejects M0001 (too many digits)');
|
||||
assertTrue(!MILESTONE_ID_RE.test('M001-ABCDEF'), 'rejects M001-ABCDEF (uppercase prefix)');
|
||||
assertTrue(!MILESTONE_ID_RE.test('M001-short'), 'rejects M001-short (5-char prefix)');
|
||||
assertTrue(!MILESTONE_ID_RE.test('M001-toolong1'), 'rejects M001-toolong1 (>6-char prefix)');
|
||||
assertTrue(!MILESTONE_ID_RE.test('IM001'), 'rejects IM001 (prefix before M)');
|
||||
assertTrue(!MILESTONE_ID_RE.test(''), 'rejects empty string');
|
||||
assertTrue(!MILESTONE_ID_RE.test('M001extra'), 'rejects M001extra (trailing chars)');
|
||||
assertTrue(!MILESTONE_ID_RE.test('notes'), 'rejects non-milestone string');
|
||||
assert.ok(!MILESTONE_ID_RE.test('M1'), 'rejects M1 (too few digits)');
|
||||
assert.ok(!MILESTONE_ID_RE.test('M0001'), 'rejects M0001 (too many digits)');
|
||||
assert.ok(!MILESTONE_ID_RE.test('M001-ABCDEF'), 'rejects M001-ABCDEF (uppercase prefix)');
|
||||
assert.ok(!MILESTONE_ID_RE.test('M001-short'), 'rejects M001-short (5-char prefix)');
|
||||
assert.ok(!MILESTONE_ID_RE.test('M001-toolong1'), 'rejects M001-toolong1 (>6-char prefix)');
|
||||
assert.ok(!MILESTONE_ID_RE.test('IM001'), 'rejects IM001 (prefix before M)');
|
||||
assert.ok(!MILESTONE_ID_RE.test(''), 'rejects empty string');
|
||||
assert.ok(!MILESTONE_ID_RE.test('M001extra'), 'rejects M001extra (trailing chars)');
|
||||
assert.ok(!MILESTONE_ID_RE.test('notes'), 'rejects non-milestone string');
|
||||
}
|
||||
|
||||
// (b) extractMilestoneSeq
|
||||
{
|
||||
console.log(' (b) extractMilestoneSeq');
|
||||
// Old format
|
||||
assertEq(extractMilestoneSeq('M001'), 1, 'M001 → 1');
|
||||
assertEq(extractMilestoneSeq('M042'), 42, 'M042 → 42');
|
||||
assertEq(extractMilestoneSeq('M999'), 999, 'M999 → 999');
|
||||
assert.deepStrictEqual(extractMilestoneSeq('M001'), 1, 'M001 → 1');
|
||||
assert.deepStrictEqual(extractMilestoneSeq('M042'), 42, 'M042 → 42');
|
||||
assert.deepStrictEqual(extractMilestoneSeq('M999'), 999, 'M999 → 999');
|
||||
|
||||
// Unique format
|
||||
assertEq(extractMilestoneSeq('M001-abc123'), 1, 'M001-abc123 → 1');
|
||||
assertEq(extractMilestoneSeq('M042-z9a8b7'), 42, 'M042-z9a8b7 → 42');
|
||||
assert.deepStrictEqual(extractMilestoneSeq('M001-abc123'), 1, 'M001-abc123 → 1');
|
||||
assert.deepStrictEqual(extractMilestoneSeq('M042-z9a8b7'), 42, 'M042-z9a8b7 → 42');
|
||||
|
||||
// Invalid → 0
|
||||
assertEq(extractMilestoneSeq(''), 0, 'empty → 0');
|
||||
assertEq(extractMilestoneSeq('notes'), 0, 'notes → 0');
|
||||
assertEq(extractMilestoneSeq('M1'), 0, 'M1 → 0');
|
||||
assertEq(extractMilestoneSeq('.DS_Store'), 0, '.DS_Store → 0');
|
||||
assertEq(extractMilestoneSeq('M-ABC-001'), 0, 'M-ABC-001 (old format) → 0');
|
||||
assert.deepStrictEqual(extractMilestoneSeq(''), 0, 'empty → 0');
|
||||
assert.deepStrictEqual(extractMilestoneSeq('notes'), 0, 'notes → 0');
|
||||
assert.deepStrictEqual(extractMilestoneSeq('M1'), 0, 'M1 → 0');
|
||||
assert.deepStrictEqual(extractMilestoneSeq('.DS_Store'), 0, '.DS_Store → 0');
|
||||
assert.deepStrictEqual(extractMilestoneSeq('M-ABC-001'), 0, 'M-ABC-001 (old format) → 0');
|
||||
}
|
||||
|
||||
// (c) parseMilestoneId
|
||||
{
|
||||
console.log(' (c) parseMilestoneId');
|
||||
// Old format — no suffix
|
||||
assertEq(parseMilestoneId('M001'), { num: 1 }, 'M001 → { num: 1 }');
|
||||
assertEq(parseMilestoneId('M042'), { num: 42 }, 'M042 → { num: 42 }');
|
||||
assert.deepStrictEqual(parseMilestoneId('M001'), { num: 1 }, 'M001 → { num: 1 }');
|
||||
assert.deepStrictEqual(parseMilestoneId('M042'), { num: 42 }, 'M042 → { num: 42 }');
|
||||
|
||||
// Unique format — with suffix
|
||||
assertEq(parseMilestoneId('M001-abc123'), { suffix: 'abc123', num: 1 }, 'M001-abc123 → { suffix, num }');
|
||||
assertEq(parseMilestoneId('M042-z9a8b7'), { suffix: 'z9a8b7', num: 42 }, 'M042-z9a8b7 → { suffix, num }');
|
||||
assert.deepStrictEqual(parseMilestoneId('M001-abc123'), { suffix: 'abc123', num: 1 }, 'M001-abc123 → { suffix, num }');
|
||||
assert.deepStrictEqual(parseMilestoneId('M042-z9a8b7'), { suffix: 'z9a8b7', num: 42 }, 'M042-z9a8b7 → { suffix, num }');
|
||||
|
||||
// Invalid → { num: 0 }
|
||||
assertEq(parseMilestoneId(''), { num: 0 }, 'empty → { num: 0 }');
|
||||
assertEq(parseMilestoneId('notes'), { num: 0 }, 'notes → { num: 0 }');
|
||||
assertEq(parseMilestoneId('M001-ABCDEF'), { num: 0 }, 'uppercase suffix → { num: 0 }');
|
||||
assertEq(parseMilestoneId('M1'), { num: 0 }, 'M1 → { num: 0 }');
|
||||
assert.deepStrictEqual(parseMilestoneId(''), { num: 0 }, 'empty → { num: 0 }');
|
||||
assert.deepStrictEqual(parseMilestoneId('notes'), { num: 0 }, 'notes → { num: 0 }');
|
||||
assert.deepStrictEqual(parseMilestoneId('M001-ABCDEF'), { num: 0 }, 'uppercase suffix → { num: 0 }');
|
||||
assert.deepStrictEqual(parseMilestoneId('M1'), { num: 0 }, 'M1 → { num: 0 }');
|
||||
}
|
||||
|
||||
// (d) milestoneIdSort
|
||||
|
|
@ -95,81 +95,81 @@ async function main(): Promise<void> {
|
|||
console.log(' (d) milestoneIdSort');
|
||||
const mixed = ['M003-abc123', 'M001', 'M002-z9a8b7'];
|
||||
const sorted = [...mixed].sort(milestoneIdSort);
|
||||
assertEq(sorted, ['M001', 'M002-z9a8b7', 'M003-abc123'], 'sorts mixed IDs by sequence number');
|
||||
assert.deepStrictEqual(sorted, ['M001', 'M002-z9a8b7', 'M003-abc123'], 'sorts mixed IDs by sequence number');
|
||||
|
||||
// All old format
|
||||
const oldOnly = ['M003', 'M001', 'M002'];
|
||||
assertEq([...oldOnly].sort(milestoneIdSort), ['M001', 'M002', 'M003'], 'sorts old-format IDs');
|
||||
assert.deepStrictEqual([...oldOnly].sort(milestoneIdSort), ['M001', 'M002', 'M003'], 'sorts old-format IDs');
|
||||
|
||||
// Invalid entries sort to front (seq 0)
|
||||
const withInvalid = ['M002', 'notes', 'M001'];
|
||||
assertEq([...withInvalid].sort(milestoneIdSort), ['notes', 'M001', 'M002'], 'invalid entries (seq 0) sort first');
|
||||
assert.deepStrictEqual([...withInvalid].sort(milestoneIdSort), ['notes', 'M001', 'M002'], 'invalid entries (seq 0) sort first');
|
||||
}
|
||||
|
||||
// (e) generateMilestoneSuffix
|
||||
{
|
||||
console.log(' (e) generateMilestoneSuffix');
|
||||
const suffix1 = generateMilestoneSuffix();
|
||||
assertEq(suffix1.length, 6, 'suffix length is 6');
|
||||
assertMatch(suffix1, /^[a-z0-9]{6}$/, 'suffix matches [a-z0-9]{6}');
|
||||
assert.deepStrictEqual(suffix1.length, 6, 'suffix length is 6');
|
||||
assert.match(suffix1, /^[a-z0-9]{6}$/, 'suffix matches [a-z0-9]{6}');
|
||||
|
||||
const suffix2 = generateMilestoneSuffix();
|
||||
assertEq(suffix2.length, 6, 'second suffix length is 6');
|
||||
assertMatch(suffix2, /^[a-z0-9]{6}$/, 'second suffix matches [a-z0-9]{6}');
|
||||
assert.deepStrictEqual(suffix2.length, 6, 'second suffix length is 6');
|
||||
assert.match(suffix2, /^[a-z0-9]{6}$/, 'second suffix matches [a-z0-9]{6}');
|
||||
|
||||
// Two calls should produce different results (36^6 = ~2.2B possibilities)
|
||||
assertTrue(suffix1 !== suffix2, 'two calls produce different suffixes');
|
||||
assert.ok(suffix1 !== suffix2, 'two calls produce different suffixes');
|
||||
}
|
||||
|
||||
// (f) nextMilestoneId
|
||||
{
|
||||
console.log(' (f) nextMilestoneId');
|
||||
// uniqueEnabled=false (default) → old format
|
||||
assertEq(nextMilestoneId([]), 'M001', 'empty + uniqueEnabled=false → M001');
|
||||
assertEq(nextMilestoneId(['M001', 'M002']), 'M003', 'sequential + uniqueEnabled=false → M003');
|
||||
assertEq(nextMilestoneId(['M001', 'M002'], false), 'M003', 'explicit false → M003');
|
||||
assert.deepStrictEqual(nextMilestoneId([]), 'M001', 'empty + uniqueEnabled=false → M001');
|
||||
assert.deepStrictEqual(nextMilestoneId(['M001', 'M002']), 'M003', 'sequential + uniqueEnabled=false → M003');
|
||||
assert.deepStrictEqual(nextMilestoneId(['M001', 'M002'], false), 'M003', 'explicit false → M003');
|
||||
|
||||
// uniqueEnabled=true → unique format
|
||||
const newId = nextMilestoneId([], true);
|
||||
assertMatch(newId, MILESTONE_ID_RE, 'uniqueEnabled=true produces valid ID');
|
||||
assertTrue(newId.startsWith('M001-'), 'uniqueEnabled=true starts with M001-');
|
||||
assertMatch(newId, /^M001-[a-z0-9]{6}$/, 'empty + uniqueEnabled=true → M001-{rand6}');
|
||||
assert.match(newId, MILESTONE_ID_RE, 'uniqueEnabled=true produces valid ID');
|
||||
assert.ok(newId.startsWith('M001-'), 'uniqueEnabled=true starts with M001-');
|
||||
assert.match(newId, /^M001-[a-z0-9]{6}$/, 'empty + uniqueEnabled=true → M001-{rand6}');
|
||||
|
||||
// Mixed array with uniqueEnabled=true
|
||||
const mixedIds = ['M001', 'M003-abc123', 'M002'];
|
||||
const nextNew = nextMilestoneId(mixedIds, true);
|
||||
assertMatch(nextNew, MILESTONE_ID_RE, 'mixed array + uniqueEnabled=true → valid ID');
|
||||
assertMatch(nextNew, /^M004-[a-z0-9]{6}$/, 'mixed array max=3 → M004-{rand6}');
|
||||
assert.match(nextNew, MILESTONE_ID_RE, 'mixed array + uniqueEnabled=true → valid ID');
|
||||
assert.match(nextNew, /^M004-[a-z0-9]{6}$/, 'mixed array max=3 → M004-{rand6}');
|
||||
|
||||
// Mixed array with uniqueEnabled=false
|
||||
assertEq(nextMilestoneId(mixedIds, false), 'M004', 'mixed array + uniqueEnabled=false → M004');
|
||||
assert.deepStrictEqual(nextMilestoneId(mixedIds, false), 'M004', 'mixed array + uniqueEnabled=false → M004');
|
||||
|
||||
// Correct sequential number from mixed arrays
|
||||
const mixedIds2 = ['M005-xyz999', 'M002'];
|
||||
assertEq(nextMilestoneId(mixedIds2, false), 'M006', 'mixed max=5 → M006');
|
||||
assert.deepStrictEqual(nextMilestoneId(mixedIds2, false), 'M006', 'mixed max=5 → M006');
|
||||
const nextNew2 = nextMilestoneId(mixedIds2, true);
|
||||
assertMatch(nextNew2, /^M006-[a-z0-9]{6}$/, 'mixed max=5 + unique → M006-{rand6}');
|
||||
assert.match(nextNew2, /^M006-[a-z0-9]{6}$/, 'mixed max=5 + unique → M006-{rand6}');
|
||||
}
|
||||
|
||||
// (g) maxMilestoneNum
|
||||
{
|
||||
console.log(' (g) maxMilestoneNum');
|
||||
// Empty
|
||||
assertEq(maxMilestoneNum([]), 0, 'empty → 0');
|
||||
assert.deepStrictEqual(maxMilestoneNum([]), 0, 'empty → 0');
|
||||
|
||||
// Old format only
|
||||
assertEq(maxMilestoneNum(['M001', 'M002', 'M003']), 3, 'old format only → 3');
|
||||
assert.deepStrictEqual(maxMilestoneNum(['M001', 'M002', 'M003']), 3, 'old format only → 3');
|
||||
|
||||
// Unique format only — must not return NaN
|
||||
assertEq(maxMilestoneNum(['M001-abc123', 'M002-def456']), 2, 'unique format only → 2');
|
||||
assertTrue(!Number.isNaN(maxMilestoneNum(['M001-abc123'])), 'unique format does not return NaN');
|
||||
assert.deepStrictEqual(maxMilestoneNum(['M001-abc123', 'M002-def456']), 2, 'unique format only → 2');
|
||||
assert.ok(!Number.isNaN(maxMilestoneNum(['M001-abc123'])), 'unique format does not return NaN');
|
||||
|
||||
// Mixed formats
|
||||
assertEq(maxMilestoneNum(['M001', 'M003-abc123', 'M002']), 3, 'mixed → 3');
|
||||
assert.deepStrictEqual(maxMilestoneNum(['M001', 'M003-abc123', 'M002']), 3, 'mixed → 3');
|
||||
|
||||
// Non-matching entries ignored
|
||||
assertEq(maxMilestoneNum(['M001', 'notes', '.DS_Store', 'M003']), 3, 'non-matching ignored → 3');
|
||||
assertEq(maxMilestoneNum(['notes', '.DS_Store']), 0, 'all non-matching → 0');
|
||||
assert.deepStrictEqual(maxMilestoneNum(['M001', 'notes', '.DS_Store', 'M003']), 3, 'non-matching ignored → 3');
|
||||
assert.deepStrictEqual(maxMilestoneNum(['notes', '.DS_Store']), 0, 'all non-matching → 0');
|
||||
}
|
||||
|
||||
// (h) Preferences round-trip via renderPreferencesForSystemPrompt
|
||||
|
|
@ -179,41 +179,25 @@ async function main(): Promise<void> {
|
|||
// validate { unique_milestone_ids: true } → field preserved (no validation error)
|
||||
const prefsTrue: GSDPreferences = { unique_milestone_ids: true };
|
||||
const renderedTrue = renderPreferencesForSystemPrompt(prefsTrue);
|
||||
assertTrue(!renderedTrue.includes('some preference values were ignored'), 'unique_milestone_ids: true validates without error');
|
||||
assert.ok(!renderedTrue.includes('some preference values were ignored'), 'unique_milestone_ids: true validates without error');
|
||||
|
||||
// validate { unique_milestone_ids: undefined } → field absent (no error)
|
||||
const prefsUndefined: GSDPreferences = {};
|
||||
const renderedUndefined = renderPreferencesForSystemPrompt(prefsUndefined);
|
||||
assertTrue(!renderedUndefined.includes('some preference values were ignored'), 'undefined unique_milestone_ids validates without error');
|
||||
assert.ok(!renderedUndefined.includes('some preference values were ignored'), 'undefined unique_milestone_ids validates without error');
|
||||
|
||||
// validate { unique_milestone_ids: false } → also valid
|
||||
const prefsFalse: GSDPreferences = { unique_milestone_ids: false };
|
||||
const renderedFalse = renderPreferencesForSystemPrompt(prefsFalse);
|
||||
assertTrue(!renderedFalse.includes('some preference values were ignored'), 'unique_milestone_ids: false validates without error');
|
||||
assert.ok(!renderedFalse.includes('some preference values were ignored'), 'unique_milestone_ids: false validates without error');
|
||||
|
||||
// validate coercion: truthy non-boolean → coerced to boolean (no crash)
|
||||
const prefsCoerced: GSDPreferences = { unique_milestone_ids: 1 as unknown as boolean };
|
||||
const renderedCoerced = renderPreferencesForSystemPrompt(prefsCoerced);
|
||||
assertTrue(!renderedCoerced.includes('some preference values were ignored'), 'truthy non-boolean coerces without validation error');
|
||||
assert.ok(!renderedCoerced.includes('some preference values were ignored'), 'truthy non-boolean coerces without validation error');
|
||||
|
||||
// GSDPreferences interface accepts the field (compile-time check — if this compiles, it works)
|
||||
const prefs: GSDPreferences = { unique_milestone_ids: true, version: 1 };
|
||||
assertTrue(prefs.unique_milestone_ids === true, 'GSDPreferences interface accepts unique_milestone_ids');
|
||||
assert.ok(prefs.unique_milestone_ids === true, 'GSDPreferences interface accepts unique_milestone_ids');
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
// When run via vitest, wrap in test(); when run via tsx, call directly.
|
||||
const isVitest = typeof globalThis !== 'undefined' && (globalThis as any).__vitest_worker__?.config?.defines != null && 'vitest' in (globalThis as any).__vitest_worker__.config.defines || process.env.VITEST;
|
||||
if (isVitest) {
|
||||
const { test } = await import('node:test');
|
||||
test('unique-milestone-ids: all ID primitives handle both formats', async () => {
|
||||
await main();
|
||||
});
|
||||
} else {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import {
|
|||
writeUnitRuntimeRecord,
|
||||
} from "../unit-runtime.ts";
|
||||
import { clearPathCache } from '../paths.ts';
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
const base = mkdtempSync(join(tmpdir(), "gsd-unit-runtime-test-"));
|
||||
const tasksDir = join(base, ".gsd", "milestones", "M100", "slices", "S02", "tasks");
|
||||
mkdirSync(tasksDir, { recursive: true });
|
||||
|
|
@ -25,22 +25,22 @@ writeFileSync(
|
|||
console.log("\n=== runtime record write/read/update ===");
|
||||
{
|
||||
const first = writeUnitRuntimeRecord(base, "execute-task", "M100/S02/T09", 1000, { phase: "dispatched" });
|
||||
assertEq(first.phase, "dispatched", "initial phase");
|
||||
assert.deepStrictEqual(first.phase, "dispatched", "initial phase");
|
||||
const second = writeUnitRuntimeRecord(base, "execute-task", "M100/S02/T09", 1000, { phase: "wrapup-warning-sent", wrapupWarningSent: true });
|
||||
assertEq(second.wrapupWarningSent, true, "warning persisted");
|
||||
assert.deepStrictEqual(second.wrapupWarningSent, true, "warning persisted");
|
||||
const loaded = readUnitRuntimeRecord(base, "execute-task", "M100/S02/T09");
|
||||
assertTrue(loaded !== null, "record readable");
|
||||
assertEq(loaded!.phase, "wrapup-warning-sent", "updated phase readable");
|
||||
assert.ok(loaded !== null, "record readable");
|
||||
assert.deepStrictEqual(loaded!.phase, "wrapup-warning-sent", "updated phase readable");
|
||||
}
|
||||
|
||||
console.log("\n=== execute-task durability inspection ===");
|
||||
{
|
||||
let status = await inspectExecuteTaskDurability(base, "M100/S02/T09");
|
||||
assertTrue(status !== null, "status exists");
|
||||
assertEq(status!.summaryExists, false, "summary initially missing");
|
||||
assertEq(status!.taskChecked, false, "task initially unchecked");
|
||||
assertEq(status!.nextActionAdvanced, false, "next action initially stale");
|
||||
assertTrue(/summary missing/i.test(formatExecuteTaskRecoveryStatus(status!)), "diagnostic mentions summary");
|
||||
assert.ok(status !== null, "status exists");
|
||||
assert.deepStrictEqual(status!.summaryExists, false, "summary initially missing");
|
||||
assert.deepStrictEqual(status!.taskChecked, false, "task initially unchecked");
|
||||
assert.deepStrictEqual(status!.nextActionAdvanced, false, "next action initially stale");
|
||||
assert.ok(/summary missing/i.test(formatExecuteTaskRecoveryStatus(status!)), "diagnostic mentions summary");
|
||||
|
||||
writeFileSync(join(tasksDir, "T09-SUMMARY.md"), "# done\n", "utf-8");
|
||||
writeFileSync(
|
||||
|
|
@ -52,17 +52,17 @@ console.log("\n=== execute-task durability inspection ===");
|
|||
clearPathCache();
|
||||
|
||||
status = await inspectExecuteTaskDurability(base, "M100/S02/T09");
|
||||
assertEq(status!.summaryExists, true, "summary found after write");
|
||||
assertEq(status!.taskChecked, true, "task checked after update");
|
||||
assertEq(status!.nextActionAdvanced, true, "next action advanced after update");
|
||||
assertEq(formatExecuteTaskRecoveryStatus(status!), "all durable task artifacts present", "clean diagnostic when complete");
|
||||
assert.deepStrictEqual(status!.summaryExists, true, "summary found after write");
|
||||
assert.deepStrictEqual(status!.taskChecked, true, "task checked after update");
|
||||
assert.deepStrictEqual(status!.nextActionAdvanced, true, "next action advanced after update");
|
||||
assert.deepStrictEqual(formatExecuteTaskRecoveryStatus(status!), "all durable task artifacts present", "clean diagnostic when complete");
|
||||
}
|
||||
|
||||
console.log("\n=== runtime record cleanup ===");
|
||||
{
|
||||
clearUnitRuntimeRecord(base, "execute-task", "M100/S02/T09");
|
||||
const loaded = readUnitRuntimeRecord(base, "execute-task", "M100/S02/T09");
|
||||
assertEq(loaded, null, "record removed");
|
||||
assert.deepStrictEqual(loaded, null, "record removed");
|
||||
}
|
||||
|
||||
console.log("\n=== hook unit type sanitization (slash in unitType) ===");
|
||||
|
|
@ -70,23 +70,23 @@ console.log("\n=== hook unit type sanitization (slash in unitType) ===");
|
|||
// Hook units have unitType like "hook/code-review" with a slash
|
||||
// This should NOT create a subdirectory - the slash must be sanitized
|
||||
const hookRecord = writeUnitRuntimeRecord(base, "hook/code-review", "M100/S02/T10", 2000, { phase: "dispatched" });
|
||||
assertEq(hookRecord.unitType, "hook/code-review", "unitType preserved in record");
|
||||
assertEq(hookRecord.unitId, "M100/S02/T10", "unitId preserved in record");
|
||||
assert.deepStrictEqual(hookRecord.unitType, "hook/code-review", "unitType preserved in record");
|
||||
assert.deepStrictEqual(hookRecord.unitId, "M100/S02/T10", "unitId preserved in record");
|
||||
|
||||
const loaded = readUnitRuntimeRecord(base, "hook/code-review", "M100/S02/T10");
|
||||
assertTrue(loaded !== null, "hook record readable");
|
||||
assertEq(loaded!.phase, "dispatched", "hook phase correct");
|
||||
assert.ok(loaded !== null, "hook record readable");
|
||||
assert.deepStrictEqual(loaded!.phase, "dispatched", "hook phase correct");
|
||||
|
||||
// Verify the file is in the units dir, not in a subdirectory
|
||||
const unitsDir = join(base, ".gsd", "runtime", "units");
|
||||
const files = readdirSync(unitsDir);
|
||||
const hookFile = files.find((f: string) => f.includes("hook-code-review"));
|
||||
assertTrue(hookFile !== undefined, "hook file exists with sanitized name");
|
||||
assertTrue(!files.some((f: string) => f === "hook"), "no 'hook' subdirectory created");
|
||||
assert.ok(hookFile !== undefined, "hook file exists with sanitized name");
|
||||
assert.ok(!files.some((f: string) => f === "hook"), "no 'hook' subdirectory created");
|
||||
|
||||
clearUnitRuntimeRecord(base, "hook/code-review", "M100/S02/T10");
|
||||
const cleared = readUnitRuntimeRecord(base, "hook/code-review", "M100/S02/T10");
|
||||
assertEq(cleared, null, "hook record removed");
|
||||
assert.deepStrictEqual(cleared, null, "hook record removed");
|
||||
}
|
||||
|
||||
// ─── Must-have durability integration tests ───────────────────────────────
|
||||
|
|
@ -121,13 +121,13 @@ console.log("\n=== must-haves: all mentioned in summary ===");
|
|||
writeFileSync(join(mhBase, ".gsd", "STATE.md"), "## Next Action\nExecute T02 for S01: next thing\n", "utf-8");
|
||||
|
||||
const status = await inspectExecuteTaskDurability(mhBase, "M200/S01/T01");
|
||||
assertTrue(status !== null, "mh-all: status exists");
|
||||
assertEq(status!.mustHaveCount, 3, "mh-all: mustHaveCount is 3");
|
||||
assertEq(status!.mustHavesMentionedInSummary, 3, "mh-all: all 3 must-haves mentioned");
|
||||
assertEq(status!.summaryExists, true, "mh-all: summary exists");
|
||||
assertEq(status!.taskChecked, true, "mh-all: task checked");
|
||||
assert.ok(status !== null, "mh-all: status exists");
|
||||
assert.deepStrictEqual(status!.mustHaveCount, 3, "mh-all: mustHaveCount is 3");
|
||||
assert.deepStrictEqual(status!.mustHavesMentionedInSummary, 3, "mh-all: all 3 must-haves mentioned");
|
||||
assert.deepStrictEqual(status!.summaryExists, true, "mh-all: summary exists");
|
||||
assert.deepStrictEqual(status!.taskChecked, true, "mh-all: task checked");
|
||||
const diag = formatExecuteTaskRecoveryStatus(status!);
|
||||
assertEq(diag, "all durable task artifacts present", "mh-all: diagnostic is clean when all must-haves met");
|
||||
assert.deepStrictEqual(diag, "all durable task artifacts present", "mh-all: diagnostic is clean when all must-haves met");
|
||||
}
|
||||
|
||||
console.log("\n=== must-haves: partially mentioned in summary ===");
|
||||
|
|
@ -156,12 +156,12 @@ console.log("\n=== must-haves: partially mentioned in summary ===");
|
|||
|
||||
clearPathCache();
|
||||
const status = await inspectExecuteTaskDurability(mhBase, "M200/S02/T01");
|
||||
assertTrue(status !== null, "mh-partial: status exists");
|
||||
assertEq(status!.mustHaveCount, 3, "mh-partial: mustHaveCount is 3");
|
||||
assertEq(status!.mustHavesMentionedInSummary, 1, "mh-partial: only 1 must-have mentioned");
|
||||
assert.ok(status !== null, "mh-partial: status exists");
|
||||
assert.deepStrictEqual(status!.mustHaveCount, 3, "mh-partial: mustHaveCount is 3");
|
||||
assert.deepStrictEqual(status!.mustHavesMentionedInSummary, 1, "mh-partial: only 1 must-have mentioned");
|
||||
const diag = formatExecuteTaskRecoveryStatus(status!);
|
||||
assertTrue(diag.includes("must-have gap"), "mh-partial: diagnostic includes 'must-have gap'");
|
||||
assertTrue(diag.includes("1 of 3"), "mh-partial: diagnostic includes '1 of 3'");
|
||||
assert.ok(diag.includes("must-have gap"), "mh-partial: diagnostic includes 'must-have gap'");
|
||||
assert.ok(diag.includes("1 of 3"), "mh-partial: diagnostic includes '1 of 3'");
|
||||
}
|
||||
|
||||
console.log("\n=== must-haves: no task plan file ===");
|
||||
|
|
@ -184,9 +184,9 @@ console.log("\n=== must-haves: no task plan file ===");
|
|||
|
||||
clearPathCache();
|
||||
const status = await inspectExecuteTaskDurability(mhBase, "M200/S03/T01");
|
||||
assertTrue(status !== null, "mh-noplan: status exists");
|
||||
assertEq(status!.mustHaveCount, 0, "mh-noplan: mustHaveCount is 0 when no task plan");
|
||||
assertEq(status!.mustHavesMentionedInSummary, 0, "mh-noplan: mustHavesMentionedInSummary is 0");
|
||||
assert.ok(status !== null, "mh-noplan: status exists");
|
||||
assert.deepStrictEqual(status!.mustHaveCount, 0, "mh-noplan: mustHaveCount is 0 when no task plan");
|
||||
assert.deepStrictEqual(status!.mustHavesMentionedInSummary, 0, "mh-noplan: mustHavesMentionedInSummary is 0");
|
||||
}
|
||||
|
||||
console.log("\n=== must-haves: present but no summary file ===");
|
||||
|
|
@ -209,10 +209,10 @@ console.log("\n=== must-haves: present but no summary file ===");
|
|||
|
||||
clearPathCache();
|
||||
const status = await inspectExecuteTaskDurability(mhBase, "M200/S04/T01");
|
||||
assertTrue(status !== null, "mh-nosummary: status exists");
|
||||
assertEq(status!.mustHaveCount, 2, "mh-nosummary: mustHaveCount is 2");
|
||||
assertEq(status!.mustHavesMentionedInSummary, 0, "mh-nosummary: mustHavesMentionedInSummary is 0 with no summary");
|
||||
assertEq(status!.summaryExists, false, "mh-nosummary: summary doesn't exist");
|
||||
assert.ok(status !== null, "mh-nosummary: status exists");
|
||||
assert.deepStrictEqual(status!.mustHaveCount, 2, "mh-nosummary: mustHaveCount is 2");
|
||||
assert.deepStrictEqual(status!.mustHavesMentionedInSummary, 0, "mh-nosummary: mustHavesMentionedInSummary is 0 with no summary");
|
||||
assert.deepStrictEqual(status!.summaryExists, false, "mh-nosummary: summary doesn't exist");
|
||||
}
|
||||
|
||||
console.log("\n=== must-haves: substring matching (no backtick tokens) ===");
|
||||
|
|
@ -241,18 +241,17 @@ console.log("\n=== must-haves: substring matching (no backtick tokens) ===");
|
|||
|
||||
clearPathCache();
|
||||
const status = await inspectExecuteTaskDurability(mhBase, "M200/S05/T01");
|
||||
assertTrue(status !== null, "mh-substr: status exists");
|
||||
assertEq(status!.mustHaveCount, 3, "mh-substr: mustHaveCount is 3");
|
||||
assert.ok(status !== null, "mh-substr: status exists");
|
||||
assert.deepStrictEqual(status!.mustHaveCount, 3, "mh-substr: mustHaveCount is 3");
|
||||
// "heuristic" appears in summary for item 1, "diagnostic" for item 2,
|
||||
// "assertions" appears in summary? No — let's check
|
||||
// Item 3: "All assertions pass" — words: "assertions", "pass" (<4 chars excluded)
|
||||
// summary doesn't contain "assertions" → not matched
|
||||
assertEq(status!.mustHavesMentionedInSummary, 2, "mh-substr: 2 of 3 matched via substring");
|
||||
assert.deepStrictEqual(status!.mustHavesMentionedInSummary, 2, "mh-substr: 2 of 3 matched via substring");
|
||||
const diag = formatExecuteTaskRecoveryStatus(status!);
|
||||
assertTrue(diag.includes("must-have gap"), "mh-substr: diagnostic includes gap info");
|
||||
assertTrue(diag.includes("2 of 3"), "mh-substr: diagnostic includes '2 of 3'");
|
||||
assert.ok(diag.includes("must-have gap"), "mh-substr: diagnostic includes gap info");
|
||||
assert.ok(diag.includes("2 of 3"), "mh-substr: diagnostic includes '2 of 3'");
|
||||
}
|
||||
|
||||
rmSync(mhBase, { recursive: true, force: true });
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
report();
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
|
||||
import { computeCriticalPath } from "../visualizer-data.js";
|
||||
import type { VisualizerMilestone } from "../visualizer-data.js";
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
function makeMs(id: string, status: "complete" | "active" | "pending", dependsOn: string[], slices: any[] = []): VisualizerMilestone {
|
||||
return { id, title: id, status, dependsOn, slices };
|
||||
|
|
@ -31,11 +31,11 @@ console.log("\n=== Critical Path: Linear Chain ===");
|
|||
];
|
||||
|
||||
const cp = computeCriticalPath(milestones);
|
||||
assertTrue(cp.milestonePath.length > 0, "linear chain has critical path");
|
||||
assertTrue(cp.milestonePath.includes("M002"), "M002 is on critical path");
|
||||
assertTrue(cp.milestonePath.includes("M003"), "M003 is on critical path");
|
||||
assertEq(cp.milestoneSlack.get("M002"), 0, "M002 has zero slack");
|
||||
assertEq(cp.milestoneSlack.get("M003"), 0, "M003 has zero slack");
|
||||
assert.ok(cp.milestonePath.length > 0, "linear chain has critical path");
|
||||
assert.ok(cp.milestonePath.includes("M002"), "M002 is on critical path");
|
||||
assert.ok(cp.milestonePath.includes("M003"), "M003 is on critical path");
|
||||
assert.deepStrictEqual(cp.milestoneSlack.get("M002"), 0, "M002 has zero slack");
|
||||
assert.deepStrictEqual(cp.milestoneSlack.get("M003"), 0, "M003 has zero slack");
|
||||
}
|
||||
|
||||
// ─── Diamond DAG ────────────────────────────────────────────────────────────
|
||||
|
|
@ -60,14 +60,14 @@ console.log("\n=== Critical Path: Diamond DAG ===");
|
|||
];
|
||||
|
||||
const cp = computeCriticalPath(milestones);
|
||||
assertTrue(cp.milestonePath.length >= 2, "diamond DAG has critical path");
|
||||
assert.ok(cp.milestonePath.length >= 2, "diamond DAG has critical path");
|
||||
// M002 has weight 3 (3 incomplete), M003 has weight 1
|
||||
// Critical path should go through M002 (longer)
|
||||
assertTrue(cp.milestonePath.includes("M002"), "M002 (heavier) is on critical path");
|
||||
assert.ok(cp.milestonePath.includes("M002"), "M002 (heavier) is on critical path");
|
||||
|
||||
// M003 should have non-zero slack since it's lighter
|
||||
const m003Slack = cp.milestoneSlack.get("M003") ?? -1;
|
||||
assertTrue(m003Slack > 0, "M003 has positive slack (lighter branch)");
|
||||
assert.ok(m003Slack > 0, "M003 has positive slack (lighter branch)");
|
||||
}
|
||||
|
||||
// ─── Independent branches ───────────────────────────────────────────────────
|
||||
|
|
@ -83,9 +83,9 @@ console.log("\n=== Critical Path: Independent Branches ===");
|
|||
];
|
||||
|
||||
const cp = computeCriticalPath(milestones);
|
||||
assertTrue(cp.milestonePath.length >= 1, "independent branches have at least one critical node");
|
||||
assert.ok(cp.milestonePath.length >= 1, "independent branches have at least one critical node");
|
||||
// M002 has the most incomplete slices, should be critical
|
||||
assertTrue(cp.milestonePath.includes("M002"), "M002 (longest) is on critical path");
|
||||
assert.ok(cp.milestonePath.includes("M002"), "M002 (longest) is on critical path");
|
||||
}
|
||||
|
||||
// ─── Slice-level critical path ──────────────────────────────────────────────
|
||||
|
|
@ -104,13 +104,13 @@ console.log("\n=== Critical Path: Slice-level ===");
|
|||
];
|
||||
|
||||
const cp = computeCriticalPath(milestones);
|
||||
assertTrue(cp.slicePath.length > 0, "has slice-level critical path");
|
||||
assertTrue(cp.slicePath.includes("S02"), "S02 is on slice critical path");
|
||||
assertTrue(cp.slicePath.includes("S04"), "S04 is on slice critical path");
|
||||
assert.ok(cp.slicePath.length > 0, "has slice-level critical path");
|
||||
assert.ok(cp.slicePath.includes("S02"), "S02 is on slice critical path");
|
||||
assert.ok(cp.slicePath.includes("S04"), "S04 is on slice critical path");
|
||||
|
||||
// S03 should have non-zero slack (it's a shorter branch)
|
||||
const s03Slack = cp.sliceSlack.get("S03") ?? -1;
|
||||
assertTrue(s03Slack > 0, "S03 has positive slack (shorter branch)");
|
||||
assert.ok(s03Slack > 0, "S03 has positive slack (shorter branch)");
|
||||
}
|
||||
|
||||
// ─── Empty milestones ───────────────────────────────────────────────────────
|
||||
|
|
@ -119,8 +119,8 @@ console.log("\n=== Critical Path: Empty ===");
|
|||
|
||||
{
|
||||
const cp = computeCriticalPath([]);
|
||||
assertEq(cp.milestonePath.length, 0, "empty milestones produce empty path");
|
||||
assertEq(cp.slicePath.length, 0, "empty milestones produce empty slice path");
|
||||
assert.deepStrictEqual(cp.milestonePath.length, 0, "empty milestones produce empty path");
|
||||
assert.deepStrictEqual(cp.slicePath.length, 0, "empty milestones produce empty slice path");
|
||||
}
|
||||
|
||||
// ─── Single milestone ───────────────────────────────────────────────────────
|
||||
|
|
@ -136,10 +136,8 @@ console.log("\n=== Critical Path: Single Milestone ===");
|
|||
];
|
||||
|
||||
const cp = computeCriticalPath(milestones);
|
||||
assertTrue(cp.milestonePath.length === 1, "single milestone is its own critical path");
|
||||
assertEq(cp.milestonePath[0], "M001", "M001 is the critical node");
|
||||
assert.ok(cp.milestonePath.length === 1, "single milestone is its own critical path");
|
||||
assert.deepStrictEqual(cp.milestonePath[0], "M001", "M001 is the critical node");
|
||||
}
|
||||
|
||||
// ─── Report ─────────────────────────────────────────────────────────────────
|
||||
|
||||
report();
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const { assertTrue, report } = createTestContext();
|
||||
|
||||
const dataPath = join(__dirname, "..", "visualizer-data.ts");
|
||||
const dataSrc = readFileSync(dataPath, "utf-8");
|
||||
|
|
@ -15,293 +15,293 @@ const dataSrc = readFileSync(dataPath, "utf-8");
|
|||
console.log("\n=== visualizer-data.ts source contracts ===");
|
||||
|
||||
// Interface exports
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("export interface VisualizerData"),
|
||||
"exports VisualizerData interface",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("export interface VisualizerMilestone"),
|
||||
"exports VisualizerMilestone interface",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("export interface VisualizerSlice"),
|
||||
"exports VisualizerSlice interface",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("export interface VisualizerTask"),
|
||||
"exports VisualizerTask interface",
|
||||
);
|
||||
|
||||
// New interfaces
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("export interface CriticalPathInfo"),
|
||||
"exports CriticalPathInfo interface",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("export interface AgentActivityInfo"),
|
||||
"exports AgentActivityInfo interface",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("export interface ChangelogEntry"),
|
||||
"exports ChangelogEntry interface",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("export interface ChangelogInfo"),
|
||||
"exports ChangelogInfo interface",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("export interface SliceVerification"),
|
||||
"exports SliceVerification interface",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("export interface KnowledgeInfo"),
|
||||
"exports KnowledgeInfo interface",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("export interface CapturesInfo"),
|
||||
"exports CapturesInfo interface",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("export interface HealthInfo"),
|
||||
"exports HealthInfo interface",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("export interface VisualizerDiscussionState"),
|
||||
"exports VisualizerDiscussionState interface",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("export type DiscussionState"),
|
||||
"exports DiscussionState type",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("export interface VisualizerSliceRef"),
|
||||
"exports VisualizerSliceRef interface",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("export interface VisualizerSliceActivity"),
|
||||
"exports VisualizerSliceActivity interface",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("export interface VisualizerStats"),
|
||||
"exports VisualizerStats interface",
|
||||
);
|
||||
|
||||
// Function export
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("export async function loadVisualizerData"),
|
||||
"exports loadVisualizerData function",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("export function computeCriticalPath"),
|
||||
"exports computeCriticalPath function",
|
||||
);
|
||||
|
||||
// Data source usage
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("deriveState"),
|
||||
"uses deriveState for state derivation",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("findMilestoneIds"),
|
||||
"uses findMilestoneIds to enumerate milestones",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("parseRoadmap"),
|
||||
"uses parseRoadmap for roadmap parsing",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("parsePlan"),
|
||||
"uses parsePlan for plan parsing",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("parseSummary"),
|
||||
"uses parseSummary for changelog parsing",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("getLedger"),
|
||||
"uses getLedger for in-memory metrics",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("loadLedgerFromDisk"),
|
||||
"uses loadLedgerFromDisk as fallback",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("getProjectTotals"),
|
||||
"uses getProjectTotals for aggregation",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("aggregateByPhase"),
|
||||
"uses aggregateByPhase",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("aggregateBySlice"),
|
||||
"uses aggregateBySlice",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("aggregateByModel"),
|
||||
"uses aggregateByModel",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("aggregateByTier"),
|
||||
"uses aggregateByTier",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("formatTierSavings"),
|
||||
"uses formatTierSavings",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("loadAllCaptures"),
|
||||
"uses loadAllCaptures",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("countPendingCaptures"),
|
||||
"uses countPendingCaptures",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("loadEffectiveGSDPreferences"),
|
||||
"uses loadEffectiveGSDPreferences",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("resolveGsdRootFile"),
|
||||
"uses resolveGsdRootFile for KNOWLEDGE path",
|
||||
);
|
||||
|
||||
// Interface fields
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("dependsOn: string[]"),
|
||||
"VisualizerMilestone has dependsOn field",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("depends: string[]"),
|
||||
"VisualizerSlice has depends field",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("totals: ProjectTotals | null"),
|
||||
"VisualizerData has nullable totals",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("units: UnitMetrics[]"),
|
||||
"VisualizerData has units array",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("estimate?: string"),
|
||||
"VisualizerTask has optional estimate field",
|
||||
);
|
||||
|
||||
// New data model fields
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("criticalPath: CriticalPathInfo"),
|
||||
"VisualizerData has criticalPath field",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("remainingSliceCount: number"),
|
||||
"VisualizerData has remainingSliceCount field",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("agentActivity: AgentActivityInfo | null"),
|
||||
"VisualizerData has agentActivity field",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("changelog: ChangelogInfo"),
|
||||
"VisualizerData has changelog field",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("sliceVerifications: SliceVerification[]"),
|
||||
"VisualizerData has sliceVerifications field",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("knowledge: KnowledgeInfo"),
|
||||
"VisualizerData has knowledge field",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("captures: CapturesInfo"),
|
||||
"VisualizerData has captures field",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("health: HealthInfo"),
|
||||
"VisualizerData has health field",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("stats: VisualizerStats"),
|
||||
"VisualizerData has stats field",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("discussion: VisualizerDiscussionState[]"),
|
||||
"VisualizerData has discussion field",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("loadDiscussionState"),
|
||||
"uses loadDiscussionState helper",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("buildVisualizerStats"),
|
||||
"uses buildVisualizerStats helper",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("byTier: TierAggregate[]"),
|
||||
"VisualizerData has byTier field",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("tierSavingsLine: string"),
|
||||
"VisualizerData has tierSavingsLine field",
|
||||
);
|
||||
|
||||
// completedAt must be coerced to String() to handle YAML Date objects (issue #644)
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("String(summary.frontmatter.completed_at"),
|
||||
"completedAt assignment coerces to String() for YAML Date safety",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
dataSrc.includes("String(b.completedAt") && dataSrc.includes("String(a.completedAt"),
|
||||
"changelog sort coerces completedAt to String() for YAML Date safety",
|
||||
);
|
||||
|
|
@ -312,112 +312,112 @@ const overlaySrc = readFileSync(overlayPath, "utf-8");
|
|||
|
||||
console.log("\n=== visualizer-overlay.ts source contracts ===");
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("export class GSDVisualizerOverlay"),
|
||||
"exports GSDVisualizerOverlay class",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("loadVisualizerData"),
|
||||
"overlay uses loadVisualizerData",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("renderProgressView"),
|
||||
"overlay delegates to renderProgressView",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("renderDepsView"),
|
||||
"overlay delegates to renderDepsView",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("renderMetricsView"),
|
||||
"overlay delegates to renderMetricsView",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("renderTimelineView"),
|
||||
"overlay delegates to renderTimelineView",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("renderAgentView"),
|
||||
"overlay delegates to renderAgentView",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("renderChangelogView"),
|
||||
"overlay delegates to renderChangelogView",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("renderExportView"),
|
||||
"overlay delegates to renderExportView",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("renderKnowledgeView"),
|
||||
"overlay delegates to renderKnowledgeView",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("renderCapturesView"),
|
||||
"overlay delegates to renderCapturesView",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("renderHealthView"),
|
||||
"overlay delegates to renderHealthView",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("handleInput"),
|
||||
"overlay has handleInput method",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("dispose"),
|
||||
"overlay has dispose method",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("wrapInBox"),
|
||||
"overlay has wrapInBox helper",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("activeTab"),
|
||||
"overlay tracks active tab",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("scrollOffsets"),
|
||||
"overlay tracks per-tab scroll offsets",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("filterMode"),
|
||||
"overlay has filterMode state",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("filterText"),
|
||||
"overlay has filterText state",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("filterField"),
|
||||
"overlay has filterField state",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("TAB_COUNT"),
|
||||
"overlay defines TAB_COUNT",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("0 Export"),
|
||||
"overlay has 10 tab labels",
|
||||
);
|
||||
|
|
@ -428,19 +428,17 @@ const coreHandlerSrc = readFileSync(coreHandlerPath, "utf-8");
|
|||
|
||||
console.log("\n=== commands/handlers/core.ts integration ===");
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
coreHandlerSrc.includes('"visualize"'),
|
||||
"core.ts has visualize in subcommands array",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
coreHandlerSrc.includes("GSDVisualizerOverlay"),
|
||||
"core.ts imports GSDVisualizerOverlay",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
coreHandlerSrc.includes("handleVisualize"),
|
||||
"core.ts has handleVisualize handler",
|
||||
);
|
||||
|
||||
report();
|
||||
|
|
|
|||
|
|
@ -4,90 +4,90 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const { assertTrue, assertEq, report } = createTestContext();
|
||||
|
||||
const overlaySrc = readFileSync(join(__dirname, "..", "visualizer-overlay.ts"), "utf-8");
|
||||
|
||||
console.log("\n=== Overlay: Tab Configuration ===");
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("TAB_COUNT = 10"),
|
||||
"TAB_COUNT is 10",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('"1 Progress"'),
|
||||
"has Progress tab label",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('"2 Timeline"'),
|
||||
"has Timeline tab label",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('"3 Deps"'),
|
||||
"has Deps tab label",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('"5 Health"'),
|
||||
"has Health tab label",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('"6 Agent"'),
|
||||
"has Agent tab label",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('"7 Changes"'),
|
||||
"has Changes tab label",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('"8 Knowledge"'),
|
||||
"has Knowledge tab label",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('"9 Captures"'),
|
||||
"has Captures tab label",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('"0 Export"'),
|
||||
"has Export tab label",
|
||||
);
|
||||
|
||||
console.log("\n=== Overlay: Filter Mode ===");
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('filterMode = false'),
|
||||
"filterMode initialized to false",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('filterText = ""'),
|
||||
"filterText initialized to empty string",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('filterField:'),
|
||||
"has filterField state",
|
||||
);
|
||||
|
||||
// Filter mode entry via "/"
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('data === "/"') || overlaySrc.includes("data === '/'"),
|
||||
"/ key enters filter mode",
|
||||
);
|
||||
|
||||
// Filter field cycling via "f"
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('data === "f"') || overlaySrc.includes("data === 'f'"),
|
||||
"f key cycles filter field",
|
||||
);
|
||||
|
|
@ -95,143 +95,141 @@ assertTrue(
|
|||
console.log("\n=== Overlay: Tab Switching ===");
|
||||
|
||||
// Supports 1-9,0 keys
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('"1234567890"'),
|
||||
"supports keys 1-9,0 for tab switching",
|
||||
);
|
||||
|
||||
// Tab wraps with TAB_COUNT
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("% TAB_COUNT"),
|
||||
"tab key wraps around TAB_COUNT",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('Key.shift("tab")') || overlaySrc.includes("Key.shift('tab')"),
|
||||
"supports Shift+Tab for reverse tab switching",
|
||||
);
|
||||
|
||||
console.log("\n=== Overlay: Page/Half-Page Scroll ===");
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("Key.pageUp"),
|
||||
"has Key.pageUp handler",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("Key.pageDown"),
|
||||
"has Key.pageDown handler",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('Key.ctrl("u")'),
|
||||
"has Ctrl+U half-page scroll",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('Key.ctrl("d")'),
|
||||
"has Ctrl+D half-page scroll",
|
||||
);
|
||||
|
||||
console.log("\n=== Overlay: Mouse Support ===");
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("parseSGRMouse"),
|
||||
"has parseSGRMouse method",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("?1003h"),
|
||||
"enables mouse tracking in constructor",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("?1003l"),
|
||||
"disables mouse tracking in dispose",
|
||||
);
|
||||
|
||||
console.log("\n=== Overlay: Collapsible Milestones ===");
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("collapsedMilestones"),
|
||||
"has collapsedMilestones state",
|
||||
);
|
||||
|
||||
console.log("\n=== Overlay: Help Overlay ===");
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("showHelp"),
|
||||
"has showHelp state",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('data === "?"'),
|
||||
"? key toggles help",
|
||||
);
|
||||
|
||||
console.log("\n=== Overlay: Export Key Interception ===");
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("activeTab === 9"),
|
||||
"export key handling checks for tab 0 (index 9)",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('handleExportKey'),
|
||||
"has handleExportKey method",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('"m"') && overlaySrc.includes('"j"') && overlaySrc.includes('"s"'),
|
||||
"handles m, j, s keys for export",
|
||||
);
|
||||
|
||||
console.log("\n=== Overlay: Footer ===");
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("1-9,0"),
|
||||
"footer hint shows 1-9,0 tab range",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("PgUp/PgDn"),
|
||||
"footer hint mentions PgUp/PgDn",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes("? help"),
|
||||
"footer hint mentions ? for help",
|
||||
);
|
||||
|
||||
console.log("\n=== Overlay: Scroll Offsets ===");
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes(`new Array(TAB_COUNT).fill(0)`),
|
||||
"scroll offsets sized to TAB_COUNT",
|
||||
);
|
||||
|
||||
console.log("\n=== Overlay: Terminal Resize Handling ===");
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('resizeHandler'),
|
||||
"has resizeHandler property",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('"resize"'),
|
||||
"listens for resize events",
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('removeListener("resize"'),
|
||||
"removes resize listener on dispose",
|
||||
);
|
||||
|
||||
console.log("\n=== Overlay: Shared Imports ===");
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
overlaySrc.includes('from "../shared/mod.js"'),
|
||||
"imports from shared barrel",
|
||||
);
|
||||
|
||||
report();
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ import {
|
|||
renderHealthView,
|
||||
} from "../visualizer-views.js";
|
||||
import type { VisualizerData } from "../visualizer-data.js";
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
// ─── Mock theme ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -165,19 +165,19 @@ console.log("\n=== renderProgressView ===");
|
|||
});
|
||||
|
||||
const lines = renderProgressView(data, mockTheme, 80);
|
||||
assertTrue(lines.length > 0, "progress view produces output");
|
||||
assertTrue(lines.some(l => l.includes("M001")), "shows milestone M001");
|
||||
assertTrue(lines.some(l => l.includes("S01")), "shows slice S01");
|
||||
assertTrue(lines.some(l => l.includes("T01")), "shows task T01 for active slice");
|
||||
assertTrue(lines.some(l => l.includes("M002")), "shows milestone M002");
|
||||
assertTrue(lines.some(l => l.includes("depends on M001")), "shows dependency note");
|
||||
assertTrue(lines.some(l => l.includes("30m")), "shows task estimate");
|
||||
assertTrue(lines.some(l => l.includes("Feature Snapshot")), "shows stats header");
|
||||
assertTrue(lines.some(l => l.includes("Missing slices")), "shows missing slices count");
|
||||
assertTrue(lines.some(l => l.includes("State Engine")), "shows missing slice preview");
|
||||
assertTrue(lines.some(l => l.includes("Updated (last 7 days)")), "shows updated count");
|
||||
assertTrue(lines.some(l => l.includes("Recent completions")), "shows recent completions section");
|
||||
assertTrue(lines.some(l => l.includes("Core structures assembled")), "shows recent one-liner entry");
|
||||
assert.ok(lines.length > 0, "progress view produces output");
|
||||
assert.ok(lines.some(l => l.includes("M001")), "shows milestone M001");
|
||||
assert.ok(lines.some(l => l.includes("S01")), "shows slice S01");
|
||||
assert.ok(lines.some(l => l.includes("T01")), "shows task T01 for active slice");
|
||||
assert.ok(lines.some(l => l.includes("M002")), "shows milestone M002");
|
||||
assert.ok(lines.some(l => l.includes("depends on M001")), "shows dependency note");
|
||||
assert.ok(lines.some(l => l.includes("30m")), "shows task estimate");
|
||||
assert.ok(lines.some(l => l.includes("Feature Snapshot")), "shows stats header");
|
||||
assert.ok(lines.some(l => l.includes("Missing slices")), "shows missing slices count");
|
||||
assert.ok(lines.some(l => l.includes("State Engine")), "shows missing slice preview");
|
||||
assert.ok(lines.some(l => l.includes("Updated (last 7 days)")), "shows updated count");
|
||||
assert.ok(lines.some(l => l.includes("Recent completions")), "shows recent completions section");
|
||||
assert.ok(lines.some(l => l.includes("Core structures assembled")), "shows recent one-liner entry");
|
||||
}
|
||||
|
||||
{
|
||||
|
|
@ -211,10 +211,10 @@ console.log("\n=== renderProgressView ===");
|
|||
});
|
||||
|
||||
const lines = renderProgressView(data, mockTheme, 80);
|
||||
assertTrue(lines.some(l => l.includes("Discussion Status")), "shows discussion section");
|
||||
assertTrue(lines.some(l => l.includes("Discussed: 1")), "counts discussed milestones");
|
||||
assertTrue(lines.some(l => l.includes("Draft")), "shows draft badge");
|
||||
assertTrue(lines.some(l => l.includes("Pending")), "shows pending badge");
|
||||
assert.ok(lines.some(l => l.includes("Discussion Status")), "shows discussion section");
|
||||
assert.ok(lines.some(l => l.includes("Discussed: 1")), "counts discussed milestones");
|
||||
assert.ok(lines.some(l => l.includes("Draft")), "shows draft badge");
|
||||
assert.ok(lines.some(l => l.includes("Pending")), "shows pending badge");
|
||||
}
|
||||
|
||||
// Verification badges
|
||||
|
|
@ -239,14 +239,14 @@ console.log("\n=== renderProgressView ===");
|
|||
|
||||
const lines = renderProgressView(data, mockTheme, 80);
|
||||
// The verification badge should show check mark and warning
|
||||
assertTrue(lines.some(l => l.includes("S01")), "shows slice with verification");
|
||||
assert.ok(lines.some(l => l.includes("S01")), "shows slice with verification");
|
||||
}
|
||||
|
||||
{
|
||||
const data = makeVisualizerData({ milestones: [] });
|
||||
const lines = renderProgressView(data, mockTheme, 80);
|
||||
assertTrue(lines.some(l => l.includes("Feature Snapshot")), "shows stats snapshot even when no milestones");
|
||||
assertTrue(lines.some(l => l.includes("Missing slices")), "reports missing slices count");
|
||||
assert.ok(lines.some(l => l.includes("Feature Snapshot")), "shows stats snapshot even when no milestones");
|
||||
assert.ok(lines.some(l => l.includes("Missing slices")), "reports missing slices count");
|
||||
}
|
||||
|
||||
// ─── Risk Heatmap ───────────────────────────────────────────────────────────
|
||||
|
|
@ -272,9 +272,9 @@ console.log("\n=== Risk Heatmap ===");
|
|||
});
|
||||
|
||||
const lines = renderProgressView(data, mockTheme, 80);
|
||||
assertTrue(lines.some(l => l.includes("Risk Heatmap")), "heatmap header present");
|
||||
assertTrue(lines.some(l => l.includes("1 low, 1 med, 2 high")), "risk summary counts");
|
||||
assertTrue(lines.some(l => l.includes("1 high-risk not started")), "high-risk not started warning");
|
||||
assert.ok(lines.some(l => l.includes("Risk Heatmap")), "heatmap header present");
|
||||
assert.ok(lines.some(l => l.includes("1 low, 1 med, 2 high")), "risk summary counts");
|
||||
assert.ok(lines.some(l => l.includes("1 high-risk not started")), "high-risk not started warning");
|
||||
}
|
||||
|
||||
// ─── Search/Filter ──────────────────────────────────────────────────────────
|
||||
|
|
@ -305,11 +305,11 @@ console.log("\n=== Search/Filter ===");
|
|||
});
|
||||
|
||||
const filtered = renderProgressView(data, mockTheme, 80, { text: "auth", field: "all" });
|
||||
assertTrue(filtered.some(l => l.includes("M001")), "filter shows matching milestone");
|
||||
assertTrue(filtered.some(l => l.includes("Filter (all): auth")), "filter indicator present");
|
||||
assert.ok(filtered.some(l => l.includes("M001")), "filter shows matching milestone");
|
||||
assert.ok(filtered.some(l => l.includes("Filter (all): auth")), "filter indicator present");
|
||||
|
||||
const riskFiltered = renderProgressView(data, mockTheme, 80, { text: "high", field: "risk" });
|
||||
assertTrue(riskFiltered.some(l => l.includes("M001")), "risk filter shows milestone with high-risk slice");
|
||||
assert.ok(riskFiltered.some(l => l.includes("M001")), "risk filter shows milestone with high-risk slice");
|
||||
}
|
||||
|
||||
// ─── renderDepsView ─────────────────────────────────────────────────────────
|
||||
|
|
@ -354,13 +354,13 @@ console.log("\n=== renderDepsView ===");
|
|||
});
|
||||
|
||||
const lines = renderDepsView(data, mockTheme, 80);
|
||||
assertTrue(lines.length > 0, "deps view produces output");
|
||||
assertTrue(lines.some(l => l.includes("M001") && l.includes("M002")), "shows milestone dep edge");
|
||||
assertTrue(lines.some(l => l.includes("S01") && l.includes("S02")), "shows slice dep edge");
|
||||
assertTrue(lines.some(l => l.includes("Critical Path")), "shows critical path section");
|
||||
assertTrue(lines.some(l => l.includes("[CRITICAL]")), "shows CRITICAL badge");
|
||||
assertTrue(lines.some(l => l.includes("Data Flow")), "shows data flow section");
|
||||
assertTrue(lines.some(l => l.includes("api-types")), "shows provides artifact");
|
||||
assert.ok(lines.length > 0, "deps view produces output");
|
||||
assert.ok(lines.some(l => l.includes("M001") && l.includes("M002")), "shows milestone dep edge");
|
||||
assert.ok(lines.some(l => l.includes("S01") && l.includes("S02")), "shows slice dep edge");
|
||||
assert.ok(lines.some(l => l.includes("Critical Path")), "shows critical path section");
|
||||
assert.ok(lines.some(l => l.includes("[CRITICAL]")), "shows CRITICAL badge");
|
||||
assert.ok(lines.some(l => l.includes("Data Flow")), "shows data flow section");
|
||||
assert.ok(lines.some(l => l.includes("api-types")), "shows provides artifact");
|
||||
}
|
||||
|
||||
{
|
||||
|
|
@ -371,7 +371,7 @@ console.log("\n=== renderDepsView ===");
|
|||
});
|
||||
|
||||
const lines = renderDepsView(data, mockTheme, 80);
|
||||
assertTrue(lines.some(l => l.includes("No milestone dependencies")), "shows no-deps message");
|
||||
assert.ok(lines.some(l => l.includes("No milestone dependencies")), "shows no-deps message");
|
||||
}
|
||||
|
||||
// ─── renderMetricsView ──────────────────────────────────────────────────────
|
||||
|
|
@ -422,21 +422,21 @@ console.log("\n=== renderMetricsView ===");
|
|||
});
|
||||
|
||||
const lines = renderMetricsView(data, mockTheme, 80);
|
||||
assertTrue(lines.length > 0, "metrics view produces output");
|
||||
assertTrue(lines.some(l => l.includes("$2.50")), "shows total cost");
|
||||
assertTrue(lines.some(l => l.includes("execution")), "shows phase name");
|
||||
assertTrue(lines.some(l => l.includes("claude-opus-4-6")), "shows model name");
|
||||
assertTrue(lines.some(l => l.includes("By Tier")), "shows tier breakdown section");
|
||||
assertTrue(lines.some(l => l.includes("standard")), "shows tier name");
|
||||
assertTrue(lines.some(l => l.includes("Dynamic routing")), "shows tier savings line");
|
||||
assertTrue(lines.some(l => l.includes("Tools: 15")), "shows tool call count");
|
||||
assertTrue(lines.some(l => l.includes("10") && l.includes("sent")), "shows message counts");
|
||||
assert.ok(lines.length > 0, "metrics view produces output");
|
||||
assert.ok(lines.some(l => l.includes("$2.50")), "shows total cost");
|
||||
assert.ok(lines.some(l => l.includes("execution")), "shows phase name");
|
||||
assert.ok(lines.some(l => l.includes("claude-opus-4-6")), "shows model name");
|
||||
assert.ok(lines.some(l => l.includes("By Tier")), "shows tier breakdown section");
|
||||
assert.ok(lines.some(l => l.includes("standard")), "shows tier name");
|
||||
assert.ok(lines.some(l => l.includes("Dynamic routing")), "shows tier savings line");
|
||||
assert.ok(lines.some(l => l.includes("Tools: 15")), "shows tool call count");
|
||||
assert.ok(lines.some(l => l.includes("10") && l.includes("sent")), "shows message counts");
|
||||
}
|
||||
|
||||
{
|
||||
const data = makeVisualizerData({ totals: null });
|
||||
const lines = renderMetricsView(data, mockTheme, 80);
|
||||
assertTrue(lines.some(l => l.includes("No metrics data")), "shows no-data message");
|
||||
assert.ok(lines.some(l => l.includes("No metrics data")), "shows no-data message");
|
||||
}
|
||||
|
||||
// ─── renderTimelineView ─────────────────────────────────────────────────────
|
||||
|
|
@ -464,16 +464,16 @@ console.log("\n=== renderTimelineView ===");
|
|||
});
|
||||
|
||||
const listLines = renderTimelineView(data, mockTheme, 80);
|
||||
assertTrue(listLines.length >= 1, "list view produces lines");
|
||||
assertTrue(listLines.some(l => l.includes("execute-task")), "shows unit type");
|
||||
assertTrue(listLines.some(l => l.includes("[standard]")), "shows tier in timeline");
|
||||
assertTrue(listLines.some(l => l.includes("opus-4-6")), "shows shortened model");
|
||||
assert.ok(listLines.length >= 1, "list view produces lines");
|
||||
assert.ok(listLines.some(l => l.includes("execute-task")), "shows unit type");
|
||||
assert.ok(listLines.some(l => l.includes("[standard]")), "shows tier in timeline");
|
||||
assert.ok(listLines.some(l => l.includes("opus-4-6")), "shows shortened model");
|
||||
}
|
||||
|
||||
{
|
||||
const data = makeVisualizerData({ units: [] });
|
||||
const lines = renderTimelineView(data, mockTheme, 80);
|
||||
assertTrue(lines.some(l => l.includes("No execution history")), "shows empty message");
|
||||
assert.ok(lines.some(l => l.includes("No execution history")), "shows empty message");
|
||||
}
|
||||
|
||||
// ─── renderAgentView ────────────────────────────────────────────────────────
|
||||
|
|
@ -514,17 +514,17 @@ console.log("\n=== renderAgentView ===");
|
|||
});
|
||||
|
||||
const lines = renderAgentView(data, mockTheme, 80);
|
||||
assertTrue(lines.length > 0, "agent view produces output");
|
||||
assertTrue(lines.some(l => l.includes("ACTIVE")), "shows active status");
|
||||
assertTrue(lines.some(l => l.includes("Pressure")), "shows pressure section");
|
||||
assertTrue(lines.some(l => l.includes("15.5%")), "shows truncation rate");
|
||||
assertTrue(lines.some(l => l.includes("Pending captures: 3")), "shows pending captures");
|
||||
assert.ok(lines.length > 0, "agent view produces output");
|
||||
assert.ok(lines.some(l => l.includes("ACTIVE")), "shows active status");
|
||||
assert.ok(lines.some(l => l.includes("Pressure")), "shows pressure section");
|
||||
assert.ok(lines.some(l => l.includes("15.5%")), "shows truncation rate");
|
||||
assert.ok(lines.some(l => l.includes("Pending captures: 3")), "shows pending captures");
|
||||
}
|
||||
|
||||
{
|
||||
const data = makeVisualizerData({ agentActivity: null });
|
||||
const lines = renderAgentView(data, mockTheme, 80);
|
||||
assertTrue(lines.some(l => l.includes("No agent activity")), "shows no-activity message");
|
||||
assert.ok(lines.some(l => l.includes("No agent activity")), "shows no-activity message");
|
||||
}
|
||||
|
||||
// ─── renderChangelogView ────────────────────────────────────────────────────
|
||||
|
|
@ -559,17 +559,17 @@ console.log("\n=== renderChangelogView ===");
|
|||
});
|
||||
|
||||
const lines = renderChangelogView(data, mockTheme, 80);
|
||||
assertTrue(lines.some(l => l.includes("M001/S01")), "shows slice reference");
|
||||
assertTrue(lines.some(l => l.includes("Decisions:")), "shows decisions section");
|
||||
assertTrue(lines.some(l => l.includes("RS256")), "shows decision content");
|
||||
assertTrue(lines.some(l => l.includes("Patterns:")), "shows patterns section");
|
||||
assertTrue(lines.some(l => l.includes("Repository pattern")), "shows pattern content");
|
||||
assert.ok(lines.some(l => l.includes("M001/S01")), "shows slice reference");
|
||||
assert.ok(lines.some(l => l.includes("Decisions:")), "shows decisions section");
|
||||
assert.ok(lines.some(l => l.includes("RS256")), "shows decision content");
|
||||
assert.ok(lines.some(l => l.includes("Patterns:")), "shows patterns section");
|
||||
assert.ok(lines.some(l => l.includes("Repository pattern")), "shows pattern content");
|
||||
}
|
||||
|
||||
{
|
||||
const data = makeVisualizerData({ changelog: { entries: [] } });
|
||||
const lines = renderChangelogView(data, mockTheme, 80);
|
||||
assertTrue(lines.some(l => l.includes("No completed slices")), "shows empty state");
|
||||
assert.ok(lines.some(l => l.includes("No completed slices")), "shows empty state");
|
||||
}
|
||||
|
||||
// ─── renderExportView ───────────────────────────────────────────────────────
|
||||
|
|
@ -579,10 +579,10 @@ console.log("\n=== renderExportView ===");
|
|||
{
|
||||
const data = makeVisualizerData();
|
||||
const lines = renderExportView(data, mockTheme, 80);
|
||||
assertTrue(lines.some(l => l.includes("Export Options")), "shows export header");
|
||||
assertTrue(lines.some(l => l.includes("[m]")), "shows markdown option");
|
||||
assertTrue(lines.some(l => l.includes("[j]")), "shows json option");
|
||||
assertTrue(lines.some(l => l.includes("[s]")), "shows snapshot option");
|
||||
assert.ok(lines.some(l => l.includes("Export Options")), "shows export header");
|
||||
assert.ok(lines.some(l => l.includes("[m]")), "shows markdown option");
|
||||
assert.ok(lines.some(l => l.includes("[j]")), "shows json option");
|
||||
assert.ok(lines.some(l => l.includes("[s]")), "shows snapshot option");
|
||||
}
|
||||
|
||||
// ─── renderKnowledgeView ────────────────────────────────────────────────────
|
||||
|
|
@ -600,13 +600,13 @@ console.log("\n=== renderKnowledgeView ===");
|
|||
});
|
||||
|
||||
const lines = renderKnowledgeView(data, mockTheme, 80);
|
||||
assertTrue(lines.some(l => l.includes("Rules")), "shows rules section");
|
||||
assertTrue(lines.some(l => l.includes("K001")), "shows rule ID");
|
||||
assertTrue(lines.some(l => l.includes("Always use transactions")), "shows rule content");
|
||||
assertTrue(lines.some(l => l.includes("Patterns")), "shows patterns section");
|
||||
assertTrue(lines.some(l => l.includes("P001")), "shows pattern ID");
|
||||
assertTrue(lines.some(l => l.includes("Lessons Learned")), "shows lessons section");
|
||||
assertTrue(lines.some(l => l.includes("L001")), "shows lesson ID");
|
||||
assert.ok(lines.some(l => l.includes("Rules")), "shows rules section");
|
||||
assert.ok(lines.some(l => l.includes("K001")), "shows rule ID");
|
||||
assert.ok(lines.some(l => l.includes("Always use transactions")), "shows rule content");
|
||||
assert.ok(lines.some(l => l.includes("Patterns")), "shows patterns section");
|
||||
assert.ok(lines.some(l => l.includes("P001")), "shows pattern ID");
|
||||
assert.ok(lines.some(l => l.includes("Lessons Learned")), "shows lessons section");
|
||||
assert.ok(lines.some(l => l.includes("L001")), "shows lesson ID");
|
||||
}
|
||||
|
||||
{
|
||||
|
|
@ -614,7 +614,7 @@ console.log("\n=== renderKnowledgeView ===");
|
|||
knowledge: { exists: false, rules: [], patterns: [], lessons: [] },
|
||||
});
|
||||
const lines = renderKnowledgeView(data, mockTheme, 80);
|
||||
assertTrue(lines.some(l => l.includes("No KNOWLEDGE.md found")), "shows no-knowledge message");
|
||||
assert.ok(lines.some(l => l.includes("No KNOWLEDGE.md found")), "shows no-knowledge message");
|
||||
}
|
||||
|
||||
// ─── renderCapturesView ─────────────────────────────────────────────────────
|
||||
|
|
@ -635,11 +635,11 @@ console.log("\n=== renderCapturesView ===");
|
|||
});
|
||||
|
||||
const lines = renderCapturesView(data, mockTheme, 80);
|
||||
assertTrue(lines.some(l => l.includes("3") && l.includes("total")), "shows total count");
|
||||
assertTrue(lines.some(l => l.includes("1") && l.includes("pending")), "shows pending count");
|
||||
assertTrue(lines.some(l => l.includes("CAP-abc123")), "shows capture ID");
|
||||
assertTrue(lines.some(l => l.includes("(inject)")), "shows classification badge");
|
||||
assertTrue(lines.some(l => l.includes("[pending]")), "shows status badge");
|
||||
assert.ok(lines.some(l => l.includes("3") && l.includes("total")), "shows total count");
|
||||
assert.ok(lines.some(l => l.includes("1") && l.includes("pending")), "shows pending count");
|
||||
assert.ok(lines.some(l => l.includes("CAP-abc123")), "shows capture ID");
|
||||
assert.ok(lines.some(l => l.includes("(inject)")), "shows classification badge");
|
||||
assert.ok(lines.some(l => l.includes("[pending]")), "shows status badge");
|
||||
}
|
||||
|
||||
{
|
||||
|
|
@ -647,7 +647,7 @@ console.log("\n=== renderCapturesView ===");
|
|||
captures: { entries: [], pendingCount: 0, totalCount: 0 },
|
||||
});
|
||||
const lines = renderCapturesView(data, mockTheme, 80);
|
||||
assertTrue(lines.some(l => l.includes("No captures recorded")), "shows empty state");
|
||||
assert.ok(lines.some(l => l.includes("No captures recorded")), "shows empty state");
|
||||
}
|
||||
|
||||
// ─── renderHealthView ───────────────────────────────────────────────────────
|
||||
|
|
@ -682,17 +682,17 @@ console.log("\n=== renderHealthView ===");
|
|||
});
|
||||
|
||||
const lines = renderHealthView(data, mockTheme, 80);
|
||||
assertTrue(lines.some(l => l.includes("Budget")), "shows budget section");
|
||||
assertTrue(lines.some(l => l.includes("Ceiling")), "shows budget ceiling");
|
||||
assertTrue(lines.some(l => l.includes("$20.00")), "shows ceiling amount");
|
||||
assertTrue(lines.some(l => l.includes("Pressure")), "shows pressure section");
|
||||
assertTrue(lines.some(l => l.includes("30.0%")), "shows truncation rate");
|
||||
assertTrue(lines.some(l => l.includes("Routing")), "shows routing section");
|
||||
assertTrue(lines.some(l => l.includes("standard")), "shows tier name");
|
||||
assertTrue(lines.some(l => l.includes("2 downgraded")), "shows downgraded count");
|
||||
assertTrue(lines.some(l => l.includes("Dynamic routing")), "shows savings line");
|
||||
assertTrue(lines.some(l => l.includes("Session")), "shows session section");
|
||||
assertTrue(lines.some(l => l.includes("Tool calls: 50")), "shows tool calls");
|
||||
assert.ok(lines.some(l => l.includes("Budget")), "shows budget section");
|
||||
assert.ok(lines.some(l => l.includes("Ceiling")), "shows budget ceiling");
|
||||
assert.ok(lines.some(l => l.includes("$20.00")), "shows ceiling amount");
|
||||
assert.ok(lines.some(l => l.includes("Pressure")), "shows pressure section");
|
||||
assert.ok(lines.some(l => l.includes("30.0%")), "shows truncation rate");
|
||||
assert.ok(lines.some(l => l.includes("Routing")), "shows routing section");
|
||||
assert.ok(lines.some(l => l.includes("standard")), "shows tier name");
|
||||
assert.ok(lines.some(l => l.includes("2 downgraded")), "shows downgraded count");
|
||||
assert.ok(lines.some(l => l.includes("Dynamic routing")), "shows savings line");
|
||||
assert.ok(lines.some(l => l.includes("Session")), "shows session section");
|
||||
assert.ok(lines.some(l => l.includes("Tool calls: 50")), "shows tool calls");
|
||||
}
|
||||
|
||||
{
|
||||
|
|
@ -709,10 +709,8 @@ console.log("\n=== renderHealthView ===");
|
|||
});
|
||||
|
||||
const lines = renderHealthView(data, mockTheme, 80);
|
||||
assertTrue(lines.some(l => l.includes("No budget ceiling set")), "shows no-ceiling message");
|
||||
assertTrue(lines.some(l => l.includes("compact")), "shows token profile");
|
||||
assert.ok(lines.some(l => l.includes("No budget ceiling set")), "shows no-ceiling message");
|
||||
assert.ok(lines.some(l => l.includes("compact")), "shows token profile");
|
||||
}
|
||||
|
||||
// ─── Report ─────────────────────────────────────────────────────────────────
|
||||
|
||||
report();
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@
|
|||
* strips backslashes (escape characters), producing `C:Usersuserproject`.
|
||||
*/
|
||||
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
// ─── shellEscape + path normalization ──────────────────────────────────────
|
||||
|
||||
|
|
@ -25,42 +25,42 @@ function bashPath(p: string): string {
|
|||
console.log("\n=== Windows backslash path normalization (#1436) ===");
|
||||
|
||||
// Backslash paths are converted to forward slashes
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
bashPath("C:\\Users\\user\\project"),
|
||||
"'C:/Users/user/project'",
|
||||
"backslash path normalised to forward slashes in shell-escaped string",
|
||||
);
|
||||
|
||||
// Unix paths pass through unchanged
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
bashPath("/home/user/project"),
|
||||
"'/home/user/project'",
|
||||
"Unix path unchanged",
|
||||
);
|
||||
|
||||
// Mixed separators are normalised
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
bashPath("C:\\Users/user\\project/src"),
|
||||
"'C:/Users/user/project/src'",
|
||||
"mixed separators normalised",
|
||||
);
|
||||
|
||||
// Paths with single quotes are still properly escaped
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
bashPath("C:\\Users\\o'brien\\project"),
|
||||
"'C:/Users/o'\\''brien/project'",
|
||||
"single quote in path is escaped after normalisation",
|
||||
);
|
||||
|
||||
// UNC paths
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
bashPath("\\\\server\\share\\dir"),
|
||||
"'//server/share/dir'",
|
||||
"UNC path normalised",
|
||||
);
|
||||
|
||||
// Empty string
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
bashPath(""),
|
||||
"''",
|
||||
"empty string handled",
|
||||
|
|
@ -72,14 +72,14 @@ console.log("\n=== cd command construction with normalised paths ===");
|
|||
|
||||
const windowsCwd = "C:\\Users\\user\\project\\.gsd\\worktrees\\M001";
|
||||
const cdCommand = `cd ${bashPath(windowsCwd)}`;
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
cdCommand,
|
||||
"cd 'C:/Users/user/project/.gsd/worktrees/M001'",
|
||||
"cd command uses forward slashes for Windows worktree path",
|
||||
);
|
||||
|
||||
// Verify the mangled form from #1436 is NOT produced
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
!cdCommand.includes("C:Users"),
|
||||
"mangled path C:Usersuserproject must not appear",
|
||||
);
|
||||
|
|
@ -90,10 +90,8 @@ console.log("\n=== teardown orphan warning path formatting ===");
|
|||
|
||||
const windowsWtDir = "C:\\Users\\user\\project\\.gsd\\worktrees\\M001";
|
||||
const helpCommand = `rm -rf "${windowsWtDir.replaceAll("\\", "/")}"`;
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
helpCommand,
|
||||
'rm -rf "C:/Users/user/project/.gsd/worktrees/M001"',
|
||||
"orphan cleanup help command uses forward slashes",
|
||||
);
|
||||
|
||||
report();
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
* and the hasActiveWorkers() status check.
|
||||
*/
|
||||
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
registerWorker,
|
||||
updateWorker,
|
||||
|
|
@ -15,7 +16,6 @@ import {
|
|||
resetWorkerRegistry,
|
||||
} from '../../subagent/worker-registry.ts';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
// ─── Setup ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -28,15 +28,15 @@ console.log("\n=== Worker Registration ===");
|
|||
{
|
||||
resetWorkerRegistry();
|
||||
const id = registerWorker("scout", "Explore codebase", 0, 3, "batch-1");
|
||||
assertTrue(id.startsWith("worker-"), "worker ID has correct prefix");
|
||||
assert.ok(id.startsWith("worker-"), "worker ID has correct prefix");
|
||||
const workers = getActiveWorkers();
|
||||
assertEq(workers.length, 1, "one worker registered");
|
||||
assertEq(workers[0].agent, "scout", "worker agent name correct");
|
||||
assertEq(workers[0].task, "Explore codebase", "worker task correct");
|
||||
assertEq(workers[0].status, "running", "worker starts as running");
|
||||
assertEq(workers[0].index, 0, "worker index correct");
|
||||
assertEq(workers[0].batchSize, 3, "worker batch size correct");
|
||||
assertEq(workers[0].batchId, "batch-1", "worker batch ID correct");
|
||||
assert.deepStrictEqual(workers.length, 1, "one worker registered");
|
||||
assert.deepStrictEqual(workers[0].agent, "scout", "worker agent name correct");
|
||||
assert.deepStrictEqual(workers[0].task, "Explore codebase", "worker task correct");
|
||||
assert.deepStrictEqual(workers[0].status, "running", "worker starts as running");
|
||||
assert.deepStrictEqual(workers[0].index, 0, "worker index correct");
|
||||
assert.deepStrictEqual(workers[0].batchSize, 3, "worker batch size correct");
|
||||
assert.deepStrictEqual(workers[0].batchId, "batch-1", "worker batch ID correct");
|
||||
}
|
||||
|
||||
// ─── Multiple workers in a batch ──────────────────────────────────────────────
|
||||
|
|
@ -50,14 +50,14 @@ console.log("\n=== Multiple Workers in a Batch ===");
|
|||
const id3 = registerWorker("worker", "Task C", 2, 3, "batch-2");
|
||||
|
||||
const workers = getActiveWorkers();
|
||||
assertEq(workers.length, 3, "three workers registered");
|
||||
assertTrue(hasActiveWorkers(), "has active workers");
|
||||
assert.deepStrictEqual(workers.length, 3, "three workers registered");
|
||||
assert.ok(hasActiveWorkers(), "has active workers");
|
||||
|
||||
const batches = getWorkerBatches();
|
||||
assertEq(batches.size, 1, "one batch");
|
||||
assert.deepStrictEqual(batches.size, 1, "one batch");
|
||||
const batch = batches.get("batch-2");
|
||||
assertTrue(batch !== undefined, "batch-2 exists");
|
||||
assertEq(batch!.length, 3, "batch has 3 workers");
|
||||
assert.ok(batch !== undefined, "batch-2 exists");
|
||||
assert.deepStrictEqual(batch!.length, 3, "batch has 3 workers");
|
||||
}
|
||||
|
||||
// ─── Worker status updates ────────────────────────────────────────────────────
|
||||
|
|
@ -72,11 +72,11 @@ console.log("\n=== Worker Status Updates ===");
|
|||
updateWorker(id1, "completed");
|
||||
const workers = getActiveWorkers();
|
||||
const w1 = workers.find(w => w.id === id1);
|
||||
assertEq(w1?.status, "completed", "worker 1 marked completed");
|
||||
assert.deepStrictEqual(w1?.status, "completed", "worker 1 marked completed");
|
||||
|
||||
const w2 = workers.find(w => w.id === id2);
|
||||
assertEq(w2?.status, "running", "worker 2 still running");
|
||||
assertTrue(hasActiveWorkers(), "still has active workers (worker 2 running)");
|
||||
assert.deepStrictEqual(w2?.status, "running", "worker 2 still running");
|
||||
assert.ok(hasActiveWorkers(), "still has active workers (worker 2 running)");
|
||||
}
|
||||
|
||||
// ─── Failed worker ────────────────────────────────────────────────────────────
|
||||
|
|
@ -88,7 +88,7 @@ console.log("\n=== Failed Worker ===");
|
|||
const id = registerWorker("scout", "Task A", 0, 1, "batch-4");
|
||||
updateWorker(id, "failed");
|
||||
const workers = getActiveWorkers();
|
||||
assertEq(workers[0].status, "failed", "worker marked failed");
|
||||
assert.deepStrictEqual(workers[0].status, "failed", "worker marked failed");
|
||||
}
|
||||
|
||||
// ─── Multiple batches ─────────────────────────────────────────────────────────
|
||||
|
|
@ -102,9 +102,9 @@ console.log("\n=== Multiple Batches ===");
|
|||
registerWorker("researcher", "Task C", 0, 1, "batch-6");
|
||||
|
||||
const batches = getWorkerBatches();
|
||||
assertEq(batches.size, 2, "two batches");
|
||||
assertEq(batches.get("batch-5")!.length, 2, "batch-5 has 2 workers");
|
||||
assertEq(batches.get("batch-6")!.length, 1, "batch-6 has 1 worker");
|
||||
assert.deepStrictEqual(batches.size, 2, "two batches");
|
||||
assert.deepStrictEqual(batches.get("batch-5")!.length, 2, "batch-5 has 2 workers");
|
||||
assert.deepStrictEqual(batches.get("batch-6")!.length, 1, "batch-6 has 1 worker");
|
||||
}
|
||||
|
||||
// ─── hasActiveWorkers with all completed ──────────────────────────────────────
|
||||
|
|
@ -117,7 +117,7 @@ console.log("\n=== hasActiveWorkers — all completed ===");
|
|||
const id2 = registerWorker("worker", "Task B", 1, 2, "batch-7");
|
||||
updateWorker(id1, "completed");
|
||||
updateWorker(id2, "completed");
|
||||
assertTrue(!hasActiveWorkers(), "no active workers when all completed");
|
||||
assert.ok(!hasActiveWorkers(), "no active workers when all completed");
|
||||
}
|
||||
|
||||
// ─── Reset clears everything ─────────────────────────────────────────────────
|
||||
|
|
@ -126,10 +126,10 @@ console.log("\n=== Reset ===");
|
|||
|
||||
{
|
||||
registerWorker("scout", "Task", 0, 1, "batch-8");
|
||||
assertTrue(getActiveWorkers().length > 0, "workers exist before reset");
|
||||
assert.ok(getActiveWorkers().length > 0, "workers exist before reset");
|
||||
resetWorkerRegistry();
|
||||
assertEq(getActiveWorkers().length, 0, "no workers after reset");
|
||||
assertTrue(!hasActiveWorkers(), "hasActiveWorkers false after reset");
|
||||
assert.deepStrictEqual(getActiveWorkers().length, 0, "no workers after reset");
|
||||
assert.ok(!hasActiveWorkers(), "hasActiveWorkers false after reset");
|
||||
}
|
||||
|
||||
// ─── Update non-existent worker is no-op ──────────────────────────────────────
|
||||
|
|
@ -140,9 +140,7 @@ console.log("\n=== Update non-existent worker ===");
|
|||
resetWorkerRegistry();
|
||||
// Should not throw
|
||||
updateWorker("nonexistent-id", "completed");
|
||||
assertEq(getActiveWorkers().length, 0, "no workers created by updating nonexistent");
|
||||
assert.deepStrictEqual(getActiveWorkers().length, 0, "no workers created by updating nonexistent");
|
||||
}
|
||||
|
||||
// ─── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
report();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
//
|
||||
// Tests registry loading, template resolution, auto-detection, and listing.
|
||||
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
loadRegistry,
|
||||
resolveByName,
|
||||
|
|
@ -12,7 +13,6 @@ import {
|
|||
loadWorkflowTemplate,
|
||||
} from '../workflow-templates.ts';
|
||||
|
||||
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Registry Loading
|
||||
|
|
@ -22,23 +22,23 @@ console.log('\n── Registry Loading ──');
|
|||
|
||||
{
|
||||
const registry = loadRegistry();
|
||||
assertTrue(registry !== null, 'Registry should load');
|
||||
assertEq(registry.version, 1, 'Registry version should be 1');
|
||||
assertTrue(Object.keys(registry.templates).length >= 8, 'Should have at least 8 templates');
|
||||
assert.ok(registry !== null, 'Registry should load');
|
||||
assert.deepStrictEqual(registry.version, 1, 'Registry version should be 1');
|
||||
assert.ok(Object.keys(registry.templates).length >= 8, 'Should have at least 8 templates');
|
||||
|
||||
// Verify required template keys exist
|
||||
const expectedIds = ['full-project', 'bugfix', 'small-feature', 'refactor', 'spike', 'hotfix', 'security-audit', 'dep-upgrade'];
|
||||
for (const id of expectedIds) {
|
||||
assertTrue(id in registry.templates, `Template "${id}" should exist in registry`);
|
||||
assert.ok(id in registry.templates, `Template "${id}" should exist in registry`);
|
||||
}
|
||||
|
||||
// Verify each template has required fields
|
||||
for (const [id, entry] of Object.entries(registry.templates)) {
|
||||
assertTrue(typeof entry.name === 'string' && entry.name.length > 0, `${id}: name should be non-empty string`);
|
||||
assertTrue(typeof entry.description === 'string' && entry.description.length > 0, `${id}: description should be non-empty`);
|
||||
assertTrue(typeof entry.file === 'string' && entry.file.endsWith('.md'), `${id}: file should be a .md path`);
|
||||
assertTrue(Array.isArray(entry.phases) && entry.phases.length > 0, `${id}: phases should be non-empty array`);
|
||||
assertTrue(Array.isArray(entry.triggers) && entry.triggers.length > 0, `${id}: triggers should be non-empty array`);
|
||||
assert.ok(typeof entry.name === 'string' && entry.name.length > 0, `${id}: name should be non-empty string`);
|
||||
assert.ok(typeof entry.description === 'string' && entry.description.length > 0, `${id}: description should be non-empty`);
|
||||
assert.ok(typeof entry.file === 'string' && entry.file.endsWith('.md'), `${id}: file should be a .md path`);
|
||||
assert.ok(Array.isArray(entry.phases) && entry.phases.length > 0, `${id}: phases should be non-empty array`);
|
||||
assert.ok(Array.isArray(entry.triggers) && entry.triggers.length > 0, `${id}: triggers should be non-empty array`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -51,31 +51,31 @@ console.log('\n── Resolve by Name ──');
|
|||
{
|
||||
// Exact match
|
||||
const bugfix = resolveByName('bugfix');
|
||||
assertTrue(bugfix !== null, 'Should resolve "bugfix"');
|
||||
assertEq(bugfix!.id, 'bugfix', 'ID should be "bugfix"');
|
||||
assertEq(bugfix!.confidence, 'exact', 'Exact name should have exact confidence');
|
||||
assert.ok(bugfix !== null, 'Should resolve "bugfix"');
|
||||
assert.deepStrictEqual(bugfix!.id, 'bugfix', 'ID should be "bugfix"');
|
||||
assert.deepStrictEqual(bugfix!.confidence, 'exact', 'Exact name should have exact confidence');
|
||||
|
||||
// Case-insensitive name match
|
||||
const spike = resolveByName('Research Spike');
|
||||
assertTrue(spike !== null, 'Should resolve "Research Spike" by name');
|
||||
assertEq(spike!.id, 'spike', 'Should resolve to spike');
|
||||
assert.ok(spike !== null, 'Should resolve "Research Spike" by name');
|
||||
assert.deepStrictEqual(spike!.id, 'spike', 'Should resolve to spike');
|
||||
|
||||
// Alias match
|
||||
const bug = resolveByName('bug');
|
||||
assertTrue(bug !== null, 'Should resolve "bug" alias');
|
||||
assertEq(bug!.id, 'bugfix', 'Alias "bug" should map to bugfix');
|
||||
assert.ok(bug !== null, 'Should resolve "bug" alias');
|
||||
assert.deepStrictEqual(bug!.id, 'bugfix', 'Alias "bug" should map to bugfix');
|
||||
|
||||
const feat = resolveByName('feat');
|
||||
assertTrue(feat !== null, 'Should resolve "feat" alias');
|
||||
assertEq(feat!.id, 'small-feature', 'Alias "feat" should map to small-feature');
|
||||
assert.ok(feat !== null, 'Should resolve "feat" alias');
|
||||
assert.deepStrictEqual(feat!.id, 'small-feature', 'Alias "feat" should map to small-feature');
|
||||
|
||||
const deps = resolveByName('deps');
|
||||
assertTrue(deps !== null, 'Should resolve "deps" alias');
|
||||
assertEq(deps!.id, 'dep-upgrade', 'Alias "deps" should map to dep-upgrade');
|
||||
assert.ok(deps !== null, 'Should resolve "deps" alias');
|
||||
assert.deepStrictEqual(deps!.id, 'dep-upgrade', 'Alias "deps" should map to dep-upgrade');
|
||||
|
||||
// No match
|
||||
const missing = resolveByName('nonexistent-template');
|
||||
assertTrue(missing === null, 'Should return null for unknown template');
|
||||
assert.ok(missing === null, 'Should return null for unknown template');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -87,32 +87,32 @@ console.log('\n── Auto-Detection ──');
|
|||
{
|
||||
// Should detect bugfix from "fix" keyword
|
||||
const fixMatches = autoDetect('fix the login button');
|
||||
assertTrue(fixMatches.length > 0, 'Should detect matches for "fix the login button"');
|
||||
assertTrue(fixMatches.some(m => m.id === 'bugfix'), 'Should include bugfix in matches');
|
||||
assert.ok(fixMatches.length > 0, 'Should detect matches for "fix the login button"');
|
||||
assert.ok(fixMatches.some(m => m.id === 'bugfix'), 'Should include bugfix in matches');
|
||||
|
||||
// Should detect spike from "research" keyword
|
||||
const researchMatches = autoDetect('research authentication libraries');
|
||||
assertTrue(researchMatches.length > 0, 'Should detect matches for "research"');
|
||||
assertTrue(researchMatches.some(m => m.id === 'spike'), 'Should include spike in matches');
|
||||
assert.ok(researchMatches.length > 0, 'Should detect matches for "research"');
|
||||
assert.ok(researchMatches.some(m => m.id === 'spike'), 'Should include spike in matches');
|
||||
|
||||
// Should detect hotfix from "urgent" keyword
|
||||
const urgentMatches = autoDetect('urgent production is down');
|
||||
assertTrue(urgentMatches.length > 0, 'Should detect matches for "urgent"');
|
||||
assertTrue(urgentMatches.some(m => m.id === 'hotfix'), 'Should include hotfix in matches');
|
||||
assert.ok(urgentMatches.length > 0, 'Should detect matches for "urgent"');
|
||||
assert.ok(urgentMatches.some(m => m.id === 'hotfix'), 'Should include hotfix in matches');
|
||||
|
||||
// Should detect dep-upgrade from "upgrade" keyword
|
||||
const upgradeMatches = autoDetect('upgrade react to v19');
|
||||
assertTrue(upgradeMatches.length > 0, 'Should detect matches for "upgrade"');
|
||||
assertTrue(upgradeMatches.some(m => m.id === 'dep-upgrade'), 'Should include dep-upgrade in matches');
|
||||
assert.ok(upgradeMatches.length > 0, 'Should detect matches for "upgrade"');
|
||||
assert.ok(upgradeMatches.some(m => m.id === 'dep-upgrade'), 'Should include dep-upgrade in matches');
|
||||
|
||||
// Multi-word triggers should score higher
|
||||
const projectMatches = autoDetect('create a new project from scratch');
|
||||
const projectMatch = projectMatches.find(m => m.id === 'full-project');
|
||||
assertTrue(projectMatch !== undefined, 'Should detect full-project for "from scratch"');
|
||||
assert.ok(projectMatch !== undefined, 'Should detect full-project for "from scratch"');
|
||||
|
||||
// Empty input should return no matches
|
||||
const emptyMatches = autoDetect('');
|
||||
assertEq(emptyMatches.length, 0, 'Empty input should return no matches');
|
||||
assert.deepStrictEqual(emptyMatches.length, 0, 'Empty input should return no matches');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -123,11 +123,11 @@ console.log('\n── List Templates ──');
|
|||
|
||||
{
|
||||
const output = listTemplates();
|
||||
assertTrue(output.includes('Workflow Templates'), 'Should have header');
|
||||
assertTrue(output.includes('bugfix'), 'Should list bugfix');
|
||||
assertTrue(output.includes('spike'), 'Should list spike');
|
||||
assertTrue(output.includes('hotfix'), 'Should list hotfix');
|
||||
assertTrue(output.includes('/gsd start'), 'Should include usage hint');
|
||||
assert.ok(output.includes('Workflow Templates'), 'Should have header');
|
||||
assert.ok(output.includes('bugfix'), 'Should list bugfix');
|
||||
assert.ok(output.includes('spike'), 'Should list spike');
|
||||
assert.ok(output.includes('hotfix'), 'Should list hotfix');
|
||||
assert.ok(output.includes('/gsd start'), 'Should include usage hint');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -138,13 +138,13 @@ console.log('\n── Template Info ──');
|
|||
|
||||
{
|
||||
const info = getTemplateInfo('bugfix');
|
||||
assertTrue(info !== null, 'Should return info for bugfix');
|
||||
assertTrue(info!.includes('Bug Fix'), 'Should include template name');
|
||||
assertTrue(info!.includes('triage'), 'Should include phase names');
|
||||
assertTrue(info!.includes('Triggers'), 'Should include triggers section');
|
||||
assert.ok(info !== null, 'Should return info for bugfix');
|
||||
assert.ok(info!.includes('Bug Fix'), 'Should include template name');
|
||||
assert.ok(info!.includes('triage'), 'Should include phase names');
|
||||
assert.ok(info!.includes('Triggers'), 'Should include triggers section');
|
||||
|
||||
const missing = getTemplateInfo('nonexistent');
|
||||
assertTrue(missing === null, 'Should return null for unknown template');
|
||||
assert.ok(missing === null, 'Should return null for unknown template');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -155,19 +155,17 @@ console.log('\n── Load Workflow Template ──');
|
|||
|
||||
{
|
||||
const content = loadWorkflowTemplate('bugfix');
|
||||
assertTrue(content !== null, 'Should load bugfix template');
|
||||
assertTrue(content!.includes('Bugfix Workflow'), 'Should contain workflow title');
|
||||
assertTrue(content!.includes('Phase 1: Triage'), 'Should contain triage phase');
|
||||
assertTrue(content!.includes('Phase 4: Ship'), 'Should contain ship phase');
|
||||
assert.ok(content !== null, 'Should load bugfix template');
|
||||
assert.ok(content!.includes('Bugfix Workflow'), 'Should contain workflow title');
|
||||
assert.ok(content!.includes('Phase 1: Triage'), 'Should contain triage phase');
|
||||
assert.ok(content!.includes('Phase 4: Ship'), 'Should contain ship phase');
|
||||
|
||||
const hotfixContent = loadWorkflowTemplate('hotfix');
|
||||
assertTrue(hotfixContent !== null, 'Should load hotfix template');
|
||||
assertTrue(hotfixContent!.includes('Hotfix Workflow'), 'Should contain hotfix title');
|
||||
assert.ok(hotfixContent !== null, 'Should load hotfix template');
|
||||
assert.ok(hotfixContent!.includes('Hotfix Workflow'), 'Should contain hotfix title');
|
||||
|
||||
const missingContent = loadWorkflowTemplate('nonexistent');
|
||||
assertTrue(missingContent === null, 'Should return null for unknown template');
|
||||
assert.ok(missingContent === null, 'Should return null for unknown template');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
report();
|
||||
|
|
|
|||
|
|
@ -14,12 +14,10 @@ import { join } from "node:path";
|
|||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { describe, it, after } from "node:test";
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { resolveGitDir } from "../worktree-manager.ts";
|
||||
import { detectWorktreeName, captureIntegrationBranch } from "../worktree.ts";
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -40,7 +38,6 @@ describe("worktree-bugfix", () => {
|
|||
const dirs: string[] = [];
|
||||
after(() => {
|
||||
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
||||
report();
|
||||
});
|
||||
|
||||
it("resolveGitDir returns .git directory in normal repo", () => {
|
||||
|
|
@ -48,8 +45,8 @@ describe("worktree-bugfix", () => {
|
|||
dirs.push(repo);
|
||||
initRepo(repo);
|
||||
const gitDir = resolveGitDir(repo);
|
||||
assertTrue(gitDir.endsWith(".git"), "ends with .git");
|
||||
assertTrue(existsSync(gitDir), ".git dir exists");
|
||||
assert.ok(gitDir.endsWith(".git"), "ends with .git");
|
||||
assert.ok(existsSync(gitDir), ".git dir exists");
|
||||
});
|
||||
|
||||
it("resolveGitDir follows gitdir: pointer in worktree", () => {
|
||||
|
|
@ -65,18 +62,18 @@ describe("worktree-bugfix", () => {
|
|||
writeFileSync(join(wtDir, ".git"), `gitdir: ${realGitDir}\n`);
|
||||
|
||||
const resolved = resolveGitDir(wtDir);
|
||||
assertEq(resolved, realGitDir, "resolves to real git dir");
|
||||
assert.deepStrictEqual(resolved, realGitDir, "resolves to real git dir");
|
||||
});
|
||||
|
||||
it("resolveGitDir returns default when .git doesn't exist", () => {
|
||||
const noGit = mkdtempSync(join(tmpdir(), "gsd-wt-fix-"));
|
||||
dirs.push(noGit);
|
||||
const gitDir = resolveGitDir(noGit);
|
||||
assertTrue(gitDir.endsWith(".git"), "returns default .git path");
|
||||
assert.ok(gitDir.endsWith(".git"), "returns default .git path");
|
||||
});
|
||||
|
||||
it("detectWorktreeName returns name for worktree path", () => {
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
detectWorktreeName("/project/.gsd/worktrees/M005"),
|
||||
"M005",
|
||||
"detects worktree name",
|
||||
|
|
@ -84,7 +81,7 @@ describe("worktree-bugfix", () => {
|
|||
});
|
||||
|
||||
it("detectWorktreeName returns null for normal repo", () => {
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
detectWorktreeName("/project"),
|
||||
null,
|
||||
"null for non-worktree path",
|
||||
|
|
@ -106,7 +103,7 @@ describe("worktree-bugfix", () => {
|
|||
// captureIntegrationBranch should be a no-op — no META.json written
|
||||
const metaPath = join(wtPath, ".gsd", "milestones", "M005", "M005-META.json");
|
||||
captureIntegrationBranch(wtPath, "M005");
|
||||
assertTrue(!existsSync(metaPath), "no META.json written in worktree");
|
||||
assert.ok(!existsSync(metaPath), "no META.json written in worktree");
|
||||
});
|
||||
|
||||
it("detectWorktreeName prevents pull in worktree context", () => {
|
||||
|
|
@ -114,7 +111,7 @@ describe("worktree-bugfix", () => {
|
|||
// the caller should skip pull/fetch operations
|
||||
const inWorktree = detectWorktreeName("/project/.gsd/worktrees/M006");
|
||||
const inNormal = detectWorktreeName("/project");
|
||||
assertTrue(inWorktree !== null, "worktree detected → skip pull");
|
||||
assertTrue(inNormal === null, "normal repo → allow pull");
|
||||
assert.ok(inWorktree !== null, "worktree detected → skip pull");
|
||||
assert.ok(inNormal === null, "normal repo → allow pull");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ import {
|
|||
isDbAvailable,
|
||||
} from "../gsd-db.ts";
|
||||
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
function run(command: string, cwd: string): string {
|
||||
return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
|
||||
|
|
@ -49,7 +49,7 @@ function createTempRepo(): string {
|
|||
return dir;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
describe('worktree-db-integration', async () => {
|
||||
const savedCwd = process.cwd();
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
|
|
@ -82,7 +82,7 @@ async function main(): Promise<void> {
|
|||
const wtPath = createAutoWorktree(tempDir, "M004");
|
||||
|
||||
const worktreeDbPath = join(worktreePath(tempDir, "M004"), ".gsd", "gsd.db");
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
existsSync(worktreeDbPath),
|
||||
"gsd.db exists in worktree .gsd after createAutoWorktree",
|
||||
);
|
||||
|
|
@ -107,10 +107,10 @@ async function main(): Promise<void> {
|
|||
console.error(" Unexpected throw:", err);
|
||||
}
|
||||
|
||||
assertTrue(!threw, "createAutoWorktree does not throw when no source DB");
|
||||
assert.ok(!threw, "createAutoWorktree does not throw when no source DB");
|
||||
|
||||
const worktreeDbPath = join(worktreePath(tempDir, "M004"), ".gsd", "gsd.db");
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
!existsSync(worktreeDbPath),
|
||||
"gsd.db is absent in worktree when source had none",
|
||||
);
|
||||
|
|
@ -145,7 +145,7 @@ async function main(): Promise<void> {
|
|||
|
||||
// Reconcile worktree → main
|
||||
const result = reconcileWorktreeDb(mainDbPath, worktreeDbPath);
|
||||
assertTrue(result.decisions >= 1, "reconcile reports at least 1 decision merged");
|
||||
assert.ok(result.decisions >= 1, "reconcile reports at least 1 decision merged");
|
||||
|
||||
// Open main DB and verify the row is present
|
||||
openDatabase(mainDbPath);
|
||||
|
|
@ -153,7 +153,7 @@ async function main(): Promise<void> {
|
|||
closeDatabase();
|
||||
|
||||
const found = decisions.some((d) => d.id === "D-WT-001");
|
||||
assertTrue(found, "worktree decision D-WT-001 present in main DB after reconcile");
|
||||
assert.ok(found, "worktree decision D-WT-001 present in main DB after reconcile");
|
||||
}
|
||||
|
||||
// ─── Test 4: reconcile non-fatal when both paths nonexistent ─────
|
||||
|
|
@ -165,7 +165,7 @@ async function main(): Promise<void> {
|
|||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
assertTrue(!threw, "reconcileWorktreeDb does not throw when worktree DB is absent");
|
||||
assert.ok(!threw, "reconcileWorktreeDb does not throw when worktree DB is absent");
|
||||
}
|
||||
|
||||
// ─── Test 5: failure path observable via stderr (diagnostic) ─────
|
||||
|
|
@ -181,10 +181,10 @@ async function main(): Promise<void> {
|
|||
closeDatabase();
|
||||
|
||||
const result = reconcileWorktreeDb(mainDbPath, "/definitely/does/not/exist.db");
|
||||
assertEq(result.decisions, 0, "decisions is 0 when worktree DB absent");
|
||||
assertEq(result.requirements, 0, "requirements is 0 when worktree DB absent");
|
||||
assertEq(result.artifacts, 0, "artifacts is 0 when worktree DB absent");
|
||||
assertEq(result.conflicts.length, 0, "conflicts is empty when worktree DB absent");
|
||||
assert.deepStrictEqual(result.decisions, 0, "decisions is 0 when worktree DB absent");
|
||||
assert.deepStrictEqual(result.requirements, 0, "requirements is 0 when worktree DB absent");
|
||||
assert.deepStrictEqual(result.artifacts, 0, "artifacts is 0 when worktree DB absent");
|
||||
assert.deepStrictEqual(result.conflicts.length, 0, "conflicts is empty when worktree DB absent");
|
||||
}
|
||||
|
||||
} finally {
|
||||
|
|
@ -199,8 +199,4 @@ async function main(): Promise<void> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createTestContext } from './test-helpers.ts';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
|
|
@ -16,7 +17,6 @@ import {
|
|||
reconcileWorktreeDb,
|
||||
} from '../gsd-db.ts';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Helpers
|
||||
|
|
@ -91,18 +91,18 @@ console.log('\n=== worktree-db: copyWorktreeDb ===');
|
|||
closeDatabase();
|
||||
|
||||
const result = copyWorktreeDb(srcDb, destDb);
|
||||
assertTrue(result === true, 'copyWorktreeDb returns true on success');
|
||||
assertTrue(fs.existsSync(destDb), 'dest DB file exists after copy');
|
||||
assert.ok(result === true, 'copyWorktreeDb returns true on success');
|
||||
assert.ok(fs.existsSync(destDb), 'dest DB file exists after copy');
|
||||
|
||||
// Open the copy and verify data is queryable
|
||||
openDatabase(destDb);
|
||||
const d = getDecisionById('D001');
|
||||
assertTrue(d !== null, 'decision queryable in copied DB');
|
||||
assertEq(d?.choice, 'node:sqlite', 'decision data preserved in copy');
|
||||
assert.ok(d !== null, 'decision queryable in copied DB');
|
||||
assert.deepStrictEqual(d?.choice, 'node:sqlite', 'decision data preserved in copy');
|
||||
|
||||
const r = getRequirementById('R001');
|
||||
assertTrue(r !== null, 'requirement queryable in copied DB');
|
||||
assertEq(r?.description, 'Must store decisions', 'requirement data preserved in copy');
|
||||
assert.ok(r !== null, 'requirement queryable in copied DB');
|
||||
assert.deepStrictEqual(r?.description, 'Must store decisions', 'requirement data preserved in copy');
|
||||
|
||||
cleanup(srcDir, destDir);
|
||||
}
|
||||
|
|
@ -123,9 +123,9 @@ console.log('\n=== worktree-db: copyWorktreeDb ===');
|
|||
|
||||
copyWorktreeDb(srcDb, destDb);
|
||||
|
||||
assertTrue(fs.existsSync(destDb), 'DB file copied');
|
||||
assertTrue(!fs.existsSync(destDb + '-wal'), 'WAL file NOT copied');
|
||||
assertTrue(!fs.existsSync(destDb + '-shm'), 'SHM file NOT copied');
|
||||
assert.ok(fs.existsSync(destDb), 'DB file copied');
|
||||
assert.ok(!fs.existsSync(destDb + '-wal'), 'WAL file NOT copied');
|
||||
assert.ok(!fs.existsSync(destDb + '-shm'), 'SHM file NOT copied');
|
||||
|
||||
cleanup(srcDir, destDir);
|
||||
}
|
||||
|
|
@ -134,7 +134,7 @@ console.log('\n=== worktree-db: copyWorktreeDb ===');
|
|||
{
|
||||
const destDir = tempDir();
|
||||
const result = copyWorktreeDb('/nonexistent/path/gsd.db', path.join(destDir, 'gsd.db'));
|
||||
assertEq(result, false, 'returns false for missing source');
|
||||
assert.deepStrictEqual(result, false, 'returns false for missing source');
|
||||
cleanup(destDir);
|
||||
}
|
||||
|
||||
|
|
@ -149,8 +149,8 @@ console.log('\n=== worktree-db: copyWorktreeDb ===');
|
|||
closeDatabase();
|
||||
|
||||
const result = copyWorktreeDb(srcDb, deepDest);
|
||||
assertTrue(result === true, 'copyWorktreeDb succeeds with nested dest');
|
||||
assertTrue(fs.existsSync(deepDest), 'DB file created at deeply nested path');
|
||||
assert.ok(result === true, 'copyWorktreeDb succeeds with nested dest');
|
||||
assert.ok(fs.existsSync(deepDest), 'DB file created at deeply nested path');
|
||||
|
||||
cleanup(srcDir, destDir);
|
||||
}
|
||||
|
|
@ -192,10 +192,10 @@ console.log('\n=== worktree-db: reconcileWorktreeDb ===');
|
|||
openDatabase(mainDb);
|
||||
const result = reconcileWorktreeDb(mainDb, wtDb);
|
||||
|
||||
assertTrue(result.decisions > 0, 'decisions merged count > 0');
|
||||
assert.ok(result.decisions > 0, 'decisions merged count > 0');
|
||||
const d2 = getDecisionById('D002');
|
||||
assertTrue(d2 !== null, 'D002 from worktree now in main');
|
||||
assertEq(d2?.choice, 'WAL', 'D002 data correct after merge');
|
||||
assert.ok(d2 !== null, 'D002 from worktree now in main');
|
||||
assert.deepStrictEqual(d2?.choice, 'WAL', 'D002 data correct after merge');
|
||||
|
||||
cleanup(mainDir, wtDir);
|
||||
}
|
||||
|
|
@ -231,10 +231,10 @@ console.log('\n=== worktree-db: reconcileWorktreeDb ===');
|
|||
openDatabase(mainDb);
|
||||
const result = reconcileWorktreeDb(mainDb, wtDb);
|
||||
|
||||
assertTrue(result.requirements > 0, 'requirements merged count > 0');
|
||||
assert.ok(result.requirements > 0, 'requirements merged count > 0');
|
||||
const r2 = getRequirementById('R002');
|
||||
assertTrue(r2 !== null, 'R002 from worktree now in main');
|
||||
assertEq(r2?.description, 'Must be fast', 'R002 data correct after merge');
|
||||
assert.ok(r2 !== null, 'R002 from worktree now in main');
|
||||
assert.deepStrictEqual(r2?.description, 'Must be fast', 'R002 data correct after merge');
|
||||
|
||||
cleanup(mainDir, wtDir);
|
||||
}
|
||||
|
|
@ -264,11 +264,11 @@ console.log('\n=== worktree-db: reconcileWorktreeDb ===');
|
|||
openDatabase(mainDb);
|
||||
const result = reconcileWorktreeDb(mainDb, wtDb);
|
||||
|
||||
assertTrue(result.artifacts > 0, 'artifacts merged count > 0');
|
||||
assert.ok(result.artifacts > 0, 'artifacts merged count > 0');
|
||||
const adapter = _getAdapter()!;
|
||||
const row = adapter.prepare('SELECT * FROM artifacts WHERE path = ?').get('docs/api.md');
|
||||
assertTrue(row !== null, 'artifact from worktree now in main');
|
||||
assertEq(row?.['artifact_type'], 'reference', 'artifact data correct after merge');
|
||||
assert.ok(row !== null, 'artifact from worktree now in main');
|
||||
assert.deepStrictEqual(row?.['artifact_type'], 'reference', 'artifact data correct after merge');
|
||||
|
||||
cleanup(mainDir, wtDir);
|
||||
}
|
||||
|
|
@ -305,15 +305,15 @@ console.log('\n=== worktree-db: reconcileWorktreeDb ===');
|
|||
openDatabase(mainDb);
|
||||
const result = reconcileWorktreeDb(mainDb, wtDb);
|
||||
|
||||
assertTrue(result.conflicts.length > 0, 'conflicts detected');
|
||||
assertTrue(
|
||||
assert.ok(result.conflicts.length > 0, 'conflicts detected');
|
||||
assert.ok(
|
||||
result.conflicts.some(c => c.includes('D001')),
|
||||
'conflict mentions D001',
|
||||
);
|
||||
|
||||
// Worktree-wins: D001 should now have worktree's value
|
||||
const d1 = getDecisionById('D001');
|
||||
assertEq(d1?.choice, 'sql.js', 'worktree wins on conflict (INSERT OR REPLACE)');
|
||||
assert.deepStrictEqual(d1?.choice, 'sql.js', 'worktree wins on conflict (INSERT OR REPLACE)');
|
||||
|
||||
cleanup(mainDir, wtDir);
|
||||
}
|
||||
|
|
@ -326,10 +326,10 @@ console.log('\n=== worktree-db: reconcileWorktreeDb ===');
|
|||
seedMainDb(mainDb);
|
||||
|
||||
const result = reconcileWorktreeDb(mainDb, '/nonexistent/worktree.db');
|
||||
assertEq(result.decisions, 0, 'no decisions merged for missing worktree DB');
|
||||
assertEq(result.requirements, 0, 'no requirements merged for missing worktree DB');
|
||||
assertEq(result.artifacts, 0, 'no artifacts merged for missing worktree DB');
|
||||
assertEq(result.conflicts.length, 0, 'no conflicts for missing worktree DB');
|
||||
assert.deepStrictEqual(result.decisions, 0, 'no decisions merged for missing worktree DB');
|
||||
assert.deepStrictEqual(result.requirements, 0, 'no requirements merged for missing worktree DB');
|
||||
assert.deepStrictEqual(result.artifacts, 0, 'no artifacts merged for missing worktree DB');
|
||||
assert.deepStrictEqual(result.conflicts.length, 0, 'no conflicts for missing worktree DB');
|
||||
|
||||
cleanup(mainDir);
|
||||
}
|
||||
|
|
@ -366,9 +366,9 @@ console.log('\n=== worktree-db: reconcileWorktreeDb ===');
|
|||
|
||||
openDatabase(mainDb);
|
||||
const result = reconcileWorktreeDb(mainDb, wtDb);
|
||||
assertTrue(result.decisions > 0, 'reconciliation works with spaces in path');
|
||||
assert.ok(result.decisions > 0, 'reconciliation works with spaces in path');
|
||||
const d3 = getDecisionById('D003');
|
||||
assertTrue(d3 !== null, 'D003 merged from worktree with spaces in path');
|
||||
assert.ok(d3 !== null, 'D003 merged from worktree with spaces in path');
|
||||
|
||||
cleanup(baseDir);
|
||||
}
|
||||
|
|
@ -388,7 +388,7 @@ console.log('\n=== worktree-db: reconcileWorktreeDb ===');
|
|||
reconcileWorktreeDb(mainDb, wtDb);
|
||||
|
||||
// Verify main DB is still fully usable after DETACH
|
||||
assertTrue(isDbAvailable(), 'DB still available after reconciliation');
|
||||
assert.ok(isDbAvailable(), 'DB still available after reconciliation');
|
||||
|
||||
insertDecision({
|
||||
id: 'D099',
|
||||
|
|
@ -403,8 +403,8 @@ console.log('\n=== worktree-db: reconcileWorktreeDb ===');
|
|||
});
|
||||
|
||||
const d99 = getDecisionById('D099');
|
||||
assertTrue(d99 !== null, 'can insert and query after reconciliation');
|
||||
assertEq(d99?.choice, 'works', 'post-reconcile data correct');
|
||||
assert.ok(d99 !== null, 'can insert and query after reconciliation');
|
||||
assert.deepStrictEqual(d99?.choice, 'works', 'post-reconcile data correct');
|
||||
|
||||
// Verify no "wt" database still attached
|
||||
const adapter = _getAdapter()!;
|
||||
|
|
@ -415,7 +415,7 @@ console.log('\n=== worktree-db: reconcileWorktreeDb ===');
|
|||
} catch {
|
||||
// Expected — wt should be detached
|
||||
}
|
||||
assertTrue(!wtAccessible, 'wt database is detached after reconciliation');
|
||||
assert.ok(!wtAccessible, 'wt database is detached after reconciliation');
|
||||
|
||||
cleanup(mainDir, wtDir);
|
||||
}
|
||||
|
|
@ -436,11 +436,10 @@ console.log('\n=== worktree-db: reconcileWorktreeDb ===');
|
|||
const result = reconcileWorktreeDb(mainDb, wtDb);
|
||||
|
||||
// Should still report counts for the existing rows (INSERT OR REPLACE touches them)
|
||||
assertTrue(result.conflicts.length === 0, 'no conflicts when DBs are identical');
|
||||
assertTrue(isDbAvailable(), 'DB usable after no-change reconciliation');
|
||||
assert.ok(result.conflicts.length === 0, 'no conflicts when DBs are identical');
|
||||
assert.ok(isDbAvailable(), 'DB usable after no-change reconciliation');
|
||||
|
||||
cleanup(mainDir, wtDir);
|
||||
}
|
||||
|
||||
// ─── Final Report ──────────────────────────────────────────────────────────
|
||||
report();
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@ import {
|
|||
import { getSliceBranchName } from "../worktree.ts";
|
||||
import { abortAndReset } from "../git-self-heal.ts";
|
||||
import { runGSDDoctor } from "../doctor.ts";
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
|
|
@ -80,7 +80,7 @@ function addSliceToMilestone(
|
|||
run(`git merge --no-ff ${sliceBranch} -m "merge ${sliceId}"`, wtPath);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
describe('worktree-e2e', async () => {
|
||||
const savedCwd = process.cwd();
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
|
|
@ -100,7 +100,7 @@ async function main(): Promise<void> {
|
|||
// Create worktree for M001
|
||||
const wtPath = createAutoWorktree(repo, "M001");
|
||||
tempDirs.push(wtPath);
|
||||
assertTrue(existsSync(wtPath), "worktree directory created");
|
||||
assert.ok(existsSync(wtPath), "worktree directory created");
|
||||
|
||||
// Add two slices with commits
|
||||
addSliceToMilestone(repo, wtPath, "M001", "S01", "Add auth", [
|
||||
|
|
@ -124,19 +124,19 @@ async function main(): Promise<void> {
|
|||
// Assert exactly one new commit on main
|
||||
const mainLogAfter = run("git log --oneline main", repo);
|
||||
const commitCountAfter = mainLogAfter.split("\n").length;
|
||||
assertEq(commitCountAfter, commitCountBefore + 1, "exactly one new commit on main");
|
||||
assert.deepStrictEqual(commitCountAfter, commitCountBefore + 1, "exactly one new commit on main");
|
||||
|
||||
// Commit message contains both slice titles
|
||||
const lastCommitMsg = run("git log -1 --format=%B main", repo);
|
||||
assertMatch(lastCommitMsg, /Add auth/, "commit message contains S01 title");
|
||||
assertMatch(lastCommitMsg, /Add dashboard/, "commit message contains S02 title");
|
||||
assert.match(lastCommitMsg, /Add auth/, "commit message contains S01 title");
|
||||
assert.match(lastCommitMsg, /Add dashboard/, "commit message contains S02 title");
|
||||
|
||||
// Worktree directory removed
|
||||
assertTrue(!existsSync(wtPath), "worktree directory removed after merge");
|
||||
assert.ok(!existsSync(wtPath), "worktree directory removed after merge");
|
||||
|
||||
// Milestone branch deleted
|
||||
const branches = run("git branch", repo);
|
||||
assertTrue(!branches.includes("milestone/M001"), "milestone branch deleted");
|
||||
assert.ok(!branches.includes("milestone/M001"), "milestone branch deleted");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
|
|
@ -159,11 +159,11 @@ async function main(): Promise<void> {
|
|||
|
||||
// Trigger merge conflict
|
||||
try { run("git merge feature", repo); } catch { /* expected */ }
|
||||
assertTrue(existsSync(join(repo, ".git", "MERGE_HEAD")), "MERGE_HEAD exists before abort");
|
||||
assert.ok(existsSync(join(repo, ".git", "MERGE_HEAD")), "MERGE_HEAD exists before abort");
|
||||
|
||||
const abortResult = abortAndReset(repo);
|
||||
assertTrue(!existsSync(join(repo, ".git", "MERGE_HEAD")), "MERGE_HEAD removed after abort");
|
||||
assertTrue(abortResult.cleaned.length > 0, "abortAndReset reports cleaned items");
|
||||
assert.ok(!existsSync(join(repo, ".git", "MERGE_HEAD")), "MERGE_HEAD removed after abort");
|
||||
assert.ok(abortResult.cleaned.length > 0, "abortAndReset reports cleaned items");
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
|
|
@ -211,19 +211,19 @@ _None_
|
|||
// Detect
|
||||
const detect = await runGSDDoctor(repo, { isolationMode: "worktree" });
|
||||
const orphanIssues = detect.issues.filter(i => i.code === "orphaned_auto_worktree");
|
||||
assertTrue(orphanIssues.length > 0, "doctor detects orphaned worktree");
|
||||
assertEq(orphanIssues[0]?.unitId, "M001", "orphaned worktree unitId is M001");
|
||||
assert.ok(orphanIssues.length > 0, "doctor detects orphaned worktree");
|
||||
assert.deepStrictEqual(orphanIssues[0]?.unitId, "M001", "orphaned worktree unitId is M001");
|
||||
|
||||
// Fix
|
||||
const fixed = await runGSDDoctor(repo, { fix: true, isolationMode: "worktree" });
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
fixed.fixesApplied.some(f => f.includes("removed orphaned worktree")),
|
||||
"doctor fix removes orphaned worktree",
|
||||
);
|
||||
|
||||
// Verify gone
|
||||
const wtList = run("git worktree list", repo);
|
||||
assertTrue(!wtList.includes("milestone/M001"), "worktree gone after doctor fix");
|
||||
assert.ok(!wtList.includes("milestone/M001"), "worktree gone after doctor fix");
|
||||
}
|
||||
} else {
|
||||
console.log("\n=== Doctor: orphaned worktree detection (skipped on Windows) ===");
|
||||
|
|
@ -234,8 +234,4 @@ _None_
|
|||
try { rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import { execSync } from "node:child_process";
|
|||
|
||||
import { getWorktreeHealth, formatWorktreeStatusLine } from "../worktree-health.ts";
|
||||
import { listWorktrees } from "../worktree-manager.ts";
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
function run(cmd: string, cwd: string): string {
|
||||
return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
|
||||
|
|
@ -32,11 +32,10 @@ function createBaseRepo(): string {
|
|||
return dir;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
describe('worktree-health', async () => {
|
||||
// Skip all tests on Windows — git worktree path resolution issues
|
||||
if (process.platform === "win32") {
|
||||
console.log("(all worktree-health tests skipped on Windows)");
|
||||
report();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -59,16 +58,16 @@ async function main(): Promise<void> {
|
|||
|
||||
const worktrees = listWorktrees(dir);
|
||||
const wt = worktrees.find(w => w.name === "done-feature");
|
||||
assertTrue(!!wt, "worktree found");
|
||||
assert.ok(!!wt, "worktree found");
|
||||
|
||||
const health = getWorktreeHealth(dir, wt!);
|
||||
assertTrue(health.mergedIntoMain, "branch detected as merged");
|
||||
assertTrue(!health.dirty, "not dirty");
|
||||
assertTrue(health.safeToRemove, "safe to remove");
|
||||
assert.ok(health.mergedIntoMain, "branch detected as merged");
|
||||
assert.ok(!health.dirty, "not dirty");
|
||||
assert.ok(health.safeToRemove, "safe to remove");
|
||||
|
||||
const line = formatWorktreeStatusLine(health);
|
||||
assertTrue(line.includes("merged"), "status line mentions merged");
|
||||
assertTrue(line.includes("safe to remove"), "status line mentions safe to remove");
|
||||
assert.ok(line.includes("merged"), "status line mentions merged");
|
||||
assert.ok(line.includes("safe to remove"), "status line mentions safe to remove");
|
||||
}
|
||||
|
||||
// ─── Test: unmerged worktree with dirty files ──────────────────────
|
||||
|
|
@ -89,13 +88,13 @@ async function main(): Promise<void> {
|
|||
|
||||
const worktrees = listWorktrees(dir);
|
||||
const wt = worktrees.find(w => w.name === "dirty-wip");
|
||||
assertTrue(!!wt, "worktree found");
|
||||
assert.ok(!!wt, "worktree found");
|
||||
|
||||
const health = getWorktreeHealth(dir, wt!);
|
||||
assertTrue(!health.mergedIntoMain, "not merged");
|
||||
assertTrue(health.dirty, "dirty detected");
|
||||
assertTrue(health.dirtyFileCount > 0, "dirty file count > 0");
|
||||
assertTrue(!health.safeToRemove, "not safe to remove");
|
||||
assert.ok(!health.mergedIntoMain, "not merged");
|
||||
assert.ok(health.dirty, "dirty detected");
|
||||
assert.ok(health.dirtyFileCount > 0, "dirty file count > 0");
|
||||
assert.ok(!health.safeToRemove, "not safe to remove");
|
||||
}
|
||||
|
||||
// ─── Test: unmerged worktree with unpushed commits ─────────────────
|
||||
|
|
@ -113,12 +112,12 @@ async function main(): Promise<void> {
|
|||
|
||||
const worktrees = listWorktrees(dir);
|
||||
const wt = worktrees.find(w => w.name === "unpushed");
|
||||
assertTrue(!!wt, "worktree found");
|
||||
assert.ok(!!wt, "worktree found");
|
||||
|
||||
const health = getWorktreeHealth(dir, wt!);
|
||||
assertTrue(!health.mergedIntoMain, "not merged");
|
||||
assertTrue(health.unpushedCommits > 0, "unpushed commits detected");
|
||||
assertTrue(!health.safeToRemove, "not safe to remove");
|
||||
assert.ok(!health.mergedIntoMain, "not merged");
|
||||
assert.ok(health.unpushedCommits > 0, "unpushed commits detected");
|
||||
assert.ok(!health.safeToRemove, "not safe to remove");
|
||||
}
|
||||
|
||||
// ─── Test: stale detection with short threshold ────────────────────
|
||||
|
|
@ -137,17 +136,17 @@ async function main(): Promise<void> {
|
|||
|
||||
const worktrees = listWorktrees(dir);
|
||||
const wt = worktrees.find(w => w.name === "stale-test");
|
||||
assertTrue(!!wt, "worktree found");
|
||||
assert.ok(!!wt, "worktree found");
|
||||
|
||||
// With staleDays=0, any worktree should be stale (commit was just now, but threshold is 0)
|
||||
// Actually, a just-created worktree has lastCommitAgeDays ~0 which is >= 0
|
||||
const health = getWorktreeHealth(dir, wt!, 0);
|
||||
assertTrue(health.stale, "stale with 0-day threshold");
|
||||
assertTrue(health.lastCommitAgeDays >= 0, "last commit age is non-negative");
|
||||
assert.ok(health.stale, "stale with 0-day threshold");
|
||||
assert.ok(health.lastCommitAgeDays >= 0, "last commit age is non-negative");
|
||||
|
||||
// With staleDays=9999, should NOT be stale
|
||||
const healthNotStale = getWorktreeHealth(dir, wt!, 9999);
|
||||
assertTrue(!healthNotStale.stale, "not stale with high threshold");
|
||||
assert.ok(!healthNotStale.stale, "not stale with high threshold");
|
||||
}
|
||||
|
||||
// ─── Test: formatWorktreeStatusLine for clean active worktree ──────
|
||||
|
|
@ -166,12 +165,12 @@ async function main(): Promise<void> {
|
|||
|
||||
const worktrees = listWorktrees(dir);
|
||||
const wt = worktrees.find(w => w.name === "clean-active");
|
||||
assertTrue(!!wt, "worktree found");
|
||||
assert.ok(!!wt, "worktree found");
|
||||
|
||||
const health = getWorktreeHealth(dir, wt!, 9999); // high threshold so not stale
|
||||
const line = formatWorktreeStatusLine(health);
|
||||
// Should show last commit age since it's not merged and not stale
|
||||
assertTrue(line.includes("last commit"), "shows last commit age for active worktree");
|
||||
assert.ok(line.includes("last commit"), "shows last commit age for active worktree");
|
||||
}
|
||||
|
||||
} finally {
|
||||
|
|
@ -179,8 +178,4 @@ async function main(): Promise<void> {
|
|||
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ import {
|
|||
} from "../worktree.ts";
|
||||
|
||||
import { deriveState } from "../state.ts";
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
function run(command: string, cwd: string): string {
|
||||
return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
|
||||
}
|
||||
|
|
@ -73,42 +73,42 @@ writeFileSync(
|
|||
run("git add .", base);
|
||||
run('git commit -m "chore: init"', base);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
describe('worktree-integration', async () => {
|
||||
// ── Verify main tree baseline ──────────────────────────────────────────────
|
||||
|
||||
console.log("\n=== Main tree baseline ===");
|
||||
assertEq(getMainBranch(base), "main", "main tree getMainBranch returns main");
|
||||
assertEq(detectWorktreeName(base), null, "main tree not detected as worktree");
|
||||
assert.deepStrictEqual(getMainBranch(base), "main", "main tree getMainBranch returns main");
|
||||
assert.deepStrictEqual(detectWorktreeName(base), null, "main tree not detected as worktree");
|
||||
|
||||
// ── Create worktree and verify detection ───────────────────────────────────
|
||||
|
||||
console.log("\n=== Create worktree ===");
|
||||
const wt = createWorktree(base, "alpha");
|
||||
assertTrue(existsSync(wt.path), "worktree created on disk");
|
||||
assertEq(wt.branch, "worktree/alpha", "worktree branch name");
|
||||
assert.ok(existsSync(wt.path), "worktree created on disk");
|
||||
assert.deepStrictEqual(wt.branch, "worktree/alpha", "worktree branch name");
|
||||
|
||||
console.log("\n=== Worktree detection ===");
|
||||
assertEq(detectWorktreeName(wt.path), "alpha", "detectWorktreeName inside worktree");
|
||||
assertEq(getMainBranch(wt.path), "worktree/alpha", "getMainBranch returns worktree branch inside worktree");
|
||||
assert.deepStrictEqual(detectWorktreeName(wt.path), "alpha", "detectWorktreeName inside worktree");
|
||||
assert.deepStrictEqual(getMainBranch(wt.path), "worktree/alpha", "getMainBranch returns worktree branch inside worktree");
|
||||
|
||||
// ── Verify current branch inside worktree ──────────────────────────────────
|
||||
|
||||
console.log("\n=== Worktree initial branch ===");
|
||||
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "worktree starts on its own branch");
|
||||
assert.deepStrictEqual(getCurrentBranch(wt.path), "worktree/alpha", "worktree starts on its own branch");
|
||||
|
||||
// ── Verify branch name helper ──────────────────────────────────────────────
|
||||
|
||||
console.log("\n=== getSliceBranchName with worktree ===");
|
||||
assertEq(getSliceBranchName("M001", "S01", "alpha"), "gsd/alpha/M001/S01", "explicit worktree param");
|
||||
assertEq(getSliceBranchName("M001", "S01"), "gsd/M001/S01", "no worktree param = plain branch");
|
||||
assert.deepStrictEqual(getSliceBranchName("M001", "S01", "alpha"), "gsd/alpha/M001/S01", "explicit worktree param");
|
||||
assert.deepStrictEqual(getSliceBranchName("M001", "S01"), "gsd/M001/S01", "no worktree param = plain branch");
|
||||
|
||||
// ── Slice branch creation and detection inside worktree ────────────────────
|
||||
|
||||
console.log("\n=== Slice branch in worktree ===");
|
||||
const sliceBranch = getSliceBranchName("M001", "S01", "alpha");
|
||||
run(`git checkout -b ${sliceBranch}`, wt.path);
|
||||
assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S01", "worktree-namespaced slice branch");
|
||||
assertTrue(SLICE_BRANCH_RE.test(getCurrentBranch(wt.path)), "slice branch regex matches namespaced branch");
|
||||
assert.deepStrictEqual(getCurrentBranch(wt.path), "gsd/alpha/M001/S01", "worktree-namespaced slice branch");
|
||||
assert.ok(SLICE_BRANCH_RE.test(getCurrentBranch(wt.path)), "slice branch regex matches namespaced branch");
|
||||
|
||||
// ── Do work on slice branch, then merge to worktree branch ─────────────────
|
||||
|
||||
|
|
@ -119,23 +119,23 @@ async function main(): Promise<void> {
|
|||
|
||||
// Checkout worktree base branch and merge slice branch
|
||||
run("git checkout worktree/alpha", wt.path);
|
||||
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "back on worktree branch");
|
||||
assert.deepStrictEqual(getCurrentBranch(wt.path), "worktree/alpha", "back on worktree branch");
|
||||
|
||||
run(`git merge --no-ff ${sliceBranch} -m "feat(M001/S01): First"`, wt.path);
|
||||
run(`git branch -d ${sliceBranch}`, wt.path);
|
||||
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "still on worktree branch after merge");
|
||||
assertTrue(readFileSync(join(wt.path, "feature.txt"), "utf-8").includes("new feature"), "merge brought feature to worktree branch");
|
||||
assert.deepStrictEqual(getCurrentBranch(wt.path), "worktree/alpha", "still on worktree branch after merge");
|
||||
assert.ok(readFileSync(join(wt.path, "feature.txt"), "utf-8").includes("new feature"), "merge brought feature to worktree branch");
|
||||
|
||||
// Verify slice branch is gone
|
||||
const branches = run("git branch", base);
|
||||
assertTrue(!branches.includes("gsd/alpha/M001/S01"), "slice branch cleaned up");
|
||||
assert.ok(!branches.includes("gsd/alpha/M001/S01"), "slice branch cleaned up");
|
||||
|
||||
// ── Second slice in same worktree ──────────────────────────────────────────
|
||||
|
||||
console.log("\n=== Second slice in worktree ===");
|
||||
const sliceBranch2 = getSliceBranchName("M001", "S02", "alpha");
|
||||
run(`git checkout -b ${sliceBranch2}`, wt.path);
|
||||
assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S02", "on S02 namespaced branch");
|
||||
assert.deepStrictEqual(getCurrentBranch(wt.path), "gsd/alpha/M001/S02", "on S02 namespaced branch");
|
||||
|
||||
writeFileSync(join(wt.path, "feature2.txt"), "second feature\n", "utf-8");
|
||||
run("git add .", wt.path);
|
||||
|
|
@ -144,28 +144,28 @@ async function main(): Promise<void> {
|
|||
run("git checkout worktree/alpha", wt.path);
|
||||
run(`git merge --no-ff ${sliceBranch2} -m "feat(M001/S02): Second"`, wt.path);
|
||||
run(`git branch -d ${sliceBranch2}`, wt.path);
|
||||
assertEq(getCurrentBranch(wt.path), "worktree/alpha", "back on worktree branch");
|
||||
assert.deepStrictEqual(getCurrentBranch(wt.path), "worktree/alpha", "back on worktree branch");
|
||||
|
||||
// ── Parallel worktrees don't conflict ──────────────────────────────────────
|
||||
|
||||
console.log("\n=== Parallel worktrees ===");
|
||||
const wt2 = createWorktree(base, "beta");
|
||||
assertEq(getMainBranch(wt2.path), "worktree/beta", "second worktree has its own base branch");
|
||||
assert.deepStrictEqual(getMainBranch(wt2.path), "worktree/beta", "second worktree has its own base branch");
|
||||
|
||||
// Both worktrees can create S01 branches without conflict
|
||||
const betaBranch = getSliceBranchName("M001", "S01", "beta");
|
||||
run(`git checkout -b ${betaBranch}`, wt2.path);
|
||||
assertEq(getCurrentBranch(wt2.path), "gsd/beta/M001/S01", "beta has its own namespaced branch");
|
||||
assert.deepStrictEqual(getCurrentBranch(wt2.path), "gsd/beta/M001/S01", "beta has its own namespaced branch");
|
||||
|
||||
// Alpha worktree can re-create S01 too (it was already merged+deleted earlier)
|
||||
const alphaReBranch = getSliceBranchName("M001", "S01", "alpha");
|
||||
run(`git checkout -b ${alphaReBranch}`, wt.path);
|
||||
assertEq(getCurrentBranch(wt.path), "gsd/alpha/M001/S01", "alpha re-created S01");
|
||||
assert.deepStrictEqual(getCurrentBranch(wt.path), "gsd/alpha/M001/S01", "alpha re-created S01");
|
||||
|
||||
// Both exist simultaneously
|
||||
const allBranches = run("git branch", base);
|
||||
assertTrue(allBranches.includes("gsd/alpha/M001/S01"), "alpha S01 branch exists");
|
||||
assertTrue(allBranches.includes("gsd/beta/M001/S01"), "beta S01 branch exists");
|
||||
assert.ok(allBranches.includes("gsd/alpha/M001/S01"), "alpha S01 branch exists");
|
||||
assert.ok(allBranches.includes("gsd/beta/M001/S01"), "beta S01 branch exists");
|
||||
|
||||
// ── State derivation in worktree ───────────────────────────────────────────
|
||||
|
||||
|
|
@ -173,8 +173,8 @@ async function main(): Promise<void> {
|
|||
// Switch alpha back to its base so deriveState sees milestone files
|
||||
run("git checkout worktree/alpha", wt.path);
|
||||
const state = await deriveState(wt.path);
|
||||
assertTrue(state.activeMilestone !== null, "worktree has active milestone");
|
||||
assertEq(state.activeMilestone?.id, "M001", "correct milestone");
|
||||
assert.ok(state.activeMilestone !== null, "worktree has active milestone");
|
||||
assert.deepStrictEqual(state.activeMilestone?.id, "M001", "correct milestone");
|
||||
|
||||
// ── autoCommitCurrentBranch in worktree ────────────────────────────────────
|
||||
|
||||
|
|
@ -183,8 +183,8 @@ async function main(): Promise<void> {
|
|||
run(`git checkout ${betaBranch}`, wt2.path);
|
||||
writeFileSync(join(wt2.path, "dirty.txt"), "uncommitted\n", "utf-8");
|
||||
const commitMsg = autoCommitCurrentBranch(wt2.path, "execute-task", "M001/S01/T01");
|
||||
assertTrue(commitMsg !== null, "auto-commit works in worktree");
|
||||
assertEq(run("git status --short", wt2.path), "", "worktree clean after auto-commit");
|
||||
assert.ok(commitMsg !== null, "auto-commit works in worktree");
|
||||
assert.deepStrictEqual(run("git status --short", wt2.path), "", "worktree clean after auto-commit");
|
||||
|
||||
// ── Cleanup ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -194,14 +194,7 @@ async function main(): Promise<void> {
|
|||
run("git checkout worktree/beta", wt2.path);
|
||||
removeWorktree(base, "alpha", { deleteBranch: true });
|
||||
removeWorktree(base, "beta", { deleteBranch: true });
|
||||
assertEq(listWorktrees(base).length, 0, "all worktrees removed");
|
||||
assert.deepStrictEqual(listWorktrees(base).length, 0, "all worktrees removed");
|
||||
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,9 +20,9 @@ import {
|
|||
listWorktrees,
|
||||
worktreePath,
|
||||
} from "../worktree-manager.ts";
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
function run(command: string, cwd: string): string {
|
||||
return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
|
||||
|
|
@ -46,8 +46,8 @@ mkdirSync(join(externalState, "worktrees"), { recursive: true });
|
|||
symlinkSync(externalState, join(base, ".gsd"));
|
||||
|
||||
// Verify the symlink is in place
|
||||
assertTrue(existsSync(join(base, ".gsd")), ".gsd symlink exists");
|
||||
assertTrue(
|
||||
assert.ok(existsSync(join(base, ".gsd")), ".gsd symlink exists");
|
||||
assert.ok(
|
||||
realpathSync(join(base, ".gsd")) === externalState,
|
||||
".gsd resolves to external state dir",
|
||||
);
|
||||
|
|
@ -57,28 +57,28 @@ writeFileSync(join(base, "README.md"), "# Test\n", "utf-8");
|
|||
run("git add .", base);
|
||||
run('git commit -m "init"', base);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
describe('worktree-symlink-removal', async () => {
|
||||
console.log("\n=== #1852: removeWorktree with symlinked .gsd/ ===");
|
||||
|
||||
// Create a worktree — git will resolve the symlink and register
|
||||
// the worktree at the external path
|
||||
const info = createWorktree(base, "M002", { branch: "milestone/M002" });
|
||||
assertTrue(info.exists, "worktree created");
|
||||
assert.ok(info.exists, "worktree created");
|
||||
|
||||
// Verify worktree was created at the resolved (external) path
|
||||
const realWtPath = realpathSync(info.path);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
realWtPath.startsWith(externalState),
|
||||
`worktree real path (${realWtPath}) is under external state dir`,
|
||||
);
|
||||
|
||||
// Verify git registered the worktree
|
||||
const gitList = run("git worktree list", base);
|
||||
assertTrue(gitList.includes("M002"), "git worktree list shows M002");
|
||||
assert.ok(gitList.includes("M002"), "git worktree list shows M002");
|
||||
|
||||
// The computed path via worktreePath uses the symlink path
|
||||
const computedPath = worktreePath(base, "M002");
|
||||
assertTrue(existsSync(computedPath), "computed path exists (via symlink)");
|
||||
assert.ok(existsSync(computedPath), "computed path exists (via symlink)");
|
||||
|
||||
// Simulate what syncStateToProjectRoot does: replace the .gsd symlink with
|
||||
// a real directory containing stale worktree data. This causes worktreePath()
|
||||
|
|
@ -93,8 +93,8 @@ async function main(): Promise<void> {
|
|||
// Now worktreePath(base, "M002") points to the LOCAL stale dir, not the
|
||||
// external path where git actually registered the worktree.
|
||||
const stalePath = worktreePath(base, "M002");
|
||||
assertTrue(existsSync(stalePath), "stale local worktree dir exists");
|
||||
assertTrue(
|
||||
assert.ok(existsSync(stalePath), "stale local worktree dir exists");
|
||||
assert.ok(
|
||||
stalePath !== realWtPath,
|
||||
`computed path (${stalePath}) differs from git-registered path (${realWtPath})`,
|
||||
);
|
||||
|
|
@ -105,36 +105,29 @@ async function main(): Promise<void> {
|
|||
|
||||
// After removal, the worktree should be gone from git's list
|
||||
const gitListAfter = run("git worktree list", base);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
!gitListAfter.includes("M002"),
|
||||
"worktree removed from git worktree list after removeWorktree",
|
||||
);
|
||||
|
||||
// The branch should be deleted
|
||||
const branches = run("git branch", base);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
!branches.includes("milestone/M002"),
|
||||
"milestone/M002 branch deleted after removeWorktree",
|
||||
);
|
||||
|
||||
// The worktree directory should be gone
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
!existsSync(realWtPath),
|
||||
"worktree directory removed from disk",
|
||||
);
|
||||
|
||||
// List should be empty
|
||||
const listed = listWorktrees(base);
|
||||
assertEq(listed.length, 0, "no worktrees listed after removal");
|
||||
assert.deepStrictEqual(listed.length, 0, "no worktrees listed after removal");
|
||||
|
||||
// Cleanup
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
rmSync(externalState, { recursive: true, force: true });
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@ import { tmpdir } from 'node:os';
|
|||
|
||||
import { syncProjectRootToWorktree } from '../auto-worktree-sync.ts';
|
||||
import { syncGsdStateToWorktree, syncWorktreeStateBack } from '../auto-worktree.ts';
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { assertTrue, report } = createTestContext();
|
||||
|
||||
function createBase(name: string): string {
|
||||
const base = mkdtempSync(join(tmpdir(), `gsd-wt-sync-${name}-`));
|
||||
|
|
@ -43,7 +43,7 @@ function cleanup(base: string): void {
|
|||
rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
describe('worktree-sync-milestones', async () => {
|
||||
|
||||
// ─── 1. Milestone directory synced from main to worktree ──────────────
|
||||
console.log('\n=== 1. milestone directory synced from main to worktree ===');
|
||||
|
|
@ -58,13 +58,13 @@ async function main(): Promise<void> {
|
|||
writeFileSync(join(m001Dir, 'M001-ROADMAP.md'), '# Roadmap');
|
||||
|
||||
// Worktree has no M001
|
||||
assertTrue(!existsSync(join(wtBase, '.gsd', 'milestones', 'M001')), 'M001 missing before sync');
|
||||
assert.ok(!existsSync(join(wtBase, '.gsd', 'milestones', 'M001')), 'M001 missing before sync');
|
||||
|
||||
syncProjectRootToWorktree(mainBase, wtBase, 'M001');
|
||||
|
||||
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001')), '#1311: M001 synced to worktree');
|
||||
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-CONTEXT.md')), 'M001 CONTEXT synced');
|
||||
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-ROADMAP.md')), 'M001 ROADMAP synced');
|
||||
assert.ok(existsSync(join(wtBase, '.gsd', 'milestones', 'M001')), '#1311: M001 synced to worktree');
|
||||
assert.ok(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-CONTEXT.md')), 'M001 CONTEXT synced');
|
||||
assert.ok(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-ROADMAP.md')), 'M001 ROADMAP synced');
|
||||
} finally {
|
||||
cleanup(mainBase);
|
||||
cleanup(wtBase);
|
||||
|
|
@ -92,8 +92,8 @@ async function main(): Promise<void> {
|
|||
|
||||
syncProjectRootToWorktree(mainBase, wtBase, 'M001');
|
||||
|
||||
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'slices', 'S02')), '#1311: S02 synced');
|
||||
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'S02-PLAN.md')), 'S02 PLAN synced');
|
||||
assert.ok(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'slices', 'S02')), '#1311: S02 synced');
|
||||
assert.ok(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'slices', 'S02', 'S02-PLAN.md')), 'S02 PLAN synced');
|
||||
} finally {
|
||||
cleanup(mainBase);
|
||||
cleanup(wtBase);
|
||||
|
|
@ -113,11 +113,11 @@ async function main(): Promise<void> {
|
|||
|
||||
// Worktree has a stale gsd.db
|
||||
writeFileSync(join(wtBase, '.gsd', 'gsd.db'), 'stale data');
|
||||
assertTrue(existsSync(join(wtBase, '.gsd', 'gsd.db')), 'gsd.db exists before sync');
|
||||
assert.ok(existsSync(join(wtBase, '.gsd', 'gsd.db')), 'gsd.db exists before sync');
|
||||
|
||||
syncProjectRootToWorktree(mainBase, wtBase, 'M001');
|
||||
|
||||
assertTrue(!existsSync(join(wtBase, '.gsd', 'gsd.db')), '#853: gsd.db deleted after sync');
|
||||
assert.ok(!existsSync(join(wtBase, '.gsd', 'gsd.db')), '#853: gsd.db deleted after sync');
|
||||
} finally {
|
||||
cleanup(mainBase);
|
||||
cleanup(wtBase);
|
||||
|
|
@ -131,7 +131,7 @@ async function main(): Promise<void> {
|
|||
try {
|
||||
// Should not throw
|
||||
syncProjectRootToWorktree(base, base, 'M001');
|
||||
assertTrue(true, 'no crash when paths are equal');
|
||||
assert.ok(true, 'no crash when paths are equal');
|
||||
} finally {
|
||||
cleanup(base);
|
||||
}
|
||||
|
|
@ -144,7 +144,7 @@ async function main(): Promise<void> {
|
|||
const wtBase = createBase('wt');
|
||||
try {
|
||||
syncProjectRootToWorktree(mainBase, wtBase, null);
|
||||
assertTrue(true, 'no crash when milestoneId is null');
|
||||
assert.ok(true, 'no crash when milestoneId is null');
|
||||
} finally {
|
||||
cleanup(mainBase);
|
||||
cleanup(wtBase);
|
||||
|
|
@ -155,7 +155,7 @@ async function main(): Promise<void> {
|
|||
console.log('\n=== 6. non-existent directories → no-op ===');
|
||||
{
|
||||
syncProjectRootToWorktree('/tmp/does-not-exist-main', '/tmp/does-not-exist-wt', 'M001');
|
||||
assertTrue(true, 'no crash on missing directories');
|
||||
assert.ok(true, 'no crash on missing directories');
|
||||
}
|
||||
|
||||
// ─── 7. milestones/ directory created in worktree when missing ────────
|
||||
|
|
@ -174,15 +174,15 @@ async function main(): Promise<void> {
|
|||
writeFileSync(join(m001Dir, 'M001-CONTEXT.md'), '# M001 Context');
|
||||
writeFileSync(join(m001Dir, 'M001-ROADMAP.md'), '# M001 Roadmap');
|
||||
|
||||
assertTrue(!existsSync(join(wtBase, '.gsd', 'milestones')), 'milestones/ missing before sync');
|
||||
assert.ok(!existsSync(join(wtBase, '.gsd', 'milestones')), 'milestones/ missing before sync');
|
||||
|
||||
const result = syncGsdStateToWorktree(mainBase, wtBase);
|
||||
|
||||
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones')), 'milestones/ created in worktree');
|
||||
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001')), 'M001 synced to worktree');
|
||||
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-CONTEXT.md')), 'M001 CONTEXT synced');
|
||||
assertTrue(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-ROADMAP.md')), 'M001 ROADMAP synced');
|
||||
assertTrue(result.synced.length > 0, 'sync reported files');
|
||||
assert.ok(existsSync(join(wtBase, '.gsd', 'milestones')), 'milestones/ created in worktree');
|
||||
assert.ok(existsSync(join(wtBase, '.gsd', 'milestones', 'M001')), 'M001 synced to worktree');
|
||||
assert.ok(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-CONTEXT.md')), 'M001 CONTEXT synced');
|
||||
assert.ok(existsSync(join(wtBase, '.gsd', 'milestones', 'M001', 'M001-ROADMAP.md')), 'M001 ROADMAP synced');
|
||||
assert.ok(result.synced.length > 0, 'sync reported files');
|
||||
} finally {
|
||||
cleanup(mainBase);
|
||||
rmSync(wtBase, { recursive: true, force: true });
|
||||
|
|
@ -212,19 +212,19 @@ async function main(): Promise<void> {
|
|||
const mainSliceDir = join(mainBase, '.gsd', 'milestones', 'M001', 'slices', 'S01');
|
||||
const mainTasksDir = join(mainSliceDir, 'tasks');
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
existsSync(join(mainSliceDir, 'S01-SUMMARY.md')),
|
||||
'#1678: slice SUMMARY synced to project root',
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
existsSync(join(mainTasksDir, 'T01-SUMMARY.md')),
|
||||
'#1678: task T01-SUMMARY synced to project root',
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
existsSync(join(mainTasksDir, 'T02-SUMMARY.md')),
|
||||
'#1678: task T02-SUMMARY synced to project root',
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
synced.some((p) => p.includes('tasks/T01-SUMMARY.md')),
|
||||
'#1678: task summary appears in synced list',
|
||||
);
|
||||
|
|
@ -257,27 +257,27 @@ async function main(): Promise<void> {
|
|||
|
||||
// Root-level files should be overwritten with worktree versions
|
||||
const reqContent = readFileSync(join(mainBase, '.gsd', 'REQUIREMENTS.md'), 'utf-8');
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
reqContent.includes('R002'),
|
||||
'REQUIREMENTS.md updated with worktree content',
|
||||
);
|
||||
|
||||
const projContent = readFileSync(join(mainBase, '.gsd', 'PROJECT.md'), 'utf-8');
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
projContent.includes('M002'),
|
||||
'PROJECT.md updated with worktree content',
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
existsSync(join(mainBase, '.gsd', 'KNOWLEDGE.md')),
|
||||
'KNOWLEDGE.md synced from worktree',
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
synced.includes('REQUIREMENTS.md'),
|
||||
'REQUIREMENTS.md appears in synced list',
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
synced.includes('PROJECT.md'),
|
||||
'PROJECT.md appears in synced list',
|
||||
);
|
||||
|
|
@ -308,11 +308,11 @@ async function main(): Promise<void> {
|
|||
writeFileSync(join(wtM002Dir, 'M002-abc123-ROADMAP.md'), '# M002 Roadmap');
|
||||
|
||||
// Main has neither
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
!existsSync(join(mainBase, '.gsd', 'milestones', 'M001')),
|
||||
'M001 missing in main before sync',
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
!existsSync(join(mainBase, '.gsd', 'milestones', 'M002-abc123')),
|
||||
'M002 missing in main before sync',
|
||||
);
|
||||
|
|
@ -321,22 +321,22 @@ async function main(): Promise<void> {
|
|||
const { synced } = syncWorktreeStateBack(mainBase, wtBase, 'M001');
|
||||
|
||||
// M001 should be synced (current milestone — always synced)
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
existsSync(join(mainBase, '.gsd', 'milestones', 'M001', 'M001-SUMMARY.md')),
|
||||
'M001 SUMMARY synced to main',
|
||||
);
|
||||
|
||||
// M002 should ALSO be synced (next milestone — the fix)
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
existsSync(join(mainBase, '.gsd', 'milestones', 'M002-abc123', 'M002-abc123-CONTEXT.md')),
|
||||
'M002 CONTEXT synced to main (next-milestone fix)',
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
existsSync(join(mainBase, '.gsd', 'milestones', 'M002-abc123', 'M002-abc123-ROADMAP.md')),
|
||||
'M002 ROADMAP synced to main (next-milestone fix)',
|
||||
);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
synced.some((p) => p.includes('M002-abc123')),
|
||||
'M002 appears in synced list',
|
||||
);
|
||||
|
|
@ -387,34 +387,34 @@ async function main(): Promise<void> {
|
|||
const { synced } = syncWorktreeStateBack(mainBase, wtBase, 'M006-589wvh');
|
||||
|
||||
// Verify M006 artifacts synced
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
existsSync(join(mainBase, '.gsd', 'milestones', 'M006-589wvh', 'M006-589wvh-SUMMARY.md')),
|
||||
'M006 SUMMARY synced',
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
existsSync(join(mainBase, '.gsd', 'milestones', 'M006-589wvh', 'slices', 'S01', 'S01-SUMMARY.md')),
|
||||
'M006 S01 SUMMARY synced',
|
||||
);
|
||||
|
||||
// Verify M007 artifacts synced (the critical fix)
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
existsSync(join(mainBase, '.gsd', 'milestones', 'M007-wortc8', 'M007-wortc8-CONTEXT.md')),
|
||||
'M007 CONTEXT synced to main (next-milestone)',
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
existsSync(join(mainBase, '.gsd', 'milestones', 'M007-wortc8', 'M007-wortc8-ROADMAP.md')),
|
||||
'M007 ROADMAP synced to main (next-milestone)',
|
||||
);
|
||||
|
||||
// Verify root-level files updated
|
||||
const reqContent = readFileSync(join(mainBase, '.gsd', 'REQUIREMENTS.md'), 'utf-8');
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
reqContent.includes('R090'),
|
||||
'REQUIREMENTS.md has R090 from worktree',
|
||||
);
|
||||
|
||||
const projContent = readFileSync(join(mainBase, '.gsd', 'PROJECT.md'), 'utf-8');
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
projContent.includes('M007'),
|
||||
'PROJECT.md has M007 from worktree',
|
||||
);
|
||||
|
|
@ -441,11 +441,11 @@ async function main(): Promise<void> {
|
|||
|
||||
// Main's REQUIREMENTS should be untouched (worktree had nothing to sync)
|
||||
const content = readFileSync(join(mainBase, '.gsd', 'REQUIREMENTS.md'), 'utf-8');
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
content === '# Original',
|
||||
'REQUIREMENTS.md unchanged when worktree has no copy',
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
!synced.includes('REQUIREMENTS.md'),
|
||||
'REQUIREMENTS.md not in synced list',
|
||||
);
|
||||
|
|
@ -473,11 +473,11 @@ async function main(): Promise<void> {
|
|||
);
|
||||
|
||||
// Main has neither
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
!existsSync(join(mainBase, '.gsd', 'QUEUE.md')),
|
||||
'QUEUE.md missing in main before sync',
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
!existsSync(join(mainBase, '.gsd', 'completed-units.json')),
|
||||
'completed-units.json missing in main before sync',
|
||||
);
|
||||
|
|
@ -485,31 +485,31 @@ async function main(): Promise<void> {
|
|||
const { synced } = syncWorktreeStateBack(mainBase, wtBase, 'M001');
|
||||
|
||||
// QUEUE.md should be synced
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
existsSync(join(mainBase, '.gsd', 'QUEUE.md')),
|
||||
'#1787: QUEUE.md synced from worktree to main',
|
||||
);
|
||||
const queueContent = readFileSync(join(mainBase, '.gsd', 'QUEUE.md'), 'utf-8');
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
queueContent.includes('M002 next'),
|
||||
'#1787: QUEUE.md has correct content',
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
synced.includes('QUEUE.md'),
|
||||
'#1787: QUEUE.md appears in synced list',
|
||||
);
|
||||
|
||||
// completed-units.json should be synced
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
existsSync(join(mainBase, '.gsd', 'completed-units.json')),
|
||||
'#1787: completed-units.json synced from worktree to main',
|
||||
);
|
||||
const cuContent = readFileSync(join(mainBase, '.gsd', 'completed-units.json'), 'utf-8');
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
cuContent.includes('M001-S01-T01'),
|
||||
'#1787: completed-units.json has correct content',
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
synced.includes('completed-units.json'),
|
||||
'#1787: completed-units.json appears in synced list',
|
||||
);
|
||||
|
|
@ -535,20 +535,20 @@ async function main(): Promise<void> {
|
|||
mkdirSync(suffixDir, { recursive: true });
|
||||
writeFileSync(join(suffixDir, 'M001-abc123-CONTEXT.md'), '# M001 Context');
|
||||
|
||||
assertTrue(!existsSync(join(wtBase, '.gsd', 'milestones', 'sprint-alpha')), 'sprint-alpha missing before sync');
|
||||
assertTrue(!existsSync(join(wtBase, '.gsd', 'milestones', 'M001-abc123')), 'M001-abc123 missing before sync');
|
||||
assert.ok(!existsSync(join(wtBase, '.gsd', 'milestones', 'sprint-alpha')), 'sprint-alpha missing before sync');
|
||||
assert.ok(!existsSync(join(wtBase, '.gsd', 'milestones', 'M001-abc123')), 'M001-abc123 missing before sync');
|
||||
|
||||
const result = syncGsdStateToWorktree(mainBase, wtBase);
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
existsSync(join(wtBase, '.gsd', 'milestones', 'sprint-alpha', 'CONTEXT.md')),
|
||||
'#1547: non-standard milestone dir "sprint-alpha" synced to worktree',
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
existsSync(join(wtBase, '.gsd', 'milestones', 'M001-abc123', 'M001-abc123-CONTEXT.md')),
|
||||
'#1547: suffixed milestone dir "M001-abc123" synced to worktree',
|
||||
);
|
||||
assertTrue(result.synced.length > 0, 'sync reported files');
|
||||
assert.ok(result.synced.length > 0, 'sync reported files');
|
||||
} finally {
|
||||
cleanup(mainBase);
|
||||
cleanup(wtBase);
|
||||
|
|
@ -570,18 +570,18 @@ async function main(): Promise<void> {
|
|||
mkdirSync(wtCustomDir, { recursive: true });
|
||||
writeFileSync(join(wtCustomDir, 'SUMMARY.md'), '# Sprint Beta Summary');
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
!existsSync(join(mainBase, '.gsd', 'milestones', 'sprint-beta')),
|
||||
'sprint-beta missing in main before sync',
|
||||
);
|
||||
|
||||
const { synced } = syncWorktreeStateBack(mainBase, wtBase, 'M001');
|
||||
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
existsSync(join(mainBase, '.gsd', 'milestones', 'sprint-beta', 'SUMMARY.md')),
|
||||
'#1547: non-standard milestone dir "sprint-beta" synced back to main',
|
||||
);
|
||||
assertTrue(
|
||||
assert.ok(
|
||||
synced.some((p) => p.includes('sprint-beta')),
|
||||
'#1547: sprint-beta appears in synced list',
|
||||
);
|
||||
|
|
@ -590,11 +590,4 @@ async function main(): Promise<void> {
|
|||
rmSync(wtBase, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ import {
|
|||
} from "../worktree.ts";
|
||||
import { readIntegrationBranch } from "../git-service.ts";
|
||||
import { _resetHasChangesCache } from "../native-git-bridge.ts";
|
||||
import { createTestContext } from './test-helpers.ts';
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
/**
|
||||
* Normalize a path for reliable comparison on Windows CI runners.
|
||||
|
|
@ -47,56 +47,56 @@ writeFileSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLA
|
|||
run("git add .", base);
|
||||
run('git commit -m "chore: init"', base);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
describe('worktree', async () => {
|
||||
|
||||
console.log("\n=== autoCommitCurrentBranch ===");
|
||||
// Clean — should return null
|
||||
const cleanResult = autoCommitCurrentBranch(base, "execute-task", "M001/S01/T01");
|
||||
assertEq(cleanResult, null, "returns null for clean repo");
|
||||
assert.deepStrictEqual(cleanResult, null, "returns null for clean repo");
|
||||
|
||||
// Make dirty — reset the nativeHasChanges cache so the fresh dirt is detected
|
||||
_resetHasChangesCache();
|
||||
writeFileSync(join(base, "dirty.txt"), "uncommitted\n", "utf-8");
|
||||
const dirtyResult = autoCommitCurrentBranch(base, "execute-task", "M001/S01/T01");
|
||||
assertTrue(dirtyResult !== null, "returns commit message for dirty repo");
|
||||
assertTrue(dirtyResult!.includes("M001/S01/T01"), "commit message includes unit id");
|
||||
assertEq(run("git status --short", base), "", "repo is clean after auto-commit");
|
||||
assert.ok(dirtyResult !== null, "returns commit message for dirty repo");
|
||||
assert.ok(dirtyResult!.includes("M001/S01/T01"), "commit message includes unit id");
|
||||
assert.deepStrictEqual(run("git status --short", base), "", "repo is clean after auto-commit");
|
||||
|
||||
console.log("\n=== getSliceBranchName ===");
|
||||
assertEq(getSliceBranchName("M001", "S01"), "gsd/M001/S01", "branch name format correct");
|
||||
assertEq(getSliceBranchName("M001", "S01", null), "gsd/M001/S01", "null worktree = plain branch");
|
||||
assertEq(getSliceBranchName("M001", "S01", "my-wt"), "gsd/my-wt/M001/S01", "worktree-namespaced branch");
|
||||
assert.deepStrictEqual(getSliceBranchName("M001", "S01"), "gsd/M001/S01", "branch name format correct");
|
||||
assert.deepStrictEqual(getSliceBranchName("M001", "S01", null), "gsd/M001/S01", "null worktree = plain branch");
|
||||
assert.deepStrictEqual(getSliceBranchName("M001", "S01", "my-wt"), "gsd/my-wt/M001/S01", "worktree-namespaced branch");
|
||||
|
||||
console.log("\n=== parseSliceBranch ===");
|
||||
const plain = parseSliceBranch("gsd/M001/S01");
|
||||
assertTrue(plain !== null, "parses plain branch");
|
||||
assertEq(plain!.worktreeName, null, "plain branch has no worktree name");
|
||||
assertEq(plain!.milestoneId, "M001", "plain branch milestone");
|
||||
assertEq(plain!.sliceId, "S01", "plain branch slice");
|
||||
assert.ok(plain !== null, "parses plain branch");
|
||||
assert.deepStrictEqual(plain!.worktreeName, null, "plain branch has no worktree name");
|
||||
assert.deepStrictEqual(plain!.milestoneId, "M001", "plain branch milestone");
|
||||
assert.deepStrictEqual(plain!.sliceId, "S01", "plain branch slice");
|
||||
|
||||
const namespaced = parseSliceBranch("gsd/feature-auth/M001/S01");
|
||||
assertTrue(namespaced !== null, "parses worktree-namespaced branch");
|
||||
assertEq(namespaced!.worktreeName, "feature-auth", "worktree name extracted");
|
||||
assertEq(namespaced!.milestoneId, "M001", "namespaced branch milestone");
|
||||
assertEq(namespaced!.sliceId, "S01", "namespaced branch slice");
|
||||
assert.ok(namespaced !== null, "parses worktree-namespaced branch");
|
||||
assert.deepStrictEqual(namespaced!.worktreeName, "feature-auth", "worktree name extracted");
|
||||
assert.deepStrictEqual(namespaced!.milestoneId, "M001", "namespaced branch milestone");
|
||||
assert.deepStrictEqual(namespaced!.sliceId, "S01", "namespaced branch slice");
|
||||
|
||||
const invalid = parseSliceBranch("main");
|
||||
assertEq(invalid, null, "non-slice branch returns null");
|
||||
assert.deepStrictEqual(invalid, null, "non-slice branch returns null");
|
||||
|
||||
const worktreeBranch = parseSliceBranch("worktree/foo");
|
||||
assertEq(worktreeBranch, null, "worktree/ prefix is not a slice branch");
|
||||
assert.deepStrictEqual(worktreeBranch, null, "worktree/ prefix is not a slice branch");
|
||||
|
||||
console.log("\n=== SLICE_BRANCH_RE ===");
|
||||
assertTrue(SLICE_BRANCH_RE.test("gsd/M001/S01"), "regex matches plain branch");
|
||||
assertTrue(SLICE_BRANCH_RE.test("gsd/my-wt/M001/S01"), "regex matches worktree branch");
|
||||
assertTrue(!SLICE_BRANCH_RE.test("main"), "regex rejects main");
|
||||
assertTrue(!SLICE_BRANCH_RE.test("gsd/"), "regex rejects bare gsd/");
|
||||
assertTrue(!SLICE_BRANCH_RE.test("worktree/foo"), "regex rejects worktree/foo");
|
||||
assert.ok(SLICE_BRANCH_RE.test("gsd/M001/S01"), "regex matches plain branch");
|
||||
assert.ok(SLICE_BRANCH_RE.test("gsd/my-wt/M001/S01"), "regex matches worktree branch");
|
||||
assert.ok(!SLICE_BRANCH_RE.test("main"), "regex rejects main");
|
||||
assert.ok(!SLICE_BRANCH_RE.test("gsd/"), "regex rejects bare gsd/");
|
||||
assert.ok(!SLICE_BRANCH_RE.test("worktree/foo"), "regex rejects worktree/foo");
|
||||
|
||||
console.log("\n=== detectWorktreeName ===");
|
||||
assertEq(detectWorktreeName("/projects/myapp"), null, "no worktree in plain path");
|
||||
assertEq(detectWorktreeName("/projects/myapp/.gsd/worktrees/feature-auth"), "feature-auth", "detects worktree name");
|
||||
assertEq(detectWorktreeName("/projects/myapp/.gsd/worktrees/my-wt/subdir"), "my-wt", "detects worktree with subdir");
|
||||
assert.deepStrictEqual(detectWorktreeName("/projects/myapp"), null, "no worktree in plain path");
|
||||
assert.deepStrictEqual(detectWorktreeName("/projects/myapp/.gsd/worktrees/feature-auth"), "feature-auth", "detects worktree name");
|
||||
assert.deepStrictEqual(detectWorktreeName("/projects/myapp/.gsd/worktrees/my-wt/subdir"), "my-wt", "detects worktree with subdir");
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Integration branch — facade-level tests
|
||||
|
|
@ -115,16 +115,16 @@ async function main(): Promise<void> {
|
|||
run("git add -A && git commit -m init", repo);
|
||||
|
||||
run("git checkout -b f-123-thing", repo);
|
||||
assertEq(getCurrentBranch(repo), "f-123-thing", "on feature branch");
|
||||
assert.deepStrictEqual(getCurrentBranch(repo), "f-123-thing", "on feature branch");
|
||||
|
||||
const commitsBefore = run("git rev-list --count HEAD", repo);
|
||||
captureIntegrationBranch(repo, "M001");
|
||||
assertEq(readIntegrationBranch(repo, "M001"), "f-123-thing",
|
||||
assert.deepStrictEqual(readIntegrationBranch(repo, "M001"), "f-123-thing",
|
||||
"captureIntegrationBranch records the current branch");
|
||||
|
||||
// Metadata is stored in external state, not committed to git.
|
||||
const commitsAfter = run("git rev-list --count HEAD", repo);
|
||||
assertEq(commitsAfter, commitsBefore, "captureIntegrationBranch does not create a git commit");
|
||||
assert.deepStrictEqual(commitsAfter, commitsBefore, "captureIntegrationBranch does not create a git commit");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -144,7 +144,7 @@ async function main(): Promise<void> {
|
|||
run("git checkout -b gsd/M001/S01", repo);
|
||||
captureIntegrationBranch(repo, "M001");
|
||||
|
||||
assertEq(readIntegrationBranch(repo, "M001"), null,
|
||||
assert.deepStrictEqual(readIntegrationBranch(repo, "M001"), null,
|
||||
"capture from slice branch is a no-op");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
|
|
@ -167,12 +167,12 @@ async function main(): Promise<void> {
|
|||
|
||||
// Without milestone set, getMainBranch returns "main"
|
||||
setActiveMilestoneId(repo, null);
|
||||
assertEq(getMainBranch(repo), "main",
|
||||
assert.deepStrictEqual(getMainBranch(repo), "main",
|
||||
"getMainBranch returns main without milestone set");
|
||||
|
||||
// With milestone set, getMainBranch returns feature branch
|
||||
setActiveMilestoneId(repo, "M001");
|
||||
assertEq(getMainBranch(repo), "my-feature",
|
||||
assert.deepStrictEqual(getMainBranch(repo), "my-feature",
|
||||
"getMainBranch returns integration branch with milestone set");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
|
|
@ -180,22 +180,22 @@ async function main(): Promise<void> {
|
|||
|
||||
// ── detectWorktreeName: symlink-resolved paths ───────────────────────────
|
||||
console.log("\n=== detectWorktreeName (symlink-resolved paths) ===");
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
detectWorktreeName("/Users/fran/.gsd/projects/89e1c9ad49bf/worktrees/M001"),
|
||||
"M001",
|
||||
"detects milestone in symlink-resolved path",
|
||||
);
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
detectWorktreeName("/Users/fran/.gsd/projects/abc123/worktrees/M002/subdir"),
|
||||
"M002",
|
||||
"detects milestone with trailing subdir in symlink-resolved path",
|
||||
);
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
detectWorktreeName("/Users/fran/.gsd/projects/abc123"),
|
||||
null,
|
||||
"returns null for project root without worktrees segment",
|
||||
);
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
detectWorktreeName("/foo/.gsd/worktrees/M001"),
|
||||
"M001",
|
||||
"still detects direct layout path",
|
||||
|
|
@ -211,7 +211,7 @@ async function main(): Promise<void> {
|
|||
|
||||
// With GSD_PROJECT_ROOT env var set (layer 1 — coordinator passes it)
|
||||
process.env.GSD_PROJECT_ROOT = "/real/project";
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
resolveProjectRoot("/Users/fran/.gsd/projects/89e1c9ad49bf/worktrees/M001"),
|
||||
"/real/project",
|
||||
"uses GSD_PROJECT_ROOT when set",
|
||||
|
|
@ -219,7 +219,7 @@ async function main(): Promise<void> {
|
|||
delete process.env.GSD_PROJECT_ROOT;
|
||||
|
||||
// Without GSD_PROJECT_ROOT, direct layout still works (no ~/.gsd collision)
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
resolveProjectRoot("/some/repo"),
|
||||
"/some/repo",
|
||||
"ignores GSD_PROJECT_ROOT override for non-worktree paths",
|
||||
|
|
@ -227,19 +227,19 @@ async function main(): Promise<void> {
|
|||
delete process.env.GSD_PROJECT_ROOT;
|
||||
|
||||
// Without GSD_PROJECT_ROOT, direct layout still works (no ~/.gsd collision)
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
resolveProjectRoot("/foo/.gsd/worktrees/M001"),
|
||||
"/foo",
|
||||
"still resolves direct layout path",
|
||||
);
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
resolveProjectRoot("/some/repo"),
|
||||
"/some/repo",
|
||||
"returns unchanged for non-worktree path",
|
||||
);
|
||||
|
||||
// Without GSD_PROJECT_ROOT, direct layout with nested subdirs
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
resolveProjectRoot("/data/.gsd/worktrees/M003/nested"),
|
||||
"/data",
|
||||
"resolves correctly with nested subdirs after worktree name (direct layout)",
|
||||
|
|
@ -264,7 +264,7 @@ async function main(): Promise<void> {
|
|||
mkdirSync(deep, { recursive: true });
|
||||
|
||||
process.env.GSD_HOME = join(fakeHome, ".gsd");
|
||||
assertEq(
|
||||
assert.deepStrictEqual(
|
||||
normalizePath(resolveProjectRoot(realpathSync(deep))),
|
||||
normalizePath(project),
|
||||
"resolves to real project root from deep symlink-resolved worktree path",
|
||||
|
|
@ -276,10 +276,4 @@ async function main(): Promise<void> {
|
|||
}
|
||||
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
report();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue