diff --git a/tests/gsd-check-update-worker-platform-gate.test.cjs b/tests/gsd-check-update-worker-platform-gate.test.cjs index 336854147..7cb383f40 100644 --- a/tests/gsd-check-update-worker-platform-gate.test.cjs +++ b/tests/gsd-check-update-worker-platform-gate.test.cjs @@ -144,6 +144,116 @@ describe('worker delegates the npm spawn (does not re-open the gate, #498)', () }); } +// ─── #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 } = 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(), 'gsd-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 gsd-core/bin/ensure-runtime-build.cjs — give + // the fake tree a real copy of it so buildColdInstallTree() succeeds. + const fakeBinDir = path.join(fakeRepoRoot, 'gsd-core', 'bin'); + fs.mkdirSync(fakeBinDir, { recursive: true }); + fs.copyFileSync( + path.join(REPO_ROOT, 'gsd-core', 'bin', 'ensure-runtime-build.cjs'), + path.join(fakeBinDir, 'ensure-runtime-build.cjs'), + ); + + const realHooksBefore = fs.readdirSync(path.join(REPO_ROOT, 'hooks')).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')).sort(); + assert.deepEqual( + realHooksAfter, + realHooksBefore, + 'the real repo hooks/ directory listing must be unchanged by this test', + ); + }); + }); + + // 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('gsd-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) diff --git a/tests/helpers/cold-runtime-lib-fixture.cjs b/tests/helpers/cold-runtime-lib-fixture.cjs index 8a22dacd0..5c20cbccd 100644 --- a/tests/helpers/cold-runtime-lib-fixture.cjs +++ b/tests/helpers/cold-runtime-lib-fixture.cjs @@ -25,20 +25,62 @@ const path = require('node:path'); const REPO_ROOT = path.resolve(__dirname, '..', '..'); /** - * @param {(dir: string) => void} [t.after] optional node:test `t` for auto-cleanup registration; caller may also ignore and use the returned `cleanup`. + * 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() { +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. - fs.cpSync(path.join(REPO_ROOT, 'hooks'), path.join(dir, 'hooks'), { - recursive: true, - filter: (src) => path.basename(src) !== 'dist', - }); + // + // 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; + 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 @@ -46,7 +88,7 @@ function buildColdInstallTree() { // "cannot auto-build" branch fires deterministically). fs.mkdirSync(path.join(dir, 'gsd-core', 'bin'), { recursive: true }); fs.copyFileSync( - path.join(REPO_ROOT, 'gsd-core', 'bin', 'ensure-runtime-build.cjs'), + path.join(repoRoot, 'gsd-core', 'bin', 'ensure-runtime-build.cjs'), path.join(dir, 'gsd-core', 'bin', 'ensure-runtime-build.cjs'), ); @@ -57,4 +99,4 @@ function buildColdInstallTree() { return { dir, hooksDir: path.join(dir, 'hooks'), cleanup }; } -module.exports = { buildColdInstallTree, REPO_ROOT }; +module.exports = { buildColdInstallTree, REPO_ROOT, shouldCopyHookEntry };