Files
msd-core/tests/release-tarball-smoke.install.test.cjs
Tom Boucher 6d072435d0 test(#1975): consolidate 51 CLI + scripts-tooling regression tests into module suites
Fold 51 issue-named CLI black-box + scripts-tooling regression files into their
canonical module suites (runtime-launcher-parity, worktree-safety, install-*, managed-hooks,
read-guard, capability-registry, etc.), plus a NEW slash-command-namespace.test.cjs grouping
the 4 slash/colon-namespace-leak invariant suites that had no canonical owner. Verbatim
block-scoped describe wrappers; 427 subtests conserved 1:1.

Host-env pre-check (per B2): no CLI-receiving host sets a redirecting GSD_WORKSTREAM/GSD_PROJECT
value. One folded suite (bug-3668 runtime resolver) creates an extension-less PATH gsd-tools
stub + bash -c; co-locating it with the host's chmodSync tripped local/no-unguarded-nonportable-exec,
so it's now Windows-guarded (skip on win32) matching the host suite's own bash -c guard.

Regenerates regression-name allowlist (222->182), ratchets file-count allowlist (graphify 7->6,
docs entry removed), makes 26 relocated allow-test-rule exemptions issue-ref-compliant (ADR-456;
prunes stale ids). Repoints 13 tests/ references across CONTEXT.md, COMMANDS.md/FEATURES.md
(EN + ja/ko/pt/zh) and ADR-0002. lint:ci green.

Part of epic #1969. Closes #1975.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 10:22:11 -04:00

435 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 path = require('node:path');
const { cleanup, createTempDir, runNpm, isolatedNpmEnv } = require('./helpers.cjs');
const { SMOKE, runSmoke } = 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'));
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 () => {
// 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. 600 s (10 min) gives a safe ceiling without
// masking genuine hangs.
const SLOW_HOST_TIMEOUT = 600_000;
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),
);
});
});
// ────────────────────────────────────────────────────────────────────────
// 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');
// 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 a chmod-0500 directory, then
// invokes runNpm(['--version']). Without the fix, npm tries to read/write
// HOME/.npmrc and HOME/.npm, fails with EACCES, and runNpm throws.
// With the fix, runNpm injects its own isolated HOME and npm succeeds.
test('runNpm succeeds even when process HOME is unwritable', () => {
// Create an unwritable dir to serve as a poisoned HOME.
const poisonedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-bug131-poison-'));
try {
fs.chmodSync(poisonedHome, 0o500); // r-x only — not writable
// We exercise the real runNpm() path by running a tiny inline Node script
// that requires helpers.cjs and calls runNpm(['--version']) 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(['--version']);
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: 30_000,
});
} 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 --version returns something like "10.x.y"
assert.match(
stdout.trim(),
/^\d+\.\d+/,
`expected semver output from npm --version, got: ${stdout}`,
);
} finally {
// Restore write permission before cleanup so the directory can be deleted.
try { fs.chmodSync(poisonedHome, 0o700); } catch (_) { /* best-effort */ }
cleanup(poisonedHome);
}
});
// ── 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: 30_000,
});
} 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',
);
});
});
});
}