Merge remote-tracking branch 'origin/next' into feat/1259-test-tier-enforcement
This commit is contained in:
242
tests/agent-install-check.test.cjs
Normal file
242
tests/agent-install-check.test.cjs
Normal file
@@ -0,0 +1,242 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Agent Install Check Module — relocation tests (#1268 T0)
|
||||
*
|
||||
* Seam: gsd-core/bin/lib/agent-install-check.cjs
|
||||
* Interface: getAgentsDir, checkAgentsInstalled
|
||||
*
|
||||
* Verifies:
|
||||
* 1. Object identity — core re-exports by reference (not re-wrapped)
|
||||
* 2. getAgentsDir behaviour: GSD_AGENTS_DIR override, claude path, non-claude path
|
||||
* 3. checkAgentsInstalled behaviour against temp dirs via GSD_AGENTS_DIR:
|
||||
* - missing dir → agents_installed:false, missing_agents = all expected
|
||||
* - existing-but-empty dir → installed_agents:[], agents_installed:false
|
||||
* - no manifest → completeness skipped (incomplete_agents empty)
|
||||
* - partial manifest (agent.toml absent, agent.md present) → incomplete_agents includes agent
|
||||
* - malformed manifest → no throw, completeness skipped
|
||||
*/
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const AGENT_INSTALL_CHECK_PATH = path.join(
|
||||
__dirname, '..', 'gsd-core', 'bin', 'lib', 'agent-install-check.cjs'
|
||||
);
|
||||
const CORE_PATH = path.join(
|
||||
__dirname, '..', 'gsd-core', 'bin', 'lib', 'core.cjs'
|
||||
);
|
||||
const RUNTIME_HOMES_PATH = path.join(
|
||||
__dirname, '..', 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs'
|
||||
);
|
||||
|
||||
const agentInstallCheck = require(AGENT_INSTALL_CHECK_PATH);
|
||||
const core = require(CORE_PATH);
|
||||
const { getGlobalConfigDir } = require(RUNTIME_HOMES_PATH);
|
||||
|
||||
// Get EXPECTED_AGENTS from model-profiles (same source of truth)
|
||||
const MODEL_PROFILES = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'model-profiles.cjs')).MODEL_PROFILES;
|
||||
const EXPECTED_AGENTS = Object.keys(MODEL_PROFILES);
|
||||
|
||||
// ─── Environment isolation ────────────────────────────────────────────────────
|
||||
|
||||
let savedAgentsDir;
|
||||
let savedRuntime;
|
||||
|
||||
beforeEach(() => {
|
||||
savedAgentsDir = process.env['GSD_AGENTS_DIR'];
|
||||
savedRuntime = process.env['GSD_RUNTIME'];
|
||||
delete process.env['GSD_AGENTS_DIR'];
|
||||
delete process.env['GSD_RUNTIME'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedAgentsDir === undefined) {
|
||||
delete process.env['GSD_AGENTS_DIR'];
|
||||
} else {
|
||||
process.env['GSD_AGENTS_DIR'] = savedAgentsDir;
|
||||
}
|
||||
if (savedRuntime === undefined) {
|
||||
delete process.env['GSD_RUNTIME'];
|
||||
} else {
|
||||
process.env['GSD_RUNTIME'] = savedRuntime;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 1. Object identity ───────────────────────────────────────────────────────
|
||||
|
||||
describe('agent-install-check: object identity with core re-exports', () => {
|
||||
test('core.getAgentsDir === agentInstallCheck.getAgentsDir (by reference)', () => {
|
||||
assert.strictEqual(
|
||||
core.getAgentsDir,
|
||||
agentInstallCheck.getAgentsDir,
|
||||
'core.getAgentsDir must be the same function reference as agentInstallCheck.getAgentsDir'
|
||||
);
|
||||
});
|
||||
|
||||
test('core.checkAgentsInstalled === agentInstallCheck.checkAgentsInstalled (by reference)', () => {
|
||||
assert.strictEqual(
|
||||
core.checkAgentsInstalled,
|
||||
agentInstallCheck.checkAgentsInstalled,
|
||||
'core.checkAgentsInstalled must be the same function reference as agentInstallCheck.checkAgentsInstalled'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. getAgentsDir behaviour ────────────────────────────────────────────────
|
||||
|
||||
describe('getAgentsDir', () => {
|
||||
test('GSD_AGENTS_DIR override takes priority', () => {
|
||||
process.env['GSD_AGENTS_DIR'] = '/tmp/x';
|
||||
assert.strictEqual(agentInstallCheck.getAgentsDir(), '/tmp/x');
|
||||
assert.strictEqual(agentInstallCheck.getAgentsDir('cursor'), '/tmp/x');
|
||||
});
|
||||
|
||||
test('claude runtime returns __dirname-relative path', () => {
|
||||
// getAgentsDir('claude') from both module and core must agree
|
||||
const fromModule = agentInstallCheck.getAgentsDir('claude');
|
||||
const fromCore = core.getAgentsDir('claude');
|
||||
assert.strictEqual(fromModule, fromCore);
|
||||
// Should end with /agents
|
||||
assert.ok(fromModule.endsWith(path.sep + 'agents') || fromModule.endsWith('/agents'),
|
||||
`Expected path to end with /agents, got: ${fromModule}`);
|
||||
});
|
||||
|
||||
test('non-claude runtime returns getGlobalConfigDir(runtime)/agents', () => {
|
||||
const runtime = 'cursor';
|
||||
const expected = path.join(getGlobalConfigDir(runtime), 'agents');
|
||||
assert.strictEqual(agentInstallCheck.getAgentsDir(runtime), expected);
|
||||
});
|
||||
|
||||
test('GSD_RUNTIME env var is respected when no argument provided', () => {
|
||||
process.env['GSD_RUNTIME'] = 'codex';
|
||||
const expected = path.join(getGlobalConfigDir('codex'), 'agents');
|
||||
assert.strictEqual(agentInstallCheck.getAgentsDir(), expected);
|
||||
});
|
||||
|
||||
test('defaults to claude when no arg and no GSD_RUNTIME', () => {
|
||||
const fromModule = agentInstallCheck.getAgentsDir();
|
||||
const fromClaude = agentInstallCheck.getAgentsDir('claude');
|
||||
assert.strictEqual(fromModule, fromClaude);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 3. checkAgentsInstalled behaviour ───────────────────────────────────────
|
||||
|
||||
describe('checkAgentsInstalled', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-agent-check-');
|
||||
// Point GSD_AGENTS_DIR at a path we control
|
||||
process.env['GSD_AGENTS_DIR'] = path.join(tmpDir, 'agents');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('missing dir → agents_installed:false, missing_agents = all expected', () => {
|
||||
// agents dir does not exist
|
||||
const result = agentInstallCheck.checkAgentsInstalled();
|
||||
assert.strictEqual(result.agents_installed, false);
|
||||
assert.deepStrictEqual(result.missing_agents, EXPECTED_AGENTS);
|
||||
assert.deepStrictEqual(result.installed_agents, []);
|
||||
assert.deepStrictEqual(result.incomplete_agents, []);
|
||||
});
|
||||
|
||||
test('existing-but-empty dir → installed_agents:[], agents_installed:false', () => {
|
||||
const agentsDir = path.join(tmpDir, 'agents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
|
||||
const result = agentInstallCheck.checkAgentsInstalled();
|
||||
assert.strictEqual(result.agents_installed, false);
|
||||
assert.deepStrictEqual(result.installed_agents, []);
|
||||
assert.ok(result.missing_agents.length > 0, 'missing_agents should not be empty');
|
||||
// No manifest → completeness skipped
|
||||
assert.deepStrictEqual(result.incomplete_agents, []);
|
||||
});
|
||||
|
||||
test('all agents present, no manifest → agents_installed:true, incomplete_agents:[]', () => {
|
||||
const agentsDir = path.join(tmpDir, 'agents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
// Write all expected agent .md files
|
||||
for (const agent of EXPECTED_AGENTS) {
|
||||
fs.writeFileSync(path.join(agentsDir, `${agent}.md`), `# ${agent}\n`);
|
||||
}
|
||||
|
||||
const result = agentInstallCheck.checkAgentsInstalled();
|
||||
assert.strictEqual(result.agents_installed, true);
|
||||
assert.deepStrictEqual(result.missing_agents, []);
|
||||
assert.deepStrictEqual(result.installed_agents, EXPECTED_AGENTS);
|
||||
assert.deepStrictEqual(result.incomplete_agents, []);
|
||||
});
|
||||
|
||||
test('partial manifest: agent.toml absent but agent.md present → incomplete_agents includes agent', () => {
|
||||
const agentsDir = path.join(tmpDir, 'agents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
|
||||
// Write all agent .md files so presence check passes
|
||||
for (const agent of EXPECTED_AGENTS) {
|
||||
fs.writeFileSync(path.join(agentsDir, `${agent}.md`), `# ${agent}\n`);
|
||||
}
|
||||
|
||||
// Pick the first expected agent to make "incomplete" via manifest
|
||||
const targetAgent = EXPECTED_AGENTS[0];
|
||||
|
||||
// Write manifest that tracks agent.toml for targetAgent (absent on disk)
|
||||
// and tracks agent.md for all others (present)
|
||||
const manifestFiles = {};
|
||||
for (const agent of EXPECTED_AGENTS) {
|
||||
manifestFiles[`agents/${agent}.md`] = {};
|
||||
}
|
||||
// Add a .toml for targetAgent to manifest (not present on disk)
|
||||
manifestFiles[`agents/${targetAgent}.toml`] = {};
|
||||
|
||||
const manifest = { files: manifestFiles };
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'gsd-file-manifest.json'),
|
||||
JSON.stringify(manifest)
|
||||
);
|
||||
|
||||
const result = agentInstallCheck.checkAgentsInstalled();
|
||||
assert.ok(result.incomplete_agents.includes(targetAgent),
|
||||
`Expected ${targetAgent} in incomplete_agents, got: ${JSON.stringify(result.incomplete_agents)}`);
|
||||
assert.strictEqual(result.agents_installed, false,
|
||||
'agents_installed must be false when any agent is incomplete');
|
||||
});
|
||||
|
||||
test('malformed manifest → no throw, completeness skipped (incomplete_agents:[])', () => {
|
||||
const agentsDir = path.join(tmpDir, 'agents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
// Write all agent files
|
||||
for (const agent of EXPECTED_AGENTS) {
|
||||
fs.writeFileSync(path.join(agentsDir, `${agent}.md`), `# ${agent}\n`);
|
||||
}
|
||||
|
||||
// Write malformed manifest
|
||||
fs.writeFileSync(path.join(tmpDir, 'gsd-file-manifest.json'), '{not json"');
|
||||
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = agentInstallCheck.checkAgentsInstalled();
|
||||
});
|
||||
// Malformed → completeness skipped → incomplete_agents empty
|
||||
assert.deepStrictEqual(result.incomplete_agents, []);
|
||||
// But presence check still passed
|
||||
assert.strictEqual(result.agents_installed, true);
|
||||
});
|
||||
|
||||
test('agents_dir and agent_runtime are returned in result', () => {
|
||||
const agentsDir = path.join(tmpDir, 'agents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
|
||||
const result = agentInstallCheck.checkAgentsInstalled('cursor');
|
||||
// GSD_AGENTS_DIR overrides, so agents_dir = our tmp path
|
||||
assert.strictEqual(result.agents_dir, agentsDir);
|
||||
assert.strictEqual(result.agent_runtime, 'cursor');
|
||||
});
|
||||
});
|
||||
@@ -64,14 +64,13 @@ const {
|
||||
resolveModelPolicy,
|
||||
resolveModelForTier,
|
||||
KNOWN_PROVIDERS,
|
||||
_resetRuntimeWarningCacheForTests,
|
||||
} = require('../gsd-core/bin/lib/core.cjs');
|
||||
|
||||
// KNOWN_PROVIDERS must also be exported directly from model-catalog.cjs
|
||||
const modelCatalog = require('../gsd-core/bin/lib/model-catalog.cjs');
|
||||
|
||||
const { isValidConfigKey } = require('../gsd-core/bin/lib/config-schema.cjs');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { createTempDir, cleanup, resetRuntimeWarningCaches } = require('./helpers.cjs');
|
||||
|
||||
const makeTmp = (prefix) => createTempDir(`gsd-49-${prefix}-`);
|
||||
|
||||
@@ -277,11 +276,11 @@ describe('#49 resolveModelInternal: model_policy in the resolution chain', () =>
|
||||
let projectDir;
|
||||
beforeEach(() => {
|
||||
projectDir = makeTmp('internal');
|
||||
_resetRuntimeWarningCacheForTests();
|
||||
resetRuntimeWarningCaches();
|
||||
});
|
||||
afterEach(() => {
|
||||
rmr(projectDir);
|
||||
_resetRuntimeWarningCacheForTests();
|
||||
resetRuntimeWarningCaches();
|
||||
});
|
||||
|
||||
test('model_policy fires before model_profile_overrides when both are set (model_policy wins)', () => {
|
||||
@@ -396,7 +395,7 @@ describe('#49 resolveModelInternal: model_policy in the resolution chain', () =>
|
||||
});
|
||||
|
||||
test('unmappable model_policy ID warns and falls back to the tier alias on claude (#1133)', () => {
|
||||
_resetRuntimeWarningCacheForTests();
|
||||
resetRuntimeWarningCaches();
|
||||
writeConfig(projectDir, {
|
||||
runtime: 'claude',
|
||||
model_profile: 'balanced',
|
||||
@@ -527,7 +526,7 @@ describe('#49 resolveModelInternal: unknown provider warning behavior', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
projectDir = makeTmp('warnings');
|
||||
_resetRuntimeWarningCacheForTests();
|
||||
resetRuntimeWarningCaches();
|
||||
captured = [];
|
||||
origWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = (chunk) => { captured.push(String(chunk)); return true; };
|
||||
@@ -536,7 +535,7 @@ describe('#49 resolveModelInternal: unknown provider warning behavior', () => {
|
||||
afterEach(() => {
|
||||
process.stderr.write = origWrite;
|
||||
rmr(projectDir);
|
||||
_resetRuntimeWarningCacheForTests();
|
||||
resetRuntimeWarningCaches();
|
||||
});
|
||||
|
||||
test('unknown provider in model_policy → falls through to model_profile_overrides, emits stderr warning once', () => {
|
||||
|
||||
@@ -259,3 +259,42 @@ describe('#1146: git.base-branch resolver', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── gitWorktreeInfoInternal: relocation identity + behaviour (#1268 T0) ─────
|
||||
|
||||
const gitBaseBranch = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'git-base-branch.cjs'));
|
||||
const core = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'core.cjs'));
|
||||
const { createTempGitProject, createTempDir } = require('./helpers.cjs');
|
||||
|
||||
describe('#1268 gitWorktreeInfoInternal: relocation to git-base-branch', () => {
|
||||
test('core.gitWorktreeInfoInternal === gitBaseBranch.gitWorktreeInfoInternal (by reference)', () => {
|
||||
assert.strictEqual(
|
||||
core.gitWorktreeInfoInternal,
|
||||
gitBaseBranch.gitWorktreeInfoInternal,
|
||||
'core.gitWorktreeInfoInternal must be the same function reference as gitBaseBranch.gitWorktreeInfoInternal'
|
||||
);
|
||||
});
|
||||
|
||||
test('gitWorktreeInfoInternal(createTempGitProject()) returns {inside:true, worktreeRoot:<non-empty string>}', (t) => {
|
||||
const dir = createTempGitProject('gsd-wt-info-');
|
||||
t.after(() => cleanup(dir));
|
||||
const result = gitBaseBranch.gitWorktreeInfoInternal(dir);
|
||||
assert.strictEqual(result.inside, true, 'inside must be true for a git project dir');
|
||||
assert.ok(typeof result.worktreeRoot === 'string' && result.worktreeRoot.length > 0,
|
||||
`worktreeRoot must be a non-empty string, got: ${JSON.stringify(result.worktreeRoot)}`);
|
||||
});
|
||||
|
||||
test('gitWorktreeInfoInternal(createTempDir()) returns {inside:false, worktreeRoot:null} for a non-git dir', (t) => {
|
||||
const dir = createTempDir('gsd-wt-info-nongit-');
|
||||
t.after(() => cleanup(dir));
|
||||
const result = gitBaseBranch.gitWorktreeInfoInternal(dir);
|
||||
assert.strictEqual(result.inside, false, 'inside must be false for a non-git dir');
|
||||
assert.strictEqual(result.worktreeRoot, null, 'worktreeRoot must be null for a non-git dir');
|
||||
});
|
||||
|
||||
test('gitWorktreeInfoInternal never throws (non-git dir)', (t) => {
|
||||
const dir = createTempDir('gsd-wt-info-nothrow-');
|
||||
t.after(() => cleanup(dir));
|
||||
assert.doesNotThrow(() => gitBaseBranch.gitWorktreeInfoInternal(dir));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -399,4 +399,18 @@ async function waitFor(predicate, { timeoutMs = 10000, stepMs = 25, message = 'w
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, TOOLS_PATH };
|
||||
/**
|
||||
* Reset all runtime-warning caches in config-loader.cjs and model-resolver.cjs.
|
||||
*
|
||||
* Use this in beforeEach/afterEach hooks in tests that exercise warning-emission
|
||||
* paths so that each test starts with a clean slate. Replaces the duplicated local
|
||||
* `_resetRuntimeWarningCacheForTests` wrappers in individual test files.
|
||||
*/
|
||||
function resetRuntimeWarningCaches() {
|
||||
const configLoader = require('../gsd-core/bin/lib/config-loader.cjs');
|
||||
const modelResolver = require('../gsd-core/bin/lib/model-resolver.cjs');
|
||||
configLoader._resetRuntimeWarningCacheForTests();
|
||||
modelResolver._resetModelPolicyWarningCacheForTests();
|
||||
}
|
||||
|
||||
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, TOOLS_PATH };
|
||||
|
||||
@@ -30,7 +30,7 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||||
const { createTempProject, cleanup, resetRuntimeWarningCaches } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
resolveModelInternal,
|
||||
@@ -38,7 +38,6 @@ const {
|
||||
resolveTierEntry,
|
||||
RUNTIME_PROFILE_MAP,
|
||||
KNOWN_RUNTIMES,
|
||||
_resetRuntimeWarningCacheForTests,
|
||||
} = require('../gsd-core/bin/lib/core.cjs');
|
||||
const { renderEffortForRuntime } = require('../gsd-core/bin/lib/model-catalog.cjs');
|
||||
const { isValidConfigKey } = require('../gsd-core/bin/lib/config-schema.cjs');
|
||||
@@ -75,7 +74,7 @@ function restoreHome() {
|
||||
// ─── Backwards compatibility — no `runtime` set ─────────────────────────────
|
||||
describe('issue #2517: backwards compat — no runtime key set', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); });
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); });
|
||||
afterEach(() => { cleanup(tmpDir); restoreHome(); });
|
||||
|
||||
test('balanced profile returns Claude alias when runtime absent', () => {
|
||||
@@ -117,7 +116,7 @@ describe('issue #2517: backwards compat — no runtime key set', () => {
|
||||
// ─── runtime: "claude" — no-op (preserves Claude-native semantics) ──────────
|
||||
describe('issue #2517: runtime "claude" is a no-op for resolution (finding #4)', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); });
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); });
|
||||
afterEach(() => { cleanup(tmpDir); restoreHome(); });
|
||||
|
||||
test('runtime:"claude" + balanced returns the alias, not the resolved Claude ID', () => {
|
||||
@@ -162,7 +161,7 @@ describe('issue #2517: runtime "claude" is a no-op for resolution (finding #4)',
|
||||
// ─── runtime: "codex" — resolves tiers to Codex IDs + reasoning_effort ──────
|
||||
describe('issue #2517: runtime "codex" — Codex tier resolution', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); });
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); });
|
||||
afterEach(() => { cleanup(tmpDir); restoreHome(); });
|
||||
|
||||
test('opus tier -> gpt-5.5 model; heavy-tier agent -> xhigh effort on codex', () => {
|
||||
@@ -228,7 +227,7 @@ describe('issue #2517: runtime "codex" — Codex tier resolution', () => {
|
||||
// ─── Precedence chain ───────────────────────────────────────────────────────
|
||||
describe('issue #2517: precedence chain', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); });
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); });
|
||||
afterEach(() => { cleanup(tmpDir); restoreHome(); });
|
||||
|
||||
test('per-agent model_overrides wins over runtime tier resolution', () => {
|
||||
@@ -283,7 +282,7 @@ describe('issue #2517: precedence chain', () => {
|
||||
// ─── Field-merge semantics — review findings #2 ─────────────────────────────
|
||||
describe('issue #2517: field-merge of overrides with built-in defaults (finding #2)', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); });
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); });
|
||||
afterEach(() => { cleanup(tmpDir); restoreHome(); });
|
||||
|
||||
test('string-shorthand override: model is overridden; unified effort derives from routing tier', () => {
|
||||
@@ -366,7 +365,7 @@ describe('issue #2517: field-merge of overrides with built-in defaults (finding
|
||||
// ─── Unknown runtime render safety (finding #3 spirit) ──────────────────────
|
||||
describe('issue #2517: unknown runtime render param is null (effort does not leak to install path)', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); });
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); });
|
||||
afterEach(() => { cleanup(tmpDir); restoreHome(); });
|
||||
|
||||
test('unknown runtime: model resolves via override; render param is null (no wire param leaked)', () => {
|
||||
@@ -402,7 +401,7 @@ describe('issue #2517: unknown runtime render param is null (effort does not lea
|
||||
// ─── Unknown runtime / unknown tier ─────────────────────────────────────────
|
||||
describe('issue #2517: unknown runtime + safe fallback', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); });
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); });
|
||||
afterEach(() => { cleanup(tmpDir); restoreHome(); });
|
||||
|
||||
test('unknown runtime falls back to Claude-alias safe default (no Codex IDs leaked)', () => {
|
||||
@@ -479,7 +478,7 @@ describe('issue #2517: loadConfig warns on unknown runtime/tier (findings #10, #
|
||||
beforeEach(() => {
|
||||
isolateHome();
|
||||
tmpDir = createTempProject();
|
||||
_resetRuntimeWarningCacheForTests();
|
||||
resetRuntimeWarningCaches();
|
||||
captured = [];
|
||||
origWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = (chunk) => { captured.push(String(chunk)); return true; };
|
||||
@@ -540,7 +539,7 @@ describe('issue #2517: install end-to-end — per-project config reaches Codex T
|
||||
const { readGsdRuntimeProfileResolver, generateCodexAgentToml } = installMod;
|
||||
|
||||
let tmpDir;
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); });
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); });
|
||||
afterEach(() => { cleanup(tmpDir); restoreHome(); });
|
||||
|
||||
test('readGsdRuntimeProfileResolver picks up runtime from .planning/config.json', () => {
|
||||
@@ -640,7 +639,7 @@ describe('issue #2517: RUNTIME_PROFILE_MAP single source of truth (finding #16)'
|
||||
// ─── Issue #2612: gemini runtime tier resolution ─────────────────────────────
|
||||
describe('issue #2612: runtime "gemini" — Gemini tier resolution', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); });
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); });
|
||||
afterEach(() => { cleanup(tmpDir); restoreHome(); });
|
||||
|
||||
test('opus tier -> gemini-3.1-pro-preview', () => {
|
||||
@@ -668,7 +667,7 @@ describe('issue #2612: runtime "gemini" — Gemini tier resolution', () => {
|
||||
// ─── Issue #2612: qwen runtime tier resolution ───────────────────────────────
|
||||
describe('issue #2612: runtime "qwen" — Qwen tier resolution', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); });
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); });
|
||||
afterEach(() => { cleanup(tmpDir); restoreHome(); });
|
||||
|
||||
test('opus tier -> qwen3-max-2026-01-23', () => {
|
||||
@@ -696,7 +695,7 @@ describe('issue #2612: runtime "qwen" — Qwen tier resolution', () => {
|
||||
// ─── Issue #2612: opencode runtime tier resolution ───────────────────────────
|
||||
describe('issue #2612: runtime "opencode" — OpenCode tier resolution', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); });
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); });
|
||||
afterEach(() => { cleanup(tmpDir); restoreHome(); });
|
||||
|
||||
test('opus tier -> anthropic/claude-opus-4-8', () => {
|
||||
@@ -724,7 +723,7 @@ describe('issue #2612: runtime "opencode" — OpenCode tier resolution', () => {
|
||||
// ─── Issue #2612: copilot runtime tier resolution ────────────────────────────
|
||||
describe('issue #2612: runtime "copilot" — Copilot tier resolution', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); });
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); });
|
||||
afterEach(() => { cleanup(tmpDir); restoreHome(); });
|
||||
|
||||
test('opus tier -> claude-opus-4-8', () => {
|
||||
@@ -787,7 +786,7 @@ describe('issue #2612: Group B runtimes — no built-in map, use unknown-runtime
|
||||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||||
isolateHome();
|
||||
const tmpDir = createTempProject();
|
||||
_resetRuntimeWarningCacheForTests();
|
||||
resetRuntimeWarningCaches();
|
||||
try {
|
||||
writeConfig(tmpDir, { runtime: 'cursor', model_profile: 'quality' });
|
||||
// Should fall back to Claude alias, not emit a provider-specific ID
|
||||
@@ -803,7 +802,7 @@ describe('issue #2612: Group B runtimes — no built-in map, use unknown-runtime
|
||||
// ─── Issue #2612: Partial override merge for new runtimes ────────────────────
|
||||
describe('issue #2612: partial override merge for new Group A runtimes', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); });
|
||||
beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); });
|
||||
afterEach(() => { cleanup(tmpDir); restoreHome(); });
|
||||
|
||||
test('gemini.opus override wins; sonnet and haiku use built-in defaults', () => {
|
||||
|
||||
279
tests/lint-core-spine-imports.test.cjs
Normal file
279
tests/lint-core-spine-imports.test.cjs
Normal file
@@ -0,0 +1,279 @@
|
||||
'use strict';
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Tests for scripts/lint-core-spine-imports.cjs (issue #1268).
|
||||
*
|
||||
* Behavioural, no source-grep. Asserts on structured return values from the
|
||||
* exported pure functions and on process exit codes from the CLI.
|
||||
*/
|
||||
|
||||
const { test, describe, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const SCRIPT_PATH = path.join(ROOT, 'scripts', 'lint-core-spine-imports.cjs');
|
||||
const ALLOWLIST_PATH = path.join(ROOT, 'scripts', 'lint-core-spine-imports.allowlist.json');
|
||||
|
||||
const { scanCoreSpineImports, loadAllowlist } = require(SCRIPT_PATH);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure-function tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('lint-core-spine-imports: scanCoreSpineImports (pure)', () => {
|
||||
let tmpDir;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
tmpDir = undefined;
|
||||
});
|
||||
|
||||
test('happy path: allowlisted importer is not a violation', () => {
|
||||
tmpDir = createTempDir('lint-spine-test-');
|
||||
|
||||
// File that imports the core spine
|
||||
const importerPath = path.join(tmpDir, 'importer.cts');
|
||||
fs.writeFileSync(importerPath, [
|
||||
"import core = require('./core.cjs');",
|
||||
'export = { x: 1 };',
|
||||
].join('\n'), 'utf8');
|
||||
|
||||
// File that does NOT import the core spine
|
||||
const cleanPath = path.join(tmpDir, 'clean.cts');
|
||||
fs.writeFileSync(cleanPath, [
|
||||
"import io = require('./io.cjs');",
|
||||
'export = { y: 2 };',
|
||||
].join('\n'), 'utf8');
|
||||
|
||||
// Allowlist contains the importer's repo-relative path
|
||||
const importerRel = path.relative(ROOT, importerPath).replace(/\\/g, '/');
|
||||
const allowlistSet = new Set([importerRel]);
|
||||
|
||||
const violations = scanCoreSpineImports([tmpDir], allowlistSet);
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
|
||||
test('violation path: non-allowlisted importer is returned with correct file and 1-based line', () => {
|
||||
tmpDir = createTempDir('lint-spine-test-');
|
||||
|
||||
// File with a blank first line, then the core import on line 2
|
||||
const importerPath = path.join(tmpDir, 'new-squatter.cts');
|
||||
fs.writeFileSync(importerPath, [
|
||||
'// a comment',
|
||||
"import core = require('./core.cjs');",
|
||||
'export = {};',
|
||||
].join('\n'), 'utf8');
|
||||
|
||||
// Empty allowlist — importer is NOT allowed
|
||||
const violations = scanCoreSpineImports([tmpDir], new Set());
|
||||
|
||||
assert.equal(violations.length, 1, 'expected exactly one violation');
|
||||
const v = violations[0];
|
||||
// File path is repo-relative POSIX
|
||||
const expectedRel = path.relative(ROOT, importerPath).replace(/\\/g, '/');
|
||||
assert.equal(v.file, expectedRel);
|
||||
// Line 2 (1-based): the second line contains the require
|
||||
assert.equal(v.line, 2);
|
||||
});
|
||||
|
||||
test('require("../lib/core.cjs") form is detected', () => {
|
||||
tmpDir = createTempDir('lint-spine-test-');
|
||||
|
||||
const f = path.join(tmpDir, 'leaf.cjs');
|
||||
fs.writeFileSync(f, 'const core = require("../lib/core.cjs");\n', 'utf8');
|
||||
|
||||
const violations = scanCoreSpineImports([tmpDir], new Set());
|
||||
assert.equal(violations.length, 1);
|
||||
assert.equal(violations[0].line, 1);
|
||||
});
|
||||
|
||||
test('require("./lib/core.cjs") form is detected', () => {
|
||||
tmpDir = createTempDir('lint-spine-test-');
|
||||
|
||||
const f = path.join(tmpDir, 'leaf.cjs');
|
||||
fs.writeFileSync(f, 'const core = require("./lib/core.cjs");\n', 'utf8');
|
||||
|
||||
const violations = scanCoreSpineImports([tmpDir], new Set());
|
||||
assert.equal(violations.length, 1);
|
||||
});
|
||||
|
||||
test('clean file (no spine import) is never a violation', () => {
|
||||
tmpDir = createTempDir('lint-spine-test-');
|
||||
|
||||
const f = path.join(tmpDir, 'pure.cts');
|
||||
fs.writeFileSync(f, [
|
||||
"import io = require('./io.cjs');",
|
||||
"import roadmap = require('./roadmap-parser.cjs');",
|
||||
].join('\n'), 'utf8');
|
||||
|
||||
const violations = scanCoreSpineImports([tmpDir], new Set());
|
||||
assert.deepEqual(violations, []);
|
||||
});
|
||||
|
||||
test('commented-out core require is NOT a violation', () => {
|
||||
tmpDir = createTempDir('lint-spine-test-');
|
||||
|
||||
const f = path.join(tmpDir, 'commented.cts');
|
||||
fs.writeFileSync(f, [
|
||||
"// import core = require('./core.cjs');",
|
||||
"// const core = require('../lib/core.cjs');",
|
||||
"import io = require('./io.cjs');",
|
||||
].join('\n'), 'utf8');
|
||||
|
||||
const violations = scanCoreSpineImports([tmpDir], new Set());
|
||||
assert.deepEqual(violations, [], 'commented-out requires should not count as violations');
|
||||
});
|
||||
|
||||
test('unconventional relative path ../lib/core.cjs IS detected', () => {
|
||||
tmpDir = createTempDir('lint-spine-test-');
|
||||
|
||||
const f = path.join(tmpDir, 'deep-leaf.cjs');
|
||||
fs.writeFileSync(f, "const core = require('../lib/core.cjs');\n", 'utf8');
|
||||
|
||||
const violations = scanCoreSpineImports([tmpDir], new Set());
|
||||
assert.equal(violations.length, 1, 'expected exactly one violation for ../lib/core.cjs');
|
||||
assert.equal(violations[0].line, 1);
|
||||
});
|
||||
|
||||
test('core-utils.cjs and core-schema.cjs are NOT detected as core spine imports', () => {
|
||||
tmpDir = createTempDir('lint-spine-test-');
|
||||
|
||||
const f = path.join(tmpDir, 'sibling.cts');
|
||||
fs.writeFileSync(f, [
|
||||
"import coreUtils = require('./core-utils.cjs');",
|
||||
"import coreSchema = require('./core-schema.cjs');",
|
||||
].join('\n'), 'utf8');
|
||||
|
||||
const violations = scanCoreSpineImports([tmpDir], new Set());
|
||||
assert.deepEqual(violations, [], 'core-utils.cjs and core-schema.cjs should not match');
|
||||
});
|
||||
|
||||
test('dynamic import("./core.cjs") form is detected', () => {
|
||||
tmpDir = createTempDir('lint-spine-test-');
|
||||
|
||||
const f = path.join(tmpDir, 'dyn-importer.cjs');
|
||||
fs.writeFileSync(f, [
|
||||
'// dynamic import form',
|
||||
"const core = await import('./core.cjs');",
|
||||
].join('\n'), 'utf8');
|
||||
|
||||
const violations = scanCoreSpineImports([tmpDir], new Set());
|
||||
assert.equal(violations.length, 1, 'expected exactly one violation for dynamic import');
|
||||
assert.equal(violations[0].line, 2);
|
||||
});
|
||||
|
||||
test('bare side-effect import \'./core.cjs\' form is detected', () => {
|
||||
tmpDir = createTempDir('lint-spine-test-');
|
||||
|
||||
const f = path.join(tmpDir, 'side-effect-importer.cjs');
|
||||
fs.writeFileSync(f, [
|
||||
'// bare side-effect import form',
|
||||
"import './core.cjs';",
|
||||
].join('\n'), 'utf8');
|
||||
|
||||
const violations = scanCoreSpineImports([tmpDir], new Set());
|
||||
assert.equal(violations.length, 1, 'expected exactly one violation for bare side-effect import');
|
||||
assert.equal(violations[0].line, 2);
|
||||
});
|
||||
|
||||
test('TS import x = require("./core.cjs") form is still flagged', () => {
|
||||
tmpDir = createTempDir('lint-spine-test-');
|
||||
|
||||
const f = path.join(tmpDir, 'ts-require.cts');
|
||||
fs.writeFileSync(f, "import core = require('./core.cjs');\n", 'utf8');
|
||||
|
||||
const violations = scanCoreSpineImports([tmpDir], new Set());
|
||||
assert.equal(violations.length, 1, 'TS import = require() should still be flagged');
|
||||
assert.equal(violations[0].line, 1);
|
||||
});
|
||||
|
||||
test('import of core-utils.cjs (sibling) is NOT flagged even with new alternations', () => {
|
||||
tmpDir = createTempDir('lint-spine-test-');
|
||||
|
||||
const f = path.join(tmpDir, 'sibling-check.cjs');
|
||||
fs.writeFileSync(f, [
|
||||
"const u = require('./core-utils.cjs');",
|
||||
"const cu = await import('./core-utils.cjs');",
|
||||
"import './core-utils.cjs';",
|
||||
].join('\n'), 'utf8');
|
||||
|
||||
const violations = scanCoreSpineImports([tmpDir], new Set());
|
||||
assert.deepEqual(violations, [], 'core-utils.cjs imports should not match even with new alternations');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// loadAllowlist
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('lint-core-spine-imports: loadAllowlist', () => {
|
||||
let tmpDir;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
tmpDir = undefined;
|
||||
});
|
||||
|
||||
test('returns a Set of the allow entries', () => {
|
||||
tmpDir = createTempDir('lint-spine-al-');
|
||||
const jsonPath = path.join(tmpDir, 'allowlist.json');
|
||||
fs.writeFileSync(jsonPath, JSON.stringify({ allow: ['src/foo.cts', 'src/bar.cts'] }), 'utf8');
|
||||
|
||||
const s = loadAllowlist(jsonPath);
|
||||
assert.ok(s instanceof Set);
|
||||
assert.ok(s.has('src/foo.cts'));
|
||||
assert.ok(s.has('src/bar.cts'));
|
||||
assert.equal(s.size, 2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T0-green / allowlist-completeness guard (real repo)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('lint-core-spine-imports: T0 allowlist covers all current importers', () => {
|
||||
test('scanCoreSpineImports against real src/ + gsd-core/bin/ returns [] with the shipped allowlist', () => {
|
||||
const roots = [
|
||||
path.join(ROOT, 'src'),
|
||||
path.join(ROOT, 'gsd-core', 'bin'),
|
||||
];
|
||||
const allowlistSet = loadAllowlist(ALLOWLIST_PATH);
|
||||
const violations = scanCoreSpineImports(roots, allowlistSet);
|
||||
|
||||
assert.deepEqual(
|
||||
violations,
|
||||
[],
|
||||
'T0 allowlist is incomplete — new core-spine importers detected:\n' +
|
||||
violations.map((v) => ` ${v.file}:${v.line}`).join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI contract
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('lint-core-spine-imports: CLI', () => {
|
||||
test('clean repo → exit 0 and stdout contains the structured ok line', () => {
|
||||
const result = spawnSync(process.execPath, [SCRIPT_PATH], {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`expected exit 0 but got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.stdout.includes('ok core-spine-imports:'),
|
||||
`stdout should contain "ok core-spine-imports:", got: ${result.stdout}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,10 +18,14 @@
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { createTempGitProject, createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const WORKTREE_SAFETY_PATH = path.join(
|
||||
__dirname, '..', 'gsd-core', 'bin', 'lib', 'worktree-safety.cjs'
|
||||
);
|
||||
const CORE_PATH = path.join(
|
||||
__dirname, '..', 'gsd-core', 'bin', 'lib', 'core.cjs'
|
||||
);
|
||||
|
||||
const {
|
||||
resolveWorktreeContext,
|
||||
@@ -1243,3 +1247,52 @@ describe('executeWorktreeWaveCleanupPlan', () => {
|
||||
assert.equal(calls.some((call) => call === 'branch -D worktree-agent-a1'), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── MOVE 2: resolveWorktreeRoot and pruneOrphanedWorktrees (#1268 T0) ────────
|
||||
|
||||
describe('worktree-safety: resolveWorktreeRoot and pruneOrphanedWorktrees relocation identity', () => {
|
||||
const worktreeSafety = require(WORKTREE_SAFETY_PATH);
|
||||
const core = require(CORE_PATH);
|
||||
|
||||
test('core.resolveWorktreeRoot === worktreeSafety.resolveWorktreeRoot (by reference)', () => {
|
||||
assert.strictEqual(
|
||||
core.resolveWorktreeRoot,
|
||||
worktreeSafety.resolveWorktreeRoot,
|
||||
'core.resolveWorktreeRoot must be the same function reference as worktreeSafety.resolveWorktreeRoot'
|
||||
);
|
||||
});
|
||||
|
||||
test('core.pruneOrphanedWorktrees === worktreeSafety.pruneOrphanedWorktrees (by reference)', () => {
|
||||
assert.strictEqual(
|
||||
core.pruneOrphanedWorktrees,
|
||||
worktreeSafety.pruneOrphanedWorktrees,
|
||||
'core.pruneOrphanedWorktrees must be the same function reference as worktreeSafety.pruneOrphanedWorktrees'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('worktree-safety: resolveWorktreeRoot behaviour', () => {
|
||||
const worktreeSafety = require(WORKTREE_SAFETY_PATH);
|
||||
|
||||
test('resolveWorktreeRoot(createTempGitProject()) returns a non-empty string', (t) => {
|
||||
const dir = createTempGitProject('gsd-wt-root-');
|
||||
t.after(() => cleanup(dir));
|
||||
const result = worktreeSafety.resolveWorktreeRoot(dir);
|
||||
assert.ok(typeof result === 'string' && result.length > 0,
|
||||
`Expected non-empty string, got: ${JSON.stringify(result)}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('worktree-safety: pruneOrphanedWorktrees behaviour', () => {
|
||||
const worktreeSafety = require(WORKTREE_SAFETY_PATH);
|
||||
|
||||
test('pruneOrphanedWorktrees(temp dir) returns [] and does not throw', (t) => {
|
||||
const dir = createTempDir('gsd-prune-');
|
||||
t.after(() => cleanup(dir));
|
||||
let result;
|
||||
assert.doesNotThrow(() => {
|
||||
result = worktreeSafety.pruneOrphanedWorktrees(dir);
|
||||
});
|
||||
assert.deepStrictEqual(result, []);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user