* test(#3900): a unix socket under the repo root must not break the overlay (failing first) * fix(#3900): the overlay walker skips non-regular files (sockets, FIFOs, device nodes) The walker classified every not-a-directory entry as a file, so a unix socket anywhere under the repo root (an MCP server, a language-server daemon, a watcher) made copyFileSync throw ENXIO — the reporter measured 32 local test failures across 6 files, all in before() hooks, none about the code under test; a FIFO would block until a writer appeared. The hard-link path does not save it: the overlay sits under tmpfs, so cross-device EXDEV always falls through to the copy. One guard at the classification site (srcStat.isFile() before the copy/link branches) — sockets, FIFOs, and device nodes are not repository content; CI never saw this because a fresh clone has no working-tree daemons, which is exactly why it ate local debugging time. Also adds opts.root (test-only root override, default REPO_ROOT) so the regression tests build the overlay from a tiny synthetic root in milliseconds instead of paying a full repo walk. * chore(#3900): changeset fragment (pr number backfilled after PR creation) * fix(#3900): review fold-ins — sibling cold-fixture guard, changeset format, root doc buildColdInstallTree enumerated the live repo's hooks/ with a NAME-only filter — the same class #3900 documents: a daemon socket or FIFO inside hooks/ would reach cpSync and throw ENXIO (or block). Dirent type guard added. The changeset gains the mandated bold user-visible lead, and opts.root is documented at its definition (mirroring cold-runtime-lib- fixture's repoRoot convention). * chore(#3900): backfill changeset PR number (4062) --------- Co-authored-by: sim <sim@local>
107 lines
5.1 KiB
JavaScript
107 lines
5.1 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Build a hermetic "cold tree" install fixture for #3582 — a copy of hooks/
|
|
* plus gsd-core/bin/ensure-runtime-build.cjs with the compiled
|
|
* gsd-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 gsd-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 gsd-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 gsd-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 gsd-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(), 'gsd-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-<pid>, 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-<pid> 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-<pid>'"). Excluding only the basename 'dist'
|
|
// does not help: '.dist-staging-<pid>' 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-<pid> 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,
|
|
});
|
|
}
|
|
|
|
// gsd-core/bin/ensure-runtime-build.cjs — the seam itself. Deliberately
|
|
// NOT gsd-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, 'gsd-core', 'bin'), { recursive: true });
|
|
fs.copyFileSync(
|
|
path.join(repoRoot, 'gsd-core', 'bin', 'ensure-runtime-build.cjs'),
|
|
path.join(dir, 'gsd-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 };
|