test(#2003): add regression tests for --runtime override on capability state/loop render-hooks

Mirrors the #1160 installed-layout block for the runtime auto-detection gap.
Covers: runtimeOverride='claude' bypasses persisted config.runtime:'codex';
no override still honours persisted runtime (regression guard); alias
canonicalization (codex-app -> codex); and an end-to-end CLI test proving
'capability state --runtime claude' resolves the Claude config dir despite a
persisted runtime:'codex'.

Expected RED against unfixed resolveCapabilityRuntimeState (no runtimeOverride
param) and unfixed gsd-tools.cjs (no --runtime parsing for capability state /
loop render-hooks).
This commit is contained in:
Tom Boucher
2026-07-06 22:57:05 -04:00
parent d99e1f9c82
commit 6a15ab9345

View File

@@ -15,10 +15,11 @@ const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { cleanup } = require('./helpers.cjs');
const { cleanup, runGsdTools } = require('./helpers.cjs');
const {
resolveCapabilityState,
resolveCapabilityRuntimeState,
isCapabilityActive,
_isSafePropKey,
_loadInstalledSkillsManifest,
@@ -1810,3 +1811,93 @@ describe('#1459 IC-04: capability-state threads gsdHome to the overlay loader',
}
});
});
describe('regressions: --runtime override bypasses persisted runtime (#2003)', () => {
// #2003: `capability state` and `loop render-hooks` parsed only --config-dir,
// never --runtime. resolveCapabilityRuntimeState derived the config dir from
// resolveRuntime(cwd) (GSD_RUNTIME → config.runtime → 'claude'), so a repo
// with persisted runtime:"codex" resolved the config dir to ~/.codex — where
// the Claude skill isn't installed → surfaced:false. Fix: thread an explicit
// --runtime override through both commands into resolveCapabilityRuntimeState
// so it bypasses the persisted-runtime fallback (mirrors the update-context /
// effort sync precedent).
function writePersistedRuntime(tmpDir, runtime) {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ runtime }),
'utf8',
);
}
test('resolveCapabilityRuntimeState: runtimeOverride="claude" bypasses persisted config.runtime:"codex"', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rt-override-'));
try {
writePersistedRuntime(tmpDir, 'codex');
const runtimeHomes = require('../gsd-core/bin/lib/runtime-homes.cjs');
const expectedClaudeDir = runtimeHomes.getGlobalConfigDir('claude');
const expectedCodexDir = runtimeHomes.getGlobalConfigDir('codex');
// Persisted runtime is codex; explicit override is claude. The override
// MUST win (resolveRuntime is never consulted when an override is given,
// so GSD_RUNTIME env cannot interfere either).
const result = resolveCapabilityRuntimeState(tmpDir, undefined, undefined, 'claude');
assert.strictEqual(result.runtimeConfigDir, expectedClaudeDir,
'--runtime claude must override persisted runtime:"codex"');
assert.notStrictEqual(result.runtimeConfigDir, expectedCodexDir,
'must NOT resolve to the codex config dir when --runtime claude is explicit');
} finally {
cleanup(tmpDir);
}
});
test('resolveCapabilityRuntimeState: no override still honours persisted config.runtime (unchanged, regression guard)', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rt-no-override-'));
// Control GSD_RUNTIME so resolveRuntime deterministically reads config.runtime.
const savedGsdRuntime = process.env.GSD_RUNTIME;
delete process.env.GSD_RUNTIME;
try {
writePersistedRuntime(tmpDir, 'codex');
const runtimeHomes = require('../gsd-core/bin/lib/runtime-homes.cjs');
const expectedCodexDir = runtimeHomes.getGlobalConfigDir('codex');
// No override → persisted codex wins (existing behavior preserved).
const result = resolveCapabilityRuntimeState(tmpDir, undefined, undefined, undefined);
assert.strictEqual(result.runtimeConfigDir, expectedCodexDir,
'without --runtime, persisted config.runtime:"codex" still drives resolution (unchanged)');
} finally {
if (savedGsdRuntime === undefined) delete process.env.GSD_RUNTIME;
else process.env.GSD_RUNTIME = savedGsdRuntime;
cleanup(tmpDir);
}
});
test('resolveCapabilityRuntimeState: runtimeOverride canonicalizes aliases (codex-app -> codex)', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rt-alias-'));
try {
writePersistedRuntime(tmpDir, 'claude');
const runtimeHomes = require('../gsd-core/bin/lib/runtime-homes.cjs');
const expectedCodexDir = runtimeHomes.getGlobalConfigDir('codex');
// Alias "codex-app" canonicalizes to "codex" via runtime-name-policy.
const result = resolveCapabilityRuntimeState(tmpDir, undefined, undefined, 'codex-app');
assert.strictEqual(result.runtimeConfigDir, expectedCodexDir,
'--runtime codex-app (alias) must canonicalize to codex');
} finally {
cleanup(tmpDir);
}
});
test('CLI: `capability state --runtime claude` reports the Claude config dir despite persisted runtime:"codex"', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-rt-cli-'));
try {
writePersistedRuntime(tmpDir, 'codex');
const result = runGsdTools('capability state --runtime claude --raw', tmpDir);
assert.ok(result.success, `capability state --runtime should succeed: ${result.error || ''}`);
const parsed = JSON.parse(result.output);
const runtimeHomes = require('../gsd-core/bin/lib/runtime-homes.cjs');
assert.strictEqual(parsed.runtimeConfigDir, runtimeHomes.getGlobalConfigDir('claude'),
'`capability state --runtime claude` must resolve to the Claude config dir, not the persisted codex dir');
} finally {
cleanup(tmpDir);
}
});
});