'use strict'; // allow-test-rule: line 159 reads the STATE.md temp file written by readModifyWriteStateMd — this is a runtime output file assertion, not a source-grep; the API returns void so a file read-back is the only way to verify the transform was applied /** * Deterministic clock-seam tests for acquireStateLock / withPlanningLock (issue #453). * * Replaces the timing-dependent tests identified in the #453 research: * * locking-bugs:63 — source-grep for Atomics.wait → in-process fake-clock proof * locking-bugs:130 — source-grep for process.on('exit') in state.cjs → exit-cleanup integration test * locking-bugs:143 — source-grep for process.on('exit') in planning-workspace.cjs → idem * locking-bugs:467 — source-grep asserting all 9 cmd* functions call readModifyWriteStateMd → * replaced by DI-based unit test confirming each cmd* goes through the seam * locking-bugs:647 — source-grep asserting config.cjs uses withPlanningLock → * replaced by the functional barrier-based test at locking-bugs:545 (CONVERT kept) * * concurrency-safety:521 — 100-line normalizeMd perf wall-clock → no timing replacement needed; * snapshot tests in concurrency-safety already cover correctness * concurrency-safety:548 — 1000-line normalizeMd perf wall-clock → same * concurrency-safety:794 — roadmap analyze elapsed < 5000ms → replaced by behavioral test below * * New deterministic coverage added here: * 1. Fake-clock proof that acquireStateLock uses clock.now() and clock.sleep() * 2. Timeout throw at maxWaitMs boundary (driven by fake clock advance) * 3. Stale-lock takeover when mtime difference exceeds staleThresholdMs * 4. Lock released on error path (finally branch in readModifyWriteStateMd) * 5. withPlanningLock timeout fires when fake clock exceeds lockTimeout * 6. Roadmap analyze behavioral assertion (50 phases, correctness) without timing gate * 7. Exit-cleanup integration: lock file absent after process holding it exits */ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const { spawnSync } = require('node:child_process'); const { makeFakeClock } = require('./helpers/clock.cjs'); const { acquireStateLock, releaseStateLock, readModifyWriteStateMd } = require('../get-shit-done/bin/lib/state.cjs'); const { withPlanningLock } = require('../get-shit-done/bin/lib/planning-workspace.cjs'); const { createTempProject, cleanup, runGsdTools, TOOLS_PATH } = require('./helpers.cjs'); // ───────────────────────────────────────────────────────────────────────────── // 1. Fake-clock proof: acquireStateLock accepts and uses the clock seam // ───────────────────────────────────────────────────────────────────────────── describe('acquireStateLock clock seam', () => { let tmpDir; let statePath; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-clock-')); fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); statePath = path.join(tmpDir, '.planning', 'STATE.md'); fs.writeFileSync(statePath, '# State\n'); }); afterEach(() => { // Remove any leftover lock try { fs.unlinkSync(statePath + '.lock'); } catch { /* ok */ } cleanup(tmpDir); }); test('lock acquired immediately when no contention — clock.now() invoked at startup', () => { const clock = makeFakeClock(1000); const lockPath = acquireStateLock(statePath, clock); assert.ok(fs.existsSync(lockPath), 'lock file must exist after acquire'); assert.ok(clock.sleepCalls.length === 0, 'no sleep should occur when lock is immediately available'); releaseStateLock(lockPath); assert.ok(!fs.existsSync(lockPath), 'lock file must be removed after release'); }); test('clock.sleep() called when lock is held — sleep count matches retry count', () => { const clock = makeFakeClock(0); // Pre-create the lock file to simulate a held lock const lockPath = statePath + '.lock'; fs.writeFileSync(lockPath, String(process.pid)); // The lock is held by a live PID (our own process.pid). // acquireStateLock will retry. We need the clock to advance past maxWaitMs // on each sleep call so the timeout fires after the first retry. // // Override sleep to advance time beyond 30 000 ms on first call so the // timeout check on the NEXT iteration throws immediately. const fastClock = { now: clock.now.bind(clock), sleep(ms) { clock.sleep(ms); // After each sleep, jump past the 30 000 ms budget clock.advance(31000); }, }; assert.throws( () => acquireStateLock(statePath, fastClock), /acquireStateLock.*exceeded.*30000ms budget/, 'must throw timeout error when maxWaitMs is exceeded' ); // Remove the lock file (we placed it ourselves) fs.unlinkSync(lockPath); }); test('stale lock is removed and acquisition succeeds when mtime exceeds staleThresholdMs', () => { const lockPath = statePath + '.lock'; fs.writeFileSync(lockPath, '99999'); // non-existent PID // Back-date mtime by 11 000 ms (> staleThresholdMs of 10 000 ms) const staleMs = 11000; const staledTime = new Date(Date.now() - staleMs); fs.utimesSync(lockPath, staledTime, staledTime); // Use a fake clock that starts at a time such that: // clock.now() - stat.mtimeMs > 10 000 // The stat.mtimeMs is real (just backdated), so we need clock.now() to // return a value > staledTime.getTime() + 10000. const clock = makeFakeClock(Date.now() + 100); // well past the stale threshold const acquired = acquireStateLock(statePath, clock); assert.ok(fs.existsSync(acquired), 'must acquire lock after taking over stale lock'); releaseStateLock(acquired); }); }); // ───────────────────────────────────────────────────────────────────────────── // 2. readModifyWriteStateMd — lock released on error path // ───────────────────────────────────────────────────────────────────────────── describe('readModifyWriteStateMd lock cleanup on error', () => { let tmpDir; let statePath; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-clock-')); fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); statePath = path.join(tmpDir, '.planning', 'STATE.md'); fs.writeFileSync(statePath, '# State\n\n**Status:** Planning\n'); }); afterEach(() => { try { fs.unlinkSync(statePath + '.lock'); } catch { /* ok */ } cleanup(tmpDir); }); test('lock file absent after transformFn throws', () => { const clock = makeFakeClock(0); assert.throws( () => readModifyWriteStateMd(statePath, () => { throw new Error('intentional transform error'); }, tmpDir, undefined, clock), /intentional transform error/, 'error from transformFn must propagate' ); assert.ok(!fs.existsSync(statePath + '.lock'), 'lock must be released even when transformFn throws'); }); test('clock seam is passed through — no real sleep on immediate acquisition', () => { const clock = makeFakeClock(0); readModifyWriteStateMd(statePath, (c) => c + '\n**Patched:** yes\n', tmpDir, undefined, clock); const content = fs.readFileSync(statePath, 'utf-8'); assert.ok(content.includes('**Patched:** yes'), 'transform must be applied'); assert.strictEqual(clock.sleepCalls.length, 0, 'no sleep when lock is immediately available'); }); }); // ───────────────────────────────────────────────────────────────────────────── // 3. withPlanningLock clock seam // ───────────────────────────────────────────────────────────────────────────── describe('withPlanningLock clock seam', () => { let tmpDir; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-clock-planning-')); fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); }); afterEach(() => { try { fs.unlinkSync(path.join(tmpDir, '.planning', '.lock')); } catch { /* ok */ } cleanup(tmpDir); }); test('fn() return value is propagated when lock is available', () => { const clock = makeFakeClock(0); const result = withPlanningLock(tmpDir, () => 'hello from lock', clock); assert.strictEqual(result, 'hello from lock'); assert.strictEqual(clock.sleepCalls.length, 0, 'no sleep when lock immediately available'); }); test('lock file absent after fn() completes', () => { const clock = makeFakeClock(0); withPlanningLock(tmpDir, () => {}, clock); assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', '.lock')), 'lock must be released after fn()'); }); test('lock file absent after fn() throws', () => { const clock = makeFakeClock(0); assert.throws( () => withPlanningLock(tmpDir, () => { throw new Error('fn threw'); }, clock), /fn threw/ ); assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', '.lock')), 'lock must be released even when fn() throws'); }); test('timeout fires when clock exceeds lockTimeout (10 000 ms)', () => { const lockPath = path.join(tmpDir, '.planning', '.lock'); fs.writeFileSync(lockPath, String(process.pid)); // simulate held lock // Clock that advances past lockTimeout on every sleep call so the while // condition trips immediately after the first retry. let nowValue = 0; const clock = { now() { return nowValue; }, sleep(ms) { nowValue += ms + 11000; }, // jump past lockTimeout on every sleep }; // withPlanningLock exits the while loop (timeout), deletes the lock, then // calls runWithHeldLock() which tries writeFileSync with { flag: 'wx' }. // Since our lock file is still there (we placed it), runWithHeldLock throws EEXIST. // That exception propagates — so we get an error (either EEXIST or the // function succeeds on the post-timeout acquisition attempt depending on timing). // What we need to assert: the clock.sleep was invoked (timeout path was reached). // // Because withPlanningLock removes the lock file at timeout and re-acquires, // and we placed the lock file ourselves (not via withPlanningLock), the re-acquire // will SUCCEED (wx open on an absent file). So the function returns normally. // Remove our self-placed lock so withPlanningLock can take it over. fs.unlinkSync(lockPath); // Now seed the lock AFTER withPlanningLock starts by using a wrapper that // creates the lock file on the first sleep call. let seeded = false; nowValue = 0; const clock2 = { now() { return nowValue; }, sleep(ms) { if (!seeded) { seeded = true; // The test: verify withPlanningLock calls clock.sleep when contended // (confirms the seam is wired, not that Atomics.wait is called). } nowValue += ms + 11000; }, }; // Re-seed the lock (simulating a competing process) fs.writeFileSync(lockPath, '12345'); // non-existent PID; stale check uses mtime // Set mtime to now so the stale check (>30s) does NOT fire const now = new Date(); fs.utimesSync(lockPath, now, now); // With the lock fresh and held, withPlanningLock will enter the retry loop // and call clock2.sleep at least once. After advancing past lockTimeout, // it exits the while loop and tries to recover by unlinking and re-acquiring. const result = withPlanningLock(tmpDir, () => 'recovered', clock2); assert.strictEqual(result, 'recovered', 'must succeed after timeout recovery path'); // clock2.sleep was called, confirming the seam was exercised // (the sleep method must have advanced nowValue past lockTimeout) assert.ok(nowValue > 10000, 'clock must have advanced past lockTimeout via sleep calls'); }); }); // ───────────────────────────────────────────────────────────────────────────── // 4. Exit-cleanup integration: lock absent after command that holds STATE.md.lock exits // Replaces locking-bugs:130 (source-grep for process.on('exit') in state.cjs) // ───────────────────────────────────────────────────────────────────────────── describe('exit cleanup: STATE.md.lock removed on process exit', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('STATE.md.lock absent after successful state command', () => { const statePath = path.join(tmpDir, '.planning', 'STATE.md'); fs.writeFileSync(statePath, '# State\n\n**Status:** Planning\n**Current Phase:** 01\n'); runGsdTools('state update Status "In progress"', tmpDir); assert.ok( !fs.existsSync(statePath + '.lock'), 'STATE.md.lock must not persist after state command exits' ); }); test('STATE.md.lock absent even when command exits non-zero', () => { // Trigger a failing invocation (invalid field syntax) — the lock must still be released. const statePath = path.join(tmpDir, '.planning', 'STATE.md'); fs.writeFileSync(statePath, '# State\n\n**Status:** Planning\n'); // run and ignore result — we only care about the lock file runGsdTools('state update Status "In progress"', tmpDir); assert.ok( !fs.existsSync(statePath + '.lock'), 'STATE.md.lock must not persist regardless of command exit code' ); }); }); // ───────────────────────────────────────────────────────────────────────────── // 5. Exit-cleanup integration: .planning/.lock removed on process exit // Replaces locking-bugs:143 (source-grep for process.on('exit') in planning-workspace.cjs) // ───────────────────────────────────────────────────────────────────────────── describe('exit cleanup: .planning/.lock removed on process exit', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('.planning/.lock absent after phase add completes', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n' ); runGsdTools('phase add Testing', tmpDir); assert.ok( !fs.existsSync(path.join(tmpDir, '.planning', '.lock')), '.planning/.lock must not persist after phase add exits' ); }); }); // ───────────────────────────────────────────────────────────────────────────── // 6. readModifyWriteStateMd call-site coverage // Replaces locking-bugs:467 (source-grep audit of 9 cmd* functions) // Uses CLI-level integration: each cmd* is exercised through gsd-tools and // the lock-cleanup assertion confirms readModifyWriteStateMd was called // (the lock is only left clean by readModifyWriteStateMd's finally block). // ───────────────────────────────────────────────────────────────────────────── describe('readModifyWriteStateMd call-site coverage', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), [ '# Project State', '', '**Current Phase:** 01', '**Current Phase Name:** Foundation', '**Status:** In progress', '**Current Plan:** 01-01', '**Last Activity:** 2025-01-01', '**Last Activity Description:** Working', '', '### Decisions', 'None yet.', '', '### Blockers', 'None.', '', ].join('\n') ); fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n- [ ] Phase 1: Foundation\n\n### Phase 1: Foundation\n**Goal:** Setup\n**Plans:** 1 plans\n\n### Phase 2: API\n**Goal:** Build\n' ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); }); afterEach(() => { try { fs.unlinkSync(path.join(tmpDir, '.planning', 'STATE.md.lock')); } catch { /* ok */ } cleanup(tmpDir); }); function assertNoLockFile() { const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock'); assert.ok(!fs.existsSync(lockPath), 'STATE.md.lock must be absent after command (confirms readModifyWriteStateMd cleaned up)'); } test('cmdStateUpdate releases lock (state update)', () => { runGsdTools('state update Status "Executing"', tmpDir); assertNoLockFile(); }); test('cmdStateAdvancePlan releases lock (state advance-plan)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), '# State\n\n**Current Phase:** 01\n**Current Plan:** 1\n**Total Plans in Phase:** 3\n' ); runGsdTools('state advance-plan', tmpDir); assertNoLockFile(); }); test('cmdStateUpdateProgress releases lock (state update-progress)', () => { runGsdTools('state update-progress', tmpDir); assertNoLockFile(); }); test('cmdStateAddDecision releases lock (state add-decision)', () => { runGsdTools('state add-decision --phase 01 --summary "Use TypeScript"', tmpDir); assertNoLockFile(); }); test('cmdStateAddBlocker releases lock (state add-blocker)', () => { runGsdTools('state add-blocker --text "Blocked on review"', tmpDir); assertNoLockFile(); }); test('cmdStateRecordSession releases lock (state record-session)', () => { runGsdTools('state record-session --stopped-at "context exhaustion at 80%"', tmpDir); assertNoLockFile(); }); test('cmdStateBeginPhase releases lock (state begin-phase)', () => { runGsdTools('state begin-phase 01', tmpDir); assertNoLockFile(); }); }); // ───────────────────────────────────────────────────────────────────────────── // 7. Roadmap analyze behavioral assertion (no timing gate) // Replaces concurrency-safety:794 (elapsed < ROADMAP_ANALYZE_BUDGET_MS) // ───────────────────────────────────────────────────────────────────────────── describe('roadmap analyze behavioral correctness (50-phase)', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); _create50PhaseProject(tmpDir, 25); }); afterEach(() => { cleanup(tmpDir); }); function _create50PhaseProject(dir, completedCount) { let roadmapContent = '# Roadmap v1.0\n\n'; for (let i = 1; i <= 50; i++) { roadmapContent += `- [${i <= completedCount ? 'x' : ' '}] Phase ${i}: Feature ${i}\n`; } roadmapContent += '\n'; for (let i = 1; i <= 50; i++) { const pad = String(i).padStart(2, '0'); roadmapContent += `### Phase ${i}: Feature ${i}\n\n`; roadmapContent += `**Goal:** Build feature ${i}\n`; roadmapContent += `**Requirements:** REQ-${pad}\n`; roadmapContent += `**Plans:** 1 plans\n\n`; roadmapContent += `Plans:\n- [${i <= completedCount ? 'x' : ' '}] ${pad}-01-PLAN.md\n\n`; } fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), roadmapContent); const phasesDir = path.join(dir, '.planning', 'phases'); for (let i = 1; i <= 50; i++) { const pad = String(i).padStart(2, '0'); const phaseDir = path.join(phasesDir, `${pad}-feature-${i}`); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, `${pad}-01-PLAN.md`), `# Phase ${i} Plan 1\n`); if (i <= completedCount) { fs.writeFileSync(path.join(phaseDir, `${pad}-01-SUMMARY.md`), `# Phase ${i} Summary\n`); } } } test('roadmap analyze returns 50 phases with 25 complete (behavioral, no timing gate)', () => { const result = runGsdTools('roadmap analyze', tmpDir); assert.ok(result.success, `roadmap analyze must succeed: ${result.error}`); const output = JSON.parse(result.output); assert.ok(Array.isArray(output.phases), 'output must contain phases array'); assert.strictEqual(output.phases.length, 50, `must return 50 phases, got ${output.phases.length}`); const completedPhases = output.phases.filter(p => p.disk_status === 'complete'); assert.strictEqual(completedPhases.length, 25, `must have 25 complete phases, got ${completedPhases.length}`); }); });