'use strict'; /** * Build a hermetic "cold tree" install fixture for #3582 — a copy of hooks/ * plus msd-core/bin/ensure-runtime-build.cjs with the compiled * msd-core/bin/lib/*.cjs directory and tsconfig.build.json deliberately * ABSENT, mirroring a raw plugin-marketplace / git-clone install that never * ran `npm run build:lib`. * * Deliberately NOT `tests/helpers/copy-script-fixture.cjs`'s * `copyScriptWithDeps`: that helper walks the require graph and copies every * dependency it finds — including msd-core/bin/lib/*.cjs, which exist in * THIS repo's already-built tree — so it would faithfully reproduce a WARM * tree, the opposite of what a cold-tree test needs. This helper copies only * hooks/ and the seam module itself, and never touches msd-core/bin/lib/ or * tsconfig.build.json — so `ensureRuntimeBuild()` inside the fixture * deterministically throws `RuntimeBuildError` ("tsconfig.build.json not * found") the first time any fixture hook reaches it, without ever deleting * or touching the real repo's msd-core/bin/lib/. */ const fs = require('node:fs'); const path = require('node:path'); const REPO_ROOT = path.resolve(__dirname, '..', '..'); /** * Pure name-filter decision for a single hooks/ top-level entry: should it be * copied into the cold-tree fixture? Excludes the build output dir and any * transient build-hooks.js staging dir (both by NAME ONLY — see the * no-stat-on-a-skipped-name rationale in buildColdInstallTree below). * @param {string} name * @returns {boolean} */ function shouldCopyHookEntry(name) { return name !== 'dist' && !name.startsWith('.dist-staging'); } /** * @param {object} [opts] * @param {string} [opts.repoRoot] TEST-ONLY: source tree root to copy hooks/ * and msd-core/bin/ensure-runtime-build.cjs from, in place of the real * REPO_ROOT. Lets a test point this fixture at a hermetic fake tree instead * of mutating the live repo's hooks/ directory (which other test files may * be concurrently reading). Defaults to REPO_ROOT. * @returns {{ dir: string, hooksDir: string, cleanup: () => void }} */ function buildColdInstallTree(opts = {}) { const repoRoot = opts.repoRoot || REPO_ROOT; const dir = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'msd-cold-tree-')); // hooks/ — entire directory (top-level hook scripts + hooks/lib/*.js + // managed-hooks-registry.cjs + hooks.json). hooks/dist/ (gitignored, // build-hooks.js output) is excluded — it is not present in a raw // marketplace checkout either. // // This is NOT a single recursive fs.cpSync(hooks/, ...) with a `filter` // callback. The live hooks/ directory is not stable during a test run: // scripts/build-hooks.js writes atomically via a per-PID staging dir // (hooks/.dist-staging-, gitignored — see .gitignore:21) that it // creates and then removes once the atomic rename into hooks/dist/ is // done, and up to nine test files invoke build-hooks.js concurrently from // their `before()` hooks. A recursive cpSync enumerates hooks/, may // observe another process's transient .dist-staging- entry, and can // then lstat/copy it AFTER that process has already deleted it — an // intermittent ENOENT ("no such file or directory, lstat // '.../hooks/.dist-staging-'"). Excluding only the basename 'dist' // does not help: '.dist-staging-' is a different name. // // Fix: enumerate hooks/ ourselves and skip transient entries BY NAME // ONLY, before ever touching them — no stat/lstat on a name we're going // to skip. Skipping by name (rather than filtering after readdir handed // control to fs.cpSync's own traversal) is what closes the race: a // vanishing .dist-staging- dir is never accessed at all once its // name has excluded it. const hooksDestDir = path.join(dir, 'hooks'); fs.mkdirSync(hooksDestDir, { recursive: true }); for (const entry of fs.readdirSync(path.join(repoRoot, 'hooks'), { withFileTypes: true })) { if (!shouldCopyHookEntry(entry.name)) continue; // #3900 (same-class guard, found in review): the live repo tree is walked // here — a daemon's socket/FIFO inside hooks/ would reach cpSync and // throw ENXIO (a FIFO would block). Dirent type check, not name-only. if (!entry.isFile() && !entry.isDirectory()) continue; fs.cpSync(path.join(repoRoot, 'hooks', entry.name), path.join(hooksDestDir, entry.name), { recursive: true, }); } // msd-core/bin/ensure-runtime-build.cjs — the seam itself. Deliberately // NOT msd-core/bin/lib/ (absent — isBuilt() reads false) and NOT // tsconfig.build.json at the fixture root (absent — ensureRuntimeBuild's // "cannot auto-build" branch fires deterministically). fs.mkdirSync(path.join(dir, 'msd-core', 'bin'), { recursive: true }); fs.copyFileSync( path.join(repoRoot, 'msd-core', 'bin', 'ensure-runtime-build.cjs'), path.join(dir, 'msd-core', 'bin', 'ensure-runtime-build.cjs'), ); function cleanup() { fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3 }); } return { dir, hooksDir: path.join(dir, 'hooks'), cleanup }; } module.exports = { buildColdInstallTree, REPO_ROOT, shouldCopyHookEntry };