* test(#3314): backfill deterministic clock-seam coverage (failing-first) Replaces loose regex/range assertions with exact-value and boundary tests for the CLI-subprocess and in-process clock-touching call sites identified by H2's audit (epic #3053): cmdCurrentTimestamp, _wsParseRetryAfter (commands.cts), cmdInitManager's is_active gate and cmdInitQuick's quick_id generation (init.cts), and reapStaleTempFiles (io.cts). The CLI-subprocess-pinned tests are expected RED until a follow-up commit routes those call sites through realClock so GSD_TEST_MODE+GSD_NOW_MS can reach them. * refactor(#3314): route CLI-subprocess clock reads through realClock cmdCurrentTimestamp, cmdInitManager's is_active gate, and cmdInitQuick's quick_id generation read Date directly, which the GSD_TEST_MODE+GSD_NOW_MS subprocess pin cannot reach (it only fires inside realClock.now()). Behavior-preserving: realClock.now() falls through to Date.now() whenever GSD_TEST_MODE is unset, which is every real invocation. * docs(#3314): amend ADR-456 with reachability-based clock-control rule ADR-456 §(a) documented one mechanism (injected {clock=Date} + t.mock.timers). Adds the two this repo already relies on: t.mock.timers for in-process direct-Date reads, and the GSD_TEST_MODE+GSD_NOW_MS subprocess pin (routed through realClock) for CLI-spawned code. Updates TESTING-STANDARDS.md's matching passages, which already referenced this issue by number as the no-elapsed-assertion promotion precondition. * fix(#3314): address orthogonal review findings Spec-axis findings: file and link the no-elapsed-assertion promotion follow-up (#3331) instead of leaving TESTING-STANDARDS.md pointing at a dead #1885, and ship the module-by-module audit table in the ADR itself rather than only in a gitignored phase artifact. Standards-axis finding: pin the "hour-old file = not active" test via GSD_TEST_MODE+GSD_NOW_MS for consistency with the reachability rule this PR's own ADR amendment now documents. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -137,11 +137,11 @@ await doWork();
|
|||||||
assert(Date.now() - start < 200, 'must complete in 200ms');
|
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
|
### 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:**
|
**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 1) | `error` | Top-level `setTimeout` in `ExpressionStatement` |
|
||||||
| `no-restricted-syntax` (ban 2) | `error` | `.only` member access on `test`/`it`/`describe` (belt-and-suspenders) |
|
| `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).
|
ESLint harness details: [`docs/adr/452-eslint-lint-harness.md`](docs/adr/452-eslint-lint-harness.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.
|
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=<epoch-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: <date>)" 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
|
### (b) Antagonistic tier — property-based and mutation testing
|
||||||
|
|
||||||
Two tools form the antagonistic tier:
|
Two tools form the antagonistic tier:
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ function cmdGenerateSlug(text: string | undefined, raw: boolean): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cmdCurrentTimestamp(format: 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;
|
let result: string;
|
||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
|
|||||||
@@ -1325,7 +1325,7 @@ function cmdInitQuick(
|
|||||||
options: Record<string, unknown> = {},
|
options: Record<string, unknown> = {},
|
||||||
): void {
|
): void {
|
||||||
const config = loadConfig(cwd);
|
const config = loadConfig(cwd);
|
||||||
const now = new Date();
|
const now = new Date(realClock.now());
|
||||||
const slug = description ? generateSlugInternal(description)?.substring(0, 40) : null;
|
const slug = description ? generateSlugInternal(description)?.substring(0, 40) : null;
|
||||||
|
|
||||||
const yy = String(now.getFullYear()).slice(-2);
|
const yy = String(now.getFullYear()).slice(-2);
|
||||||
@@ -2273,7 +2273,7 @@ function cmdInitManager(cwd: string, raw: boolean): void {
|
|||||||
else if (hasContext) diskStatus = 'discussed';
|
else if (hasContext) diskStatus = 'discussed';
|
||||||
else diskStatus = 'empty';
|
else diskStatus = 'empty';
|
||||||
|
|
||||||
const nowMs = Date.now();
|
const nowMs = realClock.now();
|
||||||
let newestMtime = 0;
|
let newestMtime = 0;
|
||||||
for (const f of phaseFiles) {
|
for (const f of phaseFiles) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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)
|
// cmdListTodos tests (CMD-02)
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -2476,17 +2526,48 @@ describe('_wsParseRetryAfter (#308)', () => {
|
|||||||
assert.strictEqual(_wsParseRetryAfter('120000'), 60000);
|
assert.strictEqual(_wsParseRetryAfter('120000'), 60000);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('future HTTP-date → value in (0, 60000]', () => {
|
// ADR-456 §(a) reachability rule: this function is required directly
|
||||||
const futureDate = new Date(Date.now() + 5000).toUTCString();
|
// (in-process), so t.mock.timers reaches it without any production change —
|
||||||
const v = _wsParseRetryAfter(futureDate);
|
// it patches the global `Date` that `Date.now()` reads from regardless of
|
||||||
assert.ok(typeof v === 'number' && v > 0 && v <= 60000, `expected (0,60000], got ${v}`);
|
// 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', () => {
|
test('past HTTP-date 5s behind → exactly 0 (deterministic)', (t) => {
|
||||||
const pastDate = new Date(Date.now() - 5000).toUTCString();
|
t.mock.timers.enable(['Date']);
|
||||||
|
t.mock.timers.setTime(PINNED_MS);
|
||||||
|
const pastDate = new Date(PINNED_MS - 5000).toUTCString();
|
||||||
assert.strictEqual(_wsParseRetryAfter(pastDate), 0);
|
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', () => {
|
test('"garbage" → null', () => {
|
||||||
assert.strictEqual(_wsParseRetryAfter('garbage'), null);
|
assert.strictEqual(_wsParseRetryAfter('garbage'), null);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -475,6 +475,69 @@ describe('init manager', () => {
|
|||||||
assert.ok(output.phases[0].last_activity !== null);
|
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', () => {
|
test('conflict filter: blocks dependent phase execute when dep is active', () => {
|
||||||
writeState(tmpDir);
|
writeState(tmpDir);
|
||||||
writeRoadmap(tmpDir, [
|
writeRoadmap(tmpDir, [
|
||||||
|
|||||||
@@ -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)
|
// cmdInitMapCodebase (INIT-05)
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -311,6 +311,45 @@ describe('reapStaleTempFiles (via io)', () => {
|
|||||||
io.reapStaleTempFiles('gsd-io-nonexistent-prefix-xyz-', { maxAgeMs: 0 });
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user