Files
msd-core/tests/check-update-config-dir.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

347 lines
14 KiB
JavaScript

/**
* Regression test for #1860: detectConfigDir in msd-check-update.js should
* prioritize .claude over .config/opencode so that Claude Code sessions
* don't report false "update available" warnings when an older OpenCode
* install exists alongside a newer Claude Code install.
*
* All coverage here is BEHAVIORAL: it spawns the real hook (as a `node -e`
* child, with `child_process.spawn` stubbed) and observes the resolved
* config-dir paths it hands to its background worker via env vars. Nothing
* in this file reads hooks/msd-check-update.js source — the hook has no
* exports (it runs entirely on require), so its only outward, in-process
* observable effect is the one spawn() call it makes to launch its worker.
* That spawn's env carries MSD_GLOBAL_VERSION_FILE / MSD_PROJECT_VERSION_FILE,
* which is deliberately borrowed as the observation seam here (Hyrum's Law:
* this is an implementation detail, not a contract) rather than a real
* subprocess launch, since the actual worker touches the network.
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { cleanup } = require('./helpers.cjs');
const { runNode, OUTCOME } = require('./helpers/process-seam.cjs');
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const CHECK_UPDATE_PATH = path.join(__dirname, '..', 'hooks', 'msd-check-update.js');
// ─── Probe harness ──────────────────────────────────────────────────────────
//
// Builds a `node -e` wrapper (assembled via array .join('\n'), never a
// multi-line template literal — CONTRIBUTING.md's fixture-string convention)
// that stubs child_process.spawn BEFORE requiring the real hook, so the
// hook's actual detectConfigDir() logic runs untouched while the worker
// launch itself is captured instead of executed. Emits exactly one JSON line
// so the test parses structured data, never regex/substring-matches stdout.
function buildProbeSource(hookPath) {
return [
"'use strict';",
'let spawned = false;',
'let capturedEnv = null;',
"const cp = require('child_process');",
'cp.spawn = function stubSpawn(command, args, opts) {',
' spawned = true;',
' capturedEnv = (opts && opts.env) || null;',
' return { unref: function () {} };',
'};',
`require(${JSON.stringify(hookPath)});`,
'const result = {',
' spawned: spawned,',
' global: capturedEnv ? capturedEnv.MSD_GLOBAL_VERSION_FILE : null,',
' project: capturedEnv ? capturedEnv.MSD_PROJECT_VERSION_FILE : null,',
' cache: capturedEnv ? capturedEnv.MSD_CACHE_FILE : null,',
'};',
'process.stdout.write(JSON.stringify(result) + "\\n");',
].join('\n');
}
/**
* Run the probe against a fake HOME/cwd and return the parsed
* { spawned, global, project, cache } envelope.
*
* @param {object} opts
* @param {string} opts.homeDir - fake HOME/USERPROFILE for this run.
* @param {string} opts.cwd - fake cwd (project base) for this run.
* @param {object} [opts.envOverrides] - applied after HOME/USERPROFILE and
* after CLAUDE_CONFIG_DIR is deleted, so a row can reintroduce it.
* @param {string} [opts.hookPath] - override the hook under test (defaults to
* the real hooks/msd-check-update.js). Used by the #3582 cold-tree suite
* below to point at a fixture copy instead.
*/
function probe({ homeDir, cwd, envOverrides = {}, hookPath = CHECK_UPDATE_PATH }) {
const childEnv = { ...process.env, HOME: homeDir, USERPROFILE: homeDir };
delete childEnv.CLAUDE_CONFIG_DIR;
Object.assign(childEnv, envOverrides);
const result = runNode(['-e', buildProbeSource(hookPath)], {
cwd,
env: childEnv,
timeoutMs: PROBE_TIMEOUT_MS,
});
assert.equal(
result.outcome,
OUTCOME.EXITED,
`probe process did not exit cleanly (outcome=${result.outcome}); stderr:\n${result.stderr}`
);
assert.equal(
result.exitCode,
0,
`probe process exited non-zero; stderr:\n${result.stderr}`
);
const lastLine = result.stdout.trim().split('\n').filter(Boolean).pop();
let parsed;
try {
parsed = JSON.parse(lastLine);
} catch (cause) {
throw new Error(
`probe: could not parse probe stdout as JSON.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
{ cause }
);
}
if (parsed.spawned !== true) {
throw new Error(
"probe: hooks/msd-check-update.js no longer calls child_process.spawn() to launch " +
"its background worker. This harness's OBSERVATION POINT (reading detectConfigDir's " +
"resolved paths off the spawn() env) has moved and needs to be re-anchored on " +
'whatever now carries the resolved config-dir paths — this is NOT evidence that ' +
"detectConfigDir's precedence/search-order logic regressed."
);
}
return parsed;
}
function configDirOf(versionFile) {
assert.ok(
typeof versionFile === 'string' && versionFile.length > 0,
'expected the probe to report a version-file path'
);
return path.dirname(path.dirname(versionFile));
}
function assertConfigDir(actualVersionFile, expectedDir, message) {
const actual = configDirOf(actualVersionFile).replace(/\\/g, '/');
const expected = expectedDir.replace(/\\/g, '/');
assert.equal(actual, expected, message);
}
function writeVersionFile(configDir) {
const versionDir = path.join(configDir, 'msd-core');
fs.mkdirSync(versionDir, { recursive: true });
fs.writeFileSync(path.join(versionDir, 'VERSION'), '1.0.0\n');
}
// ─── Fixtures ───────────────────────────────────────────────────────────────
describe('detectConfigDir runtime behavior (#1860)', () => {
let tmpHome;
let tmpProject;
beforeEach(() => {
// realpathSync'd immediately: process.cwd() inside the spawned child
// resolves symlinks (macOS resolves a temp dir through /private), while
// os.homedir()'s env-var passthrough does not. Resolving both bases once,
// up front, and using ONLY the resolved string everywhere downstream
// (as HOME/cwd for the spawn AND to build every expected path) makes
// resolving an already-resolved path a no-op on both sides, so the two
// mechanisms can never disagree — instead of patching the divergence
// back together at each assertion.
tmpHome = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'msd-test-home-')));
tmpProject = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'msd-test-project-')));
});
afterEach(() => {
cleanup(tmpHome);
cleanup(tmpProject);
});
test('#1860: returns .claude when both .claude and .config/opencode hold VERSION', () => {
writeVersionFile(path.join(tmpHome, '.config', 'opencode'));
writeVersionFile(path.join(tmpHome, '.claude'));
const result = probe({ homeDir: tmpHome, cwd: tmpProject });
assertConfigDir(
result.global,
path.join(tmpHome, '.claude'),
'.claude must win over .config/opencode when both hold VERSION (#1860)'
);
});
test('falls back to .config/opencode when only it holds VERSION', () => {
writeVersionFile(path.join(tmpHome, '.config', 'opencode'));
const result = probe({ homeDir: tmpHome, cwd: tmpProject });
assertConfigDir(
result.global,
path.join(tmpHome, '.config', 'opencode'),
'expected .config/opencode when it is the only dir with a VERSION file'
);
});
test('falls back to <home>/.claude when nothing holds VERSION and no env override', () => {
const result = probe({ homeDir: tmpHome, cwd: tmpProject });
assertConfigDir(
result.global,
path.join(tmpHome, '.claude'),
'expected the bare .claude fallback tail when no candidate dir has a VERSION file'
);
});
test('CLAUDE_CONFIG_DIR with a valid VERSION short-circuits the search order', (t) => {
const envDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'msd-test-envdir-')));
t.after(() => cleanup(envDir));
writeVersionFile(envDir);
writeVersionFile(path.join(tmpHome, '.claude'));
const result = probe({ homeDir: tmpHome, cwd: tmpProject, envOverrides: { CLAUDE_CONFIG_DIR: envDir } });
assertConfigDir(
result.global,
envDir,
'CLAUDE_CONFIG_DIR must win outright when its own VERSION file exists'
);
});
test('CLAUDE_CONFIG_DIR without a VERSION file does not short-circuit the search', (t) => {
const envDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'msd-test-envdir-')));
t.after(() => cleanup(envDir));
writeVersionFile(path.join(tmpHome, '.claude'));
const result = probe({ homeDir: tmpHome, cwd: tmpProject, envOverrides: { CLAUDE_CONFIG_DIR: envDir } });
assertConfigDir(
result.global,
path.join(tmpHome, '.claude'),
'CLAUDE_CONFIG_DIR must be ignored (falling through to the search array) when it has no VERSION file'
);
});
test('CLAUDE_CONFIG_DIR without a VERSION file anywhere falls back to the env dir itself', (t) => {
const envDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'msd-test-envdir-')));
t.after(() => cleanup(envDir));
const result = probe({ homeDir: tmpHome, cwd: tmpProject, envOverrides: { CLAUDE_CONFIG_DIR: envDir } });
assertConfigDir(
result.global,
envDir,
'the `return envDir || path.join(baseDir, ".claude")` tail must return the env dir, ' +
'not the bare .claude fallback, when CLAUDE_CONFIG_DIR is set but nothing has a VERSION file'
);
});
test('CLAUDE_CONFIG_DIR set to an empty string is treated as unset', () => {
writeVersionFile(path.join(tmpHome, '.claude'));
const result = probe({ homeDir: tmpHome, cwd: tmpProject, envOverrides: { CLAUDE_CONFIG_DIR: '' } });
assertConfigDir(
result.global,
path.join(tmpHome, '.claude'),
'an empty-string CLAUDE_CONFIG_DIR is falsy and must not be treated as a real override'
);
});
// ─── Adjacent-pair ordering (behavioral replacement for the deleted static
// array-order grep) ─────────────────────────────────────────────────
const ADJACENT_PAIRS = [
['.claude', '.gemini'],
['.gemini', '.config/opencode'],
['.config/opencode', '.opencode'],
];
for (const [winner, loser] of ADJACENT_PAIRS) {
test(`search order: ${winner} wins over ${loser} (#1860 ordering)`, () => {
writeVersionFile(path.join(tmpHome, winner));
writeVersionFile(path.join(tmpHome, loser));
const result = probe({ homeDir: tmpHome, cwd: tmpProject });
assertConfigDir(
result.global,
path.join(tmpHome, winner),
`${winner} must be searched before ${loser}`
);
});
}
test('an empty .claude directory (no VERSION file) is not a match — the file is the predicate', () => {
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
writeVersionFile(path.join(tmpHome, '.config', 'opencode'));
const result = probe({ homeDir: tmpHome, cwd: tmpProject });
assertConfigDir(
result.global,
path.join(tmpHome, '.config', 'opencode'),
'an existing .claude dir with no msd-core/VERSION file must not satisfy the search — ' +
'fs.existsSync(VERSION) is the predicate, not directory existence'
);
});
test('global (home) and project (cwd) resolve independently, each against its own base', () => {
writeVersionFile(path.join(tmpHome, '.claude'));
writeVersionFile(path.join(tmpProject, '.config', 'opencode'));
const result = probe({ homeDir: tmpHome, cwd: tmpProject });
assertConfigDir(
result.global,
path.join(tmpHome, '.claude'),
'global resolution must be independent of the project (cwd) fixture state'
);
assertConfigDir(
result.project,
path.join(tmpProject, '.config', 'opencode'),
'project resolution must be independent of the home (global) fixture state'
);
});
});
// ─── #3582: cold tree (no msd-core/bin/lib/*.cjs) — degraded cache filename ─
//
// msd-core/bin/lib/package-identity.cjs is a tsc build artifact (ADR-457),
// gitignored and absent on a raw plugin-marketplace / git-clone install that
// never ran `npm run build:lib`. This SessionStart hook degrades to the
// hardcoded fallback cache filename ('msd-update-check.json') rather than
// crash session start (see the hook's own #3582 comment). The DEGRADED
// VERDICT this test locks is observable via the SAME spawn-env probe seam
// used above: the MSD_CACHE_FILE env var the hook hands to its worker must
// end with the fallback literal, not throw and not silently vanish.
// Simulated hermetically via tests/helpers/cold-runtime-lib-fixture.cjs — the
// REAL msd-core/bin/lib/ is never touched.
describe('msd-check-update.js: #3582 cold tree — degrades to the fallback cache filename', () => {
const { buildColdInstallTree } = require('./helpers/cold-runtime-lib-fixture.cjs');
test('missing compiled runtime library -> worker still launched, with the hardcoded fallback cache filename', (t) => {
const cold = buildColdInstallTree();
t.after(cold.cleanup);
const home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-cold-home-')));
const project = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'msd-cu-cold-project-')));
t.after(() => { cleanup(home); cleanup(project); });
const result = probe({
homeDir: home,
cwd: project,
hookPath: path.join(cold.hooksDir, 'msd-check-update.js'),
});
assert.equal(
path.basename(result.cache),
'msd-update-check.json',
`expected the hardcoded degrade fallback cache filename when package-identity.cjs cannot be built; got: ${result.cache}`,
);
});
});