Files
msd-core/tests/install-scope.test.cjs
Jakub Zych fe3ed06691
Some checks failed
Tests / PR mergeability (push) Successful in 18s
Tests / Base branch health (push) Successful in 9s
Tests / Detect test scope (push) Successful in 16s
Tests / lint-tests (push) Failing after 1m43s
Tests / plugin-validate (push) Successful in 58s
Tests / test (ubuntu-latest, 24, shard 1/3) (push) Failing after 19s
Tests / test (ubuntu-latest, 24, shard 2/3) (push) Failing after 20s
Tests / test (ubuntu-latest, 24, shard 3/3) (push) Failing after 20s
Tests / test (ubuntu-latest, 24) (push) Failing after 18s
Tests / test (inert CI) (push) Has been skipped
Tests / QA loop walk (smell ratchet) (push) Failing after 19s
Tests / Coverage gate (merged shards) (push) Has been skipped
Tests / Publish emitted-baseline artifact (push) Has been skipped
Duplicate auto-close sweep / sweep (push) Successful in 19s
CI timeout budget report / report (push) Failing after 14s
Close Draft PRs (sweep) / Sweep open draft PRs (push) Successful in 9s
Dismiss Unauthorized PR Approvals / dismiss-unauthorized-approval (push) Successful in 9s
Tests / conformance test (macos-latest, 24) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 1/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 2/3) (push) Has been cancelled
Tests / conformance test (windows-latest, 24, shard 3/3) (push) Has been cancelled
Tests / Required tests (push) Has been cancelled
chore: clear dead test and allowlist leftovers of dropped runtimes
2026-10-06 20:35:12 +02:00

307 lines
13 KiB
JavaScript

'use strict';
/**
* Failing-first suite for the Install Scope Module (#2870, ADR-2866).
*
* Asserts `resolveScope`'s contract from
* .msd/phase/feat-2870-install-scope-module/40-design.md against the test
* matrix at .msd/phase/feat-2870-install-scope-module/50-test-matrix.md
* (rows 1-19; row 20 lands with the bin/install.js call-site migration and
* is deliberately out of scope here).
*
* Every case injects `env` / `home` / `existsSync` — mirroring
* `runtime-homes.cts`'s `ResolveConfigHomeOpts` shape — instead of touching
* the real filesystem or the real home directory: no `mkdtempSync`, no
* `os.homedir()`. The module under test does not exist yet, so requiring it
* below throws `MODULE_NOT_FOUND`. That is the point: this suite is RED
* until src/install-scope.cts lands and builds to
* msd-core/bin/lib/install-scope.cjs.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { toPosixPath } = require('./helpers.cjs');
const { resolveScope, isGlobalScope, SCOPE_ORDER, scopeRank } = require('../msd-core/bin/lib/install-scope.cjs');
const registry = require('../msd-core/bin/lib/capability-registry.cjs');
const { createRuntimeArtifactInstallPlan } = require('../msd-core/bin/lib/runtime-artifact-install-plan.cjs');
const FAKE_HOME = '/fake/home';
const NO_EXISTS = () => false;
function fixture(overrides) {
return { env: {}, home: FAKE_HOME, existsSync: NO_EXISTS, ...overrides };
}
// #2103: `vscode` enters `registry.runtimes` (role:runtime, kept for
// validator / host-integration coverage) but declares
// `configHome.kind === 'none'` — no file-projected config directory exists
// to resolve at all — and it is never CLI-installed (no --vscode flag,
// absent from bin/install.js's `allRuntimes`). This is the same carve-out
// tests/runtime-flags.test.cjs's `NON_INSTALLABLE_RUNTIMES` documents:
// install-scope's global-configHome resolution has nothing to resolve for a
// runtime with no config directory, so it is excluded from the "every
// runtime" sweep below rather than assumed to resolve like every other
// registered runtime. The excluded case itself is asserted directly by
// 'rejects a runtime with no config home' below (design row 13) — it is
// covered, not dropped.
const NON_INSTALLABLE_RUNTIMES = new Set(['vscode']);
const INSTALL_SCOPE_RUNTIME_IDS = Object.keys(registry.runtimes)
.filter((id) => !NON_INSTALLABLE_RUNTIMES.has(id));
describe('resolveScope', () => {
// Row 1
test('resolves claude global settings file', () => {
const result = resolveScope(fixture({ id: 'global', runtime: 'claude' }));
assert.strictEqual(result.settingsFile, 'settings.json');
});
// Row 2
test('resolves claude local settings file', () => {
const result = resolveScope(fixture({ id: 'local', runtime: 'claude', cwd: '/fake/project' }));
assert.strictEqual(result.settingsFile, 'settings.local.json');
});
// Row 3
test('returns null settingsFile for runtimes that declare none', () => {
const result = resolveScope(fixture({ id: 'global', runtime: 'codex' }));
assert.strictEqual(result.settingsFile, null);
});
// Row 4
test('resolves for every registered runtime at both scopes', () => {
assert.ok(INSTALL_SCOPE_RUNTIME_IDS.length > 0, 'registry must contain at least one installable runtime');
for (const runtime of INSTALL_SCOPE_RUNTIME_IDS) {
for (const id of ['global', 'local']) {
const result = resolveScope(fixture({ id, runtime, cwd: '/fake/project' }));
assert.strictEqual(typeof result.configHome, 'string', `${runtime}/${id}: configHome must be a string`);
assert.ok(result.configHome.length > 0, `${runtime}/${id}: configHome must be non-empty`);
assert.ok(
result.settingsFile === null || typeof result.settingsFile === 'string',
`${runtime}/${id}: settingsFile must be string or null`,
);
}
}
});
// Row 5
test('global scope requires no consent record', () => {
const result = resolveScope(fixture({ id: 'global', runtime: 'claude' }));
assert.strictEqual(result.consentRequired, false);
});
// Row 6
test('local scope requires a consent record', () => {
const result = resolveScope(fixture({ id: 'local', runtime: 'claude', cwd: '/fake/project' }));
assert.strictEqual(result.consentRequired, true);
});
// Row 7 — assert the RELATION, never a literal rank number (unread this
// phase; Phase 2 may re-base the literal values).
test('global outranks local in hostPrecedenceRank', () => {
const globalScope = resolveScope(fixture({ id: 'global', runtime: 'claude' }));
const localScope = resolveScope(fixture({ id: 'local', runtime: 'claude', cwd: '/fake/project' }));
assert.ok(
globalScope.hostPrecedenceRank > localScope.hostPrecedenceRank,
`expected global rank (${globalScope.hostPrecedenceRank}) > local rank (${localScope.hostPrecedenceRank})`,
);
});
// Row 8
test('explicit config dir overrides descriptor resolution', () => {
const result = resolveScope(fixture({ id: 'global', runtime: 'claude', explicitDir: '/custom/config/dir' }));
assert.strictEqual(result.configHome, '/custom/config/dir');
});
// Row 9
test('rejects the consent-vocabulary spelling', () => {
assert.throws(
() => resolveScope(fixture({ id: 'project', runtime: 'claude' })),
(err) => err instanceof TypeError && /global/i.test(err.message) && /local/i.test(err.message),
);
});
// Row 10
test('rejects case variants', () => {
assert.throws(
() => resolveScope(fixture({ id: 'Global', runtime: 'claude' })),
TypeError,
);
});
// Row 11
test('rejects empty and missing scope id', () => {
assert.throws(() => resolveScope(fixture({ id: '', runtime: 'claude' })), TypeError, 'id: empty string');
assert.throws(() => resolveScope(fixture({ id: undefined, runtime: 'claude' })), TypeError, 'id: undefined');
const { home, env, existsSync } = fixture({});
assert.throws(() => resolveScope({ runtime: 'claude', home, env, existsSync }), TypeError, 'id: absent key');
});
// Row 12
test('rejects unknown runtime with the established error contract', () => {
assert.throws(
() => resolveScope(fixture({ id: 'global', runtime: 'no-such-runtime' })),
(err) => err instanceof TypeError && err.message.includes('no-such-runtime'),
);
});
// Design row 13: a runtime whose descriptor has `configHome.kind ===
// 'none'` — vscode is the only one — has no installable config directory
// to resolve at all. Same contract as row 9 (unknown runtime): TypeError
// naming the runtime, one catch shape for callers.
test('rejects a runtime with no config home', () => {
assert.throws(
() => resolveScope(fixture({ id: 'global', runtime: 'vscode' })),
(err) => err instanceof TypeError
&& err.message === "resolveScope: runtime 'vscode' has no installable config directory (configHome.kind === 'none')",
);
assert.throws(
() => resolveScope(fixture({ id: 'local', runtime: 'vscode' })),
(err) => err instanceof TypeError
&& err.message === "resolveScope: runtime 'vscode' has no installable config directory (configHome.kind === 'none')",
);
});
// Row 13
test('rejects non-string scope ids without coercion', () => {
for (const id of [0, null, {}, ['global']]) {
assert.throws(
() => resolveScope(fixture({ id, runtime: 'claude' })),
TypeError,
`id=${JSON.stringify(id)} must throw TypeError, not coerce`,
);
}
});
// Row 14
test('preserves opencode config-file precedence', () => {
const filePath = '/home/x/custom/opencode-config.json';
const result = resolveScope(fixture({
id: 'global',
runtime: 'opencode',
env: { OPENCODE_CONFIG: filePath },
}));
assert.strictEqual(result.configHome, path.dirname(filePath));
});
// Row 15
test('blank env override does not win', () => {
const expected = toPosixPath(path.join(FAKE_HOME, '.claude'));
const empty = resolveScope(fixture({ id: 'global', runtime: 'claude', env: { CLAUDE_CONFIG_DIR: '' } }));
const whitespace = resolveScope(fixture({ id: 'global', runtime: 'claude', env: { CLAUDE_CONFIG_DIR: ' ' } }));
assert.strictEqual(empty.configHome, expected);
assert.strictEqual(whitespace.configHome, expected);
});
// Row 16
test('is pure and does not mutate its input', () => {
const input = Object.freeze(fixture({ id: 'global', runtime: 'claude' }));
const first = resolveScope(input);
const second = resolveScope(input);
assert.deepStrictEqual(first, second);
});
// Row 17
test('normalizes backslash paths on every platform', () => {
const result = resolveScope(fixture({ id: 'global', runtime: 'claude', home: 'C:\\Users\\x' }));
assert.ok(!result.configHome.includes('\\'), `configHome must not contain backslashes: ${result.configHome}`);
assert.strictEqual(result.configHome, 'C:/Users/x/.claude');
});
// Row 18
test('returned value cannot be corrupted by a caller', () => {
const input = fixture({ id: 'global', runtime: 'claude' });
const first = resolveScope(input);
const originalConfigHome = first.configHome;
try {
first.configHome = 'HACKED';
} catch {
// A frozen result rejecting the mutation outright is an acceptable
// defense too — either way, a second resolution must be unaffected.
}
const second = resolveScope(input);
assert.strictEqual(second.configHome, originalConfigHome);
});
// Row 19
test('install-plan imports the shared InstallScope type', () => {
const globalScope = resolveScope(fixture({ id: 'global', runtime: 'claude' }));
const localScope = resolveScope(fixture({ id: 'local', runtime: 'claude', cwd: '/fake/project' }));
for (const scope of [globalScope, localScope]) {
const result = createRuntimeArtifactInstallPlan({
layout: {
runtime: 'claude',
configDir: scope.configHome,
scope: scope.id,
kinds: [],
},
resolvedProfile: { name: 'core' },
});
assert.strictEqual(
result.ok,
true,
`runtime-artifact-install-plan must accept install-scope's '${scope.id}' spelling directly`,
);
}
});
// Row 16 follow-up: local scope's configHome must be assertable via an
// injected cwd, never the real process.cwd().
test('local scope resolves configHome against an injected cwd', () => {
const first = resolveScope(fixture({ id: 'local', runtime: 'claude', cwd: '/fake/project-a' }));
const second = resolveScope(fixture({ id: 'local', runtime: 'claude', cwd: '/fake/project-b' }));
assert.notStrictEqual(first.configHome, second.configHome);
assert.strictEqual(first.configHome, toPosixPath(path.join('/fake/project-a', '.claude')));
assert.strictEqual(second.configHome, toPosixPath(path.join('/fake/project-b', '.claude')));
});
});
describe('isGlobalScope', () => {
// #2870: the shared boolean projection that replaced four independent
// inline `scope === 'global'` re-derivations.
test('returns true only for global', () => {
assert.strictEqual(isGlobalScope('global'), true);
});
test('returns false for local', () => {
assert.strictEqual(isGlobalScope('local'), false);
});
// Parity assertion: isGlobalScope must throw the SAME TypeError contract
// resolveScope's invalid-id case (Row 9) throws, since both share
// install-scope.cts's one validator — never a second, divergent one.
test('throws TypeError for an invalid id, matching resolveScope\'s contract', () => {
assert.throws(
() => isGlobalScope('project'),
(err) => err instanceof TypeError && /global/i.test(err.message) && /local/i.test(err.message),
);
});
});
describe('SCOPE_ORDER (#2872 review finding — single source of the scope axis ordering)', () => {
// `runtime-artifact-layout.cts` and `installed-surface-resolver.cts` both
// consume this constant instead of each re-declaring their own
// `['global', 'local']` literal (the "generative fix divergence" class
// recorded in this repo's CLAUDE.md). Locking the exact value here is what
// makes a future re-declaration in either consumer fail somewhere, rather
// than silently drifting.
test('is exactly [\'global\', \'local\']', () => {
assert.deepStrictEqual(SCOPE_ORDER, ['global', 'local']);
});
test('is frozen', () => {
assert.strictEqual(Object.isFrozen(SCOPE_ORDER), true);
});
// Derives the expected order from scopeRank rather than hardcoding
// ['global', 'local'] a second time: this fails if someone reorders
// SCOPE_ORDER without changing the ranks, and equally fails if someone
// changes the ranks without reordering SCOPE_ORDER — the two can never
// silently drift apart.
test('is sorted by scopeRank descending', () => {
const expected = [...SCOPE_ORDER].sort((a, b) => scopeRank(b) - scopeRank(a));
assert.deepStrictEqual(SCOPE_ORDER, expected);
});
});