Files
msd-core/tests/check-env.test.cjs
Tom Boucher 33ffc647e2 feat(117): reproducible npm environment bootstrap + check-env validator (#136)
* test(117): add failing tests for env validator (check-env.sh)

RED phase. Six tests for scripts/check-env.sh — none pass because the
script does not exist yet. Fixtures:
  good/             — engines.node >=22, .nvmrc 26, synced lockfile
  bad-node-version/ — engines.node <14.0.0 (current Node v26 fails)
  missing-lockfile/ — no package-lock.json
  bad-nvmrc/        — .nvmrc says 22, current Node is v26

Tests cover:
  1. Happy path exits 0
  2. engines.node constraint failure exits 1
  3. Missing lockfile exits 1
  4. .nvmrc major mismatch exits 1
  5. --json flag emits {pass: boolean, checks: array}
  6. Integration smoke: exits 0 on live worktree root

Sources:
  npm engines: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines
  npm ci docs: https://docs.npmjs.com/cli/v10/commands/npm-ci

Closes #117

* feat(117): add scripts/check-env.sh with Node/npm/lockfile/version-manager checks

GREEN phase. Implements the five-check environment validator:

  1. Node version vs engines.node (semver constraint — >=, >, <=, <, =)
  2. npm version vs engines.npm (skipped if field absent)
  3. package-lock.json presence
  4. Lockfile sync via `npm ci --dry-run` (exits non-zero when drift detected)
  5. Version-manager pin (.nvmrc / .node-version / .tool-versions) vs active Node major

Exit codes: 0 = all green; 1 = at least one failure; 2 = tool error.
Flags: --json (structured report), --help.

All 7 tests pass. shellcheck clean. bash -n syntax check clean.

Sources:
  npm engines:         https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines
  Reproducible builds: https://reproducible-builds.org/docs/source-tree/
  npm ci docs:         https://docs.npmjs.com/cli/v10/commands/npm-ci

Closes #117

* chore(117): pin Node engines + .nvmrc; add check:env npm script

- Add engines.npm: ">=10.0.0" (npm 10 ships with Node 22, the CI floor).
  Source: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines
- Add .nvmrc pinning Node 22 (lowest supported version per CI matrix in
  .github/workflows/test.yml; node-version: [22, 24]).
- Add "check:env": "./scripts/check-env.sh" script to package.json.

No generator is involved (not a .generated. file). The test update in this
commit adjusts the integration smoke: it now asserts on --json structured
output rather than raw text, and accepts exit 0 or 1 (version-manager pin
mismatch is expected when developer runs Node 26 against a .nvmrc of 22).

Closes #117

* ci(117): wire environment check into test workflow

Add "Environment check" step to .github/workflows/test.yml in the `test`
job. Positioned AFTER actions/setup-node and BEFORE npm ci so that env
mismatches (wrong Node version, missing npm version, absent lockfile) are
caught before the install step obscures the root cause.

Runs `npm run check:env` (./scripts/check-env.sh) on every matrix lane
(ubuntu, macos, windows) × (Node 22, 24).

Source: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines

Closes #117

* docs(117): publish docs/contributing/bootstrap.md + link from CONTRIBUTING.md

Adds docs/contributing/bootstrap.md with:
  1. Prerequisites (nvm, fnm, asdf, mise; gh CLI)
  2. One-time setup (clone, nvm use, check:env, npm ci)
  3. Daily commands table
  4. Validation guide (check table, exit codes, --json usage)
  5. Troubleshooting (node-version, npm-version, lockfile-present,
     lockfile-sync, version-manager-pin, missing modules, locale errors)
  6. Alternative: Docker via gsd-test-runner
     (https://github.com/open-gsd/gsd-test-runner)

Adds "Bootstrap your environment" section to CONTRIBUTING.md pointing to
the new doc. No content duplication — CONTRIBUTING.md links only.

Adds .changeset/117-npm-bootstrap.md (type: Added) for changelog.

Sources:
  npm engines:         https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines
  Reproducible builds: https://reproducible-builds.org/docs/source-tree/
  npm ci docs:         https://docs.npmjs.com/cli/v10/commands/npm-ci
  gsd-test-runner:     https://github.com/open-gsd/gsd-test-runner

Closes #117

* fix(#117): make check:env script run on Windows runners

Invoke check-env.sh via `bash` instead of a bare POSIX path so
Windows CI runners (which have Git Bash on PATH) execute the script
without requiring a POSIX shell shebang dispatcher.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(117): make check-env.sh fixture .nvmrc adapt to active Node major (cross-platform fix)

Before() hook writes good/.nvmrc = activeNodeMajor and bad-nvmrc/.nvmrc = activeNodeMajor+99
at test-run time. Hardcoded .nvmrc=26 failed on every CI matrix row except Node 26.
After() restores originals so the checked-in files stay stable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(117): exempt gsd-test-runner URL path from slash-command registry check

docs/contributing/bootstrap.md links to https://github.com/open-gsd/gsd-test-runner.
The parity-test regex captures /gsd-test-runner from the URL path component and
flags it as an unregistered slash command. Add 'test-runner' to INTERNAL_COMPONENT_SLUGS
(mirrors the existing 'build' entry for GitHub org URLs) with an explanatory comment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(117): fix Node-24 and Windows-22 CI failures in check-env

Two root causes:

1. version-manager-pin on Node 24 (mac/ubuntu/win):
   The project root .nvmrc pins major 22 for local dev. When the CI
   matrix runs Node 24, check-env.sh fails the version-manager-pin
   check and exits 1, blocking the entire test job before any test
   runs. Fix: skip the pin check when CI=true (GitHub Actions always
   sets this). The pin is a local dev guard, not a gate for multi-
   version matrix CI.

2. engines.node appears missing on Windows-22 (pkg_field backslash):
   pkg_field() embedded PACKAGE_JSON directly into a node -e string
   literal using require(). On Windows, the path uses backslashes
   (D:\a\...) which are silently interpreted as JS escape sequences
   inside the string, causing require() to fail silently (2>/dev/null
   || true). engines.node returns empty, triggering a spurious FAIL.
   Fix: switch to fs.readFileSync + JSON.parse and normalise
   backslashes to forward-slashes before embedding in the JS literal.

Also pass { CI: '' } from the bad-nvmrc unit test so the
version-manager-pin fixture test still exercises the mismatch path
even when running inside CI runners.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(117): use relative ./package.json path in pkg_field to fix Windows CI

On Windows, Git Bash exposes \$PWD as a POSIX path (/d/a/…) which
node.exe cannot resolve via fs.readFileSync. The previous fix embedded
the absolute PACKAGE_JSON path in the node -e string after converting
backslashes to forward-slashes, but the POSIX form produced by Git Bash
(/d/a/…) has no backslashes — so the conversion was a no-op and node
received an unresolvable path. The silent catch(e) { process.exit(0) }
swallowed the ENOENT, returning empty string for every engines.* field.

Fix: use './package.json' (relative to CWD). pkg_field() is always
called before any cd in the script so CWD === PROJECT_ROOT at call time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 18:38:49 -04:00

235 lines
11 KiB
JavaScript

/**
* Tests for scripts/check-env.sh (issue #117).
*
* Verifies the environment validator exits correctly and emits
* structured output for every documented check:
* 1. Node version vs engines.node constraint
* 2. npm version vs engines.npm constraint (if present)
* 3. Lockfile presence
* 4. Lockfile sync (npm ci --dry-run)
* 5. Version-manager pin file matches active Node major
* 6. --json flag produces parseable JSON with documented shape
* 7. Integration smoke: exits 0 on the live worktree root
*
* Sources:
* npm engines: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines
* npm ci: https://docs.npmjs.com/cli/v10/commands/npm-ci
*/
'use strict';
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const { spawnSync } = require('node:child_process');
const SCRIPT = path.resolve(__dirname, '..', 'scripts', 'check-env.sh');
const FIXTURE_ROOT = path.resolve(__dirname, 'fixtures', 'check-env');
const LIVE_ROOT = path.resolve(__dirname, '..');
/**
* Run check-env.sh synchronously in `cwd` with optional extra args.
* Returns { status, stdout, stderr }.
* @param {string} cwd
* @param {string[]} args
* @param {Record<string,string>} [envOverrides] - optional env vars to overlay
* on process.env. Pass { CI: '' } to suppress GitHub Actions CI detection
* so that version-manager-pin is exercised even inside CI runners.
*/
function runScript(cwd, args = [], envOverrides = {}) {
const result = spawnSync('bash', [SCRIPT, ...args], {
cwd,
encoding: 'utf8',
timeout: 30_000,
env: { ...process.env, ...envOverrides },
});
return {
status: result.status ?? 1,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
};
}
describe('check-env.sh', () => {
// -------------------------------------------------------------------------
// Dynamic .nvmrc setup: write fixture .nvmrc files at test-run time so the
// tests are correct across all Node major versions in the CI matrix (Node 22,
// 24, 26, …). A hardcoded value like "26" passes on Node 26 but fails on
// every other matrix row; using the active major makes the fixture portable.
//
// good/ → .nvmrc = active Node major (should match → exit 0)
// bad-nvmrc/ → .nvmrc = active+99 (guaranteed mismatch → exit 1)
// -------------------------------------------------------------------------
const activeNodeMajor = parseInt(process.version.match(/^v(\d+)/)[1], 10);
const goodNvmrc = path.join(FIXTURE_ROOT, 'good', '.nvmrc');
const badNvmrc = path.join(FIXTURE_ROOT, 'bad-nvmrc', '.nvmrc');
let originalGoodNvmrc;
let originalBadNvmrc;
before(() => {
originalGoodNvmrc = fs.existsSync(goodNvmrc) ? fs.readFileSync(goodNvmrc, 'utf8') : null;
originalBadNvmrc = fs.existsSync(badNvmrc) ? fs.readFileSync(badNvmrc, 'utf8') : null;
fs.writeFileSync(goodNvmrc, `${activeNodeMajor}\n`);
fs.writeFileSync(badNvmrc, `${activeNodeMajor + 99}\n`);
});
after(() => {
if (originalGoodNvmrc !== null) {
fs.writeFileSync(goodNvmrc, originalGoodNvmrc);
}
if (originalBadNvmrc !== null) {
fs.writeFileSync(badNvmrc, originalBadNvmrc);
}
});
// -------------------------------------------------------------------------
// Test 1: Happy path — all checks green
// -------------------------------------------------------------------------
test('exits 0 in a fixture directory with engines, .nvmrc, and matching lockfile', () => {
const cwd = path.join(FIXTURE_ROOT, 'good');
const { status, stdout } = runScript(cwd);
assert.equal(
status, 0,
`Expected exit 0, got ${status}.\nstdout: ${stdout}`
);
});
// -------------------------------------------------------------------------
// Test 2: engines.node constraint not satisfied
// -------------------------------------------------------------------------
test('exits 1 when engines.node constraint is not satisfied by current Node', () => {
const cwd = path.join(FIXTURE_ROOT, 'bad-node-version');
// Fixture has engines.node: "<14.0.0"; current Node is much higher.
const { status, stdout } = runScript(cwd);
assert.equal(
status, 1,
`Expected exit 1 (bad node version), got ${status}.\nstdout: ${stdout}`
);
});
// -------------------------------------------------------------------------
// Test 3: Missing lockfile
// -------------------------------------------------------------------------
test('exits 1 when package-lock.json is missing', () => {
const cwd = path.join(FIXTURE_ROOT, 'missing-lockfile');
const { status, stdout } = runScript(cwd);
assert.equal(
status, 1,
`Expected exit 1 (missing lockfile), got ${status}.\nstdout: ${stdout}`
);
});
// -------------------------------------------------------------------------
// Test 4: .nvmrc major doesn't match active Node major
// -------------------------------------------------------------------------
test('exits 1 when .nvmrc major version does not match active Node major', () => {
const cwd = path.join(FIXTURE_ROOT, 'bad-nvmrc');
// Fixture .nvmrc is set to (activeNodeMajor + 99) by the before() hook above,
// guaranteeing a mismatch regardless of the CI matrix Node version.
// Override CI='' so the version-manager-pin check is not skipped even when
// this test runs inside a CI runner (GitHub Actions sets CI=true, which
// would otherwise turn the pin check into a skip and exit 0).
const { status, stdout } = runScript(cwd, [], { CI: '' });
assert.equal(
status, 1,
`Expected exit 1 (nvmrc mismatch), got ${status}.\nstdout: ${stdout}`
);
});
// -------------------------------------------------------------------------
// Test 5: --json flag produces parseable JSON with documented shape
// -------------------------------------------------------------------------
test('--json emits parseable JSON with pass and checks keys', () => {
const cwd = path.join(FIXTURE_ROOT, 'good');
const { status, stdout } = runScript(cwd, ['--json']);
let parsed;
try {
parsed = JSON.parse(stdout);
} catch (err) {
assert.fail(`--json output was not valid JSON: ${err.message}\nstdout: ${stdout}`);
}
// Top-level shape
assert.equal(typeof parsed.pass, 'boolean', 'JSON must have boolean `pass` key');
assert.ok(Array.isArray(parsed.checks), 'JSON must have array `checks` key');
// The good fixture has engines.node, .nvmrc, and package-lock.json — expect
// at least the node-version, lockfile-present, lockfile-sync, and
// version-manager-pin checks to appear.
const checkNames = parsed.checks.map((c) => c.name);
assert.ok(
checkNames.includes('node-version'),
`Expected 'node-version' check in JSON, got: ${checkNames.join(', ')}`
);
assert.ok(
checkNames.includes('lockfile-present'),
`Expected 'lockfile-present' check in JSON, got: ${checkNames.join(', ')}`
);
// Every check item must have name, status, message fields with expected types
for (const check of parsed.checks) {
assert.equal(typeof check.name, 'string', `check.name must be string in ${JSON.stringify(check)}`);
assert.ok(
['pass', 'fail', 'skip'].includes(check.status),
`check.status must be pass|fail|skip in ${JSON.stringify(check)}`
);
assert.equal(typeof check.message, 'string', `check.message must be string in ${JSON.stringify(check)}`);
}
// Good fixture: overall result must be pass:true
assert.equal(parsed.pass, true, 'good fixture must report pass:true');
assert.equal(
status, 0,
`Expected exit 0 in good fixture with --json, got ${status}`
);
});
// -------------------------------------------------------------------------
// Test 5b: --json reports pass:false on failure fixtures (counter-test for 5)
// -------------------------------------------------------------------------
test('--json reports pass:false when a check fails', () => {
const cwd = path.join(FIXTURE_ROOT, 'bad-node-version');
const { status, stdout } = runScript(cwd, ['--json']);
let parsed;
try {
parsed = JSON.parse(stdout);
} catch (err) {
assert.fail(`--json output was not valid JSON: ${err.message}\nstdout: ${stdout}`);
}
assert.equal(parsed.pass, false, 'failure fixture must report pass:false');
assert.equal(status, 1, `Expected exit 1 with --json on failure fixture, got ${status}`);
// The node-version check must be present and marked fail
const nodeCheck = parsed.checks.find((c) => c.name === 'node-version');
assert.ok(nodeCheck, 'node-version check must appear in JSON output');
assert.equal(nodeCheck.status, 'fail', `Expected node-version status=fail, got ${nodeCheck.status}`);
});
// -------------------------------------------------------------------------
// Test 6: Integration smoke — script runs without tool-error on live root
//
// Verifies the script executes against a real repo without a tool error (exit 2).
// Exit 0 or 1 are acceptable — local Node may differ from the .nvmrc pin (22).
// Uses --json for structured assertion, avoiding raw output-grep.
// -------------------------------------------------------------------------
test('script runs without tool error on the live worktree root (--json)', () => {
const { status, stdout, stderr } = runScript(LIVE_ROOT, ['--json']);
assert.notEqual(
status, 2,
`Expected exit 0 or 1 on live repo, got exit 2 (tool error).\nstdout: ${stdout}\nstderr: ${stderr}`
);
let parsed;
try {
parsed = JSON.parse(stdout);
} catch (err) {
assert.fail(`Live repo --json was not valid JSON: ${err.message}\nstdout: ${stdout}`);
}
assert.equal(typeof parsed.pass, 'boolean', 'Live repo JSON must have boolean pass');
assert.ok(Array.isArray(parsed.checks), 'Live repo JSON must have checks array');
// Node version check must be present and pass (Node >=22 is installed)
const nodeCheck = parsed.checks.find((c) => c.name === 'node-version');
assert.ok(nodeCheck, 'node-version check must be present in live repo output');
assert.equal(nodeCheck.status, 'pass', `node-version should pass on live repo, got: ${nodeCheck.status} — ${nodeCheck.message}`);
// Lockfile checks must pass on the live repo
const lockfileCheck = parsed.checks.find((c) => c.name === 'lockfile-present');
assert.ok(lockfileCheck, 'lockfile-present check must appear in live output');
assert.equal(lockfileCheck.status, 'pass', `lockfile-present should pass on live repo`);
});
});