diff --git a/TESTING-STANDARDS.md b/TESTING-STANDARDS.md index bf092aca9..c8d895fe0 100644 --- a/TESTING-STANDARDS.md +++ b/TESTING-STANDARDS.md @@ -137,11 +137,11 @@ await doWork(); assert(Date.now() - start < 200, 'must complete in 200ms'); ``` -**Enforcement:** `local/no-elapsed-assertion` (ESLint, currently `warn`; promotion to `error` is sequenced behind #3314 — 10 of 19 clock-touching `src` modules have no sanctioned time-control mechanism until ADR-456 is amended there). Also `no-restricted-syntax` ban on `performance.now()` comparisons in assertions. +**Enforcement:** `local/no-elapsed-assertion` (ESLint, currently `warn`; promotion to `error` was sequenced behind #3314, now delivered — ADR-456 §(a) is amended with a reachability-based selection rule covering all three clock-control mechanisms this repo uses, and the direct-use modules carrying real time-gating logic (`commands.cts`, `init.cts`, `io.cts`) have deterministic backfill coverage. The actual `warn`→`error` promotion is tracked at [#3331](https://github.com/open-gsd/gsd-core/issues/3331), since #3314's own precondition-handoff target, #1885, closed before this issue landed). Also `no-restricted-syntax` ban on `performance.now()` comparisons in assertions. ### Clock-seam pattern for concurrency -Concurrency logic must be tested via an injectable clock seam backed by `node:test` `mock.timers`. Real OS scheduler races are non-deterministic on loaded CI runners and are not a permitted test pattern. +Concurrency logic must be tested deterministically, via one of three reachability-selected mechanisms (see ADR-456 §(a)): an injectable clock seam for modules that accept `{clock = Date}`, `node:test` `mock.timers` for in-process direct-`Date`-reading code, or the `GSD_TEST_MODE`+`GSD_NOW_MS` subprocess pin (routed through `realClock`) for CLI-spawned code. Real OS scheduler races are non-deterministic on loaded CI runners and are not a permitted test pattern regardless of which mechanism applies. **Compliant pattern:** @@ -214,7 +214,7 @@ Real multi-process race tests are deleted once the corresponding deterministic c | `no-restricted-syntax` (ban 1) | `error` | Top-level `setTimeout` in `ExpressionStatement` | | `no-restricted-syntax` (ban 2) | `error` | `.only` member access on `test`/`it`/`describe` (belt-and-suspenders) | -`local/no-source-grep` and `local/no-magic-sleep-in-tests` now ship at `error` (promoted by [#3313](https://github.com/open-gsd/gsd-core/issues/3313), absorbing the cleanup sweep originally tracked at #453). `local/no-elapsed-assertion` remains `warn`, gated behind [#3314](https://github.com/open-gsd/gsd-core/issues/3314) — 10 of 19 clock-touching `src` modules have no sanctioned time-control mechanism until ADR-456 is amended there; promoting sooner would fail CI on correct, currently-unfixable code. New violations added after the acceptance of ADR 456 are out of policy regardless of the current ESLint severity. +`local/no-source-grep` and `local/no-magic-sleep-in-tests` now ship at `error` (promoted by [#3313](https://github.com/open-gsd/gsd-core/issues/3313), absorbing the cleanup sweep originally tracked at #453). `local/no-elapsed-assertion` remains `warn` — [#3314](https://github.com/open-gsd/gsd-core/issues/3314) delivered its precondition (ADR-456 §(a) amended with a reachability-based 3-mechanism rule; `commands.cts`/`init.cts`/`io.cts` backfilled with deterministic coverage), but does not itself own the `warn`→`error` promotion (mirroring the same handover boundary the epic draws for its other items) — that promotion is tracked at [#3331](https://github.com/open-gsd/gsd-core/issues/3331). New violations added after the acceptance of ADR 456 are out of policy regardless of the current ESLint severity. ESLint harness details: [`docs/adr/452-eslint-lint-harness.md`](docs/adr/452-eslint-lint-harness.md). diff --git a/docs/adr/456-test-rigor-architecture.md b/docs/adr/456-test-rigor-architecture.md index 86b398a79..d4f07d559 100644 --- a/docs/adr/456-test-rigor-architecture.md +++ b/docs/adr/456-test-rigor-architecture.md @@ -53,6 +53,27 @@ Real multi-process race tests (where two OS processes genuinely compete for a re Wall-clock timing in production code paths that cannot accept an injectable clock (e.g., third-party integrations) must be wrapped behind an adapter interface so tests can substitute a controlled clock. +#### Reachability-based mechanism selection (amended 2026-08-10, #3314) + +As originally written, this section names one mechanism — an injected `{clock = Date}` parameter driven by `t.mock.timers`. In practice this repo uses three, and the choice between them is determined by how a test reaches the system under test, not by preference: + +| Test reaches the SUT via… | Mechanism | Why | +|---|---|---| +| **Direct in-process call** (`require(...)` then invoke) — regardless of whether the SUT accepts an injected clock or reads a global | `t.mock.timers.enable(['Date'])` | Patches the process-global `Date` class. Any code in the same process — whether it reads `Date.now()` directly or through `realClock.now()` — observes the mocked time. No production code change is required to make an in-process-tested function deterministic. | +| **Spawned CLI subprocess** (`execFileSync`/`spawnSync` into a new Node process) | `GSD_TEST_MODE=1` + `GSD_NOW_MS=` (subprocess time-pin adapter, issue #474), with `TZ` also pinned when local-time getters are involved | `t.mock.timers` in the parent test process cannot reach a child process's clock — mocking is process-local. `GSD_NOW_MS` is read inside `realClock.now()`'s `_pinnedNowMs()` check (`src/clock.cts`), so it reaches the subprocess's clock **only if the subprocess's code path reads time through `realClock`** (`now()`/`nowIso()`/`today()`/`localToday()`). A code path that calls raw `Date.now()`/`new Date()` bypasses the pin entirely and stays untestable no matter what the test does — this is the concrete, repo-specific form the preceding paragraph's "must be wrapped behind an adapter interface" requirement takes at the subprocess boundary. `realClock.now() === Date.now()` whenever `GSD_TEST_MODE` is unset, so routing a call site through the seam is behavior-preserving in production. | +| Module **accepts** an injected `{clock = Date}` parameter | `makeFakeClock()` (`tests/helpers/clock.cjs`) | The pattern this section already documented — unchanged, still correct for this population. | + +This does not relax the typed-surface or delete-bad-tests policies elsewhere in this ADR; it only names the two mechanisms the original text omitted. Their absence from the documented policy is why several `src` modules read as "zero time-control adoption" despite some already routing through `realClock` correctly for part of their output. #3314's audit of the 10 originally-flagged direct-use modules: + +| Module | Classification | Rationale | +|---|---|---| +| `commands.cts` | Backfilled | `cmdCurrentTimestamp`'s entire output is a function of the clock (was regex-only tested); `_wsParseRetryAfter`'s HTTP-date branch had a demonstrated defect — a loose range assertion in place of an exact one, because the test couldn't pin `Date.now()`. | +| `init.cts` | Backfilled | `cmdInitManager`'s `is_active` gate (`nowMs - newestMtime < 300000`) had zero boundary coverage; `cmdInitQuick`'s `quick_id` generation already used `realClock` for two of its three time-derived output fields but not the third, and its own test admitted non-determinism ("we just verify format"). | +| `io.cts` | Backfilled | `reapStaleTempFiles`'s `maxAgeMs` gate had no `limit-1`/`limit`/`limit+1` boundary coverage; already in-process reachable by `t.mock.timers`, so no production code change was needed, only tests. | +| `roadmap.cts`, `workstream.cts`, `gsd2-import.cts`, `template.cts`, `verify.cts` | Incidental — no change | Each calls `realClock.localToday()`/`nowIso()` only to **stamp** a date into written content (`created:`, `Last Activity:`, archive-dir suffixes, backup filenames, a "(Backfilled: )" note) — no branch, comparison, or gating decision depends on the value. Already routed through the sanctioned seam; adding exact-value tests here would assert "today equals today," not exercise real logic. | +| `review-lane-invocation.cts` | Correction — does not touch the clock | Named in the epic's original module list, but the file's own header states "PURE. No filesystem, no network, no subprocess, no clock," confirmed by zero `Date`/`clock` references in the file. Likely refactored to pure-function shape after the epic was drafted. | +| `clock.cts` | N/A — is the seam itself | Already has dedicated coverage (`tests/clock-seam.test.cjs`, `tests/fix-2136-clock-local-today.test.cjs`); it is the thing being tested against, not a consumer needing backfill. | + ### (b) Antagonistic tier — property-based and mutation testing Two tools form the antagonistic tier: diff --git a/src/commands.cts b/src/commands.cts index d3a590a36..ccb8f6392 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -199,7 +199,7 @@ function cmdGenerateSlug(text: string | undefined, raw: boolean): void { } function cmdCurrentTimestamp(format: string | undefined, raw: boolean): void { - const now = new Date(); + const now = new Date(realClock.now()); let result: string; switch (format) { diff --git a/src/init.cts b/src/init.cts index d489ac568..02beeb3e2 100644 --- a/src/init.cts +++ b/src/init.cts @@ -1325,7 +1325,7 @@ function cmdInitQuick( options: Record = {}, ): void { const config = loadConfig(cwd); - const now = new Date(); + const now = new Date(realClock.now()); const slug = description ? generateSlugInternal(description)?.substring(0, 40) : null; const yy = String(now.getFullYear()).slice(-2); @@ -2273,7 +2273,7 @@ function cmdInitManager(cwd: string, raw: boolean): void { else if (hasContext) diskStatus = 'discussed'; else diskStatus = 'empty'; - const nowMs = Date.now(); + const nowMs = realClock.now(); let newestMtime = 0; for (const f of phaseFiles) { try { diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs index aa3a150fc..fc8fbf1af 100644 --- a/tests/commands.test.cjs +++ b/tests/commands.test.cjs @@ -968,6 +968,56 @@ describe('current-timestamp command', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// cmdCurrentTimestamp exact-value tests (#3314 — ADR-456 subprocess clock pin) +// ───────────────────────────────────────────────────────────────────────────── + +describe('current-timestamp command — exact value under GSD_NOW_MS pin', () => { + let tmpDir; + // Pinned instant with a non-zero millisecond fraction so the 'full' format + // assertion can't accidentally pass against a truncated value. + const PINNED_MS = 1_700_000_000_123; // 2023-11-14T22:13:20.123Z + const PIN_ENV = { GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS) }; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('date format: exact value for pinned instant', () => { + const result = runGsdTools('current-timestamp date', tmpDir, PIN_ENV); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + const expected = new Date(PINNED_MS).toISOString().split('T')[0]; + assert.strictEqual(output.timestamp, expected); + }); + + test('filename format: exact value for pinned instant', () => { + const result = runGsdTools('current-timestamp filename', tmpDir, PIN_ENV); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + const expected = new Date(PINNED_MS).toISOString().replace(/:/g, '-').replace(/\..+/, ''); + assert.strictEqual(output.timestamp, expected); + }); + + test('full format: exact value for pinned instant', () => { + const result = runGsdTools('current-timestamp full', tmpDir, PIN_ENV); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.timestamp, new Date(PINNED_MS).toISOString()); + }); + + test('default format: exact value for pinned instant', () => { + const result = runGsdTools('current-timestamp', tmpDir, PIN_ENV); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.timestamp, new Date(PINNED_MS).toISOString()); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // cmdListTodos tests (CMD-02) // ───────────────────────────────────────────────────────────────────────────── @@ -2476,17 +2526,48 @@ describe('_wsParseRetryAfter (#308)', () => { assert.strictEqual(_wsParseRetryAfter('120000'), 60000); }); - test('future HTTP-date → value in (0, 60000]', () => { - const futureDate = new Date(Date.now() + 5000).toUTCString(); - const v = _wsParseRetryAfter(futureDate); - assert.ok(typeof v === 'number' && v > 0 && v <= 60000, `expected (0,60000], got ${v}`); + // ADR-456 §(a) reachability rule: this function is required directly + // (in-process), so t.mock.timers reaches it without any production change — + // it patches the global `Date` that `Date.now()` reads from regardless of + // whether the SUT goes through realClock. Fixed, second-aligned pin so the + // HTTP-date's whole-second precision doesn't round the expected value away. + const PINNED_MS = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z + + test('future HTTP-date 5s ahead → exactly 5000 (deterministic)', (t) => { + t.mock.timers.enable(['Date']); + t.mock.timers.setTime(PINNED_MS); + const futureDate = new Date(PINNED_MS + 5000).toUTCString(); + assert.strictEqual(_wsParseRetryAfter(futureDate), 5000); }); - test('past HTTP-date → 0', () => { - const pastDate = new Date(Date.now() - 5000).toUTCString(); + test('past HTTP-date 5s behind → exactly 0 (deterministic)', (t) => { + t.mock.timers.enable(['Date']); + t.mock.timers.setTime(PINNED_MS); + const pastDate = new Date(PINNED_MS - 5000).toUTCString(); assert.strictEqual(_wsParseRetryAfter(pastDate), 0); }); + test('boundary: HTTP-date 59s ahead → 59000, not clamped', (t) => { + t.mock.timers.enable(['Date']); + t.mock.timers.setTime(PINNED_MS); + const d = new Date(PINNED_MS + 59_000).toUTCString(); + assert.strictEqual(_wsParseRetryAfter(d), 59_000); + }); + + test('boundary: HTTP-date 60s ahead → 60000, at cap exactly', (t) => { + t.mock.timers.enable(['Date']); + t.mock.timers.setTime(PINNED_MS); + const d = new Date(PINNED_MS + 60_000).toUTCString(); + assert.strictEqual(_wsParseRetryAfter(d), 60_000); + }); + + test('boundary: HTTP-date 61s ahead → clamped to 60000', (t) => { + t.mock.timers.enable(['Date']); + t.mock.timers.setTime(PINNED_MS); + const d = new Date(PINNED_MS + 61_000).toUTCString(); + assert.strictEqual(_wsParseRetryAfter(d), 60_000); + }); + test('"garbage" → null', () => { assert.strictEqual(_wsParseRetryAfter('garbage'), null); }); diff --git a/tests/init-manager.test.cjs b/tests/init-manager.test.cjs index 499d2a127..cebfcedeb 100644 --- a/tests/init-manager.test.cjs +++ b/tests/init-manager.test.cjs @@ -475,6 +475,69 @@ describe('init manager', () => { assert.ok(output.phases[0].last_activity !== null); }); + test('activity detection: hour-old file = not active', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [{ number: '1', name: 'Stale Phase' }]); + + const PINNED_NOW_MS = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z (second-aligned) + const phaseDir = scaffoldPhase(tmpDir, 1, { slug: 'stale-phase', context: true }); + const files = fs.readdirSync(phaseDir); + const old = new Date(PINNED_NOW_MS - 60 * 60 * 1000); // 1 hour before the pinned "now" + for (const f of files) { + fs.utimesSync(path.join(phaseDir, f), old, old); + } + + const result = runGsdTools('init manager', tmpDir, { + GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_NOW_MS), + }); + const output = JSON.parse(result.output); + + assert.strictEqual(output.phases[0].is_active, false); + }); + + // #3314 — ADR-456 subprocess clock pin: cmdInitManager's `is_active` gate + // (nowMs - newestMtime < 300000) is CLI-subprocess tested, so only the + // GSD_TEST_MODE+GSD_NOW_MS pin can control "now" here (t.mock.timers in the + // test process cannot reach the spawned child). newestMtime is set via + // fs.utimesSync to an exact epoch offset from the pinned "now" so the + // 300000ms boundary is provable exactly, not just "roughly recent"/"roughly old". + describe('is_active boundary (#3314 — exactly 300000ms, condition is strict <)', () => { + const PINNED_NOW_MS = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z (second-aligned) + const PIN_ENV = { GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_NOW_MS) }; + + function scaffoldWithMtimeOffset(tmpDirLocal, offsetMs) { + writeState(tmpDirLocal); + writeRoadmap(tmpDirLocal, [{ number: '1', name: 'Boundary Phase' }]); + const phaseDir = scaffoldPhase(tmpDirLocal, 1, { slug: 'boundary-phase', context: true }); + const mtimeMs = PINNED_NOW_MS - offsetMs; + const mtimeDate = new Date(mtimeMs); + for (const f of fs.readdirSync(phaseDir)) { + fs.utimesSync(path.join(phaseDir, f), mtimeDate, mtimeDate); + } + } + + test('boundary: 299999ms since last activity → is_active true', () => { + scaffoldWithMtimeOffset(tmpDir, 299_999); + const result = runGsdTools('init manager', tmpDir, PIN_ENV); + const output = JSON.parse(result.output); + assert.strictEqual(output.phases[0].is_active, true); + }); + + test('boundary: exactly 300000ms since last activity → is_active false', () => { + scaffoldWithMtimeOffset(tmpDir, 300_000); + const result = runGsdTools('init manager', tmpDir, PIN_ENV); + const output = JSON.parse(result.output); + assert.strictEqual(output.phases[0].is_active, false); + }); + + test('boundary: 300001ms since last activity → is_active false', () => { + scaffoldWithMtimeOffset(tmpDir, 300_001); + const result = runGsdTools('init manager', tmpDir, PIN_ENV); + const output = JSON.parse(result.output); + assert.strictEqual(output.phases[0].is_active, false); + }); + }); + test('conflict filter: blocks dependent phase execute when dep is active', () => { writeState(tmpDir); writeRoadmap(tmpDir, [ diff --git a/tests/init.test.cjs b/tests/init.test.cjs index 5eb3b2c38..40c70e06e 100644 --- a/tests/init.test.cjs +++ b/tests/init.test.cjs @@ -1713,6 +1713,80 @@ describe('cmdInitQuick', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// cmdInitQuick quick_id exact-value tests (#3314 — ADR-456 subprocess clock pin) +// ───────────────────────────────────────────────────────────────────────────── + +describe('cmdInitQuick quick_id — exact value under GSD_NOW_MS+TZ pin', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.realpathSync(createFixture()); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // Computes the expected quick_id independently from the SAME algorithm + // documented in src/init.cts's cmdInitQuick — NOT copy-pasted from its + // runtime output — so this test can actually catch a broken implementation. + function expectedQuickId(ms) { + const d = new Date(ms); + const yy = String(d.getFullYear()).slice(-2); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + const dateStr = yy + mm + dd; + const secondsSinceMidnight = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds(); + const timeBlocks = Math.floor(secondsSinceMidnight / 2); + const timeEncoded = timeBlocks.toString(36).padStart(3, '0'); + return dateStr + '-' + timeEncoded; + } + + test('quick_id: exact value for pinned instant', () => { + const PINNED_MS = 1_700_000_000_000; // 2023-11-14T22:13:20.000Z + const result = runGsdTools('init quick "pinned task"', tmpDir, { + GSD_TEST_MODE: '1', GSD_NOW_MS: String(PINNED_MS), TZ: 'UTC', + }); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.quick_id, expectedQuickId(PINNED_MS)); + }); + + test('boundary: two calls in the same 2s block share quick_id (documented collision, not a bug)', () => { + const BLOCK_START_MS = 1_700_000_000_000; // aligned so +0 and +1000 fall in the same 2s block + const r1 = runGsdTools('init quick "task a"', tmpDir, { + GSD_TEST_MODE: '1', GSD_NOW_MS: String(BLOCK_START_MS), TZ: 'UTC', + }); + const r2 = runGsdTools('init quick "task b"', tmpDir, { + GSD_TEST_MODE: '1', GSD_NOW_MS: String(BLOCK_START_MS + 1000), TZ: 'UTC', + }); + assert.ok(r1.success && r2.success); + const o1 = JSON.parse(r1.output); + const o2 = JSON.parse(r2.output); + assert.strictEqual(o1.quick_id, expectedQuickId(BLOCK_START_MS)); + assert.strictEqual(o2.quick_id, expectedQuickId(BLOCK_START_MS + 1000)); + assert.strictEqual(o1.quick_id, o2.quick_id, 'both instants are in the same 2-second block and must share a quick_id'); + }); + + test('boundary: two calls straddling a 2s block edge get different quick_id', () => { + const BEFORE_EDGE_MS = 1_700_000_000_000; // even second → block boundary at +2000ms + const AFTER_EDGE_MS = BEFORE_EDGE_MS + 2000; + const r1 = runGsdTools('init quick "task a"', tmpDir, { + GSD_TEST_MODE: '1', GSD_NOW_MS: String(BEFORE_EDGE_MS), TZ: 'UTC', + }); + const r2 = runGsdTools('init quick "task b"', tmpDir, { + GSD_TEST_MODE: '1', GSD_NOW_MS: String(AFTER_EDGE_MS), TZ: 'UTC', + }); + assert.ok(r1.success && r2.success); + const o1 = JSON.parse(r1.output); + const o2 = JSON.parse(r2.output); + assert.strictEqual(o1.quick_id, expectedQuickId(BEFORE_EDGE_MS)); + assert.strictEqual(o2.quick_id, expectedQuickId(AFTER_EDGE_MS)); + assert.notStrictEqual(o1.quick_id, o2.quick_id, 'instants 2000ms apart cross a 2-second block edge and must differ'); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // cmdInitMapCodebase (INIT-05) // ───────────────────────────────────────────────────────────────────────────── diff --git a/tests/io.test.cjs b/tests/io.test.cjs index fc1be4989..23e0a7034 100644 --- a/tests/io.test.cjs +++ b/tests/io.test.cjs @@ -311,6 +311,45 @@ describe('reapStaleTempFiles (via io)', () => { io.reapStaleTempFiles('gsd-io-nonexistent-prefix-xyz-', { maxAgeMs: 0 }); }); }); + + // #3314 — ADR-456 in-process reachability: t.mock.timers patches the + // process-global Date, so it controls `now` inside reapStaleTempFiles with + // no production code change needed. Both sides of the comparison (mocked + // "now" and the fs.utimesSync mtime) use second-aligned epoch values to + // avoid filesystem mtime sub-second-precision truncation on filesystems + // that round mtime to the nearest second. + describe('boundary: age exactly at maxAgeMs (condition is strictly-greater)', () => { + const MTIME_MS = 1_700_000_000_000; // second-aligned + const MAX_AGE_MS = 5000; + + function plantFileAtAge(t, ageMs) { + fs.mkdirSync(io.GSD_TEMP_DIR, { recursive: true }); + const p = path.join(io.GSD_TEMP_DIR, TEST_PREFIX + `boundary-${ageMs}.json`); + fs.writeFileSync(p, '{}'); + fs.utimesSync(p, new Date(MTIME_MS), new Date(MTIME_MS)); + t.mock.timers.enable(['Date']); + t.mock.timers.setTime(MTIME_MS + ageMs); + return p; + } + + test('boundary: age exactly maxAgeMs-1 is kept', (t) => { + const p = plantFileAtAge(t, MAX_AGE_MS - 1); + io.reapStaleTempFiles(TEST_PREFIX, { maxAgeMs: MAX_AGE_MS }); + assert.ok(fs.existsSync(p), 'file at maxAgeMs-1 must be kept'); + }); + + test('boundary: age exactly maxAgeMs is kept (condition is strictly-greater)', (t) => { + const p = plantFileAtAge(t, MAX_AGE_MS); + io.reapStaleTempFiles(TEST_PREFIX, { maxAgeMs: MAX_AGE_MS }); + assert.ok(fs.existsSync(p), 'file at exactly maxAgeMs must be kept — condition is strictly-greater, not >='); + }); + + test('boundary: age exactly maxAgeMs+1 is removed', (t) => { + const p = plantFileAtAge(t, MAX_AGE_MS + 1); + io.reapStaleTempFiles(TEST_PREFIX, { maxAgeMs: MAX_AGE_MS }); + assert.ok(!fs.existsSync(p), 'file at maxAgeMs+1 must be removed'); + }); + }); });