Files
msd-core/tests/hooks-commonjs-marker.test.cjs
Jakub Zych 6cfa0c55d2 refactor: drop 12 runtimes, keep Claude, Codex, OpenCode, Cursor, ZCode, Antigravity
Removes kilo, kimi, kimi-code, copilot, windsurf, augment, trae, qwen, hermes,
cline, codebuddy and pi end to end: capability descriptors, installer branches
and converters (bin/install.js 14.9k -> 11.2k lines), TypeScript converters,
hook surfaces and runtime homes, review lanes qwen/kimi-code, the two pi
migrations, Kimi payload normalization in the hook guards, dead hostBehaviors
vocabulary, launcher home probes, fixtures, runtime-specific tests and the
prose that presented them as supported.

Installer output for the six kept runtimes is byte-identical to before the
prune. The Kimi tool-vocabulary tests in workflow-guard, read-guard and
read-injection-scanner are left in place pending a decision.
2026-10-06 20:02:40 +02:00

254 lines
12 KiB
JavaScript

'use strict';
// Regression tests for #2717: cursor 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 MSD-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 { STAGED_HOOK_SCRIPT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const COMMONJS_MARKER = '{"type":"commonjs"}\n';
// Runtimes that stage `.js` hooks via the dedicated cursor/codex paths
// (skipSharedHooksInstall or the !isCodex gate) — the two #2717 covers.
const AFFECTED_RUNTIMES = [
{ runtime: 'cursor', sampleHook: 'msd-cursor-session-start.js' },
{ runtime: 'codex', sampleHook: 'msd-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 MSD'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 MSD-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: STAGED_HOOK_SCRIPT_TIMEOUT_MS,
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', 'msd-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). msd-check-update.js uses
// require() at module load, so it surfaces the ESM failure immediately.
assertHookLoadsUnderEsmRoot(t, 'codex', 'msd-check-update.js', '');
});
test('uninstall path: removeCommonJsMarkerIfMsdOwned removes only MSD-owned markers', (t) => {
// The uninstall cleanup uses removeCommonJsMarkerIfMsdOwned (exported from
// the runtime-hooks-surface). Assert its contract directly: it deletes a
// MSD-written marker but never a user-authored package.json.
const {
removeCommonJsMarkerIfMsdOwned,
ensureCommonJsMarker,
} = require('../msd-core/bin/lib/runtime-hooks-surface.cjs');
// Case 1: MSD-owned marker is removed.
const dirA = createTempDir('msd-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(removeCommonJsMarkerIfMsdOwned(dirA), 'removes a MSD-owned marker');
assert.ok(!fs.existsSync(markerA), 'MSD-owned marker is gone');
// Case 2: user-authored package.json is preserved.
const dirB = createTempDir('msd-2717-keepB-');
t.after(() => cleanup(dirB));
const userContent = '{"name":"user-owned","type":"module"}\n';
fs.writeFileSync(path.join(dirB, 'package.json'), userContent);
assert.ok(!removeCommonJsMarkerIfMsdOwned(dirB), 'does not remove a non-MSD package.json');
assert.strictEqual(fs.readFileSync(path.join(dirB, 'package.json'), 'utf8'), userContent);
// Case 3: no marker → no-op, no throw.
const dirC = createTempDir('msd-2717-noopC-');
t.after(() => cleanup(dirC));
assert.ok(!removeCommonJsMarkerIfMsdOwned(dirC), 'no-op when no marker exists');
// Case 4: ensureCommonJsMarker is idempotent and does not clobber a user file.
const dirD = createTempDir('msd-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);
});
// #4759: the shared hooks path's `preserved-foreign` report used to make an
// unconditional module-resolution claim ("MSD hooks may not resolve as
// CommonJS") that is FALSE for the ordinary case — any foreign file that does
// not declare {"type":"module"} leaves the staged .js hooks loading as
// CommonJS (Node's default). The sibling plugin path
// (src/install-engine.cts) words the same outcome conditionally; these tests
// pin the hooks path to the same shape: name the ownership fact, and make
// will-not-load conditional on "type": "module" — the only case where it is
// true.
test('#4759: preserved-foreign hooks marker warning must not claim the hooks may not load when the file declares commonjs', (t) => {
const foreignCommonJs = '{\n "type": "commonjs"\n}\n';
const root = createTempDir('msd-4759-commonjs-');
// Register cleanup BEFORE the spawn: runMinimalInstall deliberately never
// removes a caller-provided root, on the failure path either, so a spawn
// failure must not leak the temp tree.
t.after(() => cleanup(root));
// Plant the foreign file BEFORE the install, at the config dir the global
// install will resolve (RUNTIME_META.claude.globalSuffix = '.claude').
const hooksDir = path.join(root, '.claude', 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });
fs.writeFileSync(path.join(hooksDir, 'package.json'), foreignCommonJs);
// The marker notice is a console.warn — stderr. Assert on the combined
// output so a future stream change cannot silently vacate the guards
// (precedent: tests/install.test.cjs merges stdout+stderr for installer
// output assertions).
const { configDir, stdout, stderr } = runMinimalInstall({ runtime: 'claude', scope: 'global', root });
const output = stdout + stderr;
assert.strictEqual(configDir, path.join(root, '.claude'));
// Ownership behavior is NOT the bug — the foreign file stays byte-identical.
assert.strictEqual(
fs.readFileSync(path.join(configDir, 'hooks', 'package.json'), 'utf8'),
foreignCommonJs,
'the foreign package.json must be left untouched',
);
// The false claim must be gone.
assert.ok(
!output.includes('may not resolve as CommonJS'),
`a foreign package.json declaring "type": "commonjs" must not be told the hooks may not load; output:\n${output}`,
);
// The preserved-foreign notice stays, now with the conditional wording.
assert.ok(
/Left existing hooks\/package\.json untouched \(not MSD's marker\)\. If it declares "type": "module", the staged hooks will not load\./.test(output),
`expected the conditional preserved-foreign wording; output:\n${output}`,
);
});
test('#4759: a module-typed foreign hooks package.json gets the conditional will-not-load warning', (t) => {
const foreignEsm = '{\n "type": "module"\n}\n';
const root = createTempDir('msd-4759-esm-');
t.after(() => cleanup(root));
const hooksDir = path.join(root, '.claude', 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });
fs.writeFileSync(path.join(hooksDir, 'package.json'), foreignEsm);
const { configDir, stdout, stderr } = runMinimalInstall({ runtime: 'claude', scope: 'global', root });
const output = stdout + stderr;
assert.strictEqual(
fs.readFileSync(path.join(configDir, 'hooks', 'package.json'), 'utf8'),
foreignEsm,
'the foreign package.json must be left untouched',
);
// Still no UNCONDITIONAL may-claim — the will-not-load statement is carried
// by the conditional sentence, which is true here and only here.
assert.ok(
!output.includes('may not resolve as CommonJS'),
`the unconditional may-not-load claim must not come back; output:\n${output}`,
);
assert.ok(
/If it declares "type": "module", the staged hooks will not load\./.test(output),
`a "type": "module" foreign file must still get the conditional will-not-load warning; output:\n${output}`,
);
});
test('#4759: a foreign hooks package.json with no type field gets the conditional wording, not the may-claim', (t) => {
// No "type" field: Node defaults .js to CommonJS, so the hooks load — the
// old unconditional claim was false here too, and this is arguably the most
// common real file (any hand-authored package.json).
const foreignNoType = '{\n "name": "user-hooks"\n}\n';
const root = createTempDir('msd-4759-notype-');
t.after(() => cleanup(root));
const hooksDir = path.join(root, '.claude', 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });
fs.writeFileSync(path.join(hooksDir, 'package.json'), foreignNoType);
const { configDir, stdout, stderr } = runMinimalInstall({ runtime: 'claude', scope: 'global', root });
const output = stdout + stderr;
assert.strictEqual(
fs.readFileSync(path.join(configDir, 'hooks', 'package.json'), 'utf8'),
foreignNoType,
'the foreign package.json must be left untouched',
);
assert.ok(
!output.includes('may not resolve as CommonJS'),
`a type-less foreign package.json must not be told the hooks may not load; output:\n${output}`,
);
assert.ok(
/If it declares "type": "module", the staged hooks will not load\./.test(output),
`expected the conditional preserved-foreign wording; output:\n${output}`,
);
});
});