diff --git a/CONTEXT.md b/CONTEXT.md index cc9eb6e79..ac8af7a86 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -427,7 +427,7 @@ The single subprocess-spawning primitive test code uses (`tests/helpers/process- The throw-preserving companion to the process seam (`tests/helpers/git-fixture.cjs`, #3143): `gitOrThrow(args, options)` runs `runGit` and returns `stdout` as a string on a clean exit, but throws on any other outcome. It exists because the seam **deliberately never throws** while `execSync` and `execFileSync` — the two forms 237 migrating call sites use — both throw on a non-zero exit. Migrating those mechanically onto `runGit` would convert a loud failure into a silent one: fixture setup that failed would return an empty string and surface as a baffling assertion failure further down. The thrown error carries `status` **and** `exitCode` as deliberate aliases (`status` is what the legacy `execSync` catch idiom reads, e.g. `tests/worktree-safety.test.cjs:1361`), plus `stdout`, `stderr`, `signal`, `timedOut` and `outcome`. Use `runGit` when every outcome is data you branch on; use `gitOrThrow` for fixture setup that must abort loudly. The seam module is **not** modified to add this — a throwing export would falsify the never-throws contract stated in its own header and in the `### Process seam` entry above. The module also exports `throwIfFailed(result, displayName)`, the single implementation of that throw shape: `gitOrThrow` itself is `throwIfFailed` specialized to `runGit`, so it routes through the same code path and the two cannot drift apart. Per-suite wrappers driving non-git targets — a node CLI via `runNode`, a bash snippet via `runHook` — call `throwIfFailed` directly rather than hand-rolling their own copy of this shape, which is exactly how five call sites had drifted from each other before this module exported it (#3144). ### Unbounded-spawn guard -The lint rule enforcing `DEFECT.UNBOUNDED-SUBPROCESS` across the test suite (`eslint-rules/no-unbounded-spawn.cjs`, #3143, wired into the `tests/**/*.cjs` block of `eslint.config.mjs`). Flags `spawnSync` / `execFileSync` / `execSync` whose options carry no usable `timeout`. It resolves renamed destructures (`const { execSync: exec } = require('node:child_process')`) and chained requires (`require('node:child_process').execSync(...)`) rather than matching literal callee names — both forms exist in the suite today and a name-only matcher leaves them permanently invisible. It resolves an options object held in a single-write `const`, which is what keeps `process-seam.cjs` — the bounded reference implementation — from flagging itself. Two values are rejected as *nominally* bounded: `timeout: 0` (Node reads zero as no timeout) and anything above the 600000 ms ceiling (effectively unbounded); a non-literal value is trusted, since the target shape is one named constant with a comment. `no-unbounded-spawn.allowlist.json` grandfathers pre-existing violations and ratchets **down only** — a listed file with zero violations reports its own entry as stale, so the list cannot go quiet while the class survives. Companion tests assert the list never grows, carries no dead entries, and that no `eslint-disable` for this rule exists anywhere under `tests/`. +The lint rule enforcing `DEFECT.UNBOUNDED-SUBPROCESS` across the test suite (`eslint-rules/no-unbounded-spawn.cjs`, #3143, wired into the `tests/**/*.cjs` block of `eslint.config.mjs`). Flags `spawnSync` / `execFileSync` / `execSync` whose options carry no usable `timeout`. It resolves renamed destructures (`const { execSync: exec } = require('node:child_process')`) and chained requires (`require('node:child_process').execSync(...)`) rather than matching literal callee names — both forms exist in the suite today and a name-only matcher leaves them permanently invisible. It resolves an options object held in a single-write `const`, which is what keeps `process-seam.cjs` — the bounded reference implementation — from flagging itself. Two values are rejected as *nominally* bounded: `timeout: 0` (Node reads zero as no timeout) and anything above the 600000 ms ceiling (effectively unbounded); a non-literal value is trusted, since the target shape is one named constant with a comment. `no-unbounded-spawn.allowlist.json` grandfathers pre-existing violations and ratchets **down only** — a listed file with zero violations reports its own entry as stale, so the list cannot go quiet while the class survives. Companion tests assert the list never grows, carries no dead entries, and that no `eslint-disable` for this rule exists anywhere under `tests/`. #3145 added an auditable ceiling escape: a literal timeout over the 600000 ms ceiling is permitted, without `timeoutTooLarge`, only when the call carries an inline `// allow-spawn-timeout-ceiling: ` marker comment (the `// allow-test-rule: ` idiom) with a non-empty reason, on the line immediately above the call or anywhere inside the call's own source range. It binds to that call only — a marker on one call never suppresses a different over-ceiling call elsewhere in the file — and it is deliberately narrow: it raises the ceiling for a call that already has a resolvable numeric timeout, it never waives the requirement for a bound, so a marked call with no `timeout` at all still reports `unboundedSpawn`. The real load-tested exception this exists for is `fragment-single-edit-propagation.install.test.cjs`'s `timeout: 900000` on `npm run regen:derived` (a full build plus eight generators), where 300000 was observed killing a genuinely-completed run near the end on a loaded bench. ### Deterministic scheduler Test-execution model in which all timing and concurrency outcomes are fully controlled by the test (via clock seam, explicit `await` ordering, or synchronous stepping) rather than by the OS thread scheduler. Opposed to real-race tests, which are non-deterministic on loaded CI runners. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f0869f3ec..6f095cfab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -417,6 +417,14 @@ bench OOM. Inject faults in-process through a module's `deps` parameter instead. Per-suite wrappers are still expected and encouraged: bind your fixture (cwd, env, payload) in a local helper and delegate the spawn to the seam. +**Class-norm timeouts live in `tests/helpers/timeouts.cjs`** — `PROBE_TIMEOUT_MS`, +`GIT_TIMEOUT_MS`, `BUILD_TIMEOUT_MS`, `INSTALL_TIMEOUT_MS`. These describe how long a whole CLASS +of subprocess call takes (a CLI probe, git plumbing on a fixture repo, a hooks build, a full +`bin/install.js` run), not a single suite's preference, so import them rather than re-declaring the +same literal with the same comment in yet another file. Only write a local constant when a site +genuinely differs from its class (a real `tsc` compile, a `regen:derived` run, ...) — and give that +local constant its own justifying comment explaining why it departs from the norm. + #### When you want git to *throw*: `gitOrThrow` `runGit` never throws — that is the whole point of it. But `execSync` and `execFileSync` **do** @@ -476,6 +484,23 @@ Two things it deliberately rejects, because both look bounded and are not: A non-literal value (`timeout: GIT_TIMEOUT_MS`) is trusted — that is the shape you should be writing. +When a call genuinely needs more than the 600000 ms ceiling — a full installer run, a build plus +generators — the escape is an inline marker comment, exactly the `// allow-test-rule: ` +idiom above: + +```javascript +// allow-spawn-timeout-ceiling: regen:derived chains a full build plus eight generators +timeout: 900000, +``` + +The reason is required and must be non-empty; a bare `// allow-spawn-timeout-ceiling:` (or one +with only whitespace after the colon) is not an audit trail and still reports `timeoutTooLarge`. +The marker binds only to the call it decorates — either the line immediately above it, or +anywhere inside that call's own source range — never to the rest of the file. Critically, the +escape only ever raises the ceiling for a call that already resolves to a numeric timeout: it +never waives the requirement for a bound. A marked call with no `timeout` at all still reports +`unboundedSpawn`. + `eslint-rules/no-unbounded-spawn.allowlist.json` grandfathers files that predate the rule. It only ratchets **down**: once a file is clean, the rule reports its allowlist line as stale and you delete it. Never add an entry, and never reach for `eslint-disable` on this rule — a test asserts that no diff --git a/eslint-rules/no-unbounded-spawn.allowlist.json b/eslint-rules/no-unbounded-spawn.allowlist.json index 9ecf64299..48cc1c283 100644 --- a/eslint-rules/no-unbounded-spawn.allowlist.json +++ b/eslint-rules/no-unbounded-spawn.allowlist.json @@ -1,18 +1,8 @@ [ "tests/adr-15-progress-converge.test.cjs", - "tests/agent-fragments-emission.install.test.cjs", - "tests/agent-install-validation.test.cjs", "tests/agent-skills.test.cjs", - "tests/antigravity-upgrades.test.cjs", - "tests/augment-upgrades.test.cjs", "tests/autonomous-converge.test.cjs", "tests/bugs-1656-1657.test.cjs", - "tests/capability-cli.test.cjs", - "tests/capability-consent.test.cjs", - "tests/capability-ledger.test.cjs", - "tests/capability-lifecycle.test.cjs", - "tests/capability-loader.test.cjs", - "tests/capability-writer.test.cjs", "tests/changeset-cli.test.cjs", "tests/changeset-github-release-notes.test.cjs", "tests/changeset-lint.test.cjs", @@ -23,26 +13,15 @@ "tests/cli-exit.test.cjs", "tests/close-phase-todos-padded-resolves.test.cjs", "tests/code-review-pipeline-regression.test.cjs", - "tests/codex-config.test.cjs", "tests/commands.test.cjs", "tests/commonjs-marker.test.cjs", "tests/config-get-default.test.cjs", "tests/config-loader.test.cjs", "tests/config.test.cjs", "tests/configuration-migrate-config.test.cjs", - "tests/copilot-install.test.cjs", - "tests/declarative-reference-antigravity.test.cjs", - "tests/declarative-reference-augment.test.cjs", - "tests/declarative-reference-codebuddy.test.cjs", - "tests/declarative-reference-copilot.test.cjs", - "tests/declarative-reference-windsurf.test.cjs", - "tests/declarative-reference-zcode.test.cjs", "tests/drift-detection.test.cjs", "tests/edge-probe.test.cjs", - "tests/effort-sync-installed-runtime.test.cjs", - "tests/emitted-caps-gate.test.cjs", "tests/emitted-sizes.test.cjs", - "tests/ensure-runtime-build.test.cjs", "tests/execute-wave-post-gate-pipeline-e2e.test.cjs", "tests/fix-2136-clock-local-today.test.cjs", "tests/fix-2590-workflow-script-contract.test.cjs", @@ -52,60 +31,36 @@ "tests/fix-3045-dispatch-isolation-resolver.test.cjs", "tests/fixture-builder.test.cjs", "tests/fixtures/index.cjs", - "tests/fragment-single-edit-propagation.install.test.cjs", "tests/frontmatter-cli.test.cjs", - "tests/gemini-runtime-removed.test.cjs", "tests/gen-registry.test.cjs", - "tests/golden-install-tree.test.cjs", "tests/graphify-auto-update.slow.test.cjs", "tests/graphify-visualization.test.cjs", "tests/gsd-agent-isolation-guard.test.cjs", "tests/gsd-statusline.test.cjs", "tests/helpers.cjs", "tests/helpers/graphify.cjs", - "tests/helpers/install-shared.cjs", - "tests/hooks-opt-in.test.cjs", "tests/host-integration.test.cjs", "tests/ingest-docs.test.cjs", "tests/init.test.cjs", - "tests/install-minimal-hooks.test.cjs", - "tests/install-regressions.test.cjs", - "tests/install-write-confinement.test.cjs", - "tests/install.test.cjs", - "tests/installer-migration-install.integration.test.cjs", - "tests/installer-migrations.test.cjs", "tests/io.test.cjs", - "tests/issue-2695-codex-hook-set.test.cjs", "tests/issue-2765-brace-expansion-lockfile.test.cjs", "tests/issue-498-update-context.test.cjs", - "tests/issue-787-cline-hooks-agents.test.cjs", "tests/issue-844-manifest-version-sync.test.cjs", - "tests/kilo-upgrades.test.cjs", - "tests/kimi-agent-converter.test.cjs", - "tests/kimi-upgrades.test.cjs", "tests/lint-docs-command-form.test.cjs", "tests/lint-legacy-dir-name.test.cjs", "tests/lint-pr-check-project-dir.test.cjs", "tests/lint-regression-test-names.test.cjs", "tests/lint-skill-deps.test.cjs", "tests/lint-test-file-count.test.cjs", - "tests/loop-render-hooks.test.cjs", - "tests/managed-hooks.test.cjs", - "tests/mcp-catalog-parity.install.test.cjs", "tests/mutation-matrix-ratchet.test.cjs", "tests/new-milestone-clear-phases.test.cjs", - "tests/opencode-command-dir-plural.test.cjs", - "tests/opencode-plugin-adapter.test.cjs", "tests/pause-work-improvements.test.cjs", "tests/phase.test.cjs", - "tests/precommit-alias-drift-hook.test.cjs", - "tests/prepush-enterprise-email-hook.test.cjs", "tests/process-seam.test.cjs", "tests/prohibition-enforcement.test.cjs", "tests/project-instruction-file-parity.test.cjs", "tests/repo-layout.test.cjs", "tests/reviewer-docs-parity.test.cjs", - "tests/roadmap-upgrade.test.cjs", "tests/run-tests-harness.test.cjs", "tests/runtime-launcher-parity.test.cjs", "tests/skill-frontmatter-contract.test.cjs", @@ -116,7 +71,5 @@ "tests/state.test.cjs", "tests/tsconfig-noemit.test.cjs", "tests/validate-registry.test.cjs", - "tests/windsurf-hooks-bridge.test.cjs", - "tests/workflow-fragments-emission.install.test.cjs", "tests/workflow-guard.test.cjs" ] diff --git a/eslint-rules/no-unbounded-spawn.cjs b/eslint-rules/no-unbounded-spawn.cjs index e24850aa2..2c8548b6e 100644 --- a/eslint-rules/no-unbounded-spawn.cjs +++ b/eslint-rules/no-unbounded-spawn.cjs @@ -51,6 +51,21 @@ const path = require('path'); * `allowlist` (repo-relative POSIX paths) grandfathers pre-existing * violations. The allowlist only ratchets down: a listed file with zero * violations reports `staleAllowlistEntry` so the dead entry gets deleted. + * + * ## The ceiling escape — `allow-spawn-timeout-ceiling` + * + * A literal timeout over `maxTimeoutMs` is permitted, without triggering + * `timeoutTooLarge`, only when the call carries an inline + * `// allow-spawn-timeout-ceiling: ` marker comment with a + * non-empty reason — the same idiom as `// allow-test-rule: ` + * documented in CONTRIBUTING.md. The marker may sit on the line + * immediately above the call, or anywhere inside the call's own source + * range (e.g. next to the `timeout:` property itself). It binds only to + * the call it decorates, never file-wide, and a bare/whitespace-only + * reason does not count. Critically, the escape only ever raises the + * ceiling for a call that already has a resolvable numeric timeout — it + * never waives the requirement for a bound: a marked call with no + * timeout at all still reports `unboundedSpawn`. */ /** @type {import('eslint').Rule.RuleModule} */ @@ -102,6 +117,29 @@ const rule = { /** Map from local (in-scope) name -> canonical target function name. */ const aliases = new Map(); + /** Requires a non-empty, non-whitespace-only reason after the colon. */ + const CEILING_MARKER_RE = /allow-spawn-timeout-ceiling:\s*\S/; + + /** + * Returns true if `node` (a CallExpression) carries a valid + * `// allow-spawn-timeout-ceiling: ` marker comment — either on + * the line immediately above the call, or anywhere inside the call's + * own source range. Binds to this call only: a marker decorating a + * different call elsewhere in the file is never considered. + */ + function hasCeilingMarkerComment(node) { + const sourceCode = context.sourceCode || context.getSourceCode(); + const allComments = sourceCode.getAllComments(); + for (const comment of allComments) { + const isInline = comment.range[0] >= node.range[0] && comment.range[1] <= node.range[1]; + const isImmediatelyAbove = comment.loc.end.line === node.loc.start.line - 1; + if ((isInline || isImmediatelyAbove) && CEILING_MARKER_RE.test(comment.value)) { + return true; + } + } + return false; + } + const filename = context.filename || context.getFilename(); const cwd = context.cwd || (context.getCwd ? context.getCwd() : process.cwd()); const rel = path.relative(cwd, filename).split(path.sep).join('/'); @@ -347,6 +385,18 @@ const rule = { if (verdict === 'bounded') return; + // The ceiling escape only ever raises the ceiling for a call that + // already resolved to a numeric-but-too-large timeout. It never + // applies to an 'unbounded' verdict — a marker cannot waive the + // requirement for a bound. + if ( + typeof verdict === 'object' && + verdict.tooLarge !== undefined && + hasCeilingMarkerComment(node) + ) { + return; + } + violations += 1; if (allowlisted) return; diff --git a/tests/agent-fragments-emission.install.test.cjs b/tests/agent-fragments-emission.install.test.cjs index 092974b91..2fbdb7d0d 100644 --- a/tests/agent-fragments-emission.install.test.cjs +++ b/tests/agent-fragments-emission.install.test.cjs @@ -45,13 +45,15 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const { spawnSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); const { cleanup } = require('./helpers.cjs'); const { RUNTIME_META, installerEnv, walk } = require('./helpers/install-shared.cjs'); const { buildOverlayRepo } = require('./helpers/overlay-repo.cjs'); const REPO_ROOT = path.join(__dirname, '..'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); /** The agent used as the marker probe. Any agent works — this suite proves the * EMISSION PATH, not this file's own content. gsd-codebase-mapper is the @@ -124,11 +126,12 @@ function spawnInstall(repoRoot, runtime, scope) { const cwd = root; if (scope === 'global') args.push('--global', '--config-dir', root); else args.push('--local'); - const result = spawnSync(process.execPath, args, { + const seamResult = runNode(args, { cwd, - encoding: 'utf8', env: installerEnv({ HOME: root, USERPROFILE: root }), + timeoutMs: INSTALL_TIMEOUT_MS, }); + const result = { status: seamResult.exitCode, stdout: seamResult.stdout, stderr: seamResult.stderr }; return { result, root }; } diff --git a/tests/agent-install-validation.test.cjs b/tests/agent-install-validation.test.cjs index bff0e50c5..61e16591d 100644 --- a/tests/agent-install-validation.test.cjs +++ b/tests/agent-install-validation.test.cjs @@ -12,10 +12,12 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const os = require('os'); const path = require('path'); -const { spawnSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); const { installerEnv } = require('./helpers/install-shared.cjs'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const AGENTS_DIR_NAME = 'agents'; const MODEL_PROFILES = require('../gsd-core/bin/lib/model-profiles.cjs').MODEL_PROFILES; const EXPECTED_AGENTS = Object.keys(MODEL_PROFILES); @@ -397,17 +399,16 @@ describe('checkAgentsInstalled: Kimi agents/subagents layout', () => { }); try { - const installResult = spawnSync( - process.execPath, + const installResult = runNode( [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--no-sdk'], { cwd: tmpDir, - encoding: 'utf8', env, + timeoutMs: INSTALL_TIMEOUT_MS, }, ); assert.strictEqual( - installResult.status, + installResult.exitCode, 0, `Kimi install failed\nstdout: ${installResult.stdout}\nstderr: ${installResult.stderr}`, ); diff --git a/tests/antigravity-upgrades.test.cjs b/tests/antigravity-upgrades.test.cjs index f196bc956..bb9033423 100644 --- a/tests/antigravity-upgrades.test.cjs +++ b/tests/antigravity-upgrades.test.cjs @@ -32,6 +32,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { spawnSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); const { runMinimalInstall, installerEnv, INSTALL_SCRIPT } = require('./helpers/install-shared.cjs'); const { cleanup } = require('./helpers.cjs'); @@ -248,11 +249,11 @@ test('antigravity --global uninstall removes only GSD-owned permissions.allow ru t.after(() => cleanup(root)); const args = [INSTALL_SCRIPT, '--antigravity', '--global', '--config-dir', root]; - const installResult = spawnSync(process.execPath, args, { - encoding: 'utf8', + const installResult = runNode(args, { env: installerEnv({ HOME: root, USERPROFILE: root }), + timeoutMs: 120000, }); - assert.strictEqual(installResult.status, 0, `install failed: ${installResult.stderr}`); + assert.strictEqual(installResult.exitCode, 0, `install failed: ${installResult.stderr}`); // Seed user-owned data alongside GSD's contributions, post-install. const settingsPath = path.join(root, 'settings.json'); @@ -267,11 +268,11 @@ test('antigravity --global uninstall removes only GSD-owned permissions.allow ru fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + '\n'); const uninstallArgs = [INSTALL_SCRIPT, '--antigravity', '--global', '--config-dir', root, '--uninstall']; - const uninstallResult = spawnSync(process.execPath, uninstallArgs, { - encoding: 'utf8', + const uninstallResult = runNode(uninstallArgs, { env: installerEnv({ HOME: root, USERPROFILE: root }), + timeoutMs: 120000, }); - assert.strictEqual(uninstallResult.status, 0, `uninstall failed: ${uninstallResult.stderr}`); + assert.strictEqual(uninstallResult.exitCode, 0, `uninstall failed: ${uninstallResult.stderr}`); const settingsAfter = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); assert.deepEqual(settingsAfter.permissions.allow, ['command(git)'], 'GSD allow rules removed, user rule preserved'); diff --git a/tests/augment-upgrades.test.cjs b/tests/augment-upgrades.test.cjs index 323d902ae..d82eac73b 100644 --- a/tests/augment-upgrades.test.cjs +++ b/tests/augment-upgrades.test.cjs @@ -21,7 +21,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); -const { spawnSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); const { runMinimalInstall, installerEnv, INSTALL_SCRIPT } = require('./helpers/install-shared.cjs'); const { cleanup } = require('./helpers.cjs'); @@ -169,8 +169,8 @@ test('augment --global: reinstalling does not duplicate or clobber the gsd MCP c const args = [INSTALL_SCRIPT, '--augment', '--global', '--config-dir', root]; const env = installerEnv({ HOME: root, USERPROFILE: root }); - const first = spawnSync(process.execPath, args, { encoding: 'utf8', env }); - assert.strictEqual(first.status, 0, `first install failed: ${first.stderr}`); + const first = runNode(args, { env, timeoutMs: 120000 }); + assert.strictEqual(first.exitCode, 0, `first install failed: ${first.stderr}`); const settingsPath = path.join(root, 'settings.json'); const afterFirst = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); @@ -179,8 +179,8 @@ test('augment --global: reinstalling does not duplicate or clobber the gsd MCP c afterFirst.mcpServers.gsd.args.push('--custom-flag'); fs.writeFileSync(settingsPath, JSON.stringify(afterFirst, null, 2) + '\n'); - const second = spawnSync(process.execPath, args, { encoding: 'utf8', env }); - assert.strictEqual(second.status, 0, `second install failed: ${second.stderr}`); + const second = runNode(args, { env, timeoutMs: 120000 }); + assert.strictEqual(second.exitCode, 0, `second install failed: ${second.stderr}`); const afterSecond = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); assert.equal(Object.keys(afterSecond.mcpServers).length, 1, 'reinstall must not duplicate the gsd entry'); @@ -201,11 +201,11 @@ test('augment --global: installing preserves a pre-existing unrelated mcpServers }, null, 2)); const args = [INSTALL_SCRIPT, '--augment', '--global', '--config-dir', root]; - const result = spawnSync(process.execPath, args, { - encoding: 'utf8', + const result = runNode(args, { env: installerEnv({ HOME: root, USERPROFILE: root }), + timeoutMs: 120000, }); - assert.strictEqual(result.status, 0, `install failed: ${result.stderr}`); + assert.strictEqual(result.exitCode, 0, `install failed: ${result.stderr}`); const settings = JSON.parse(fs.readFileSync(path.join(root, 'settings.json'), 'utf8')); assert.deepEqual(settings.mcpServers.other, { command: 'my-tool', args: ['--flag'] }, @@ -223,8 +223,8 @@ test('augment --global uninstall removes only the GSD-owned mcpServers.gsd entry const env = installerEnv({ HOME: root, USERPROFILE: root }); const installArgs = [INSTALL_SCRIPT, '--augment', '--global', '--config-dir', root]; - const installResult = spawnSync(process.execPath, installArgs, { encoding: 'utf8', env }); - assert.strictEqual(installResult.status, 0, `install failed: ${installResult.stderr}`); + const installResult = runNode(installArgs, { env, timeoutMs: 120000 }); + assert.strictEqual(installResult.exitCode, 0, `install failed: ${installResult.stderr}`); // Seed user-owned data alongside GSD's contributions, post-install. const settingsPath = path.join(root, 'settings.json'); @@ -233,8 +233,8 @@ test('augment --global uninstall removes only the GSD-owned mcpServers.gsd entry fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); const uninstallArgs = [INSTALL_SCRIPT, '--augment', '--global', '--config-dir', root, '--uninstall']; - const uninstallResult = spawnSync(process.execPath, uninstallArgs, { encoding: 'utf8', env }); - assert.strictEqual(uninstallResult.status, 0, `uninstall failed: ${uninstallResult.stderr}`); + const uninstallResult = runNode(uninstallArgs, { env, timeoutMs: 120000 }); + assert.strictEqual(uninstallResult.exitCode, 0, `uninstall failed: ${uninstallResult.stderr}`); const settingsAfter = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); assert.equal(settingsAfter.mcpServers && settingsAfter.mcpServers.gsd, undefined, 'gsd MCP entry removed'); diff --git a/tests/capability-cli.test.cjs b/tests/capability-cli.test.cjs index 111aff81e..072e42d16 100644 --- a/tests/capability-cli.test.cjs +++ b/tests/capability-cli.test.cjs @@ -1008,8 +1008,13 @@ describe('capability consent store (#1459)', () => { const capId = 'fifo-list-cap'; const dir = path.join(cwd, '.gsd', 'capabilities', capId); fs.mkdirSync(dir, { recursive: true }); - const { execFileSync } = require('node:child_process'); - execFileSync('mkfifo', [path.join(dir, 'capability.json')]); + const { runHook } = require('./helpers/process-seam.cjs'); + const { throwIfFailed } = require('./helpers/git-fixture.cjs'); + const fifoPath = path.join(dir, 'capability.json'); + throwIfFailed( + runHook(fifoPath, [], { interpreter: 'mkfifo', timeoutMs: 15000 }), + `mkfifo ${fifoPath}`, + ); // A committed project ledger so the list iterates this entry (the FIFO is on the metadata-read path). fs.writeFileSync(path.join(cwd, '.gsd-capabilities.json'), JSON.stringify({ version: '1', updatedAt: '2026-01-01T00:00:00Z', diff --git a/tests/capability-consent.test.cjs b/tests/capability-consent.test.cjs index a85fca827..fbdab56c4 100644 --- a/tests/capability-consent.test.cjs +++ b/tests/capability-consent.test.cjs @@ -27,8 +27,19 @@ const path = require('node:path'); const crypto = require('node:crypto'); const { cleanup } = require('./helpers.cjs'); +const { runHook } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const consent = require('../gsd-core/bin/lib/capability-consent.cjs'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + +/** Create a FIFO at `fifoPath` via `mkfifo`, throwing on failure. */ +function mkfifo(fifoPath) { + const r = runHook(fifoPath, [], { interpreter: 'mkfifo', timeoutMs: PROBE_TIMEOUT_MS }); + throwIfFailed(r, `mkfifo ${fifoPath}`); +} + function tmpDir(prefix) { return fs.mkdtempSync(path.join(os.tmpdir(), prefix || 'cap-consent-test-')); } @@ -144,8 +155,7 @@ test('bundleContentHash: refuses to follow a symlink in the bundle (fail closed) test('bundleContentHash: refuses a non-regular (FIFO) entry in the bundle (fail closed)', { skip: process.platform === 'win32' }, () => { const dir = makeBundle({ manifest: { id: 'cap', role: 'feature', version: '1.0.0' } }); try { - const { execFileSync } = require('node:child_process'); - execFileSync('mkfifo', [path.join(dir, 'fifo')]); + mkfifo(path.join(dir, 'fifo')); assert.throws(() => consent.bundleContentHash(dir), /non-regular/i, 'a FIFO in the bundle is rejected'); } finally { cleanup(dir); @@ -504,8 +514,7 @@ test('readConsentStore: a FIFO at the store path does not block; returns empty', const home = tmpDir(); try { fs.mkdirSync(path.join(home, '.gsd'), { recursive: true }); - const { execFileSync } = require('node:child_process'); - execFileSync('mkfifo', [consent.consentStorePath(home)]); + mkfifo(consent.consentStorePath(home)); assert.deepStrictEqual(consent.readConsentStore(home), { records: {} }); } finally { cleanup(home); diff --git a/tests/capability-ledger.test.cjs b/tests/capability-ledger.test.cjs index b8e23ac34..d71d8778c 100644 --- a/tests/capability-ledger.test.cjs +++ b/tests/capability-ledger.test.cjs @@ -25,12 +25,14 @@ const { // Destructure optional exports (new in this patch) — will be undefined until implemented. const { LedgerIOError, isValidLedgerEntry, readLedgerStrict, readSmallRegularFile } = capLedger; -const cp = require('node:child_process'); +const { runHook } = require('./helpers/process-seam.cjs'); +// A single `mkfifo` system call creating a fixture FIFO — no install or build. +const MKFIFO_TIMEOUT_MS = 15000; /** POSIX-only: make a FIFO at `p` (skips/returns false where mkfifo is unavailable). */ function tryMkfifo(p) { if (process.platform === 'win32') return false; - const res = cp.spawnSync('mkfifo', [p], { stdio: 'ignore' }); - return res.status === 0; + const res = runHook(p, [], { interpreter: 'mkfifo', timeoutMs: MKFIFO_TIMEOUT_MS }); + return res.exitCode === 0; } // --------------------------------------------------------------------------- diff --git a/tests/capability-lifecycle.test.cjs b/tests/capability-lifecycle.test.cjs index d1b649b55..28ce73138 100644 --- a/tests/capability-lifecycle.test.cjs +++ b/tests/capability-lifecycle.test.cjs @@ -26,12 +26,14 @@ const { CAP_MARKER } = lifecycle; // Helpers // --------------------------------------------------------------------------- -const cp = require('node:child_process'); +const { runHook } = require('./helpers/process-seam.cjs'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); /** POSIX-only: make a FIFO at `p` (returns false where mkfifo is unavailable). */ function tryMkfifoLife(p) { if (process.platform === 'win32') return false; - const res = cp.spawnSync('mkfifo', [p], { stdio: 'ignore' }); - return res.status === 0; + const res = runHook(p, [], { interpreter: 'mkfifo', timeoutMs: PROBE_TIMEOUT_MS }); + return res.exitCode === 0; } const cleanups = []; diff --git a/tests/capability-loader.test.cjs b/tests/capability-loader.test.cjs index 4da5075e1..a4a9e6db9 100644 --- a/tests/capability-loader.test.cjs +++ b/tests/capability-loader.test.cjs @@ -16,12 +16,23 @@ const os = require('node:os'); const path = require('node:path'); const { cleanup } = require('./helpers.cjs'); +const { runHook } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { loadRegistry, _setValidatorForTest, _setGeneratorForTest } = require('../gsd-core/bin/lib/capability-loader.cjs'); const baseRegistry = require('../gsd-core/bin/lib/capability-registry.cjs'); const { buildRegistry } = require('../scripts/gen-capability-registry.cjs'); const HOST = '1.6.0'; +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + +/** Create a FIFO at `fifoPath` via `mkfifo`, throwing on failure. */ +function mkfifo(fifoPath) { + const r = runHook(fifoPath, [], { interpreter: 'mkfifo', timeoutMs: PROBE_TIMEOUT_MS }); + throwIfFailed(r, `mkfifo ${fifoPath}`); +} + function featureCap(id, extra) { return { id, role: 'feature', version: '1.0.0', title: id, description: 'overlay cap', @@ -598,8 +609,7 @@ describe('loadRegistry — project-scope consent gate (#1459)', () => { const { proj, home, writeCap } = projectFixture(); t.after(() => { cleanup(proj); cleanup(home); }); writeCap(featureCap('fifo-ledger-cap', { skills: ['fifo-skill'] })); - const { execFileSync } = require('node:child_process'); - execFileSync('mkfifo', [path.join(proj, '.gsd-capabilities.json')]); + mkfifo(path.join(proj, '.gsd-capabilities.json')); let reg; assert.doesNotThrow(() => { reg = loadRegistry({ includeInstalled: true, gsdHome: home, cwd: proj, hostVersion: HOST }); }); assert.ok(!reg.capabilities || !reg.capabilities['fifo-ledger-cap'], 'FIFO ledger → no committed ids → inactive'); @@ -796,8 +806,7 @@ describe('loadRegistry — project-scope consent gate (#1459)', () => { t.after(() => cleanup(home)); const dir = path.join(home, '.gsd', 'capabilities', 'fifo-manifest'); fs.mkdirSync(dir, { recursive: true }); - const { execFileSync } = require('node:child_process'); - execFileSync('mkfifo', [path.join(dir, 'capability.json')]); + mkfifo(path.join(dir, 'capability.json')); // Co-located GLOBAL committed ledger so the cap is on the hot path (committed → manifest read reached). fs.writeFileSync(path.join(home, '.gsd-capabilities.json'), JSON.stringify({ version: '1', updatedAt: '2026-01-01T00:00:00Z', entries: { 'fifo-manifest': { id: 'fifo-manifest', version: '1.0.0', source: 's', integrity: '', files: [], sharedEdits: [] } } }), 'utf8'); let reg; @@ -866,8 +875,7 @@ describe('loadRegistry — convergence: gate-before-materialize + realpath fail- }); const dir = writeCap(cap); // The fragment.path points at a FIFO INSIDE the cap dir (passes the escape guard; only the READ hangs). - const { execFileSync } = require('node:child_process'); - execFileSync('mkfifo', [path.join(dir, 'frag.md')]); + mkfifo(path.join(dir, 'frag.md')); // A committed in-repo ledger marks it committed — but the user never consented HERE. writeProjectLedger(proj, [{ id: 'conv1-fifo-frag', integrity: '' }]); // No consent record written. @@ -892,8 +900,7 @@ describe('loadRegistry — convergence: gate-before-materialize + realpath fail- const dir = path.join(home, '.gsd', 'capabilities', 'conv1b-fifo-frag'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'capability.json'), JSON.stringify(cap), 'utf8'); - const { execFileSync } = require('node:child_process'); - execFileSync('mkfifo', [path.join(dir, 'frag.md')]); + mkfifo(path.join(dir, 'frag.md')); let reg; assert.doesNotThrow(() => { reg = loadRegistry({ includeInstalled: true, gsdHome: home, cwd: home, hostVersion: HOST }); }); assert.ok(!reg.capabilities || !reg.capabilities['conv1b-fifo-frag'], 'a global overlay with a FIFO fragment is inactive (materialize fails closed)'); diff --git a/tests/capability-writer.test.cjs b/tests/capability-writer.test.cjs index 10072d0af..26e3eefb0 100644 --- a/tests/capability-writer.test.cjs +++ b/tests/capability-writer.test.cjs @@ -13,7 +13,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const { spawnSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); const { cleanup } = require('./helpers.cjs'); @@ -299,31 +299,30 @@ describe('capability-writer: setCapabilityState', () => { fs.writeFileSync(path.join(planningDir, 'config.json'), '{}'); const gsdToolsBin = path.resolve(__dirname, '../gsd-core/bin/gsd-tools.cjs'); - const nodeExe = process.execPath; // Test: capability set ui --off (--config-dir rcd) exits 0 - const offResult = spawnSync(nodeExe, [gsdToolsBin, 'capability', 'set', 'ui', '--off', '--config-dir', rcd], { - encoding: 'utf8', + const offResult = runNode([gsdToolsBin, 'capability', 'set', 'ui', '--off', '--config-dir', rcd], { cwd, + timeoutMs: 15000, }); - assert.equal(offResult.status, 0, - `capability set ui --off should exit 0, got ${String(offResult.status)}. stderr: ${offResult.stderr}`); + assert.equal(offResult.exitCode, 0, + `capability set ui --off should exit 0, got ${String(offResult.exitCode)}. stderr: ${offResult.stderr}`); // Test: capability set ui --on (--config-dir rcd) exits 0 - const onResult = spawnSync(nodeExe, [gsdToolsBin, 'capability', 'set', 'ui', '--on', '--config-dir', rcd], { - encoding: 'utf8', + const onResult = runNode([gsdToolsBin, 'capability', 'set', 'ui', '--on', '--config-dir', rcd], { cwd, + timeoutMs: 15000, }); - assert.equal(onResult.status, 0, - `capability set ui --on should exit 0, got ${String(onResult.status)}. stderr: ${onResult.stderr}`); + assert.equal(onResult.exitCode, 0, + `capability set ui --on should exit 0, got ${String(onResult.exitCode)}. stderr: ${onResult.stderr}`); // Test: unknown id exits non-zero - const unknownResult = spawnSync(nodeExe, [gsdToolsBin, 'capability', 'set', 'does-not-exist', '--off', '--config-dir', rcd], { - encoding: 'utf8', + const unknownResult = runNode([gsdToolsBin, 'capability', 'set', 'does-not-exist', '--off', '--config-dir', rcd], { cwd, + timeoutMs: 15000, }); - assert.notEqual(unknownResult.status, 0, - `capability set does-not-exist --off should exit non-zero, got ${String(unknownResult.status)}`); + assert.notEqual(unknownResult.exitCode, 0, + `capability set does-not-exist --off should exit non-zero, got ${String(unknownResult.exitCode)}`); } finally { cleanup(rcd); cleanup(cwd); @@ -495,16 +494,14 @@ describe('capability-writer: setCapabilityState', () => { fs.writeFileSync(path.join(planningDir, 'config.json'), '{}'); const gsdToolsBin = path.resolve(__dirname, '../gsd-core/bin/gsd-tools.cjs'); - const nodeExe = process.execPath; - const conflictResult = spawnSync( - nodeExe, + const conflictResult = runNode( [gsdToolsBin, 'capability', 'set', 'ui', '--on', '--off', '--config-dir', rcd], - { encoding: 'utf8', cwd }, + { cwd, timeoutMs: 15000 }, ); assert.notEqual( - conflictResult.status, 0, - `capability set ui --on --off should exit non-zero, got ${String(conflictResult.status)}. stderr: ${conflictResult.stderr}`, + conflictResult.exitCode, 0, + `capability set ui --on --off should exit non-zero, got ${String(conflictResult.exitCode)}. stderr: ${conflictResult.stderr}`, ); } finally { cleanup(rcd); diff --git a/tests/ci-rebase-check.test.cjs b/tests/ci-rebase-check.test.cjs index da7772ccf..f5a312786 100644 --- a/tests/ci-rebase-check.test.cjs +++ b/tests/ci-rebase-check.test.cjs @@ -26,10 +26,8 @@ const SCRIPT = path.join(ROOT, 'scripts', 'ci-rebase-check.cjs'); const NODE = process.execPath; const { cleanup } = require('./helpers.cjs'); const { gitOrThrow } = require('./helpers/git-fixture.cjs'); - -// 15000ms: git plumbing (init/clone/config/checkout/add/commit) on a small -// mkdtemp fixture repo — far over any observed duration for that class of call. -const GIT_TIMEOUT_MS = 15000; +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); // --------------------------------------------------------------------------- // Helper: run a small inline Node snippet that requires the run() helper diff --git a/tests/codex-config.test.cjs b/tests/codex-config.test.cjs index 04d36e9d9..a48dfd3b1 100644 --- a/tests/codex-config.test.cjs +++ b/tests/codex-config.test.cjs @@ -18,7 +18,8 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('os'); -const { execFileSync } = require('child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { cleanup } = require('./helpers.cjs'); const fc = require('fast-check'); const { CLAUDE_AGENT_ALIASES } = require('../gsd-core/bin/lib/model-resolver.cjs'); @@ -32,12 +33,16 @@ const { CLAUDE_AGENT_ALIASES } = require('../gsd-core/bin/lib/model-resolver.cjs // Build on demand so the test passes regardless of runner ordering. const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); before(() => { if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { - execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { - encoding: 'utf-8', - stdio: 'pipe', - }); + throwIfFailed( + runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }), + `node ${BUILD_HOOKS_SCRIPT}`, + ); } }); @@ -2976,19 +2981,24 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('os'); -const { execFileSync } = require('child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const { install, GSD_CODEX_MARKER } = require(INSTALL_SRC); const { cleanup } = require('./helpers.cjs'); // Ensure hooks/dist/ is populated before install tests before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { - encoding: 'utf-8', - stdio: 'pipe', - }); + throwIfFailed( + runNode([BUILD_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), + `node ${BUILD_SCRIPT}`, + ); }); describe('#2698: CRLF stale gsd-update-check block is removed on Codex reinstall', () => { @@ -4788,7 +4798,8 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('os'); -const { execFileSync } = require('child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { cleanup } = require('./helpers.cjs'); const { parseTomlToObject, validateCodexConfigSchema, install } = require('../bin/install.js'); @@ -4803,9 +4814,16 @@ if (previousGsdTestMode === undefined) { // Ensure hooks/dist/ is populated — mirrors the pattern used by codex-config.test.cjs. const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); before(() => { if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { - execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); + throwIfFailed( + runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), + `node ${BUILD_HOOKS_SCRIPT}`, + ); } }); @@ -5314,7 +5332,8 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('os'); -const { execFileSync } = require('child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { validateCodexConfigSchema, install } = require('../bin/install.js'); const { cleanup } = require('./helpers.cjs'); @@ -5329,9 +5348,16 @@ if (previousGsdTestMode === undefined) { const { before, beforeEach, afterEach } = require('node:test'); const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); before(() => { if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { - execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); + throwIfFailed( + runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), + `node ${BUILD_HOOKS_SCRIPT}`, + ); } }); @@ -6347,13 +6373,18 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { install, uninstall, parseTomlToObject } = require('../bin/install.js'); const { createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs'); const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); function withCodexHome(codexHome, fn) { const prev = process.env.CODEX_HOME; @@ -6399,7 +6430,10 @@ describe('#3427 + #3433 — Codex installer avoids duplicate skills and mixed ho beforeEach(() => { if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { - execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + throwIfFailed( + runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), + `node ${BUILD_HOOKS_SCRIPT}`, + ); } tmpRoot = createTempDir('gsd-3427-3433-'); codexHome = path.join(tmpRoot, '.codex'); @@ -6561,13 +6595,18 @@ const { describe, test, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { install } = require('../bin/install.js'); const { createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs'); const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); function withCodexHome(codexHome, fn) { const prev = process.env.CODEX_HOME; @@ -6601,7 +6640,10 @@ describe('#3562 — Codex install produces discoverable $gsd-* skill surface', { beforeEach(() => { if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { - execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + throwIfFailed( + runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), + `node ${BUILD_HOOKS_SCRIPT}`, + ); } tmpRoot = createTempDir('gsd-3562-'); codexHome = path.join(tmpRoot, '.codex'); @@ -6723,13 +6765,18 @@ const { describe, test, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { install, uninstall, parseTomlToObject } = require('../bin/install.js'); const { createTempDir, cleanup } = require('./helpers.cjs'); const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); function withCodexHome(codexHome, fn) { const prev = process.env.CODEX_HOME; @@ -6776,7 +6823,10 @@ describe('#3566 — Codex feature flag is canonical "hooks" (not legacy "codex_h beforeEach(() => { if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { - execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + throwIfFailed( + runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), + `node ${BUILD_HOOKS_SCRIPT}`, + ); } tmpRoot = createTempDir('gsd-3566-'); codexHome = path.join(tmpRoot, '.codex'); @@ -7445,7 +7495,8 @@ const { describe, test, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { install, @@ -7455,6 +7506,10 @@ const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs'); const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); function withCodexHome(codexHome, fn) { const prev = process.env.CODEX_HOME; @@ -7488,7 +7543,10 @@ describe('#570 — Codex leak scanner sub-bugs', { concurrency: false }, () => { beforeEach(() => { if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { - execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + throwIfFailed( + runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), + `node ${BUILD_HOOKS_SCRIPT}`, + ); } tmpRoot = createTempDir('gsd-570-'); codexHome = path.join(tmpRoot, '.codex'); diff --git a/tests/commit-docs-bypass.test.cjs b/tests/commit-docs-bypass.test.cjs index 7719a192d..83cf6c654 100644 --- a/tests/commit-docs-bypass.test.cjs +++ b/tests/commit-docs-bypass.test.cjs @@ -231,10 +231,8 @@ const fs = require('node:fs'); const path = require('node:path'); const { createTempGitProject, cleanup, runGsdTools } = require('./helpers.cjs'); const { gitOrThrow } = require('./helpers/git-fixture.cjs'); - -// 15000ms: git plumbing (diff/rev-parse) on a small mkdtemp fixture repo — -// far over any observed duration for that class of call. -const GIT_TIMEOUT_MS = 15000; +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); // Repo root resolution. This test file lives in `/tests/`. Use a single // parent reference (the established repo-wide pattern, e.g. tests/helpers.cjs diff --git a/tests/commit-files-deletion.test.cjs b/tests/commit-files-deletion.test.cjs index b8faf9235..1eba8570b 100644 --- a/tests/commit-files-deletion.test.cjs +++ b/tests/commit-files-deletion.test.cjs @@ -15,10 +15,8 @@ const fs = require('fs'); const path = require('path'); const { createTempGitProject, cleanup, runGsdTools } = require('./helpers.cjs'); const { gitOrThrow } = require('./helpers/git-fixture.cjs'); - -// 15000ms: git plumbing (add/commit/diff) on a small mkdtemp fixture repo — -// far over any observed duration for that class of call. -const GIT_TIMEOUT_MS = 15000; +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); describe('commit --files: missing files must not stage deletions (#2014)', () => { let tmpDir; diff --git a/tests/commit-files-pathspec.test.cjs b/tests/commit-files-pathspec.test.cjs index 7c7a8f506..355eccc36 100644 --- a/tests/commit-files-pathspec.test.cjs +++ b/tests/commit-files-pathspec.test.cjs @@ -17,10 +17,8 @@ const fs = require('fs'); const path = require('path'); const { createTempGitProject, cleanup, runGsdTools } = require('./helpers.cjs'); const { gitOrThrow } = require('./helpers/git-fixture.cjs'); - -// 15000ms: git plumbing (add/commit/diff/status/rev-list) on a small mkdtemp -// fixture repo — far over any observed duration for that class of call. -const GIT_TIMEOUT_MS = 15000; +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); describe('commit --files: pathspec honors declared scope (#2112)', () => { let tmpDir; diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs index 1c833298c..c66d1e56b 100644 --- a/tests/copilot-install.test.cjs +++ b/tests/copilot-install.test.cjs @@ -1111,21 +1111,22 @@ describe('Copilot lifecycle hook config (#786)', () => { }); test('executing the bash hook body produces valid sessionStart JSON', { skip: process.platform === 'win32' }, () => { - const { execFileSync } = require('child_process'); const [entry] = buildCopilotHookConfig().hooks.sessionStart; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hook-exec-')); try { // No .planning/STATE.md → absent branch - const outAbsent = execFileSync('bash', ['-c', entry.bash], { cwd: tmp, encoding: 'utf8' }); - const parsedAbsent = JSON.parse(outAbsent); + const rAbsent = runHook('-c', [entry.bash], { interpreter: 'bash', cwd: tmp, timeoutMs: PROBE_TIMEOUT_MS }); + throwIfFailed(rAbsent, 'bash -c (absent branch)'); + const parsedAbsent = JSON.parse(rAbsent.stdout); assert.ok(typeof parsedAbsent.additionalContext === 'string', 'absent branch yields additionalContext string'); assert.ok(/gsd-new-project/.test(parsedAbsent.additionalContext), 'absent branch suggests gsd-new-project'); // With .planning/STATE.md → present branch fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); fs.writeFileSync(path.join(tmp, '.planning', 'STATE.md'), '# state\n'); - const outPresent = execFileSync('bash', ['-c', entry.bash], { cwd: tmp, encoding: 'utf8' }); - const parsedPresent = JSON.parse(outPresent); + const rPresent = runHook('-c', [entry.bash], { interpreter: 'bash', cwd: tmp, timeoutMs: PROBE_TIMEOUT_MS }); + throwIfFailed(rPresent, 'bash -c (present branch)'); + const parsedPresent = JSON.parse(rPresent.stdout); assert.ok(/STATE\.md present/.test(parsedPresent.additionalContext), 'present branch references STATE.md'); } finally { cleanup(tmp); @@ -1347,8 +1348,9 @@ describe('Copilot manifest and patches fixes', () => { // E2E Integration Tests — Copilot Install & Uninstall // ============================================================================ -const { execFileSync } = require('child_process'); const crypto = require('crypto'); +const { runNode, runHook } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const INSTALL_PATH = path.join(__dirname, '..', 'bin', 'install.js'); const EXPECTED_SKILLS = fs.readdirSync(path.join(__dirname, '..', 'commands', 'gsd')) @@ -1356,26 +1358,31 @@ const EXPECTED_SKILLS = fs.readdirSync(path.join(__dirname, '..', 'commands', 'g // Source-roster count (gsd-*.md basenames) — shared helper. const EXPECTED_AGENTS = listAgentFiles().length; +// #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs. +const { PROBE_TIMEOUT_MS, INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + function runCopilotInstall(cwd) { const env = { ...process.env }; delete env.GSD_TEST_MODE; - return execFileSync(process.execPath, [INSTALL_PATH, '--copilot', '--local', '--no-sdk'], { + const r = runNode([INSTALL_PATH, '--copilot', '--local', '--no-sdk'], { cwd, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], env, + timeoutMs: INSTALL_TIMEOUT_MS, }); + throwIfFailed(r, `node ${INSTALL_PATH} --copilot --local --no-sdk`); + return r.stdout; } function runCopilotUninstall(cwd) { const env = { ...process.env }; delete env.GSD_TEST_MODE; - return execFileSync(process.execPath, [INSTALL_PATH, '--copilot', '--local', '--uninstall', '--no-sdk'], { + const r = runNode([INSTALL_PATH, '--copilot', '--local', '--uninstall', '--no-sdk'], { cwd, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], env, + timeoutMs: INSTALL_TIMEOUT_MS, }); + throwIfFailed(r, `node ${INSTALL_PATH} --copilot --local --uninstall --no-sdk`); + return r.stdout; } describe('E2E: Copilot full install verification', () => { @@ -1668,25 +1675,23 @@ describe('E2E: Copilot uninstall verification', () => { function runCopilotInstallGlobal(cwd, configDir) { const env = { ...process.env }; delete env.GSD_TEST_MODE; - return execFileSync(process.execPath, - [INSTALL_PATH, '--copilot', '--global', '--config-dir', configDir, '--no-sdk'], { - cwd, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - env, - }); + const r = runNode( + [INSTALL_PATH, '--copilot', '--global', '--config-dir', configDir, '--no-sdk'], + { cwd, env, timeoutMs: INSTALL_TIMEOUT_MS }, + ); + throwIfFailed(r, `node ${INSTALL_PATH} --copilot --global --config-dir ${configDir} --no-sdk`); + return r.stdout; } function runCopilotUninstallGlobal(cwd, configDir) { const env = { ...process.env }; delete env.GSD_TEST_MODE; - return execFileSync(process.execPath, - [INSTALL_PATH, '--copilot', '--global', '--config-dir', configDir, '--uninstall', '--no-sdk'], { - cwd, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - env, - }); + const r = runNode( + [INSTALL_PATH, '--copilot', '--global', '--config-dir', configDir, '--uninstall', '--no-sdk'], + { cwd, env, timeoutMs: INSTALL_TIMEOUT_MS }, + ); + throwIfFailed(r, `node ${INSTALL_PATH} --copilot --global --config-dir ${configDir} --uninstall --no-sdk`); + return r.stdout; } describe('E2E: Copilot global install (#786)', () => { @@ -1731,23 +1736,25 @@ describe('E2E: Copilot global install (#786)', () => { function runClaudeInstall(cwd) { const env = { ...process.env }; delete env.GSD_TEST_MODE; - return execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local', '--no-sdk'], { + const r = runNode([INSTALL_PATH, '--claude', '--local', '--no-sdk'], { cwd, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], env, + timeoutMs: INSTALL_TIMEOUT_MS, }); + throwIfFailed(r, `node ${INSTALL_PATH} --claude --local --no-sdk`); + return r.stdout; } function runClaudeUninstall(cwd) { const env = { ...process.env }; delete env.GSD_TEST_MODE; - return execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local', '--uninstall', '--no-sdk'], { + const r = runNode([INSTALL_PATH, '--claude', '--local', '--uninstall', '--no-sdk'], { cwd, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], env, + timeoutMs: INSTALL_TIMEOUT_MS, }); + throwIfFailed(r, `node ${INSTALL_PATH} --claude --local --uninstall --no-sdk`); + return r.stdout; } describe('Claude uninstall preserves user-generated files (#1423)', () => { diff --git a/tests/declarative-reference-antigravity.test.cjs b/tests/declarative-reference-antigravity.test.cjs index 13642f8a6..7803f3d2d 100644 --- a/tests/declarative-reference-antigravity.test.cjs +++ b/tests/declarative-reference-antigravity.test.cjs @@ -33,7 +33,8 @@ const { test, before } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { profileOf, @@ -50,9 +51,12 @@ const DESC = path.join(__dirname, '..', 'capabilities', 'antigravity', 'capabili const ANTIGRAVITY_CAP = JSON.parse(fs.readFileSync(DESC, 'utf8')); const ANTIGRAVITY_AXES = ANTIGRAVITY_CAP.runtime.hostIntegration; +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + // hooks/dist is gitignored and built (mirrors golden-install-parity harness). before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); + throwIfFailed(runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }), `node ${BUILD_SCRIPT}`); }); test('Antigravity classifies as the declarative-cli reference profile (profileOf)', () => { diff --git a/tests/declarative-reference-augment.test.cjs b/tests/declarative-reference-augment.test.cjs index 68bfe52e9..4b33ab2cf 100644 --- a/tests/declarative-reference-augment.test.cjs +++ b/tests/declarative-reference-augment.test.cjs @@ -21,7 +21,8 @@ const { test, before } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { profileOf, @@ -36,9 +37,13 @@ const DESC = path.join(__dirname, '..', 'capabilities', 'augment', 'capability.j const AUGMENT_CAP = JSON.parse(fs.readFileSync(DESC, 'utf8')); const AUGMENT_AXES = AUGMENT_CAP.runtime.hostIntegration; +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + // hooks/dist is gitignored and built (mirrors golden-install-parity harness). before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); + const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); + throwIfFailed(r, `node ${BUILD_SCRIPT}`); }); test('Augment classifies as the declarative-cli profile (profileOf)', () => { diff --git a/tests/declarative-reference-codebuddy.test.cjs b/tests/declarative-reference-codebuddy.test.cjs index e5ba1c395..da0d0a7ed 100644 --- a/tests/declarative-reference-codebuddy.test.cjs +++ b/tests/declarative-reference-codebuddy.test.cjs @@ -38,7 +38,8 @@ const { test, before } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { profileOf, @@ -54,10 +55,17 @@ const { walk, runMinimalInstall, BUILD_SCRIPT } = require('./helpers/install-sha const DESC = path.join(__dirname, '..', 'capabilities', 'codebuddy', 'capability.json'); const CODEBUDDY_CAP = JSON.parse(fs.readFileSync(DESC, 'utf8')); const CODEBUDDY_AXES = CODEBUDDY_CAP.runtime.hostIntegration; +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); // hooks/dist is gitignored and built (mirrors golden-install-parity harness). before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); + throwIfFailed( + runNode([BUILD_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), + `node ${BUILD_SCRIPT}`, + ); }); test('CodeBuddy classifies as the declarative-cli reference profile (profileOf)', () => { diff --git a/tests/declarative-reference-copilot.test.cjs b/tests/declarative-reference-copilot.test.cjs index 91bc9ffc3..0e0c7297e 100644 --- a/tests/declarative-reference-copilot.test.cjs +++ b/tests/declarative-reference-copilot.test.cjs @@ -43,7 +43,8 @@ const { test, before } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { profileOf, @@ -60,9 +61,12 @@ const DESC = path.join(__dirname, '..', 'capabilities', 'copilot', 'capability.j const COPILOT_CAP = JSON.parse(fs.readFileSync(DESC, 'utf8')); const COPILOT_AXES = COPILOT_CAP.runtime.hostIntegration; +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + // hooks/dist is gitignored and built (mirrors golden-install-parity harness). before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); + throwIfFailed(runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }), `node ${BUILD_SCRIPT}`); }); test('Copilot classifies as the declarative-cli reference profile (profileOf)', () => { diff --git a/tests/declarative-reference-windsurf.test.cjs b/tests/declarative-reference-windsurf.test.cjs index 3871c1334..f6ec2020c 100644 --- a/tests/declarative-reference-windsurf.test.cjs +++ b/tests/declarative-reference-windsurf.test.cjs @@ -40,7 +40,8 @@ const { test, before } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { profileOf, @@ -61,9 +62,13 @@ const DESC = path.join(__dirname, '..', 'capabilities', 'windsurf', 'capability. const WINDSURF_CAP = JSON.parse(fs.readFileSync(DESC, 'utf8')); const WINDSURF_AXES = WINDSURF_CAP.runtime.hostIntegration; +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + // hooks/dist is gitignored and built (mirrors golden-install-parity harness). before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); + const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); + throwIfFailed(r, `node ${BUILD_SCRIPT}`); }); test('Windsurf classifies as the declarative-cli reference profile (profileOf)', () => { diff --git a/tests/declarative-reference-zcode.test.cjs b/tests/declarative-reference-zcode.test.cjs index 20e7dbf08..17a413ebd 100644 --- a/tests/declarative-reference-zcode.test.cjs +++ b/tests/declarative-reference-zcode.test.cjs @@ -35,7 +35,8 @@ const { test, before } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { profileOf, @@ -51,10 +52,17 @@ const { walk, runMinimalInstall, BUILD_SCRIPT } = require('./helpers/install-sha const DESC = path.join(__dirname, '..', 'capabilities', 'zcode', 'capability.json'); const ZCODE_CAP = JSON.parse(fs.readFileSync(DESC, 'utf8')); const ZCODE_AXES = ZCODE_CAP.runtime.hostIntegration; +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); // hooks/dist is gitignored and built (mirrors golden-install-parity harness). before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); + throwIfFailed( + runNode([BUILD_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), + `node ${BUILD_SCRIPT}`, + ); }); test('ZCode classifies as the declarative-cli reference profile (profileOf)', () => { diff --git a/tests/effort-sync-installed-runtime.test.cjs b/tests/effort-sync-installed-runtime.test.cjs index 34c0ecbd8..3bf710c00 100644 --- a/tests/effort-sync-installed-runtime.test.cjs +++ b/tests/effort-sync-installed-runtime.test.cjs @@ -21,7 +21,7 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); const { runMinimalInstall } = require('./helpers/install-shared.cjs'); const { cleanup } = require('./helpers.cjs'); @@ -44,16 +44,11 @@ describe('#2071: effort sync runs in an installed runtime (no package-root bin/i ); const gsdTools = path.join(configDir, 'gsd-core', 'bin', 'gsd-tools.cjs'); - let combined = ''; - try { - combined = execFileSync( - process.execPath, - [gsdTools, 'effort', 'sync', '--config-dir', configDir], - { cwd: root, encoding: 'utf-8', env: { ...process.env, HOME: root } }, - ); - } catch (e) { - combined = `${e.stdout || ''}${e.stderr || ''}${e.message || ''}`; - } + const result = runNode( + [gsdTools, 'effort', 'sync', '--config-dir', configDir], + { cwd: root, env: { ...process.env, HOME: root }, timeoutMs: 15000 }, + ); + const combined = `${result.stdout || ''}${result.stderr || ''}`; assert.doesNotMatch( combined, diff --git a/tests/emitted-caps-gate.test.cjs b/tests/emitted-caps-gate.test.cjs index aec2774e5..63576e5e7 100644 --- a/tests/emitted-caps-gate.test.cjs +++ b/tests/emitted-caps-gate.test.cjs @@ -24,7 +24,8 @@ const { test, before, after } = require('node:test'); const assert = require('node:assert/strict'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { cleanup } = require('./helpers.cjs'); const { @@ -34,11 +35,15 @@ const { } = require('./helpers/install-shared.cjs'); const { evaluateEmittedCaps, formatCapReport } = require('./helpers/emitted-caps.cjs'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + // hooks/dist is gitignored and built (DEFECT.HOOKS-DIST-SCOPED-CI). Build it // idempotently before the shared real-install fixture, mirroring // tests/emitted-sizes.test.cjs. before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); + const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); + throwIfFailed(r, `node ${BUILD_SCRIPT}`); }); // ─── Shared real LOCAL windsurf install, built once ─────────────────────────── diff --git a/tests/ensure-runtime-build.test.cjs b/tests/ensure-runtime-build.test.cjs index 2716ea39c..ed87d82b4 100644 --- a/tests/ensure-runtime-build.test.cjs +++ b/tests/ensure-runtime-build.test.cjs @@ -16,7 +16,8 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { createTempDir, cleanup } = require('./helpers.cjs'); const { @@ -28,6 +29,10 @@ const { } = require('../gsd-core/bin/ensure-runtime-build.cjs'); const REPO_ROOT = path.resolve(__dirname, '..'); +// 180000: a build — this driver invokes ensureRuntimeBuild against a REAL +// tsc compile (not a query), at the tsc-compile end of the +// 120000-180000 build-class norm. +const BUILD_TIMEOUT_MS = 180000; /** Fresh isolated package dir (auto-cleaned) with a libDir and optional tsconfig. */ function makeEnv(t, { withTsconfig = true, prebuilt = false } = {}) { @@ -333,8 +338,9 @@ test('integration: real tsc heals a missing lib end-to-end (subprocess)', (t) => ); assert.ok(!isBuilt(path.join(dir, 'gsd-core', 'bin', 'lib')), 'lib starts empty'); - const out = execFileSync(process.execPath, [driver], { encoding: 'utf8', cwd: dir }); - const parsed = JSON.parse(out); + const driverResult = runNode([driver], { cwd: dir, timeoutMs: BUILD_TIMEOUT_MS }); + throwIfFailed(driverResult, `node ${driver}`); + const parsed = JSON.parse(driverResult.stdout); assert.equal(parsed.healed, true, 'self-healed the missing build'); assert.equal(parsed.run, 'ok', 'the freshly-compiled cli-exit.cjs loads and runs'); }); diff --git a/tests/fragment-single-edit-propagation.install.test.cjs b/tests/fragment-single-edit-propagation.install.test.cjs index fa3a03398..5d02d6e93 100644 --- a/tests/fragment-single-edit-propagation.install.test.cjs +++ b/tests/fragment-single-edit-propagation.install.test.cjs @@ -55,6 +55,8 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { spawnSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { gitOrThrow } = require('./helpers/git-fixture.cjs'); const { cleanup, readFileNormalized } = require('./helpers.cjs'); const { RUNTIME_META, installerEnv } = require('./helpers/install-shared.cjs'); @@ -215,11 +217,12 @@ function installOverlay(overlayRoot, runtime, extraArgs = []) { root, ...extraArgs, ]; - const result = spawnSync(process.execPath, args, { + const result = runNode(args, { cwd: root, - encoding: 'utf8', env: installerEnv({ HOME: root, USERPROFILE: root }), + timeoutMs: 120000, }); + result.status = result.exitCode; assert.equal( result.status, 0, @@ -250,11 +253,12 @@ function installOverlayExpectingFailure(overlayRoot, runtime, extraArgs = []) { root, ...extraArgs, ]; - const result = spawnSync(process.execPath, args, { + const result = runNode(args, { cwd: root, - encoding: 'utf8', env: installerEnv({ HOME: root, USERPROFILE: root }), + timeoutMs: 120000, }); + result.status = result.exitCode; return { configDir: root, root, result }; } @@ -268,11 +272,13 @@ function installOverlayExpectingFailure(overlayRoot, runtime, extraArgs = []) { */ function runOverlayCheck(overlayRoot, scriptRelPath, extraArgs = []) { const scriptPath = path.join(overlayRoot, ...scriptRelPath.split('/')); - return spawnSync(process.execPath, [scriptPath, '--check', ...extraArgs], { + const result = runNode([scriptPath, '--check', ...extraArgs], { cwd: overlayRoot, - encoding: 'utf8', env: installerEnv(), + timeoutMs: 120000, }); + result.status = result.exitCode; + return result; } /** @@ -284,11 +290,13 @@ function runOverlayCheck(overlayRoot, scriptRelPath, extraArgs = []) { */ function runOverlayGenerate(overlayRoot, scriptRelPath) { const scriptPath = path.join(overlayRoot, ...scriptRelPath.split('/')); - return spawnSync(process.execPath, [scriptPath], { + const result = runNode([scriptPath], { cwd: overlayRoot, - encoding: 'utf8', env: installerEnv(), + timeoutMs: 120000, }); + result.status = result.exitCode; + return result; } /** @@ -311,14 +319,11 @@ function runOverlayGenerate(overlayRoot, scriptRelPath) { * config (unlike `git config --global --add safe.directory`). */ function trackedFileSet(repoRoot) { - const lsFiles = spawnSync('git', ['-c', 'safe.directory=*', 'ls-files'], { + const stdout = gitOrThrow(['-c', 'safe.directory=*', 'ls-files'], { cwd: repoRoot, - encoding: 'utf8', + timeoutMs: 120000, }); - if (lsFiles.status !== 0) { - throw new Error(`git ls-files must succeed in ${repoRoot}\nstderr: ${lsFiles.stderr}`); - } - return lsFiles.stdout.split('\n').map((line) => line.trim()).filter(Boolean); + return stdout.split('\n').map((line) => line.trim()).filter(Boolean); } /** @@ -1165,7 +1170,13 @@ function isEnvDependentGeneratedPath(rel) { return ENV_DEPENDENT_GENERATED_PATHS.some((prefix) => rel.startsWith(prefix)); } -test('regenDerivedPropagatesSingleFragmentEditWithNoSecondSourceSurface', (t) => { +test('regenDerivedPropagatesSingleFragmentEditWithNoSecondSourceSurface', { + skip: process.platform === 'win32' + ? 'regen:derived (full build + 8 generators) is bounded at 900000ms, which exceeds the ' + + '600000ms per-chunk CI budget — the chunk killer always fires first, so this can never ' + + 'complete on the Windows lane. Covered on the Linux lanes. See #3145.' + : false, +}, (t) => { // 1. COPY-mode overlay — the whole point of this row over rows 1-20: every // leaf is a real independent inode (see overlay-repo.cjs's opts.mode doc), // so the REAL `--write` chain below can run to completion without ever @@ -1187,6 +1198,10 @@ test('regenDerivedPropagatesSingleFragmentEditWithNoSecondSourceSurface', (t) => // completed run on a loaded bench (linux-node22), not from a real hang. // 900_000 (15min) is deliberately generous so this can never again flake // on load while still catching a true hang. + // allow-spawn-timeout-ceiling: regen:derived is a full build plus eight + // generators; 300_000 was observed killing a genuinely-completed run + // near the end on a loaded bench, not a real hang, so 900_000 is + // deliberately above the 600000 ceiling to never repeat that flake. timeout: 900000, maxBuffer: 64 * 1024 * 1024, }); diff --git a/tests/gemini-runtime-removed.test.cjs b/tests/gemini-runtime-removed.test.cjs index ef5c5ad0c..b61889836 100644 --- a/tests/gemini-runtime-removed.test.cjs +++ b/tests/gemini-runtime-removed.test.cjs @@ -22,7 +22,9 @@ const { describe, test, before } = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); const fs = require('node:fs'); -const { spawnSync, execFileSync } = require('node:child_process'); +const { spawnSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { createTempDir, cleanup } = require('./helpers.cjs'); const { runMinimalInstall, BUILD_SCRIPT } = require('./helpers/install-shared.cjs'); @@ -30,10 +32,13 @@ const { runMinimalInstall, BUILD_SCRIPT } = require('./helpers/install-shared.cj const ROOT = path.join(__dirname, '..'); const INSTALL_JS = path.join(ROOT, 'bin', 'install.js'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + // hooks/dist is gitignored + built; build it idempotently so a real install // emits hooks (mirrors golden-install-parity / install-minimal-hooks). before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { stdio: 'pipe' }); + throwIfFailed(runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }), `node ${BUILD_SCRIPT}`); }); const { diff --git a/tests/git-base-branch.test.cjs b/tests/git-base-branch.test.cjs index 9f0b6ffc4..bdc24cbe9 100644 --- a/tests/git-base-branch.test.cjs +++ b/tests/git-base-branch.test.cjs @@ -34,13 +34,8 @@ const { makeFaultyGit } = require('./helpers/faulty-deps.cjs'); const { gitOrThrow, throwIfFailed } = require('./helpers/git-fixture.cjs'); const { runHook } = require('./helpers/process-seam.cjs'); -/** - * Bound for every subprocess in this file: git plumbing (init/config/add/ - * commit/clone/symbolic-ref/remote/branch) against small mkdtemp fixture - * repos, plus the one short handle_branching bash-script run below — all - * orders of magnitude under this. #3144. - */ -const GIT_TIMEOUT_MS = 15000; +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); // ─── helpers ────────────────────────────────────────────────────────────────── diff --git a/tests/golden-install-tree.test.cjs b/tests/golden-install-tree.test.cjs index e2a145ce1..2a73bcde7 100644 --- a/tests/golden-install-tree.test.cjs +++ b/tests/golden-install-tree.test.cjs @@ -23,17 +23,22 @@ const { test, before } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { cleanup } = require('./helpers.cjs'); const { RUNTIME_META, runMinimalInstall, BUILD_SCRIPT, buildInstallTree } = require('./helpers/install-shared.cjs'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + // hooks/dist is gitignored and built (DEFECT.HOOKS-DIST-SCOPED-CI). The scoped // CI test lane does not run build:hooks, so a real install there emits no hooks/ // dir — making the harness report "removed (N) hooks/…". Build it idempotently // here so the harness is lane-independent (mirrors golden-install-parity.test.cjs). before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); + const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); + throwIfFailed(r, `node ${BUILD_SCRIPT}`); }); const UPDATE = process.env.UPDATE_INSTALL_TREE === '1'; diff --git a/tests/helpers/install-shared.cjs b/tests/helpers/install-shared.cjs index 15d1a18b5..b4c5ce580 100644 --- a/tests/helpers/install-shared.cjs +++ b/tests/helpers/install-shared.cjs @@ -17,9 +17,10 @@ const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const crypto = require('node:crypto'); -const { spawnSync } = require('node:child_process'); const assert = require('node:assert/strict'); +const { runNode } = require('./process-seam.cjs'); + const { resolveRuntimeArtifactLayout, } = require('../../gsd-core/bin/lib/runtime-artifact-layout.cjs'); @@ -27,6 +28,12 @@ const { const INSTALL_SCRIPT = path.join(__dirname, '..', '..', 'bin', 'install.js'); const MANIFEST_NAME = 'gsd-file-manifest.json'; +// #3145: class-norm timeout (bounds the previously-unbounded spawnSync in +// runMinimalInstall) — not a per-suite value. See helpers/timeouts.cjs for +// the justification; every one of install-shared.cjs's 37+ importers +// inherits this value, so it must stay generous rather than tight. +const { INSTALL_TIMEOUT_MS } = require('./timeouts.cjs'); + const BUILD_SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'build-hooks.js'); const HOOKS_DIST = path.join(__dirname, '..', '..', 'hooks', 'dist'); @@ -560,12 +567,24 @@ function runMinimalInstall({ runtime, scope, extraArgs = [], installScript = INS configDir = runtime === 'cline' ? root : path.join(root, LOCAL_DIR_NAME[runtime]); } args.push(...extraArgs); - const result = spawnSync(process.execPath, args, { - cwd, encoding: 'utf8', + const result = runNode(args, { + cwd, env: installerEnv({ HOME: root, USERPROFILE: root, ...extraEnv }), + timeoutMs: INSTALL_TIMEOUT_MS, }); - assert.strictEqual(result.status, 0, - `installer exited with status ${result.status} for ${runtime} --${scope}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); + // Kept as a hand-rolled assert (rather than throwIfFailed from + // git-fixture.cjs) so the embedded stdout+stderr survives verbatim — + // that is the whole diagnostic value of this message for a failing + // install, and throwIfFailed's message only carries a trimmed stderr. + // `result.exitCode` (never `result.status` — the seam has no such key) + // is `null` for a non-EXITED outcome (TIMED_OUT/KILLED/BUFFER_OVERFLOW/ + // SPAWN_FAILED), so `outcome`/`timedOut`/`signal` are folded into the + // message too: a bare "expected null to equal 0" would not tell anyone + // the installer never actually exited. + assert.strictEqual(result.exitCode, 0, + `installer exited with status ${result.exitCode} (outcome=${result.outcome}` + + `${result.timedOut ? ', timedOut=true' : ''}${result.signal ? `, signal=${result.signal}` : ''}) ` + + `for ${runtime} --${scope}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); const manifestPath = path.join(configDir, MANIFEST_NAME); const manifest = fs.existsSync(manifestPath) ? JSON.parse(fs.readFileSync(manifestPath, 'utf8')) diff --git a/tests/helpers/timeouts.cjs b/tests/helpers/timeouts.cjs new file mode 100644 index 000000000..5414c7600 --- /dev/null +++ b/tests/helpers/timeouts.cjs @@ -0,0 +1,63 @@ +'use strict'; + +/** + * Shared CLASS-NORM subprocess timeouts for the test suite (#3145 pre-PR + * review finding). + * + * These four values are not per-suite fixture bindings — each is a fact + * about how long a CLASS of subprocess call takes, derived from observed + * bench behavior. Before this module existed, all four were hand-copied + * across dozens of files with the same justifying comment restated each + * time (52 copies across this wave's diff alone), so the norm could drift + * silently the next time it moved — as `INSTALL_TIMEOUT_MS` already did + * once, from 60000 to 120000, after a real bench `ETIMEDOUT`. Import from + * here instead of re-declaring. + * + * This module is for the shared norms ONLY. A call site that genuinely + * differs from its class (e.g. a real `tsc` compile in + * tests/ensure-runtime-build.test.cjs, or a `regen:derived` run in + * tests/fragment-single-edit-propagation.install.test.cjs) keeps its own + * local constant with its own justifying comment — do not force those + * sites onto a shared value that doesn't describe them. + */ + +const { DEFAULT_GIT_TIMEOUT_MS } = require('./git-fixture.cjs'); + +/** + * A single short CLI query or `node -e` probe against a temp fixture — + * e.g. reading back a version string or a small piece of emitted state. + * 15000ms is well over any observed duration for that class of call. + */ +const PROBE_TIMEOUT_MS = 15000; + +/** + * Git plumbing (rev-parse, branch, log, ...) against a small mkdtemp + * fixture repo. Re-exports `tests/helpers/git-fixture.cjs`'s + * `DEFAULT_GIT_TIMEOUT_MS` rather than restating the literal, so the two + * can never disagree. + */ +const GIT_TIMEOUT_MS = DEFAULT_GIT_TIMEOUT_MS; + +/** + * Hooks bundling via `scripts/build-hooks.js` (not a full project build — + * see per-site comments for sites that run a heavier build and therefore + * keep a larger local value). 30000ms is well over any observed duration + * for a hooks-only bundle pass. + */ +const BUILD_TIMEOUT_MS = 30000; + +/** + * A full `bin/install.js` run. Idle runs measure 13-30s; a load-tested + * bench recorded a real `spawnSync ETIMEDOUT` at a 60000ms cap + * (tests/install.test.cjs:5505-5513) while another lane passed the SAME + * commit in 12.7s — 60000 is too tight for this class of spawn under + * load. 120000ms is the load-tested norm. + */ +const INSTALL_TIMEOUT_MS = 120000; + +module.exports = { + PROBE_TIMEOUT_MS, + GIT_TIMEOUT_MS, + BUILD_TIMEOUT_MS, + INSTALL_TIMEOUT_MS, +}; diff --git a/tests/hooks-opt-in.test.cjs b/tests/hooks-opt-in.test.cjs index e9458d22c..7c07c0e76 100644 --- a/tests/hooks-opt-in.test.cjs +++ b/tests/hooks-opt-in.test.cjs @@ -20,10 +20,12 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('os'); -const { spawnSync } = require('child_process'); +const { runHook } = require('./helpers/process-seam.cjs'); const HOOKS_DIR = path.join(__dirname, '..', 'hooks'); const isWindows = process.platform === 'win32'; +// 15000: a single bash hook script under test, not an install or a build. +const HOOK_TIMEOUT_MS = 15000; // Ensure the running node binary is on PATH so bash hooks can call `node` // (Claude Code shell sessions do not have `node` on PATH). @@ -33,8 +35,16 @@ const hookEnv = { }; // Wrapper that always injects hookEnv so bash hooks can find `node`. +// Preserves the legacy spawnSync-shaped return (`status`, `stdout`, `stderr`, +// `signal`) that every call site in this file asserts against. function spawnHook(hookPath, options) { - return spawnSync('bash', [hookPath], { ...options, env: hookEnv }); + const r = runHook(hookPath, [], { + ...options, + interpreter: 'bash', + env: hookEnv, + timeoutMs: HOOK_TIMEOUT_MS, + }); + return { status: r.exitCode, stdout: r.stdout, stderr: r.stderr, signal: r.signal }; } // ─── Helpers ──────────────────────────────────────────────────────────────── diff --git a/tests/install-minimal-hooks.test.cjs b/tests/install-minimal-hooks.test.cjs index cc4d2b091..36cba1f67 100644 --- a/tests/install-minimal-hooks.test.cjs +++ b/tests/install-minimal-hooks.test.cjs @@ -28,7 +28,9 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); -const { spawnSync, execFileSync } = require('node:child_process'); + +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { createTempDir, cleanup } = require('./helpers.cjs'); @@ -115,10 +117,9 @@ describe('install-profiles: MINIMAL_SKILL_ALLOWLIST', () => { describe('install: --help profile counts match PROFILES (#834)', () => { function helpText() { - return execFileSync(process.execPath, [INSTALL_SCRIPT, '--help'], { - encoding: 'utf8', - env: installerEnv(), - }); + const r = runNode([INSTALL_SCRIPT, '--help'], { env: installerEnv(), timeoutMs: 15000 }); + throwIfFailed(r, `node ${INSTALL_SCRIPT} --help`); + return r.stdout; } test('core line advertises PROFILES.core.length main-loop skills', () => { @@ -442,10 +443,9 @@ describe('install: manifest records mode for both profiles', () => { function manifestModeAfterInstall(extraArgs) { const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-manifest-mode-')); try { - spawnSync( - process.execPath, + runNode( [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', targetDir, ...extraArgs], - { encoding: 'utf8', env: installerEnv() }, + { env: installerEnv(), timeoutMs: 120000 }, ); const manifestPath = path.join(targetDir, MANIFEST_NAME); if (!fs.existsSync(manifestPath)) return { mode: '', skillCount: 0, agentCount: 0 }; @@ -496,10 +496,9 @@ describe('install-minimal-backcompat: --minimal and --profile=core produce same function installAndGetManifest(extraArgs) { const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-backcompat-')); try { - spawnSync( - process.execPath, + runNode( [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', targetDir, ...extraArgs], - { encoding: 'utf8', env: installerEnv() }, + { env: installerEnv(), timeoutMs: 120000 }, ); const manifestPath = path.join(targetDir, MANIFEST_NAME); if (!fs.existsSync(manifestPath)) return { mode: null, skillCount: 0, profileMarker: null }; @@ -573,10 +572,9 @@ describe('install: Codex full → minimal downgrade cleans stale agent state', ( // unsandboxed spawn here would write gsd-* skill dirs into the developer's // real $HOME/.agents/skills. This test only asserts on agents/ and // config.toml (both under targetDir), so the sandbox has no effect on intent. - const result = spawnSync( - process.execPath, + const result = runNode( [INSTALL_SCRIPT, '--codex', '--global', '--config-dir', targetDir, '--minimal'], - { encoding: 'utf8', env: installerEnv({ HOME: targetDir, USERPROFILE: targetDir }) }, + { env: installerEnv({ HOME: targetDir, USERPROFILE: targetDir }), timeoutMs: 120000 }, ); assert.ok(result.stdout || result.stderr); @@ -611,10 +609,9 @@ describe('install: Claude full → minimal downgrade removes stale agents', () = fs.writeFileSync(path.join(agentsDir, 'gsd-planner.md'), 'stale\n'); fs.writeFileSync(path.join(agentsDir, 'my-custom-agent.md'), 'user owns this\n'); - spawnSync( - process.execPath, + runNode( [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', targetDir, '--minimal'], - { encoding: 'utf8', env: installerEnv() }, + { env: installerEnv(), timeoutMs: 120000 }, ); const remaining = fs.existsSync(agentsDir) ? fs.readdirSync(agentsDir) : []; @@ -630,8 +627,11 @@ describe('install: Claude full → minimal downgrade removes stale agents', () = // ─── Section 13: Hooks copy, manifest, uninstall settings cleanup ───────────── +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { BUILD_TIMEOUT_MS: SECTION13_BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); + throwIfFailed(runNode([BUILD_SCRIPT], { timeoutMs: SECTION13_BUILD_TIMEOUT_MS }), `node ${BUILD_SCRIPT}`); }); const isWindows = process.platform === 'win32'; @@ -701,13 +701,12 @@ describe('#1821/#2305: ZCode receives no dead hook files; Kilo/OpenCode/Claude k function installAndCollect(runtime, opts = {}) { const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-1821-${runtime}-`)); try { - const result = spawnSync( - process.execPath, + const result = runNode( [INSTALL_SCRIPT, `--${runtime}`, '--global', '--config-dir', targetDir], - { encoding: 'utf8', env: installerEnv() }, + { env: installerEnv(), timeoutMs: 120000 }, ); - assert.strictEqual(result.status, 0, - `installer exited with status ${result.status} for --${runtime} --global\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); + assert.strictEqual(result.exitCode, 0, + `installer exited with status ${result.exitCode} for --${runtime} --global\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); // Collect results while targetDir still exists — cleanup() below removes it. const pluginRelPath = opts.pluginRelPath || path.join('plugins', 'gsd-core.js'); // #3023: the shared hooks bundle's staged directory name is per-runtime @@ -1238,16 +1237,20 @@ const { test, describe, before } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const REPO_ROOT = path.resolve(__dirname, '..'); const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist'); const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + /** Idempotently ensure hooks/dist contains built .js files. */ function ensureHooksDist() { if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) { - execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + throwIfFailed(runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }), `node ${BUILD_HOOKS_SCRIPT}`); } } @@ -1677,7 +1680,8 @@ const { test, describe, before, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { install } = require('../bin/install.js'); const { createTempDir, cleanup } = require('./helpers.cjs'); @@ -1694,13 +1698,16 @@ const REPO_ROOT = path.resolve(__dirname, '..'); const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist'); const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + /** * Idempotently ensure hooks/dist contains built .js files. * Runs build-hooks.js only when the directory is absent or empty of .js files. */ function ensureHooksDist() { if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) { - execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + throwIfFailed(runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }), `node ${BUILD_HOOKS_SCRIPT}`); } } @@ -2989,16 +2996,20 @@ const { test, describe, before } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const REPO_ROOT = path.resolve(__dirname, '..'); const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist'); const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + /** Idempotently ensure hooks/dist contains built .js files. */ function ensureHooksDist() { if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) { - execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + throwIfFailed(runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }), `node ${BUILD_HOOKS_SCRIPT}`); } } @@ -3428,7 +3439,8 @@ const { test, describe, before, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { install } = require('../bin/install.js'); const { createTempDir, cleanup } = require('./helpers.cjs'); @@ -3445,13 +3457,16 @@ const REPO_ROOT = path.resolve(__dirname, '..'); const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist'); const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + /** * Idempotently ensure hooks/dist contains built .js files. * Runs build-hooks.js only when the directory is absent or empty of .js files. */ function ensureHooksDist() { if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) { - execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + throwIfFailed(runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }), `node ${BUILD_HOOKS_SCRIPT}`); } } @@ -4097,12 +4112,16 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('os'); -const { execFileSync } = require('child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); const isWindows = process.platform === 'win32'; +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + const SH_HOOKS = [ 'gsd-session-state.sh', 'gsd-validate-commit.sh', @@ -4112,10 +4131,7 @@ const SH_HOOKS = [ // ─── Ensure hooks/dist/ is populated before any install test ──────────────── before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { - encoding: 'utf-8', - stdio: 'pipe', - }); + throwIfFailed(runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }), `node ${BUILD_SCRIPT}`); }); // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -4138,14 +4154,16 @@ function runInstaller(configDir) { // --no-sdk: this test covers hook deployment only; skip SDK build to avoid // flakiness and keep the test fast (SDK install path has dedicated coverage // in install-smoke.yml). - execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], { - encoding: 'utf-8', - stdio: 'pipe', - env: { - ...process.env, - CLAUDE_CONFIG_DIR: configDir, - }, - }); + throwIfFailed( + runNode([INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], { + timeoutMs: 120000, + env: { + ...process.env, + CLAUDE_CONFIG_DIR: configDir, + }, + }), + `node ${INSTALL_SCRIPT} --claude --global --yes --no-sdk`, + ); return path.join(configDir, 'hooks'); } diff --git a/tests/install-regressions.test.cjs b/tests/install-regressions.test.cjs index 15d0cd822..338680779 100644 --- a/tests/install-regressions.test.cjs +++ b/tests/install-regressions.test.cjs @@ -18,7 +18,8 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { spawnSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { createTempDir, cleanup } = require('./helpers.cjs'); const { @@ -45,6 +46,8 @@ const { } = require('../gsd-core/bin/lib/install-engine.cjs'); const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const HOOKS_SRC = path.join(__dirname, '..', 'hooks'); const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR); @@ -83,16 +86,15 @@ describe('#2429 regression: Codex local skills stay project-scoped', () => { delete env.GSD_TEST_MODE; delete env.CODEX_HOME; - const result = spawnSync( - process.execPath, + const result = runNode( [INSTALL_SCRIPT, '--codex', '--local', '--profile=core'], - { cwd: projectDir, encoding: 'utf8', env, timeout: 60_000 }, + { cwd: projectDir, env, timeoutMs: 60_000 }, ); assert.strictEqual( - result.status, + result.exitCode, 0, - `installer exited ${result.status}\n${result.stdout}\n${result.stderr}`, + `installer exited ${result.exitCode}\n${result.stdout}\n${result.stderr}`, ); assert.ok( fs.existsSync(path.join(projectDir, '.codex', 'skills', 'gsd-help', 'SKILL.md')), @@ -156,14 +158,13 @@ describe('Defect #2 regression (Qwen, #3664): --qwen --profile=core writes skill const root = createTempDir('gsd-qwen-reg2-'); t.after(() => cleanup(root)); - const result = spawnSync( - process.execPath, + const result = runNode( [INSTALL_SCRIPT, '--qwen', '--global', '--config-dir', root, '--profile=core'], - { encoding: 'utf8', env: { ...process.env, HOME: root, USERPROFILE: root } }, + { env: { ...process.env, HOME: root, USERPROFILE: root }, timeoutMs: INSTALL_TIMEOUT_MS }, ); - assert.strictEqual(result.status, 0, - `installer exited ${result.status}\n${result.stdout}\n${result.stderr}`); + assert.strictEqual(result.exitCode, 0, + `installer exited ${result.exitCode}\n${result.stdout}\n${result.stderr}`); const qwenSkillsDir = path.join(root, 'skills'); assert.ok(fs.existsSync(qwenSkillsDir)); @@ -189,14 +190,13 @@ describe('Defect #2 regression (Hermes, #3664): --hermes --profile=core writes s const root = createTempDir('gsd-hermes-reg2-'); t.after(() => cleanup(root)); - const result = spawnSync( - process.execPath, + const result = runNode( [INSTALL_SCRIPT, '--hermes', '--global', '--config-dir', root, '--profile=core'], - { encoding: 'utf8', env: { ...process.env, HOME: root, USERPROFILE: root } }, + { env: { ...process.env, HOME: root, USERPROFILE: root }, timeoutMs: INSTALL_TIMEOUT_MS }, ); - assert.strictEqual(result.status, 0, - `installer exited ${result.status}\n${result.stdout}\n${result.stderr}`); + assert.strictEqual(result.exitCode, 0, + `installer exited ${result.exitCode}\n${result.stdout}\n${result.stderr}`); const hermesSkillsGsd = path.join(root, 'skills', 'gsd'); assert.ok(fs.existsSync(hermesSkillsGsd)); @@ -227,14 +227,13 @@ describe('M1 (#2973, #947): --hermes --global --profile=core migrates dev-prefer fs.mkdirSync(legacyDir, { recursive: true }); fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my hermes prefs\n'); - const result = spawnSync( - process.execPath, + const result = runNode( [INSTALL_SCRIPT, '--hermes', '--global', '--config-dir', root, '--profile=core'], - { encoding: 'utf8', env: { ...process.env, HOME: root, USERPROFILE: root } }, + { env: { ...process.env, HOME: root, USERPROFILE: root }, timeoutMs: INSTALL_TIMEOUT_MS }, ); - assert.strictEqual(result.status, 0, - `installer exited ${result.status}\n${result.stdout}\n${result.stderr}`); + assert.strictEqual(result.exitCode, 0, + `installer exited ${result.exitCode}\n${result.stdout}\n${result.stderr}`); // #947: Hermes uses prefix='gsd-' so dev-preferences lands at gsd-dev-preferences/ (not dev-preferences/) const skillFile = path.join(root, 'skills', 'gsd', 'gsd-dev-preferences', 'SKILL.md'); @@ -257,14 +256,13 @@ describe('M2 (#2973): --qwen --global --profile=core migrates dev-preferences fs.mkdirSync(legacyDir, { recursive: true }); fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my qwen prefs\n'); - const result = spawnSync( - process.execPath, + const result = runNode( [INSTALL_SCRIPT, '--qwen', '--global', '--config-dir', root, '--profile=core'], - { encoding: 'utf8', env: { ...process.env, HOME: root, USERPROFILE: root } }, + { env: { ...process.env, HOME: root, USERPROFILE: root }, timeoutMs: INSTALL_TIMEOUT_MS }, ); - assert.strictEqual(result.status, 0, - `installer exited ${result.status}\n${result.stdout}\n${result.stderr}`); + assert.strictEqual(result.exitCode, 0, + `installer exited ${result.exitCode}\n${result.stdout}\n${result.stderr}`); const skillFile = path.join(root, 'skills', 'gsd-dev-preferences', 'SKILL.md'); assert.ok(fs.existsSync(skillFile), @@ -285,14 +283,13 @@ describe('M3 (#2973): --claude --global --profile=core migrates dev-preferences fs.mkdirSync(legacyDir, { recursive: true }); fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my claude prefs\n'); - const result = spawnSync( - process.execPath, + const result = runNode( [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root, '--profile=core'], - { encoding: 'utf8', env: { ...process.env, HOME: root, USERPROFILE: root } }, + { env: { ...process.env, HOME: root, USERPROFILE: root }, timeoutMs: INSTALL_TIMEOUT_MS }, ); - assert.strictEqual(result.status, 0, - `installer exited ${result.status}\n${result.stdout}\n${result.stderr}`); + assert.strictEqual(result.exitCode, 0, + `installer exited ${result.exitCode}\n${result.stdout}\n${result.stderr}`); const skillFile = path.join(root, 'skills', 'gsd-dev-preferences', 'SKILL.md'); assert.ok(fs.existsSync(skillFile), @@ -649,14 +646,13 @@ describe('mergeClaudePermissions (#768): end-to-end install writes permissions t const root = createTempDir('gsd-claude-perm-install-'); t.after(() => cleanup(root)); - const result = spawnSync( - process.execPath, + const result = runNode( [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root], - { encoding: 'utf8', env: { ...process.env, HOME: root, USERPROFILE: root } }, + { env: { ...process.env, HOME: root, USERPROFILE: root }, timeoutMs: INSTALL_TIMEOUT_MS }, ); - assert.strictEqual(result.status, 0, - `installer exited ${result.status}\n${result.stdout}\n${result.stderr}`); + assert.strictEqual(result.exitCode, 0, + `installer exited ${result.exitCode}\n${result.stdout}\n${result.stderr}`); const settingsPath = path.join(root, 'settings.json'); assert.ok(fs.existsSync(settingsPath), 'settings.json must exist after claude install'); @@ -679,14 +675,13 @@ describe('mergeClaudePermissions (#768): end-to-end install writes permissions t const root = createTempDir('gsd-antigravity-perm-install-'); t.after(() => cleanup(root)); - const result = spawnSync( - process.execPath, + const result = runNode( [INSTALL_SCRIPT, '--antigravity', '--global', '--config-dir', root], - { encoding: 'utf8', env: { ...process.env, HOME: root, USERPROFILE: root } }, + { env: { ...process.env, HOME: root, USERPROFILE: root }, timeoutMs: INSTALL_TIMEOUT_MS }, ); - assert.strictEqual(result.status, 0, - `installer exited ${result.status}\n${result.stdout}\n${result.stderr}`); + assert.strictEqual(result.exitCode, 0, + `installer exited ${result.exitCode}\n${result.stdout}\n${result.stderr}`); const settingsPath = path.join(root, 'settings.json'); // If settings.json doesn't exist, permissions are definitely not written — pass. @@ -702,19 +697,19 @@ describe('mergeClaudePermissions (#768): end-to-end install writes permissions t const root = createTempDir('gsd-claude-perm-idempotent-'); t.after(() => cleanup(root)); - const spawnOpts = { - encoding: 'utf8', + const runOpts = { env: { ...process.env, HOME: root, USERPROFILE: root }, + timeoutMs: INSTALL_TIMEOUT_MS, }; const args = [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root]; // First install - const r1 = spawnSync(process.execPath, args, spawnOpts); - assert.strictEqual(r1.status, 0, `first install failed: ${r1.stderr}`); + const r1 = runNode(args, runOpts); + assert.strictEqual(r1.exitCode, 0, `first install failed: ${r1.stderr}`); // Second install (reinstall) - const r2 = spawnSync(process.execPath, args, spawnOpts); - assert.strictEqual(r2.status, 0, `reinstall failed: ${r2.stderr}`); + const r2 = runNode(args, runOpts); + assert.strictEqual(r2.exitCode, 0, `reinstall failed: ${r2.stderr}`); const settings = JSON.parse(fs.readFileSync(path.join(root, 'settings.json'), 'utf8')); for (const entry of GSD_CLAUDE_ALLOW_PERMISSIONS) { @@ -733,18 +728,17 @@ describe('mergeClaudePermissions (#768): end-to-end install writes permissions t const root = createTempDir('gsd-claude-perm-uninstall-'); t.after(() => cleanup(root)); - const spawnOpts = { - encoding: 'utf8', + const runOpts = { env: { ...process.env, HOME: root, USERPROFILE: root }, + timeoutMs: INSTALL_TIMEOUT_MS, }; // Install first - const r1 = spawnSync( - process.execPath, + const r1 = runNode( [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root], - spawnOpts, + runOpts, ); - assert.strictEqual(r1.status, 0, `install failed: ${r1.stderr}`); + assert.strictEqual(r1.exitCode, 0, `install failed: ${r1.stderr}`); // Verify permissions were written const settingsPath = path.join(root, 'settings.json'); @@ -758,12 +752,11 @@ describe('mergeClaudePermissions (#768): end-to-end install writes permissions t fs.writeFileSync(settingsPath, JSON.stringify(afterInstall, null, 2) + '\n'); // Uninstall - const r2 = spawnSync( - process.execPath, + const r2 = runNode( [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root, '--uninstall'], - spawnOpts, + runOpts, ); - assert.strictEqual(r2.status, 0, `uninstall failed: ${r2.stderr}`); + assert.strictEqual(r2.exitCode, 0, `uninstall failed: ${r2.stderr}`); const afterUninstall = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); const allow = afterUninstall.permissions?.allow ?? []; @@ -788,18 +781,17 @@ describe('mergeClaudePermissions (#768): end-to-end install writes permissions t const root = createTempDir('gsd-claude-perm-uninstall-legacy-'); t.after(() => cleanup(root)); - const spawnOpts = { - encoding: 'utf8', + const runOpts = { env: { ...process.env, HOME: root, USERPROFILE: root }, + timeoutMs: INSTALL_TIMEOUT_MS, }; // Install first (writes the current Edit(...) forms). - const r1 = spawnSync( - process.execPath, + const r1 = runNode( [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root], - spawnOpts, + runOpts, ); - assert.strictEqual(r1.status, 0, `install failed: ${r1.stderr}`); + assert.strictEqual(r1.exitCode, 0, `install failed: ${r1.stderr}`); // Simulate a pre-fix install that still carries the stale Write(...) // forms alongside the current Edit(...) forms and a user entry. @@ -809,12 +801,11 @@ describe('mergeClaudePermissions (#768): end-to-end install writes permissions t fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); // Uninstall - const r2 = spawnSync( - process.execPath, + const r2 = runNode( [INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root, '--uninstall'], - spawnOpts, + runOpts, ); - assert.strictEqual(r2.status, 0, `uninstall failed: ${r2.stderr}`); + assert.strictEqual(r2.exitCode, 0, `uninstall failed: ${r2.stderr}`); const afterUninstall = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); const allow = afterUninstall.permissions?.allow ?? []; @@ -1113,18 +1104,21 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('os'); -const { execFileSync } = require('child_process'); const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); // ─── Ensure hooks/dist/ is populated before any install test ───────────────── before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { - encoding: 'utf-8', - stdio: 'pipe', - }); + throwIfFailed( + runNode([BUILD_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), + `node ${BUILD_SCRIPT}`, + ); }); // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -1148,11 +1142,13 @@ function runInstaller(configDir) { delete env.GSD_TEST_MODE; // --no-sdk: this test covers user-artifact preservation only; skip SDK // build (covered by install-smoke.yml) to keep the test deterministic. - execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], { - encoding: 'utf-8', - stdio: 'pipe', - env, - }); + throwIfFailed( + runNode( + [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], + { env, timeoutMs: INSTALL_TIMEOUT_MS }, + ), + `node ${INSTALL_SCRIPT} --claude --global --yes --no-sdk`, + ); } // ─── Test 1: USER-PROFILE.md is preserved across re-install ───────────────── diff --git a/tests/install-write-confinement.test.cjs b/tests/install-write-confinement.test.cjs index 5a8345032..d44f69855 100644 --- a/tests/install-write-confinement.test.cjs +++ b/tests/install-write-confinement.test.cjs @@ -2592,17 +2592,19 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const { spawnSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); const { cleanup } = require('./helpers.cjs'); const ROOT = path.resolve(__dirname, '..'); const DRIFT_LINT = path.join(ROOT, 'scripts', 'lint-shell-command-projection-drift.cjs'); function runLint(targetFile) { - return spawnSync(process.execPath, [DRIFT_LINT, targetFile], { + const result = runNode([DRIFT_LINT, targetFile], { cwd: ROOT, - encoding: 'utf8', + timeoutMs: 15000, }); + result.status = result.exitCode; + return result; } // (The buildWindowsShimTriple parity test was removed with the gsd-sdk shim, diff --git a/tests/install.test.cjs b/tests/install.test.cjs index b491ec42e..5e9efcf6b 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -30,8 +30,12 @@ const path = require('node:path'); const os = require('node:os'); const { createTempDir, createTempProject, cleanup, parseFrontmatter } = require('./helpers.cjs'); +const { runNode } = require('./helpers/process-seam.cjs'); const pkg = require('../package.json'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + const { getDirName, getConfigDirFromHome, @@ -930,8 +934,7 @@ describe('install — changeset CLI lands at scripts/changeset/cli.cjs (#935)', install(false, 'claude'); const claudeDir = path.join(tmpDir, '.claude'); const cliPath = path.join(claudeDir, 'scripts', 'changeset', 'cli.cjs'); - const { spawnSync } = require('node:child_process'); - const result = spawnSync(process.execPath, [cliPath, '--help'], { encoding: 'utf8' }); + const result = runNode([cliPath, '--help'], { timeoutMs: PROBE_TIMEOUT_MS }); // --help exits with code 1 (usage shown), but must NOT throw a MODULE_NOT_FOUND error assert.ok( !result.stderr.includes('MODULE_NOT_FOUND'), @@ -952,11 +955,9 @@ describe('install — changeset CLI lands at scripts/changeset/cli.cjs (#935)', // Use the CHANGELOG.md that was installed into gsd-core/ (installed by the installer) const changelogPath = path.join(claudeDir, 'gsd-core', 'CHANGELOG.md'); assert.ok(fs.existsSync(changelogPath), 'CHANGELOG.md must be installed under gsd-core/'); - const { spawnSync } = require('node:child_process'); - const result = spawnSync( - process.execPath, + const result = runNode( [cliPath, 'extract', '--from', '0.0.0', '--to', '9999.0.0', '--changelog', changelogPath, '--json'], - { encoding: 'utf8' }, + { timeoutMs: PROBE_TIMEOUT_MS }, ); // extract must NOT throw a MODULE_NOT_FOUND or Cannot find module error assert.ok( @@ -966,8 +967,8 @@ describe('install — changeset CLI lands at scripts/changeset/cli.cjs (#935)', // extract exit code 0 (found entries) or 2 (no entries in range) are both valid; // any other exit code is an error assert.ok( - result.status === 0 || result.status === 2, - `installed cli.cjs extract must exit 0 or 2; got ${result.status}; stderr=${result.stderr}`, + result.exitCode === 0 || result.exitCode === 2, + `installed cli.cjs extract must exit 0 or 2; got ${result.exitCode}; stderr=${result.stderr}`, ); }); @@ -3503,7 +3504,8 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); -const { execFileSync } = require('child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { createTempDir, cleanup } = require('./helpers.cjs'); const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); @@ -3511,18 +3513,26 @@ const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); const MANIFEST_NAME = 'gsd-file-manifest.json'; const PATCHES_DIR_NAME = 'gsd-local-patches'; +// #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs. +const { + BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS, + INSTALL_TIMEOUT_MS, +} = require('./helpers/timeouts.cjs'); + before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); + const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); + throwIfFailed(r, `node ${BUILD_SCRIPT}`); }); function runInstaller(configDir) { const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir }; delete env.GSD_TEST_MODE; - return execFileSync( - process.execPath, + const r = runNode( [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], - { encoding: 'utf-8', stdio: 'pipe', env } + { env, timeoutMs: INSTALL_TIMEOUT_MS } ); + throwIfFailed(r, `node ${INSTALL_SCRIPT} --claude --global --yes --no-sdk`); + return r.stdout; } // ─── Test 1: writeManifest must NOT record USER-PROFILE.md ──────────────────── @@ -4632,13 +4642,17 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('os'); -const { execFileSync } = require('child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); const { install } = require(INSTALL_SRC); const { cleanup } = require('./helpers.cjs'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + // ─── Ensure hooks/dist/ is populated before install tests ──────────────────── // With --test-concurrency=4, other install tests (bug-1834, bug-1924) run // build-hooks.js concurrently. That script creates hooks/dist/ empty first, @@ -4646,10 +4660,8 @@ const { cleanup } = require('./helpers.cjs'); // install() fails with "directory is empty" → process.exit(1). before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { - encoding: 'utf-8', - stdio: 'pipe', - }); + const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); + throwIfFailed(r, `node ${BUILD_SCRIPT}`); }); // ─── #1736 + #1367: local install deploys commands in flat gsd-.md layout ─── @@ -4762,19 +4774,21 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('os'); -const { execFileSync } = require('child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); const { install, finishInstall } = require(INSTALL_SRC); const { cleanup, captureConsole } = require('./helpers.cjs'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + // ─── Ensure hooks/dist/ is populated before install tests ──────────────────── before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { - encoding: 'utf-8', - stdio: 'pipe', - }); + const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); + throwIfFailed(r, `node ${BUILD_SCRIPT}`); }); // ─── #2248: local install must NOT write statusLine to repo settings.json ──── @@ -4921,19 +4935,21 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('os'); -const { execFileSync } = require('child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); const { install, finishInstall } = require(INSTALL_SRC); const { cleanup } = require('./helpers.cjs'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + // ─── Ensure hooks/dist/ is populated before install tests ──────────────────── before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { - encoding: 'utf-8', - stdio: 'pipe', - }); + const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); + throwIfFailed(r, `node ${BUILD_SCRIPT}`); }); // ─── Helper: run both install phases ───────────────────────────────────────── @@ -5465,7 +5481,8 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { cleanup } = require('./helpers.cjs'); const REPO_ROOT = path.resolve(__dirname, '..'); @@ -5473,6 +5490,12 @@ const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js'); const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist'); const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); +// #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs. +const { + BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS, + INSTALL_TIMEOUT_MS, +} = require('./helpers/timeouts.cjs'); + /** * Ensure hooks/dist is populated before any suite that reads it. * hooks/dist/ is gitignored and only produced by `npm run build:hooks`. @@ -5482,7 +5505,8 @@ const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); */ function ensureHooksDist() { if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) { - execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + const r = runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); + throwIfFailed(r, `node ${BUILD_HOOKS_SCRIPT}`); } } @@ -5497,22 +5521,21 @@ function ensureHooksDist() { function runInstall(cwd, args) { const env = { ...process.env }; delete env.GSD_TEST_MODE; - execFileSync(process.execPath, [INSTALL_PATH, ...args], { + // 120s, not 60s. A full install copies and converts the whole shipped + // payload (117 workflows, 100 references, 34 agents, ~71 skills) and + // measures 13-30s on an idle runner — under 2x headroom at the old cap. + // On a loaded bench that margin is not enough: the Cursor suite's before + // hook died with `spawnSync ETIMEDOUT` on the node24 lane while the node22 + // lane passed the SAME commit in 12.7s, cancelling three child tests as + // collateral. The cap also shrinks in real terms every time a file joins + // the payload. Matches the 120s already used for the heavy install case + // below. Aligned with the other runInstall helper in this file. + const r = runNode([INSTALL_PATH, ...args], { cwd, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], env, - // 120s, not 60s. A full install copies and converts the whole shipped - // payload (117 workflows, 100 references, 34 agents, ~71 skills) and - // measures 13-30s on an idle runner — under 2x headroom at the old cap. - // On a loaded bench that margin is not enough: the Cursor suite's before - // hook died with `spawnSync ETIMEDOUT` on the node24 lane while the node22 - // lane passed the SAME commit in 12.7s, cancelling three child tests as - // collateral. The cap also shrinks in real terms every time a file joins - // the payload. Matches the 120s already used for the heavy install case - // below. Aligned with the other runInstall helper in this file. - timeout: 120000, + timeoutMs: INSTALL_TIMEOUT_MS, }); + throwIfFailed(r, `node ${INSTALL_PATH} ${args.join(' ')}`); } /** @@ -7527,7 +7550,8 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); -const { execFileSync } = require('child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { createTempDir, cleanup } = require('./helpers.cjs'); const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); @@ -7535,18 +7559,26 @@ const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); const MANIFEST_NAME = 'gsd-file-manifest.json'; const PATCHES_DIR_NAME = 'gsd-local-patches'; +// #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs. +const { + BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS, + INSTALL_TIMEOUT_MS, +} = require('./helpers/timeouts.cjs'); + before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); + const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); + throwIfFailed(r, `node ${BUILD_SCRIPT}`); }); function runInstaller(configDir) { const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir }; delete env.GSD_TEST_MODE; - return execFileSync( - process.execPath, + const r = runNode( [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], - { encoding: 'utf-8', stdio: 'pipe', env } + { env, timeoutMs: INSTALL_TIMEOUT_MS } ); + throwIfFailed(r, `node ${INSTALL_SCRIPT} --claude --global --yes --no-sdk`); + return r.stdout; } // ─── Test 1: writeManifest must NOT record USER-PROFILE.md ──────────────────── @@ -8656,13 +8688,17 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('os'); -const { execFileSync } = require('child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); const { install } = require(INSTALL_SRC); const { cleanup } = require('./helpers.cjs'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + // ─── Ensure hooks/dist/ is populated before install tests ──────────────────── // With --test-concurrency=4, other install tests (bug-1834, bug-1924) run // build-hooks.js concurrently. That script creates hooks/dist/ empty first, @@ -8670,10 +8706,8 @@ const { cleanup } = require('./helpers.cjs'); // install() fails with "directory is empty" → process.exit(1). before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { - encoding: 'utf-8', - stdio: 'pipe', - }); + const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); + throwIfFailed(r, `node ${BUILD_SCRIPT}`); }); // ─── #1736 + #1367: local install deploys commands in flat gsd-.md layout ─── @@ -8786,19 +8820,21 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('os'); -const { execFileSync } = require('child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); const { install, finishInstall } = require(INSTALL_SRC); const { cleanup, captureConsole } = require('./helpers.cjs'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + // ─── Ensure hooks/dist/ is populated before install tests ──────────────────── before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { - encoding: 'utf-8', - stdio: 'pipe', - }); + const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); + throwIfFailed(r, `node ${BUILD_SCRIPT}`); }); // ─── #2248: local install must NOT write statusLine to repo settings.json ──── @@ -8945,19 +8981,21 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('os'); -const { execFileSync } = require('child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); const { install, finishInstall } = require(INSTALL_SRC); const { cleanup } = require('./helpers.cjs'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + // ─── Ensure hooks/dist/ is populated before install tests ──────────────────── before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { - encoding: 'utf-8', - stdio: 'pipe', - }); + const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); + throwIfFailed(r, `node ${BUILD_SCRIPT}`); }); // ─── Helper: run both install phases ───────────────────────────────────────── @@ -9489,7 +9527,8 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { cleanup } = require('./helpers.cjs'); const REPO_ROOT = path.resolve(__dirname, '..'); @@ -9497,6 +9536,12 @@ const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js'); const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist'); const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); +// #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs. +const { + BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS, + INSTALL_TIMEOUT_MS, +} = require('./helpers/timeouts.cjs'); + /** * Ensure hooks/dist is populated before any suite that reads it. * hooks/dist/ is gitignored and only produced by `npm run build:hooks`. @@ -9506,7 +9551,8 @@ const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); */ function ensureHooksDist() { if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) { - execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + const r = runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); + throwIfFailed(r, `node ${BUILD_HOOKS_SCRIPT}`); } } @@ -9521,22 +9567,21 @@ function ensureHooksDist() { function runInstall(cwd, args) { const env = { ...process.env }; delete env.GSD_TEST_MODE; - execFileSync(process.execPath, [INSTALL_PATH, ...args], { + // 120s, not 60s. A full install copies and converts the whole shipped + // payload (117 workflows, 100 references, 34 agents, ~71 skills) and + // measures 13-30s on an idle runner — under 2x headroom at the old cap. + // On a loaded bench that margin is not enough: the Cursor suite's before + // hook died with `spawnSync ETIMEDOUT` on the node24 lane while the node22 + // lane passed the SAME commit in 12.7s, cancelling three child tests as + // collateral. The cap also shrinks in real terms every time a file joins + // the payload. Matches the 120s already used for the heavy install case + // below. Aligned with the other runInstall helper in this file. + const r = runNode([INSTALL_PATH, ...args], { cwd, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], env, - // 120s, not 60s. A full install copies and converts the whole shipped - // payload (117 workflows, 100 references, 34 agents, ~71 skills) and - // measures 13-30s on an idle runner — under 2x headroom at the old cap. - // On a loaded bench that margin is not enough: the Cursor suite's before - // hook died with `spawnSync ETIMEDOUT` on the node24 lane while the node22 - // lane passed the SAME commit in 12.7s, cancelling three child tests as - // collateral. The cap also shrinks in real terms every time a file joins - // the payload. Matches the 120s already used for the heavy install case - // below. Aligned with the other runInstall helper in this file. - timeout: 120000, + timeoutMs: INSTALL_TIMEOUT_MS, }); + throwIfFailed(r, `node ${INSTALL_PATH} ${args.join(' ')}`); } /** @@ -9918,7 +9963,8 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { cleanup } = require('./helpers.cjs'); const REPO_ROOT = path.resolve(__dirname, '..'); @@ -9929,6 +9975,12 @@ const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js'); // leaves hooks/dist empty and install.js hard-fails "directory is empty" (#1926). const BUILD_HOOKS = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); +// #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs. +const { + BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS, + INSTALL_TIMEOUT_MS, +} = require('./helpers/timeouts.cjs'); + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -9940,12 +9992,12 @@ const BUILD_HOOKS = path.join(REPO_ROOT, 'scripts', 'build-hooks.js'); function runClaudeLocalInstall(cwd) { const env = { ...process.env }; delete env.GSD_TEST_MODE; - execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local', '--no-sdk'], { + const r = runNode([INSTALL_PATH, '--claude', '--local', '--no-sdk'], { cwd, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], env, + timeoutMs: INSTALL_TIMEOUT_MS, }); + throwIfFailed(r, `node ${INSTALL_PATH} --claude --local --no-sdk`); } // --------------------------------------------------------------------------- @@ -9958,11 +10010,11 @@ describe('bug #1367 — Claude local install uses flat gsd-.md command layo before(() => { // #1926: build hooks/dist/ so the installer's verifyInstalled(hooks) doesn't hit an // empty directory. Self-contained — no dependency on the lane having pre-built hooks. - execFileSync(process.execPath, [BUILD_HOOKS], { + const r = runNode([BUILD_HOOKS], { cwd: REPO_ROOT, - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], + timeoutMs: BUILD_HOOKS_TIMEOUT_MS, }); + throwIfFailed(r, `node ${BUILD_HOOKS}`); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1367-')); runClaudeLocalInstall(tmpDir); }); @@ -10100,9 +10152,13 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { spawnSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); const os = require('node:os'); +// A single short CLI query (install.js --skills-root ) — no full +// install or build involved. +const SKILLS_ROOT_PROBE_TIMEOUT_MS = 15000; + const INSTALL_JS = path.join(__dirname, '../bin/install.js'); const WORKFLOW = path.join(__dirname, '../gsd-core/workflows/sync-skills.md'); const COMMAND = path.join(__dirname, '../commands/gsd/sync-skills.md'); @@ -10126,9 +10182,9 @@ describe('install.js --skills-root', () => { for (const { runtime, expected } of CASES) { test(`resolves correct skills root for ${runtime}`, () => { - const result = spawnSync(process.execPath, [INSTALL_JS, '--skills-root', runtime], { - encoding: 'utf-8', + const result = runNode([INSTALL_JS, '--skills-root', runtime], { env: { ...process.env, GSD_TEST_MODE: undefined }, // ensure not in test mode + timeoutMs: SKILLS_ROOT_PROBE_TIMEOUT_MS, }); // Strip trailing newline const actual = result.stdout.trim(); @@ -10137,15 +10193,15 @@ describe('install.js --skills-root', () => { } test('exits non-zero when runtime arg is missing', () => { - const result = spawnSync(process.execPath, [INSTALL_JS, '--skills-root'], { - encoding: 'utf-8', + const result = runNode([INSTALL_JS, '--skills-root'], { + timeoutMs: SKILLS_ROOT_PROBE_TIMEOUT_MS, }); - assert.notStrictEqual(result.status, 0, 'Should exit with error when runtime arg is missing'); + assert.notStrictEqual(result.exitCode, 0, 'Should exit with error when runtime arg is missing'); }); test('returns a path ending in /skills', () => { - const result = spawnSync(process.execPath, [INSTALL_JS, '--skills-root', 'windsurf'], { - encoding: 'utf-8', + const result = runNode([INSTALL_JS, '--skills-root', 'windsurf'], { + timeoutMs: SKILLS_ROOT_PROBE_TIMEOUT_MS, }); assert.ok(result.stdout.trim().endsWith('skills'), 'Skills root must end in /skills'); }); diff --git a/tests/installer-migration-install.integration.test.cjs b/tests/installer-migration-install.integration.test.cjs index 22f9524cd..89a6489fb 100644 --- a/tests/installer-migration-install.integration.test.cjs +++ b/tests/installer-migration-install.integration.test.cjs @@ -11,16 +11,20 @@ process.env.GSD_TEST_MODE = '1'; const { describe, test, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); -const { spawnSync } = require('node:child_process'); const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); +const { runNode } = require('./helpers/process-seam.cjs'); + const installModule = require('../bin/install.js'); const pkg = require('../package.json'); const { install } = installModule; const { createTempDir, cleanup } = require('./helpers.cjs'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + const installScript = path.join(__dirname, '..', 'bin', 'install.js'); const SUPPORTED_RUNTIMES = installModule.allRuntimes; const RUNTIME_INSTALL_CONTRACTS = { @@ -190,8 +194,7 @@ function runInstallerCli(runtime, targetDir, options = {}) { env.HOME = path.join(path.dirname(targetDir), 'home'); env.USERPROFILE = env.HOME; - return spawnSync( - process.execPath, + return runNode( [ installScript, `--${runtime}`, @@ -202,8 +205,8 @@ function runInstallerCli(runtime, targetDir, options = {}) { '--no-sdk', ], { - encoding: 'utf8', env, + timeoutMs: INSTALL_TIMEOUT_MS, } ); } @@ -583,7 +586,7 @@ describe('installer migration install integration', { concurrency: false }, () = const result = runInstallerCli(runtime, targetDir, { minimal: false }); - assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(result.exitCode, 0, result.stderr || result.stdout); const output = stripAnsi(`${result.stdout}\n${result.stderr}`); assert.match(output, /Installing for /); assert.match(output, /Installer migrations/); @@ -614,7 +617,7 @@ describe('installer migration install integration', { concurrency: false }, () = const result = runInstallerCli(runtime, targetDir); - assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(result.exitCode, 0, result.stderr || result.stdout); const output = stripAnsi(`${result.stdout}\n${result.stderr}`); assert.match(output, /Installer migrations/); assert.match(output, /removed\s+hooks\/statusline\.js/); @@ -633,7 +636,7 @@ describe('installer migration install integration', { concurrency: false }, () = const result = runInstallerCli(runtime, targetDir); - assert.notEqual(result.status, 0, 'install should fail before materialization'); + assert.notEqual(result.exitCode, 0, 'install should fail before materialization'); const output = stripAnsi(`${result.stdout}\n${result.stderr}`); assert.match(output, /Installer migrations/); assert.match(output, /blocked\s+gsd-core\/gsd-retired-tool\.cjs/); diff --git a/tests/installer-migrations.test.cjs b/tests/installer-migrations.test.cjs index a66a97c15..da3f7ceec 100644 --- a/tests/installer-migrations.test.cjs +++ b/tests/installer-migrations.test.cjs @@ -2082,7 +2082,8 @@ const { describe, test, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const installModule = require('../bin/install.js'); const { readInstallState } = require('../gsd-core/bin/lib/installer-migrations.cjs'); @@ -2090,6 +2091,11 @@ const { install, parseTomlToObject, reconcileCodexHooksJsonEvent } = installModu const { createTempDir, cleanup } = require('./helpers.cjs'); const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist'); const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything, even +// run in beforeEach on a fresh worktree. See tests/helpers/timeouts.cjs for +// the class-norm justification. +const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); function withCodexHome(codexHome, fn) { const previousCodexHome = process.env.CODEX_HOME; @@ -2147,7 +2153,10 @@ describe('#3357 — Codex install removes legacy GSD hooks.json entries', { conc beforeEach(() => { if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) { - execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' }); + throwIfFailed( + runNode([BUILD_HOOKS_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), + `node ${BUILD_HOOKS_SCRIPT}`, + ); } tmpRoot = createTempDir('gsd-3357-'); codexHome = path.join(tmpRoot, '.codex'); diff --git a/tests/issue-2695-codex-hook-set.test.cjs b/tests/issue-2695-codex-hook-set.test.cjs index c56bd6ab0..98fc43fb7 100644 --- a/tests/issue-2695-codex-hook-set.test.cjs +++ b/tests/issue-2695-codex-hook-set.test.cjs @@ -20,7 +20,8 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); -const { spawnSync, execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { cleanup } = require('./helpers.cjs'); const { @@ -32,6 +33,12 @@ const { const PKG_VERSION = require('../package.json').version; +// #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs. +const { + BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS, + INSTALL_TIMEOUT_MS, +} = require('./helpers/timeouts.cjs'); + // The four-file hook set the Codex surface must deliver together (#2695). const CODEX_HOOK_FILES = [ 'gsd-check-update.js', @@ -42,7 +49,8 @@ const CODEX_HOOK_FILES = [ // Build hooks/dist before any install runs (the installer copies from there). before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' }); + const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS }); + throwIfFailed(r, `node ${BUILD_SCRIPT}`); }); function hooksDirOf(configDir) { @@ -62,10 +70,9 @@ function runCodexInstall({ profile, preseed }) { // Sandbox HOME/USERPROFILE to configDir: Codex's skills-kind `home: ".agents"` // override resolves via os.homedir(); sandboxing keeps the spawn self-contained // (mirrors tests/install-minimal-hooks.test.cjs Codex downgrade test). - const result = spawnSync( - process.execPath, + const result = runNode( [INSTALL_SCRIPT, '--codex', '--global', '--config-dir', configDir, `--profile=${profile}`], - { encoding: 'utf8', env: installerEnv({ HOME: configDir, USERPROFILE: configDir }) }, + { env: installerEnv({ HOME: configDir, USERPROFILE: configDir }), timeoutMs: INSTALL_TIMEOUT_MS }, ); return { configDir, result }; } @@ -212,10 +219,9 @@ describe('#2695: re-running the installer is idempotent for the four-file set', const first = runCodexInstall({ profile: 'full' }); t.after(() => cleanup(first.configDir)); // Second run into the SAME config dir. - const result2 = spawnSync( - process.execPath, + const result2 = runNode( [INSTALL_SCRIPT, '--codex', '--global', '--config-dir', first.configDir, '--profile=full'], - { encoding: 'utf8', env: installerEnv({ HOME: first.configDir, USERPROFILE: first.configDir }) }, + { env: installerEnv({ HOME: first.configDir, USERPROFILE: first.configDir }), timeoutMs: INSTALL_TIMEOUT_MS }, ); assert.ok(result2.stdout || result2.stderr); diff --git a/tests/issue-787-cline-hooks-agents.test.cjs b/tests/issue-787-cline-hooks-agents.test.cjs index 5f33bfa48..2bb058b95 100644 --- a/tests/issue-787-cline-hooks-agents.test.cjs +++ b/tests/issue-787-cline-hooks-agents.test.cjs @@ -31,11 +31,14 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); -const { spawnSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); const { createTempDir, cleanup } = require('./helpers.cjs'); const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); +// #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs. +const { PROBE_TIMEOUT_MS, INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + const { install, uninstall, @@ -68,8 +71,8 @@ describe('#787 Cline pure helpers', () => { try { const p = path.join(tmp, 'PreToolUse'); fs.writeFileSync(p, script); - const res = spawnSync(process.execPath, ['--check', p], { encoding: 'utf8' }); - assert.equal(res.status, 0, `node --check failed: ${res.stderr}`); + const res = runNode(['--check', p], { timeoutMs: PROBE_TIMEOUT_MS }); + assert.equal(res.exitCode, 0, `node --check failed: ${res.stderr}`); } finally { cleanup(tmp); } @@ -80,11 +83,11 @@ describe('#787 Cline pure helpers', () => { try { const p = path.join(tmp, 'PreToolUse'); fs.writeFileSync(p, buildClinePreToolUseHook()); - const res = spawnSync(process.execPath, [p], { + const res = runNode([p], { input: JSON.stringify({ toolName: 'read_file', toolInput: { path: 'src/index.ts' } }), - encoding: 'utf8', + timeoutMs: PROBE_TIMEOUT_MS, }); - assert.equal(res.status, 0); + assert.equal(res.exitCode, 0); const out = JSON.parse(res.stdout); assert.equal(out.cancel, false); } finally { @@ -97,11 +100,11 @@ describe('#787 Cline pure helpers', () => { try { const p = path.join(tmp, 'PreToolUse'); fs.writeFileSync(p, buildClinePreToolUseHook()); - const res = spawnSync(process.execPath, [p], { + const res = runNode([p], { input: JSON.stringify({ toolName: 'write_to_file', toolInput: { path: '.planning/ROADMAP.md', content: 'x' } }), - encoding: 'utf8', + timeoutMs: PROBE_TIMEOUT_MS, }); - assert.equal(res.status, 0); + assert.equal(res.exitCode, 0); const out = JSON.parse(res.stdout); assert.equal(out.cancel, true); assert.match(out.errorMessage, /\.planning/); @@ -115,14 +118,14 @@ describe('#787 Cline pure helpers', () => { try { const p = path.join(tmp, 'PreToolUse'); fs.writeFileSync(p, buildClinePreToolUseHook()); - const res = spawnSync(process.execPath, [p], { + const res = runNode([p], { input: JSON.stringify({ toolName: 'write_to_file', toolInput: { path: 'docs/guide.md', content: 'Edit your .planning/ROADMAP.md via /gsd commands.' }, }), - encoding: 'utf8', + timeoutMs: PROBE_TIMEOUT_MS, }); - assert.equal(res.status, 0); + assert.equal(res.exitCode, 0); assert.equal(JSON.parse(res.stdout).cancel, false, 'content mentioning .planning must not trigger a cancel'); } finally { cleanup(tmp); @@ -134,8 +137,8 @@ describe('#787 Cline pure helpers', () => { try { const p = path.join(tmp, 'PreToolUse'); fs.writeFileSync(p, buildClinePreToolUseHook()); - const res = spawnSync(process.execPath, [p], { input: 'not json{', encoding: 'utf8' }); - assert.equal(res.status, 0); + const res = runNode([p], { input: 'not json{', timeoutMs: PROBE_TIMEOUT_MS }); + assert.equal(res.exitCode, 0); assert.equal(JSON.parse(res.stdout).cancel, false); } finally { cleanup(tmp); @@ -263,10 +266,9 @@ describe('#787 Cline global install — ~/.agents/AGENTS.md', () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-787-cline-global-')); const env = { ...process.env, HOME: root, USERPROFILE: root }; delete env.GSD_TEST_MODE; - const res = spawnSync( - process.execPath, + const res = runNode( [INSTALL_SCRIPT, '--cline', '--global', '--config-dir', path.join(root, '.cline')], - { cwd: root, encoding: 'utf8', env }, + { cwd: root, env, timeoutMs: INSTALL_TIMEOUT_MS }, ); return { root, res }; } @@ -274,7 +276,7 @@ describe('#787 Cline global install — ~/.agents/AGENTS.md', () => { test('writes ~/.agents/AGENTS.md with a GSD marker block', () => { const { root, res } = runGlobalClineInstall(); try { - assert.equal(res.status, 0, `installer failed: ${res.stderr}`); + assert.equal(res.exitCode, 0, `installer failed: ${res.stderr}`); const agents = path.join(root, '.agents', 'AGENTS.md'); assert.ok(fs.existsSync(agents), '~/.agents/AGENTS.md must exist after a global Cline install'); const content = fs.readFileSync(agents, 'utf8'); diff --git a/tests/kilo-upgrades.test.cjs b/tests/kilo-upgrades.test.cjs index 68d83dce3..c5a2a8a01 100644 --- a/tests/kilo-upgrades.test.cjs +++ b/tests/kilo-upgrades.test.cjs @@ -32,6 +32,7 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { spawnSync } = require('node:child_process'); +const { runNode, runGit } = require('./helpers/process-seam.cjs'); const { runMinimalInstall, BUILD_SCRIPT } = require('./helpers/install-shared.cjs'); const { cleanup } = require('./helpers.cjs'); @@ -309,9 +310,13 @@ test('capabilities/kilo/capability.json extendedHookEvents is exactly [] (hooksS // hooks/dist is gitignored and built; the scoped CI lane does not run // build:hooks, so a real install there would stage no hooks/ dir. Build it // idempotently (mirrors golden-install-parity + install-minimal-hooks). +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); before(() => { - const build = spawnSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf8' }); - assert.equal(build.status, 0, `build:hooks failed: ${build.stderr}`); + const build = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }); + assert.equal(build.exitCode, 0, `build:hooks failed: ${build.stderr}`); }); // The three PreToolUse guards the plugin spawns that ship today. When a new @@ -373,8 +378,8 @@ test('kilo: a disallowed write through the REAL installed tree is rejected by th const mainRepo = path.join(scratch, 'main'); fs.mkdirSync(mainRepo, { recursive: true }); const git = (args, cwd) => { - const r = spawnSync('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', ...args], { cwd, encoding: 'utf8' }); - assert.equal(r.status, 0, `git ${args.join(' ')} failed: ${r.stderr}`); + const r = runGit(['-c', 'user.email=t@t', '-c', 'user.name=t', ...args], { cwd }); + assert.equal(r.exitCode, 0, `git ${args.join(' ')} failed: ${r.stderr}`); return r; }; git(['init', '-q'], mainRepo); diff --git a/tests/kimi-agent-converter.test.cjs b/tests/kimi-agent-converter.test.cjs index 4b2d31d48..2efc430e7 100644 --- a/tests/kimi-agent-converter.test.cjs +++ b/tests/kimi-agent-converter.test.cjs @@ -174,7 +174,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const { spawnSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); const { cleanup } = require('./helpers.cjs'); const { installerEnv } = require('./helpers/install-shared.cjs'); @@ -185,6 +185,8 @@ const ROOT = path.join(__dirname, '..'); const KIMI_CFG = path.join(os.tmpdir(), 'gsd-kimi-config-test').replace(/\\/g, '/'); const XDG_HOME = path.join(os.tmpdir(), 'gsd-xdg-home-test'); const INSTALL_SCRIPT = path.join(ROOT, 'bin', 'install.js'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const { getGlobalConfigDir, @@ -306,18 +308,17 @@ describe('Kimi runtime homes', () => { const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-existing-agents-home-')); try { fs.mkdirSync(path.join(tmpHome, '.agents', 'skills'), { recursive: true }); - const result = spawnSync( - process.execPath, + const result = runNode( [INSTALL_SCRIPT, '--kimi', '--global', '--no-sdk'], { cwd: tmpProject, - encoding: 'utf8', env: installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }), + timeoutMs: INSTALL_TIMEOUT_MS, }, ); assert.strictEqual( - result.status, + result.exitCode, 0, `expected --kimi --global to reuse existing ~/.agents/skills\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, ); @@ -362,18 +363,17 @@ describe('Kimi local install guard', () => { const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-local-project-')); const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-local-home-')); try { - const result = spawnSync( - process.execPath, + const result = runNode( [INSTALL_SCRIPT, '--kimi', '--local', '--no-sdk'], { cwd: tmpProject, - encoding: 'utf8', env: installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }), + timeoutMs: INSTALL_TIMEOUT_MS, }, ); assert.strictEqual( - result.status, + result.exitCode, 0, `expected --kimi --local guard to no-op successfully\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, ); @@ -396,18 +396,17 @@ describe('Kimi local install guard', () => { const tmpConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-global-config-')); const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-global-home-')); try { - const result = spawnSync( - process.execPath, + const result = runNode( [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--no-sdk'], { cwd: tmpProject, - encoding: 'utf8', env: installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }), + timeoutMs: INSTALL_TIMEOUT_MS, }, ); assert.strictEqual( - result.status, + result.exitCode, 0, `expected --kimi --global to install Kimi skills successfully\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, ); @@ -480,13 +479,13 @@ describe('Kimi local install guard', () => { try { const installArgs = [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--no-sdk']; - const first = spawnSync(process.execPath, installArgs, { + const first = runNode(installArgs, { cwd: tmpProject, - encoding: 'utf8', env, + timeoutMs: INSTALL_TIMEOUT_MS, }); assert.strictEqual( - first.status, + first.exitCode, 0, `first install failed\nstdout: ${first.stdout}\nstderr: ${first.stderr}`, ); @@ -496,13 +495,13 @@ describe('Kimi local install guard', () => { fs.appendFileSync(skillFile, '\nUSER LOCAL KIMI SKILL EDIT\n'); fs.appendFileSync(agentPrompt, '\nUSER LOCAL KIMI AGENT EDIT\n'); - const second = spawnSync(process.execPath, installArgs, { + const second = runNode(installArgs, { cwd: tmpProject, - encoding: 'utf8', env, + timeoutMs: INSTALL_TIMEOUT_MS, }); assert.strictEqual( - second.status, + second.exitCode, 0, `second install failed\nstdout: ${second.stdout}\nstderr: ${second.stderr}`, ); @@ -534,13 +533,13 @@ describe('Kimi local install guard', () => { try { const installArgs = [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--no-sdk']; - const installResult = spawnSync(process.execPath, installArgs, { + const installResult = runNode(installArgs, { cwd: tmpProject, - encoding: 'utf8', env, + timeoutMs: INSTALL_TIMEOUT_MS, }); assert.strictEqual( - installResult.status, + installResult.exitCode, 0, `install failed\nstdout: ${installResult.stdout}\nstderr: ${installResult.stderr}`, ); @@ -554,17 +553,16 @@ describe('Kimi local install guard', () => { fs.writeFileSync(foreignRootAgent, 'version: 1\n', 'utf8'); fs.writeFileSync(foreignSubagent, 'version: 1\n', 'utf8'); - const uninstallResult = spawnSync( - process.execPath, + const uninstallResult = runNode( [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--uninstall'], { cwd: tmpProject, - encoding: 'utf8', env, + timeoutMs: INSTALL_TIMEOUT_MS, }, ); assert.strictEqual( - uninstallResult.status, + uninstallResult.exitCode, 0, `uninstall failed\nstdout: ${uninstallResult.stdout}\nstderr: ${uninstallResult.stderr}`, ); diff --git a/tests/kimi-upgrades.test.cjs b/tests/kimi-upgrades.test.cjs index ad5559e81..2c2d921d2 100644 --- a/tests/kimi-upgrades.test.cjs +++ b/tests/kimi-upgrades.test.cjs @@ -38,7 +38,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const { spawnSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); const { runMinimalInstall, INSTALL_SCRIPT, installerEnv } = require('./helpers/install-shared.cjs'); const { cleanup, createTempDir, toPosixPath } = require('./helpers.cjs'); @@ -142,12 +142,13 @@ test('kimi --global: reinstalling is idempotent — the GSD [[hooks]] block is n // Reinstall over the SAME root/config (runMinimalInstall always mkdtemps a // fresh root, so the reinstall is driven directly against this test's root // exactly the way runMinimalInstall drives its own install internally). - const reinstall = spawnSync(process.execPath, [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', root], { - cwd: process.cwd(), encoding: 'utf8', + const reinstall = runNode([INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', root], { + cwd: process.cwd(), env: installerEnv({ HOME: root, USERPROFILE: root }), + timeoutMs: 120000, }); - assert.strictEqual(reinstall.status, 0, - `reinstall exited with status ${reinstall.status}\nstdout: ${reinstall.stdout}\nstderr: ${reinstall.stderr}`); + assert.strictEqual(reinstall.exitCode, 0, + `reinstall exited with status ${reinstall.exitCode}\nstdout: ${reinstall.stdout}\nstderr: ${reinstall.stderr}`); const second = fs.readFileSync(tomlPath, 'utf8'); assert.equal(beginMarkers(second), 1, 'reinstall must not duplicate the BEGIN marker'); assert.equal(endMarkers(second), 1, 'reinstall must not duplicate the END marker'); diff --git a/tests/loop-render-hooks.test.cjs b/tests/loop-render-hooks.test.cjs index ef91f7313..d3d1df0d4 100644 --- a/tests/loop-render-hooks.test.cjs +++ b/tests/loop-render-hooks.test.cjs @@ -775,18 +775,17 @@ describe('renderLoopHooks', () => { // ─── 9. End-to-end cmdLoopRenderHooks (via gsd-tools subprocess) ───────────── -const { spawnSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); const ROOT = path.resolve(__dirname, '..'); const GSD_TOOLS = path.join(ROOT, 'gsd-core', 'bin', 'gsd-tools.cjs'); describe('cmdLoopRenderHooks end-to-end (via gsd-tools)', () => { test('loop render-hooks plan:pre returns JSON envelope with ui-phase step active', () => { - const result = spawnSync( - process.execPath, + const result = runNode( [GSD_TOOLS, 'loop', 'render-hooks', 'plan:pre', '--cwd', tmpProjectDir], - { cwd: ROOT, encoding: 'utf8' }, + { cwd: ROOT, timeoutMs: 15000 }, ); - assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || '')); + assert.strictEqual(result.exitCode, 0, 'Expected exit 0. stderr: ' + (result.stderr || '')); const envelope = JSON.parse(result.stdout.trim()); assert.strictEqual(envelope.point, 'plan:pre'); assert.ok(Array.isArray(envelope.activeHooks)); @@ -799,12 +798,11 @@ describe('cmdLoopRenderHooks end-to-end (via gsd-tools)', () => { // FIX 4: schema-default activation — no config.json in project → ui-phase step active by default test('loop render-hooks plan:pre with no config.json → ui-phase step active by schema default', () => { - const result = spawnSync( - process.execPath, + const result = runNode( [GSD_TOOLS, 'loop', 'render-hooks', 'plan:pre', '--cwd', tmpEmptyProjectDir], - { cwd: ROOT, encoding: 'utf8' }, + { cwd: ROOT, timeoutMs: 15000 }, ); - assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || '')); + assert.strictEqual(result.exitCode, 0, 'Expected exit 0. stderr: ' + (result.stderr || '')); const envelope = JSON.parse(result.stdout.trim()); const uiStep = envelope.activeHooks.find(h => h.capId === 'ui' && h.kind === 'step'); assert.ok( @@ -815,8 +813,7 @@ describe('cmdLoopRenderHooks end-to-end (via gsd-tools)', () => { }); test('loop render-hooks plan:pre with ui capability disabled in surface → ui hooks absent', () => { - const result = spawnSync( - process.execPath, + const result = runNode( [ GSD_TOOLS, 'loop', @@ -827,9 +824,9 @@ describe('cmdLoopRenderHooks end-to-end (via gsd-tools)', () => { '--config-dir', tmpUiDisabledConfigDir, ], - { cwd: ROOT, encoding: 'utf8' }, + { cwd: ROOT, timeoutMs: 15000 }, ); - assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || '')); + assert.strictEqual(result.exitCode, 0, 'Expected exit 0. stderr: ' + (result.stderr || '')); const envelope = JSON.parse(result.stdout.trim()); const uiHooks = envelope.activeHooks.filter(h => h.capId === 'ui'); assert.deepStrictEqual( @@ -841,12 +838,11 @@ describe('cmdLoopRenderHooks end-to-end (via gsd-tools)', () => { // FIX 4: explicit false in config.json overrides schema default test('loop render-hooks plan:pre with ui_phase=false in config.json → ui-phase step absent', () => { - const result = spawnSync( - process.execPath, + const result = runNode( [GSD_TOOLS, 'loop', 'render-hooks', 'plan:pre', '--cwd', tmpFalseConfigProjectDir], - { cwd: ROOT, encoding: 'utf8' }, + { cwd: ROOT, timeoutMs: 15000 }, ); - assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || '')); + assert.strictEqual(result.exitCode, 0, 'Expected exit 0. stderr: ' + (result.stderr || '')); const envelope = JSON.parse(result.stdout.trim()); const uiStep = envelope.activeHooks.find(h => h.capId === 'ui' && h.kind === 'step'); assert.strictEqual( @@ -857,12 +853,11 @@ describe('cmdLoopRenderHooks end-to-end (via gsd-tools)', () => { }); test('loop render-hooks invalid-point exits non-zero', () => { - const result = spawnSync( - process.execPath, + const result = runNode( [GSD_TOOLS, 'loop', 'render-hooks', 'plan:mid', '--cwd', tmpProjectDir], - { cwd: ROOT, encoding: 'utf8' }, + { cwd: ROOT, timeoutMs: 15000 }, ); - assert.notStrictEqual(result.status, 0, 'Expected non-zero exit for invalid point'); + assert.notStrictEqual(result.exitCode, 0, 'Expected non-zero exit for invalid point'); assert.match(result.stderr, /plan:mid|Invalid loop point/); }); }); @@ -901,53 +896,48 @@ describe('--active-cap flag (loop render-hooks)', () => { }); test('--active-cap tdd with tdd_mode=true → stdout trimmed === "true", exit 0', () => { - const result = spawnSync( - process.execPath, + const result = runNode( [GSD_TOOLS, 'loop', 'render-hooks', 'execute:post', '--active-cap', 'tdd', '--cwd', tddOnDir], - { cwd: ROOT, encoding: 'utf8' }, + { cwd: ROOT, timeoutMs: 15000 }, ); - assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || '')); + assert.strictEqual(result.exitCode, 0, 'Expected exit 0. stderr: ' + (result.stderr || '')); assert.strictEqual(result.stdout.trim(), 'true', 'Expected stdout "true" when tdd_mode=true'); }); test('--active-cap tdd with tdd_mode=false → stdout trimmed === "false", exit 0', () => { - const result = spawnSync( - process.execPath, + const result = runNode( [GSD_TOOLS, 'loop', 'render-hooks', 'execute:post', '--active-cap', 'tdd', '--cwd', tddOffDir], - { cwd: ROOT, encoding: 'utf8' }, + { cwd: ROOT, timeoutMs: 15000 }, ); - assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || '')); + assert.strictEqual(result.exitCode, 0, 'Expected exit 0. stderr: ' + (result.stderr || '')); assert.strictEqual(result.stdout.trim(), 'false', 'Expected stdout "false" when tdd_mode=false'); }); test('--active-cap → stdout trimmed === "false", exit 0', () => { - const result = spawnSync( - process.execPath, + const result = runNode( [GSD_TOOLS, 'loop', 'render-hooks', 'execute:post', '--active-cap', 'no-such-capability-xyz', '--cwd', tddOffDir], - { cwd: ROOT, encoding: 'utf8' }, + { cwd: ROOT, timeoutMs: 15000 }, ); - assert.strictEqual(result.status, 0, 'Expected exit 0 for unknown capId. stderr: ' + (result.stderr || '')); + assert.strictEqual(result.exitCode, 0, 'Expected exit 0 for unknown capId. stderr: ' + (result.stderr || '')); assert.strictEqual(result.stdout.trim(), 'false', 'Expected stdout "false" for unknown capId'); }); test('--active-cap with no value → non-zero exit and error message', () => { - const result = spawnSync( - process.execPath, + const result = runNode( [GSD_TOOLS, 'loop', 'render-hooks', 'execute:post', '--active-cap', '--cwd', tddOffDir], - { cwd: ROOT, encoding: 'utf8' }, + { cwd: ROOT, timeoutMs: 15000 }, ); - assert.notStrictEqual(result.status, 0, 'Expected non-zero exit when --active-cap has no value'); + assert.notStrictEqual(result.exitCode, 0, 'Expected non-zero exit when --active-cap has no value'); assert.match(result.stderr, /active-cap/i, 'Expected error message referencing --active-cap'); }); test('--active-cap output is exactly "true" or "false" (no JSON envelope, clean for shell capture)', () => { // The entire stdout must be just "true" or "false" + newline — no envelope object - const result = spawnSync( - process.execPath, + const result = runNode( [GSD_TOOLS, 'loop', 'render-hooks', 'execute:post', '--active-cap', 'tdd', '--cwd', tddOnDir], - { cwd: ROOT, encoding: 'utf8' }, + { cwd: ROOT, timeoutMs: 15000 }, ); - assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || '')); + assert.strictEqual(result.exitCode, 0, 'Expected exit 0. stderr: ' + (result.stderr || '')); // Must be exactly "true" or "false" — not a JSON object/envelope const trimmed = result.stdout.trim(); assert.ok( @@ -1072,12 +1062,11 @@ describe('ADR-1244 D2: load-failed capability gates fail OPEN with a loud warnin } function renderHooks(overlayHome, point, extraArgs = []) { - const result = spawnSync( - process.execPath, + const result = runNode( [GSD_TOOLS, 'loop', 'render-hooks', point, '--cwd', overlayHome, ...extraArgs], - { cwd: ROOT, encoding: 'utf8', env: { ...process.env, GSD_HOME: overlayHome } }, + { cwd: ROOT, timeoutMs: 15000, env: { ...process.env, GSD_HOME: overlayHome } }, ); - assert.strictEqual(result.status, 0, `Expected exit 0 at ${point}. stderr: ` + (result.stderr || '')); + assert.strictEqual(result.exitCode, 0, `Expected exit 0 at ${point}. stderr: ` + (result.stderr || '')); return result; } diff --git a/tests/managed-hooks.test.cjs b/tests/managed-hooks.test.cjs index 271267d7b..233e781d2 100644 --- a/tests/managed-hooks.test.cjs +++ b/tests/managed-hooks.test.cjs @@ -126,13 +126,18 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const os = require('os'); -const { execFileSync } = require('child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const HOOKS_DIR = path.join(__dirname, '..', 'hooks'); const _CHECK_UPDATE_FILE = path.join(HOOKS_DIR, 'gsd-check-update.js'); const WORKER_FILE = path.join(HOOKS_DIR, 'gsd-check-update-worker.js'); const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { INSTALL_TIMEOUT_MS, PROBE_TIMEOUT_MS, BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const SH_HOOKS = [ 'gsd-phase-boundary.sh', @@ -143,10 +148,10 @@ const SH_HOOKS = [ // ─── Ensure hooks/dist/ is populated before install tests ──────────────────── before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { - encoding: 'utf-8', - stdio: 'pipe', - }); + throwIfFailed( + runNode([BUILD_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), + `node ${BUILD_SCRIPT}`, + ); }); // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -163,11 +168,13 @@ function cleanup(dir) { function runInstaller(configDir) { // --no-sdk: this test covers .sh hook version stamping only; skip SDK // build (covered by install-smoke.yml). - execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], { - encoding: 'utf-8', - stdio: 'pipe', - env: { ...process.env, CLAUDE_CONFIG_DIR: configDir }, - }); + throwIfFailed( + runNode( + [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], + { env: { ...process.env, CLAUDE_CONFIG_DIR: configDir }, timeoutMs: INSTALL_TIMEOUT_MS }, + ), + `node ${INSTALL_SCRIPT} --claude --global --yes --no-sdk`, + ); return path.join(configDir, 'hooks'); } @@ -377,8 +384,9 @@ describe('bug #2136 part 4: installed .sh hooks contain stamped concrete version process.stdout.write(JSON.stringify(staleHooks)); `; - const result = execFileSync(process.execPath, ['-e', checkScript], { encoding: 'utf8' }); - const staleHooks = JSON.parse(result); + const probeResult = runNode(['-e', checkScript], { timeoutMs: PROBE_TIMEOUT_MS }); + throwIfFailed(probeResult, 'node -e '); + const staleHooks = JSON.parse(probeResult.stdout); assert.deepStrictEqual( staleHooks, @@ -428,23 +436,28 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); -const { execFileSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const crypto = require('node:crypto'); const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); const MANIFEST_NAME = 'gsd-file-manifest.json'; +// scripts/build-hooks.js copies pre-built hook files into hooks/dist and +// syntax-checks them with vm — it does not compile/bundle anything. See +// tests/helpers/timeouts.cjs for the class-norm justification. +const { INSTALL_TIMEOUT_MS, PROBE_TIMEOUT_MS, BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const { HOOKS_TO_COPY } = require('../scripts/build-hooks.js'); // ─── Ensure hooks/dist/ is populated before any install test ──────────────── before(() => { - execFileSync(process.execPath, [BUILD_SCRIPT], { - encoding: 'utf-8', - stdio: 'pipe', - }); + throwIfFailed( + runNode([BUILD_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }), + `node ${BUILD_SCRIPT}`, + ); }); // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -469,11 +482,13 @@ function runInstaller(configDir) { // must not skip the main() gate or the install is a no-op. const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir }; delete env.GSD_TEST_MODE; - execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes'], { - encoding: 'utf-8', - stdio: 'pipe', - env, - }); + throwIfFailed( + runNode( + [INSTALL_SCRIPT, '--claude', '--global', '--yes'], + { env, timeoutMs: INSTALL_TIMEOUT_MS }, + ), + `node ${INSTALL_SCRIPT} --claude --global --yes`, + ); return configDir; } @@ -481,12 +496,12 @@ function runInstaller(configDir) { * Run detect-custom-files and return parsed JSON output. */ function detectCustomFiles(configDir) { - const result = execFileSync(process.execPath, [TOOLS_PATH, 'detect-custom-files', '--config-dir', configDir], { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, GSD_SESSION_KEY: '' }, - }); - return JSON.parse(result.trim()); + const result = runNode( + [TOOLS_PATH, 'detect-custom-files', '--config-dir', configDir], + { env: { ...process.env, GSD_SESSION_KEY: '' }, timeoutMs: PROBE_TIMEOUT_MS }, + ); + throwIfFailed(result, `node ${TOOLS_PATH} detect-custom-files --config-dir ${configDir}`); + return JSON.parse(result.stdout.trim()); } // ─── Tests ──────────────────────────────────────────────────────────────────── diff --git a/tests/mcp-catalog-parity.install.test.cjs b/tests/mcp-catalog-parity.install.test.cjs index 215779d2a..7e515b825 100644 --- a/tests/mcp-catalog-parity.install.test.cjs +++ b/tests/mcp-catalog-parity.install.test.cjs @@ -60,7 +60,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const { spawnSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); const { cleanup } = require('./helpers.cjs'); const { runMinimalInstall, installerEnv } = require('./helpers/install-shared.cjs'); @@ -71,6 +71,9 @@ const { buildCatalog, readResource, shouldCompose } = require('../gsd-core/bin/l const REPO_ROOT = path.resolve(__dirname, '..'); const MARKER_TOKEN = 'gsd:section'; +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + /** Recursively collect `.md` file paths under `absDir`, relative to `REPO_ROOT`, POSIX-normalized. */ function collectMarkdownFiles(absDir, out = []) { let entries; @@ -130,10 +133,10 @@ function installOverlay(overlayRoot, runtime, extraArgs = []) { root, ...extraArgs, ]; - const result = spawnSync(process.execPath, args, { + const result = runNode(args, { cwd: root, - encoding: 'utf8', env: installerEnv({ HOME: root, USERPROFILE: root }), + timeoutMs: INSTALL_TIMEOUT_MS, }); return { configDir: root, root, result }; } @@ -242,7 +245,7 @@ describe('a marker-documenting non-workflow composes on neither surface (row 50) const dest = installOverlay(overlayRepo, 'claude'); t.after(() => cleanup(dest.root)); assert.equal( - dest.result.status, + dest.result.exitCode, 0, `install must succeed: a non-workflow doc's marker-shaped line must never reach composeWorkflow\nstderr: ${dest.result.stderr}`, ); @@ -291,7 +294,7 @@ describe('the parity gate is non-vacuous against a real installer regression (ro const dest = installOverlay(brokenPredicateRepo, 'claude'); t.after(() => cleanup(dest.root)); assert.equal( - dest.result.status, + dest.result.exitCode, 0, `broken-predicate install must still succeed (composeWorkflow simply never runs)\nstderr: ${dest.result.stderr}`, ); diff --git a/tests/no-unbounded-spawn-allowlist.test.cjs b/tests/no-unbounded-spawn-allowlist.test.cjs index 441fb7b35..62282078d 100644 --- a/tests/no-unbounded-spawn-allowlist.test.cjs +++ b/tests/no-unbounded-spawn-allowlist.test.cjs @@ -31,8 +31,9 @@ const REPO_ROOT = path.join(__dirname, '..'); // the time this guard was written (139 entries) — each future migration // wave lowers it as files are moved off the allowlist by adding real // timeouts; it must never grow back up. Lowered to 120 by the #3144 Wave-1 -// process-seam migration (19 files' unbounded spawns bounded). -const BASELINE = 120; +// process-seam migration (19 files' unbounded spawns bounded), then to 73 +// by the #3145 Wave-2 migration (47 files' unbounded spawns bounded). +const BASELINE = 73; function readAllowlist() { const raw = fs.readFileSync(ALLOWLIST_PATH, 'utf8'); diff --git a/tests/no-unbounded-spawn.test.cjs b/tests/no-unbounded-spawn.test.cjs index 62c94b6a0..138750ec7 100644 --- a/tests/no-unbounded-spawn.test.cjs +++ b/tests/no-unbounded-spawn.test.cjs @@ -207,6 +207,207 @@ describe('no-unbounded-spawn: A — detection', () => { }); }); +// ─── ceiling escape (allow-spawn-timeout-ceiling) — #3145 matrix section A ── +// +// Rows A1-A13 of .gsd/phase/chore-3145-bound-installer-runtime/50-test-matrix.md. +// (Distinct from the "A — detection" section above, which is #3143's matrix.) + +describe('no-unbounded-spawn: ceiling escape (allow-spawn-timeout-ceiling)', () => { + test('A1: exactly the ceiling is still clean without a marker', () => { + ruleTester.run('local/no-unbounded-spawn', rule, { + valid: [{ code: `spawnSync(c, a, { timeout: 600000 });`, filename: FILE }], + invalid: [], + }); + }); + + test('A2: just over the ceiling still reports without a marker', () => { + ruleTester.run('local/no-unbounded-spawn', rule, { + valid: [], + invalid: [ + { + code: `spawnSync(c, a, { timeout: 600001 });`, + filename: FILE, + errors: [{ messageId: 'timeoutTooLarge' }], + }, + ], + }); + }); + + test('A3: a reasoned marker permits an over-ceiling bound', () => { + ruleTester.run('local/no-unbounded-spawn', rule, { + valid: [ + { + code: + `// allow-spawn-timeout-ceiling: regen:derived runs a full build\n` + + `spawnSync(c, a, { timeout: 900000 });`, + filename: FILE, + }, + ], + invalid: [], + }); + }); + + test('A4: an empty reason is not an audit trail', () => { + ruleTester.run('local/no-unbounded-spawn', rule, { + valid: [], + invalid: [ + { + code: + `// allow-spawn-timeout-ceiling:\n` + + `spawnSync(c, a, { timeout: 900000 });`, + filename: FILE, + errors: [{ messageId: 'timeoutTooLarge' }], + }, + ], + }); + }); + + test('A5: whitespace-only reason is rejected', () => { + ruleTester.run('local/no-unbounded-spawn', rule, { + valid: [], + invalid: [ + { + code: + `// allow-spawn-timeout-ceiling: \n` + + `spawnSync(c, a, { timeout: 900000 });`, + filename: FILE, + errors: [{ messageId: 'timeoutTooLarge' }], + }, + ], + }); + }); + + test('A6: the marker binds to its own call, not the file', () => { + ruleTester.run('local/no-unbounded-spawn', rule, { + valid: [], + invalid: [ + { + code: + `// allow-spawn-timeout-ceiling: reason for the first call\n` + + `spawnSync(c, a, { timeout: 900000 });\n` + + `spawnSync(c, b, { timeout: 700000 });`, + filename: FILE, + errors: [{ messageId: 'timeoutTooLarge' }], + }, + ], + }); + }); + + test('A7: an inert marker is not itself an error', () => { + ruleTester.run('local/no-unbounded-spawn', rule, { + valid: [ + { + code: + `// allow-spawn-timeout-ceiling: reason\n` + + `spawnSync(c, a, { timeout: 5000 });`, + filename: FILE, + }, + ], + invalid: [], + }); + }); + + test('A8: the escape raises the ceiling, it never waives the bound', () => { + ruleTester.run('local/no-unbounded-spawn', rule, { + valid: [], + invalid: [ + { + code: + `// allow-spawn-timeout-ceiling: reason\n` + + `spawnSync(c, a, {});`, + filename: FILE, + errors: [{ messageId: 'unboundedSpawn' }], + }, + ], + }); + }); + + test('A9: marker is recognized above the call', () => { + ruleTester.run('local/no-unbounded-spawn', rule, { + valid: [ + { + code: + `// allow-spawn-timeout-ceiling: reason above the call\n` + + `spawnSync(c, a, { timeout: 900000 });`, + filename: FILE, + }, + ], + invalid: [], + }); + }); + + test('A10: marker is recognized inline at the timeout', () => { + ruleTester.run('local/no-unbounded-spawn', rule, { + valid: [ + { + code: + `spawnSync(c, a, {\n` + + ` // allow-spawn-timeout-ceiling: reason inline at the timeout\n` + + ` timeout: 900000,\n` + + `});`, + filename: FILE, + }, + ], + invalid: [], + }); + }); + + test('A11: only a real comment counts as a marker', () => { + ruleTester.run('local/no-unbounded-spawn', rule, { + valid: [], + invalid: [ + { + code: + `const note = 'allow-spawn-timeout-ceiling: reason in a string';\n` + + `spawnSync(c, a, { timeout: 900000 });`, + filename: FILE, + errors: [{ messageId: 'timeoutTooLarge' }], + }, + ], + }); + }); + + test('A12: an unrelated marker does not apply', () => { + // The marker text is built via concatenation, not a string literal, so + // this file does not itself contain the contiguous exemption-directive + // text (the no-source-grep marker, name split across the concat below) — + // the refs linter that guards that directive can't tell fixture data + // proving non-suppression from a real exemption, and a literal here + // would be misread as an unreferenced one. Same idiom as GUARDED_RULE + // in no-unbounded-spawn-allowlist.test.cjs. + const UNRELATED_MARKER = '// ' + 'allow-test-rule' + ': unrelated escape\n'; + ruleTester.run('local/no-unbounded-spawn', rule, { + valid: [], + invalid: [ + { + code: UNRELATED_MARKER + `spawnSync(c, a, { timeout: 900000 });`, + filename: FILE, + errors: [{ messageId: 'timeoutTooLarge' }], + }, + ], + }); + }); + + test('A13: the real 900000 site passes with its marker', () => { + const { ESLint } = require('eslint'); + const eslint = new ESLint({ + overrideConfigFile: true, + overrideConfig: { + languageOptions: { ecmaVersion: 2022, sourceType: 'commonjs' }, + plugins: { local: { rules: { 'no-unbounded-spawn': rule } } }, + rules: { 'local/no-unbounded-spawn': ['error', { allowlist: [] }] }, + }, + }); + return eslint + .lintFiles(['tests/fragment-single-edit-propagation.install.test.cjs']) + .then((results) => { + const messages = results[0] ? results[0].messages : []; + const tooLarge = messages.filter((m) => m.messageId === 'timeoutTooLarge'); + assert.deepEqual(tooLarge, []); + }); + }); +}); + // ─── B. the timeout value (Goodhart defenses) ────────────────────────────── describe('no-unbounded-spawn: B — timeout value boundaries', () => { diff --git a/tests/opencode-command-dir-plural.test.cjs b/tests/opencode-command-dir-plural.test.cjs index 4477e66e9..c51aa68a5 100644 --- a/tests/opencode-command-dir-plural.test.cjs +++ b/tests/opencode-command-dir-plural.test.cjs @@ -33,13 +33,15 @@ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { spawnSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); const { runMinimalInstall, installerEnv, INSTALL_SCRIPT, } = require('./helpers/install-shared.cjs'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const { cleanup } = require('./helpers.cjs'); const { resolveRuntimeArtifactLayout, @@ -61,11 +63,12 @@ function reinstallOpencode(root, scope = 'global') { args.push('--local'); cwd = root; } - return spawnSync(process.execPath, args, { + const r = runNode(args, { cwd, - encoding: 'utf8', env: installerEnv({ HOME: root, USERPROFILE: root }), + timeoutMs: INSTALL_TIMEOUT_MS, }); + return { status: r.exitCode, stdout: r.stdout, stderr: r.stderr }; } function gsdMdFiles(dir) { diff --git a/tests/opencode-plugin-adapter.test.cjs b/tests/opencode-plugin-adapter.test.cjs index 4fedab237..6d4c11ecc 100644 --- a/tests/opencode-plugin-adapter.test.cjs +++ b/tests/opencode-plugin-adapter.test.cjs @@ -381,15 +381,18 @@ test('plugin implements the full declared opencode extension-event surface (Clau // --------------------------------------------------------------------------- test('installer copies plugin as .js, records it in the manifest, and removes it on uninstall', (t) => { - const { spawnSync } = require('node:child_process'); + const { runNode } = require('./helpers/process-seam.cjs'); const installer = path.join(__dirname, '..', 'bin', 'install.js'); const cfg = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-oc-install-'))); t.after(() => cleanup(cfg)); - const run = (args) => - spawnSync(process.execPath, [installer, '--opencode', '--global', '--config-dir', cfg, ...args], { - encoding: 'utf8', + const run = (args) => { + const result = runNode([installer, '--opencode', '--global', '--config-dir', cfg, ...args], { + timeoutMs: 120000, }); + result.status = result.exitCode; + return result; + }; // Install const install = run([]); diff --git a/tests/precommit-alias-drift-hook.test.cjs b/tests/precommit-alias-drift-hook.test.cjs index 202bd1bff..1866fe6da 100644 --- a/tests/precommit-alias-drift-hook.test.cjs +++ b/tests/precommit-alias-drift-hook.test.cjs @@ -4,7 +4,8 @@ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runHook } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { createTempDir, cleanup } = require('./helpers.cjs'); const ROOT = path.resolve(__dirname, '..'); @@ -31,16 +32,20 @@ describe('.githooks/pre-commit alias drift guard', () => { const marker = path.join(tmpDir, 'npm-called.txt'); - execFileSync('bash', [HOOK_PATH], { - cwd: ROOT, - env: { - ...process.env, - GIT_OVERRIDE: mockGit, - NPM_OVERRIDE: mockNpm, - GSD_TEST_NPM_MARKER: marker, - }, - stdio: 'pipe', - }); + throwIfFailed( + runHook(HOOK_PATH, [], { + interpreter: 'bash', + cwd: ROOT, + env: { + ...process.env, + GIT_OVERRIDE: mockGit, + NPM_OVERRIDE: mockNpm, + GSD_TEST_NPM_MARKER: marker, + }, + timeoutMs: 15000, + }), + `bash ${HOOK_PATH}`, + ); assert.ok(fs.existsSync(marker), 'expected npm run check:alias-drift to be invoked'); }); @@ -54,16 +59,20 @@ describe('.githooks/pre-commit alias drift guard', () => { const marker = path.join(tmpDir, 'npm-called.txt'); - execFileSync('bash', [HOOK_PATH], { - cwd: ROOT, - env: { - ...process.env, - GIT_OVERRIDE: mockGit, - NPM_OVERRIDE: mockNpm, - GSD_TEST_NPM_MARKER: marker, - }, - stdio: 'pipe', - }); + throwIfFailed( + runHook(HOOK_PATH, [], { + interpreter: 'bash', + cwd: ROOT, + env: { + ...process.env, + GIT_OVERRIDE: mockGit, + NPM_OVERRIDE: mockNpm, + GSD_TEST_NPM_MARKER: marker, + }, + timeoutMs: 15000, + }), + `bash ${HOOK_PATH}`, + ); assert.ok(!fs.existsSync(marker), 'expected npm check to be skipped for unrelated staged files'); }); diff --git a/tests/prepush-enterprise-email-hook.test.cjs b/tests/prepush-enterprise-email-hook.test.cjs index 765e9fccb..0b3f74732 100644 --- a/tests/prepush-enterprise-email-hook.test.cjs +++ b/tests/prepush-enterprise-email-hook.test.cjs @@ -4,12 +4,16 @@ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { execFileSync } = require('node:child_process'); +const { runHook } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); const { createTempDir, cleanup } = require('./helpers.cjs'); const ROOT = path.resolve(__dirname, '..'); const HOOK_PATH = path.join(ROOT, '.githooks', 'pre-push'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); + /** * Write a mock bash script to a .sh file in tmpDir and return its absolute path. * The hook invokes it via GIT_OVERRIDE — bash executes the path directly via the @@ -47,7 +51,8 @@ exit 1 `); assert.throws(() => { - execFileSync('bash', [HOOK_PATH], { + const r = runHook(HOOK_PATH, [], { + interpreter: 'bash', cwd: ROOT, env: { ...process.env, @@ -55,8 +60,9 @@ exit 1 GSD_BLOCKED_AUTHOR_REGEX: '@example-corp\\.com$', }, input: 'refs/heads/pr refs-local-sha refs/heads/pr refs-remote-sha\n', - stdio: 'pipe', + timeoutMs: PROBE_TIMEOUT_MS, }); + throwIfFailed(r, `bash ${HOOK_PATH}`); }, /Push blocked: commit author email matched local blocked regex/); }); @@ -78,7 +84,8 @@ fi exit 1 `); - execFileSync('bash', [HOOK_PATH], { + const r = runHook(HOOK_PATH, [], { + interpreter: 'bash', cwd: ROOT, env: { ...process.env, @@ -86,7 +93,8 @@ exit 1 GSD_BLOCKED_AUTHOR_REGEX: '@example-corp\\.com$', }, input: 'refs/heads/pr refs-local-sha refs/heads/pr refs-remote-sha\n', - stdio: 'pipe', + timeoutMs: PROBE_TIMEOUT_MS, }); + throwIfFailed(r, `bash ${HOOK_PATH}`); }); }); diff --git a/tests/prune-orphaned-worktrees.test.cjs b/tests/prune-orphaned-worktrees.test.cjs index a3aba2105..460ea89c1 100644 --- a/tests/prune-orphaned-worktrees.test.cjs +++ b/tests/prune-orphaned-worktrees.test.cjs @@ -13,11 +13,8 @@ const fs = require('fs'); const path = require('path'); const { createTempDir, cleanup } = require('./helpers.cjs'); const { gitOrThrow } = require('./helpers/git-fixture.cjs'); - -// 15000ms: git plumbing (init/config/add/commit/branch/worktree/merge/checkout) -// on a small mkdtemp fixture repo — far over any observed duration for that -// class of call. -const GIT_TIMEOUT_MS = 15000; +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); // Lazy-loaded so tests can fail clearly when the export doesn't exist yet. function getPruneOrphanedWorktrees() { diff --git a/tests/release-hotfix-empty-cherry-pick.test.cjs b/tests/release-hotfix-empty-cherry-pick.test.cjs index 913964427..7b7fd1613 100644 --- a/tests/release-hotfix-empty-cherry-pick.test.cjs +++ b/tests/release-hotfix-empty-cherry-pick.test.cjs @@ -22,10 +22,8 @@ const { cleanup } = require('./helpers.cjs'); const RELEASE_WORKFLOW = path.join(__dirname, '..', '.github', 'workflows', 'release.yml'); -// 15000ms: git plumbing (config, add, commit, rev-parse, checkout, cherry-pick) -// against a small mkdtemp fixture repo — gitOrThrow's own documented default, -// reused here as the named constant for this file's calls. -const GIT_TIMEOUT_MS = 15000; +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); // ─── git helpers ──────────────────────────────────────────────────────────── diff --git a/tests/roadmap-upgrade.test.cjs b/tests/roadmap-upgrade.test.cjs index 1a892962f..84ed771dc 100644 --- a/tests/roadmap-upgrade.test.cjs +++ b/tests/roadmap-upgrade.test.cjs @@ -4,7 +4,7 @@ const { test, describe, mock } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { execSync } = require('node:child_process'); +const { gitOrThrow } = require('./helpers/git-fixture.cjs'); const { createTempDir, cleanup } = require('./helpers.cjs'); const { computeMigrationPlan, applyMigration } = require('../gsd-core/bin/lib/roadmap-upgrade.cjs'); @@ -18,13 +18,13 @@ function makeGitignoredPlanningProject() { const dir = createTempDir('m3-rollback-'); fs.writeFileSync(path.join(dir, '.gitignore'), '.planning/\n'); fs.writeFileSync(path.join(dir, 'README.md'), '# tracked\n'); - const git = (c) => execSync(c, { cwd: dir, stdio: 'pipe' }); - git('git init'); - git('git config user.email t@t.t'); - git('git config user.name t'); - git('git config commit.gpgsign false'); - git('git add -A'); - git('git commit -m initial'); + const git = (argv) => gitOrThrow(argv, { cwd: dir }); + git(['init']); + git(['config', 'user.email', 't@t.t']); + git(['config', 'user.name', 't']); + git(['config', 'commit.gpgsign', 'false']); + git(['add', '-A']); + git(['commit', '-m', 'initial']); // .planning created AFTER the commit → untracked + gitignored. const planning = path.join(dir, '.planning'); diff --git a/tests/test-timings.json b/tests/test-timings.json index 322f2edc5..12e1cedae 100644 --- a/tests/test-timings.json +++ b/tests/test-timings.json @@ -6,707 +6,777 @@ "test-events-linux-node22.jsonl", "test-events-linux-node24.jsonl" ], - "file_count": 700, + "file_count": 770, "timings": { - "active-workstream-store.test.cjs": 42, - "active-workstream-store.unit.test.cjs": 277, - "adapter-declarative-equivalence.test.cjs": 29, - "adapter-imperative.test.cjs": 169, - "adr-15-progress-converge.test.cjs": 334, - "adr-218-release-version-validation.test.cjs": 88, - "adr-22-plan-drift-guard.test.cjs": 3331, - "adr-230-pr-target-policy.test.cjs": 153, - "adr-612-bracket-grammar.test.cjs": 249, - "adr-612-collision-characterization.test.cjs": 71, - "adr-index-gate.test.cjs": 5307, - "adr-parser.property.test.cjs": 1855, - "adr-parser.test.cjs": 62, - "adr-parser.unit.test.cjs": 452, - "adr857-contribution-merge.test.cjs": 119, - "adr857-core-without-capabilities.test.cjs": 5207, - "adr857-predicate-boundary.test.cjs": 85, - "affected-tests-lib.test.cjs": 105, - "agent-classification-parity.test.cjs": 89, - "agent-frontmatter.test.cjs": 945, - "agent-install-check.test.cjs": 101, - "agent-install-validation.test.cjs": 8799, - "agent-required-reading-consistency.test.cjs": 160, - "agent-size-budget.test.cjs": 163, - "agent-skills-awareness.test.cjs": 68, - "agent-skills-bootstrap.test.cjs": 244, - "agent-skills.test.cjs": 21368, - "ai-evals.test.cjs": 5307, - "allowlist-ratchet.test.cjs": 148, - "analyze-dependencies.test.cjs": 35, - "anti-pattern-enforcement.test.cjs": 66, - "antigravity-repo-grounding.test.cjs": 79, - "antigravity-reviewer.test.cjs": 58, - "antigravity-upgrades.test.cjs": 5412, - "api-coverage-gate-e2e.test.cjs": 5637, - "api-coverage.test.cjs": 877, - "artifacts.test.cjs": 267, - "ask-user-questions-fallback.test.cjs": 232, - "assumption-delta-checkpoint-e2e.test.cjs": 4596, - "assumption-delta.test.cjs": 1246, - "atomic-write-coverage.test.cjs": 43, - "audit-command-cutover.test.cjs": 5401, - "audit-fix-command.test.cjs": 150, - "augment-conversion.test.cjs": 85, - "augment-upgrades.test.cjs": 8688, - "autonomous-allowed-tools.test.cjs": 29, - "autonomous-converge.test.cjs": 416, - "autonomous-decomposition.test.cjs": 48, - "autonomous-interactive.test.cjs": 42, - "autonomous-to-flag.test.cjs": 49, - "autonomous-ui-steps.test.cjs": 114, - "backwards-compat-phase-id.test.cjs": 49, - "branch-no-track-guard.test.cjs": 102, - "broken-windows.test.cjs": 12686, - "bugs-1656-1657.test.cjs": 281, - "capability-cli.test.cjs": 43755, - "capability-command-dispatch.test.cjs": 218, - "capability-consent.test.cjs": 7222, - "capability-consumption.test.cjs": 393, - "capability-ledger.test.cjs": 1211, - "capability-lifecycle.test.cjs": 5491, - "capability-loader.test.cjs": 1208, - "capability-manifest-version.test.cjs": 435, - "capability-matrix-sync.test.cjs": 26, - "capability-precedence-parity.test.cjs": 118, - "capability-registry.test.cjs": 36285, - "capability-source.test.cjs": 1141, - "capability-state.test.cjs": 7930, - "capability-trust.test.cjs": 161, - "capability-writer.test.cjs": 2794, - "chain-flag-plan-phase.test.cjs": 58, - "changeset-cli.test.cjs": 5900, - "changeset-github-release-notes.test.cjs": 1201, - "changeset-lint.test.cjs": 1296, - "changeset-new.test.cjs": 261, - "changeset-parse.test.cjs": 178, - "changeset-render.test.cjs": 43, - "changeset-serialize.test.cjs": 44, - "check-env.test.cjs": 9683, - "check-gap-analysis-plan-post-e2e.test.cjs": 14843, - "check-glossary-refs.test.cjs": 1546, - "check-predicate.test.cjs": 204, - "check-tdd-review-checkpoint-e2e.test.cjs": 5991, - "check-ui-plan-gate.test.cjs": 401, - "check-ui-safety-gate.test.cjs": 1935, - "check-update-config-dir.test.cjs": 394, - "ci-rebase-check.test.cjs": 1634, - "ci-test-job-timeout-budget.test.cjs": 82, - "ci-test-scope.test.cjs": 44131, - "cjs-command-router-adapter.test.cjs": 6166, - "claude-imperative-reference.test.cjs": 149, - "claude-md-path.test.cjs": 4212, - "claude-md.test.cjs": 11827, - "claude-orchestration-command-router.test.cjs": 2863, - "claude-orchestration.test.cjs": 1618, - "claude-skills-migration.test.cjs": 126, - "cleanup-branch-pruning.test.cjs": 73, - "cli-exit.test.cjs": 1042, - "cline-beforetool-upgrade.test.cjs": 64, - "cline-dispatch-degradation.test.cjs": 69, - "cline-imperative-reference.test.cjs": 115, - "cline-install.test.cjs": 3360, - "cline-model-override-upgrade.test.cjs": 82, - "cline-support.test.cjs": 32, - "clock-seam.test.cjs": 9641, - "close-phase-todos-padded-resolves.test.cjs": 712, - "close-phase-todos-stage-deletion.test.cjs": 23, - "clusters.test.cjs": 68, - "code-review-agent-skills.test.cjs": 33, - "code-review-command.test.cjs": 570, - "code-review-flags.test.cjs": 106, - "code-review-pipeline-regression.test.cjs": 258, - "code-review-summary-parser.test.cjs": 138, - "code-review.test.cjs": 2592, - "codebuddy-install.test.cjs": 3870, - "codebuddy-upgrades.test.cjs": 2201, - "codex-config.test.cjs": 118852, - "codex-declarative-reference.test.cjs": 101, - "command-arg-projection.test.cjs": 67, - "command-contract.test.cjs": 866, - "command-routing-hub.test.cjs": 29374, - "commands.test.cjs": 80272, - "commit-docs-bypass.test.cjs": 2116, - "commit-files-deletion.test.cjs": 2759, - "commit-files-pathspec.test.cjs": 6175, - "concurrency-safety.test.cjs": 11518, - "config-field-docs.test.cjs": 1319, - "config-get-default.test.cjs": 16526, - "config-loader.test.cjs": 8375, - "config-schema.property.test.cjs": 6073, - "config.test.cjs": 128262, - "configuration-migrate-config.test.cjs": 2451, - "context-enrichment.test.cjs": 70, - "context-index-sync.test.cjs": 140, - "context-predicates-query.test.cjs": 14493, - "context-predicates.property.test.cjs": 419, - "context-predicates.test.cjs": 285, - "context-utilization.property.test.cjs": 187, - "context-utilization.test.cjs": 106, - "context7-plugin-grant-parity.test.cjs": 170, - "continuation-grammar-parity.test.cjs": 116, - "contributor-standards.test.cjs": 259, - "conventional-title.property.test.cjs": 282, - "conventional-title.test.cjs": 202, - "copilot-install.test.cjs": 50303, - "copilot-upgrades.test.cjs": 4932, - "core-utils.test.cjs": 521, - "coverage-metadata-parser.test.cjs": 10083, - "coverage-uat-routing.test.cjs": 2242, - "cross-ai-execution.test.cjs": 382, - "cursor-conversion.test.cjs": 59, - "cursor-dispatch-upgrade.test.cjs": 33, - "cursor-hook-bus-upgrade.test.cjs": 76, - "cursor-hooks.test.cjs": 408, - "cursor-imperative-reference.test.cjs": 80, - "cursor-reviewer.test.cjs": 117, - "debug-session-management.test.cjs": 94, - "debug-session-manager-commit.test.cjs": 1696, - "debugger-bug-taxonomy.test.cjs": 105, - "debugger-fix-acceptance.test.cjs": 71, - "debugger-prevention.test.cjs": 69, - "debugger-rca-branching.test.cjs": 59, - "debugger-repro-hardening.test.cjs": 56, - "debugger-sbfl.test.cjs": 176, - "debugger-semantic-recall.test.cjs": 66, - "decisions.test.cjs": 12151, - "declarative-reference-antigravity.test.cjs": 1490, - "declarative-reference-augment.test.cjs": 2001, - "declarative-reference-codebuddy.test.cjs": 1357, - "declarative-reference-copilot.test.cjs": 1644, - "declarative-reference-windsurf.test.cjs": 1663, - "declarative-reference-zcode.test.cjs": 1500, - "defaults-json-fallback.test.cjs": 161, - "derive-progress.property.test.cjs": 240, - "discord-release-summary.test.cjs": 75, - "discuss-all-flag.test.cjs": 70, - "discuss-checkpoint.test.cjs": 88, - "discuss-mode.test.cjs": 438, - "discuss-phase-power.test.cjs": 90, - "dispatcher.test.cjs": 14429, - "docs-parity-live-registry.test.cjs": 613, - "docs-update.test.cjs": 7300, - "drift-detection.test.cjs": 7147, - "edge-probe-docs-fixtures.test.cjs": 57, - "edge-probe-planner-contract.test.cjs": 28, + "active-workstream-store.test.cjs": 81, + "active-workstream-store.unit.test.cjs": 364, + "adapter-declarative-equivalence.test.cjs": 45, + "adapter-imperative.test.cjs": 164, + "adr-15-progress-converge.test.cjs": 511, + "adr-218-release-version-validation.test.cjs": 70, + "adr-22-plan-drift-guard.test.cjs": 3290, + "adr-230-pr-target-policy.test.cjs": 201, + "adr-612-bracket-grammar.test.cjs": 416, + "adr-612-collision-characterization.test.cjs": 174, + "adr-index-gate.test.cjs": 7042, + "adr-parser.property.test.cjs": 1423, + "adr-parser.test.cjs": 59, + "adr-parser.unit.test.cjs": 605, + "adr857-contribution-merge.test.cjs": 200, + "adr857-core-without-capabilities.test.cjs": 6187, + "adr857-predicate-boundary.test.cjs": 113, + "affected-tests-lib.test.cjs": 240, + "agent-classification-parity.test.cjs": 49, + "agent-fragments-emission.install.test.cjs": 79456, + "agent-frontmatter.test.cjs": 1076, + "agent-install-check.test.cjs": 274, + "agent-install-validation.test.cjs": 12405, + "agent-marker-documentation-guard.test.cjs": 108, + "agent-required-reading-consistency.test.cjs": 396, + "agent-size-budget.test.cjs": 126, + "agent-skills-awareness.test.cjs": 225, + "agent-skills-bootstrap.test.cjs": 169, + "agent-skills.test.cjs": 28917, + "ai-evals.test.cjs": 6762, + "allowlist-ratchet.test.cjs": 151, + "analyze-dependencies.test.cjs": 56, + "anti-pattern-enforcement.test.cjs": 56, + "antigravity-repo-grounding.test.cjs": 87, + "antigravity-reviewer.test.cjs": 199, + "antigravity-upgrades.test.cjs": 7589, + "api-coverage-gate-e2e.test.cjs": 7002, + "api-coverage.test.cjs": 1266, + "artifacts.test.cjs": 339, + "ask-user-questions-fallback.test.cjs": 394, + "assumption-delta-checkpoint-e2e.test.cjs": 5359, + "assumption-delta.test.cjs": 1552, + "atomic-write-coverage.test.cjs": 47, + "audit-command-cutover.test.cjs": 6076, + "audit-fix-command.test.cjs": 179, + "augment-conversion.test.cjs": 77, + "augment-upgrades.test.cjs": 10336, + "autonomous-allowed-tools.test.cjs": 41, + "autonomous-converge.test.cjs": 524, + "autonomous-decomposition.test.cjs": 43, + "autonomous-interactive.test.cjs": 43, + "autonomous-to-flag.test.cjs": 81, + "autonomous-ui-steps.test.cjs": 80, + "backwards-compat-phase-id.test.cjs": 107, + "branch-no-track-guard.test.cjs": 120, + "broken-windows-description.test.cjs": 24, + "broken-windows.test.cjs": 13686, + "bugs-1656-1657.test.cjs": 261, + "capability-cli.test.cjs": 47297, + "capability-command-dispatch.test.cjs": 323, + "capability-consent.test.cjs": 7105, + "capability-consumption.test.cjs": 267, + "capability-ledger.test.cjs": 1361, + "capability-lifecycle.test.cjs": 6019, + "capability-loader.test.cjs": 1123, + "capability-manifest-version.test.cjs": 477, + "capability-matrix-sync.test.cjs": 41, + "capability-precedence-parity.test.cjs": 96, + "capability-registry.test.cjs": 38250, + "capability-source.test.cjs": 1888, + "capability-state.test.cjs": 7894, + "capability-trust.test.cjs": 259, + "capability-writer.test.cjs": 3230, + "chain-flag-plan-phase.test.cjs": 144, + "changeset-cli.test.cjs": 7271, + "changeset-github-release-notes.test.cjs": 1222, + "changeset-lint.test.cjs": 1088, + "changeset-new.test.cjs": 194, + "changeset-parse.test.cjs": 205, + "changeset-render.test.cjs": 38, + "changeset-serialize.test.cjs": 62, + "check-env.test.cjs": 11248, + "check-gap-analysis-plan-post-e2e.test.cjs": 15187, + "check-glossary-refs.test.cjs": 1299, + "check-predicate.test.cjs": 364, + "check-tdd-review-checkpoint-e2e.test.cjs": 6287, + "check-ui-plan-gate.test.cjs": 166, + "check-ui-safety-gate.test.cjs": 2195, + "check-update-config-dir.test.cjs": 351, + "ci-full-lane-sharding.test.cjs": 114, + "ci-rebase-check.test.cjs": 1922, + "ci-test-job-timeout-budget.test.cjs": 78, + "ci-test-scope.test.cjs": 48164, + "cjs-command-router-adapter.test.cjs": 6030, + "claude-imperative-reference.test.cjs": 110, + "claude-md-path.test.cjs": 3806, + "claude-md.test.cjs": 11799, + "claude-orchestration-command-router.test.cjs": 3122, + "claude-orchestration.test.cjs": 1908, + "claude-skills-migration.test.cjs": 201, + "cleanup-branch-pruning.test.cjs": 190, + "cli-exit.test.cjs": 1316, + "cline-beforetool-upgrade.test.cjs": 72, + "cline-dispatch-degradation.test.cjs": 40, + "cline-imperative-reference.test.cjs": 76, + "cline-install.test.cjs": 3511, + "cline-model-override-upgrade.test.cjs": 51, + "cline-support.test.cjs": 82, + "clock-seam.test.cjs": 10986, + "close-phase-todos-padded-resolves.test.cjs": 798, + "close-phase-todos-stage-deletion.test.cjs": 32, + "clusters.test.cjs": 58, + "code-review-agent-skills.test.cjs": 136, + "code-review-command.test.cjs": 576, + "code-review-flags.test.cjs": 119, + "code-review-pipeline-regression.test.cjs": 214, + "code-review-summary-parser.test.cjs": 85, + "code-review.test.cjs": 5022, + "codebase-mapper-date-restamp.test.cjs": 138, + "codebuddy-install.test.cjs": 3763, + "codebuddy-upgrades.test.cjs": 2932, + "codex-config.test.cjs": 127783, + "codex-declarative-reference.test.cjs": 205, + "command-arg-projection.test.cjs": 113, + "command-contract.test.cjs": 1105, + "command-routing-hub.test.cjs": 31039, + "commands.test.cjs": 75735, + "commit-docs-bypass.test.cjs": 2892, + "commit-files-deletion.test.cjs": 2909, + "commit-files-pathspec.test.cjs": 6768, + "commonjs-marker.test.cjs": 29531, + "concurrency-safety.test.cjs": 12226, + "config-defaults-runtime-exclusion.test.cjs": 1177, + "config-field-docs.test.cjs": 1377, + "config-get-default.test.cjs": 17431, + "config-loader.test.cjs": 7014, + "config-schema.property.test.cjs": 4065, + "config.test.cjs": 141323, + "configuration-migrate-config.test.cjs": 2219, + "context-composer.property.test.cjs": 283, + "context-composer.test.cjs": 110, + "context-enrichment.test.cjs": 158, + "context-index-sync.test.cjs": 105, + "context-predicates-query.test.cjs": 14164, + "context-predicates.property.test.cjs": 334, + "context-predicates.test.cjs": 319, + "context-utilization.property.test.cjs": 178, + "context-utilization.test.cjs": 121, + "context7-plugin-grant-parity.test.cjs": 105, + "context7-tool-name-parity.test.cjs": 140, + "continuation-grammar-parity.test.cjs": 123, + "contributor-standards.test.cjs": 169, + "conventional-title.property.test.cjs": 229, + "conventional-title.test.cjs": 261, + "copilot-install.test.cjs": 55244, + "copilot-upgrades.test.cjs": 6069, + "core-utils.test.cjs": 270, + "coverage-metadata-parser.test.cjs": 10242, + "coverage-uat-routing.test.cjs": 2153, + "cross-ai-execution.test.cjs": 219, + "cursor-conversion.test.cjs": 51, + "cursor-dispatch-upgrade.test.cjs": 55, + "cursor-hook-bus-upgrade.test.cjs": 74, + "cursor-hooks.test.cjs": 436, + "cursor-imperative-reference.test.cjs": 79, + "cursor-reviewer.test.cjs": 123, + "debug-session-management.test.cjs": 104, + "debug-session-manager-commit.test.cjs": 1865, + "debugger-bug-taxonomy.test.cjs": 67, + "debugger-fix-acceptance.test.cjs": 110, + "debugger-prevention.test.cjs": 212, + "debugger-rca-branching.test.cjs": 126, + "debugger-repro-hardening.test.cjs": 91, + "debugger-sbfl.test.cjs": 118, + "debugger-semantic-recall.test.cjs": 106, + "decisions.test.cjs": 13530, + "declarative-reference-antigravity.test.cjs": 1582, + "declarative-reference-augment.test.cjs": 2079, + "declarative-reference-codebuddy.test.cjs": 1824, + "declarative-reference-copilot.test.cjs": 1492, + "declarative-reference-windsurf.test.cjs": 2161, + "declarative-reference-zcode.test.cjs": 2119, + "defaults-json-fallback.test.cjs": 188, + "derive-progress.property.test.cjs": 396, + "discord-release-summary.test.cjs": 70, + "discuss-all-flag.test.cjs": 92, + "discuss-checkpoint.test.cjs": 152, + "discuss-mode.test.cjs": 115, + "discuss-phase-power.test.cjs": 115, + "dispatcher.test.cjs": 15340, + "docs-parity-live-registry.test.cjs": 745, + "docs-update.test.cjs": 8402, + "drift-detection.test.cjs": 7336, + "edge-probe-docs-fixtures.test.cjs": 126, + "edge-probe-planner-contract.test.cjs": 33, "edge-probe-spec-phase-contract.test.cjs": 48, - "edge-probe.test.cjs": 1274, - "edit-phase.test.cjs": 174, - "effort-surface-axis.test.cjs": 6187, - "effort-sync-installed-runtime.test.cjs": 1523, - "emitted-attribution.test.cjs": 174010, - "emitted-provenance.test.cjs": 32365, - "ensure-runtime-build.test.cjs": 6792, - "eslint-rules.test.cjs": 1622, - "estimate-calibrate.test.cjs": 10138, - "estimate-loop-convergence.test.cjs": 13467, - "eval.property.test.cjs": 232, - "eval.test.cjs": 82, - "event.test.cjs": 90, - "execute-mvp-tdd-gate.test.cjs": 1710, - "execute-phase-active-flags.test.cjs": 191, - "execute-phase-step-5-5-deviation-doc.test.cjs": 80, - "execute-phase-wave.test.cjs": 2068, - "execute-phase-worktree-artifacts.test.cjs": 141, - "execute-phase-worktree-guard.test.cjs": 3527, - "execute-wave-post-gate-pipeline-e2e.test.cjs": 8859, - "executor-mvp-tdd-section.test.cjs": 123, - "explore-command.test.cjs": 95, - "external-descriptor-confinement.test.cjs": 75, - "external-descriptor-loader-wiring.test.cjs": 214, - "external-job-waiting.test.cjs": 72, - "external-job.test.cjs": 157, - "extract-learnings.test.cjs": 122, - "extraction-discipline-consistency.test.cjs": 52, - "feat-2296-provider-escalation.test.cjs": 28336, - "federated-config-key-removal.test.cjs": 111, - "federated-config-loadconfig.test.cjs": 642, - "federated-config.test.cjs": 202, - "few-shot-calibration.test.cjs": 105, - "fix-1941-quick-worktree-stale-base.test.cjs": 61, - "fix-2068-resolve-execution-dynamic-routing.test.cjs": 5656, - "fix-2136-clock-local-today.test.cjs": 555, - "fix-2138-ship-note-lost-on-merge.test.cjs": 55, - "fix-2194-review-timeout-guidance.test.cjs": 39, - "fix-2196-debug-agent-handoff.test.cjs": 58, - "fix-2257-debug-nonterminal-resume.test.cjs": 46, - "fix-2284-hermes-agent-delegate-task-projection.test.cjs": 8516, - "fix-2285-claude-orchestration-wiring.test.cjs": 4018, - "fix-2287-deferred-items-reader.test.cjs": 3324, - "fix-2289-context-monitor-event-allowlist.test.cjs": 4290, - "fix-2297-resolve-model-ids-runtime-scoping.test.cjs": 195, - "fix-2337-add-todo-severity.test.cjs": 68, - "fix-2358-review-temp-path-scoping.test.cjs": 84, - "fix-2494-review-claude-gemini-empty-guard.test.cjs": 161, - "fix-2587-cursor-hook-workspace-roots.test.cjs": 3751, - "fix-2589-workflow-jq-dependency.test.cjs": 186, - "fix-2590-workflow-script-contract.test.cjs": 2504, - "fix-2598-opencode-background-dispatch.test.cjs": 40, - "fix-2603-kimi-code-host-matrix.test.cjs": 190, - "fix-2605-review-local-server-empty-guard.test.cjs": 100, - "fix-2608-commit-staging-failure.test.cjs": 7752, - "fix-2615-effortsurface-matrix-parity.test.cjs": 261, - "fix-2717-cursor-windsurf-codex-commonjs-marker.test.cjs": 9415, - "fix-2794-review-qwen-empty-guard.test.cjs": 81, - "fixture-builder.test.cjs": 342, - "forensics.test.cjs": 107, - "format-github-release-notes.test.cjs": 193, - "frontmatter-cli.test.cjs": 16119, - "frontmatter.property.test.cjs": 2602, - "frontmatter.test.cjs": 10076, - "frontmatter.unit.test.cjs": 543, - "gap-checker.property.test.cjs": 11380, - "gate-predicate-evaluator.test.cjs": 296, - "gates-taxonomy.test.cjs": 66, - "gemini-runtime-removed.test.cjs": 5349, - "gen-context-index.test.cjs": 4858, - "gen-registry.test.cjs": 2902, - "getdirname-registry-derivation.test.cjs": 38, - "git-base-branch.test.cjs": 7151, - "global-config-home-fragment.test.cjs": 91, - "golden-install-tree.test.cjs": 27233, - "golden-parity-single-source.test.cjs": 123, - "graphify-auto-update.slow.test.cjs": 40040, - "graphify-command-cutover.test.cjs": 12649, - "graphify-graph-path.test.cjs": 487, - "graphify-query.test.cjs": 1285, - "graphify-visualization.test.cjs": 4536, - "graphify.test.cjs": 986, - "gsd-check-update-worker-platform-gate.test.cjs": 1485, - "gsd-mcp-server-bin.test.cjs": 467, - "gsd-mcp-server.test.cjs": 1020, - "gsd-researcher-app-aware.test.cjs": 124, - "gsd-researcher-flow-diagram.test.cjs": 55, - "gsd-settings-advanced.test.cjs": 2870, - "gsd-statusline-git.property.test.cjs": 2359, - "gsd-statusline-state.property.test.cjs": 313, - "gsd-statusline.test.cjs": 7746, - "gsd-tools-path-refs.test.cjs": 261, - "gsd-workflow.structure.test.cjs": 191, - "gsd2-import.test.cjs": 5048, - "handshake-serialized.test.cjs": 259, - "hardcoded-paths.test.cjs": 501, - "health-validation.test.cjs": 16001, - "helpers-cleanup.test.cjs": 32, - "helpers-process-isolation.test.cjs": 44, - "hermes-dispatch-upgrade.test.cjs": 34, - "hermes-imperative-reference.test.cjs": 150, - "hermes-skills-migration.test.cjs": 132, - "hook-bus.test.cjs": 62, - "hook-validation.test.cjs": 128, - "hooks-opt-in.test.cjs": 8147, - "host-integration-descriptors.test.cjs": 410, - "host-integration-validator-parity.test.cjs": 107, - "host-integration.test.cjs": 11827, - "hub-logger-integration.test.cjs": 98, - "import-command.test.cjs": 189, - "ingest-docs.test.cjs": 3247, - "init-manager.test.cjs": 20228, - "init.test.cjs": 70651, - "injection-blocking-config.test.cjs": 1034, - "inline-plan-threshold.test.cjs": 1185, - "install-minimal-hooks.test.cjs": 145698, - "install-nested-layout.test.cjs": 3004, - "install-path-detection.test.cjs": 6137, - "install-regressions.test.cjs": 30663, - "install-runtime-artifacts.test.cjs": 32308, - "install-update-marker.test.cjs": 142, - "install-write-confinement.test.cjs": 4019, - "install.test.cjs": 112670, - "installer-migration-authoring.test.cjs": 119, - "installer-migration-install.integration.test.cjs": 59743, - "installer-migration-pi-extension-ext.test.cjs": 59, - "installer-migration-prune-stale-pristine.test.cjs": 92, - "installer-migration-rename-gsd-core.test.cjs": 141, - "installer-migration-report.test.cjs": 279, - "installer-migrations.test.cjs": 4771, - "intel-command-cutover.test.cjs": 6893, - "intel.test.cjs": 6937, - "inventory-headings-countfree.test.cjs": 37, - "inventory-manifest-sync.test.cjs": 76, - "io.test.cjs": 2030, - "ios-scaffold-safety.test.cjs": 73, - "issue-1575-agent-descriptor-parity.test.cjs": 3011, - "issue-1855-marketplace-manifest.test.cjs": 75, - "issue-2045-third-party-skills-surface.test.cjs": 2906, - "issue-2517-runtime-aware-profiles.test.cjs": 1842, - "issue-2639-codex-toml-neutralization.test.cjs": 42, - "issue-2695-codex-hook-set.test.cjs": 23677, - "issue-2701-nul-corrupted-validators.test.cjs": 4725, - "issue-2762-plan-reviews-chunked.test.cjs": 40, - "issue-2765-brace-expansion-lockfile.test.cjs": 2351, - "issue-2771-advisor-subagent-type.test.cjs": 21, - "issue-2772-discuss-phase-text-inconsistencies.test.cjs": 46, - "issue-2828-flat-roadmap-total-phases.test.cjs": 832, - "issue-2834-codex-install-model-ordering.test.cjs": 58, - "issue-429-comment-text-gate.test.cjs": 1560, - "issue-498-identity-drift-lint.test.cjs": 212, - "issue-498-package-identity.test.cjs": 118, - "issue-498-update-backup-runtime-dir.test.cjs": 31, - "issue-498-update-context.test.cjs": 589, - "issue-57-runtime-install-no-drift.test.cjs": 113, - "issue-607-cache-lineage.test.cjs": 55, - "issue-607-installer-dry-run.install.test.cjs": 821, - "issue-607-legacy-cleanup.test.cjs": 228, - "issue-69-surface-keeps-nested.test.cjs": 512, - "issue-766-plugin-manifest.test.cjs": 421, - "issue-787-cline-hooks-agents.test.cjs": 7701, - "issue-815-update-next-channel.test.cjs": 221, - "issue-844-manifest-version-sync.test.cjs": 306, - "issue-dedupe.test.cjs": 297, - "issue-version-gate.test.cjs": 220, - "kilo-imperative-reference.test.cjs": 69, - "kilo-upgrades.test.cjs": 12639, - "kimi-agent-converter.test.cjs": 7478, - "kimi-guard-normalization-parity.test.cjs": 62, - "kimi-guard-typed-payload-reads.test.cjs": 91, - "kimi-imperative-reference.test.cjs": 165, - "kimi-normalize-payload.property.test.cjs": 359, - "kimi-payload-field-shadowing.security.test.cjs": 1280, - "kimi-skill-converter.test.cjs": 63, - "kimi-tool-mapping.test.cjs": 33, - "kimi-upgrades.test.cjs": 7963, - "kimi-variant-disambiguation.test.cjs": 1745, - "learnings.test.cjs": 2333, - "lint-docs-required.test.cjs": 140, - "lint-legacy-dir-name.test.cjs": 1035, - "lint-pr-check-project-dir.test.cjs": 304, - "lint-regression-test-names.test.cjs": 1703, - "lint-resolution-provenance.test.cjs": 47, - "lint-skill-deps.test.cjs": 1875, - "lint-test-file-count.test.cjs": 1547, - "list-seeds.property.test.cjs": 195, - "list-seeds.test.cjs": 7535, - "locking-bugs-1909-1916-1925-1927.test.cjs": 3772, - "logger.test.cjs": 163, - "loop-hook-firing-spike.test.cjs": 41, - "loop-hooks-empty-points-e2e.test.cjs": 14192, - "loop-hooks-ship-pre-e2e.test.cjs": 3657, - "loop-hooks-verify-post-e2e.test.cjs": 7479, - "loop-host-contract.test.cjs": 362, - "loop-render-hooks.test.cjs": 8041, - "m8-writestatemd-scan-after-lock.test.cjs": 77, - "m9-statelock-write-error-orphan.test.cjs": 61, - "managed-hooks.test.cjs": 9104, - "markdown-sectionizer.test.cjs": 2218, - "markdown-table.test.cjs": 307, - "mcp-tool-inheritance.test.cjs": 374, - "mempalace-capture-headless-invocation.test.cjs": 181, - "methodology-artifact.test.cjs": 61, - "milestone-archive.test.cjs": 10365, - "milestone-helper.test.cjs": 86, - "milestone-prefixed-convention.test.cjs": 3461, - "milestone-summary.test.cjs": 1511, - "milestone.test.cjs": 32900, - "model-adapter.test.cjs": 62, - "model-alias-map.test.cjs": 50, - "model-catalog-runtime-defaults.test.cjs": 63, - "model-catalog-valid-tiers.test.cjs": 125, - "model-omit-when-inherit-guard.test.cjs": 6322, - "model-profiles.test.cjs": 2920, - "model-resolver.test.cjs": 37304, - "model-routing-spawn-threading.test.cjs": 360, - "multi-runtime-select.test.cjs": 168, - "mutation-matrix-ratchet.test.cjs": 407, - "mutation-matrix-stdin-eagain.test.cjs": 44, - "mutation-workflow-base-ref.test.cjs": 2979, - "mvp-phase-command.test.cjs": 65, - "mvp-phase-integration.test.cjs": 2771, - "mvp-phase-spidr.test.cjs": 64, - "new-milestone-clear-phases.test.cjs": 12089, - "new-project-mvp-prompt.test.cjs": 143, - "next-decimal-roadmap-scan.test.cjs": 4968, - "next-safety-gates.test.cjs": 60, - "next-up-clear-order.test.cjs": 133, - "no-bare-gsd-tools-command-position.test.cjs": 167, - "no-bare-npm-exec.rule.test.cjs": 229, - "no-crlf-fragile-split.rule.test.cjs": 436, - "no-dead-sdk-refs.test.cjs": 223, - "no-hardcoded-home-gsd-tools.test.cjs": 261, - "no-hardcoded-tmp.rule.test.cjs": 257, - "no-path-literal-in-assert.rule.test.cjs": 559, - "no-phantom-issue-refs.test.cjs": 1008, - "no-posix-mode-bit-assert.rule.test.cjs": 492, - "no-unguarded-nonportable-exec.rule.test.cjs": 638, - "non-claude-runtimes-registry-derivation.test.cjs": 34, - "normalize-path-in-content.rule.test.cjs": 1395, - "normalize-test-command.test.cjs": 170, - "npm-integrity-gate.test.cjs": 3769, - "onboard-command.test.cjs": 11426, - "opencode-command-dir-plural.test.cjs": 14426, - "opencode-imperative-reference.test.cjs": 78, - "opencode-permissions.test.cjs": 40, - "opencode-plugin-adapter.test.cjs": 3248, - "opencode-review-reconstruction.property.test.cjs": 314, - "orphan-worktree-detection.test.cjs": 1193, - "orphaned-hooks.test.cjs": 73, - "package-legitimacy-gate.test.cjs": 371, - "package-legitimacy.property.test.cjs": 179, - "package-legitimacy.test.cjs": 243, - "package-manifest.test.cjs": 137, - "package-name-single-source.test.cjs": 143, - "parallel-dependent-plans.test.cjs": 65, - "path-replacement.test.cjs": 499, - "pattern-mapper.test.cjs": 2907, - "pause-work-improvements.test.cjs": 299, - "perf-315-loadconfig-subrepo-scan.test.cjs": 65, - "perf-316-state-lock-buffer-alloc.test.cjs": 654, - "perf-317-context-monitor-fs.test.cjs": 6679, - "perf-407-planning-lock-buffer-alloc.test.cjs": 54, - "phase-command-router.test.cjs": 2634, - "phase-dependency-levels.test.cjs": 38, - "phase-estimation.test.cjs": 19034, - "phase-id-drift-guard.test.cjs": 589, - "phase-id.test.cjs": 1184, - "phase-lifecycle.test.cjs": 49, - "phase-locator.test.cjs": 410, - "phase.test.cjs": 158303, - "phase6-capability-docs.test.cjs": 70, - "phase6-capstone-conformance.test.cjs": 3974, - "phase6-planning-capabilities.test.cjs": 60, - "phase6-review-capabilities.test.cjs": 113, - "phases-command-router.test.cjs": 2148, - "pi-extension-reachability.test.cjs": 1709, - "pi-imperative-reference.test.cjs": 79, - "pi-upgrades.test.cjs": 322, - "pick-flag.test.cjs": 2970, - "plan-bounce.test.cjs": 1299, - "plan-phase-drift-guard.test.cjs": 688, - "plan-phase-mvp-flag.test.cjs": 991, - "plan-phase-ui-redirect.test.cjs": 201, - "plan-pre-hook-e2e.test.cjs": 7507, - "plan-review-convergence.test.cjs": 6033, - "planner-decomposition.test.cjs": 109, - "planner-estimate-emission.test.cjs": 88, - "planner-language-regression.test.cjs": 1153, - "planner-mvp-mode.test.cjs": 70, - "planning-workspace.test.cjs": 7034, - "platform-guard.unit.test.cjs": 462, - "playwright-ui-verify.test.cjs": 37, - "policy-138-nyquist-config-default.test.cjs": 59, - "policy-160-route0-resume.test.cjs": 249, - "policy-lint-shallow-checkout.test.cjs": 66, - "policy-release-no-npm-self-upgrade.test.cjs": 47, - "policy-shell-pinning.test.cjs": 781, - "portability-rule-disable-ban.test.cjs": 29608, - "portability-vocab-drift.test.cjs": 2089, - "post-planning-gaps-2493.test.cjs": 25540, - "pr-template-policy.test.cjs": 112, - "precommit-alias-drift-hook.test.cjs": 958, - "precondition-element.test.cjs": 1454, - "prepush-enterprise-email-hook.test.cjs": 535, - "probe-core.property.test.cjs": 391, - "probe-core.test.cjs": 218, - "product-name-purity.test.cjs": 51, - "profile-output.test.cjs": 8064, - "profile-pipeline.test.cjs": 5317, - "progress-forensic.test.cjs": 142, - "progress-mvp-display.test.cjs": 98, - "prohibition-enforcement.property.test.cjs": 160, - "prohibition-enforcement.test.cjs": 56619, - "prohibition-probe.docs-fixtures.test.cjs": 76, - "prohibition-probe.planner-contract.test.cjs": 79, - "prohibition-probe.schema.test.cjs": 179, - "prohibition-probe.spec-phase-contract.test.cjs": 128, - "prohibition-probe.validators.test.cjs": 67, - "prohibition-probe.verify-tier.test.cjs": 51, - "project-instruction-file-parity.test.cjs": 5695, - "project-root.test.cjs": 112, - "prompt-budget-cli.test.cjs": 3641, - "prompt-budget.property.test.cjs": 2106, - "prompt-budget.test.cjs": 103, - "prompt-budget.unit.test.cjs": 774, - "prompt-injection-scan.security.test.cjs": 1901, - "prompt-thinning.test.cjs": 193, - "prune-orphaned-worktrees.test.cjs": 1832, - "quick-branching.test.cjs": 1782, - "quick-commit-boundary.test.cjs": 110, - "quick-research.test.cjs": 4017, - "quick-session-management.test.cjs": 84, - "qwen-imperative-reference.test.cjs": 111, - "qwen-skills-migration.test.cjs": 244, - "qwen-upgrades.test.cjs": 5675, - "reachability-check.test.cjs": 88, - "read-guard.test.cjs": 6080, - "read-injection-scanner.property.test.cjs": 18583, - "read-injection-scanner.security.test.cjs": 6784, - "reapply-patches.test.cjs": 259, - "reapply-verify-hunks.test.cjs": 4674, - "redaction.test.cjs": 78, - "refactor-1390-t3-characterization.test.cjs": 290, - "registry-axes-parity.test.cjs": 154, - "registry-reviewer-parity.test.cjs": 85, - "registry-schema.test.cjs": 826, - "release-backmerge-invariants.test.cjs": 130, - "release-coverage-scope.test.cjs": 44, - "release-finalize-syncs-next-version.test.cjs": 31, - "release-tarball-smoke-bin-invocation.test.cjs": 57, + "edge-probe.test.cjs": 1205, + "edit-phase.test.cjs": 142, + "effort-surface-axis.test.cjs": 5485, + "effort-sync-installed-runtime.test.cjs": 1763, + "emitted-attribution.test.cjs": 189330, + "emitted-caps-gate.test.cjs": 2351, + "emitted-caps.test.cjs": 231, + "emitted-provenance.test.cjs": 38803, + "emitted-sizes.test.cjs": 3269, + "ensure-runtime-build.test.cjs": 7800, + "eslint-rules.test.cjs": 2624, + "estimate-calibrate.test.cjs": 10911, + "estimate-loop-convergence.test.cjs": 14663, + "eval.property.test.cjs": 309, + "eval.test.cjs": 96, + "event.test.cjs": 173, + "execute-mvp-tdd-gate.test.cjs": 1783, + "execute-phase-active-flags.test.cjs": 131, + "execute-phase-branch-base-ahead-warning.test.cjs": 24, + "execute-phase-step-5-5-deviation-doc.test.cjs": 120, + "execute-phase-wave.test.cjs": 1767, + "execute-phase-worktree-artifacts.test.cjs": 233, + "execute-phase-worktree-guard.test.cjs": 3160, + "execute-wave-post-gate-pipeline-e2e.test.cjs": 10289, + "executor-mvp-tdd-section.test.cjs": 134, + "explore-command.test.cjs": 121, + "external-descriptor-confinement.test.cjs": 62, + "external-descriptor-loader-wiring.test.cjs": 252, + "external-job-waiting.test.cjs": 60, + "external-job.test.cjs": 192, + "extract-learnings.test.cjs": 95, + "extraction-discipline-consistency.test.cjs": 72, + "faulty-deps.test.cjs": 1071, + "feat-2296-provider-escalation.test.cjs": 33715, + "feat-2646-deferred-items-audit-scanner.test.cjs": 6543, + "federated-config-key-removal.test.cjs": 528, + "federated-config-loadconfig.test.cjs": 620, + "federated-config.test.cjs": 261, + "few-shot-calibration.test.cjs": 87, + "fix-1700-spike-manifest-idea-scoping.test.cjs": 107, + "fix-1941-quick-worktree-stale-base.test.cjs": 68, + "fix-2068-resolve-execution-dynamic-routing.test.cjs": 5270, + "fix-2136-clock-local-today.test.cjs": 692, + "fix-2138-ship-note-lost-on-merge.test.cjs": 70, + "fix-2194-review-timeout-guidance.test.cjs": 49, + "fix-2196-debug-agent-handoff.test.cjs": 35, + "fix-2257-debug-nonterminal-resume.test.cjs": 76, + "fix-2284-hermes-agent-delegate-task-projection.test.cjs": 9484, + "fix-2285-claude-orchestration-wiring.test.cjs": 4486, + "fix-2287-deferred-items-reader.test.cjs": 2890, + "fix-2289-context-monitor-event-allowlist.test.cjs": 5841, + "fix-2297-resolve-model-ids-runtime-scoping.test.cjs": 354, + "fix-2337-add-todo-severity.test.cjs": 61, + "fix-2358-review-temp-path-scoping.test.cjs": 93, + "fix-2494-review-claude-gemini-empty-guard.test.cjs": 119, + "fix-2587-cursor-hook-workspace-roots.test.cjs": 4678, + "fix-2589-workflow-jq-dependency.test.cjs": 120, + "fix-2590-workflow-script-contract.test.cjs": 1990, + "fix-2598-opencode-background-dispatch.test.cjs": 150, + "fix-2603-kimi-code-host-matrix.test.cjs": 86, + "fix-2605-review-local-server-empty-guard.test.cjs": 168, + "fix-2608-commit-staging-failure.test.cjs": 9605, + "fix-2615-effortsurface-matrix-parity.test.cjs": 188, + "fix-2649-diagnose-issues-worktree-stale-base.test.cjs": 67, + "fix-2650-plan-phase-stall-detection.test.cjs": 44503, + "fix-2657-untrack-compiled-artifacts.test.cjs": 381, + "fix-2658-trae-runtime-detection-and-instruction-path.test.cjs": 2083, + "fix-2717-cursor-windsurf-codex-commonjs-marker.test.cjs": 9093, + "fix-2766-audit-uat-archived-and-table-shapes.test.cjs": 5786, + "fix-2794-review-qwen-empty-guard.test.cjs": 83, + "fix-2830-halted-plan-dependents.test.cjs": 11301, + "fix-2847-gap-closure-frontmatter.test.cjs": 62, + "fix-2855-phase-locator-workstream-archive-scope.test.cjs": 132, + "fix-3045-cursor-subagent-isolation.test.cjs": 14459, + "fix-3045-dispatch-isolation-resolver.test.cjs": 7355, + "fixture-builder.test.cjs": 324, + "forensics.test.cjs": 164, + "format-github-release-notes.test.cjs": 69, + "fragment-single-edit-propagation.install.test.cjs": 229914, + "frontmatter-cli.test.cjs": 25728, + "frontmatter.property.test.cjs": 2551, + "frontmatter.test.cjs": 10565, + "frontmatter.unit.test.cjs": 423, + "gap-checker.property.test.cjs": 12147, + "gate-predicate-evaluator-missing.test.cjs": 110, + "gate-predicate-evaluator.test.cjs": 186, + "gates-taxonomy.test.cjs": 57, + "gemini-runtime-removed.test.cjs": 5775, + "gen-context-index.test.cjs": 4024, + "gen-registry.test.cjs": 2754, + "gen-section-manifest.test.cjs": 3175, + "getdirname-registry-derivation.test.cjs": 32, + "git-base-branch.test.cjs": 7770, + "git-fixture.test.cjs": 1446, + "global-config-home-fragment.test.cjs": 33, + "golden-install-tree.test.cjs": 32585, + "golden-parity-single-source.test.cjs": 74, + "graphify-auto-update.slow.test.cjs": 37442, + "graphify-command-cutover.test.cjs": 13547, + "graphify-graph-path.test.cjs": 767, + "graphify-query.test.cjs": 17488, + "graphify-visualization.test.cjs": 4639, + "graphify.test.cjs": 902, + "gsd-agent-isolation-guard.test.cjs": 13745, + "gsd-check-update-worker-platform-gate.test.cjs": 1493, + "gsd-mcp-server-bin.test.cjs": 832, + "gsd-mcp-server.test.cjs": 958, + "gsd-researcher-app-aware.test.cjs": 57, + "gsd-researcher-flow-diagram.test.cjs": 115, + "gsd-settings-advanced.test.cjs": 4160, + "gsd-statusline-git.property.test.cjs": 2078, + "gsd-statusline-state.property.test.cjs": 164, + "gsd-statusline.test.cjs": 6522, + "gsd-tools-path-refs.test.cjs": 298, + "gsd-workflow.structure.test.cjs": 137, + "gsd-write-guard.property.test.cjs": 9794, + "gsd-write-guard.test.cjs": 8812, + "gsd2-import.test.cjs": 4683, + "handshake-serialized.test.cjs": 44, + "hardcoded-paths.test.cjs": 676, + "health-validation.test.cjs": 17036, + "helpers-cleanup.test.cjs": 110, + "helpers-process-isolation.test.cjs": 37, + "hermes-dispatch-upgrade.test.cjs": 33, + "hermes-imperative-reference.test.cjs": 75, + "hermes-skills-migration.test.cjs": 121, + "hook-bus.test.cjs": 50, + "hook-validation.test.cjs": 84, + "hooks-opt-in.test.cjs": 8614, + "host-integration-descriptors.test.cjs": 386, + "host-integration-validator-parity.test.cjs": 318, + "host-integration.test.cjs": 11468, + "hub-logger-integration.test.cjs": 129, + "import-command.test.cjs": 101, + "ingest-docs.test.cjs": 4016, + "init-debug-workflow-contract.test.cjs": 74, + "init-debug.test.cjs": 22785, + "init-manager.test.cjs": 24917, + "init.test.cjs": 113693, + "injection-blocking-config.test.cjs": 1543, + "inline-plan-threshold.test.cjs": 1116, + "install-minimal-hooks.test.cjs": 174882, + "install-nested-layout.test.cjs": 2783, + "install-path-detection.test.cjs": 5930, + "install-regressions.test.cjs": 41460, + "install-runtime-artifacts.test.cjs": 37114, + "install-update-marker.test.cjs": 128, + "install-write-confinement.test.cjs": 7167, + "install.test.cjs": 134719, + "installer-migration-authoring.test.cjs": 136, + "installer-migration-config-root-marker.test.cjs": 94, + "installer-migration-install.integration.test.cjs": 74165, + "installer-migration-pi-extension-ext.test.cjs": 61, + "installer-migration-prune-stale-pristine.test.cjs": 82, + "installer-migration-rename-gsd-core.test.cjs": 265, + "installer-migration-report.test.cjs": 339, + "installer-migrations.test.cjs": 7406, + "intel-command-cutover.test.cjs": 7613, + "intel.test.cjs": 7789, + "inventory-headings-countfree.test.cjs": 43, + "inventory-manifest-sync.test.cjs": 52, + "inventory-nested-families.test.cjs": 192, + "io.test.cjs": 2567, + "ios-scaffold-safety.test.cjs": 71, + "issue-1575-agent-descriptor-parity.test.cjs": 4530, + "issue-1855-marketplace-manifest.test.cjs": 309, + "issue-2045-third-party-skills-surface.test.cjs": 3282, + "issue-2517-runtime-aware-profiles.test.cjs": 2761, + "issue-2639-codex-toml-neutralization.test.cjs": 85, + "issue-2695-codex-hook-set.test.cjs": 27204, + "issue-2701-nul-corrupted-validators.test.cjs": 6250, + "issue-2762-plan-reviews-chunked.test.cjs": 37, + "issue-2765-brace-expansion-lockfile.test.cjs": 1918, + "issue-2771-advisor-subagent-type.test.cjs": 43, + "issue-2772-discuss-phase-text-inconsistencies.test.cjs": 74, + "issue-2828-flat-roadmap-total-phases.test.cjs": 445, + "issue-2834-codex-install-model-ordering.test.cjs": 30, + "issue-2927-reviewer-lane-overlay-invocation.test.cjs": 4610, + "issue-2939-dispatch-flatten-maxdepth.test.cjs": 62, + "issue-2940-codex-config-merge-trailing.test.cjs": 57, + "issue-2945-phase-complete-checkbox-rollback.test.cjs": 2489, + "issue-2949-phase-complete-stage3-sentinel.test.cjs": 2857, + "issue-2977-frontmatter-bom.test.cjs": 202, + "issue-429-comment-text-gate.test.cjs": 1204, + "issue-498-identity-drift-lint.test.cjs": 371, + "issue-498-package-identity.test.cjs": 127, + "issue-498-update-backup-runtime-dir.test.cjs": 30, + "issue-498-update-context.test.cjs": 608, + "issue-57-runtime-install-no-drift.test.cjs": 66, + "issue-607-cache-lineage.test.cjs": 90, + "issue-607-installer-dry-run.install.test.cjs": 959, + "issue-607-legacy-cleanup.test.cjs": 259, + "issue-69-surface-keeps-nested.test.cjs": 402, + "issue-766-plugin-manifest.test.cjs": 412, + "issue-787-cline-hooks-agents.test.cjs": 10063, + "issue-815-update-next-channel.test.cjs": 51, + "issue-844-manifest-version-sync.test.cjs": 269, + "issue-dedupe.test.cjs": 357, + "issue-version-gate.test.cjs": 286, + "kilo-imperative-reference.test.cjs": 118, + "kilo-upgrades.test.cjs": 15771, + "kimi-agent-converter.test.cjs": 10214, + "kimi-guard-normalization-parity.test.cjs": 43, + "kimi-guard-typed-payload-reads.test.cjs": 172, + "kimi-imperative-reference.test.cjs": 88, + "kimi-normalize-payload.property.test.cjs": 614, + "kimi-payload-field-shadowing.security.test.cjs": 1643, + "kimi-skill-converter.test.cjs": 34, + "kimi-tool-mapping.test.cjs": 35, + "kimi-upgrades.test.cjs": 28880, + "kimi-variant-disambiguation.test.cjs": 3560, + "learnings.test.cjs": 2925, + "lint-docs-command-form.test.cjs": 2986, + "lint-docs-required.test.cjs": 210, + "lint-legacy-dir-name.test.cjs": 1003, + "lint-pr-check-project-dir.test.cjs": 471, + "lint-regression-test-names.test.cjs": 1504, + "lint-resolution-provenance.test.cjs": 85, + "lint-skill-deps.test.cjs": 2176, + "lint-test-file-count.test.cjs": 1661, + "list-seeds.property.test.cjs": 201, + "list-seeds.test.cjs": 7164, + "load-bearing-contract-gate.test.cjs": 57, + "locking-bugs-1909-1916-1925-1927.test.cjs": 3223, + "logger.test.cjs": 168, + "loop-hook-firing-spike.test.cjs": 94, + "loop-hooks-empty-points-e2e.test.cjs": 15096, + "loop-hooks-ship-pre-e2e.test.cjs": 4395, + "loop-hooks-verify-post-e2e.test.cjs": 8055, + "loop-host-contract.test.cjs": 366, + "loop-render-hooks.test.cjs": 9266, + "loop-walk.qa.test.cjs": 28396, + "m8-writestatemd-scan-after-lock.test.cjs": 45, + "m9-statelock-write-error-orphan.test.cjs": 39, + "managed-hooks.test.cjs": 12020, + "markdown-sectionizer.test.cjs": 2192, + "markdown-table.test.cjs": 381, + "mcp-catalog-parity.install.test.cjs": 5166, + "mcp-catalog.property.test.cjs": 409, + "mcp-catalog.test.cjs": 291, + "mcp-server-catalog.test.cjs": 131, + "mcp-tool-inheritance.test.cjs": 231, + "mempalace-capture-gate-default.test.cjs": 24, + "mempalace-capture-headless-invocation.test.cjs": 53, + "methodology-artifact.test.cjs": 64, + "milestone-archive.test.cjs": 10539, + "milestone-helper.test.cjs": 171, + "milestone-prefixed-convention.test.cjs": 4739, + "milestone-summary.test.cjs": 1912, + "milestone.test.cjs": 42308, + "model-adapter.test.cjs": 31, + "model-alias-map.test.cjs": 62, + "model-catalog-runtime-defaults.test.cjs": 37, + "model-catalog-valid-tiers.test.cjs": 36, + "model-omit-when-inherit-guard.test.cjs": 8427, + "model-profiles.test.cjs": 2036, + "model-resolver.test.cjs": 42376, + "model-routing-spawn-threading.test.cjs": 247, + "multi-runtime-select.test.cjs": 155, + "mutation-matrix-ratchet.test.cjs": 626, + "mutation-matrix-stdin-eagain.test.cjs": 39, + "mutation-workflow-base-ref.test.cjs": 2795, + "mvp-phase-command.test.cjs": 48, + "mvp-phase-integration.test.cjs": 2125, + "mvp-phase-spidr.test.cjs": 69, + "new-milestone-clear-phases.test.cjs": 14574, + "new-project-mvp-prompt.test.cjs": 130, + "next-decimal-roadmap-scan.test.cjs": 4845, + "next-safety-gates.test.cjs": 73, + "next-up-clear-order.test.cjs": 236, + "no-bare-gsd-tools-command-position.test.cjs": 154, + "no-bare-npm-exec.rule.test.cjs": 282, + "no-crlf-fragile-split.rule.test.cjs": 333, + "no-dead-sdk-refs.test.cjs": 217, + "no-hardcoded-home-gsd-tools.test.cjs": 391, + "no-hardcoded-tmp.rule.test.cjs": 346, + "no-path-literal-in-assert.rule.test.cjs": 618, + "no-phantom-issue-refs.test.cjs": 995, + "no-posix-mode-bit-assert.rule.test.cjs": 673, + "no-unbounded-spawn-allowlist.test.cjs": 286, + "no-unbounded-spawn.test.cjs": 1429, + "no-unguarded-nonportable-exec.rule.test.cjs": 428, + "non-claude-runtimes-registry-derivation.test.cjs": 102, + "normalize-path-in-content.rule.test.cjs": 1518, + "normalize-test-command.test.cjs": 145, + "npm-integrity-gate.test.cjs": 3707, + "onboard-command.test.cjs": 13941, + "opencode-command-dir-plural.test.cjs": 17349, + "opencode-imperative-reference.test.cjs": 125, + "opencode-permissions.test.cjs": 79, + "opencode-plugin-adapter.test.cjs": 3312, + "opencode-review-reconstruction.property.test.cjs": 361, + "orphan-worktree-detection.test.cjs": 1632, + "orphaned-hooks.test.cjs": 36, + "package-legitimacy-gate.test.cjs": 745, + "package-legitimacy.property.test.cjs": 298, + "package-legitimacy.test.cjs": 190, + "package-manifest.test.cjs": 88, + "package-name-single-source.test.cjs": 160, + "packaging-shipped-scripts-require-only-shipped.test.cjs": 23676, + "parallel-dependent-plans.test.cjs": 102, + "path-replacement.test.cjs": 470, + "pattern-mapper.test.cjs": 4914, + "pause-work-improvements.test.cjs": 477, + "perf-315-loadconfig-subrepo-scan.test.cjs": 141, + "perf-316-state-lock-buffer-alloc.test.cjs": 923, + "perf-317-context-monitor-fs.test.cjs": 7980, + "perf-407-planning-lock-buffer-alloc.test.cjs": 86, + "phase-command-router.test.cjs": 3560, + "phase-dependency-levels.test.cjs": 37, + "phase-estimation.test.cjs": 25574, + "phase-id-drift-guard.test.cjs": 488, + "phase-id.test.cjs": 760, + "phase-lifecycle.test.cjs": 105, + "phase-locator.test.cjs": 477, + "phase.test.cjs": 166720, + "phase6-capability-docs.test.cjs": 192, + "phase6-capstone-conformance.test.cjs": 4936, + "phase6-planning-capabilities.test.cjs": 72, + "phase6-review-capabilities.test.cjs": 55, + "phases-command-router.test.cjs": 1972, + "pi-extension-reachability.test.cjs": 1686, + "pi-imperative-reference.test.cjs": 65, + "pi-upgrades.test.cjs": 309, + "pick-flag.test.cjs": 4706, + "plan-bounce.test.cjs": 1658, + "plan-phase-drift-guard.test.cjs": 487, + "plan-phase-mvp-flag.test.cjs": 734, + "plan-phase-ui-redirect.test.cjs": 204, + "plan-pre-hook-e2e.test.cjs": 7661, + "plan-review-convergence.test.cjs": 5670, + "planner-decomposition.test.cjs": 128, + "planner-estimate-emission.test.cjs": 232, + "planner-language-regression.test.cjs": 1060, + "planner-mvp-mode.test.cjs": 40, + "planning-workspace.test.cjs": 9074, + "platform-guard.unit.test.cjs": 734, + "playwright-ui-verify.test.cjs": 30, + "policy-138-nyquist-config-default.test.cjs": 33, + "policy-160-route0-resume.test.cjs": 167, + "policy-lint-shallow-checkout.test.cjs": 42, + "policy-release-no-npm-self-upgrade.test.cjs": 72, + "policy-shell-pinning.test.cjs": 446, + "portability-rule-disable-ban.test.cjs": 32091, + "portability-vocab-drift.test.cjs": 1922, + "post-planning-gaps-2493.test.cjs": 24245, + "pr-template-policy.test.cjs": 128, + "precommit-alias-drift-hook.test.cjs": 1059, + "precondition-element.test.cjs": 1243, + "prepush-enterprise-email-hook.test.cjs": 447, + "probe-core.property.test.cjs": 509, + "probe-core.test.cjs": 309, + "process-seam.test.cjs": 8731, + "product-name-purity.test.cjs": 110, + "profile-output.test.cjs": 9683, + "profile-pipeline.test.cjs": 6064, + "progress-forensic.test.cjs": 85, + "progress-mvp-display.test.cjs": 182, + "prohibition-enforcement.property.test.cjs": 189, + "prohibition-enforcement.test.cjs": 59691, + "prohibition-probe.docs-fixtures.test.cjs": 195, + "prohibition-probe.planner-contract.test.cjs": 55, + "prohibition-probe.schema.test.cjs": 98, + "prohibition-probe.spec-phase-contract.test.cjs": 83, + "prohibition-probe.validators.test.cjs": 41, + "prohibition-probe.verify-tier.test.cjs": 257, + "project-instruction-file-parity.test.cjs": 5394, + "project-root.test.cjs": 130, + "prompt-budget-cli.test.cjs": 3993, + "prompt-budget-parity.test.cjs": 87, + "prompt-budget.property.test.cjs": 2628, + "prompt-budget.test.cjs": 105, + "prompt-budget.unit.test.cjs": 601, + "prompt-injection-scan.security.test.cjs": 1253, + "prompt-thinning.test.cjs": 97, + "prune-orphaned-worktrees.test.cjs": 1223, + "quick-branching.test.cjs": 2380, + "quick-commit-boundary.test.cjs": 73, + "quick-research.test.cjs": 4873, + "quick-session-management.test.cjs": 98, + "qwen-imperative-reference.test.cjs": 330, + "qwen-skills-migration.test.cjs": 1047, + "qwen-upgrades.test.cjs": 8100, + "reachability-check.test.cjs": 44, + "read-guard.test.cjs": 5618, + "read-injection-scanner.property.test.cjs": 22710, + "read-injection-scanner.security.test.cjs": 6812, + "reapply-patches.test.cjs": 224, + "reapply-verify-hunks.test.cjs": 4143, + "redaction.test.cjs": 83, + "refactor-1390-t3-characterization.test.cjs": 239, + "registry-axes-parity.test.cjs": 203, + "registry-reviewer-parity.test.cjs": 94, + "registry-schema.test.cjs": 803, + "release-backmerge-invariants.test.cjs": 100, + "release-coverage-scope.test.cjs": 19, + "release-finalize-syncs-next-version.test.cjs": 69, + "release-hotfix-empty-cherry-pick.test.cjs": 1062, + "release-tarball-smoke-bin-invocation.test.cjs": 69, "release-tarball-smoke-workflow.test.cjs": 64, - "release-tarball-smoke.install.test.cjs": 156753, - "repo-invariants.test.cjs": 23695, - "repo-layout.test.cjs": 116, - "representative-corpus.test.cjs": 3967, - "require-fs-op-fallback.rule.test.cjs": 396, - "require-userprofile-with-home.rule.test.cjs": 397, - "research-agent-profiles.test.cjs": 188, - "research-cli.test.cjs": 10934, - "research-provider.property.test.cjs": 316, - "research-provider.test.cjs": 185, - "research-store.property.test.cjs": 299, - "research-store.test.cjs": 347, - "resolution.test.cjs": 218, - "resolve-dispatch-type.test.cjs": 92, - "reversibility-tagging.test.cjs": 3685, - "review-default-reviewers-config.test.cjs": 6287, - "review-default-reviewers-resolution.test.cjs": 53, - "review-default-reviewers-workflow.test.cjs": 222, - "review-lane-descriptor.test.cjs": 524, - "review-lane-invocation.test.cjs": 605, - "review-lane-runner.test.cjs": 326, - "review-model-config.test.cjs": 8789, - "review-reviewer-instances-config.test.cjs": 1689, - "review-reviewer-instances.test.cjs": 134, - "review-reviewer-selection.test.cjs": 85, - "reviewer-config-federation.test.cjs": 5448, - "reviewer-docs-parity.test.cjs": 12981, - "reviewer-lane-declarations.test.cjs": 145, - "reviewer-manifest-body.test.cjs": 1243, - "reviewer-trust-disclosure.test.cjs": 808, - "roadmap-command-router.test.cjs": 87, - "roadmap-mode-field.test.cjs": 2499, - "roadmap-parser.test.cjs": 30303, - "roadmap-phase-fallback.test.cjs": 3910, - "roadmap-upgrade.test.cjs": 536, - "roadmap.test.cjs": 51467, - "roadmapper-granularity.test.cjs": 6335, - "run-tests-harness.test.cjs": 194169, - "run-with-timeout.test.cjs": 18838, - "runtime-artifact-install-plan.test.cjs": 98, - "runtime-artifact-layout-descriptor-drive.test.cjs": 293, - "runtime-artifact-layout-install-profiles.test.cjs": 2619, - "runtime-artifact-layout-surface.test.cjs": 2347, - "runtime-artifact-layout.test.cjs": 6450, - "runtime-config-adapter-registry.test.cjs": 235, - "runtime-converters.test.cjs": 771, - "runtime-flags.test.cjs": 63, - "runtime-homes-descriptor-drive.test.cjs": 489, - "runtime-homes.property.test.cjs": 378, - "runtime-label-policy.test.cjs": 183, - "runtime-launcher-parity.test.cjs": 2615, - "runtime-name-policy.test.cjs": 149, - "scan-command.test.cjs": 63, - "schema-drift.test.cjs": 5026, - "sdk-removal-query-family-dispatch.test.cjs": 2727, - "sdk-smoke.test.cjs": 104, - "secret-scan-lint.security.test.cjs": 4384, - "secure-phase-single-writer.test.cjs": 49, - "secure-phase.test.cjs": 287, - "security-dead-exports.regression.test.cjs": 61, - "security-prompt-injection.security.test.cjs": 9278, - "security-scan.security.test.cjs": 11821, - "security.test.cjs": 1590, - "seed-scan-new-milestone.test.cjs": 72, + "release-tarball-smoke.install.test.cjs": 167690, + "repo-invariants.test.cjs": 25592, + "repo-layout.test.cjs": 221, + "representative-corpus.test.cjs": 4343, + "require-fs-op-fallback.rule.test.cjs": 671, + "require-userprofile-with-home.rule.test.cjs": 426, + "research-agent-profiles.test.cjs": 290, + "research-cli.test.cjs": 12258, + "research-provider.property.test.cjs": 228, + "research-provider.test.cjs": 208, + "research-store.property.test.cjs": 427, + "research-store.test.cjs": 363, + "resolution.test.cjs": 47, + "resolve-dispatch-type.test.cjs": 72, + "resolver-hoist-guard.test.cjs": 303, + "retired-artifact-cleanup.test.cjs": 292, + "reversibility-tagging.test.cjs": 3968, + "review-default-reviewers-config.test.cjs": 6215, + "review-default-reviewers-resolution.test.cjs": 83, + "review-default-reviewers-workflow.test.cjs": 254, + "review-lane-descriptor.test.cjs": 643, + "review-lane-invocation.test.cjs": 528, + "review-lane-runner.test.cjs": 475, + "review-model-config.test.cjs": 9373, + "review-reviewer-instances-config.test.cjs": 1676, + "review-reviewer-instances.test.cjs": 186, + "review-reviewer-selection.test.cjs": 134, + "reviewer-config-federation.test.cjs": 5705, + "reviewer-docs-parity.test.cjs": 13117, + "reviewer-lane-declarations.test.cjs": 171, + "reviewer-manifest-body.test.cjs": 1706, + "reviewer-trust-disclosure.test.cjs": 855, + "roadmap-command-router.test.cjs": 42, + "roadmap-mode-field.test.cjs": 2305, + "roadmap-parser.test.cjs": 29242, + "roadmap-phase-fallback.test.cjs": 4059, + "roadmap-upgrade.test.cjs": 440, + "roadmap.test.cjs": 60326, + "roadmapper-granularity.test.cjs": 6931, + "run-tests-harness.test.cjs": 223372, + "run-with-timeout.test.cjs": 22895, + "runtime-artifact-install-plan.test.cjs": 124, + "runtime-artifact-layout-descriptor-drive.test.cjs": 256, + "runtime-artifact-layout-install-profiles.test.cjs": 3188, + "runtime-artifact-layout-surface.test.cjs": 3727, + "runtime-artifact-layout.test.cjs": 8396, + "runtime-brand-swap-parity.test.cjs": 78, + "runtime-config-adapter-registry.test.cjs": 304, + "runtime-converters.test.cjs": 534, + "runtime-flags.test.cjs": 37, + "runtime-homes-descriptor-drive.test.cjs": 702, + "runtime-homes.property.test.cjs": 352, + "runtime-label-policy.test.cjs": 59, + "runtime-launcher-parity.test.cjs": 2206, + "runtime-name-policy.test.cjs": 128, + "scan-command.test.cjs": 96, + "schema-drift.test.cjs": 5129, + "sdk-removal-query-family-dispatch.test.cjs": 2725, + "sdk-smoke.test.cjs": 54, + "secret-scan-lint.security.test.cjs": 3885, + "section-manifest-init-facts.test.cjs": 15827, + "section-manifest.property.test.cjs": 487, + "section-manifest.test.cjs": 538, + "section-marker-inversion-guard.test.cjs": 125, + "secure-phase-single-writer.test.cjs": 48, + "secure-phase.test.cjs": 303, + "security-dead-exports.regression.test.cjs": 119, + "security-prompt-injection.security.test.cjs": 8289, + "security-scan.security.test.cjs": 16659, + "security.test.cjs": 1666, + "seed-scan-new-milestone.test.cjs": 91, "semver-compare.test.cjs": 144, - "settings-integrations.test.cjs": 15868, - "settings-jsonc.test.cjs": 133, - "sh-hook-paths.test.cjs": 133, - "shell-command-projection-dispatch.test.cjs": 4647, - "shell-command-projection-path-sep.test.cjs": 193, - "skill-frontmatter-contract.test.cjs": 1149, - "skill-manifest.test.cjs": 4759, - "slash-command-namespace.test.cjs": 5158, - "slurm-adapter.test.cjs": 59, - "smart-entry.unit.test.cjs": 2494, - "spawn-liveness-banner.test.cjs": 174, - "spec-phase-probe-reachability.test.cjs": 210, - "spec-section.test.cjs": 620, - "stale-bake-guard.test.cjs": 462, - "state-acquirestatelock-non-eexist.test.cjs": 199, - "state-command-cutover.test.cjs": 1147, - "state-document.test.cjs": 454, - "state-io.test.cjs": 67, - "state-prune.test.cjs": 7892, - "state-rebuild-cli.test.cjs": 3037, - "state-rebuild.test.cjs": 134, - "state-transition.test.cjs": 352, - "state.test.cjs": 131709, - "stats-mvp-display.test.cjs": 754, - "subagent-timeout.test.cjs": 7278, - "surface-empty-manifest-agents.test.cjs": 184, - "surface-md-paths.regression.test.cjs": 55, - "sync-next-version.test.cjs": 91, - "table-schema-drift-lint.test.cjs": 231, - "tdd-mode.test.cjs": 10230, - "teams-status.test.cjs": 3130, - "temp-subdir.test.cjs": 94, - "template.test.cjs": 5352, - "test-failure-reasons.test.cjs": 337, - "test-gate-watch-mode.test.cjs": 136, - "thinking-model-guidance.test.cjs": 212, - "thinking-partner.test.cjs": 799, - "thread-session-management.test.cjs": 65, - "todos-done-rename-guard.test.cjs": 235, - "trace-correlation.test.cjs": 66, - "tracer-bullet.test.cjs": 3119, - "trae-imperative-reference.test.cjs": 79, - "trae-upgrades.test.cjs": 5069, - "transition-verification-gate.test.cjs": 47, - "tsconfig-noemit.test.cjs": 7241, - "uat-predicate.test.cjs": 498, - "uat.test.cjs": 16989, - "ui-checker-adversarial-stance.test.cjs": 29, - "ui-consideration-probe-docs-fixtures.test.cjs": 81, - "ui-consideration-probe.test.cjs": 321, - "ui-review-next-guidance.test.cjs": 72, - "ui-safety-gate.test.cjs": 106, - "ultraplan-phase.test.cjs": 437, - "untrusted-input-isolation.test.cjs": 183, - "unusable-input.test.cjs": 438, - "update-custom-backup.test.cjs": 27765, - "validate-context.test.cjs": 5228, - "validate-registry.test.cjs": 2174, - "verification-overrides.test.cjs": 141, - "verification-status.test.cjs": 3372, - "verifier-behavior-unverified.test.cjs": 93, + "settings-integrations.test.cjs": 15116, + "settings-jsonc.test.cjs": 119, + "sh-hook-paths.test.cjs": 146, + "shell-command-projection-dispatch.test.cjs": 4982, + "shell-command-projection-path-sep.test.cjs": 260, + "skill-frontmatter-contract.test.cjs": 1345, + "skill-manifest.test.cjs": 5349, + "slash-command-namespace.test.cjs": 4909, + "slurm-adapter.test.cjs": 108, + "smart-entry.unit.test.cjs": 3186, + "spawn-liveness-banner.test.cjs": 166, + "spec-phase-probe-reachability.test.cjs": 55, + "spec-section.test.cjs": 668, + "stale-bake-guard.test.cjs": 548, + "state-acquirestatelock-non-eexist.test.cjs": 177, + "state-command-cutover.test.cjs": 1084, + "state-document.test.cjs": 444, + "state-io.test.cjs": 52, + "state-prune.test.cjs": 7213, + "state-rebuild-cli.test.cjs": 3336, + "state-rebuild.test.cjs": 210, + "state-transition.test.cjs": 432, + "state.test.cjs": 152684, + "stats-mvp-display.test.cjs": 637, + "subagent-timeout.test.cjs": 6544, + "surface-empty-manifest-agents.test.cjs": 96, + "surface-md-paths.regression.test.cjs": 106, + "sync-next-version.test.cjs": 135, + "table-schema-drift-lint.test.cjs": 159, + "tdd-mode.test.cjs": 9789, + "teams-status.test.cjs": 2581, + "temp-subdir.test.cjs": 132, + "template.test.cjs": 5687, + "test-failure-reasons.test.cjs": 46, + "test-gate-watch-mode.test.cjs": 135, + "thinking-model-guidance.test.cjs": 442, + "thinking-partner.test.cjs": 747, + "thread-session-management.test.cjs": 100, + "todos-done-rename-guard.test.cjs": 290, + "trace-correlation.test.cjs": 54, + "tracer-bullet.test.cjs": 2886, + "trae-imperative-reference.test.cjs": 121, + "trae-upgrades.test.cjs": 7465, + "transition-verification-gate.test.cjs": 51, + "trim-safety.test.cjs": 143, + "tsconfig-noemit.test.cjs": 7162, + "uat-predicate.test.cjs": 558, + "uat.test.cjs": 18339, + "ui-checker-adversarial-stance.test.cjs": 38, + "ui-consideration-probe-docs-fixtures.test.cjs": 64, + "ui-consideration-probe.test.cjs": 171, + "ui-review-next-guidance.test.cjs": 69, + "ui-safety-gate.test.cjs": 91, + "ultraplan-phase.test.cjs": 163, + "untrusted-input-isolation.test.cjs": 56, + "unusable-input.test.cjs": 244, + "update-custom-backup.test.cjs": 29931, + "validate-context.test.cjs": 4932, + "validate-registry.test.cjs": 2041, + "verification-overrides.test.cjs": 133, + "verification-status.test.cjs": 6341, + "verifier-behavior-unverified.test.cjs": 131, "verifier-deferred-items.test.cjs": 91, - "verifier-mvp-section.test.cjs": 49, - "verifier-spotcheck-test-discipline.test.cjs": 107, - "verify-health.test.cjs": 35460, - "verify-mvp-uat.test.cjs": 76, - "verify-npm-publish.test.cjs": 54, - "verify-test-quality.test.cjs": 133, - "verify-work-auto-transition.test.cjs": 108, - "verify.test.cjs": 38878, - "vscode-browser-no-node-api.test.cjs": 54, - "vscode-extension-reachability.test.cjs": 1841, - "vscode-ide-reference.test.cjs": 153, - "vscode-lm-tools.test.cjs": 1894, - "vscode-subagent-dispatch.test.cjs": 2095, - "windows-robustness.test.cjs": 501, - "windsurf-conversion.test.cjs": 170, - "windsurf-hooks-bridge.test.cjs": 6161, - "windsurf-install.test.cjs": 66, - "workflow-compat.test.cjs": 140, - "workflow-guard-registration.test.cjs": 410, - "workflow-guard.test.cjs": 2286, - "workflow-maintainer-skip.test.cjs": 109, - "workflow-shell-pinning.test.cjs": 48, - "workflow-size-budget.test.cjs": 321, - "workflow-size.test.cjs": 216, - "workflow-step-tag-balance.test.cjs": 143, - "workspace.test.cjs": 7781, - "workstream-inventory.test.cjs": 77, - "workstream-name-policy.test.cjs": 36, - "workstream.test.cjs": 41535, - "worktree-base-ref.test.cjs": 272, - "worktree-baseref-install.test.cjs": 10423, - "worktree-cleanup.test.cjs": 3393, - "worktree-safety.test.cjs": 18865, - "worktree.test.cjs": 1034, - "write-set.test.cjs": 201 + "verifier-mvp-section.test.cjs": 38, + "verifier-spotcheck-test-discipline.test.cjs": 30, + "verify-health.test.cjs": 37335, + "verify-mvp-uat.test.cjs": 74, + "verify-npm-publish.test.cjs": 58, + "verify-test-quality.test.cjs": 339, + "verify-work-auto-transition.test.cjs": 70, + "verify.test.cjs": 42371, + "vscode-browser-no-node-api.test.cjs": 78, + "vscode-extension-reachability.test.cjs": 2043, + "vscode-ide-reference.test.cjs": 106, + "vscode-lm-tools.test.cjs": 2453, + "vscode-subagent-dispatch.test.cjs": 2682, + "windows-robustness.test.cjs": 238, + "windsurf-conversion.test.cjs": 203, + "windsurf-hooks-bridge.test.cjs": 4388, + "windsurf-install.test.cjs": 121, + "workflow-compat.test.cjs": 296, + "workflow-fragments-emission.install.test.cjs": 113902, + "workflow-fragments.property.test.cjs": 281, + "workflow-fragments.test.cjs": 305, + "workflow-guard-registration.test.cjs": 490, + "workflow-guard.test.cjs": 2696, + "workflow-maintainer-skip.test.cjs": 77, + "workflow-shell-pinning.test.cjs": 70, + "workflow-size-budget.test.cjs": 377, + "workflow-size.test.cjs": 136, + "workflow-step-tag-balance.test.cjs": 169, + "workspace.test.cjs": 8258, + "workstream-inventory.test.cjs": 3541, + "workstream-name-policy.test.cjs": 49, + "workstream.test.cjs": 44722, + "worktree-base-ref.test.cjs": 220, + "worktree-baseref-install.test.cjs": 14460, + "worktree-cleanup.test.cjs": 2309, + "worktree-safety-reap.test.cjs": 15672, + "worktree-safety.test.cjs": 23463, + "worktree.test.cjs": 817, + "write-set.test.cjs": 138 } } diff --git a/tests/verification-status.test.cjs b/tests/verification-status.test.cjs index 6d3f78661..5c122825c 100644 --- a/tests/verification-status.test.cjs +++ b/tests/verification-status.test.cjs @@ -38,10 +38,8 @@ const { readVerificationStatus, } = require('../gsd-core/bin/lib/verification.cjs'); -// 15000ms: git plumbing (init/config/add/commit) against a small mkdtemp -// fixture repo, plus a `git --version` availability probe — gitOrThrow's own -// documented default, reused here as this file's named constant. -const GIT_TIMEOUT_MS = 15000; +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); // ─── Helpers ───────────────────────────────────────────────────────────────── diff --git a/tests/verify.test.cjs b/tests/verify.test.cjs index b12c66ec7..d6e33bd89 100644 --- a/tests/verify.test.cjs +++ b/tests/verify.test.cjs @@ -10,11 +10,8 @@ const { runGsdTools, createTempProject, createTempGitProject, cleanup } = requir const { gitOrThrow } = require('./helpers/git-fixture.cjs'); const { runHook } = require('./helpers/process-seam.cjs'); -/** - * Bound for every git subprocess in this file: plumbing (init/config/add/ - * commit/rev-parse) against small fixture repos — well under this. #3144. - */ -const GIT_TIMEOUT_MS = 15000; +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); /** * Bound for the grep/sed availability probes and region-extraction calls in diff --git a/tests/windsurf-hooks-bridge.test.cjs b/tests/windsurf-hooks-bridge.test.cjs index 29cd1a798..fa9b693f0 100644 --- a/tests/windsurf-hooks-bridge.test.cjs +++ b/tests/windsurf-hooks-bridge.test.cjs @@ -44,8 +44,8 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); -const { execFileSync } = require('node:child_process'); const { runHook: runHookSeam } = require('./helpers/process-seam.cjs'); +const { gitOrThrow } = require('./helpers/git-fixture.cjs'); const { createTempDir, cleanup } = require('./helpers.cjs'); @@ -76,10 +76,10 @@ function runHook(scriptPath, payload, opts = {}) { } function initGitRepo(dir) { - execFileSync('git', ['init', '-q', '.'], { cwd: dir, stdio: 'pipe' }); - execFileSync('git', ['config', 'user.email', 'gsd-test@example.com'], { cwd: dir, stdio: 'pipe' }); - execFileSync('git', ['config', 'user.name', 'gsd-test'], { cwd: dir, stdio: 'pipe' }); - execFileSync('git', ['commit', '--allow-empty', '-q', '-m', 'init'], { cwd: dir, stdio: 'pipe' }); + gitOrThrow(['init', '-q', '.'], { cwd: dir }); + gitOrThrow(['config', 'user.email', 'gsd-test@example.com'], { cwd: dir }); + gitOrThrow(['config', 'user.name', 'gsd-test'], { cwd: dir }); + gitOrThrow(['commit', '--allow-empty', '-q', '-m', 'init'], { cwd: dir }); } // --------------------------------------------------------------------------- diff --git a/tests/workflow-fragments-emission.install.test.cjs b/tests/workflow-fragments-emission.install.test.cjs index cf0b4538b..d2d10568c 100644 --- a/tests/workflow-fragments-emission.install.test.cjs +++ b/tests/workflow-fragments-emission.install.test.cjs @@ -49,7 +49,7 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const crypto = require('node:crypto'); -const { spawnSync } = require('node:child_process'); +const { runNode } = require('./helpers/process-seam.cjs'); const { cleanup } = require('./helpers.cjs'); const { RUNTIME_META, runMinimalInstall, installerEnv } = require('./helpers/install-shared.cjs'); @@ -58,6 +58,8 @@ const { executionContextRefs } = require('../scripts/command-contract-helpers.cj const { composeWorkflow } = require('../gsd-core/bin/lib/workflow-fragments.cjs'); const REPO_ROOT = path.join(__dirname, '..'); +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const PILOT_REL = path.join('gsd-core', 'workflows', 'execute-phase.md'); const PILOT_PATH = path.join(REPO_ROOT, PILOT_REL); // plan-phase.md was the original #2930 pilot but was reverted to unmarked @@ -88,11 +90,12 @@ function spawnGlobalInstall(installScript, runtime, extraArgs = []) { root, ...extraArgs, ]; - const result = spawnSync(process.execPath, args, { + const seamResult = runNode(args, { cwd: root, - encoding: 'utf8', env: installerEnv({ HOME: root, USERPROFILE: root }), + timeoutMs: INSTALL_TIMEOUT_MS, }); + const result = { status: seamResult.exitCode, stdout: seamResult.stdout, stderr: seamResult.stderr }; return { result, configDir: root, root }; } diff --git a/tests/workspace.test.cjs b/tests/workspace.test.cjs index 44710bab5..531e7ba26 100644 --- a/tests/workspace.test.cjs +++ b/tests/workspace.test.cjs @@ -12,10 +12,8 @@ const path = require('path'); const { runGsdTools, createTempProject, createTempDir, cleanup } = require('./helpers.cjs'); const { detectChildRepos } = require('../gsd-core/bin/lib/init.cjs'); const { gitOrThrow } = require('./helpers/git-fixture.cjs'); - -// 15000ms: git plumbing (init/config/add/commit/worktree/clone) on a small -// mkdtemp fixture repo — far over any observed duration for that class of call. -const GIT_TIMEOUT_MS = 15000; +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); // ─── detectChildRepos ──────────────────────────────────────────────────────── diff --git a/tests/worktree-baseref-install.test.cjs b/tests/worktree-baseref-install.test.cjs index 3939895a7..1a6a05512 100644 --- a/tests/worktree-baseref-install.test.cjs +++ b/tests/worktree-baseref-install.test.cjs @@ -24,16 +24,13 @@ const path = require('path'); const os = require('os'); const { runNode } = require('./helpers/process-seam.cjs'); const { throwIfFailed } = require('./helpers/git-fixture.cjs'); +const { BUILD_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); const { install, finishInstall } = require(INSTALL_SRC); const { cleanup } = require('./helpers.cjs'); -// 60000ms: matches the process seam's own default for a Node CLI run — this -// is a real build step (scripts/build-hooks.js), not fixture plumbing. -const BUILD_TIMEOUT_MS = 60000; - // ─── Ensure hooks/dist/ is populated before install tests ──────────────────── before(() => { const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_TIMEOUT_MS }); diff --git a/tests/worktree-cleanup.test.cjs b/tests/worktree-cleanup.test.cjs index 1c40724e1..0ef835575 100644 --- a/tests/worktree-cleanup.test.cjs +++ b/tests/worktree-cleanup.test.cjs @@ -1503,10 +1503,9 @@ const { gitOrThrow } = require('./helpers/git-fixture.cjs'); const REPO_ROOT = path.join(__dirname, '..'); const EXECUTE_PHASE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'execute-phase.md'); -// 15000ms: git plumbing (init/config/add/commit/worktree) against a small -// mkdtemp fixture repo — gitOrThrow's own documented default; the guard -// itself keeps its separately-justified 30000ms (see runGuard below). -const GIT_TIMEOUT_MS = 15000; +// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. +// The guard itself keeps its separately-justified 30000ms (see runGuard below). +const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); // --------------------------------------------------------------------------- // Extract the cwd-drift guard bash block from execute-phase.md diff --git a/tests/worktree-safety-reap.test.cjs b/tests/worktree-safety-reap.test.cjs index 4aac62946..4a6c09c75 100644 --- a/tests/worktree-safety-reap.test.cjs +++ b/tests/worktree-safety-reap.test.cjs @@ -52,6 +52,11 @@ const STALE_MTIME = new Date(0); /** The lock-owner PID written into every fixture; liveness is always injected. */ const LOCK_OWNER_PID = '4242'; +// #3145: deliberately double the GIT_TIMEOUT_MS class norm (see +// helpers/timeouts.cjs) — each test here does real-git worktree/branch setup +// AND a `.git/worktrees//` admin-directory mutation AND one or more +// reapOrphanWorktrees invocations, more subprocess work per test than the +// plain fixture-setup case the norm is sized for. const GIT_TIMEOUT_MS = 30000; // ─── Path + git helpers ──────────────────────────────────────────────────────