diff --git a/CONTEXT.md b/CONTEXT.md index 5758f3725..39e17673a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -171,7 +171,7 @@ The canonical lint infrastructure adopted in ADR 452 (`docs/adr/452-eslint-lint- `LEARNING.prompt-budget.boundary-gap=PR #3708 commit 2df566ed reserved NOTE_RESERVE_TOKENS in pressure-threshold AND in minSet pre-check; both buggy paths only fire when baseTokens ∈ (effectiveBudget - NOTE_RESERVE_TOKENS, effectiveBudget]; original test suite used budgets far from that band so neither path was exercised; fix bde1ae8f confines NOTE_RESERVE accounting to post-trim assembly path only; future budget/limit code MUST add boundary fixtures per RULESET.TESTS.boundary-coverage.fixtures` `RULESET.TESTS.no-timing-assertion=do not assert on wall-clock elapsed time (Date.now() delta, performance.now(), process.hrtime() comparison); such assertions test the host machine not the SUT and flake on loaded CI runners; enforcement: local/no-elapsed-assertion ESLint rule (warn → error after #453); canonical replacement: clock-seam pattern with node:test mock.timers` -`RULESET.TESTS.clock-seam=concurrency logic must accept an optional {clock=Date} parameter; tests control time via t.mock.timers.enable(['Date']) + t.mock.timers.setTime(0) + t.mock.timers.tick(N); real OS scheduler races are not a permitted test pattern after ADR 456 (2026-05-28); real-race tests are deleted once deterministic seam tests cover the same logical path` +`RULESET.TESTS.clock-seam=concurrency logic must accept an optional {clock=Date} parameter; tests control time via t.mock.timers.enable(['Date']) + t.mock.timers.setTime(0) + t.mock.timers.tick(N); real OS scheduler races are not a permitted test pattern after ADR 456 (2026-05-28); real-race tests are deleted once deterministic seam tests cover the same logical path; clock.cjs realClock adds nowIso() (→ new Date(this.now()).toISOString()) and today() (→ nowIso().split('T')[0]) so all date-stamping in state.cjs routes through the seam; subprocess time-pin adapter: set GSD_TEST_MODE=1 + GSD_NOW_MS= in runGsdTools env to pin the date written by the SUT without touching real wall-clock (issue #474)` `RULESET.TESTS.property-based-testing=modules implementing parsing / transformation / budget-limit / bijective contracts must include at least one fast-check (fc) property test asserting a domain invariant; invariant categories: round-trip, monotonicity, boundary-containment, idempotency; property tests live in *.test.cjs alongside unit tests; CI signal: Stryker mutation score below 80% blocks merge` `RULESET.TESTS.mutation-score=Stryker runs incremental (--since origin/next) on ubuntu-latest/Node24 CI leg; default threshold 80% killed/total; surviving mutants in scope block merge unless path is listed in stryker.config.mjs with documented reason; treat surviving mutant as a failing test specification` `RULESET.TESTS.delete-bad-tests=pass-always / vacuous-truth / source-grep / elapsed-time / real-race / permanent-allow-test-rule tests are DELETED and replaced with compliant tests in the same PR; not skipped, not commented out, not permanently exempted; replacement must cover the same logical path via typed-surface assertion or clock-seam pattern` diff --git a/get-shit-done/bin/lib/clock.cjs b/get-shit-done/bin/lib/clock.cjs index 5ea74c498..ea7fd95b9 100644 --- a/get-shit-done/bin/lib/clock.cjs +++ b/get-shit-done/bin/lib/clock.cjs @@ -19,12 +19,67 @@ // identical to allocating a fresh buffer each time. const _realSleepBuf = new Int32Array(new SharedArrayBuffer(4)); +/** + * Parse GSD_NOW_MS to a valid pinned epoch millisecond value, or return null. + * + * Accepts ONLY strict decimal integer strings within JS Date bounds + * (abs(ms) <= 8.64e15). Rejects empty strings, whitespace-only, floats + * ('12.5'), scientific notation ('1e30'), and out-of-range values. + * + * Returns null (fall back to Date.now()) for any invalid or absent input. + * Returns null when GSD_TEST_MODE is not set. + * + * @returns {number|null} + */ +function _pinnedNowMs() { + if (!process.env.GSD_TEST_MODE) return null; + const raw = process.env.GSD_NOW_MS; + if (typeof raw !== 'string') return null; + const t = raw.trim(); + if (!/^-?\d+$/.test(t)) return null; // reject '', 'abc', '1e30', '12.5' + const ms = Number(t); + if (!Number.isFinite(ms) || Math.abs(ms) > 8.64e15) return null; // Date-valid bounds + return ms; +} + const realClock = { - /** Return current epoch milliseconds (same as the inline Date.now() calls in state.cjs). */ + /** + * Return current epoch milliseconds. + * + * When both GSD_TEST_MODE and GSD_NOW_MS are set (subprocess time-pin adapter, + * issue #474), returns the pinned millisecond value so all date-stamping in the + * subprocess SUT is deterministic. In production, falls back to Date.now(). + * + * Only strict decimal integer strings within JS Date bounds are accepted as pins. + * Any other value (empty string, float, scientific notation, out-of-range) falls + * back to Date.now() to prevent RangeError from new Date(ms).toISOString(). + */ now() { + const pinned = _pinnedNowMs(); + if (pinned !== null) return pinned; return Date.now(); }, + /** + * Return the current instant as an ISO 8601 string (UTC). + * Uses this.now() so the subprocess time-pin adapter is honoured. + * + * @returns {string} e.g. "2020-06-15T12:00:00.000Z" + */ + nowIso() { + return new Date(this.now()).toISOString(); + }, + + /** + * Return today's date as a YYYY-MM-DD string (UTC calendar day). + * Uses this.now() so the subprocess time-pin adapter is honoured. + * + * @returns {string} e.g. "2020-06-15" + */ + today() { + return this.nowIso().split('T')[0]; + }, + /** * Synchronous sleep via Atomics.wait. * This is the identical primitive acquireStateLock and withPlanningLock used diff --git a/get-shit-done/bin/lib/installer-migrations.cjs b/get-shit-done/bin/lib/installer-migrations.cjs index c1039034d..5d9337622 100644 --- a/get-shit-done/bin/lib/installer-migrations.cjs +++ b/get-shit-done/bin/lib/installer-migrations.cjs @@ -8,6 +8,7 @@ const { validateInstallerMigrationRecord, } = require('./installer-migration-authoring.cjs'); const { platformWriteSync } = require('./shell-command-projection.cjs'); +const { realClock } = require('./clock.cjs'); const MANIFEST_NAME = 'gsd-file-manifest.json'; const INSTALL_STATE_NAME = 'gsd-install-state.json'; @@ -257,10 +258,10 @@ function readLockFile(lockPath) { } } -function acquireInstallMigrationLock(configDir, { timeoutMs = DEFAULT_LOCK_TIMEOUT_MS } = {}) { +function acquireInstallMigrationLock(configDir, { timeoutMs = DEFAULT_LOCK_TIMEOUT_MS } = {}, clock = realClock) { fs.mkdirSync(configDir, { recursive: true }); const lockPath = path.join(configDir, INSTALL_MIGRATION_LOCK_NAME); - const started = Date.now(); + const started = clock.now(); while (true) { let fd = null; @@ -324,11 +325,11 @@ function acquireInstallMigrationLock(configDir, { timeoutMs = DEFAULT_LOCK_TIMEO if (reclaimed) continue; } } - if (Date.now() - started >= timeoutMs) { + if (clock.now() - started >= timeoutMs) { const holderInfo = lockData ? ` (held by pid ${lockData.pid} since ${lockData.acquiredAt})` : ''; throw new Error(`installer migration lock is held: ${lockPath}${holderInfo}`); } - sleepSync(Math.min(50, Math.max(1, timeoutMs - (Date.now() - started)))); + clock.sleep(Math.min(50, Math.max(1, timeoutMs - (clock.now() - started)))); continue; } throw error; @@ -765,6 +766,7 @@ module.exports = { INSTALL_MIGRATION_LOCK_NAME, INSTALL_STATE_NAME, MANIFEST_NAME, + acquireInstallMigrationLock, applyInstallerMigrationPlan, classifyArtifact, discoverInstallerMigrations, diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index 570af0d0a..534d7c466 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -300,7 +300,7 @@ function cmdStateAdvancePlan(cwd, raw) { const statePath = planningPaths(cwd).state; if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } - const today = new Date().toISOString().split('T')[0]; + const today = realClock.today(); let result = null; readModifyWriteStateMd(statePath, (content) => { @@ -629,7 +629,7 @@ function cmdStateRecordSession(cwd, options, raw) { const statePath = planningPaths(cwd).state; if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; } - const now = new Date().toISOString(); + const now = realClock.nowIso(); const updated = []; readModifyWriteStateMd(statePath, (content) => { @@ -927,7 +927,7 @@ function buildStateFrontmatter(bodyContent, cwd) { fm.status = normalizedStatus; if (stoppedAt) fm.stopped_at = stoppedAt; if (pausedAt) fm.paused_at = pausedAt; - fm.last_updated = new Date().toISOString(); + fm.last_updated = realClock.nowIso(); if (lastActivity) fm.last_activity = lastActivity; const progress = {}; @@ -1176,7 +1176,7 @@ function cmdStateBeginPhase(cwd, phaseNumber, phaseName, planCount, raw) { return; } - const today = new Date().toISOString().split('T')[0]; + const today = realClock.today(); const updated = []; readModifyWriteStateMd(statePath, (content) => { @@ -1310,7 +1310,7 @@ function cmdSignalWaiting(cwd, type, question, options, phase, raw) { type: type || 'decision_point', question: question || null, options: options ? options.split('|').map(o => o.trim()) : [], - since: new Date().toISOString(), + since: realClock.nowIso(), phase: phase || null, }; @@ -1393,7 +1393,7 @@ function cmdStatePlannedPhase(cwd, phaseNumber, planCount, raw) { } let content = fs.readFileSync(statePath, 'utf-8'); - const today = new Date().toISOString().split('T')[0]; + const today = realClock.today(); const updated = []; const statusDefaults = KNOWN_TEMPLATE_DEFAULTS['Status']; @@ -1448,7 +1448,7 @@ function cmdStateMilestoneSwitch(cwd, version, name, raw) { } const resolvedName = (name && String(name).trim()) || 'milestone'; const statePath = planningPaths(cwd).state; - const today = new Date().toISOString().split('T')[0]; + const today = realClock.today(); const lockPath = acquireStateLock(statePath); try { @@ -1475,7 +1475,7 @@ function cmdStateMilestoneSwitch(cwd, version, name, raw) { milestone: version, milestone_name: resolvedName, status: 'planning', - last_updated: new Date().toISOString(), + last_updated: realClock.nowIso(), last_activity: today, progress: { total_phases: 0, @@ -1581,7 +1581,7 @@ function cmdStateSync(cwd, options, raw) { const content = fs.readFileSync(statePath, 'utf-8'); const changes = []; let modified = content; - const today = new Date().toISOString().split('T')[0]; + const today = realClock.today(); const phasesDir = planningPaths(cwd).phases; if (!fs.existsSync(phasesDir)) { @@ -1856,7 +1856,7 @@ function cmdStatePrune(cwd, options, raw) { // Write archived entries to STATE-ARCHIVE.md if (archived.length > 0) { - const timestamp = new Date().toISOString().split('T')[0]; + const timestamp = realClock.today(); let archiveContent = platformReadSync(archivePath); if (archiveContent === null) { archiveContent = '# STATE Archive\n\nPruned entries from STATE.md. Recoverable but no longer loaded into agent context.\n\n'; @@ -1931,7 +1931,7 @@ function cmdStateCompletePhase(cwd, raw, overridePhase) { return; } - const today = new Date().toISOString().split('T')[0]; + const today = realClock.today(); const updated = []; readModifyWriteStateMd(statePath, (content) => { diff --git a/tests/bug-3127-state-begin-phase-idempotent.test.cjs b/tests/bug-3127-state-begin-phase-idempotent.test.cjs index 1b376205b..2fb9a075b 100644 --- a/tests/bug-3127-state-begin-phase-idempotent.test.cjs +++ b/tests/bug-3127-state-begin-phase-idempotent.test.cjs @@ -146,20 +146,32 @@ describe('bug #3127: state.begin-phase idempotency guard', () => { } }); - test('begin-phase always updates Last Activity date (safe on resume)', () => { + test('begin-phase always updates Last Activity date (safe on resume, pinned via GSD_NOW_MS)', () => { const stateModule = requireStateCjs(); const { cmdStateBeginPhase } = stateModule; if (!cmdStateBeginPhase) return; const dir = makeTempPlanning(MID_FLIGHT_STATE); + + const PINNED_MS = Date.parse('2020-11-25T09:00:00.000Z'); + const PINNED_DATE = '2020-11-25'; + // Pin the in-process clock via env vars before calling the function directly. + const origTestMode = process.env.GSD_TEST_MODE; + const origNowMs = process.env.GSD_NOW_MS; + process.env.GSD_TEST_MODE = '1'; + process.env.GSD_NOW_MS = String(PINNED_MS); try { cmdStateBeginPhase(dir, '5', 'test-phase', 8, false); const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8'); - const today = new Date().toISOString().split('T')[0]; assert.ok( - after.includes(today), - 'begin-phase must update Last Activity date even on resume (safe field)', + after.includes(PINNED_DATE), + `begin-phase must update Last Activity date to the pinned date ${PINNED_DATE} even on resume (safe field)`, ); } finally { + // Restore env vars before cleanup to avoid leaking state to other tests. + if (origTestMode === undefined) delete process.env.GSD_TEST_MODE; + else process.env.GSD_TEST_MODE = origTestMode; + if (origNowMs === undefined) delete process.env.GSD_NOW_MS; + else process.env.GSD_NOW_MS = origNowMs; fs.rmSync(dir, { recursive: true, force: true }); } }); diff --git a/tests/bug-474-clock-seam-date-determinism.test.cjs b/tests/bug-474-clock-seam-date-determinism.test.cjs new file mode 100644 index 000000000..5239eaaf4 --- /dev/null +++ b/tests/bug-474-clock-seam-date-determinism.test.cjs @@ -0,0 +1,278 @@ +// allow-test-rule: source-text-is-the-product +// STATE.md is the product surface; assertions on its text content test the +// deployed contract (date field written by the subprocess SUT). + +'use strict'; + +/** + * Bug #474 — clock seam: subprocess date-stamping must be deterministic. + * + * Tests in this file verify that: + * 1. state.cjs date-stamping is routed through realClock (not bare new Date()), + * so GSD_NOW_MS pins the written date deterministically in subprocess tests. + * 2. installer-migrations.cjs lock-loop timeout fires deterministically via + * the clock seam (in-process, using makeFakeClock — no subprocess needed). + */ + +const { describe, it, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, cleanup } = require('./helpers.cjs'); +const { createFixture } = require('./fixtures/index.cjs'); +const { makeFakeClock } = require('./helpers/clock.cjs'); + +// ───────────────────────────────────────────────────────────────────────────── +// §1 Subprocess date-pin: state advance-plan writes the pinned date +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Why advance-plan? + * cmdStateAdvancePlan captures `const today = new Date().toISOString().split('T')[0]` + * and writes it to "Last Activity" via stateReplaceFieldIfTemplate. + * The fixture below has Last Activity = 2024-01-10 (an ISO date, treated as a + * handler-generated template default by isStateTemplateDefault), so the field IS + * overwritten — making this the simplest single-subcommand probe of the bug. + */ + +// A fixed historical instant far in the past — will NEVER match today's real date. +const PINNED_MS = Date.parse('2020-06-15T12:00:00.000Z'); +const PINNED_DATE = '2020-06-15'; + +// A minimal STATE.md that satisfies advance-plan's parser: +// - Current Plan: 1 (not on last plan → normal-advance branch) +// - Total Plans in Phase: 3 +// - Last Activity: 2024-01-10 (ISO date → isStateTemplateDefault returns true → will be replaced) +const ADVANCE_FIXTURE = [ + '# Project State', + '', + '**Current Plan:** 1', + '**Total Plans in Phase:** 3', + '**Status:** Executing', + '**Last Activity:** 2024-01-10', +].join('\n') + '\n'; + +describe('bug-474: state date-stamping is pinned by GSD_NOW_MS', () => { + let tmpDir; + + before(() => { + // AAA — Arrange: create a temp project with the advance fixture + tmpDir = createFixture(); + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), ADVANCE_FIXTURE); + }); + + after(() => { + cleanup(tmpDir); + }); + + test('state advance-plan writes pinned date (not real today) when GSD_NOW_MS is set', () => { + // AAA — Act: run advance-plan with a pinned historical timestamp + const result = runGsdTools('state advance-plan', tmpDir, { + GSD_TEST_MODE: '1', + GSD_NOW_MS: String(PINNED_MS), + }); + + assert.ok(result.success, `advance-plan failed unexpectedly: ${result.error}`); + + // AAA — Assert: the written STATE.md must contain the pinned date + const written = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + + // Must contain the pinned historical date (2020-06-15) + assert.ok( + written.includes(PINNED_DATE), + `Expected STATE.md to contain pinned date ${PINNED_DATE}.\nActual STATE.md:\n${written}`, + ); + + // Must NOT contain today's real date — that would mean the seam is bypassed + const realToday = new Date().toISOString().split('T')[0]; + assert.ok( + !written.includes(realToday), + `Expected STATE.md NOT to contain real today (${realToday}) when time is pinned.\nActual STATE.md:\n${written}`, + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// §2 realClock GSD_NOW_MS invalid-input hardening +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Verify that malformed / out-of-range GSD_NOW_MS values fall back to the real + * clock instead of crashing with RangeError (issue #474 hardening). + * + * Each invalid input must: + * a) not throw from realClock.nowIso(), and + * b) produce a valid parseable ISO string (i.e. fell back to Date.now()). + * + * A valid pinned value must produce the expected date string. + */ + +describe('bug-474: realClock GSD_NOW_MS invalid-input hardening', () => { + const realClock = require('../get-shit-done/bin/lib/clock.cjs').realClock; + + // Save and restore env so these tests cannot bleed into neighbouring tests. + let savedTestMode; + let savedNowMs; + + before(() => { + savedTestMode = process.env.GSD_TEST_MODE; + savedNowMs = process.env.GSD_NOW_MS; + process.env.GSD_TEST_MODE = '1'; + }); + + after(() => { + if (savedTestMode === undefined) { + delete process.env.GSD_TEST_MODE; + } else { + process.env.GSD_TEST_MODE = savedTestMode; + } + if (savedNowMs === undefined) { + delete process.env.GSD_NOW_MS; + } else { + process.env.GSD_NOW_MS = savedNowMs; + } + }); + + // AAA matrix: each invalid value must NOT crash and must fall back to the real clock. + const INVALID_INPUTS = [ + ['empty string', ''], + ['whitespace only', ' '], + ['alphabetic', 'abc'], + ['scientific notation', '1e30'], + ['decimal float', '12.5'], + ['integer > 8.64e15', '99999999999999999999'], + ]; + + for (const [label, value] of INVALID_INPUTS) { + test(`GSD_NOW_MS='${value}' (${label}) falls back to real clock — no crash, valid ISO`, () => { + // AAA — Arrange + process.env.GSD_NOW_MS = value; + + // AAA — Act + Assert: must not throw + assert.doesNotThrow( + () => realClock.nowIso(), + `realClock.nowIso() must not throw for GSD_NOW_MS='${value}'`, + ); + + // AAA — Assert: result is a valid ISO string (fell back to real clock) + const iso = realClock.nowIso(); + assert.ok( + !Number.isNaN(Date.parse(iso)), + `realClock.nowIso() must return a valid ISO date for GSD_NOW_MS='${value}', got: ${iso}`, + ); + }); + } + + test('GSD_NOW_MS valid decimal integer pins the clock', () => { + // AAA — Arrange: a known historical epoch + process.env.GSD_NOW_MS = String(PINNED_MS); + + // AAA — Act + const tod = realClock.today(); + + // AAA — Assert + assert.strictEqual(tod, PINNED_DATE, `realClock.today() must return pinned date ${PINNED_DATE}`); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// §3 In-process lock-loop: installer-migrations timeout fires deterministically +// ───────────────────────────────────────────────────────────────────────────── + +/** + * This test exercises acquireInstallMigrationLock's EEXIST retry loop via a + * makeFakeClock. The approach: + * - Write a lock file held by PID 1 (init/launchd — always alive on any OS) + * so neither isSameProcess nor isDeadProcess is true; stale-lock reclamation + * is NOT triggered, and the loop must reach the timeout check. + * - Use TIMEOUT_MS = 500 (non-zero) so the loop must retry at least once before + * the injected clock trips the deadline. A zero timeout would fire on the + * very first check without exercising clock.sleep() at all. + * - The fake clock's sleep(ms) ADVANCES its internal now by ms (confirmed from + * helpers/clock.cjs implementation), so the loop drives itself to termination + * purely through the injected clock without any wall-clock delay. + * - Post-throw assertions on clock.sleepCalls and clock.now() prove the seam: + * if the loop reverted to raw Date.now()/sleepSync, sleepCalls would be 0. + */ + +describe('bug-474: installer-migrations lock-loop timeout is deterministic via clock seam', () => { + test('makeFakeClock nowIso() and today() derive from pinned now()', () => { + // AAA — Arrange + const clock = makeFakeClock(PINNED_MS); + + // AAA — Act + const iso = clock.nowIso(); + const tod = clock.today(); + + // AAA — Assert + assert.strictEqual(iso, '2020-06-15T12:00:00.000Z', 'nowIso() must return ISO string of pinned epoch'); + assert.strictEqual(tod, PINNED_DATE, 'today() must return YYYY-MM-DD of pinned epoch'); + }); + + test('makeFakeClock advance() shifts nowIso() and today()', () => { + // AAA — Arrange: start at PINNED_MS, advance by 24 h + const clock = makeFakeClock(PINNED_MS); + const ONE_DAY_MS = 24 * 60 * 60 * 1000; + + // AAA — Act + clock.advance(ONE_DAY_MS); + + // AAA — Assert + assert.strictEqual(clock.today(), '2020-06-16', 'today() must reflect advanced time'); + }); + + test('acquireInstallMigrationLock timeout path fires deterministically via makeFakeClock', (t) => { + // AAA — Arrange + const os = require('os'); + const { acquireInstallMigrationLock } = require('../get-shit-done/bin/lib/installer-migrations.cjs'); + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-474-lock-')); + + t.after(() => { + fs.rmSync(configDir, { recursive: true, force: true }); + }); + + const LOCK_NAME = 'gsd-install-migration.lock'; + const lockPath = path.join(configDir, LOCK_NAME); + + // Write a lock file held by process.ppid (the parent process, always alive + // and never equal to process.pid on every platform). pid 1 was used + // previously but isPidAlive(1) returns false on Windows (no init/launchd + // pid-1 concept), so the lock was treated as stale, reclaimed, and + // acquireInstallMigrationLock succeeded instead of throwing — failing the + // timeout assertion on windows-latest,22 (#474). process.ppid is a live, + // non-self process on all platforms, so the lock is correctly seen as held + // and the timeout path throws deterministically cross-platform. + fs.writeFileSync( + lockPath, + JSON.stringify({ pid: process.ppid, acquiredAt: new Date().toISOString() }) + '\n', + ); + + // TIMEOUT_MS is non-zero so the first EEXIST iteration does NOT immediately + // trip the deadline. The loop must call clock.sleep() at least once; that + // sleep() advances the fake clock past TIMEOUT_MS, causing the next check to + // throw. This proves the seam: if the loop used raw Date.now()/sleepSync, + // clock.sleepCalls would remain 0. + const TIMEOUT_MS = 500; + const clock = makeFakeClock(0); + + // AAA — Act + Assert: timeout error thrown with no real wall-clock delay + assert.throws( + () => acquireInstallMigrationLock(configDir, { timeoutMs: TIMEOUT_MS }, clock), + /lock|held/i, + 'Expected acquireInstallMigrationLock to throw with "lock"/"held" in message on timeout', + ); + + // AAA — Assert seam was actually exercised through the injected clock: + // If the loop used raw sleepSync instead of clock.sleep(), sleepCalls would be 0. + assert.ok( + clock.sleepCalls.length > 0, + 'loop must retry via injected clock.sleep() — proves seam wiring, not raw wall clock', + ); + + // The injected clock must have advanced past TIMEOUT_MS through its own sleep() calls. + assert.ok( + clock.now() >= TIMEOUT_MS, + `injected clock advanced past timeout deterministically: clock.now()=${clock.now()} must be >= TIMEOUT_MS=${TIMEOUT_MS}`, + ); + }); +}); diff --git a/tests/helpers/clock.cjs b/tests/helpers/clock.cjs index 212fe3277..87146c8ea 100644 --- a/tests/helpers/clock.cjs +++ b/tests/helpers/clock.cjs @@ -70,6 +70,26 @@ function makeFakeClock(startMs) { _now += ms; }, + /** + * Return the virtual instant as an ISO 8601 string (UTC). + * Mirrors realClock.nowIso() so fake clocks are drop-in substitutes. + * + * @returns {string} e.g. "2020-06-15T12:00:00.000Z" + */ + nowIso() { + return new Date(_now).toISOString(); + }, + + /** + * Return the virtual date as a YYYY-MM-DD string (UTC calendar day). + * Mirrors realClock.today() so fake clocks are drop-in substitutes. + * + * @returns {string} e.g. "2020-06-15" + */ + today() { + return new Date(_now).toISOString().split('T')[0]; + }, + /** Array of ms values passed to sleep() in call order. */ get sleepCalls() { return _sleepCalls; diff --git a/tests/state.test.cjs b/tests/state.test.cjs index d551ecadf..dc4f5cdab 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -1022,8 +1022,12 @@ describe('cmdStateAdvancePlan (state advance-plan)', () => { test('advances plan counter when not on last plan', () => { fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), advanceFixture); - const before = new Date().toISOString().split('T')[0]; - const result = runGsdTools('state advance-plan', tmpDir); + const PINNED_MS = Date.parse('2020-06-15T12:00:00.000Z'); + const PINNED_DATE = '2020-06-15'; + const result = runGsdTools('state advance-plan', tmpDir, { + GSD_TEST_MODE: '1', + GSD_NOW_MS: String(PINNED_MS), + }); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); @@ -1035,10 +1039,9 @@ describe('cmdStateAdvancePlan (state advance-plan)', () => { const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok(updated.includes('**Current Plan:** 2'), 'Current Plan should be updated to 2'); assert.ok(updated.includes('**Status:** Ready to execute'), 'Status should be Ready to execute'); - const after = new Date().toISOString().split('T')[0]; assert.ok( - updated.includes(`**Last Activity:** ${before}`) || updated.includes(`**Last Activity:** ${after}`), - `Last Activity should be today (${before}) or next day if midnight boundary (${after})` + updated.includes(`**Last Activity:** ${PINNED_DATE}`), + `Last Activity should be the pinned date ${PINNED_DATE}`, ); }); @@ -1406,9 +1409,12 @@ describe('cmdStateRecordSession (state record-session)', () => { test('updates session fields with stopped-at and resume-file', () => { fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), sessionFixture); + const PINNED_MS = Date.parse('2020-07-20T10:00:00.000Z'); + const PINNED_ISO = '2020-07-20T10:00:00.000Z'; const result = runGsdTools( 'state record-session --stopped-at "Phase 3, Plan 2" --resume-file ".planning/phases/03/03-02-PLAN.md"', - tmpDir + tmpDir, + { GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) }, ); assert.ok(result.success, `Command failed: ${result.error}`); @@ -1419,23 +1425,25 @@ describe('cmdStateRecordSession (state record-session)', () => { const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); assert.ok(updated.includes('Phase 3, Plan 2'), 'Stopped at should be updated'); assert.ok(updated.includes('.planning/phases/03/03-02-PLAN.md'), 'Resume file should be updated'); - - const today = new Date().toISOString().split('T')[0]; - assert.ok(updated.includes(today), 'Last session should be updated to today'); + assert.ok(updated.includes(PINNED_ISO), `Last session should be the pinned ISO timestamp ${PINNED_ISO}`); }); test('updates Last session timestamp even with no other options', () => { fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), sessionFixture); - const result = runGsdTools('state record-session', tmpDir); + const PINNED_MS = Date.parse('2020-08-01T08:30:00.000Z'); + const PINNED_ISO = '2020-08-01T08:30:00.000Z'; + const result = runGsdTools('state record-session', tmpDir, { + GSD_TEST_MODE: '1', + GSD_NOW_MS: String(PINNED_MS), + }); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.recorded, true, 'recorded should be true'); const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); - const today = new Date().toISOString().split('T')[0]; - assert.ok(updated.includes(today), 'Last session should contain today\'s date'); + assert.ok(updated.includes(PINNED_ISO), `Last session should contain the pinned ISO timestamp ${PINNED_ISO}`); }); test('sets Resume file to None when not specified', () => { @@ -2122,18 +2130,26 @@ describe('state planned-phase command', () => { assert.ok(stateContent.match(/Total Plans in Phase.*7/), 'Total Plans should be 7'); }); - test('after call: Last Activity is today\'s date', () => { + test('after call: Last Activity is the pinned date (deterministic via GSD_NOW_MS)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'STATE.md'), `# Project State\n\n**Status:** Planning\n**Total Plans in Phase:** 0\n**Last Activity:** 2024-01-01\n**Current Phase:** 1\n` ); - const result = runGsdTools(['state', 'planned-phase', '--phase', '1', '--name', 'Setup', '--plans', '3'], tmpDir); + const PINNED_MS = Date.parse('2020-09-10T15:00:00.000Z'); + const PINNED_DATE = '2020-09-10'; + const result = runGsdTools( + ['state', 'planned-phase', '--phase', '1', '--name', 'Setup', '--plans', '3'], + tmpDir, + { GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) }, + ); assert.ok(result.success, `Command failed: ${result.error}`); - const today = new Date().toISOString().split('T')[0]; const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); - assert.ok(stateContent.includes(today), `Last Activity should contain today's date (${today})`); + assert.ok( + stateContent.includes(PINNED_DATE), + `Last Activity should contain the pinned date ${PINNED_DATE}`, + ); }); test('missing STATE.md returns graceful error', () => {