Files
msd-core/tests/release-tarball-smoke.install.test.cjs
Tom Boucher 899c8cff3a fix(#131): isolate HOME for release-tarball-smoke (before() + runSmoke A-F) (#139)
* fix(#131): pass explicit HOME and npm cache to before() npm invocations

npm reads $HOME/.npmrc (user config) and writes to $HOME/.npm (default
cache dir) unless overridden. On Docker hosts the running user's HOME
may be uninitialized, unwritable, or contain stale state from prior
runs — any of which causes `npm pack` / `npm install -g` in the
before() hook to fail with EACCES, cancelling all 6 subtests (A–F).

Fix: allocate a fresh mkdtemp dir once per test process in helpers.cjs
and inject it as HOME, npm_config_cache, and npm_config_userconfig for
every runNpm() call. A process.on('exit') handler removes the dir on
teardown. The caller-supplied env option (if any) is merged on top of
the isolated env so explicit overrides still win.

TDD: tests/bug-131-release-tarball-smoke-explicit-home.test.cjs
- Test 1: runNpm succeeds when process HOME is chmod-0500 (unwritable)
- Test 2: npm_config_cache resolves under tmpdir, not caller HOME

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(#131): extend HOME isolation to runSmoke spawnSync calls so A-F pass

Pass effectiveNpmEnv to the gsd-sdk --version and gsd-sdk query spawnSync
invocations inside runSmoke(), matching the isolation already applied to the
npm install step. Also add npmEnv: isolatedNpmEnv() to every runSmoke() call
in the install test so the full env isolation chain is in effect.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(#131): address CI feedback — prompt-injection comment, Windows USERPROFILE stub, macOS realpath

- Rephrase 'act as a poisoned HOME' comment to 'serve as a poisoned HOME'
  to avoid triggering the prompt-injection scanner's act-as pattern
- Add paired process.env.USERPROFILE stub alongside process.env.HOME in
  Test 1 inline script so Windows parity guard offender count stays at 8
- Fix macOS /var→/private/var symlink false-negative in Test 2 by resolving
  the nearest existing ancestor with fs.realpathSync before the startsWith
  comparison

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(#131): export isolatedNpmEnv from helpers.cjs (CI repro of missing symbol)

isolatedNpmEnv() was defined in tests/helpers.cjs but never committed —
the function body and the updated module.exports line were left as unstaged
local edits. CI checkouts saw the old module.exports (without isolatedNpmEnv),
causing TypeError: isolatedNpmEnv is not a function at the call site in
bug-131-release-tarball-smoke-explicit-home.test.cjs:178 and in
release-tarball-smoke.install.test.cjs wherever the function is destructured.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(#131): canonicalize macOS tmpdir in remaining startsWith assertions

Replace the ad-hoc try/catch realpathSync fallback chain in Test 2 and the
inline try/catch in Test 3 with a shared safeRealpath() helper that walks up
to the nearest existing ancestor before resolving, then reconstructs the
canonical path. This ensures /var→/private/var symlink expansion succeeds
even when the leaf (.npm cache dir) does not yet exist on macOS CI runners.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 00:24:32 -04:00

201 lines
8.2 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 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);
assert.equal(result.details.version, pkg.version);
});
// ── 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);
});
// ── 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 `get-shit-done-redux --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);
// 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);
assert.equal(result.details.command, 'nonexistent-phase-xyz');
assert.ok(typeof result.details.path === 'string' && result.details.path.length > 0);
});
// ── Test E — SDK binary callable ──────────────────────────────────────────
// Verifies that `gsd-sdk query state.json` exits 0 and returns parseable JSON.
// This is the primary guard for SDK_BINARY_NOT_CALLABLE regressions.
test('E: sdk binary callable — gsd-sdk query produces parseable JSON', () => {
const result = runSmoke({
tarballPath,
installPrefix,
expectedVersion: pkg.version,
fixtureDir,
lifecycleCommands: [], // skip lifecycle checks; isolate SDK check
npmEnv: isolatedNpmEnv(),
});
// If the binary can't be called, code would be SDK_BINARY_NOT_CALLABLE
assert.notEqual(result.code, SMOKE.SDK_BINARY_NOT_CALLABLE);
assert.equal(result.details.sdkQueryParsed, true);
});
// ── Test F — workflow-body checks run (informational) ─────────────────────
// Asserts that the workflow-body scanning machinery ran (structural assertion).
// Does NOT assert colonLeakCount or missingFallbackCount are zero — they will
// be non-zero against current main per #3668 and the /gsd: leak backlog.
// When those issues are fixed, this test continues to pass unchanged.
test('F: 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,
`expected workflowsScanned >= 1, got ${result.details.workflowsScanned}`,
);
assert.ok(
Number.isInteger(result.details.colonLeakCount),
`expected colonLeakCount to be an integer, got ${result.details.colonLeakCount}`,
);
assert.ok(
Number.isInteger(result.details.missingFallbackCount),
`expected missingFallbackCount to be an integer, got ${result.details.missingFallbackCount}`,
);
});
});