* fix(state): read the hybrid "Current Plan: N of M" shape advancePlanCore derived the value FORMAT from the field NAME, so it handled the legacy pair (`Current Plan` + `Total Plans in Phase`) and the compound `Plan: N of M`, but not the hybrid of the two: the legacy field name carrying a compound value with no Total Plans sibling. `legacyTotal` is null so the legacy branch fell through, and the compound branch reads the `Plan` field through a `^Plan:`-anchored pattern that never matches `Current Plan:`. Both produced NaN against a file whose plan numbers are plainly readable. The shape is not exotic. An agent wrote it unprompted into a project's STATE.md, believing it was the parseable form, and every subsequent run in that project inherited the failure and worked around it by hand. Track the field name and the value shape separately (`planSourceField`, `planRawValue`) so write-back targets whichever field the value came from. The legacy pair still takes precedence when both fields exist, so a stray "of N" inside Current Plan cannot override an explicit Total Plans — covered by a new test. Also replace the caller's catch-all error. It reported "Cannot parse Current Plan or Total Plans" for ANY transition failure, and named no accepted shape, so a reader learned neither what failed nor what to write. It now distinguishes "no result" from "unreadable plan position" and lists all three shapes. The existing test asserted the literal "cannot parse"; it now asserts the message names the shapes, which is the property that makes it actionable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016QHxbMHTPYbEqAnKTpJPR8 * fix(state): keep zero-padding when advancing a compound plan value The compound write-back rewrote only the leading half of "N of M", so a padded value drifted lopsided: "04 of 06" advanced to "5 of 06". Cosmetic on its own, but a plan line that looks wrong is one the next writer tidies by hand, and hand-tidying this particular line is what produced the hybrid shape the previous commit had to teach the parser to read. Pad the incremented number to the width it was written with. padStart never truncates, so a value that outgrows its padding widens correctly: 09 of 12 advances to 10 of 12. Unpadded values are untouched — 2 of 6 still advances to 3 of 6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016QHxbMHTPYbEqAnKTpJPR8 * fix(state): pass a literal field name to the compound write-back The previous commit passed `planSourceField` — a variable — as the field-name argument to `stateReplaceField`, which trips the state-write-path drift guard's `unstripped_content_write` axis (ADR-3408 §8.3(b)). The guard is right to care: a Title-Case literal cannot collide with a lowercase or snake_case frontmatter key, so it is safe whatever the content argument is, while a variable could hold anything and therefore requires its content to be demonstrably frontmatter-stripped first. The content argument here IS stripped — `body` is `stripFrontmatter(content)` — but the guard does a narrow backward scan rather than dataflow tracking, by design, and the nearest preceding assignment to `body` is another `stateReplaceField` result. Rather than baseline a bypass or ask a future reader to re-derive that the invariant holds, dispatch on the discriminator and pass the literal. Guard goes from 1 finding to 0; its own 32 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016QHxbMHTPYbEqAnKTpJPR8 * chore(3784): add changeset fragment for #3785 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016QHxbMHTPYbEqAnKTpJPR8 * test(#3784): cover the maintainer's AC1 write-back and AC6 reader-anchoring Triage published six acceptance criteria; two were only half-covered. AC1 asks that the hybrid write back to the SAME field with padding preserved. The existing hybrid test used an unpadded value and asserted only `result.data`, so it proved the parse but never the write. Now asserts the written content is `05 of 06` on the original field, and that no separate `Plan:` field appears as a side effect. AC6 asks that the shared field reader not be loosened. Reading the hybrid is the transition's job; `stateExtractField('Plan')` is line-anchored and has 13+ callers, so teaching it to match a name merely ENDING in "Plan" would be the wrong fix and would silently change what those callers read. This holds by construction here — the reader is untouched — but nothing locked it in. The new test fails if anyone later reaches for that shortcut. Also drops the changeset fragment written against the auto-closed PR number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016QHxbMHTPYbEqAnKTpJPR8 * chore(#3784): add changeset fragment for #3791 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016QHxbMHTPYbEqAnKTpJPR8 * fix(#3784): write the advanced plan back to the field it was read from Review findings 2-6 on #3791 were one defect seen from several angles: the read path learned the hybrid `Current Plan: N of M` shape, the write path did not follow it. - `bumpLeadingNumber` now owns the increment for all three parse branches. Only the leading digits belong to this transition; the padding width and everything after it (` of M`, and the `\r` of a CRLF file) are the author's text and are preserved. The legacy branch wrote `String(newPlan)`, which turned `2 of 99` into `3` and `04` into `5`. - `mutateCurrentPositionForAdvance` takes the plan field NAME. Its plan arm only ever looked for `Plan:`, so on a hybrid file the `## Current Position` section was never reached; combined with the body-level write being single-shot and bold-preferring, a file carrying the field at both sites advanced the header and left the section a plan behind. The parameter defaults to `Plan`, so the two callers that pass no plan are unchanged. - Tests: both-sites-advance (fails without the section arm), legacy write-back content assertions (the previous test read only `data` and so could not see the lossy write), hybrid boundary at limit-1 and limit+1, a CRLF fixture, and an fc property pinning the padding-width contract. Two characterization tests pinned `**Current Plan:** 02` advancing to `3`. That dropped padding is the defect #3784 reports, so the expectation is corrected to `03` rather than the fix being narrowed around it. * fix(#3784): drop the unreachable advance-plan error branch, sync the doc Findings 1 and 8 on #3791. The `!resultData` arm could not fire: the transform callback assigns `resultData` unconditionally, only runs once STATE.md is known to exist (the missing-file case returns "STATE.md not found" upstream), and every `advancePlanCore` return path sets `data`. It was a speculative second failure mode with a message no caller could receive, and the comment beside it claimed to distinguish two things that were never two. `!resultData` stays in the condition as a type guard, which is all it ever was. `docs/json-errors.md:142` quoted the old error literal verbatim and was the sole occurrence in the tree; it now quotes the emitted one. * chore(#3784): describe the write-back fix in the changeset * fix(#3784): anchor the plan grammar and widen the schema row to match Review round 3 on #3791: B1, B2, M1, M2, M3, M4 and the planSourceField nit. B1 — `STATE_FIELD_SCHEMA.current_plan.acceptedShapes` widens to `['N', 'N of M']`, which is what `src/state-md-schema.cts`'s own comment instructed this PR to do on merge. `'N/M'` stays undeclared so row 23 keeps a non-vacuous undeclared candidate to probe. Verified `gen-state-md-docs --check` exits 0 and `--write` rewrites 0 of 6: the generated artifacts do not surface this row, so there is nothing stale to regenerate. B2/M4 — the discriminator was `/of\s+(\d+)/`, unanchored, so a total could be read out of prose. `Current Plan: 4 — blocked on review of 2 PRs` parsed as `4 of 2`, took the `currentPlan >= totalPlans` branch and WROTE `Status: Phase complete — ready for verification` into the user's file. Both shapes are now anchored at the start and every number comes from a capture group via `planNumberFrom`, which rejects anything past `Number.MAX_SAFE_INTEGER` rather than letting `data` and the persisted string disagree. Nothing on this path calls `parseInt` on a raw field value any more. The grammar keeps a trailing remainder after the total, because `Plan: 2 of 5 in current phase` is a real tested shape. The refusal comes from requiring `of <total>` to follow the leading number immediately, not from forbidding a suffix. M1 — `bumpLeadingNumber` is total. It returned its input unchanged when there were no leading digits, so `+2` reported `advanced: true` while writing the file untouched. M2 — both section arms use replacer functions. File-derived text was being spliced into a `String.replace` replacement string, where `$&` / `` $` `` / `$'` expand: a value of `04 of 06 $&` spliced part of the document into itself. `stateReplaceField` already used a function; these now agree with it. M3 — the section arm targets the name the SECTION carries, and the body write now writes both spellings, each with its own rendering. Keying off the header's name left the other name stale in both directions: a legacy header beside a `Current Plan:` section line, and a `**Plan:**` header beside one. * fix(#3784): derive the shape error from the schema, widen the test coverage Review round 3 on #3791: B3, m1, m2, m5 and the two test nits. B3 — the accepted-shape set had two owners: the parser branches and an English list hand-written beside them in `state.cts`. Nothing coupled them, so adding a branch left the message stale and removing one left it advertising a shape that errors, with no test able to see either. The message is now built from `STATE_FIELD_SCHEMA.current_plan.acceptedShapes`, and the CLI test walks the schema instead of restating the list. `Plan: N of M` is still spelled out explicitly because no schema row owns the body-only `Plan` field — `buildStateFrontmatter` never reads it into frontmatter, so it has no key to hang a row on. m1 — the property drove only the pre-existing `**Plan:**` branch, i.e. not the branch under review. It now drives both compound spellings and ranges past 99 so the width transition is covered by the property rather than one example. A second property covers the legacy pair's own preservation contract. Both were mutation-checked: dropping the padStart turns 9 tests red. m2 — degenerate boundary fixtures around the threshold (`0 of 0` is phase-complete, not an error; `0 of 3` advances) plus the shapes the anchored grammar must refuse, including Arabic-Indic digits. m5 — `docs/json-errors.md` described rather than quoted the message, since it is now schema-derived and a verbatim quote would be a third owner. Nits — the CRLF assertion could not see a `\n` at index 0; the `!/^Plan:/m` presence proxy is now an identity assertion on the whole `## Current Position` body. * fix(#3784): give the section plan write its own flag, and stop narrowing what parses Review round 4 on #3791: Blockers 1-4, Majors 1-2, Minors 1-2. B1 — the section fallback was guarded by `!mutated`, and `mutated` is FUNCTION-wide, already set by the phase/status/lastActivity arms that `advancePlanCore` always populates. A section spelling the field bold or as a pipe-table row therefore skipped its fallback because an UNRELATED field had been refreshed, and stayed a plan behind the header — the split-brain document this arm exists to prevent. The arm now tracks its own `planWritten`. Worth recording: the reviewer's fixture does not reproduce. The body-level status write lands on the section's own `Status:` when the document has no header `Status:`, so `mutated` is still false by the time the plan arm runs and the fallback fires. The discriminating shape needs a header `Status:` to absorb that write AND a bold section plan line. The mechanism was right; the example was not, and the regression test uses the shape that actually fails. B2 — `fallbackName` chose one name by ternary. In the legacy shape both values are populated, so it always chose `Current Plan` and a `**Plan:**` section line — which base did write — got nothing. Each name is now attempted independently with its own fallback. B3 — `PLAN_SHAPE_N` was anchored harder than `PLAN_SHAPE_N_OF_M`, so values base parsed via `parseInt` began to hard-error: `Total Plans in Phase: 5 phases`, `Current Plan: 3 (blocked)`. #3784's brief puts normalizing plan numbers beyond this transition's read/write out of scope, so that narrowing was not licensed. Both grammars now carry the same trailing tolerance. The prose defect stays closed by the START anchor, not by forbidding suffixes. Major 1 — the whole-body `Plan` write is scoped to documents that declare a `Plan` field, instead of firing unconditionally where `stateReplaceField`'s first match could be prose outside `## Current Position`. Major 2 — the error message names both `Plan` spellings the parser accepts; it previously omitted the sibling-paired form, which is the same message-disagrees-with-parser drift the derivation exists to close. B4 — the changeset claimed a guarantee B1 broke; it now describes what ships. Minors — safe-integer boundary coverage at limit-1/limit/limit+1, and the CRLF comment states the real mechanism (`stateExtractField`'s `(.+)` stops before the CR; the trailing group is belt-and-braces, not the primary defence). All three blocker regression tests verified red against the pre-fix source. * test(#3784): pin the hybrid shape against #3807's ambiguity refusal #4028 landed `advance-plan`'s multi-`Phase:` refusal on `next` after this branch's last run, on the same function. The guard sits above the parse, so a refused document is never parsed and the shape #3784 adds cannot reach the mutation — but that is a property of source ordering, so assert it as behaviour instead. Fail-first proven, not assumed: with `phaseCandidates.length > 1` disabled, the ambiguous hybrid document advances its FIRST entry's `Current Plan: 04 of 06` to `05 of 06` and writes it — #3807's exact defect, reached through #3784's shape. Both tests go red; both go green with the guard restored. The control pins the other direction: an unambiguous hybrid section still advances, and its zero-padding still survives. * fix(#3784): advance every spelling from its own text, refuse when they disagree Round 6 review. B1 and M1 are one defect, so they are one fix. `advancePlanCore` picked one field to parse from, computed `newPlan`, then wrote BOTH spellings from that field's numbers. Two symptoms: B1 With `Plan` as the parse source, `Current Plan` was re-stamped with the number just derived from `Plan`. `Current Plan: 7` beside `Plan: 2 of 5` silently became `Current Plan: 3` — a value nothing derived for that field, no error, no diagnostic. M1 With the legacy pair winning, the `Plan:` line was re-rendered from a bare `${newPlan} of ${totalPlans}` built out of the sibling field. `Plan: 2 of 9` became `3 of 5`; `Plan: 03 of 05` became `4 of 5`. The changeset's claim that padding and everything after it survive was true only for whichever field happened to be the parse source. Now: every spelling is advanced from its own raw text via `bumpLeadingNumber`, so each keeps its own padding, its own total and its own trailing annotation. Differing TOTALS are preserved, not reconciled — `Plan: 2 of 9` beside a `Total Plans in Phase: 5` advances to `3 of 9`. Differing CURRENT numbers are refused, with `reason: "ambiguous_plan_position"` and both candidates named. Same posture as #3807's multi-`Phase:` guard one field over: name the conflict, let the caller resolve it, never pick. The guard sits immediately after the parse, BEFORE the phase-complete branch — guarding only the normal advance would let `Current Plan: 7` beside `Plan: 5 of 5` write a terminal "Phase complete" into a document whose two spellings never agreed. A field present but unreadable (`Plan: TBD`) is left exactly as authored. Refusing the whole document because an unrelated line cannot be read would be a narrowing #3784 does not license; writing a derived number over it is the fabrication B1 was filed for. The `planSourceField`/`planRawValue`/`useCompoundFormat` tracking is gone. It existed only so the write path could ask which field the value came from, and the write path no longer asks. M2. The bare `Plan: N` + `Total Plans in Phase: M` shape is dropped. A revision of this PR added it; base refused it. It cannot be given the schema-row + forcing-test coupling the other shapes have, because `Plan` is body-only and `buildStateFrontmatter` never reads it into frontmatter, so there is no `current_*` key to hang a row on. Parser, the spelling in `advancePlanShapeError`, and the lockstep test move together — the invariant is the lockstep, not the length of the list. N1. The whitespace narrowing (`5phases` no longer parses where `parseInt` read 5) is documented in the changeset beside the other deliberate narrowings, rather than loosened. Loosening restores the half-parse this change exists to remove. Tests: eight new cases plus a property that crosses the two spellings with agreeing and disagreeing numbers — the review noted the existing properties never did. Fail-first proven: restoring the old write path reddens seven of the eight, both new property arms, and two pre-existing padding tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H3eK225hgcnEDZsnmtaP1U * fix(#3784): report Current Plan as updated only when it was written The write became conditional in the previous commit — a `Current Plan:` that is present but unreadable is left as authored — but the `updated` push stayed unconditional, so `transitionCore` reported a field it had not touched. `reconcileReportedFields` would have caught it against the persisted bytes at the `state.cts` caller, but `transitionCore`'s own `updated` is consumed directly (milestone-lock, the transition tests) and has to be true on its own. Covers the mirror of the unreadable-spelling case: `Current Plan: TBD` beside a readable `Plan: 2 of 5`, where `Plan` is the parse source and the legacy field is the one that cannot advance. Fail-first proven. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H3eK225hgcnEDZsnmtaP1U * test(#3784): account for the new refusal in the output({error}) census `tests/io.test.cjs`' A3 census asserts the exact population of `output({error})` call sites in `src/`, per module. The `ambiguous_plan_position` refusal added a 27th to `state.cts`, so the census went red at 26/65. Updated the way #3807 updated it when it added the ambiguous-POSITION error one line above: bump the count and name the addition inline, so the next person reads why the number is what it is. The alarm did its job — it is the only gate that noticed a new user-visible error path had been introduced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H3eK225hgcnEDZsnmtaP1U --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
1093 lines
47 KiB
JavaScript
1093 lines
47 KiB
JavaScript
/**
|
|
* Tests for src/io.cts (compiled to gsd-core/bin/lib/io.cjs).
|
|
*
|
|
* Verifies behavioural contracts of the extracted CLI I/O primitives:
|
|
* - output() writes expected structure to stdout
|
|
* - error() writes expected structure to stderr and exits
|
|
* - ERROR_REASON constants have the correct wire values
|
|
* - setJsonErrorMode/getJsonErrorMode toggle behaviour
|
|
* - core.cjs re-export shims resolve to the exact same objects as io.cjs
|
|
*
|
|
* ADR-857 phase 1 / issue #859.
|
|
*/
|
|
|
|
const { test, describe, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const fs = require('node:fs');
|
|
|
|
const io = require('../gsd-core/bin/lib/io.cjs');
|
|
const {
|
|
ExitError, resolveContractVersion, setPendingOutcome, getPendingOutcome, runMain,
|
|
} = require('../gsd-core/bin/lib/cli-exit.cjs');
|
|
const { EXIT_CODES } = require('../gsd-core/bin/lib/exit-code-registry.cjs');
|
|
const { runNode } = require('./helpers/process-seam.cjs');
|
|
const { toLegacyResult } = require('./helpers/git-fixture.cjs');
|
|
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
const fc = require('./helpers/fast-check-setup.cjs');
|
|
const ts = require('typescript');
|
|
|
|
function runScript(script) {
|
|
return toLegacyResult(runNode(['-e', script], { timeoutMs: PROBE_TIMEOUT_MS }));
|
|
}
|
|
|
|
// ─── ERROR_REASON constants ───────────────────────────────────────────────────
|
|
|
|
describe('ERROR_REASON', () => {
|
|
test('is a frozen object', () => {
|
|
assert.ok(Object.isFrozen(io.ERROR_REASON));
|
|
});
|
|
|
|
test('contains expected wire values', () => {
|
|
assert.strictEqual(io.ERROR_REASON.CONFIG_KEY_NOT_FOUND, 'config_key_not_found');
|
|
assert.strictEqual(io.ERROR_REASON.CONFIG_NO_FILE, 'config_no_file');
|
|
assert.strictEqual(io.ERROR_REASON.CONFIG_PARSE_FAILED, 'config_parse_failed');
|
|
assert.strictEqual(io.ERROR_REASON.CONFIG_INVALID_KEY, 'config_invalid_key');
|
|
assert.strictEqual(io.ERROR_REASON.SDK_FAIL_FAST, 'sdk_fail_fast');
|
|
assert.strictEqual(io.ERROR_REASON.SDK_UNKNOWN_COMMAND, 'sdk_unknown_command');
|
|
assert.strictEqual(io.ERROR_REASON.SDK_MISSING_ARG, 'sdk_missing_arg');
|
|
assert.strictEqual(io.ERROR_REASON.PHASE_NOT_FOUND, 'phase_not_found');
|
|
assert.strictEqual(io.ERROR_REASON.SUMMARY_NO_PLANNING, 'summary_no_planning');
|
|
assert.strictEqual(io.ERROR_REASON.GRAPHIFY_NO_GRAPH, 'graphify_no_graph');
|
|
assert.strictEqual(io.ERROR_REASON.GRAPHIFY_INVALID_QUERY, 'graphify_invalid_query');
|
|
assert.strictEqual(io.ERROR_REASON.HOOKS_OPT_OUT, 'hooks_opt_out');
|
|
assert.strictEqual(io.ERROR_REASON.SECURITY_SCAN_FAILED, 'security_scan_failed');
|
|
assert.strictEqual(io.ERROR_REASON.USAGE, 'usage');
|
|
assert.strictEqual(io.ERROR_REASON.UNKNOWN, 'unknown');
|
|
});
|
|
});
|
|
|
|
// ─── setJsonErrorMode / getJsonErrorMode ─────────────────────────────────────
|
|
|
|
describe('setJsonErrorMode / getJsonErrorMode', () => {
|
|
// Reset to false after each test so other tests are unaffected
|
|
afterEach(() => {
|
|
io.setJsonErrorMode(false);
|
|
});
|
|
|
|
test('defaults to false', () => {
|
|
io.setJsonErrorMode(false); // ensure clean state
|
|
assert.strictEqual(io.getJsonErrorMode(), false);
|
|
});
|
|
|
|
test('setJsonErrorMode(true) enables JSON error mode', () => {
|
|
io.setJsonErrorMode(true);
|
|
assert.strictEqual(io.getJsonErrorMode(), true);
|
|
});
|
|
|
|
test('setJsonErrorMode(false) disables JSON error mode', () => {
|
|
io.setJsonErrorMode(true);
|
|
io.setJsonErrorMode(false);
|
|
assert.strictEqual(io.getJsonErrorMode(), false);
|
|
});
|
|
|
|
test('setJsonErrorMode coerces truthy values', () => {
|
|
io.setJsonErrorMode(1);
|
|
assert.strictEqual(io.getJsonErrorMode(), true);
|
|
io.setJsonErrorMode(0);
|
|
assert.strictEqual(io.getJsonErrorMode(), false);
|
|
});
|
|
|
|
test('setJsonErrorMode coerces string truthy', () => {
|
|
io.setJsonErrorMode('yes');
|
|
assert.strictEqual(io.getJsonErrorMode(), true);
|
|
io.setJsonErrorMode('');
|
|
assert.strictEqual(io.getJsonErrorMode(), false);
|
|
});
|
|
});
|
|
|
|
// ─── output() ────────────────────────────────────────────────────────────────
|
|
|
|
// output() writes directly to fd 1 and never calls process.exit, so we can
|
|
// test it by spawning a child process and capturing its stdout.
|
|
|
|
describe('output()', () => {
|
|
const ioPath = path.resolve(__dirname, '../gsd-core/bin/lib/io.cjs');
|
|
|
|
test('emits JSON-serialised result to stdout', () => {
|
|
const script = `
|
|
const io = require(${JSON.stringify(ioPath)});
|
|
io.output({ ok: true, value: 42 }, false);
|
|
`;
|
|
const result = runScript(script);
|
|
assert.strictEqual(result.status, 0, `process exited non-zero: ${result.stderr}`);
|
|
const parsed = JSON.parse(result.stdout);
|
|
assert.deepStrictEqual(parsed, { ok: true, value: 42 });
|
|
});
|
|
|
|
test('emits raw string value when raw=true and rawValue provided', () => {
|
|
const script = `
|
|
const io = require(${JSON.stringify(ioPath)});
|
|
io.output({ ignored: true }, true, 'raw-text-output');
|
|
`;
|
|
const result = runScript(script);
|
|
assert.strictEqual(result.status, 0, `process exited non-zero: ${result.stderr}`);
|
|
assert.strictEqual(result.stdout, 'raw-text-output');
|
|
});
|
|
|
|
test('falls back to JSON when raw=true but rawValue is undefined', () => {
|
|
const script = `
|
|
const io = require(${JSON.stringify(ioPath)});
|
|
io.output({ fallback: true }, true);
|
|
`;
|
|
const result = runScript(script);
|
|
assert.strictEqual(result.status, 0, `process exited non-zero: ${result.stderr}`);
|
|
const parsed = JSON.parse(result.stdout);
|
|
assert.deepStrictEqual(parsed, { fallback: true });
|
|
});
|
|
|
|
test('emits null correctly', () => {
|
|
const script = `
|
|
const io = require(${JSON.stringify(ioPath)});
|
|
io.output(null, false);
|
|
`;
|
|
const result = runScript(script);
|
|
assert.strictEqual(result.status, 0, `process exited non-zero: ${result.stderr}`);
|
|
assert.strictEqual(result.stdout, 'null');
|
|
});
|
|
|
|
test('large payload (>50000 chars) spills to @file: tempfile', (t) => {
|
|
// Build a payload whose serialized JSON exceeds 50000 chars.
|
|
// A string of 60000 'x' chars serializes to 60002 chars ("x...x").
|
|
const largeString = 'x'.repeat(60000);
|
|
const payload = { large: largeString };
|
|
const serialized = JSON.stringify(payload, null, 2);
|
|
assert.ok(serialized.length > 50000, 'precondition: payload must exceed 50000 chars');
|
|
|
|
const tmpFilesCreated = [];
|
|
|
|
t.after(() => {
|
|
for (const p of tmpFilesCreated) {
|
|
try { fs.unlinkSync(p); } catch { /* ignore */ }
|
|
}
|
|
});
|
|
|
|
const script = `
|
|
const io = require(${JSON.stringify(ioPath)});
|
|
const largeString = 'x'.repeat(60000);
|
|
io.output({ large: largeString }, false);
|
|
`;
|
|
const result = runScript(script);
|
|
assert.strictEqual(result.status, 0, `process exited non-zero: ${result.stderr}`);
|
|
|
|
const stdout = result.stdout.trim();
|
|
assert.ok(stdout.startsWith('@file:'), `expected stdout to start with "@file:", got: ${stdout.slice(0, 80)}`);
|
|
|
|
const tmpPath = stdout.slice('@file:'.length);
|
|
tmpFilesCreated.push(tmpPath);
|
|
|
|
assert.ok(fs.existsSync(tmpPath), `expected temp file to exist at: ${tmpPath}`);
|
|
|
|
const fileContents = fs.readFileSync(tmpPath, 'utf-8');
|
|
const parsed = JSON.parse(fileContents);
|
|
assert.deepStrictEqual(parsed, payload);
|
|
|
|
fs.unlinkSync(tmpPath);
|
|
tmpFilesCreated.length = 0; // already cleaned, skip t.after
|
|
});
|
|
});
|
|
|
|
// ─── error() ─────────────────────────────────────────────────────────────────
|
|
|
|
describe('error()', () => {
|
|
const ioPath = path.resolve(__dirname, '../gsd-core/bin/lib/io.cjs');
|
|
const cliExitPath = path.resolve(__dirname, '../gsd-core/bin/lib/cli-exit.cjs');
|
|
|
|
// ADR-3889: io.error() now throws ExitError instead of calling
|
|
// process.exit() directly. A bare `node -e` script that calls io.error()
|
|
// with no termination seam would let that ExitError escape as an uncaught
|
|
// exception (a stack trace on stderr, not the single "Error: <msg>" line
|
|
// error() itself already wrote). Every harness script below wraps the
|
|
// error() call in runMain — the sanctioned entrypoint seam — so the
|
|
// process terminates exactly the way a real CLI invocation would: the one
|
|
// stderr write error() performs itself, then `process.exitCode = err.code`
|
|
// with nothing further written (ExitError from error() carries no message,
|
|
// so runMain's own "hasUserMessage" stderr write is a no-op here).
|
|
|
|
test('plain-text mode: writes "Error: <msg>" to stderr and exits 1', () => {
|
|
const script = `
|
|
const io = require(${JSON.stringify(ioPath)});
|
|
const { runMain } = require(${JSON.stringify(cliExitPath)});
|
|
io.setJsonErrorMode(false);
|
|
runMain(() => { io.error('something went wrong'); });
|
|
`;
|
|
const result = runScript(script);
|
|
assert.strictEqual(result.status, 1);
|
|
assert.ok(result.stderr.includes('Error: something went wrong'), `stderr was: ${result.stderr}`);
|
|
assert.strictEqual(result.stdout, '');
|
|
});
|
|
|
|
test('plain-text mode: default reason does not appear in stderr text', () => {
|
|
const script = `
|
|
const io = require(${JSON.stringify(ioPath)});
|
|
const { runMain } = require(${JSON.stringify(cliExitPath)});
|
|
io.setJsonErrorMode(false);
|
|
runMain(() => { io.error('no reason code expected'); });
|
|
`;
|
|
const result = runScript(script);
|
|
assert.strictEqual(result.status, 1);
|
|
// plain mode does NOT include the reason field
|
|
assert.ok(!result.stderr.includes('"reason"'), `stderr unexpectedly contained reason: ${result.stderr}`);
|
|
});
|
|
|
|
test('JSON-error mode: writes structured JSON to stderr and exits 1', () => {
|
|
const script = `
|
|
const io = require(${JSON.stringify(ioPath)});
|
|
const { runMain } = require(${JSON.stringify(cliExitPath)});
|
|
io.setJsonErrorMode(true);
|
|
runMain(() => { io.error('structured error', io.ERROR_REASON.SDK_FAIL_FAST); });
|
|
`;
|
|
const result = runScript(script);
|
|
assert.strictEqual(result.status, 1);
|
|
assert.strictEqual(result.stdout, '');
|
|
const payload = JSON.parse(result.stderr.trim());
|
|
assert.strictEqual(payload.ok, false);
|
|
assert.strictEqual(payload.reason, 'sdk_fail_fast');
|
|
assert.strictEqual(payload.message, 'structured error');
|
|
});
|
|
|
|
test('JSON-error mode: defaults reason to UNKNOWN when not supplied', () => {
|
|
const script = `
|
|
const io = require(${JSON.stringify(ioPath)});
|
|
const { runMain } = require(${JSON.stringify(cliExitPath)});
|
|
io.setJsonErrorMode(true);
|
|
runMain(() => { io.error('no reason given'); });
|
|
`;
|
|
const result = runScript(script);
|
|
assert.strictEqual(result.status, 1);
|
|
const payload = JSON.parse(result.stderr.trim());
|
|
assert.strictEqual(payload.reason, 'unknown');
|
|
assert.strictEqual(payload.message, 'no reason given');
|
|
});
|
|
|
|
test('all ERROR_REASON values round-trip through JSON-error mode', () => {
|
|
// spot-check a few variants
|
|
const cases = [
|
|
['config_key_not_found', 'CONFIG_KEY_NOT_FOUND'],
|
|
['phase_not_found', 'PHASE_NOT_FOUND'],
|
|
['usage', 'USAGE'],
|
|
];
|
|
for (const [expected, key] of cases) {
|
|
const script = `
|
|
const io = require(${JSON.stringify(ioPath)});
|
|
const { runMain } = require(${JSON.stringify(cliExitPath)});
|
|
io.setJsonErrorMode(true);
|
|
runMain(() => { io.error('test', io.ERROR_REASON.${key}); });
|
|
`;
|
|
const result = runScript(script);
|
|
assert.strictEqual(result.status, 1, `key=${key}`);
|
|
const payload = JSON.parse(result.stderr.trim());
|
|
assert.strictEqual(payload.reason, expected, `key=${key}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── GSD_TEMP_DIR / reapStaleTempFiles ───────────────────────────────────────
|
|
|
|
describe('GSD_TEMP_DIR', () => {
|
|
test('resolves to <tmpdir>/gsd', () => {
|
|
assert.strictEqual(io.GSD_TEMP_DIR, path.join(os.tmpdir(), 'gsd'));
|
|
});
|
|
});
|
|
|
|
describe('reapStaleTempFiles (via io)', () => {
|
|
const TEST_PREFIX = 'gsd-io-test-';
|
|
|
|
afterEach(() => {
|
|
// clean up any test files we created
|
|
try {
|
|
const entries = fs.readdirSync(io.GSD_TEMP_DIR);
|
|
for (const e of entries) {
|
|
if (e.startsWith(TEST_PREFIX)) {
|
|
const p = path.join(io.GSD_TEMP_DIR, e);
|
|
try { fs.unlinkSync(p); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
});
|
|
|
|
test('removes stale files beyond maxAgeMs', () => {
|
|
fs.mkdirSync(io.GSD_TEMP_DIR, { recursive: true });
|
|
const stalePath = path.join(io.GSD_TEMP_DIR, TEST_PREFIX + 'stale.json');
|
|
fs.writeFileSync(stalePath, '{}');
|
|
// backdate mtime so it looks older than 1ms
|
|
const old = new Date(Date.now() - 10000);
|
|
fs.utimesSync(stalePath, old, old);
|
|
|
|
io.reapStaleTempFiles(TEST_PREFIX, { maxAgeMs: 5000 });
|
|
assert.ok(!fs.existsSync(stalePath), 'stale file should have been removed');
|
|
});
|
|
|
|
test('keeps fresh files within maxAgeMs', () => {
|
|
fs.mkdirSync(io.GSD_TEMP_DIR, { recursive: true });
|
|
const freshPath = path.join(io.GSD_TEMP_DIR, TEST_PREFIX + 'fresh.json');
|
|
fs.writeFileSync(freshPath, '{}');
|
|
// mtime is just now — well within a 1-hour window
|
|
io.reapStaleTempFiles(TEST_PREFIX, { maxAgeMs: 60 * 60 * 1000 });
|
|
assert.ok(fs.existsSync(freshPath), 'fresh file should have been kept');
|
|
});
|
|
|
|
test('does not throw when GSD_TEMP_DIR does not exist yet', () => {
|
|
// reap against a non-existent prefix — must not throw
|
|
assert.doesNotThrow(() => {
|
|
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');
|
|
});
|
|
});
|
|
});
|
|
|
|
|
|
// ─── bug #1008: output()/error() tolerate a full / slow non-blocking pipe ─────
|
|
//
|
|
// The pre-fix bare `fs.writeSync(fd, data)` assumed it blocks until the kernel
|
|
// accepts every byte — false when fd is a non-blocking pipe (the parallel
|
|
// node:test runner on Linux): a full pipe throws EAGAIN and a partially-drained
|
|
// pipe returns a SHORT count. These behavioral tests inject fs.writeSync via
|
|
// mock.method (the approved fault-injection seam) and assert the observable
|
|
// contract (no throw, full payload, real errors still surface). They are red
|
|
// against the pre-fix io.cjs (throw / truncate).
|
|
|
|
// Normalize either writeSync call form to the chunk it emits:
|
|
// buffer form: writeSync(fd, buffer, offset, length) ← the fixed writeAllSync loop
|
|
// string form: writeSync(fd, string) ← the pre-fix bare call
|
|
function bug1008ChunkOf(data, offset, length) {
|
|
if (Buffer.isBuffer(data)) {
|
|
const start = offset ?? 0;
|
|
const end = length === undefined ? data.length : start + length;
|
|
return data.subarray(start, end).toString('utf8');
|
|
}
|
|
return String(data);
|
|
}
|
|
|
|
function bug1008WriteError(code, errno) {
|
|
const e = new Error(`${code}: write`);
|
|
e.code = code;
|
|
e.errno = errno;
|
|
e.syscall = 'write';
|
|
return e;
|
|
}
|
|
|
|
describe('bug #1008: io.output() tolerates a full / slow non-blocking pipe', () => {
|
|
test('retries on EAGAIN and emits the full payload without throwing', (t) => {
|
|
const written = [];
|
|
let calls = 0;
|
|
t.mock.method(fs, 'writeSync', (fd, data, offset, length) => {
|
|
calls += 1;
|
|
if (calls === 1) throw bug1008WriteError('EAGAIN', -11); // pipe momentarily full
|
|
const chunk = bug1008ChunkOf(data, offset, length);
|
|
written.push(chunk);
|
|
return Buffer.byteLength(chunk, 'utf8');
|
|
});
|
|
|
|
const payload = { ok: true, n: 42 };
|
|
assert.doesNotThrow(() => io.output(payload, false));
|
|
assert.ok(calls >= 2, `expected a retry after EAGAIN, got ${calls} call(s)`);
|
|
assert.equal(written.join(''), JSON.stringify(payload, null, 2), 'full payload must reach the fd');
|
|
});
|
|
|
|
test('retries on EINTR (signal-interrupted write) too', (t) => {
|
|
const written = [];
|
|
let calls = 0;
|
|
t.mock.method(fs, 'writeSync', (fd, data, offset, length) => {
|
|
calls += 1;
|
|
if (calls === 1) throw bug1008WriteError('EINTR', -4);
|
|
const chunk = bug1008ChunkOf(data, offset, length);
|
|
written.push(chunk);
|
|
return Buffer.byteLength(chunk, 'utf8');
|
|
});
|
|
|
|
assert.doesNotThrow(() => io.output('plain', true, 'PLAIN-RAW'));
|
|
assert.equal(written.join(''), 'PLAIN-RAW');
|
|
});
|
|
|
|
test('handles short (partial) writes without truncating', (t) => {
|
|
const written = [];
|
|
const CAP = 3; // each writeSync accepts at most 3 bytes, like a draining pipe
|
|
t.mock.method(fs, 'writeSync', (fd, data, offset, length) => {
|
|
const chunk = bug1008ChunkOf(data, offset, length);
|
|
const part = chunk.slice(0, CAP);
|
|
written.push(part);
|
|
return Buffer.byteLength(part, 'utf8');
|
|
});
|
|
|
|
const payload = { message: 'a reasonably long ascii payload to force many short writes' };
|
|
io.output(payload, false);
|
|
assert.equal(written.join(''), JSON.stringify(payload, null, 2), 'no bytes may be dropped on short writes');
|
|
});
|
|
|
|
test('does NOT swallow a genuine, non-transient write error (EPIPE)', (t) => {
|
|
t.mock.method(fs, 'writeSync', () => { throw bug1008WriteError('EPIPE', -32); });
|
|
assert.throws(
|
|
() => io.output({ ok: true }, false),
|
|
(err) => err.code === 'EPIPE',
|
|
'real (non-transient) errors must still surface',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('bug #1008: io.error() tolerates a full non-blocking stderr pipe', () => {
|
|
// ADR-3889: error() throws ExitError instead of calling process.exit()
|
|
// directly, so mocking process.exit and asserting doesNotThrow no longer
|
|
// matches the contract — error() now DOES throw, on purpose, and the
|
|
// termination semantics (translating that throw into a process exit code)
|
|
// belong to runMain() at the entrypoint, not to error() itself. This test
|
|
// asserts the real contract directly: catch the ExitError and check its
|
|
// `code`.
|
|
test('retries on EAGAIN, emits the full message, and throws ExitError(1)', () => {
|
|
const written = [];
|
|
let calls = 0;
|
|
const restore = fs.writeSync;
|
|
fs.writeSync = (fd, data, offset, length) => {
|
|
calls += 1;
|
|
if (calls === 1) throw bug1008WriteError('EAGAIN', -11);
|
|
assert.equal(fd, 2, 'error() must write to stderr');
|
|
const chunk = bug1008ChunkOf(data, offset, length);
|
|
written.push(chunk);
|
|
return Buffer.byteLength(chunk, 'utf8');
|
|
};
|
|
try {
|
|
assert.throws(
|
|
() => io.error('boom', io.ERROR_REASON.UNKNOWN),
|
|
(err) => err instanceof ExitError && err.code === 1,
|
|
'error() must throw ExitError(1) after a retried write',
|
|
);
|
|
} finally {
|
|
fs.writeSync = restore;
|
|
}
|
|
assert.ok(calls >= 2, 'error() should retry after EAGAIN');
|
|
assert.equal(written.join(''), 'Error: boom\n');
|
|
});
|
|
});
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
// Folded from tests/bug-1891-file-resolution.test.cjs — consolidation epic #1969 (B5 #1974)
|
|
// ────────────────────────────────────────────────────────────────────────
|
|
{
|
|
const { describe: __foldDescribe } = require('node:test');
|
|
__foldDescribe("folded:bug-1891-file-resolution (consolidation epic #1969 B5 #1974)", () => {
|
|
// allow-test-rule: structural-implementation-guard (see #1891)
|
|
// gsd-tools.cjs @file: resolution is a low-level stdout interception that cannot be
|
|
// exercised end-to-end via runGsdTools without a real workflow that emits @file: output.
|
|
// These structural tests guard the interception wiring until a behavioral integration
|
|
// test suite for the full @file: path is added.
|
|
|
|
/**
|
|
* Regression tests for bug #1891
|
|
*
|
|
* gsd-tools.cjs must transparently resolve @file: references in stdout
|
|
* so that workflows never see the @file: prefix. This eliminates the
|
|
* bash-specific `if [[ "$INIT" == @file:* ]]` check that breaks on
|
|
* PowerShell and other non-bash shells.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { describe, test, before } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const GSD_TOOLS_SRC = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
|
|
|
describe('bug #1891: @file: resolution in gsd-tools.cjs', () => {
|
|
let src;
|
|
|
|
before(() => {
|
|
src = fs.readFileSync(GSD_TOOLS_SRC, 'utf-8');
|
|
});
|
|
|
|
test('main() intercepts stdout and resolves @file: references', () => {
|
|
// The non-pick path should have @file: resolution, just like the --pick path
|
|
assert.ok(
|
|
src.includes("captured.startsWith('@file:')") ||
|
|
src.includes('captured.startsWith(\'@file:\')'),
|
|
'main() should check for @file: prefix in captured output'
|
|
);
|
|
});
|
|
|
|
test('@file: resolution reads file content via readFileSync', () => {
|
|
// Verify the resolution reads the actual file
|
|
assert.ok(
|
|
src.includes("readFileSync(captured.slice(6)") ||
|
|
src.includes('readFileSync(captured.slice(6)'),
|
|
'@file: resolution should read file at the path after the prefix'
|
|
);
|
|
});
|
|
|
|
test('stdout interception wraps runCommand in the non-pick path', () => {
|
|
// The main function should resolve @file: output in BOTH --pick and
|
|
// non-pick paths. This can be either two inline checks or a shared helper.
|
|
const mainFunc = src.slice(src.indexOf('async function main()'));
|
|
const resolveCalls = (mainFunc.match(/resolveAtFileOutput\(/g) || []).length;
|
|
const inlineAtFileChecks = (mainFunc.match(/@file:/g) || []).length;
|
|
assert.ok(
|
|
resolveCalls >= 2 || inlineAtFileChecks >= 2,
|
|
'Both --pick and normal paths should resolve @file: references'
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// #3912 (ADR-3889 §4, epic #3889 Phase 8) — gsd-tools declares outcomes,
|
|
// pinned at v1. See .gsd/phase/enhance-3912-gsd-tools-outcomes/40-design.md
|
|
// and 50-test-matrix.md.
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
const CLI_EXIT_PATH_3912 = path.resolve(__dirname, '../gsd-core/bin/lib/cli-exit.cjs');
|
|
const IO_PATH_3912 = path.resolve(__dirname, '../gsd-core/bin/lib/io.cjs');
|
|
const REGISTERED_NAMES_3912 = EXIT_CODES.map((e) => e.name);
|
|
const CODE_FOR_3912 = new Map(EXIT_CODES.map((e) => [e.name, e.code]));
|
|
const VERSIONS_3912 = ['v1', 'v2'];
|
|
|
|
/**
|
|
* Expected reason -> outcome mapping, mirroring src/io.cts's own
|
|
* REASON_TO_OUTCOME table. Kept as an independent, explicit table here
|
|
* (rather than importing the internal function) so the test is a real
|
|
* behavioral check against error()'s observable exit code, not a tautology
|
|
* that re-imports the thing it is meant to verify.
|
|
*/
|
|
const EXPECTED_REASON_OUTCOME_3912 = {
|
|
config_key_not_found: 'NO_INPUT',
|
|
config_no_file: 'UNAVAILABLE',
|
|
config_parse_failed: 'UNAVAILABLE',
|
|
config_invalid_key: 'USAGE',
|
|
sdk_fail_fast: 'INTERNAL',
|
|
sdk_unknown_command: 'USAGE',
|
|
sdk_missing_arg: 'USAGE',
|
|
phase_not_found: 'UNAVAILABLE',
|
|
phase_verification_incomplete: 'UNAVAILABLE',
|
|
phase_plan_coverage_incomplete: 'UNAVAILABLE',
|
|
summary_no_planning: 'NO_INPUT',
|
|
workstream_mode_none_active: 'NO_INPUT',
|
|
workstream_mode_marker_unresolved: 'UNAVAILABLE',
|
|
graphify_no_graph: 'UNAVAILABLE',
|
|
graphify_invalid_query: 'USAGE',
|
|
estimate_phases_unreadable: 'UNAVAILABLE',
|
|
hooks_opt_out: 'FAIL',
|
|
commit_docs_guard_not_a_repo: 'UNAVAILABLE',
|
|
commit_docs_guard_foreign_hook: 'UNAVAILABLE',
|
|
commit_docs_guard_hooks_path_set: 'UNAVAILABLE',
|
|
security_scan_failed: 'INTERNAL',
|
|
pick_field_absent: 'UNAVAILABLE',
|
|
pick_output_not_json: 'UNAVAILABLE',
|
|
usage: 'USAGE',
|
|
unknown: 'FAIL',
|
|
};
|
|
|
|
/** Expected exit code for `error(msg, reason)` under a given contract version. */
|
|
function expectedErrorCode3912(reasonValue, version) {
|
|
if (version === 'v1') return 1;
|
|
const outcome = EXPECTED_REASON_OUTCOME_3912[reasonValue];
|
|
if (outcome === 'FAIL') return 1;
|
|
return CODE_FOR_3912.get(outcome);
|
|
}
|
|
|
|
describe('#3912 A1/B1: error() declares from ERROR_REASON, exhaustive over the 25-member enum', () => {
|
|
afterEach(() => {
|
|
resolveContractVersion({ argv: ['node', 'x'], env: {} }); // restore v1 default
|
|
});
|
|
|
|
// A1 — the acceptance criterion: EVERY member of ERROR_REASON, iterated
|
|
// from the enum itself (not a hand-picked subset), exits 1 under v1. A
|
|
// 26th member added to the enum without a table entry still exits 1
|
|
// under v1 (v1 never consults the table at all); under v2, the table
|
|
// lookup for that member yields `undefined`, `CODE_FOR_3912.get(undefined)`
|
|
// yields `undefined`, and `err.code === expected` fails against the real
|
|
// code (1) for that reason. With the set-equality assertion below in
|
|
// place, that drift is caught first, with a message naming the specific
|
|
// missing/extra reason instead of a confusing "must exit undefined".
|
|
assert.deepEqual(
|
|
Object.keys(EXPECTED_REASON_OUTCOME_3912).sort(),
|
|
Object.values(io.ERROR_REASON).slice().sort(),
|
|
'this table must cover exactly the ERROR_REASON enum values, no more, no less',
|
|
);
|
|
for (const [key, reasonValue] of Object.entries(io.ERROR_REASON)) {
|
|
test(`v1: ERROR_REASON.${key} (${reasonValue}) exits 1`, () => {
|
|
resolveContractVersion({ argv: ['node', 'x'], env: {} }); // v1
|
|
assert.throws(
|
|
() => io.error('msg', reasonValue),
|
|
(err) => err instanceof ExitError && err.code === 1,
|
|
`ERROR_REASON.${key} must exit 1 under v1`,
|
|
);
|
|
});
|
|
|
|
test(`v2: ERROR_REASON.${key} (${reasonValue}) projects to its mapped outcome's registered code`, () => {
|
|
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
|
|
const expected = expectedErrorCode3912(reasonValue, 'v2');
|
|
assert.throws(
|
|
() => io.error('msg', reasonValue),
|
|
(err) => err instanceof ExitError && err.code === expected,
|
|
`ERROR_REASON.${key} under v2 must exit ${expected}`,
|
|
);
|
|
});
|
|
}
|
|
|
|
// A2 — the 226-site default: no reason argument at all -> UNKNOWN -> exit 1.
|
|
test('A2: error() with no reason argument exits 1 under v1 (defaults to UNKNOWN)', () => {
|
|
resolveContractVersion({ argv: ['node', 'x'], env: {} });
|
|
assert.throws(
|
|
() => io.error('no reason given'),
|
|
(err) => err instanceof ExitError && err.code === 1,
|
|
);
|
|
});
|
|
|
|
test('A2: error() with no reason argument stays FAIL (exit 1) under v2 too — UNKNOWN is not a specific outcome', () => {
|
|
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
|
|
assert.throws(
|
|
() => io.error('no reason given'),
|
|
(err) => err instanceof ExitError && err.code === 1,
|
|
);
|
|
});
|
|
|
|
// B2 — spot-check the specific mappings the design calls out by name.
|
|
test('B2: SDK_MISSING_ARG / SDK_UNKNOWN_COMMAND / USAGE all reach USAGE (64) under v2', () => {
|
|
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
|
|
for (const key of ['SDK_MISSING_ARG', 'SDK_UNKNOWN_COMMAND', 'USAGE']) {
|
|
assert.throws(
|
|
() => io.error('msg', io.ERROR_REASON[key]),
|
|
(err) => err instanceof ExitError && err.code === 64,
|
|
`${key} must project to 64 under v2`,
|
|
);
|
|
}
|
|
});
|
|
|
|
// B5 — the anti-vacuity test. Without this, a mapping where every reason
|
|
// projects to 1 under both versions would satisfy every row above.
|
|
test('B5 (anti-vacuity): v1 and v2 differ for at least one reason', () => {
|
|
resolveContractVersion({ argv: ['node', 'x'], env: {} });
|
|
let v1Code;
|
|
try { io.error('msg', io.ERROR_REASON.SDK_MISSING_ARG); } catch (e) { v1Code = e.code; }
|
|
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
|
|
let v2Code;
|
|
try { io.error('msg', io.ERROR_REASON.SDK_MISSING_ARG); } catch (e) { v2Code = e.code; }
|
|
assert.equal(v1Code, 1);
|
|
assert.equal(v2Code, 64);
|
|
assert.notEqual(v1Code, v2Code, 'v1 and v2 must differ for at least one reason, or the declaration is decorative');
|
|
});
|
|
});
|
|
|
|
describe('#3912 A3-A5: output({error}) records DEGRADED — shape-exhaustive plus a real census', () => {
|
|
// A3/A4 — shape exhaustive: both key orders, and varying the error value's
|
|
// own type/truthiness (irrelevant to detection — presence of the key is
|
|
// what counts).
|
|
// The discriminator is a SERIALIZABLE error value, not mere key presence:
|
|
// every row here embeds its payload via `JSON.stringify(payload)` to build
|
|
// the child script's literal, and `JSON.stringify` drops any key whose
|
|
// value is `undefined` — so an `{ error: undefined }` row would silently
|
|
// arrive at `output()` with NO `error` key at all, making the row pass for
|
|
// the wrong reason (or, as originally written, fail outright: see the
|
|
// dedicated `{error: undefined}` case below, which constructs the object
|
|
// as source text instead so the key survives).
|
|
const shapes = [
|
|
['error first', { error: 'boom', found: false }],
|
|
['error last (A4)', { found: false, error: 'boom' }],
|
|
['error in the middle', { a: 1, error: 'boom', b: 2 }],
|
|
['error value is falsy (0)', { found: false, error: 0 }],
|
|
['error value is null', { found: false, error: null }],
|
|
['error value is an object', { error: { code: 'X' }, found: false }],
|
|
];
|
|
for (const [label, payload] of shapes) {
|
|
test(`A3/A4 (${label}): exits 0 under v1 and is recorded as DEGRADED`, () => {
|
|
const script = `
|
|
const io = require(${JSON.stringify(IO_PATH_3912)});
|
|
const c = require(${JSON.stringify(CLI_EXIT_PATH_3912)});
|
|
io.output(${JSON.stringify(payload)}, false);
|
|
process.stdout.write('|PENDING=' + c.getPendingOutcome());
|
|
`;
|
|
const result = toLegacyResult(runNode(['-e', script], { timeoutMs: PROBE_TIMEOUT_MS }));
|
|
assert.strictEqual(result.status, 0, `stderr: ${result.stderr}`);
|
|
assert.ok(result.stdout.includes('|PENDING=DEGRADED'), `expected DEGRADED recorded; got: ${result.stdout}`);
|
|
});
|
|
}
|
|
|
|
// A3/A4 pin — `{ error: undefined }` is NOT degraded. The object literal is
|
|
// written as SOURCE TEXT here (not round-tripped through
|
|
// `JSON.stringify(payload)`), so the `error` key genuinely reaches
|
|
// `output()` with an `undefined` value. `JSON.stringify` (the serializer
|
|
// `output()` itself uses to build the payload the user actually receives)
|
|
// drops a key whose value is `undefined`, so the wire payload is
|
|
// `{"found":false}` — no error at all. Recording DEGRADED here would be a
|
|
// false verdict: exit 80 under v2 for output the user sees as clean. The
|
|
// discriminator is a SERIALIZABLE error value, not key presence.
|
|
test('A3/A4 pin: {error: undefined} is NOT recorded as DEGRADED (JSON.stringify drops it)', () => {
|
|
const script = `
|
|
const io = require(${JSON.stringify(IO_PATH_3912)});
|
|
const c = require(${JSON.stringify(CLI_EXIT_PATH_3912)});
|
|
io.output({ found: false, error: undefined }, false);
|
|
process.stdout.write('|PENDING=' + c.getPendingOutcome());
|
|
`;
|
|
const result = toLegacyResult(runNode(['-e', script], { timeoutMs: PROBE_TIMEOUT_MS }));
|
|
assert.strictEqual(result.status, 0, `stderr: ${result.stderr}`);
|
|
assert.ok(
|
|
!result.stdout.includes('|PENDING=DEGRADED'),
|
|
`{error: undefined} carries no serializable error and must not be degraded; got: ${result.stdout}`,
|
|
);
|
|
});
|
|
|
|
// A5 — negative space: no `error` key at all must NOT be recorded as degraded.
|
|
test('A5: output() with no error key exits 0 and records NOTHING', () => {
|
|
const script = `
|
|
const io = require(${JSON.stringify(IO_PATH_3912)});
|
|
const c = require(${JSON.stringify(CLI_EXIT_PATH_3912)});
|
|
io.output({ ok: true, value: 1 }, false);
|
|
process.stdout.write('|PENDING=' + c.getPendingOutcome());
|
|
`;
|
|
const result = toLegacyResult(runNode(['-e', script], { timeoutMs: PROBE_TIMEOUT_MS }));
|
|
assert.strictEqual(result.status, 0, `stderr: ${result.stderr}`);
|
|
assert.ok(!result.stdout.includes('|PENDING=DEGRADED'), `must not record DEGRADED; got: ${result.stdout}`);
|
|
});
|
|
|
|
// A3 census — AST-based, not a text grep (local/no-source-grep bans
|
|
// readFileSync().includes()/.match()/etc on source; this parses an AST
|
|
// instead and never calls a string-search method on the source text).
|
|
// Counts every call to an identifier literally named `output` (the name
|
|
// every call site in this tree destructures it to — `const { output } =
|
|
// ioMod`) whose first argument is an object literal carrying an `error`
|
|
// property. This is the SHAPE the design measured, over the real tree,
|
|
// not a hand-picked subset — and it independently reproduces the design
|
|
// doc's per-file breakdown (frontmatter 7, phase 4, roadmap 3, state 25,
|
|
// verify 8, workstream 7, commands 5, template 3, gsd2-import 2 = 64),
|
|
// which is itself the corrected count over ADR-2980's stale 60.
|
|
test('A3 census: exactly 64 output({error}) call sites exist in src/, across the 9 modules the design measured', () => {
|
|
const SRC_ROOT = path.resolve(__dirname, '../src');
|
|
|
|
function listCtsFiles(dir) {
|
|
const out = [];
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) out.push(...listCtsFiles(full));
|
|
else if (entry.name.endsWith('.cts') && !entry.name.endsWith('.d.cts')) out.push(full);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function hasErrorProp(objLit) {
|
|
return objLit.properties.some((p) => {
|
|
if (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) {
|
|
const name = p.name;
|
|
if (ts.isIdentifier(name)) return name.text === 'error';
|
|
if (ts.isStringLiteral(name)) return name.text === 'error';
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
const perFile = {};
|
|
let total = 0;
|
|
for (const file of listCtsFiles(SRC_ROOT)) {
|
|
const text = fs.readFileSync(file, 'utf8');
|
|
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
let count = 0;
|
|
(function visit(node) {
|
|
if (ts.isCallExpression(node)) {
|
|
let calleeName = null;
|
|
if (ts.isIdentifier(node.expression)) calleeName = node.expression.text;
|
|
else if (ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.name)) {
|
|
calleeName = node.expression.name.text;
|
|
}
|
|
if (calleeName === 'output' && node.arguments.length > 0 && ts.isObjectLiteralExpression(node.arguments[0])) {
|
|
if (hasErrorProp(node.arguments[0])) count += 1;
|
|
}
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
})(sf);
|
|
if (count > 0) perFile[path.basename(file)] = count;
|
|
total += count;
|
|
}
|
|
|
|
assert.deepStrictEqual(
|
|
perFile,
|
|
{
|
|
'commands.cts': 5, 'frontmatter.cts': 7, 'gsd2-import.cts': 2, 'phase.cts': 4,
|
|
'roadmap.cts': 3, 'state.cts': 27, 'template.cts': 3, 'verify.cts': 8, 'workstream.cts': 7, // +1 #3807: advance-plan's ambiguous-position error; +1 #3784: advance-plan's ambiguous-PLAN-position error (two plan spellings, different numbers)
|
|
},
|
|
`per-file output({error}) census drifted: ${JSON.stringify(perFile)}`,
|
|
);
|
|
assert.strictEqual(total, 66, `enumerated output({error}) population drifted from the measured 66 (64 + #3807's ambiguous-position error + #3784's ambiguous-plan-position error): got ${total}`);
|
|
});
|
|
});
|
|
|
|
describe('#3912 C5/D: output({error}) DEGRADED reaches process.exitCode only through runMain', () => {
|
|
afterEach(() => {
|
|
setPendingOutcome(undefined);
|
|
resolveContractVersion({ argv: ['node', 'x'], env: {} });
|
|
});
|
|
|
|
test('a real gsd-tools-shaped main() that calls output({error}) and returns nothing exits 0 under v1, 80 under v2', () => {
|
|
const script = `
|
|
const io = require(${JSON.stringify(IO_PATH_3912)});
|
|
const c = require(${JSON.stringify(CLI_EXIT_PATH_3912)});
|
|
c.runMain(() => {
|
|
io.output({ found: false, error: 'not found' }, false);
|
|
return undefined;
|
|
});
|
|
setImmediate(() => {});
|
|
`;
|
|
const v1 = toLegacyResult(runNode(['-e', script], {
|
|
timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v1' },
|
|
}));
|
|
assert.strictEqual(v1.status, 0, `stderr: ${v1.stderr}`);
|
|
const v2 = toLegacyResult(runNode(['-e', script], {
|
|
timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v2' },
|
|
}));
|
|
assert.strictEqual(v2.status, 80, `stderr: ${v2.stderr}`);
|
|
});
|
|
|
|
test('an explicit main() return still wins over a DEGRADED output({error}) call in the same main()', () => {
|
|
const script = `
|
|
const io = require(${JSON.stringify(IO_PATH_3912)});
|
|
const c = require(${JSON.stringify(CLI_EXIT_PATH_3912)});
|
|
c.runMain(() => {
|
|
io.output({ found: false, error: 'not found' }, false);
|
|
return 0;
|
|
});
|
|
setImmediate(() => {});
|
|
`;
|
|
const r = toLegacyResult(runNode(['-e', script], {
|
|
timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v2' },
|
|
}));
|
|
assert.strictEqual(r.status, 0, `an explicit 0 return must win over the DEGRADED cell; stderr: ${r.stderr}`);
|
|
});
|
|
});
|
|
|
|
describe('review fix: pending-outcome cell lifetime (last-write-wins, cleared on consumption)', () => {
|
|
// These drive runMain/output IN-PROCESS (not via a subprocess), which is
|
|
// exactly the gap that let the leak through: every other #3912 test above
|
|
// spawns a fresh process per case, so a cell that is never cleared was
|
|
// unobservable. runMain mutates process.exitCode as a side effect, so each
|
|
// test saves/restores it to avoid corrupting the real node:test run's own
|
|
// exit code.
|
|
function waitForRunMain() {
|
|
// runMain resolves its outcome via a Promise.resolve().then().then()
|
|
// chain (microtasks); a macrotask tick guarantees both have drained.
|
|
return new Promise((resolve) => { setImmediate(resolve); });
|
|
}
|
|
|
|
afterEach(() => {
|
|
// Harmless test hygiene now that production also clears the cell on
|
|
// every runMain call and on every clean output() — this scrub is a
|
|
// belt-and-suspenders reset between test cases, not the mechanism that
|
|
// prevents the leak (that mechanism now lives in cli-exit.cts/io.cts).
|
|
setPendingOutcome(undefined);
|
|
resolveContractVersion({ argv: ['node', 'x'], env: {} });
|
|
});
|
|
|
|
test('leak regression: output({error}) then a second void-returning runMain must NOT inherit stale DEGRADED', async () => {
|
|
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
|
|
const savedExitCode = process.exitCode;
|
|
try {
|
|
// First invocation declares DEGRADED via a payload-carried error and
|
|
// returns nothing — runMain projects it to 80 under v2.
|
|
runMain(() => {
|
|
io.output({ found: false, error: 'not found' }, false);
|
|
return undefined;
|
|
});
|
|
await waitForRunMain();
|
|
assert.strictEqual(process.exitCode, 80, 'first runMain should have projected the DEGRADED cell to 80');
|
|
|
|
// Second, unrelated invocation in the SAME process declares nothing
|
|
// and returns nothing. Before the fix, the cell was never cleared by
|
|
// runMain, so this would inherit the first call's stale DEGRADED and
|
|
// also exit 80 — the exact bug the reviewers found.
|
|
process.exitCode = undefined;
|
|
runMain(() => undefined);
|
|
await waitForRunMain();
|
|
assert.strictEqual(
|
|
process.exitCode,
|
|
undefined,
|
|
'a later void-returning runMain must not inherit a prior invocation\'s stale DEGRADED declaration',
|
|
);
|
|
} finally {
|
|
process.exitCode = savedExitCode;
|
|
}
|
|
});
|
|
|
|
test('last-write-wins: output({error}) then output({ok:true}) in the SAME invocation is not degraded', async () => {
|
|
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
|
|
const savedExitCode = process.exitCode;
|
|
try {
|
|
runMain(() => {
|
|
io.output({ found: false, error: 'not found' }, false);
|
|
io.output({ ok: true }, false);
|
|
return undefined;
|
|
});
|
|
await waitForRunMain();
|
|
assert.strictEqual(
|
|
process.exitCode,
|
|
undefined,
|
|
'a later clean output() must undo an earlier degraded one — exit code must stay untouched, not 80',
|
|
);
|
|
} finally {
|
|
process.exitCode = savedExitCode;
|
|
}
|
|
});
|
|
|
|
test('consumption clears: after runMain consumes a pending outcome, the cell reads unset', async () => {
|
|
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
|
|
const savedExitCode = process.exitCode;
|
|
try {
|
|
runMain(() => {
|
|
io.output({ error: 'x' }, false);
|
|
return undefined;
|
|
});
|
|
await waitForRunMain();
|
|
assert.strictEqual(process.exitCode, 80);
|
|
assert.strictEqual(getPendingOutcome(), undefined, 'the cell must be cleared once runMain has consumed it');
|
|
} finally {
|
|
process.exitCode = savedExitCode;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('#3912 A6: error() stderr bytes are unchanged by this phase', () => {
|
|
afterEach(() => {
|
|
resolveContractVersion({ argv: ['node', 'x'], env: {} });
|
|
});
|
|
|
|
test('plain mode: "Error: <msg>" bytes are identical regardless of reason or contract version', () => {
|
|
for (const version of VERSIONS_3912) {
|
|
resolveContractVersion({ argv: ['node', 'x', `--exit-contract=${version}`], env: {} });
|
|
let caught;
|
|
const chunks = [];
|
|
const origWrite = fs.writeSync;
|
|
fs.writeSync = (fd, buf, offset, length) => {
|
|
if (fd !== 2) return origWrite(fd, buf, offset, length);
|
|
const slice = Buffer.isBuffer(buf) ? buf.subarray(offset, offset + length) : Buffer.from(String(buf));
|
|
chunks.push(slice.toString('utf8'));
|
|
return slice.length;
|
|
};
|
|
try {
|
|
io.setJsonErrorMode(false);
|
|
try { io.error('boundary case', io.ERROR_REASON.SDK_MISSING_ARG); } catch (e) { caught = e; }
|
|
} finally {
|
|
fs.writeSync = origWrite;
|
|
}
|
|
assert.ok(caught instanceof ExitError);
|
|
assert.strictEqual(chunks.join(''), 'Error: boundary case\n', `version=${version}`);
|
|
}
|
|
});
|
|
|
|
test('json mode: the stderr envelope is identical regardless of contract version (only the thrown exit code differs)', () => {
|
|
for (const version of VERSIONS_3912) {
|
|
resolveContractVersion({ argv: ['node', 'x', `--exit-contract=${version}`], env: {} });
|
|
const chunks = [];
|
|
const origWrite = fs.writeSync;
|
|
fs.writeSync = (fd, buf, offset, length) => {
|
|
if (fd !== 2) return origWrite(fd, buf, offset, length);
|
|
const slice = Buffer.isBuffer(buf) ? buf.subarray(offset, offset + length) : Buffer.from(String(buf));
|
|
chunks.push(slice.toString('utf8'));
|
|
return slice.length;
|
|
};
|
|
let caught;
|
|
try {
|
|
io.setJsonErrorMode(true);
|
|
try { io.error('boundary case', io.ERROR_REASON.SDK_MISSING_ARG); } catch (e) { caught = e; }
|
|
} finally {
|
|
fs.writeSync = origWrite;
|
|
io.setJsonErrorMode(false);
|
|
}
|
|
assert.ok(caught instanceof ExitError);
|
|
assert.deepStrictEqual(
|
|
JSON.parse(chunks.join('').trim()),
|
|
{ ok: false, reason: 'sdk_missing_arg', message: 'boundary case' },
|
|
`version=${version}`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('#3912 B1/B4/E1: projectOutcome-backed checks over the real registry', () => {
|
|
test('B1: every registered outcome name is reachable through error() via SOME reason, and matches the registry', () => {
|
|
// Sanity check that CODE_FOR_3912 (derived straight from the shipped
|
|
// registry) matches the pinned table cli-exit.test.cjs already asserts.
|
|
assert.strictEqual(CODE_FOR_3912.get('USAGE'), 64);
|
|
assert.strictEqual(CODE_FOR_3912.get('NO_INPUT'), 66);
|
|
assert.strictEqual(CODE_FOR_3912.get('UNAVAILABLE'), 69);
|
|
assert.strictEqual(CODE_FOR_3912.get('INTERNAL'), 70);
|
|
assert.strictEqual(CODE_FOR_3912.get('DEGRADED'), 80);
|
|
});
|
|
|
|
test('B4: any output({error}) site under v2 exits 80 (DEGRADED)', () => {
|
|
const script = `
|
|
const io = require(${JSON.stringify(IO_PATH_3912)});
|
|
const c = require(${JSON.stringify(CLI_EXIT_PATH_3912)});
|
|
c.runMain(() => { io.output({ error: 'x' }, false); return undefined; });
|
|
setImmediate(() => {});
|
|
`;
|
|
const r = toLegacyResult(runNode(['-e', script], {
|
|
timeoutMs: PROBE_TIMEOUT_MS, env: { ...process.env, GSD_EXIT_CONTRACT: 'v2' },
|
|
}));
|
|
assert.strictEqual(r.status, 80, `stderr: ${r.stderr}`);
|
|
});
|
|
|
|
// E1 lives primarily in tests/cli-exit.test.cjs (projectOutcome is defined
|
|
// there); this is the io-side control confirming REGISTERED_NAMES_3912
|
|
// used by the reason-mapping table above matches the live registry.
|
|
test('registered names used by the reason-mapping table are exactly the live registry names', () => {
|
|
for (const outcome of Object.values(EXPECTED_REASON_OUTCOME_3912)) {
|
|
if (outcome === 'FAIL') continue;
|
|
assert.ok(
|
|
REGISTERED_NAMES_3912.includes(outcome),
|
|
`mapped outcome ${outcome} must be a registered exit-code name`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('fast-check: E1 sanity — every mapped outcome/version pair used by error() yields a non-negative integer', () => {
|
|
const outcomes = [...new Set(Object.values(EXPECTED_REASON_OUTCOME_3912))];
|
|
fc.assert(
|
|
fc.property(
|
|
fc.constantFrom(...outcomes),
|
|
fc.constantFrom(...VERSIONS_3912),
|
|
(outcome, version) => {
|
|
const code = outcome === 'FAIL' ? 1 : (version === 'v1' ? 1 : CODE_FOR_3912.get(outcome));
|
|
assert.ok(Number.isInteger(code) && code >= 0);
|
|
},
|
|
),
|
|
{ seed: 39120, numRuns: 100 },
|
|
);
|
|
});
|
|
});
|