Files
msd-core/tests/hooks-commonjs-marker.test.cjs
Tom Boucher 33fca50d8a test(#3333): fold the runtime & install surface fix-* cluster — Wave 1 (#3341)
* test(#3333): fold the runtime & install surface fix-* cluster — Wave 1

Folds 11 legacy tests/fix-*.test.cjs regression files into their module's
main test suite: 6 folded into existing suites (host-integration-descriptors,
effort-surface-axis, trae-imperative-reference, hermes-skills-migration,
gsd-agent-isolation-guard), 5 renamed to become the module's sole suite
(cursor-hook-workspace-roots, cursor-subagent-isolation,
lint-compiled-artifact-sync, hooks-commonjs-marker,
shared-hooks-dir-resolution). All 195 test() blocks preserved with zero
drops; lint-test-file-count.cjs and eslint remain clean. No production code
changed. Wave 1 of 7 in #3315 (H3 of epic #3053).

* test(#3333): replace try/finally with t.after() in isolation-guard tests

CONTRIBUTING.md bans try/finally inside test bodies (masks failures, not an
approved pattern). The fold in the prior commit carried 27 instances forward
verbatim from the deleted fix-3045-dispatch-isolation-resolver.test.cjs into
an otherwise-clean file. Converts each to the approved per-test t.after()
cleanup pattern — same cleanup call, registered instead of finally-wrapped.
No assertion, fixture, or test-name change; test( count unchanged at 50.

Found by the Standards review pass on Wave 1 (#3333, H3 of epic #3053).

* fix(#3333): restore raw NUL byte mangled by the fold in hermes-skills-migration.test.cjs

The prior fold commit copied fix-2284-hermes-agent-delegate-task-projection's
"collision-robust" test via a text-based Read/Write pipeline, which silently
turned a raw NUL byte (0x00) embedded in two string literals into a regular
space character. That corrupted the test's actual purpose (proving a NUL
byte survives a string-rewrite operation untouched) and produced a genuine
gsd-test failure: `24 !== 1` for `out.split(' ').length`, because splitting
on a space finds every space in the sentence instead of the single NUL byte
the test meant to isolate.

Root-caused by diffing the raw bytes (via `git cat-file blob` + `cat -v`)
between the pre-fold source and the folded target — confirmed exactly two
bytes differ. Restored via a byte-precise patch (latin1 round-trip) touching
only those two lines; test( count and every other byte unchanged.

* fix(#3333): use \x00 escape sequence instead of a raw NUL byte in test fixture

The prior commit restored a byte-exact raw NUL byte matching the original
fix-2284 source, and the production function (applyClaudeCodeBrandSwap) was
confirmed correct in a standalone repro. But the same raw byte still failed
through gsd-test's remote pipeline. Root cause is upstream of gsd-core: some
step in that transfer path does not carry a raw 0x00 byte through untouched.

A raw embedded NUL byte was never necessary here — `\x00` as a 4-character
escape sequence in the source text produces the identical runtime character
(U+0000) without ever putting a raw byte in the tracked file, sidestepping
any byte-oriented transfer step. Applied at both call sites (the fixture
string and the split() delimiter). No behavior change; test( count unchanged
at 76.

* fix(#3333): harden copyWithPathReplacement against a source file vanishing mid-copy (TOCTOU)

Surfaced by this PR's own gsd-test run: tests/install-minimal-hooks.test.cjs
and tests/opencode-command-dir-plural.test.cjs intermittently crashed with
ENOENT reading gsd-core/workflows/zzz-e5-drift-fixture.md. Root cause is
unrelated to test-file consolidation — tests/planning-prompt-drift.test.cjs
writes that fixture directly into the real, shared gsd-core/workflows/ tree
(main() hardcodes its scan root to the real repo) and deletes it in
t.after(); copyWithPathReplacement's readdirSync-then-read loop has no
protection against the listed file vanishing before it gets there, so a
concurrently-running install path can crash entirely on what is otherwise a
completely benign race.

Fixed by skipping (not crashing on) a listed entry that no longer exists by
the time the loop reaches it. Added a regression test that deterministically
reproduces the race (readdirSync snapshot still lists the file; it is
deleted immediately after) and proves both outcomes: no throw, and the
vanished entry's destination is never partially written.

Per CLAUDE.md's no-defer rule, a defect surfaced while verifying this PR is
fixed inline rather than deferred — this overrides one-concern-per-PR.

* fix(#3333): fix third NUL-byte-mangled occurrence missed by prior fix passes

The fold originally mangled three raw-NUL-byte occurrences to spaces, not
two — the earlier byte-restore and escape-sequence commits both only
targeted the fixture string and the split() delimiter, missing
out.includes('[ ]') a few lines below (should read out.includes('[\x00]')).
A remote gsd-test run kept failing on this exact assertion even after both
prior fixes, which is what surfaced the miss. Verified via a standalone
repro using the file's real (not retyped) fixture content: all six
assertions in the collision-robust test now pass. Zero raw NUL bytes remain
in the file; test( count unchanged at 76.

* chore(#3333): add changeset for the copyWithPathReplacement TOCTOU fix

Fixed-type fragment for the production defect fixed inline in this PR
(bin/install.js's copyWithPathReplacement). Exempt from docs/ requirements
per CONTRIBUTING.md (only Added/Changed/Deprecated/Removed require it).

* chore(#3333): backfill changeset PR number (pr:0 -> pr:3341)

---------

Co-authored-by: sim <sim@local>
2026-08-10 19:02:34 -04:00

148 lines
6.9 KiB
JavaScript

'use strict';
// Regression tests for #2717: cursor, windsurf, and codex stage `.js` hook
// scripts via dedicated paths that bypass installSharedHooksBundle (the only
// writer of the {"type":"commonjs"} marker). Under a config root declaring
// {"type":"module"}, Node loaded those scripts as ESM and every require() failed
// with "require is not defined", silently disabling the runtime's lifecycle
// hooks.
//
// These tests assert the invariant structurally: whenever a runtime stages one
// or more `.js` hooks into its GSD-owned hooks directory, a package.json forcing
// CommonJS mode exists in that SAME directory, and a require()-using hook
// actually loads under an ESM-typed parent.
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { runMinimalInstall } = require('./helpers/install-shared.cjs');
const COMMONJS_MARKER = '{"type":"commonjs"}\n';
// Runtimes that stage `.js` hooks via the dedicated cursor/windsurf/codex paths
// (skipSharedHooksInstall or the !isCodex gate) — the three #2717 covers.
const AFFECTED_RUNTIMES = [
{ runtime: 'cursor', sampleHook: 'gsd-cursor-session-start.js' },
{ runtime: 'windsurf', sampleHook: 'gsd-windsurf-pre-write.js' },
{ runtime: 'codex', sampleHook: 'gsd-check-update.js' },
];
function readMarker(configDir) {
const p = path.join(configDir, 'hooks', 'package.json');
if (!fs.existsSync(p)) return null;
return fs.readFileSync(p, 'utf8');
}
describe('#2717 CommonJS marker for staged .js hooks', () => {
for (const { runtime, sampleHook } of AFFECTED_RUNTIMES) {
test(`${runtime}: install writes {"type":"commonjs"} into hooks/ alongside the staged .js scripts`, (t) => {
const { configDir, root } = runMinimalInstall({ runtime, scope: 'global' });
t.after(() => cleanup(root));
// The sample hook must actually be staged (sanity — confirms the install
// reached the dedicated .js-staging path for this runtime).
const hookPath = path.join(configDir, 'hooks', sampleHook);
assert.ok(
fs.existsSync(hookPath),
`${runtime} install must stage ${sampleHook} (got: ${fs.readdirSync(path.join(configDir, 'hooks')).join(',')})`,
);
// The marker must exist in the SAME directory, with GSD's exact content.
const marker = readMarker(configDir);
assert.strictEqual(
marker,
COMMONJS_MARKER,
`${runtime}: hooks/package.json must be exactly {"type":"commonjs"}\\n so Node loads the staged .js hooks as CommonJS even when the config root declares {"type":"module"}`,
);
});
}
// Reproduces the exact failure mode in the issue: a config-root package.json
// declaring {"type":"module"}. Pre-fix, Node walked up from the .js hook,
// found this file, and loaded the hook as ESM → require() threw. Post-fix,
// the GSD-written hooks/package.json is nearer and wins. The hook may exit
// non-zero for benign reasons (no STATE.md, no config, etc.) — the ONLY
// failure we gate on is the ESM/require error on stderr.
function assertHookLoadsUnderEsmRoot(t, runtime, hookFile, stdinPayload) {
const { configDir, root } = runMinimalInstall({ runtime, scope: 'global' });
t.after(() => cleanup(root));
// Plant the hostile ESM-typed package.json at the config root.
fs.writeFileSync(path.join(configDir, 'package.json'), '{"type":"module"}\n');
const hookPath = path.join(configDir, 'hooks', hookFile);
assert.ok(fs.existsSync(hookPath), `${runtime} hook ${hookFile} must be staged`);
let stderr = '';
try {
execFileSync(process.execPath, [hookPath], {
cwd: root,
input: stdinPayload,
encoding: 'utf8',
timeout: 20000,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (e) {
// Non-zero exit is allowed (benign); capture stderr for the ESM check.
stderr = String(e.stderr || '');
}
assert.ok(
!/require is not defined/i.test(stderr),
`${runtime} hook ${hookFile} must load as CommonJS under an ESM-typed config root; got ESM error:\n${stderr}`,
);
}
test('cursor: a require()-using hook loads under an ESM-typed config root after install', (t) => {
assertHookLoadsUnderEsmRoot(t, 'cursor', 'gsd-cursor-session-start.js', JSON.stringify({ workspace_roots: [] }));
});
test('codex: a require()-using hook loads under an ESM-typed config root after install', (t) => {
// codex is the !isCodex-gated path most likely to regress (its marker write
// lives in bin/install.js, not the surface). gsd-check-update.js uses
// require() at module load, so it surfaces the ESM failure immediately.
assertHookLoadsUnderEsmRoot(t, 'codex', 'gsd-check-update.js', '');
});
test('uninstall path: removeCommonJsMarkerIfGsdOwned removes only GSD-owned markers', (t) => {
// The uninstall cleanup uses removeCommonJsMarkerIfGsdOwned (exported from
// the runtime-hooks-surface). Assert its contract directly: it deletes a
// GSD-written marker but never a user-authored package.json.
const {
removeCommonJsMarkerIfGsdOwned,
ensureCommonJsMarker,
} = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
// Case 1: GSD-owned marker is removed.
const dirA = createTempDir('gsd-2717-rmA-');
t.after(() => cleanup(dirA));
assert.ok(ensureCommonJsMarker(dirA), 'ensureCommonJsMarker writes the marker');
const markerA = path.join(dirA, 'package.json');
assert.strictEqual(fs.readFileSync(markerA, 'utf8'), COMMONJS_MARKER);
assert.ok(removeCommonJsMarkerIfGsdOwned(dirA), 'removes a GSD-owned marker');
assert.ok(!fs.existsSync(markerA), 'GSD-owned marker is gone');
// Case 2: user-authored package.json is preserved.
const dirB = createTempDir('gsd-2717-keepB-');
t.after(() => cleanup(dirB));
const userContent = '{"name":"user-owned","type":"module"}\n';
fs.writeFileSync(path.join(dirB, 'package.json'), userContent);
assert.ok(!removeCommonJsMarkerIfGsdOwned(dirB), 'does not remove a non-GSD package.json');
assert.strictEqual(fs.readFileSync(path.join(dirB, 'package.json'), 'utf8'), userContent);
// Case 3: no marker → no-op, no throw.
const dirC = createTempDir('gsd-2717-noopC-');
t.after(() => cleanup(dirC));
assert.ok(!removeCommonJsMarkerIfGsdOwned(dirC), 'no-op when no marker exists');
// Case 4: ensureCommonJsMarker is idempotent and does not clobber a user file.
const dirD = createTempDir('gsd-2717-idemD-');
t.after(() => cleanup(dirD));
fs.writeFileSync(path.join(dirD, 'package.json'), userContent);
assert.ok(!ensureCommonJsMarker(dirD), 'does not overwrite a user-authored package.json');
assert.strictEqual(fs.readFileSync(path.join(dirD, 'package.json'), 'utf8'), userContent);
});
});