* test(#4667): add failing-first coverage for the codex @-include rewrite Behavioral end-to-end: a real in-process install(true,'codex') into a temp CODEX_HOME must leave zero @~/.claude includes in GSD-owned .md artifacts, rewrite the issue's own example include to @~/.codex/, keep the deliberate _GSD_RUNTIME_ROOT .claude fallback chains byte-identical, and stay idempotent across a reinstall (no doubled prefix). All four are RED until the installer grows the manifest-scoped rewrite pass. * fix(#4667): rewrite codex @ includes to the codex install root Codex-installed agents and commands kept @~/.claude/gsd-core/... (and @/Users/trekkie/.claude/gsd-core/...) include references pointing into the Claude install: silent wrong-copy reads on dual-runtime machines at divergent versions, missing files on codex-only ones. Several emitters bypass the per-runtime converters, so the per-emitter fixes since #570 rotted. Adds a manifest-scoped rewrite pass in install() beside the leak scanner: for codex, every manifest-tracked .md/.toml artifact has the @-include forms rewritten to the codex root. The pass matches the exact include literal only — the _GSD_RUNTIME_ROOT/$PREFERRED_CONFIG_DIR fallback chains, prose .claude mentions, and CHANGELOG.md are untouched — and runs before the scanner, which remains the verification backstop for anything a future emitter introduces. * test(#4667): cover the HOME-anchored include form and sync the pass comment Adds behavioral coverage for the second rewrite literal (@$HOME/.claude/gsd-core/ -> @$HOME/.codex/gsd-core/) via the plan-review-convergence command, and corrects the pass's comment: the agent .tomls are generated after it and prefix themselves, so the .toml branch of the rewrite is inert by design. * test(#4667): baseline the offline upgrade against the deployed tree (sanctioned) * chore(#4667): backfill changeset PR number (4858) * test(#4667): sandbox the install home in the include-rewrite tests (#3712 guard) * test(#4667): install via subprocess with an isolated env in the include-rewrite tests --------- Co-authored-by: sim <sim@local>
917 lines
43 KiB
JavaScript
917 lines
43 KiB
JavaScript
// allow-test-rule: integration-test-input
|
||
// The script under test (scripts/release-tarball-smoke.cjs) is the system
|
||
// under test. We exercise it via its exported pure function, not by reading
|
||
// source text. The tarball fixture is produced by npm pack in before().
|
||
|
||
'use strict';
|
||
|
||
process.env.GSD_TEST_MODE = '1';
|
||
|
||
const { describe, test, before, after } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
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 { cleanup, createTempDir, runNpm, isolatedNpmEnv } = require('./helpers.cjs');
|
||
const { ensureHooksDist } = require('./helpers/hooks-dist.cjs');
|
||
const { SMOKE, runSmoke, entrypointFixtureHome, CHILD_TIMEOUT_MS, configuredEntrypointsIn } = require('../scripts/release-tarball-smoke.cjs');
|
||
|
||
const smokeMsg = (label, result) =>
|
||
`${label}: code=${result.code} details=${JSON.stringify(result.details)}`;
|
||
|
||
const PKG_PATH = path.join(__dirname, '..', 'package.json');
|
||
const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf-8'));
|
||
|
||
function installedPackageRoot(prefix) {
|
||
const parts = pkg.name.split('/');
|
||
const posix = path.join(prefix, 'lib', 'node_modules', ...parts);
|
||
const windows = path.join(prefix, 'node_modules', ...parts);
|
||
return fs.existsSync(posix) ? posix : windows;
|
||
}
|
||
|
||
function hashTree(root) {
|
||
const result = new Map();
|
||
const visit = (dir) => {
|
||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
const absolute = path.join(dir, entry.name);
|
||
if (entry.isDirectory()) visit(absolute);
|
||
else if (entry.isFile()) {
|
||
result.set(
|
||
path.relative(root, absolute).replace(/\\/g, '/'),
|
||
crypto.createHash('sha256').update(fs.readFileSync(absolute)).digest('hex'),
|
||
);
|
||
}
|
||
}
|
||
};
|
||
visit(root);
|
||
return result;
|
||
}
|
||
|
||
// ─── runSmoke install timeout must clear a slow-host cold install (#2335) ────
|
||
// Regression: runSmoke()'s internal `npm install -g` used CHILD_TIMEOUT_MS,
|
||
// which was 120 s on non-Windows while before()'s pack+install used 600 s. A
|
||
// cold-cache install of the 1499-file tarball takes 3–6 min on a slow bench, so
|
||
// the per-test installs (B/C/D/E) fired SIGTERM at 120 s and returned a spurious
|
||
// INSTALL_FAILED (`spawnSync npm ETIMEDOUT`, empty stdout/stderr) on cartographer
|
||
// while passing on faster holodeck — a host-dependent false failure, not a flake.
|
||
// The ceiling is now a single exported constant shared by both surfaces; this
|
||
// pins it at/above the documented 600 s slow-host floor on EVERY platform, so a
|
||
// reintroduced 120 s (or a Windows-only 600 s) fails here instead of on a bench.
|
||
describe('release-tarball-smoke: install timeout ceiling', () => {
|
||
test('CHILD_TIMEOUT_MS clears the 600 s slow-host cold-install floor', () => {
|
||
assert.ok(
|
||
Number.isInteger(CHILD_TIMEOUT_MS) && CHILD_TIMEOUT_MS >= 600_000,
|
||
`CHILD_TIMEOUT_MS must be >= 600000ms for cartographer-class hosts; got ${CHILD_TIMEOUT_MS}`,
|
||
);
|
||
});
|
||
|
||
test('the ceiling is platform-uniform — no host slower than the CI matrix is left under-provisioned', () => {
|
||
// The slow-host reality (cold disk, constrained CPU) is not OS-specific, so
|
||
// the constant must not be gated behind `process.platform`. A single numeric
|
||
// constant satisfies this by construction; this guards against a future
|
||
// reintroduction of a per-platform ternary that under-provisions Linux/macOS.
|
||
assert.equal(typeof CHILD_TIMEOUT_MS, 'number');
|
||
assert.ok(CHILD_TIMEOUT_MS >= 600_000);
|
||
});
|
||
});
|
||
|
||
describe('release-tarball-smoke', () => {
|
||
// Shared fixture state: pack the tarball once, install it once, reuse for all tests.
|
||
let packDir;
|
||
let installPrefix;
|
||
let tarballPath;
|
||
// fixtureDir for lifecycle / init tests; created once in before(), cleaned in after().
|
||
let fixtureDir;
|
||
|
||
before(async () => {
|
||
// hooks/dist is gitignored and only produced by `npm run build:hooks`; the
|
||
// pack below must ship it or Cycle 4's entrypoint scan runs against a
|
||
// tarball with no hook scripts at all (SMOKE.INIT_FAILED on a clean tree).
|
||
ensureHooksDist();
|
||
|
||
// Pack once into a temp dir.
|
||
packDir = createTempDir('gsd-smoke-pack-');
|
||
installPrefix = createTempDir('gsd-smoke-prefix-');
|
||
fixtureDir = createTempDir('gsd-smoke-fixture-');
|
||
|
||
// npm pack + npm install -g on a large tarball (1499 files, ~10 MB) can take
|
||
// 3–6 minutes on slow Docker hosts (cold disk, constrained CPU). The runNpm
|
||
// default timeout of 180 s is sufficient on fast machines but insufficient on
|
||
// cartographer-class hosts. Share the smoke script's CHILD_TIMEOUT_MS ceiling
|
||
// so before() (pack+install) and runSmoke()'s per-test installs cannot diverge
|
||
// — divergence was the #2335-run defect: before() had 600 s, runSmoke had 120 s
|
||
// on Linux, so the per-test installs alone timed out on cartographer.
|
||
const SLOW_HOST_TIMEOUT = CHILD_TIMEOUT_MS;
|
||
|
||
const packOutput = runNpm(
|
||
['pack', '--pack-destination', packDir],
|
||
{ cwd: path.join(__dirname, '..'), timeout: SLOW_HOST_TIMEOUT },
|
||
);
|
||
|
||
// npm pack prints the filename as the last line of stdout.
|
||
const lines = packOutput.split(/\r?\n/).filter(Boolean);
|
||
const tgzName = lines[lines.length - 1];
|
||
tarballPath = path.join(packDir, tgzName);
|
||
if (!fs.existsSync(tarballPath)) {
|
||
const found = fs.readdirSync(packDir).find((f) => f.endsWith('.tgz'));
|
||
if (!found) throw new Error(`npm pack produced no .tgz in ${packDir}; output: ${packOutput}`);
|
||
tarballPath = path.join(packDir, found);
|
||
}
|
||
|
||
// Install once into installPrefix. All tests share this install.
|
||
runNpm(['install', '-g', '--prefix', installPrefix, tarballPath], { timeout: SLOW_HOST_TIMEOUT });
|
||
});
|
||
|
||
after(() => {
|
||
cleanup(packDir);
|
||
cleanup(installPrefix);
|
||
cleanup(fixtureDir);
|
||
});
|
||
|
||
// ── Test A — happy path ────────────────────────────────────────────────────
|
||
test('A: happy path — installed version matches package.json', () => {
|
||
const result = runSmoke({
|
||
tarballPath,
|
||
installPrefix,
|
||
expectedVersion: pkg.version,
|
||
fixtureDir,
|
||
npmEnv: isolatedNpmEnv(),
|
||
});
|
||
|
||
assert.equal(result.code, SMOKE.OK, smokeMsg('A', result));
|
||
assert.equal(result.details.version, pkg.version, smokeMsg('A', result));
|
||
});
|
||
|
||
// ── Test B — version mismatch detected ────────────────────────────────────
|
||
test('B: version mismatch detected — returns VERSION_MISMATCH', () => {
|
||
const result = runSmoke({
|
||
tarballPath,
|
||
installPrefix,
|
||
expectedVersion: '99.99.99',
|
||
fixtureDir,
|
||
npmEnv: isolatedNpmEnv(),
|
||
});
|
||
|
||
assert.equal(result.code, SMOKE.VERSION_MISMATCH, smokeMsg('B', result));
|
||
});
|
||
|
||
// ── Test C — happy lifecycle ───────────────────────────────────────────────
|
||
// Verifies that the installed package has all expected command .md files and
|
||
// that each command resolves a workflow .md file that also exists.
|
||
// Also verifies that `gsd-core --local --claude` (init) succeeds in
|
||
// the fixtureDir and creates the expected .claude/ directories.
|
||
test('C: happy lifecycle — command + workflow files resolve OK', () => {
|
||
const result = runSmoke({
|
||
tarballPath,
|
||
installPrefix,
|
||
expectedVersion: pkg.version,
|
||
fixtureDir,
|
||
lifecycleCommands: ['init', 'discuss-phase', 'plan-phase'],
|
||
npmEnv: isolatedNpmEnv(),
|
||
});
|
||
|
||
assert.equal(result.code, SMOKE.OK, smokeMsg('C', result));
|
||
|
||
// Each non-init command must be in lifecycleResolved with both paths populated
|
||
const resolved = result.details.lifecycleResolved;
|
||
assert.ok(Array.isArray(resolved));
|
||
|
||
for (const entry of resolved) {
|
||
assert.ok(
|
||
typeof entry.commandPath === 'string' && entry.commandPath.length > 0,
|
||
`expected commandPath for ${entry.command}`,
|
||
);
|
||
assert.ok(
|
||
fs.existsSync(entry.commandPath) && fs.statSync(entry.commandPath).isFile(),
|
||
`commandPath must be an existing file: ${entry.commandPath}`,
|
||
);
|
||
assert.ok(
|
||
typeof entry.workflowPath === 'string' && entry.workflowPath.length > 0,
|
||
`expected workflowPath for ${entry.command}`,
|
||
);
|
||
assert.ok(
|
||
fs.existsSync(entry.workflowPath) && fs.statSync(entry.workflowPath).isFile(),
|
||
`workflowPath must be an existing file: ${entry.workflowPath}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
// ── Test D — missing command detected ─────────────────────────────────────
|
||
// Passes a nonexistent command name; expects the smoke to detect the missing
|
||
// command .md file and return COMMAND_FILE_MISSING with the right details.
|
||
test('D: missing command detected — returns COMMAND_FILE_MISSING', () => {
|
||
const result = runSmoke({
|
||
tarballPath,
|
||
installPrefix,
|
||
expectedVersion: pkg.version,
|
||
fixtureDir,
|
||
lifecycleCommands: ['init', 'nonexistent-phase-xyz'],
|
||
npmEnv: isolatedNpmEnv(),
|
||
});
|
||
|
||
assert.equal(result.code, SMOKE.COMMAND_FILE_MISSING, smokeMsg('D', result));
|
||
assert.equal(result.details.command, 'nonexistent-phase-xyz', smokeMsg('D', result));
|
||
assert.ok(typeof result.details.path === 'string' && result.details.path.length > 0, smokeMsg('D', result));
|
||
});
|
||
|
||
// ── Test E — workflow-body checks run (informational) ─────────────────────
|
||
// Asserts that the workflow-body scanning machinery ran (structural assertion).
|
||
// Does NOT assert colonLeakCount is zero — when those issues are fixed, this
|
||
// test continues to pass unchanged.
|
||
test('E: workflow-body checks run — scan counts are present integers', () => {
|
||
const result = runSmoke({
|
||
tarballPath,
|
||
installPrefix,
|
||
expectedVersion: pkg.version,
|
||
fixtureDir,
|
||
lifecycleCommands: [],
|
||
npmEnv: isolatedNpmEnv(),
|
||
});
|
||
|
||
// Structural: the scan ran and populated the counters
|
||
assert.ok(
|
||
Number.isInteger(result.details.workflowsScanned) && result.details.workflowsScanned >= 1,
|
||
smokeMsg('E', result),
|
||
);
|
||
assert.ok(
|
||
Number.isInteger(result.details.colonLeakCount),
|
||
smokeMsg('E', result),
|
||
);
|
||
});
|
||
|
||
test('F: packed package carries the complete canonical Runtime Surface corpus', () => {
|
||
const packageRoot = installedPackageRoot(installPrefix);
|
||
assert.deepStrictEqual(
|
||
hashTree(path.join(packageRoot, 'commands', 'gsd')),
|
||
hashTree(path.join(__dirname, '..', 'commands', 'gsd')),
|
||
);
|
||
assert.deepStrictEqual(
|
||
hashTree(path.join(packageRoot, 'agents')),
|
||
hashTree(path.join(__dirname, '..', 'agents')),
|
||
);
|
||
});
|
||
|
||
test('G: deployed Codex and Claude modules materially change a surface after package source is unreachable', (t) => {
|
||
const runtimeRoot = createTempDir('gsd-smoke-offline-runtime-');
|
||
const packageRoot = installedPackageRoot(installPrefix);
|
||
const hiddenPackageRoot = `${packageRoot}.offline-${process.pid}`;
|
||
t.after(() => {
|
||
if (fs.existsSync(hiddenPackageRoot) && !fs.existsSync(packageRoot)) fs.renameSync(hiddenPackageRoot, packageRoot);
|
||
cleanup(runtimeRoot);
|
||
});
|
||
const installs = [];
|
||
for (const runtime of ['codex', 'claude']) {
|
||
const configDir = path.join(runtimeRoot, `.${runtime}`);
|
||
const result = spawnSync(process.execPath, [
|
||
path.join(packageRoot, 'bin', 'install.js'),
|
||
`--${runtime}`,
|
||
'--global',
|
||
'--config-dir',
|
||
configDir,
|
||
], {
|
||
cwd: runtimeRoot,
|
||
env: {
|
||
...process.env,
|
||
...isolatedNpmEnv(),
|
||
HOME: runtimeRoot,
|
||
USERPROFILE: runtimeRoot,
|
||
GSD_TEST_MODE: '',
|
||
NO_UPDATE_NOTIFIER: '1',
|
||
npm_config_update_notifier: 'false',
|
||
},
|
||
encoding: 'utf8',
|
||
timeout: CHILD_TIMEOUT_MS,
|
||
});
|
||
assert.equal(result.status, 0, `${runtime} packed install failed:\n${result.stdout}\n${result.stderr}`);
|
||
installs.push({ runtime, configDir });
|
||
}
|
||
|
||
// Synthetic untouched 1.12-style deployed tree: converted outputs and a
|
||
// hash manifest exist, but the raw corpus/marker do not. It must not use
|
||
// those outputs as source when the executing package disappears.
|
||
const legacyConfigDir = path.join(runtimeRoot, '.legacy-codex');
|
||
fs.cpSync(installs.find((entry) => entry.runtime === 'codex').configDir, legacyConfigDir, { recursive: true });
|
||
cleanup(path.join(legacyConfigDir, 'gsd-core', 'commands'));
|
||
cleanup(path.join(legacyConfigDir, 'gsd-core', 'agents'));
|
||
const legacyMarker = path.join(legacyConfigDir, '.gsd-source');
|
||
if (fs.existsSync(legacyMarker)) fs.unlinkSync(legacyMarker);
|
||
|
||
// S04: upgrade a synthetic source-less legacy tree while the fixed
|
||
// package is available. Preserve its exact committed selection and an
|
||
// unrelated file, converge artifacts to that selection, and later prove
|
||
// an offline expansion works from the provisioned corpus alone.
|
||
const upgradedHome = path.join(runtimeRoot, 'legacy-codex-upgrade-home');
|
||
const upgradeCwd = path.join(runtimeRoot, 'legacy-codex-upgrade-worktree');
|
||
const upgradedConfigDir = path.join(upgradedHome, '.codex');
|
||
fs.mkdirSync(upgradeCwd, { recursive: true });
|
||
fs.cpSync(installs.find((entry) => entry.runtime === 'codex').configDir, upgradedConfigDir, { recursive: true });
|
||
fs.cpSync(path.join(runtimeRoot, '.agents', 'skills'), path.join(upgradedHome, '.agents', 'skills'), { recursive: true });
|
||
fs.mkdirSync(path.join(upgradedHome, '.agents', 'skills', 'user-owned-skill'), { recursive: true });
|
||
fs.writeFileSync(path.join(upgradedHome, '.agents', 'skills', 'user-owned-skill', 'SKILL.md'), '# preserve me\n');
|
||
cleanup(path.join(upgradedConfigDir, 'gsd-core', 'commands'));
|
||
cleanup(path.join(upgradedConfigDir, 'gsd-core', 'agents'));
|
||
const upgradedMarker = path.join(upgradedConfigDir, '.gsd-source');
|
||
if (fs.existsSync(upgradedMarker)) fs.unlinkSync(upgradedMarker);
|
||
const selectedState = JSON.stringify({ baseProfile: 'core', disabledClusters: [], explicitAdds: [], explicitRemoves: [] }, null, 2) + '\n';
|
||
const priorGates = JSON.stringify({ workflow: { code_review: false, research: true } }, null, 2) + '\n';
|
||
fs.writeFileSync(path.join(upgradedConfigDir, '.gsd-surface.json'), selectedState);
|
||
fs.writeFileSync(path.join(upgradedConfigDir, 'user-owned.txt'), 'preserve me\n');
|
||
fs.mkdirSync(path.join(upgradeCwd, '.planning'), { recursive: true });
|
||
fs.writeFileSync(path.join(upgradeCwd, '.planning', 'config.json'), priorGates);
|
||
const upgradeResult = spawnSync(process.execPath, [
|
||
path.join(packageRoot, 'bin', 'install.js'),
|
||
'--codex',
|
||
'--global',
|
||
'--config-dir',
|
||
upgradedConfigDir,
|
||
], {
|
||
cwd: upgradeCwd,
|
||
env: {
|
||
...process.env,
|
||
...isolatedNpmEnv(),
|
||
HOME: upgradedHome,
|
||
USERPROFILE: upgradedHome,
|
||
GSD_TEST_MODE: '',
|
||
NO_UPDATE_NOTIFIER: '1',
|
||
npm_config_update_notifier: 'false',
|
||
},
|
||
encoding: 'utf8',
|
||
timeout: CHILD_TIMEOUT_MS,
|
||
});
|
||
assert.equal(upgradeResult.status, 0, `legacy upgrade failed:\n${upgradeResult.stdout}\n${upgradeResult.stderr}`);
|
||
assert.equal(fs.readFileSync(path.join(upgradedConfigDir, '.gsd-surface.json'), 'utf8'), selectedState);
|
||
assert.equal(fs.readFileSync(path.join(upgradedConfigDir, 'user-owned.txt'), 'utf8'), 'preserve me\n');
|
||
assert.equal(fs.readFileSync(path.join(upgradeCwd, '.planning', 'config.json'), 'utf8'), priorGates);
|
||
// #4667 (sanctioned baseline change): the deployed codex corpus is a
|
||
// CONVERTED projection of the package source — @ includes are rewritten
|
||
// to the codex install root — so the no-drift baseline for an upgrade is
|
||
// the previously-DEPLOYED tree (the fresh codex install from the same
|
||
// package), not the raw package bytes. Comparing against packageRoot
|
||
// would pin the pre-#4667 leak (codex artifacts carrying Claude-rooted
|
||
// @ includes) as required output.
|
||
const deployedCodexCommands = path.join(
|
||
installs.find((entry) => entry.runtime === 'codex').configDir, 'gsd-core', 'commands', 'gsd',
|
||
);
|
||
const deployedCodexAgents = path.join(
|
||
installs.find((entry) => entry.runtime === 'codex').configDir, 'gsd-core', 'agents',
|
||
);
|
||
assert.deepStrictEqual(
|
||
hashTree(path.join(upgradedConfigDir, 'gsd-core', 'commands', 'gsd')),
|
||
hashTree(deployedCodexCommands),
|
||
);
|
||
assert.deepStrictEqual(
|
||
hashTree(path.join(upgradedConfigDir, 'gsd-core', 'agents')),
|
||
hashTree(deployedCodexAgents),
|
||
);
|
||
const upgradedSkillRoot = path.join(upgradedHome, '.agents', 'skills');
|
||
const upgradedSkillCount = fs.readdirSync(upgradedSkillRoot).filter((name) => name.startsWith('gsd-')).length;
|
||
assert.ok(upgradedSkillCount > 0 && upgradedSkillCount < 71, `upgrade must converge the selected core surface, got ${upgradedSkillCount}`);
|
||
assert.equal(fs.readFileSync(path.join(upgradedSkillRoot, 'user-owned-skill', 'SKILL.md'), 'utf8'), '# preserve me\n');
|
||
|
||
fs.renameSync(packageRoot, hiddenPackageRoot);
|
||
|
||
const upgradedOfflineScript = [
|
||
"const fs=require('node:fs'),path=require('node:path');",
|
||
`const configDir=${JSON.stringify(upgradedConfigDir)};`,
|
||
"const lib=path.join(configDir,'gsd-core','bin','lib');",
|
||
"const surface=require(path.join(lib,'surface.cjs'));",
|
||
"const layoutModule=require(path.join(lib,'runtime-artifact-layout.cjs'));",
|
||
"const profiles=require(path.join(lib,'install-profiles.cjs'));",
|
||
"const clusters=require(path.join(lib,'clusters.cjs'));",
|
||
"const manifest=profiles.loadSkillsManifest(path.join(configDir,'gsd-core','commands','gsd'));",
|
||
"const layout=layoutModule.resolveRuntimeArtifactLayout('codex',configDir,'global');",
|
||
"const skillKind=layout.kinds.find(kind=>kind.kind==='skills');",
|
||
"const skillRoot=path.join(skillKind.home||configDir,skillKind.destSubpath);",
|
||
"const before=fs.readdirSync(skillRoot).filter(n=>n.startsWith('gsd-')).length;",
|
||
"surface.applySurface(configDir,layout,manifest,clusters.CLUSTERS,undefined,{surfaceState:{baseProfile:'full',disabledClusters:[],explicitAdds:[],explicitRemoves:[]}});",
|
||
"const after=fs.readdirSync(skillRoot).filter(n=>n.startsWith('gsd-')).length;",
|
||
"if(!(after>before))throw new Error(`offline upgraded expansion failed: ${before} -> ${after}`);",
|
||
].join('');
|
||
const upgradedOfflineResult = spawnSync(process.execPath, ['-e', upgradedOfflineScript], {
|
||
cwd: runtimeRoot,
|
||
env: { ...process.env, HOME: upgradedHome, USERPROFILE: upgradedHome, GSD_TEST_MODE: '' },
|
||
encoding: 'utf8',
|
||
timeout: CHILD_TIMEOUT_MS,
|
||
});
|
||
assert.equal(upgradedOfflineResult.status, 0, `upgraded offline surface failed:\n${upgradedOfflineResult.stdout}\n${upgradedOfflineResult.stderr}`);
|
||
|
||
const legacyChildScript = [
|
||
"const fs=require('node:fs'),path=require('node:path');",
|
||
`const configDir=${JSON.stringify(legacyConfigDir)};`,
|
||
"const lib=path.join(configDir,'gsd-core','bin','lib');",
|
||
"const surface=require(path.join(lib,'surface.cjs'));",
|
||
"const layoutModule=require(path.join(lib,'runtime-artifact-layout.cjs'));",
|
||
"const profiles=require(path.join(lib,'install-profiles.cjs'));",
|
||
"const clusters=require(path.join(lib,'clusters.cjs'));",
|
||
"const surfacePath=path.join(configDir,'.gsd-surface.json');",
|
||
"const agentPath=path.join(configDir,'agents','gsd-planner.md');",
|
||
"const beforeSurface=fs.existsSync(surfacePath)?fs.readFileSync(surfacePath):null;",
|
||
"const beforeAgent=fs.readFileSync(agentPath);",
|
||
"const layout=layoutModule.resolveRuntimeArtifactLayout('codex',configDir,'global');",
|
||
"let error=null;try{surface.applySurface(configDir,layout,new Map(),clusters.CLUSTERS,undefined,{surfaceState:{baseProfile:'core',disabledClusters:[],explicitAdds:[],explicitRemoves:[]}})}catch(value){error=value}",
|
||
"if(!error||!/install or upgrade gsd-core/.test(error.message))throw new Error(`expected actionable source failure, got ${error&&error.message}`);",
|
||
"const afterSurface=fs.existsSync(surfacePath)?fs.readFileSync(surfacePath):null;",
|
||
"if(!Buffer.isBuffer(beforeAgent)||!beforeAgent.equals(fs.readFileSync(agentPath)))throw new Error('legacy output changed');",
|
||
"if(beforeSurface===null?afterSurface!==null:!beforeSurface.equals(afterSurface))throw new Error('legacy surface state changed');",
|
||
].join('');
|
||
const legacyResult = spawnSync(process.execPath, ['-e', legacyChildScript], {
|
||
cwd: runtimeRoot,
|
||
env: { ...process.env, HOME: runtimeRoot, USERPROFILE: runtimeRoot, GSD_TEST_MODE: '' },
|
||
encoding: 'utf8',
|
||
timeout: CHILD_TIMEOUT_MS,
|
||
});
|
||
assert.equal(legacyResult.status, 0, `legacy source-less refusal failed:\n${legacyResult.stdout}\n${legacyResult.stderr}`);
|
||
|
||
for (const { runtime, configDir } of installs) {
|
||
const childScript = [
|
||
"const path=require('node:path');",
|
||
`const configDir=${JSON.stringify(configDir)};`,
|
||
`const runtime=${JSON.stringify(runtime)};`,
|
||
"const lib=path.join(configDir,'gsd-core','bin','lib');",
|
||
"const surface=require(path.join(lib,'surface.cjs'));",
|
||
"const layoutModule=require(path.join(lib,'runtime-artifact-layout.cjs'));",
|
||
"const profiles=require(path.join(lib,'install-profiles.cjs'));",
|
||
"const clusters=require(path.join(lib,'clusters.cjs'));",
|
||
"const corpus=path.join(configDir,'gsd-core','commands','gsd');",
|
||
"const manifest=profiles.loadSkillsManifest(corpus);",
|
||
"const layout=layoutModule.resolveRuntimeArtifactLayout(runtime,configDir,'global');",
|
||
"const before=layout.kinds.map(k=>{const root=path.join(k.home||configDir,k.destSubpath);try{return require('node:fs').readdirSync(root).length}catch{return 0}}).reduce((a,b)=>a+b,0);",
|
||
"const state={baseProfile:'core',disabledClusters:[],explicitAdds:[],explicitRemoves:[]};",
|
||
"surface.applySurface(configDir,layout,manifest,clusters.CLUSTERS,undefined,{surfaceState:state});",
|
||
"const after=layout.kinds.map(k=>{const root=path.join(k.home||configDir,k.destSubpath);try{return require('node:fs').readdirSync(root).length}catch{return 0}}).reduce((a,b)=>a+b,0);",
|
||
"if(!(after>0&&after<before))throw new Error(`surface did not materially shrink: ${before} -> ${after}`);",
|
||
"process.stdout.write(JSON.stringify({before,after}));",
|
||
].join('');
|
||
const result = spawnSync(process.execPath, ['-e', childScript], {
|
||
cwd: runtimeRoot,
|
||
env: { ...process.env, HOME: runtimeRoot, USERPROFILE: runtimeRoot, GSD_TEST_MODE: '' },
|
||
encoding: 'utf8',
|
||
timeout: CHILD_TIMEOUT_MS,
|
||
});
|
||
assert.equal(result.status, 0, `${runtime} offline materialization failed:\n${result.stdout}\n${result.stderr}`);
|
||
const counts = JSON.parse(result.stdout);
|
||
assert.ok(counts.after > 0 && counts.after < counts.before);
|
||
}
|
||
});
|
||
|
||
// ── H: configured entrypoints resolve for supported runtime profiles ──────
|
||
//
|
||
// #4154's scope bullet: the installer's in-process assertConfiguredEntrypoints
|
||
// gate is unit-covered in tests/configured-entrypoint-validation.test.cjs.
|
||
// This asserts the same property survives the packaged path — install the
|
||
// real tarball, run its installer for a runtime, and re-read that runtime's
|
||
// own config back off disk.
|
||
test('H: packed install leaves every configured entrypoint resolvable', () => {
|
||
const result = runSmoke({
|
||
tarballPath,
|
||
installPrefix,
|
||
expectedVersion: pkg.version,
|
||
fixtureDir,
|
||
// The lifecycle-command and workflow-body cycles are covered by A/C/E;
|
||
// skipping them here keeps this test to the entrypoint cycle.
|
||
lifecycleCommands: [],
|
||
entrypointRuntimes: ['claude', 'codex'],
|
||
npmEnv: isolatedNpmEnv(),
|
||
});
|
||
|
||
assert.equal(result.code, SMOKE.OK, smokeMsg('H', result));
|
||
assert.deepEqual(
|
||
result.details.entrypointProfiles.map((profile) => profile.runtime),
|
||
['claude', 'codex'],
|
||
smokeMsg('H', result),
|
||
);
|
||
for (const profile of result.details.entrypointProfiles) {
|
||
// Structural: a profile whose scan found nothing would satisfy the
|
||
// "no unresolved entrypoints" verdict vacuously.
|
||
assert.ok(
|
||
profile.entrypointsChecked > 0,
|
||
`${profile.runtime} configured no entrypoints: ${smokeMsg('H', result)}`,
|
||
);
|
||
}
|
||
});
|
||
|
||
// ── I: an unresolvable configured entrypoint fails the packed install ─────
|
||
//
|
||
// Red proof for ENTRYPOINT_UNRESOLVED, end to end through the packed tarball,
|
||
// and the reason this smoke check is not redundant with the installer's own
|
||
// gate: the fixture pre-registers a hook no install will ever write, the
|
||
// packed installer merges its own entries around it and exits 0 — its
|
||
// in-process assertConfiguredEntrypoints only validates paths the config
|
||
// writers registered with it during that run, so a registration it never
|
||
// touched is invisible to it — and reading settings.json back off disk is
|
||
// what catches the dangling launch path.
|
||
test('I: a registered hook that no install writes fails the smoke', (t) => {
|
||
const runtimeHome = entrypointFixtureHome(fixtureDir, 'claude');
|
||
// fixtureDir is shared with A/C/E/H, which install into this same home.
|
||
t.after(() => cleanup(runtimeHome));
|
||
|
||
const configDir = path.join(runtimeHome, '.claude');
|
||
const ghostHook = path.join(configDir, 'hooks', 'gsd-ghost-hook.js');
|
||
fs.mkdirSync(configDir, { recursive: true });
|
||
fs.writeFileSync(path.join(configDir, 'settings.json'), JSON.stringify({
|
||
hooks: {
|
||
PreToolUse: [
|
||
{ matcher: 'Bash', hooks: [{ type: 'command', command: `node "${ghostHook}"` }] },
|
||
],
|
||
},
|
||
}, null, 2));
|
||
|
||
const result = runSmoke({
|
||
tarballPath,
|
||
installPrefix,
|
||
expectedVersion: pkg.version,
|
||
fixtureDir,
|
||
lifecycleCommands: [],
|
||
entrypointRuntimes: ['claude'],
|
||
npmEnv: isolatedNpmEnv(),
|
||
});
|
||
|
||
assert.equal(result.code, SMOKE.ENTRYPOINT_UNRESOLVED, smokeMsg('I', result));
|
||
assert.equal(result.details.runtime, 'claude', smokeMsg('I', result));
|
||
assert.deepEqual(
|
||
result.details.unresolved,
|
||
[{ configPath: path.join(configDir, 'settings.json'), scriptPath: ghostHook }],
|
||
smokeMsg('I', result),
|
||
);
|
||
});
|
||
|
||
// ── J: configuredEntrypointsIn tolerates whitespace in configDir ──────────
|
||
//
|
||
// #4249 (antigravity review): the prior SCRIPT_PATH_RE excluded `\s` from
|
||
// the path match to avoid swallowing a shell command's trailing args, which
|
||
// also truncated any legitimate path containing a space — e.g. a real
|
||
// `/Users/John Doe/.claude` home — so the scan silently returned zero
|
||
// checked paths there. A direct unit test on the exported pure function:
|
||
// no packed install needed to prove this property.
|
||
test('J: configuredEntrypointsIn resolves a script path even when configDir contains a space', () => {
|
||
const configDir = path.join(os.tmpdir(), 'John Doe', '.claude');
|
||
const scriptPath = path.join(configDir, 'hooks', 'gsd-write-guard.js');
|
||
const text = JSON.stringify({
|
||
hooks: {
|
||
PreToolUse: [
|
||
{ matcher: 'Bash', hooks: [{ type: 'command', command: `node "${scriptPath}"` }] },
|
||
],
|
||
},
|
||
});
|
||
|
||
assert.deepEqual(configuredEntrypointsIn(text, configDir), [path.resolve(scriptPath)]);
|
||
});
|
||
|
||
// ── K: configuredEntrypointsIn does not swallow a preceding interpreter path ──
|
||
//
|
||
// #4249 (antigravity review): anchoring on the literal configDir prefix (a
|
||
// fix for J) must not regress the original "don't swallow the rest of a
|
||
// shell command" property — a command string that concatenates an
|
||
// interpreter path ahead of the real script path must resolve to the
|
||
// script path alone, not a combined interpreter+script string.
|
||
test('K: configuredEntrypointsIn does not include a preceding interpreter path', () => {
|
||
const configDir = path.join(os.tmpdir(), '.claude');
|
||
const scriptPath = path.join(configDir, 'hooks', 'gsd-write-guard.js');
|
||
const text = `"command": "/usr/local/bin/node ${scriptPath} --flag"`;
|
||
|
||
assert.deepEqual(configuredEntrypointsIn(text, configDir), [path.resolve(scriptPath)]);
|
||
});
|
||
});
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-131-release-tarball-smoke-explicit-home.test.cjs — consolidation epic #1969 (B6 #1975)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-131-release-tarball-smoke-explicit-home (consolidation epic #1969 B6 #1975)", () => {
|
||
// allow-test-rule: integration-test-input (see #131)
|
||
// Regression test for #131: runNpm() must not fail when HOME points at an
|
||
// unwritable directory. The before() hook in release-tarball-smoke.install.test.cjs
|
||
// calls runNpm(['pack', ...]) and runNpm(['install', '-g', ...]) — if those inherit
|
||
// an unwritable HOME from the environment (common in constrained Docker hosts),
|
||
// the entire hook fails and all 6 subtests are cancelled.
|
||
//
|
||
// Fix: runNpm() must inject an explicit HOME, npm_config_cache, and
|
||
// npm_config_userconfig that point into a temp directory it owns, so that npm
|
||
// never reads from or writes to the caller's HOME.
|
||
//
|
||
// Test 3 (added in the second fix pass) verifies that isolatedNpmEnv() — the
|
||
// companion export that lets runSmoke() apply the same isolation — also redirects
|
||
// HOME away from the caller's HOME. Without this, subtests A-F of
|
||
// release-tarball-smoke.install.test.cjs still fail because runSmoke() calls
|
||
// spawnSync('npm', ...) internally and was not covered by the runNpm() fix.
|
||
|
||
'use strict';
|
||
|
||
const { describe, test } = require('node:test');
|
||
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');
|
||
|
||
// The helpers under test.
|
||
const { isolatedNpmEnv, cleanup } = require('./helpers.cjs');
|
||
|
||
// Bounds the `node -e` npm-harness spawn pattern shared by the three
|
||
// execFileSync(process.execPath, ['-e', script], ...) sites in this file,
|
||
// each of which requires helpers.cjs and calls runNpm(...).
|
||
const NPM_HARNESS_SPAWN_TIMEOUT_MS = 30_000;
|
||
|
||
// Resolve a filesystem path to its canonical (symlink-free) form even if the
|
||
// leaf does not exist yet (e.g. ~/.npm before npm has written its cache).
|
||
// Walks up to the nearest existing ancestor, resolves that, then re-appends
|
||
// the trailing segments. This handles macOS /var → /private/var symlinks for
|
||
// paths created under os.tmpdir() where the leaf directory may not exist yet.
|
||
function safeRealpath(p) {
|
||
try {
|
||
return fs.realpathSync(p);
|
||
} catch (_) {
|
||
// Leaf does not exist — resolve the nearest existing ancestor then
|
||
// reconstruct the original suffix so the result is still canonical.
|
||
const segments = [];
|
||
let cur = p;
|
||
for (;;) {
|
||
const parent = path.dirname(cur);
|
||
if (parent === cur) {
|
||
// Reached filesystem root — return original path unchanged.
|
||
return p;
|
||
}
|
||
segments.unshift(path.basename(cur));
|
||
cur = parent;
|
||
try {
|
||
return path.join(fs.realpathSync(cur), ...segments);
|
||
} catch (__) {
|
||
// Keep walking up.
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
describe('bug-131: runNpm isolates HOME from the caller environment', () => {
|
||
// ── Test 1 — runNpm works with an unwritable HOME ────────────────────────
|
||
// Spawn a child Node process that sets HOME to an unwritable directory, then
|
||
// invokes runNpm(['cache', 'verify']). `npm --version` performs zero
|
||
// filesystem I/O against HOME/.npm or HOME/.npmrc on modern npm, and even
|
||
// `npm config get cache` only *resolves* the cache path as a string without
|
||
// touching disk — both stay green even without HOME isolation, making the
|
||
// assertion vacuous. `npm cache verify` genuinely creates/reads/writes the
|
||
// cache directory under HOME (mkdir _cacache, write logs), so without the
|
||
// fix it fails with ENOTDIR against the unwritable HOME, and with the fix
|
||
// runNpm's injected isolated HOME lets it succeed. (Proven empirically: with
|
||
// this exact probe, neutralising runNpm()'s isolation flips this test from
|
||
// green to red, whereas `npm config get cache` stayed green either way.)
|
||
test('runNpm succeeds even when process HOME is unwritable', () => {
|
||
// Simulate an unwritable HOME with a mechanism that holds for every uid,
|
||
// including root (as gsd-test Docker benches run). chmod 0o500 is
|
||
// insufficient because root bypasses mode bits entirely, silently making
|
||
// this assertion vacuous under root — see CLAUDE.md section 4. Instead,
|
||
// make the PARENT of "HOME" a regular file rather than a directory: any
|
||
// attempt to create or write an entry under a non-directory parent fails
|
||
// with ENOTDIR at the filesystem/VFS level, a property that has nothing
|
||
// to do with permission bits and therefore cannot be bypassed by root.
|
||
const poisonedHomeBlocker = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-bug131-poison-'));
|
||
const blockerFile = path.join(poisonedHomeBlocker, 'blocker');
|
||
fs.writeFileSync(blockerFile, ''); // regular file, not a directory
|
||
const poisonedHome = path.join(blockerFile, 'home'); // parent is a file → ENOTDIR
|
||
try {
|
||
|
||
// We exercise the real runNpm() path by running a tiny inline Node script
|
||
// that requires helpers.cjs and calls runNpm(['cache', 'verify']) with
|
||
// HOME set to the unwritable dir. The script exits 0 on success, non-zero
|
||
// on throw.
|
||
const script = `
|
||
process.env.HOME = ${JSON.stringify(poisonedHome)};
|
||
process.env.USERPROFILE = ${JSON.stringify(poisonedHome)};
|
||
const { runNpm } = require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))});
|
||
try {
|
||
const out = runNpm(['cache', 'verify']);
|
||
if (!out || out.trim() === '') process.exit(2); // vacuous success guard
|
||
process.stdout.write(out);
|
||
process.exit(0);
|
||
} catch (e) {
|
||
process.stderr.write(e.message + '\\n');
|
||
process.exit(1);
|
||
}
|
||
`;
|
||
|
||
let stdout = '';
|
||
let stderr = '';
|
||
let exitCode = 0;
|
||
try {
|
||
stdout = execFileSync(process.execPath, ['-e', script], {
|
||
encoding: 'utf-8',
|
||
timeout: NPM_HARNESS_SPAWN_TIMEOUT_MS,
|
||
});
|
||
} catch (err) {
|
||
stdout = err.stdout || '';
|
||
stderr = err.stderr || '';
|
||
exitCode = err.status ?? 1;
|
||
}
|
||
|
||
assert.equal(
|
||
exitCode,
|
||
0,
|
||
`runNpm should succeed with an unwritable HOME but exited ${exitCode}. stderr: ${stderr}`,
|
||
);
|
||
// npm cache verify reports what it found/fixed in the cache directory.
|
||
assert.match(
|
||
stdout,
|
||
/cache verified|content verified/i,
|
||
`expected npm cache verify output, got: ${stdout}`,
|
||
);
|
||
} finally {
|
||
// poisonedHome itself was never created (its parent is a file), so only
|
||
// the directory holding the blocker file needs cleanup.
|
||
cleanup(poisonedHomeBlocker);
|
||
}
|
||
});
|
||
|
||
// ── Test 2 — runNpm does not leak a caller-supplied HOME into npm ────────
|
||
// Even if the caller exports HOME=/some/real/path, the injected HOME must be
|
||
// a different (temp) path so npm writes never touch the caller's $HOME.
|
||
test('runNpm injects a HOME distinct from process.env.HOME', () => {
|
||
// Capture what HOME runNpm actually passes to npm by asking npm to print
|
||
// the value it sees for the $HOME env var. We do this via `npm config get
|
||
// cache` which reveals the cache path — if it's under process.env.HOME,
|
||
// the fix is absent; if it's under a tmp dir, the fix is present.
|
||
|
||
const script = `
|
||
const { runNpm } = require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))});
|
||
try {
|
||
// npm config get cache prints the effective cache directory.
|
||
const out = runNpm(['config', 'get', 'cache']);
|
||
process.stdout.write(out.trim());
|
||
process.exit(0);
|
||
} catch (e) {
|
||
process.stderr.write(e.message + '\\n');
|
||
process.exit(1);
|
||
}
|
||
`;
|
||
|
||
let stdout = '';
|
||
let stderr = '';
|
||
let exitCode = 0;
|
||
try {
|
||
stdout = execFileSync(process.execPath, ['-e', script], {
|
||
encoding: 'utf-8',
|
||
timeout: NPM_HARNESS_SPAWN_TIMEOUT_MS,
|
||
});
|
||
} catch (err) {
|
||
stdout = err.stdout || '';
|
||
stderr = err.stderr || '';
|
||
exitCode = err.status ?? 1;
|
||
}
|
||
|
||
assert.equal(
|
||
exitCode,
|
||
0,
|
||
`runNpm config get cache failed with exit ${exitCode}. stderr: ${stderr}`,
|
||
);
|
||
|
||
const effectiveCacheDir = stdout.trim();
|
||
|
||
// The effective npm cache must NOT be inside the calling process's HOME.
|
||
// If it is, the fix was not applied and the Docker regression can still occur.
|
||
const callerHome = os.homedir();
|
||
assert.ok(
|
||
!effectiveCacheDir.startsWith(callerHome),
|
||
`npm cache dir ${effectiveCacheDir} is still under caller HOME ${callerHome} — fix not applied`,
|
||
);
|
||
|
||
// It must be somewhere under the system tmp dir, confirming isolation.
|
||
// Use safeRealpath on both sides so that macOS /var→/private/var symlinks
|
||
// do not cause a false mismatch when os.tmpdir() and the resolved cache
|
||
// path differ only in symlink expansion. The cache sub-directory (.npm) may
|
||
// not exist yet; safeRealpath walks up to the nearest existing ancestor.
|
||
const sysTmp = safeRealpath(os.tmpdir());
|
||
const realCacheDir = safeRealpath(effectiveCacheDir);
|
||
assert.ok(
|
||
realCacheDir.startsWith(sysTmp),
|
||
`npm cache dir ${realCacheDir} should be under tmpdir ${sysTmp}`,
|
||
);
|
||
});
|
||
|
||
// ── Test 3 — isolatedNpmEnv() redirects HOME away from the caller's HOME ──
|
||
// runSmoke() calls spawnSync('npm', ...) with npmEnv from isolatedNpmEnv().
|
||
// If isolatedNpmEnv() didn't redirect HOME, subtests A-F would still fail on
|
||
// Docker hosts with an unwritable HOME (the original bug #131 root cause,
|
||
// manifesting via the sibling runSmoke() path). (#131)
|
||
test('isolatedNpmEnv() HOME is distinct from the caller HOME and lives under tmpdir', () => {
|
||
const env = isolatedNpmEnv();
|
||
|
||
// Must expose a HOME key.
|
||
assert.ok(
|
||
typeof env.HOME === 'string' && env.HOME.length > 0,
|
||
'isolatedNpmEnv() must set HOME',
|
||
);
|
||
|
||
// Must not be the caller's HOME.
|
||
const callerHome = os.homedir();
|
||
assert.notEqual(
|
||
env.HOME,
|
||
callerHome,
|
||
`isolatedNpmEnv() HOME must differ from caller HOME ${callerHome}`,
|
||
);
|
||
|
||
// Must live under the system tmpdir, confirming it is an isolated temp directory.
|
||
// Use safeRealpath on both sides so that macOS /var→/private/var symlinks
|
||
// do not cause a false mismatch.
|
||
const sysTmp = safeRealpath(os.tmpdir());
|
||
const realHome = safeRealpath(env.HOME);
|
||
assert.ok(
|
||
realHome.startsWith(sysTmp),
|
||
`isolatedNpmEnv() HOME ${realHome} should be under tmpdir ${sysTmp}`,
|
||
);
|
||
|
||
// npm_config_cache and npm_config_userconfig must also be set and under the isolated HOME.
|
||
assert.ok(
|
||
typeof env.npm_config_cache === 'string' && env.npm_config_cache.startsWith(env.HOME),
|
||
`npm_config_cache ${env.npm_config_cache} should be under isolated HOME ${env.HOME}`,
|
||
);
|
||
assert.ok(
|
||
typeof env.npm_config_userconfig === 'string' && env.npm_config_userconfig.startsWith(env.HOME),
|
||
`npm_config_userconfig ${env.npm_config_userconfig} should be under isolated HOME ${env.HOME}`,
|
||
);
|
||
assert.equal(
|
||
env.npm_config_loglevel,
|
||
'error',
|
||
'isolatedNpmEnv() should suppress npm notice/warn chatter in test gates',
|
||
);
|
||
assert.equal(
|
||
env.npm_config_update_notifier,
|
||
'false',
|
||
'isolatedNpmEnv() should disable npm update-notifier notices in test gates',
|
||
);
|
||
assert.equal(
|
||
env.NO_UPDATE_NOTIFIER,
|
||
'1',
|
||
'isolatedNpmEnv() should disable npm update-notifier notices for npm versions that honor NO_UPDATE_NOTIFIER',
|
||
);
|
||
});
|
||
|
||
// ── Test 4 — runNpm's 180000ms bound survives an explicit `timeout: undefined` ──
|
||
// (#3148 wave 4) runNpm() used to spread `...defaults` (which carries
|
||
// `timeout: 180000`) and then `...otherOptions` AFTER it, so a caller
|
||
// passing an own `timeout: undefined` key (not an omission) silently won
|
||
// the spread and erased the bound, leaving the underlying execFileSync call
|
||
// unbounded. The fix destructures `timeout` off `options` with a default
|
||
// and passes it explicitly after both spreads, so an own `undefined` key
|
||
// resolves to the default instead of erasing it.
|
||
//
|
||
// Proof is behavioral, not textual: a fresh child process monkeypatches
|
||
// `child_process.execFileSync` to capture the options object it actually
|
||
// receives — before `helpers.cjs` is required in that child, so its
|
||
// top-level `const { execFileSync } = require('child_process')` picks up
|
||
// the patched function — then calls `runNpm(['--version'], { timeout:
|
||
// undefined })` and reports back the captured `timeout` value. Reverting
|
||
// the destructure-with-default fix (restoring the plain `{ ...defaults,
|
||
// ...otherOptions, env: mergedEnv }` spread order) makes this test fail:
|
||
// the captured `timeout` becomes `undefined` instead of `180000`.
|
||
test('runNpm resolves an explicit `timeout: undefined` to the 180000ms bound, not unbounded', () => {
|
||
const script = `
|
||
const cp = require('node:child_process');
|
||
const seen = [];
|
||
cp.execFileSync = (cmd, args, options) => {
|
||
seen.push(options);
|
||
return '9.9.9';
|
||
};
|
||
const { runNpm } = require(${JSON.stringify(path.join(__dirname, 'helpers.cjs'))});
|
||
// An own \`timeout: undefined\` key — not an omitted key — is the exact
|
||
// hazard: a caller-controlled property that must not erase the bound.
|
||
runNpm(['--version'], { timeout: undefined });
|
||
process.stdout.write(JSON.stringify({ timeout: seen[0] && seen[0].timeout }));
|
||
`;
|
||
|
||
let stdout = '';
|
||
let stderr = '';
|
||
let exitCode = 0;
|
||
try {
|
||
stdout = execFileSync(process.execPath, ['-e', script], {
|
||
encoding: 'utf-8',
|
||
timeout: NPM_HARNESS_SPAWN_TIMEOUT_MS,
|
||
});
|
||
} catch (err) {
|
||
stdout = err.stdout || '';
|
||
stderr = err.stderr || '';
|
||
exitCode = err.status ?? 1;
|
||
}
|
||
|
||
assert.equal(
|
||
exitCode,
|
||
0,
|
||
`runNpm timeout-capture probe failed with exit ${exitCode}. stderr: ${stderr}`,
|
||
);
|
||
|
||
const captured = JSON.parse(stdout.trim());
|
||
assert.equal(
|
||
captured.timeout,
|
||
180000,
|
||
`runNpm must resolve an explicit timeout: undefined to the 180000ms bound; ` +
|
||
`captured options.timeout was ${JSON.stringify(captured.timeout)} — an unset bound ` +
|
||
`is not a bound (DEFECT.UNBOUNDED-SUBPROCESS)`,
|
||
);
|
||
});
|
||
});
|
||
});
|
||
}
|