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
1108 lines
46 KiB
JavaScript
1108 lines
46 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* Equivalence proof for ADR-857 phase 5b: descriptor-driven getGlobalConfigDir.
|
||
*
|
||
* For every runtime in the 15-entry capability registry, plus grok and unknown
|
||
* runtime, this test asserts that getGlobalConfigDir() produces exactly the
|
||
* same path that the old hardcoded switch produced (golden expected values
|
||
* captured from the switch BEFORE any edits). All assertions are byte-identical.
|
||
*
|
||
* The injected opts seam on resolveConfigHomeFromDescriptor is used to control:
|
||
* - the env record (avoid ambient env var pollution)
|
||
* - the home directory (make tests hermetic)
|
||
* - existsSync (control probe-hit / probe-miss scenarios)
|
||
*/
|
||
|
||
const { describe, test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const path = require('node:path');
|
||
const os = require('node:os');
|
||
const fs = require('node:fs');
|
||
const { cleanup } = require('./helpers.cjs');
|
||
|
||
const ROOT = path.join(__dirname, '..');
|
||
const {
|
||
getGlobalConfigDir,
|
||
resolveAntigravityGlobalDir,
|
||
resolveConfigHomeFromDescriptor,
|
||
resolveSkillsBaseFromDescriptor,
|
||
detectAntigravityDirAmbiguity,
|
||
} = require(path.join(ROOT, 'msd-core', 'bin', 'lib', 'runtime-homes.cjs'));
|
||
|
||
const HOME = os.homedir();
|
||
|
||
// ── Helper: run fn with process.env temporarily mutated ──────────────────────
|
||
|
||
function withEnv(overrides, fn) {
|
||
const saved = {};
|
||
for (const [k, v] of Object.entries(overrides)) {
|
||
saved[k] = process.env[k];
|
||
if (v === undefined) delete process.env[k];
|
||
else process.env[k] = v;
|
||
}
|
||
try {
|
||
return fn();
|
||
} finally {
|
||
for (const [k] of Object.entries(overrides)) {
|
||
if (saved[k] === undefined) delete process.env[k];
|
||
else process.env[k] = saved[k];
|
||
}
|
||
}
|
||
}
|
||
|
||
// All env vars for all runtimes — cleared in each test that calls getGlobalConfigDir directly
|
||
const ALL_ENV_KEYS = [
|
||
'CLAUDE_CONFIG_DIR', 'CURSOR_CONFIG_DIR', 'CODEX_HOME',
|
||
'GROK_AGENTS_HOME', 'ANTIGRAVITY_CONFIG_DIR',
|
||
'OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG',
|
||
'XDG_CONFIG_HOME',
|
||
];
|
||
|
||
function clearAllEnvKeys() {
|
||
const saved = {};
|
||
for (const k of ALL_ENV_KEYS) {
|
||
saved[k] = process.env[k];
|
||
delete process.env[k];
|
||
}
|
||
return saved;
|
||
}
|
||
|
||
function restoreEnvKeys(saved) {
|
||
for (const k of ALL_ENV_KEYS) {
|
||
if (saved[k] !== undefined) process.env[k] = saved[k];
|
||
else delete process.env[k];
|
||
}
|
||
}
|
||
|
||
// ── STEP 0: golden scenarios captured from old switch BEFORE edits ────────────
|
||
|
||
// GOLDEN DEFAULTS (no env vars set, no existsSync probe hits).
|
||
// antigravity default also depends on probing; the default assumes NO dirs exist.
|
||
const GOLDEN_DEFAULTS = {
|
||
claude: path.join(HOME, '.claude'),
|
||
cursor: path.join(HOME, '.cursor'),
|
||
codex: path.join(HOME, '.codex'),
|
||
grok: path.join(HOME, '.agents'),
|
||
antigravity: path.join(HOME, '.gemini', 'antigravity'), // probe-miss → first candidate
|
||
opencode: path.join(HOME, '.config', 'opencode'),
|
||
zcode: path.join(HOME, '.zcode'),
|
||
};
|
||
|
||
// ── GOLDEN DEFAULTS ────────────────────────────────────────────────────────────
|
||
|
||
describe('descriptor-driven equivalence: defaults (no env vars, no probe hits)', () => {
|
||
// antigravity is excluded: it also depends on real fs probing (probe candidates
|
||
// ~/.gemini/antigravity, ~/.gemini/antigravity-ide, ~/.gemini/antigravity-cli);
|
||
// a machine that has antigravity-ide or antigravity-cli but not antigravity gets a
|
||
// different result. antigravity probe scenarios are covered in the dot-home-nested
|
||
// suite with injected existsSync.
|
||
for (const [runtime, expected] of Object.entries(GOLDEN_DEFAULTS).filter(
|
||
([r]) => r !== 'antigravity',
|
||
)) {
|
||
test(`${runtime} default resolves to its golden config dir`, () => {
|
||
const saved = clearAllEnvKeys();
|
||
try {
|
||
assert.strictEqual(getGlobalConfigDir(runtime), expected, `${runtime} default → ${expected}`);
|
||
} finally {
|
||
restoreEnvKeys(saved);
|
||
}
|
||
});
|
||
}
|
||
|
||
test('unknown runtime falls back to ~/.claude (CLAUDE_CONFIG_DIR unset)', () => {
|
||
const saved = clearAllEnvKeys();
|
||
try {
|
||
assert.strictEqual(getGlobalConfigDir('totally-unknown-runtime-xyz'), path.join(HOME, '.claude'));
|
||
} finally {
|
||
restoreEnvKeys(saved);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ── GOLDEN ENV OVERRIDES ──────────────────────────────────────────────────────
|
||
|
||
describe('descriptor-driven equivalence: env-var overrides', () => {
|
||
const cases = [
|
||
{ runtime: 'claude', envKey: 'CLAUDE_CONFIG_DIR', value: '/custom/claude' },
|
||
{ runtime: 'cursor', envKey: 'CURSOR_CONFIG_DIR', value: '/custom/cursor' },
|
||
{ runtime: 'codex', envKey: 'CODEX_HOME', value: '/custom/codex' },
|
||
{ runtime: 'grok', envKey: 'GROK_AGENTS_HOME', value: '/custom/grok' },
|
||
{ runtime: 'antigravity', envKey: 'ANTIGRAVITY_CONFIG_DIR', value: '/custom/antigravity' },
|
||
{ runtime: 'opencode', envKey: 'OPENCODE_CONFIG_DIR', value: '/custom/opencode' },
|
||
];
|
||
|
||
for (const { runtime, envKey, value } of cases) {
|
||
test(`${runtime}: ${envKey} override → ${value}`, () => {
|
||
const saved = clearAllEnvKeys();
|
||
process.env[envKey] = value;
|
||
try {
|
||
assert.strictEqual(getGlobalConfigDir(runtime), value);
|
||
} finally {
|
||
restoreEnvKeys(saved);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// ── GOLDEN TILDE EXPANSION ─────────────────────────────────────────────────────
|
||
|
||
describe('descriptor-driven equivalence: tilde expansion in env overrides', () => {
|
||
test('claude: CLAUDE_CONFIG_DIR=~/foo expands to homedir/foo', () => {
|
||
withEnv({ CLAUDE_CONFIG_DIR: '~/foo' }, () => {
|
||
assert.strictEqual(getGlobalConfigDir('claude'), path.join(HOME, 'foo'));
|
||
});
|
||
});
|
||
});
|
||
|
||
// ── expandTilde honors an injected opts.home (not just the real os.homedir()) ─
|
||
//
|
||
// `expandTilde` used to hardcode `os.homedir()` and ignore the `home` that
|
||
// `resolveConfigHomeFromDescriptor` had already resolved from `opts.home`.
|
||
// Every configHome.env override (claude's CLAUDE_CONFIG_DIR,
|
||
// antigravity, ...) routes a tilde-prefixed
|
||
// value through this seam. A caller that injects a sandbox `home` — exactly
|
||
// what hermetic tests do to keep installs inside a temp dir — silently got
|
||
// the developer's REAL home directory back instead, both a correctness bug
|
||
// and a test-escape hazard.
|
||
describe('expandTilde honors an injected opts.home (regression)', () => {
|
||
const INJECTED_HOME = path.join(os.tmpdir(), 'msd-injected-home-fixture');
|
||
|
||
test('claude (dot-home): CLAUDE_CONFIG_DIR=~/custom + injected home → resolves under injected home, not os.homedir()', () => {
|
||
const result = resolveConfigHomeFromDescriptor(
|
||
{ kind: 'dot-home', name: '.claude', env: ['CLAUDE_CONFIG_DIR'] },
|
||
{ env: { CLAUDE_CONFIG_DIR: '~/custom' }, home: INJECTED_HOME },
|
||
);
|
||
assert.strictEqual(result, path.join(INJECTED_HOME, 'custom'));
|
||
assert.notStrictEqual(result, path.join(HOME, 'custom'));
|
||
});
|
||
|
||
test('claude: absolute env override + injected home → unchanged (tilde expansion not triggered)', () => {
|
||
const result = resolveConfigHomeFromDescriptor(
|
||
{ kind: 'dot-home', name: '.claude', env: ['CLAUDE_CONFIG_DIR'] },
|
||
{ env: { CLAUDE_CONFIG_DIR: '/absolute/custom' }, home: INJECTED_HOME },
|
||
);
|
||
assert.strictEqual(result, '/absolute/custom');
|
||
});
|
||
|
||
test('claude: no injected home → still resolves under the real os.homedir() (no behavior change for production callers)', () => {
|
||
assert.strictEqual(
|
||
resolveConfigHomeFromDescriptor(
|
||
{ kind: 'dot-home', name: '.claude', env: ['CLAUDE_CONFIG_DIR'] },
|
||
{ env: { CLAUDE_CONFIG_DIR: '~/custom' } },
|
||
),
|
||
path.join(HOME, 'custom'),
|
||
);
|
||
});
|
||
|
||
test('claude: no injected home, via getGlobalConfigDir (real end-to-end seam) → real os.homedir()', () => {
|
||
withEnv({ CLAUDE_CONFIG_DIR: '~/custom' }, () => {
|
||
assert.strictEqual(getGlobalConfigDir('claude'), path.join(HOME, 'custom'));
|
||
});
|
||
});
|
||
|
||
});
|
||
|
||
// ── #3023 review finding 1: whitespace-only env override must fall back ──────
|
||
//
|
||
// expandTilde's old call sites gated on a bare `if (val)`, which is falsy
|
||
// only for `''`. A whitespace-only value (e.g. `CLAUDE_CONFIG_DIR=' '`,
|
||
// which a broken shell template can produce when a substitution is blank but
|
||
// still quoted) passed the truthy check and resolved to the literal
|
||
// three-space string instead of falling back to the descriptor default. The
|
||
// fix gates every env-override consumption site in
|
||
// resolveConfigHomeFromDescriptor on `hasNonBlankOverride` (real string, at
|
||
// least one non-whitespace char) instead of bare truthiness — covering
|
||
// dot-home, dot-home-nested, all three xdg steps, and generic-agents-root
|
||
// alike (same class, same fix).
|
||
//
|
||
// Leading/trailing whitespace on an otherwise non-blank value is deliberately
|
||
// NOT trimmed (see hasNonBlankOverride's doc comment in runtime-homes.cts):
|
||
// this module never trims env-var path values elsewhere, so trimming here
|
||
// would make some non-whitespace values behave differently from before this
|
||
// fix, violating "default behavior for every non-whitespace value must stay
|
||
// byte-identical". Only entirely-blank values are rejected.
|
||
describe('#3023 review finding 1: whitespace-only env override falls back to default (regression)', () => {
|
||
const CLAUDE_DESCRIPTOR = { kind: 'dot-home', name: '.claude', env: ['CLAUDE_CONFIG_DIR'] };
|
||
|
||
describe('claude (dot-home)', () => {
|
||
test('whitespace-only env value falls back to the descriptor default, never the literal whitespace string', () => {
|
||
const result = resolveConfigHomeFromDescriptor(CLAUDE_DESCRIPTOR, {
|
||
env: { CLAUDE_CONFIG_DIR: ' ' },
|
||
home: '/home/u',
|
||
});
|
||
assert.strictEqual(result, path.join('/home/u', '.claude'));
|
||
assert.notStrictEqual(result, ' ');
|
||
});
|
||
|
||
test('empty-string env value falls back to the default (existing behavior preserved)', () => {
|
||
const result = resolveConfigHomeFromDescriptor(CLAUDE_DESCRIPTOR, {
|
||
env: { CLAUDE_CONFIG_DIR: '' },
|
||
home: '/home/u',
|
||
});
|
||
assert.strictEqual(result, path.join('/home/u', '.claude'));
|
||
});
|
||
|
||
test('unset env value falls back to the default', () => {
|
||
const result = resolveConfigHomeFromDescriptor(CLAUDE_DESCRIPTOR, {
|
||
env: {},
|
||
home: '/home/u',
|
||
});
|
||
assert.strictEqual(result, path.join('/home/u', '.claude'));
|
||
});
|
||
|
||
test('env value with interior spaces resolves under the injected home, spaces intact (guard is not over-broad)', () => {
|
||
const result = resolveConfigHomeFromDescriptor(CLAUDE_DESCRIPTOR, {
|
||
env: { CLAUDE_CONFIG_DIR: '~/My Agent Dir' },
|
||
home: '/home/u',
|
||
});
|
||
assert.strictEqual(result, path.join('/home/u', 'My Agent Dir'));
|
||
});
|
||
|
||
test('normal absolute path env value is unchanged', () => {
|
||
const result = resolveConfigHomeFromDescriptor(CLAUDE_DESCRIPTOR, {
|
||
env: { CLAUDE_CONFIG_DIR: '/custom/claude' },
|
||
home: '/home/u',
|
||
});
|
||
assert.strictEqual(result, '/custom/claude');
|
||
});
|
||
});
|
||
|
||
// Full branch coverage: the same whitespace-only guard applies to every
|
||
// env-override consumption site in resolveConfigHomeFromDescriptor, not
|
||
// just dot-home/dot-home-nested. Each of these fails before the fix and
|
||
// passes after.
|
||
describe('remaining branches (xdg all 3 steps, generic-agents-root)', () => {
|
||
test('xdg env[0] (direct override): whitespace-only falls back to default', () => {
|
||
const result = resolveConfigHomeFromDescriptor(
|
||
{ kind: 'xdg', name: 'opencode', env: ['OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG', 'XDG_CONFIG_HOME'] },
|
||
{ env: { OPENCODE_CONFIG_DIR: ' ' }, home: '/home/u' },
|
||
);
|
||
assert.strictEqual(result, path.join('/home/u', '.config', 'opencode'));
|
||
});
|
||
|
||
test('xdg env[1] (file-path override): whitespace-only falls through to default (not env[2])', () => {
|
||
const result = resolveConfigHomeFromDescriptor(
|
||
{ kind: 'xdg', name: 'opencode', env: ['OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG', 'XDG_CONFIG_HOME'] },
|
||
{ env: { OPENCODE_CONFIG: ' ' }, home: '/home/u' },
|
||
);
|
||
assert.strictEqual(result, path.join('/home/u', '.config', 'opencode'));
|
||
});
|
||
|
||
test('xdg env[2] (XDG_CONFIG_HOME): whitespace-only falls back to default', () => {
|
||
const result = resolveConfigHomeFromDescriptor(
|
||
{ kind: 'xdg', name: 'opencode', env: ['OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG', 'XDG_CONFIG_HOME'] },
|
||
{ env: { XDG_CONFIG_HOME: ' ' }, home: '/home/u' },
|
||
);
|
||
assert.strictEqual(result, path.join('/home/u', '.config', 'opencode'));
|
||
});
|
||
|
||
test('generic-agents-root: whitespace-only env override falls back to probe/default', () => {
|
||
const result = resolveConfigHomeFromDescriptor(
|
||
{
|
||
kind: 'generic-agents-root',
|
||
name: 'agents',
|
||
env: ['EXAMPLE_CONFIG_DIR'],
|
||
probe: ['~/.config/agents', '~/.agents'],
|
||
probeExists: 'skills',
|
||
},
|
||
{ env: { EXAMPLE_CONFIG_DIR: ' ' }, home: '/home/u', existsSync: () => false },
|
||
);
|
||
assert.strictEqual(result, path.join('/home/u', '.config', 'agents'));
|
||
});
|
||
});
|
||
});
|
||
|
||
// ── GOLDEN XDG SCENARIOS ──────────────────────────────────────────────────────
|
||
|
||
describe('descriptor-driven equivalence: xdg runtimes (opencode)', () => {
|
||
// opencode
|
||
test('opencode: OPENCODE_CONFIG (file-path) → dirname', () => {
|
||
const saved = clearAllEnvKeys();
|
||
process.env['OPENCODE_CONFIG'] = '/home/u/cfg/opencode.json';
|
||
try {
|
||
assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/home/u/cfg');
|
||
} finally {
|
||
restoreEnvKeys(saved);
|
||
}
|
||
});
|
||
|
||
test('opencode: OPENCODE_CONFIG_DIR takes precedence over OPENCODE_CONFIG', () => {
|
||
const saved = clearAllEnvKeys();
|
||
process.env['OPENCODE_CONFIG_DIR'] = '/dir/wins';
|
||
process.env['OPENCODE_CONFIG'] = '/file/loses.json';
|
||
try {
|
||
assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/dir/wins');
|
||
} finally {
|
||
restoreEnvKeys(saved);
|
||
}
|
||
});
|
||
|
||
test('opencode: OPENCODE_CONFIG takes precedence over XDG_CONFIG_HOME', () => {
|
||
const saved = clearAllEnvKeys();
|
||
process.env['OPENCODE_CONFIG'] = '/cfg/opencode.json';
|
||
process.env['XDG_CONFIG_HOME'] = '/xdg/should/lose';
|
||
try {
|
||
assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/cfg');
|
||
} finally {
|
||
restoreEnvKeys(saved);
|
||
}
|
||
});
|
||
|
||
test('opencode: XDG_CONFIG_HOME → ~/.config/opencode subdir', () => {
|
||
const saved = clearAllEnvKeys();
|
||
process.env['XDG_CONFIG_HOME'] = '/xdg';
|
||
try {
|
||
assert.strictEqual(getGlobalConfigDir('opencode'), path.join('/xdg', 'opencode'));
|
||
} finally {
|
||
restoreEnvKeys(saved);
|
||
}
|
||
});
|
||
|
||
test('opencode: tilde in OPENCODE_CONFIG → dirname expands tilde', () => {
|
||
const saved = clearAllEnvKeys();
|
||
process.env['OPENCODE_CONFIG'] = '~/cfg/opencode.json';
|
||
try {
|
||
assert.strictEqual(getGlobalConfigDir('opencode'), path.join(HOME, 'cfg'));
|
||
} finally {
|
||
restoreEnvKeys(saved);
|
||
}
|
||
});
|
||
|
||
});
|
||
|
||
// ── GOLDEN DOT-HOME-NESTED (antigravity probe) ────────────────────────────────
|
||
|
||
describe('descriptor-driven equivalence: dot-home-nested antigravity probe hit/miss', () => {
|
||
test('antigravity probe-miss → ~/.gemini/antigravity (first candidate)', () => {
|
||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-equiv-antigravity-miss-'));
|
||
try {
|
||
// no candidates exist → fallback to first
|
||
const result = resolveConfigHomeFromDescriptor(
|
||
{
|
||
kind: 'dot-home-nested',
|
||
name: 'antigravity',
|
||
parent: '.gemini',
|
||
env: ['ANTIGRAVITY_CONFIG_DIR'],
|
||
probe: ['antigravity', 'antigravity-ide', 'antigravity-cli'],
|
||
},
|
||
{ env: {}, home: tmpHome, existsSync: () => false },
|
||
);
|
||
assert.strictEqual(result, path.join(tmpHome, '.gemini', 'antigravity'));
|
||
} finally {
|
||
cleanup(tmpHome);
|
||
}
|
||
});
|
||
|
||
test('antigravity probe-hit antigravity → returns ~/.gemini/antigravity', () => {
|
||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-equiv-antigravity-hit-'));
|
||
try {
|
||
const hitPath = path.join(tmpHome, '.gemini', 'antigravity');
|
||
const result = resolveConfigHomeFromDescriptor(
|
||
{
|
||
kind: 'dot-home-nested',
|
||
name: 'antigravity',
|
||
parent: '.gemini',
|
||
env: ['ANTIGRAVITY_CONFIG_DIR'],
|
||
probe: ['antigravity', 'antigravity-ide', 'antigravity-cli'],
|
||
},
|
||
{ env: {}, home: tmpHome, existsSync: (p) => p === hitPath },
|
||
);
|
||
assert.strictEqual(result, hitPath);
|
||
} finally {
|
||
cleanup(tmpHome);
|
||
}
|
||
});
|
||
|
||
test('antigravity probe-hit antigravity-ide → returns ~/.gemini/antigravity-ide', () => {
|
||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-equiv-antigravity-ide-'));
|
||
try {
|
||
const hitPath = path.join(tmpHome, '.gemini', 'antigravity-ide');
|
||
const result = resolveConfigHomeFromDescriptor(
|
||
{
|
||
kind: 'dot-home-nested',
|
||
name: 'antigravity',
|
||
parent: '.gemini',
|
||
env: ['ANTIGRAVITY_CONFIG_DIR'],
|
||
probe: ['antigravity', 'antigravity-ide', 'antigravity-cli'],
|
||
},
|
||
{ env: {}, home: tmpHome, existsSync: (p) => p === hitPath },
|
||
);
|
||
assert.strictEqual(result, hitPath);
|
||
} finally {
|
||
cleanup(tmpHome);
|
||
}
|
||
});
|
||
|
||
test('antigravity probe-hit antigravity-cli (only cli exists) → returns ~/.gemini/antigravity-cli', () => {
|
||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-equiv-antigravity-cli-'));
|
||
try {
|
||
const hitPath = path.join(tmpHome, '.gemini', 'antigravity-cli');
|
||
const result = resolveConfigHomeFromDescriptor(
|
||
{
|
||
kind: 'dot-home-nested',
|
||
name: 'antigravity',
|
||
parent: '.gemini',
|
||
env: ['ANTIGRAVITY_CONFIG_DIR'],
|
||
probe: ['antigravity', 'antigravity-ide', 'antigravity-cli'],
|
||
},
|
||
{ env: {}, home: tmpHome, existsSync: (p) => p === hitPath },
|
||
);
|
||
assert.strictEqual(result, hitPath);
|
||
} finally {
|
||
cleanup(tmpHome);
|
||
}
|
||
});
|
||
|
||
// ── #213/#217 coexistence regression: probeExists disambiguation ──────────
|
||
// Before probeExists on dot-home-nested, first-bare-existing-wins meant a CLI
|
||
// user (antigravity-cli) who also had the IDE's ~/.gemini/antigravity dir
|
||
// present was shadowed to the legacy dir (probed first). probeExists =
|
||
// 'msd-core/VERSION' makes the dir MSD actually owns win, regardless of order.
|
||
const AG_PROBE = ['antigravity', 'antigravity-ide', 'antigravity-cli'];
|
||
const AG_MARKER = path.join('msd-core', 'VERSION');
|
||
|
||
function antigravityDescriptor(withMarker) {
|
||
const d = {
|
||
kind: 'dot-home-nested',
|
||
name: 'antigravity',
|
||
parent: '.gemini',
|
||
env: ['ANTIGRAVITY_CONFIG_DIR'],
|
||
probe: AG_PROBE,
|
||
};
|
||
if (withMarker) d.probeExists = AG_MARKER;
|
||
return d;
|
||
}
|
||
|
||
test('coexistence: legacy antigravity + antigravity-cli both exist, only cli is MSD-marked → returns antigravity-cli', () => {
|
||
const home = '/home/u';
|
||
const cliDir = path.join(home, '.gemini', 'antigravity-cli');
|
||
const legacyDir = path.join(home, '.gemini', 'antigravity');
|
||
const markerPath = path.join(cliDir, AG_MARKER);
|
||
// Both dirs exist on disk; only the cli dir carries msd-core/VERSION.
|
||
const existsSync = (p) =>
|
||
p === markerPath || p === cliDir || p === legacyDir;
|
||
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(true), {
|
||
env: {},
|
||
home,
|
||
existsSync,
|
||
});
|
||
assert.strictEqual(result, cliDir, 'MSD-marked cli dir must win over bare-existing legacy dir');
|
||
});
|
||
|
||
test('coexistence WITHOUT probeExists still shadows to legacy (documents the pre-fix behavior)', () => {
|
||
const home = '/home/u';
|
||
const cliDir = path.join(home, '.gemini', 'antigravity-cli');
|
||
const legacyDir = path.join(home, '.gemini', 'antigravity');
|
||
const existsSync = (p) => p === cliDir || p === legacyDir;
|
||
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(false), {
|
||
env: {},
|
||
home,
|
||
existsSync,
|
||
});
|
||
// No marker → legacy first-bare-existing wins. This is exactly the #217 bug
|
||
// and proves probeExists is the load-bearing fix.
|
||
assert.strictEqual(result, legacyDir);
|
||
});
|
||
|
||
test('coexistence: legacy + ide both exist, only ide is MSD-marked → returns antigravity-ide', () => {
|
||
const home = '/home/u';
|
||
const ideDir = path.join(home, '.gemini', 'antigravity-ide');
|
||
const legacyDir = path.join(home, '.gemini', 'antigravity');
|
||
const markerPath = path.join(ideDir, AG_MARKER);
|
||
const existsSync = (p) => p === markerPath || p === ideDir || p === legacyDir;
|
||
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(true), {
|
||
env: {},
|
||
home,
|
||
existsSync,
|
||
});
|
||
assert.strictEqual(result, ideDir);
|
||
});
|
||
|
||
test('marker on legacy dir: MSD lives in legacy antigravity (a real 1.x install) → returns legacy even when cli dir exists bare', () => {
|
||
const home = '/home/u';
|
||
const legacyDir = path.join(home, '.gemini', 'antigravity');
|
||
const cliDir = path.join(home, '.gemini', 'antigravity-cli');
|
||
const markerPath = path.join(legacyDir, AG_MARKER);
|
||
// Legacy carries the marker; cli dir exists but is not MSD's. Legacy wins.
|
||
const existsSync = (p) => p === markerPath || p === legacyDir || p === cliDir;
|
||
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(true), {
|
||
env: {},
|
||
home,
|
||
existsSync,
|
||
});
|
||
assert.strictEqual(result, legacyDir);
|
||
});
|
||
|
||
test('no marker anywhere (dirs exist but no MSD installed yet): falls back to bare-existence first match', () => {
|
||
const home = '/home/u';
|
||
const ideDir = path.join(home, '.gemini', 'antigravity-ide');
|
||
// Only ide dir exists, no msd-core/VERSION anywhere → pass 2 returns ide.
|
||
const existsSync = (p) => p === ideDir;
|
||
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(true), {
|
||
env: {},
|
||
home,
|
||
existsSync,
|
||
});
|
||
assert.strictEqual(result, ideDir, 'with no marker, bare-existence pass still resolves the single existing 2.x dir');
|
||
});
|
||
|
||
test('probeExists present but nothing exists → fallback to probe[0] (legacy default preserved)', () => {
|
||
const home = '/home/u';
|
||
const result = resolveConfigHomeFromDescriptor(antigravityDescriptor(true), {
|
||
env: {},
|
||
home,
|
||
existsSync: () => false,
|
||
});
|
||
assert.strictEqual(result, path.join(home, '.gemini', 'antigravity'));
|
||
});
|
||
|
||
test('antigravity: ANTIGRAVITY_CONFIG_DIR env override wins over any probe', () => {
|
||
const result = resolveConfigHomeFromDescriptor(
|
||
{
|
||
kind: 'dot-home-nested',
|
||
name: 'antigravity',
|
||
parent: '.gemini',
|
||
env: ['ANTIGRAVITY_CONFIG_DIR'],
|
||
probe: ['antigravity', 'antigravity-ide', 'antigravity-cli'],
|
||
},
|
||
{ env: { ANTIGRAVITY_CONFIG_DIR: '/custom/ag' }, home: '/home/u', existsSync: () => true },
|
||
);
|
||
assert.strictEqual(result, '/custom/ag');
|
||
});
|
||
|
||
});
|
||
|
||
// ── #213/#217 thread-4: existing-install ambiguity detector ───────────────────
|
||
describe('detectAntigravityDirAmbiguity (migration/operator-guidance signal)', () => {
|
||
const HOMEU = '/home/u';
|
||
const dir = (name) => path.join(HOMEU, '.gemini', name);
|
||
const markerOf = (name) => path.join(dir(name), 'msd-core', 'VERSION');
|
||
|
||
test('single dir present → not ambiguous', () => {
|
||
const cli = dir('antigravity-cli');
|
||
const r = detectAntigravityDirAmbiguity({
|
||
env: {},
|
||
home: HOMEU,
|
||
existsSync: (p) => p === cli || p === markerOf('antigravity-cli'),
|
||
});
|
||
assert.strictEqual(r.ambiguous, false);
|
||
assert.strictEqual(r.resolved, cli);
|
||
assert.deepStrictEqual(r.presentDirs, [cli]);
|
||
assert.deepStrictEqual(r.msdMarkedDirs, [cli]);
|
||
assert.strictEqual(r.envOverridden, false);
|
||
});
|
||
|
||
test('legacy + cli both present, MSD marked in cli → ambiguous, resolves to cli', () => {
|
||
const legacy = dir('antigravity');
|
||
const cli = dir('antigravity-cli');
|
||
const r = detectAntigravityDirAmbiguity({
|
||
env: {},
|
||
home: HOMEU,
|
||
existsSync: (p) => p === legacy || p === cli || p === markerOf('antigravity-cli'),
|
||
});
|
||
assert.strictEqual(r.ambiguous, true, 'two probe dirs present must flag ambiguity');
|
||
assert.strictEqual(r.resolved, cli, 'marker disambiguates resolution to cli');
|
||
assert.deepStrictEqual(r.presentDirs.sort(), [legacy, cli].sort());
|
||
assert.deepStrictEqual(r.msdMarkedDirs, [cli]);
|
||
});
|
||
|
||
test('misinstall surface: legacy + cli present but MSD marked ONLY in legacy → ambiguous, resolves to legacy', () => {
|
||
// This is exactly the #217 victim: MSD was written into the legacy/IDE dir,
|
||
// so the marker is in legacy and the resolver keeps it there. The detector
|
||
// flags ambiguity so the installer/update can prompt the operator.
|
||
const legacy = dir('antigravity');
|
||
const cli = dir('antigravity-cli');
|
||
const r = detectAntigravityDirAmbiguity({
|
||
env: {},
|
||
home: HOMEU,
|
||
existsSync: (p) => p === legacy || p === cli || p === markerOf('antigravity'),
|
||
});
|
||
assert.strictEqual(r.ambiguous, true);
|
||
assert.strictEqual(r.resolved, legacy);
|
||
assert.deepStrictEqual(r.msdMarkedDirs, [legacy]);
|
||
});
|
||
|
||
test('env override short-circuits: envOverridden flag set when ANTIGRAVITY_CONFIG_DIR present', () => {
|
||
const r = detectAntigravityDirAmbiguity({
|
||
env: { ANTIGRAVITY_CONFIG_DIR: '/custom/ag' },
|
||
home: HOMEU,
|
||
existsSync: () => true,
|
||
});
|
||
assert.strictEqual(r.envOverridden, true);
|
||
assert.strictEqual(r.resolved, '/custom/ag', 'env override wins over probe entirely');
|
||
});
|
||
});
|
||
|
||
// ── resolveAntigravityGlobalDir wrapper ───────────────────────────────────────
|
||
|
||
describe('descriptor-driven equivalence: resolveAntigravityGlobalDir wrapper', () => {
|
||
// Verify resolveAntigravityGlobalDir wrapper delegates correctly
|
||
test('resolveAntigravityGlobalDir wrapper: probe-miss → ~/.gemini/antigravity', () => {
|
||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-equiv-ragd-miss-'));
|
||
try {
|
||
assert.strictEqual(
|
||
resolveAntigravityGlobalDir({ env: {}, home: tmpHome, existsSync: () => false }),
|
||
path.join(tmpHome, '.gemini', 'antigravity'),
|
||
);
|
||
} finally {
|
||
cleanup(tmpHome);
|
||
}
|
||
});
|
||
|
||
test('resolveAntigravityGlobalDir wrapper: probe-hit antigravity-ide', () => {
|
||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-equiv-ragd-hit-'));
|
||
try {
|
||
const hitPath = path.join(tmpHome, '.gemini', 'antigravity-ide');
|
||
assert.strictEqual(
|
||
resolveAntigravityGlobalDir({
|
||
env: {},
|
||
home: tmpHome,
|
||
existsSync: (p) => p === hitPath,
|
||
}),
|
||
hitPath,
|
||
);
|
||
} finally {
|
||
cleanup(tmpHome);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ── GOLDEN EXPLICIT DIR OVERRIDE ──────────────────────────────────────────────
|
||
|
||
describe('descriptor-driven equivalence: explicitDir short-circuit', () => {
|
||
test('explicitDir absolute path returned as-is (any runtime)', () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('claude', '/tmp/explicit')).replace(/\\/g, '/'), '/tmp/explicit');
|
||
assert.strictEqual(String(getGlobalConfigDir('opencode', '/tmp/explicit')).replace(/\\/g, '/'), '/tmp/explicit');
|
||
assert.strictEqual(String(getGlobalConfigDir('cursor', '/tmp/explicit')).replace(/\\/g, '/'), '/tmp/explicit');
|
||
assert.strictEqual(String(getGlobalConfigDir('grok', '/tmp/explicit')).replace(/\\/g, '/'), '/tmp/explicit');
|
||
});
|
||
|
||
test('explicitDir with ~ is expanded', () => {
|
||
assert.strictEqual(
|
||
getGlobalConfigDir('claude', '~/foo'),
|
||
path.join(HOME, 'foo'),
|
||
);
|
||
});
|
||
|
||
test('explicitDir wins even when env var is set', () => {
|
||
withEnv({ CLAUDE_CONFIG_DIR: '/should/not/win' }, () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('claude', '/explicit/wins')).replace(/\\/g, '/'), '/explicit/wins');
|
||
});
|
||
});
|
||
});
|
||
|
||
// ── GOLDEN GROK (not in registry, hardcoded) ──────────────────────────────────
|
||
|
||
describe('descriptor-driven equivalence: grok (not in registry)', () => {
|
||
test('grok default → ~/.agents', () => {
|
||
const saved = clearAllEnvKeys();
|
||
try {
|
||
assert.strictEqual(getGlobalConfigDir('grok'), path.join(HOME, '.agents'));
|
||
} finally {
|
||
restoreEnvKeys(saved);
|
||
}
|
||
});
|
||
|
||
test('grok: GROK_AGENTS_HOME override', () => {
|
||
withEnv({ GROK_AGENTS_HOME: '/custom/grok-agents' }, () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('grok')).replace(/\\/g, '/'), '/custom/grok-agents');
|
||
});
|
||
});
|
||
|
||
test('grok: GROK_AGENTS_HOME tilde expansion', () => {
|
||
withEnv({ GROK_AGENTS_HOME: '~/grok' }, () => {
|
||
assert.strictEqual(getGlobalConfigDir('grok'), path.join(HOME, 'grok'));
|
||
});
|
||
});
|
||
});
|
||
|
||
// ── GOLDEN UNKNOWN RUNTIME (Claude fallback) ──────────────────────────────────
|
||
|
||
describe('descriptor-driven equivalence: unknown runtime fallback', () => {
|
||
test('unknown runtime → ~/.claude default', () => {
|
||
const saved = clearAllEnvKeys();
|
||
try {
|
||
assert.strictEqual(getGlobalConfigDir('no-such-runtime'), path.join(HOME, '.claude'));
|
||
} finally {
|
||
restoreEnvKeys(saved);
|
||
}
|
||
});
|
||
|
||
test('unknown runtime → CLAUDE_CONFIG_DIR if set', () => {
|
||
withEnv({ CLAUDE_CONFIG_DIR: '/custom/claude-for-unknown' }, () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('no-such-runtime')).replace(/\\/g, '/'), '/custom/claude-for-unknown');
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('descriptor-driven global skills base', () => {
|
||
|
||
test('synthetic runtime skillsHome descriptor resolves without a runtime-name branch', () => {
|
||
const base = resolveSkillsBaseFromDescriptor(
|
||
{
|
||
kind: 'xdg',
|
||
name: 'futurecli',
|
||
env: ['FUTURE_CONFIG_DIR', 'FUTURE_CONFIG', 'XDG_CONFIG_HOME'],
|
||
skillsHome: {
|
||
kind: 'dot-home',
|
||
name: '.futurecli',
|
||
env: ['FUTURE_SKILLS_HOME'],
|
||
},
|
||
},
|
||
{
|
||
env: { FUTURE_SKILLS_HOME: '/custom/future-skills' },
|
||
home: '/home/u',
|
||
existsSync: () => false,
|
||
},
|
||
);
|
||
|
||
assert.strictEqual(base, path.join('/custom/future-skills', 'skills'));
|
||
});
|
||
});
|
||
|
||
// ── GOLDEN PARITY: getGlobalConfigDir via process.env for every non-probe registry runtime ──
|
||
|
||
describe('descriptor-driven parity: 13 non-probe registry runtimes × no-env-vars = golden defaults', () => {
|
||
// This is the hardest assertion: it drives getGlobalConfigDir() (which calls
|
||
// the registry internally) and compares against GOLDEN_DEFAULTS captured from
|
||
// the old switch. Any discrepancy means a regression.
|
||
// antigravity is excluded because it also depends on real fs probing — a machine
|
||
// with ~/.gemini/antigravity-ide or ~/.gemini/antigravity-cli (but not
|
||
// ~/.gemini/antigravity) gets a different result. Probe scenarios are covered in
|
||
// the dot-home-nested suite with injected existsSync.
|
||
// grok is excluded because it is not in the registry (hardcoded branch).
|
||
const registryRuntimes = Object.keys(GOLDEN_DEFAULTS).filter(
|
||
r => r !== 'grok' && r !== 'antigravity',
|
||
);
|
||
|
||
for (const runtime of registryRuntimes) {
|
||
test(`${runtime} via getGlobalConfigDir matches its golden default`, () => {
|
||
const saved = clearAllEnvKeys();
|
||
try {
|
||
assert.strictEqual(
|
||
getGlobalConfigDir(runtime),
|
||
GOLDEN_DEFAULTS[runtime],
|
||
`${runtime} via getGlobalConfigDir matches golden: ${GOLDEN_DEFAULTS[runtime]}`,
|
||
);
|
||
} finally {
|
||
restoreEnvKeys(saved);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
// Folded from tests/bug-3126-global-skills-base-runtime-path.test.cjs — consolidation epic #1969 (B3 #1972)
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
{
|
||
const { describe: __foldDescribe } = require('node:test');
|
||
__foldDescribe("folded:bug-3126-global-skills-base-runtime-path (consolidation epic #1969 B3 #1972)", () => {
|
||
'use strict';
|
||
// Regression guard for bug #3126.
|
||
//
|
||
// buildAgentSkillsBlock() in init.cjs hardcoded `globalSkillsBase` to
|
||
// `~/.claude/skills` regardless of the active runtime. On a Cursor install,
|
||
// global: skills live under `~/.cursor/skills`, causing every global: lookup
|
||
// to silently fail with:
|
||
// [agent-skills] WARNING: Global skill not found at "~/.cursor/skills/X/SKILL.md" — skipping
|
||
//
|
||
// Fix introduces msd-core/bin/lib/runtime-homes.cjs with first-class
|
||
// support for every supported runtime, including:
|
||
// - CLAUDE_CONFIG_DIR env var for Claude (was missing)
|
||
// - All other runtime-specific env vars
|
||
|
||
const { describe, test } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const path = require('node:path');
|
||
const os = require('node:os');
|
||
const { cleanup } = require('./helpers.cjs');
|
||
|
||
const ROOT = path.join(__dirname, '..');
|
||
const {
|
||
getGlobalConfigDir,
|
||
getGlobalSkillsBase,
|
||
getGlobalSkillDir,
|
||
} = require(path.join(ROOT, 'msd-core', 'bin', 'lib', 'runtime-homes.cjs'));
|
||
|
||
// Helper: run fn with an env var temporarily set
|
||
function withEnv(key, value, fn) {
|
||
const orig = process.env[key];
|
||
if (value === undefined) delete process.env[key];
|
||
else process.env[key] = value;
|
||
try { return fn(); }
|
||
finally {
|
||
if (orig === undefined) delete process.env[key];
|
||
else process.env[key] = orig;
|
||
}
|
||
}
|
||
|
||
describe('bug #3126: runtime-homes getGlobalConfigDir — defaults', () => {
|
||
const defaults = [
|
||
['claude', path.join(os.homedir(), '.claude')],
|
||
['cursor', path.join(os.homedir(), '.cursor')],
|
||
['codex', path.join(os.homedir(), '.codex')],
|
||
['antigravity', path.join(os.homedir(), '.gemini', 'antigravity')],
|
||
['opencode', path.join(os.homedir(), '.config', 'opencode')],
|
||
];
|
||
for (const [runtime, expected] of defaults) {
|
||
test(`${runtime} default configDir`, () => {
|
||
// Derive env-var list from the registry so new runtimes are auto-covered.
|
||
// GROK_AGENTS_HOME is kept explicitly (grok has no registry entry).
|
||
const { runtimes: _reg3126 } = require(path.join(ROOT, 'msd-core', 'bin', 'lib', 'capability-registry.cjs'));
|
||
const _regEnvKeys3126 = Object.values(_reg3126).flatMap((r) => {
|
||
const ch = r.runtime?.configHome;
|
||
if (!ch) return [];
|
||
const envs = Array.isArray(ch.env) ? ch.env : [];
|
||
const skillsEnvs = ch.skillsHome && Array.isArray(ch.skillsHome.env) ? ch.skillsHome.env : [];
|
||
return [...envs, ...skillsEnvs];
|
||
});
|
||
const envKeys = [...new Set([..._regEnvKeys3126, 'GROK_AGENTS_HOME', 'XDG_CONFIG_HOME'])];
|
||
const saved = {};
|
||
for (const k of envKeys) { saved[k] = process.env[k]; delete process.env[k]; }
|
||
try {
|
||
assert.strictEqual(getGlobalConfigDir(runtime), expected);
|
||
} finally {
|
||
for (const k of envKeys) {
|
||
if (saved[k] !== undefined) process.env[k] = saved[k];
|
||
}
|
||
}
|
||
});
|
||
}
|
||
test('unknown runtime falls back to ~/.claude', () => {
|
||
withEnv('CLAUDE_CONFIG_DIR', undefined, () => {
|
||
assert.strictEqual(getGlobalConfigDir('unknown-xyz'), path.join(os.homedir(), '.claude'));
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('bug #3126: runtime-homes env-var overrides', () => {
|
||
test('claude respects CLAUDE_CONFIG_DIR (was missing in old code)', () => {
|
||
withEnv('CLAUDE_CONFIG_DIR', '/custom/claude', () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('claude')).replace(/\\/g, '/'), '/custom/claude');
|
||
});
|
||
});
|
||
test('cursor respects CURSOR_CONFIG_DIR', () => {
|
||
withEnv('CURSOR_CONFIG_DIR', '/custom/cursor', () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('cursor')).replace(/\\/g, '/'), '/custom/cursor');
|
||
});
|
||
});
|
||
test('opencode respects OPENCODE_CONFIG_DIR', () => {
|
||
withEnv('OPENCODE_CONFIG_DIR', '/custom/opencode', () => {
|
||
withEnv('XDG_CONFIG_HOME', undefined, () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/custom/opencode');
|
||
});
|
||
});
|
||
});
|
||
test('opencode uses XDG_CONFIG_HOME when OPENCODE_CONFIG_DIR absent', () => {
|
||
withEnv('OPENCODE_CONFIG_DIR', undefined, () => {
|
||
withEnv('OPENCODE_CONFIG', undefined, () => {
|
||
withEnv('XDG_CONFIG_HOME', '/xdg', () => {
|
||
assert.strictEqual(getGlobalConfigDir('opencode'), path.join('/xdg', 'opencode'));
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
test('antigravity detects 2.x IDE dir when legacy dir is absent', () => {
|
||
const home = require('node:fs').mkdtempSync(path.join(os.tmpdir(), 'msd-antigravity-home-'));
|
||
try {
|
||
require('node:fs').mkdirSync(path.join(home, '.gemini', 'antigravity-ide'), { recursive: true });
|
||
const savedHome = process.env.HOME;
|
||
const savedUserProfile = process.env.USERPROFILE;
|
||
process.env.HOME = home;
|
||
process.env.USERPROFILE = home;
|
||
withEnv('ANTIGRAVITY_CONFIG_DIR', undefined, () => {
|
||
assert.strictEqual(
|
||
getGlobalConfigDir('antigravity'),
|
||
path.join(home, '.gemini', 'antigravity-ide'),
|
||
);
|
||
});
|
||
if (savedHome === undefined) delete process.env.HOME;
|
||
else process.env.HOME = savedHome;
|
||
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
|
||
else process.env.USERPROFILE = savedUserProfile;
|
||
} finally {
|
||
cleanup(home);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('bug #3126: runtime-homes getGlobalSkillsBase', () => {
|
||
test('most runtimes: skills at <configDir>/skills', () => {
|
||
withEnv('CURSOR_CONFIG_DIR', undefined, () => {
|
||
assert.strictEqual(
|
||
getGlobalSkillsBase('cursor'),
|
||
path.join(os.homedir(), '.cursor', 'skills'),
|
||
);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('bug #3126: runtime-homes getGlobalSkillDir', () => {
|
||
test('cursor: <configDir>/skills/<skillName>', () => {
|
||
withEnv('CURSOR_CONFIG_DIR', undefined, () => {
|
||
assert.strictEqual(
|
||
getGlobalSkillDir('cursor', 'msd-executor'),
|
||
path.join(os.homedir(), '.cursor', 'skills', 'msd-executor'),
|
||
);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('getGlobalConfigDir — explicitDir override and opencode file-path precedence', () => {
|
||
// ── explicitDir override ──────────────────────────────────────────────────
|
||
test('explicitDir absolute path is returned as-is (claude)', () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('claude', '/tmp/x')).replace(/\\/g, '/'), '/tmp/x');
|
||
});
|
||
|
||
test('explicitDir with tilde is expanded (opencode)', () => {
|
||
assert.strictEqual(
|
||
getGlobalConfigDir('opencode', '~/foo'),
|
||
path.join(os.homedir(), 'foo'),
|
||
);
|
||
});
|
||
|
||
test('explicitDir wins even when OPENCODE_CONFIG_DIR is also set', () => {
|
||
withEnv('OPENCODE_CONFIG_DIR', '/should/not/win', () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('opencode', '/explicit/wins')).replace(/\\/g, '/'), '/explicit/wins');
|
||
});
|
||
});
|
||
|
||
// ── opencode: OPENCODE_CONFIG file-path step ──────────────────────────────
|
||
test('opencode: OPENCODE_CONFIG → path.dirname(expandTilde(value))', () => {
|
||
withEnv('OPENCODE_CONFIG_DIR', undefined, () => {
|
||
withEnv('XDG_CONFIG_HOME', undefined, () => {
|
||
withEnv('OPENCODE_CONFIG', '/home/u/cfg/opencode.json', () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/home/u/cfg');
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
test('opencode: OPENCODE_CONFIG_DIR takes precedence over OPENCODE_CONFIG', () => {
|
||
withEnv('OPENCODE_CONFIG_DIR', '/dir/wins', () => {
|
||
withEnv('OPENCODE_CONFIG', '/file/loses.json', () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/dir/wins');
|
||
});
|
||
});
|
||
});
|
||
|
||
test('opencode: OPENCODE_CONFIG takes precedence over XDG_CONFIG_HOME', () => {
|
||
withEnv('OPENCODE_CONFIG_DIR', undefined, () => {
|
||
withEnv('OPENCODE_CONFIG', '/cfg/opencode.json', () => {
|
||
withEnv('XDG_CONFIG_HOME', '/xdg/should/lose', () => {
|
||
assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/cfg');
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
test('opencode: default ~/.config/opencode when no env vars set', () => {
|
||
withEnv('OPENCODE_CONFIG_DIR', undefined, () => {
|
||
withEnv('OPENCODE_CONFIG', undefined, () => {
|
||
withEnv('XDG_CONFIG_HOME', undefined, () => {
|
||
assert.strictEqual(
|
||
getGlobalConfigDir('opencode'),
|
||
path.join(os.homedir(), '.config', 'opencode'),
|
||
);
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
});
|
||
|
||
describe('bug #3126: buildAgentSkillsBlock resolves the agent-skills path per runtime (not hardcoded .claude)', () => {
|
||
// Behavioral replacement (#3466) for the three init.cjs source-grep assertions
|
||
// ("no hardcoded ~/.claude/skills assignment", "requires runtime-homes",
|
||
// "warning message no longer hardcodes ~/.claude/skills"). Those proved a
|
||
// STRING was absent/present in init.cjs's text; they would pass even if
|
||
// buildAgentSkillsBlock resolved the WRONG path for a non-claude runtime, as
|
||
// long as the literal old hardcoded expression didn't reappear verbatim. This
|
||
// drives buildAgentSkillsBlock() itself — the real exported function bug
|
||
// #3126 fixed — for two DIFFERENT runtimes with real fixture skill files
|
||
// under real per-runtime config dirs, and asserts each resolves under ITS
|
||
// OWN runtime's skills dir and never falls back to (or leaks into) the
|
||
// other's.
|
||
const fs = require('node:fs');
|
||
const { buildAgentSkillsBlock } = require(path.join(ROOT, 'msd-core', 'bin', 'lib', 'init.cjs'));
|
||
|
||
/**
|
||
* Creates a temp config dir with a real `skills/<skillName>/SKILL.md` fixture,
|
||
* points `configDirEnvKey` at it for the duration of `fn`, and cleans up
|
||
* (including restoring the env var) afterward.
|
||
*/
|
||
function withSkillFixture(configDirEnvKey, skillName, fn) {
|
||
const tmpConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3126-skills-'));
|
||
const skillDir = path.join(tmpConfigDir, 'skills', skillName);
|
||
fs.mkdirSync(skillDir, { recursive: true });
|
||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# fixture skill\n');
|
||
const saved = process.env[configDirEnvKey];
|
||
process.env[configDirEnvKey] = tmpConfigDir;
|
||
try {
|
||
return fn(tmpConfigDir);
|
||
} finally {
|
||
if (saved === undefined) delete process.env[configDirEnvKey];
|
||
else process.env[configDirEnvKey] = saved;
|
||
cleanup(tmpConfigDir);
|
||
}
|
||
}
|
||
|
||
test('cursor: resolves under CURSOR_CONFIG_DIR/skills, never falls back to .claude/skills', () => {
|
||
withSkillFixture('CURSOR_CONFIG_DIR', 'msd-executor', (tmpConfigDir) => {
|
||
const diagnostics = { warnings: [] };
|
||
const block = buildAgentSkillsBlock(
|
||
{ runtime: 'cursor', agent_skills: { 'msd-executor': 'global:msd-executor' } },
|
||
'msd-executor',
|
||
tmpConfigDir,
|
||
diagnostics,
|
||
);
|
||
const expectedRef = path.join(tmpConfigDir, 'skills', 'msd-executor', 'SKILL.md').replace(/\\/g, '/');
|
||
assert.ok(block.includes(expectedRef), `expected block to include ${expectedRef}, got: ${block}`);
|
||
assert.ok(!block.includes('.claude/skills'), `cursor resolution must not fall back to .claude/skills, got: ${block}`);
|
||
assert.deepEqual(diagnostics.warnings, [], `expected no warnings, got: ${JSON.stringify(diagnostics.warnings)}`);
|
||
});
|
||
});
|
||
|
||
test('claude: resolves under CLAUDE_CONFIG_DIR/skills, never leaks into .cursor/skills', () => {
|
||
withSkillFixture('CLAUDE_CONFIG_DIR', 'msd-executor', (tmpConfigDir) => {
|
||
const diagnostics = { warnings: [] };
|
||
const block = buildAgentSkillsBlock(
|
||
{ runtime: 'claude', agent_skills: { 'msd-executor': 'global:msd-executor' } },
|
||
'msd-executor',
|
||
tmpConfigDir,
|
||
diagnostics,
|
||
);
|
||
const expectedRef = path.join(tmpConfigDir, 'skills', 'msd-executor', 'SKILL.md').replace(/\\/g, '/');
|
||
assert.ok(block.includes(expectedRef), `expected block to include ${expectedRef}, got: ${block}`);
|
||
assert.ok(!block.includes('.cursor/skills'), `claude resolution must not use .cursor/skills, got: ${block}`);
|
||
assert.deepEqual(diagnostics.warnings, [], `expected no warnings, got: ${JSON.stringify(diagnostics.warnings)}`);
|
||
});
|
||
});
|
||
|
||
test('per-runtime resolution: two different runtimes in the same process each resolve into THEIR OWN config dir, never the other\'s', () => {
|
||
// Proves this isn't a single special-cased runtime — cursor and claude,
|
||
// driven back-to-back, must never cross-resolve into each other's fixture dir.
|
||
withSkillFixture('CURSOR_CONFIG_DIR', 'msd-executor', (cursorDir) => {
|
||
withSkillFixture('CLAUDE_CONFIG_DIR', 'msd-executor', (claudeDir) => {
|
||
const cursorBlock = buildAgentSkillsBlock(
|
||
{ runtime: 'cursor', agent_skills: { x: 'global:msd-executor' } }, 'x', cursorDir, { warnings: [] },
|
||
);
|
||
const claudeBlock = buildAgentSkillsBlock(
|
||
{ runtime: 'claude', agent_skills: { x: 'global:msd-executor' } }, 'x', claudeDir, { warnings: [] },
|
||
);
|
||
const cursorPosix = cursorDir.replace(/\\/g, '/');
|
||
const claudePosix = claudeDir.replace(/\\/g, '/');
|
||
assert.ok(cursorBlock.includes(cursorPosix), `cursor block must reference its own config dir, got: ${cursorBlock}`);
|
||
assert.ok(!cursorBlock.includes(claudePosix), `cursor block must not reference claude's config dir, got: ${cursorBlock}`);
|
||
assert.ok(claudeBlock.includes(claudePosix), `claude block must reference its own config dir, got: ${claudeBlock}`);
|
||
assert.ok(!claudeBlock.includes(cursorPosix), `claude block must not reference cursor's config dir, got: ${claudeBlock}`);
|
||
});
|
||
});
|
||
});
|
||
});
|
||
});
|
||
}
|