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.
347 lines
14 KiB
JavaScript
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}`,
|
|
);
|
|
});
|
|
});
|