* fix(#4306): forward real bytes through io.test.cjs's fault-injection mocks The bug #1008 fault-injection tests mock fs.writeSync scoped only by file descriptor. On their "success" arms (the retry-after-EAGAIN/EINTR call, and the short-write simulation) they fabricated a return byte count without ever calling the real writeSync -- the bytes went into a local array and nowhere else. node:test's process-isolation runner (default on Node >= 22) reads each test file's own stdout to parse its child-to-parent result protocol. If the runner's own reporter write for an adjacent test lands on fd 1 while one of these mocks is installed, that write was silently swallowed instead of reaching the real pipe -- observed in CI as "Unable to deserialize cloned data" (a corrupted/truncated byte stream on the parent's read side), not a thrown exception. Every "success" arm now forwards the real bytes to orig()/restore() instead of fabricating a return value, so anything else sharing the fd during the mocked window still gets its bytes delivered for real. writeAllSync (the only production caller reaching this mock) always passes a Buffer, so the forwarded calls use the buffer-form fs.writeSync overload unambiguously. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4306): extend fault-injection fd-swallow fix across the whole suite The originally-fixed instance (tests/io.test.cjs) was one occurrence of a copy-pasted defect: mocked fs.writeSync arms fabricated a return byte count without ever forwarding the call to the real fs.writeSync, silently discarding bytes. Under node:test's process-isolated runner, the parent reads the child's real stdout to parse v8-serialized report frames interleaved with plain output (confirmed against node's own lib/internal/test_runner/runner.js and a matching upstream issue, nodejs/node#64061) — a swallowed write on that fd corrupts the parent's parse ("Unable to deserialize cloned data"). Adds a shared, safe capture helper to tests/helpers.cjs, captureFdSync(fd, fn): it always forwards every write to the real fs.writeSync first, then records only the observed fd's bytes, sliced by the real return count (not the requested length), decoded once via Buffer.concat so a short write can't split a multi-byte codepoint across two decodes. 17 test files migrate their local copy of the unsafe mock to this shared helper. tests/worktree-base-ref.test.cjs keeps a narrower in-place fix instead (it needs to record every fd a write touched, which the shared helper doesn't expose). tests/io.test.cjs gets two follow-up correctness fixes on top of the already-committed forwarding fix: the EAGAIN/EINTR/short-write arms now derive their recorded chunk from the real return count everywhere (including the string-form overload), and the short-write test no longer forces a Buffer-shaped truncation call onto a string-form write that could land on the same fd. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
412 lines
17 KiB
JavaScript
412 lines
17 KiB
JavaScript
'use strict';
|
|
|
|
// Phase 2 integration tests for the `state rebuild` CLI subcommand (#1826).
|
|
//
|
|
// These tests exercise the CLI dispatch path (routeStateCommand → cmdStateRebuild),
|
|
// the --dry-run flag (no write), and the --verbose flag (stderr emit). The
|
|
// underlying rebuildCore logic is covered by tests/state-rebuild.test.cjs (Phase 1).
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { runNode } = require('./helpers/process-seam.cjs');
|
|
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
|
|
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
|
|
const {
|
|
createTempProject,
|
|
cleanup,
|
|
runGsdTools,
|
|
captureFdSync,
|
|
} = require('./helpers.cjs');
|
|
const { withFaultyFs } = require('./helpers/faulty-deps.cjs');
|
|
const stateMod = require('../gsd-core/bin/lib/state.cjs');
|
|
const { stateExtractField } = require('../gsd-core/bin/lib/state-document.cjs');
|
|
const { parseMarkdownTable } = require('../gsd-core/bin/lib/markdown-table.cjs');
|
|
const { collectSection } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs');
|
|
|
|
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Write a STATE.md with one drift signature (stale Current Phase in body vs
|
|
* frontmatter) into a freshly-created temp project. Returns the temp dir.
|
|
*/
|
|
function projectWithDriftedState() {
|
|
const cwd = createTempProject('state-rebuild-cli');
|
|
const planningPath = path.join(cwd, '.planning');
|
|
// Drop two phase dirs so phaseInventoryProvider has something to scan.
|
|
fs.mkdirSync(path.join(planningPath, 'phases', '01-phase-one'), { recursive: true });
|
|
fs.writeFileSync(path.join(planningPath, 'phases', '01-phase-one', '01-PLAN.md'), '# Plan');
|
|
fs.mkdirSync(path.join(planningPath, 'phases', '02-phase-two'), { recursive: true });
|
|
fs.writeFileSync(path.join(planningPath, 'phases', '02-phase-two', '01-PLAN.md'), '# Plan');
|
|
|
|
// STATE.md with a drift: body says Current Phase 1, frontmatter says 2.
|
|
const stateContent = [
|
|
'---',
|
|
'gsd_state_version: \'1.0\'',
|
|
'status: executing',
|
|
'milestone: 1.0.0',
|
|
'milestone_name: Test',
|
|
'current_phase: 2',
|
|
'current_phase_name: Phase Two',
|
|
'current_plan: 1',
|
|
'progress:',
|
|
' total_phases: 2',
|
|
' completed_phases: 1',
|
|
' total_plans: 2',
|
|
' completed_plans: 1',
|
|
' percent: 50',
|
|
'---',
|
|
'',
|
|
'# Project State',
|
|
'',
|
|
'## Project Reference',
|
|
'',
|
|
'**Core value:** A test project',
|
|
'**Current focus:** Phase Two',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'**Current Phase:** 1',
|
|
'**Current Phase Name:** Phase One',
|
|
'**Current Plan:** 1',
|
|
'**Total Plans in Phase:** 1',
|
|
'**Status:** executing',
|
|
'**Last Activity:** 2026-06-29',
|
|
'**Last Activity Description:** mid-flight',
|
|
'',
|
|
'Phase: 2 of 2 (Phase Two)',
|
|
'Plan: 1 of 1',
|
|
'Status: Executing Phase 2',
|
|
'Last activity: 2026-06-29 — mid-flight',
|
|
'',
|
|
'**Progress:** [█████░░░░░] 50%',
|
|
'',
|
|
'## Performance Metrics',
|
|
'',
|
|
'**By Phase:**',
|
|
'',
|
|
'| Phase | Plans | Total | Avg/Plan |',
|
|
'|-------|-------|-------|----------|',
|
|
'| 99 | 1 | - | - |',
|
|
'',
|
|
'## Accumulated Context',
|
|
'',
|
|
'### Decisions',
|
|
'',
|
|
'None yet.',
|
|
'',
|
|
'## Session Continuity',
|
|
'',
|
|
'Last session: 2026-06-29 12:00',
|
|
'Stopped at: mid-flight',
|
|
'Resume file: None',
|
|
'',
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(planningPath, 'STATE.md'), stateContent);
|
|
return cwd;
|
|
}
|
|
|
|
/** Read the live STATE.md from a project (strips the audit log so assertions
|
|
* check the canonical body, not the appended ## Rebuild Log entries). */
|
|
function readLiveState(cwd) {
|
|
const statePath = path.join(cwd, '.planning', 'STATE.md');
|
|
const content = fs.readFileSync(statePath, 'utf8');
|
|
// Strip ## Rebuild Log and everything after for shape assertions.
|
|
// eslint-disable-next-line local/no-unbounded-quantifier -- parses STATE.md this test wrote via a fixture, fixed-size test-controlled content
|
|
return content.replace(/^## Rebuild Log[\s\S]*$/m, '');
|
|
}
|
|
|
|
/**
|
|
* A project whose ONLY drift is the `**By Phase:**` table (an orphan row for
|
|
* phase 99, which isn't on disk). Every other body/frontmatter field is
|
|
* already canonical, so `rebuildCore`'s other reconciliation steps are all
|
|
* no-ops — the ONLY thing that can add a `## Rebuild Log` entry / flip
|
|
* `mutated` is whether the phase-inventory disk scan actually reconciles the
|
|
* table. This isolates the phase-inventory-scan outcome for #3057 B1.
|
|
*/
|
|
function projectWithOnlyPhaseTableDrift() {
|
|
const cwd = createTempProject('state-rebuild-cli-scan');
|
|
const planningPath = path.join(cwd, '.planning');
|
|
fs.mkdirSync(path.join(planningPath, 'phases', '01-phase-one'), { recursive: true });
|
|
fs.writeFileSync(path.join(planningPath, 'phases', '01-phase-one', '01-PLAN.md'), '# Plan');
|
|
|
|
const stateContent = [
|
|
'---',
|
|
'gsd_state_version: \'1.0\'',
|
|
'status: executing',
|
|
'milestone: 1.0.0',
|
|
'milestone_name: Test',
|
|
'current_phase: 1',
|
|
'current_phase_name: Phase One',
|
|
'current_plan: 1',
|
|
'progress:',
|
|
' total_phases: 1',
|
|
' completed_phases: 0',
|
|
' total_plans: 1',
|
|
' completed_plans: 0',
|
|
' percent: 0',
|
|
'---',
|
|
'',
|
|
'# Project State',
|
|
'',
|
|
'## Project Reference',
|
|
'',
|
|
'**Core value:** A test project',
|
|
'**Current focus:** Phase One',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'**Current Phase:** 1',
|
|
'**Current Phase Name:** Phase One',
|
|
'**Current Plan:** 1',
|
|
'**Total Plans in Phase:** 1',
|
|
'**Status:** executing',
|
|
'**Last Activity:** 2026-06-29',
|
|
'**Last Activity Description:** mid-flight',
|
|
'',
|
|
'Phase: 1 of 1 (Phase One)',
|
|
'Plan: 1 of 1',
|
|
'Status: Executing Phase 1',
|
|
'Last activity: 2026-06-29 — mid-flight',
|
|
'',
|
|
'**Progress:** [░░░░░░░░░░] 0%',
|
|
'',
|
|
'## Performance Metrics',
|
|
'',
|
|
'**By Phase:**',
|
|
'',
|
|
'| Phase | Plans | Total | Avg/Plan |',
|
|
'|-------|-------|-------|----------|',
|
|
'| 1 | 1 | - | - |',
|
|
'| 99 | 1 | - | - |',
|
|
'',
|
|
'## Accumulated Context',
|
|
'',
|
|
'### Decisions',
|
|
'',
|
|
'None yet.',
|
|
'',
|
|
'## Session Continuity',
|
|
'',
|
|
'Last session: 2026-06-29 12:00',
|
|
'Stopped at: mid-flight',
|
|
'Resume file: None',
|
|
'',
|
|
].join('\n');
|
|
fs.writeFileSync(path.join(planningPath, 'STATE.md'), stateContent);
|
|
return cwd;
|
|
}
|
|
|
|
/**
|
|
* Typed presence check for the `## Rebuild Log` audit-log section, shared by
|
|
* both call sites that need it (CONTRIBUTING: no raw-text `.includes()` on
|
|
* produced STATE.md text). Built on the existing `collectSection` seam
|
|
* (markdown-sectionizer.cjs) — a `Section | null`, not string matching.
|
|
*/
|
|
function hasRebuildLogSection(content) {
|
|
return collectSection(content, (h) => h.text.trim() === 'Rebuild Log') !== null;
|
|
}
|
|
|
|
/**
|
|
* Run `fn` while capturing every fd-1 write `cmdStateRebuild`'s `output()`
|
|
* performs (it writes via a raw `fs.writeSync(1, ...)`, never
|
|
* `console.log`/`process.stdout.write` — same seam `tests/io.test.cjs`
|
|
* exercises for bug #1008). Delegates to the shared, safe helper (#4306) —
|
|
* see `tests/helpers.cjs`'s `captureFdSync` for why a hand-rolled version
|
|
* that fabricates a byte count instead of forwarding is unsafe.
|
|
*/
|
|
function captureStdout(fn) {
|
|
return captureFdSync(1, fn);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('ADR-1817 Phase 2: `state rebuild` CLI subcommand dispatch (criterion #5 + end-to-end)', () => {
|
|
test('`state rebuild` with no flags reconciles drifted body fields and writes', (t) => {
|
|
const cwd = projectWithDriftedState();
|
|
t.after(() => cleanup(cwd));
|
|
|
|
const result = runGsdTools('state rebuild', cwd);
|
|
assert.ok(result.success, `state rebuild should succeed; stderr: ${result.stderr || result.error || ''}`);
|
|
|
|
// Body fields reconciled with frontmatter.
|
|
const live = readLiveState(cwd);
|
|
assert.strictEqual(stateExtractField(live, 'Current Phase'), '2',
|
|
'body Current Phase must be reconciled to frontmatter value 2');
|
|
assert.strictEqual(stateExtractField(live, 'Current Phase Name'), 'Phase Two',
|
|
'body Current Phase Name must be reconciled to frontmatter value');
|
|
|
|
// Orphan table row dropped (phase 99 is not on disk).
|
|
const byPhaseTable = parseMarkdownTable(live);
|
|
assert.ok(byPhaseTable.ok, `By Phase table must parse; reason: ${byPhaseTable.ok ? '' : byPhaseTable.reason}`);
|
|
const phaseIds = byPhaseTable.value.rows.map((r) => r.Phase);
|
|
assert.ok(!phaseIds.includes('99'),
|
|
`orphan row for phase 99 must be dropped (phaseInventoryProvider wired to disk scan); rows: ${JSON.stringify(phaseIds)}`);
|
|
|
|
// Audit log appended.
|
|
const fullState = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf8');
|
|
assert.ok(hasRebuildLogSection(fullState),
|
|
'audit log section must be appended');
|
|
});
|
|
|
|
test('`state rebuild --dry-run` computes the diff but writes nothing', (t) => {
|
|
const cwd = projectWithDriftedState();
|
|
t.after(() => cleanup(cwd));
|
|
const before = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf8');
|
|
|
|
const result = runGsdTools('state rebuild --dry-run', cwd);
|
|
assert.ok(result.success, `state rebuild --dry-run should succeed; error: ${result.error || ''}`);
|
|
|
|
const after = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf8');
|
|
assert.strictEqual(after, before,
|
|
'--dry-run must NOT modify STATE.md on disk');
|
|
|
|
// The structured output should signal mutations would occur (the fixture
|
|
// has drift, so mutated=true in dry-run preview). `state rebuild --dry-run`
|
|
// emits pure JSON to stdout (src/state.cts:3221 `cmdStateRebuild`'s dry-run
|
|
// branch: `emit({ ..., mutated, ... })`).
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(parsed.mutated, true,
|
|
`dry-run output should report mutated:true; output: ${result.output}`);
|
|
});
|
|
|
|
test('`state rebuild --verbose` emits audit-log entries to stderr', (t) => {
|
|
const cwd = projectWithDriftedState();
|
|
t.after(() => cleanup(cwd));
|
|
|
|
// runGsdTools returns stdout only on success; --verbose writes to stderr,
|
|
// so invoke gsd-tools directly to capture both streams separately.
|
|
const r = runNode(
|
|
[TOOLS_PATH, 'state', 'rebuild', '--verbose'],
|
|
{ cwd, timeoutMs: PROBE_TIMEOUT_MS },
|
|
);
|
|
throwIfFailed(r, 'gsd-tools state rebuild --verbose');
|
|
const stdout = r.stdout;
|
|
// The seam's runNode captures stderr separately (unlike execFileSync's
|
|
// default inherited stderr) but this test does not need it — see below.
|
|
// Assert via the canonical record written to STATE.md: the audit log
|
|
// section is always appended (mutated fixture), and --verbose merely
|
|
// tees the same entries to stderr. The functional guarantee (audit log
|
|
// written) is what matters; the stderr tee is a convenience.
|
|
const after = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf8');
|
|
assert.ok(hasRebuildLogSection(after),
|
|
'--verbose must still produce the audit log section in STATE.md');
|
|
// The real (non-dry-run) path emits `{ rebuilt: capturedMutated, ... }`
|
|
// to stdout as pure JSON (src/state.cts:3254 `cmdStateRebuild`). The
|
|
// fixture has drift, so `rebuilt` must be true.
|
|
const parsedStdout = JSON.parse(stdout);
|
|
assert.strictEqual(parsedStdout.rebuilt, true,
|
|
`--verbose stdout must report rebuilt:true; got: ${stdout.slice(0, 200)}`);
|
|
});
|
|
|
|
test('`state rebuild` on a clean STATE.md is a no-op (idempotency, end-to-end)', (t) => {
|
|
const cwd = projectWithDriftedState();
|
|
t.after(() => cleanup(cwd));
|
|
|
|
// First run: reconcile the drift.
|
|
const first = runGsdTools('state rebuild', cwd);
|
|
assert.ok(first.success, 'first rebuild should succeed');
|
|
const afterFirst = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf8');
|
|
|
|
// Second run: should detect no drift, write nothing beyond what's there.
|
|
const second = runGsdTools('state rebuild', cwd);
|
|
assert.ok(second.success, 'second rebuild should succeed');
|
|
const afterSecond = fs.readFileSync(path.join(cwd, '.planning', 'STATE.md'), 'utf8');
|
|
|
|
assert.strictEqual(afterSecond, afterFirst,
|
|
'second rebuild on the just-rebuilt file must be byte-identical (idempotency)');
|
|
});
|
|
|
|
test('`state rebuild` on a missing STATE.md emits a clean error, no stack trace', (t) => {
|
|
const cwd = createTempProject('state-rebuild-missing');
|
|
t.after(() => cleanup(cwd));
|
|
// No STATE.md written.
|
|
|
|
const result = runGsdTools('state rebuild', cwd);
|
|
// The command emits a typed JSON error result and exits 0 — it does not
|
|
// crash the process (src/state.cts:3136 `cmdStateRebuild`'s missing-file
|
|
// guard: `emit({ error: 'STATE.md not found' }, raw); return;`, no
|
|
// `process.exit`). `result.success` proves the clean exit (a crash would
|
|
// flip it to false and populate `result.error` with a raw stack trace
|
|
// instead); `JSON.parse` proves stdout is exactly the structured payload
|
|
// with nothing else — including no leaked stack-trace text — mixed in.
|
|
assert.ok(result.success,
|
|
`state rebuild on a missing STATE.md should exit cleanly (no crash); stderr: ${result.error || ''}`);
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(parsed.error, 'STATE.md not found',
|
|
`missing STATE.md should produce a typed error field; output: ${result.output}`);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #3057 B1: the PRODUCTION `phaseInventoryProvider` closure (state.cts:3106),
|
|
// not just the pure `reconcileByPhaseTable` core, must distinguish a real
|
|
// disk-scan failure from a genuinely-empty/reconciled scan. In-process fault
|
|
// injection via `withFaultyFs` on the real `fs.readdirSync` the closure
|
|
// calls — never chmod, never the subprocess seam (both banned per
|
|
// tests/helpers/faulty-deps.cjs's module doc).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('#3057 B1: cmdStateRebuild (production adapter) surfaces a real phase-inventory scan failure', () => {
|
|
test('FAILURE path: readdirSync(.planning/phases) faulted → phase_inventory_scan_failed:true, table left untouched, mutated:false', (t) => {
|
|
const cwd = projectWithOnlyPhaseTableDrift();
|
|
t.after(() => cleanup(cwd));
|
|
const phasesDir = path.join(cwd, '.planning', 'phases');
|
|
const originalReaddirSync = fs.readdirSync;
|
|
|
|
const stdout = withFaultyFs({
|
|
readdirSync: (p, ...rest) => {
|
|
if (String(p) === phasesDir) {
|
|
throw Object.assign(new Error('EACCES: permission denied, scandir ' + phasesDir), { code: 'EACCES' });
|
|
}
|
|
return originalReaddirSync(p, ...rest);
|
|
},
|
|
}, () => captureStdout(() => {
|
|
stateMod.cmdStateRebuild(cwd, { dryRun: true }, false);
|
|
}));
|
|
|
|
const parsed = JSON.parse(stdout);
|
|
assert.strictEqual(parsed.phase_inventory_scan_failed, true,
|
|
`a faulted disk scan must report phase_inventory_scan_failed:true; got: ${stdout}`);
|
|
assert.strictEqual(parsed.mutated, false,
|
|
'nothing else drifted in this fixture, so mutated must stay false — the failure is carried ONLY by the dedicated field');
|
|
assert.strictEqual(parsed.phase_inventory_scan_reason,
|
|
'EACCES: permission denied, scandir ' + phasesDir,
|
|
`phase_inventory_scan_reason must carry the exact fault message from the injected readdirSync throw; got: ${JSON.stringify(parsed.phase_inventory_scan_reason)}`);
|
|
|
|
// The orphan row must survive untouched — a failed scan is not a
|
|
// trustworthy inventory to reconcile the table against.
|
|
const live = readLiveState(cwd);
|
|
const byPhaseTable = parseMarkdownTable(live);
|
|
assert.ok(byPhaseTable.ok, `By Phase table must parse; reason: ${byPhaseTable.ok ? '' : byPhaseTable.reason}`);
|
|
const phaseIds = byPhaseTable.value.rows.map((r) => r.Phase);
|
|
assert.ok(phaseIds.includes('99'),
|
|
`orphan row for phase 99 must be preserved when the scan failed; rows: ${JSON.stringify(phaseIds)}`);
|
|
});
|
|
|
|
test('BENIGN path: the same fixture with an unfaulted disk scan reconciles the table and reports no failure', (t) => {
|
|
const cwd = projectWithOnlyPhaseTableDrift();
|
|
t.after(() => cleanup(cwd));
|
|
|
|
const stdout = captureStdout(() => {
|
|
stateMod.cmdStateRebuild(cwd, { dryRun: true }, false);
|
|
});
|
|
|
|
const parsed = JSON.parse(stdout);
|
|
assert.strictEqual(parsed.phase_inventory_scan_failed, false,
|
|
`an unfaulted disk scan must report phase_inventory_scan_failed:false; got: ${stdout}`);
|
|
assert.strictEqual(parsed.mutated, true,
|
|
'the real disk scan finds phase 1 only, so the orphan row 99 is real drift the (dry-run) rebuild would fix');
|
|
assert.strictEqual(parsed.phase_inventory_scan_reason, undefined,
|
|
'a clean scan must leave phase_inventory_scan_reason absent, distinguishing it from a faulted scan');
|
|
});
|
|
});
|