diff --git a/tests/capability-state.test.cjs b/tests/capability-state.test.cjs index 66bba4e9c..2757e3b5f 100644 --- a/tests/capability-state.test.cjs +++ b/tests/capability-state.test.cjs @@ -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); + } + }); +});