/** * Tests for the Windows npm resolution platform gate. * * Background (issue #3103, PR #3102): * On Windows, `npm` ships as `npm.cmd`. Node's spawn does not apply PATHEXT * resolution and fails with ENOENT. The fix is to spawn through a shell on * Windows (cmd.exe resolves npm.cmd via PATHEXT). On POSIX, `npm` resolves * without a shell, so spawning `/bin/sh -c` is pure overhead and changes * signal / exit-code semantics — undesirable. * * Relocation (#498): the SessionStart worker no longer spawns npm itself. It * delegates the latest-version lookup to check-latest-version's * `checkLatestVersion()`, which routes through `execNpm` in the shell-command * projection seam. The PR #3102 contract therefore now lives on `execNpm`. * This test locks it there, and additionally locks that the worker does NOT * re-introduce a direct npm spawn (which would re-open the gate question in a * second place). * * Source-grep policy: these structural assertions read source via readFileSync. * The behavior (Windows-only shell resolution) is platform-gated at runtime and * cannot be reached on POSIX CI without a Windows lane; a structural assertion * is the minimum-cost contract. */ 'use strict'; const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); const { scanFencedBlocks } = require('../msd-core/bin/lib/markdown-sectionizer.cjs'); const { LOOKUP_TIMEOUT_MS } = require('../msd-core/bin/check-latest-version.cjs'); /** * NOT a margin above LOOKUP_TIMEOUT_MS (imported above for disclosure, * per this batch's epic issue -- this file and check-latest-version.cjs * once independently guessed the same 15000ms value, causing a Windows * double-SIGKILL collision fixed in PR #4428). This specific scenario * (missing compiled runtime library) degrades BEFORE reaching the wrapped * release-tag lookup at all -- see this test's own #3582 comment * ("checkLatestVersion -> not ok" on the degrade, never invoked) -- so its * bound has no margin relationship to LOOKUP_TIMEOUT_MS to preserve. * Kept as its own independent, pre-existing value. */ const WORKER_DEGRADED_PATH_TIMEOUT_MS = 8000; /** * msd-update-banner.js only reads a small cache file from disk and prints * JSON -- no subprocess/network work -- so this leaves generous headroom * over its sub-second worst case even on a contended CI runner. NOT * related to LOOKUP_TIMEOUT_MS or WORKER_DEGRADED_PATH_TIMEOUT_MS above * (a different hook script entirely). */ const UPDATE_BANNER_HOOK_TIMEOUT_MS = 10_000; // Imported for disclosure only (see WORKER_DEGRADED_PATH_TIMEOUT_MS's own // doc comment above): confirms this file references the collision-prone // constant from check-latest-version.cjs without deriving any of this // file's own timeout values from it. void LOOKUP_TIMEOUT_MS; const WORKER_PATH = path.join(__dirname, '..', 'hooks', 'msd-check-update-worker.js'); const PROJECTION_PATH = path.join( __dirname, '..', 'msd-core', 'bin', 'lib', 'shell-command-projection.cjs', ); // allow-test-rule: structural-regression-guard (#3103) // This helper feeds real source (via readFileSync) into structural // assertions below. The behavior it guards — Windows-only shell // resolution — is platform-gated at runtime and cannot be reached on // POSIX CI without a Windows lane, so a structural assertion on the // spawn-options shape is the minimum-cost contract. function codeOnly(file) { return fs.readFileSync(file, 'utf8') // eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own bounded hooks/lib source, not adversarial input .replace(/\/\*[\s\S]*?\*\//g, '') // eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own bounded hooks source, not adversarial input .replace(/(^|[^:])\/\/[^\r\n]*/g, '$1'); } describe('execNpm: Windows npm spawn platform gate (PR #3102, relocated #498)', () => { test('projection seam exists', () => { assert.ok(fs.existsSync(PROJECTION_PATH), `not found at ${PROJECTION_PATH}`); }); test('execNpm gates shell to process.platform === "win32"', () => { assert.match( codeOnly(PROJECTION_PATH), /shell:\s*process\.platform\s*===\s*['"]win32['"]/, [ 'execNpm must gate shell to `process.platform === "win32"`.', 'A regression to `shell: true` would spawn /bin/sh -c on POSIX', '(adds shell overhead, changes signal/exit semantics). See PR #3102.', ].join(' '), ); }); test('no unconditional shell: true on the npm spawn', () => { assert.doesNotMatch( codeOnly(PROJECTION_PATH), /shell\s*:\s*true\s*[,\s}]/, 'shell: true is forbidden — use the `process.platform === "win32"` gate.', ); }); }); describe('worker delegates the npm spawn (does not re-open the gate, #498)', () => { test('worker does NOT spawn npm directly', () => { const code = codeOnly(WORKER_PATH); assert.doesNotMatch( code, /(execFileSync|spawnSync|execSync|exec)\s*\(\s*['"]npm['"]/, 'Worker must delegate to checkLatestVersion(), not spawn npm itself.', ); }); test('worker requires check-latest-version for the lookup', () => { assert.match(codeOnly(WORKER_PATH), /check-latest-version/); }); }); // ─── #3582: cold tree (no msd-core/bin/lib/*.cjs) — degrade, not crash ───── // // msd-core/bin/lib/semver-compare.cjs, package-identity.cjs, and (via // check-latest-version.cjs's own transitive requires) msd-core/bin/lib/ // cli-exit.cjs + shell-command-projection.cjs are tsc build artifacts // (ADR-457), gitignored and absent on a raw plugin-marketplace / git-clone // install that never ran `npm run build:lib`. This worker is a DETACHED // SessionStart background process (spawned with stdio: 'ignore' by // hooks/msd-check-update.js) — before #3582 a missing library crashed the // worker at module load with no visible signal (stderr discarded by the // parent) and no cache-file write at all, so the statusline/banner would // silently never see an update signal. The fix wraps ensureRuntimeBuild() // and the three compiled-lib requires in one try/catch and degrades to // no-signal fallbacks (isSemverNewer -> false, checkLatestVersion -> not ok, // PACKAGE_NAME -> null) on failure — the worker still runs to completion and // writes a result cache record. Simulated hermetically via a fixture install // tree that copies hooks/ + the seam module but never msd-core/bin/lib/ or // tsconfig.build.json (tests/helpers/cold-runtime-lib-fixture.cjs) — the REAL // msd-core/bin/lib/ is never touched. { const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { runHook: runHookSeam } = require('./helpers/process-seam.cjs'); const { buildColdInstallTree } = require('./helpers/cold-runtime-lib-fixture.cjs'); const { createTempDir, cleanup } = require('./helpers.cjs'); describe('msd-check-update-worker.js: #3582 cold tree — degrade, not crash', () => { test('missing compiled runtime library -> worker still writes a degraded result cache, no crash', (t) => { const cold = buildColdInstallTree(); t.after(cold.cleanup); const cacheDir = createTempDir('msd-worker-cold-'); t.after(() => cleanup(cacheDir)); const cacheFile = path.join(cacheDir, 'cache.json'); const env = { ...process.env, MSD_CACHE_FILE: cacheFile, MSD_PROJECT_VERSION_FILE: path.join(cacheDir, 'no-such-project', 'VERSION'), MSD_GLOBAL_VERSION_FILE: path.join(cacheDir, 'no-such-global', 'VERSION'), }; const r = runHookSeam(path.join(cold.hooksDir, 'msd-check-update-worker.js'), [], { env, timeoutMs: WORKER_DEGRADED_PATH_TIMEOUT_MS, }); assert.equal(r.exitCode, 0, `worker must exit 0 on a build failure; stderr: ${r.stderr}`); assert.ok(fs.existsSync(cacheFile), 'worker must still reach the end and write a cache record'); const cache = JSON.parse(fs.readFileSync(cacheFile, 'utf8')); assert.equal(cache.package_name, null, 'degraded package_name must be null (no-signal, never a stale/foreign value)'); assert.ok(!cache.update_available, 'degraded update_available must be falsy'); assert.equal(cache.installed, '0.0.0', 'installed detection is unaffected by the compiled-lib degrade'); }); }); } // ─── #3582: cold-tree fixture must tolerate a concurrent build-hooks.js // staging dir, without mutating the live hooks/ tree ───────────────────── // // scripts/build-hooks.js writes atomically via a per-PID staging dir // (hooks/.dist-staging-) that it creates and removes; up to nine test // files invoke it concurrently from their `before()` hooks, so the live // hooks/ dir is never guaranteed stable during a test run. This is proven // HERMETICALLY, against a fake source tree under a temp dir — planting a // staging dir inside the REAL repo's hooks/ would itself be the exact // shared-state race this fixture exists to guard against (other test files // read hooks/ concurrently), and cleanup() (tests/helpers.cjs) deliberately // refuses to remove any path outside the known temp roots, so a real-repo // plant can never be cleaned up through it either. { 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 { buildColdInstallTree, REPO_ROOT, shouldCopyHookEntry } = require('./helpers/cold-runtime-lib-fixture.cjs'); const { cleanup } = require('./helpers.cjs'); describe('cold-runtime-lib-fixture.cjs: #3582 tolerates a concurrent hooks/.dist-staging- dir', () => { test('a live .dist-staging-test- dir does not break the fixture copy, and is excluded from it', (t) => { // Build a hermetic fake source tree — never touch the real repo's hooks/. const fakeRepoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-fake-repo-')); t.after(() => cleanup(fakeRepoRoot)); const fakeHooksDir = path.join(fakeRepoRoot, 'hooks'); fs.mkdirSync(fakeHooksDir, { recursive: true }); // Representative real hook entries the fixture must still carry over. fs.writeFileSync(path.join(fakeHooksDir, 'hooks.json'), '{}'); fs.writeFileSync(path.join(fakeHooksDir, 'top-level-hook.js'), '// fake hook\n'); fs.mkdirSync(path.join(fakeHooksDir, 'lib'), { recursive: true }); fs.writeFileSync(path.join(fakeHooksDir, 'lib', 'helper.js'), '// fake lib helper\n'); // Build output dir — must be excluded. fs.mkdirSync(path.join(fakeHooksDir, 'dist'), { recursive: true }); fs.writeFileSync(path.join(fakeHooksDir, 'dist', 'built.js'), '// built output\n'); // Concurrent build-hooks.js staging dir — must be excluded, and must // not break the copy even while "live". const stagingName = `.dist-staging-99999`; const stagingDir = path.join(fakeHooksDir, stagingName); fs.mkdirSync(stagingDir); fs.writeFileSync(path.join(stagingDir, 'scratch.txt'), 'transient build output'); // The fixture also copies msd-core/bin/ensure-runtime-build.cjs — give // the fake tree a real copy of it so buildColdInstallTree() succeeds. const fakeBinDir = path.join(fakeRepoRoot, 'msd-core', 'bin'); fs.mkdirSync(fakeBinDir, { recursive: true }); fs.copyFileSync( path.join(REPO_ROOT, 'msd-core', 'bin', 'ensure-runtime-build.cjs'), path.join(fakeBinDir, 'ensure-runtime-build.cjs'), ); // Filtered by shouldCopyHookEntry — the same predicate the fixture // itself uses to decide what to copy. Up to nine other test files' // before() hooks concurrently invoke scripts/build-hooks.js, which // creates/removes hooks/.dist-staging- at unpredictable times, so // a RAW (unfiltered) before/after listing comparison of the live // hooks/ dir is itself racy — it can observe a sibling build's // transient staging dir appear or vanish between the two snapshots and // fail with no real defect. Filtering both snapshots the same way the // fixture does preserves the actual intent (this test adds/removes no // REAL entry in the repo's hooks/) while tolerating scratch that is // not this test's doing and is excluded from the fixture anyway. const realHooksBefore = fs .readdirSync(path.join(REPO_ROOT, 'hooks')) .filter(shouldCopyHookEntry) .sort(); const cold = buildColdInstallTree({ repoRoot: fakeRepoRoot }); t.after(cold.cleanup); const entries = fs.readdirSync(cold.hooksDir); assert.ok( !entries.some((e) => e.startsWith('.dist-staging')), `fixture hooks/ must not contain any .dist-staging* entry, got: ${entries.join(', ')}`, ); assert.ok(!entries.includes('dist'), `fixture hooks/ must not contain dist/, got: ${entries.join(', ')}`); assert.ok(entries.includes('hooks.json'), 'fixture must still contain the representative hooks.json'); assert.ok(entries.includes('top-level-hook.js'), 'fixture must still contain the representative top-level hook'); assert.ok( fs.existsSync(path.join(cold.hooksDir, 'lib', 'helper.js')), 'fixture must still contain the representative hooks/lib/ subdir file', ); const realHooksAfter = fs .readdirSync(path.join(REPO_ROOT, 'hooks')) .filter(shouldCopyHookEntry) .sort(); assert.deepEqual( realHooksAfter, realHooksBefore, 'the real repo hooks/ directory listing, filtered by shouldCopyHookEntry, must be unchanged by ' + 'this test (transient hooks/.dist-staging- entries from concurrent build-hooks.js runs are ' + 'excluded from the comparison since this test does not own them)', ); }); }); // Direct pin on shouldCopyHookEntry() itself — the predicate IS the fix // (exact 'dist' match plus a '.dist-staging' PREFIX, not a loose // startsWith('dist')/includes('dist') substring match). Pinning it only // indirectly, via the fixture-shape assertions above, would let a looser // implementation (e.g. name.startsWith('dist')) pass every case above // while still being wrong — this pins the exact rule. describe('cold-runtime-lib-fixture.cjs: shouldCopyHookEntry() name-filter rule', () => { const { shouldCopyHookEntry } = require('./helpers/cold-runtime-lib-fixture.cjs'); test('excludes the build output dir and any .dist-staging* prefix name', () => { assert.equal(shouldCopyHookEntry('dist'), false); assert.equal(shouldCopyHookEntry('.dist-staging-20836'), false); assert.equal(shouldCopyHookEntry('.dist-staging-test-abc'), false); assert.equal(shouldCopyHookEntry('.dist-staging'), false); }); test('keeps real hook entries', () => { assert.equal(shouldCopyHookEntry('hooks.json'), true); assert.equal(shouldCopyHookEntry('lib'), true); assert.equal(shouldCopyHookEntry('msd-check-update.js'), true); }); test('does not over-match on a bare "dist" substring/prefix', () => { assert.equal(shouldCopyHookEntry('dist-staging-no-dot'), true); assert.equal(shouldCopyHookEntry('distant.js'), true); }); }); } // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-2992-check-latest-version.test.cjs — consolidation epic #1969 (B5 #1974) // ──────────────────────────────────────────────────────────────────────── { const { describe: __foldDescribe } = require('node:test'); __foldDescribe("folded:bug-2992-check-latest-version (consolidation epic #1969 B5 #1974)", () => { 'use strict'; process.env.MSD_TEST_MODE = '1'; const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); const ROOT = path.join(__dirname, '..'); const { checkLatestVersion, CHECK_REASON, PACKAGE_NAME, REPO_URL } = require( path.join(ROOT, 'msd-core', 'bin', 'check-latest-version.cjs'), ); // checkLatestVersion is a pure-ish function: it runs one fixed // `git ls-remote --tags` against the baked repository URL, picks the highest // release tag, and returns { ok, version | reason }. The repository is // HARDCODED — not a free choice for the caller. Tests use a pluggable spawn // so no real git process (or network) is involved. // `git ls-remote --tags --refs` output: one `\t` line per tag. const lsRemote = (...tags) => tags.map((t, i) => `${String(i).padStart(40, '0')}\trefs/tags/${t}`).join('\n') + '\n'; const okSpawn = (...tags) => () => ({ status: 0, stdout: lsRemote(...tags), stderr: '' }); describe('Bug #2992: deterministic latest-version check', () => { test('PACKAGE_NAME is the constant @golem15/msd-core (no callers can override)', () => { assert.equal(PACKAGE_NAME, '@golem15/msd-core'); }); test('REPO_URL is the baked repository the release tags are read from', () => { assert.equal(REPO_URL, 'https://git.golem15.com/golem15/msd-core'); }); test('CHECK_REASON enum exposes the documented codes', () => { assert.deepEqual( Object.keys(CHECK_REASON).sort(), ['FAIL_LOOKUP_FAILED', 'FAIL_NO_RELEASES', 'OK'].sort(), ); }); test('returns { ok: true, version } for the highest vX.Y.Z tag', () => { const r = checkLatestVersion({ spawn: okSpawn('v1.39.1', 'v1.40.0', 'v1.9.9') }); assert.deepEqual(r, { ok: true, version: '1.40.0', reason: CHECK_REASON.OK }); }); }); describe('Bug #2992: error paths', () => { const { checkLatestVersion, CHECK_REASON } = require(require('node:path').join(__dirname, '..', 'msd-core', 'bin', 'check-latest-version.cjs')); test('FAIL_LOOKUP_FAILED when git exits non-zero (e.g. offline, repo moved)', () => { const r = checkLatestVersion({ spawn: () => ({ status: 128, stdout: '', stderr: 'fatal: unable to access\n' }), }); assert.equal(r.ok, false); assert.equal(r.reason, CHECK_REASON.FAIL_LOOKUP_FAILED); assert.equal(r.detail, 'fatal: unable to access', 'detail should be the trimmed stderr when git reports a real error'); }); // #2993 CR: distinguish timeout from genuine failure in `detail`. // spawnSync sets status=null and signal='SIGTERM' on timeout; stderr is // typically empty. Without the signal-first branch, both shape as // 'git exited non-zero' and the operator cannot tell timeout from failure. test('FAIL_LOOKUP_FAILED detail names the signal when spawn times out', () => { const r = checkLatestVersion({ spawn: () => ({ status: null, signal: 'SIGTERM', stdout: '', stderr: '' }), }); assert.equal(r.ok, false); assert.equal(r.reason, CHECK_REASON.FAIL_LOOKUP_FAILED); assert.equal(r.detail, 'git timed out (signal: SIGTERM)', 'detail should explicitly name the signal when status is null and signal is set'); }); test('FAIL_LOOKUP_FAILED detail falls back to generic when neither stderr nor signal is present', () => { const r = checkLatestVersion({ spawn: () => ({ status: 1, stdout: '', stderr: '' }), }); assert.equal(r.detail, 'git exited non-zero'); }); test('FAIL_NO_RELEASES when the remote has no release tags', () => { const r = checkLatestVersion({ spawn: () => ({ status: 0, stdout: '', stderr: '' }) }); assert.equal(r.ok, false); assert.equal(r.reason, CHECK_REASON.FAIL_NO_RELEASES); }); test('FAIL_NO_RELEASES when only non-release refs exist (junk tags are data, not versions)', () => { const r = checkLatestVersion({ spawn: okSpawn('nightly', 'v1.2', '1.2.3', 'v1.2.3