* test(#3145): bound the installer/runtime cluster onto the process seam Migrates 156 unbounded sync spawn sites across 47 files. Allowlist 120 to 73. Timeouts are sized from evidence already in the tree rather than a house default, because this wave spawns installers rather than git plumbing and an undersized bound does not catch a hang -- it manufactures CI flake, which is worse, since a flake gets re-run instead of investigated. install.test.cjs records a real spawnSync ETIMEDOUT at a 60000ms cap on a loaded bench while another lane passed the same commit in 12.7s, so full installs are bound at 120000ms against that recorded incident. Also adds an auditable escape to the guard's timeout ceiling. The 600000ms cap was set in #3143 from partial evidence, but fragment-single-edit- propagation carries a documented, load-tested 900000ms bound on a run that chains a full build plus eight generators -- the guard would have rejected a correct timeout the moment that file left the allowlist. A value above the ceiling is now permitted only with an inline allow-spawn-timeout-ceiling marker carrying a non-empty reason. It raises the ceiling; it never waives the requirement for a bound, which is asserted directly. install-shared.cjs keeps its hand-rolled assert rather than routing through throwIfFailed: its message embeds both streams, and throwIfFailed carries only a trimmed stderr. The message now also names the outcome, so a bounded timeout reads as such across its 38 importers instead of as expected null to equal 0. * test(#3145): extract class-norm timeouts and correct the build-hooks sizing A pre-PR review found 52 copies of four class-norm timeout constants across this wave. These are not per-suite fixture bindings -- they are shared facts about how long a class of subprocess takes, derived from a recorded bench incident. That norm already moved once (60000 to 120000 after a real ETIMEDOUT), and 52 copies would have drifted the next time it moved. Extracts tests/helpers/timeouts.cjs, where each norm is justified once, and converts the copies. A site that genuinely differs -- a real tsc compile, or regen:derived -- keeps its own local constant with its own justification. Also corrects a misclassification: scripts/build-hooks.js was sized as a build at 120000 in twelve places and 60000 in another, but it compiles and bundles nothing. Its own header says no bundling needed; it copies pre-built files and syntax-checks them with vm. Three different values bounded one script; now there is one. * test(#3145): fix red CI — lint self-match and a Windows chunk overrun Two failures on PR 3176. lint-allow-test-rule-refs read a RuleTester fixture as a real exemption. The fixture exists to prove an unrelated marker does NOT suppress the rule, so it carries that marker's literal text as test data. Split via concatenation, the same idiom no-unbounded-spawn-allowlist.test.cjs already uses for its own self-match problem. The explanatory comment needed the same treatment. The Windows shard 3/3 chunk was killed at its 600000ms budget. Output stopped seven minutes before the kill, so this was an overrun rather than a slow chunk: regenDerivedPropagatesSingleFragmentEditWithNoSecondSourceSurface runs regen:derived bounded at 900000ms, which is larger than the whole chunk budget, so the chunk killer always fires first and it can never complete there. Both the test and that bound predate this change; modifying the file pulled it into the Windows targeted set and exposed it. Skipped on Windows with the reason recorded; the Linux lanes cover it. The 900000 bound and its ceiling marker are unchanged -- they are correct. * test(#3145): refresh the stale test-timings cost table The Windows shard was killed at its 600000ms per-chunk budget. run-tests.cjs packs chunks by measured duration from tests/test-timings.json, and an unknown file falls back to the table's median weight -- advisory by design, but it silently underweights exactly the files that matter. Four of the failing chunk's 22 files were absent from the table, including the two heaviest: fragment-single-edit-propagation.install.test.cjs at 230s (it runs regen:derived) and agent-fragments-emission.install.test.cjs at 79s. Both were weighted as average, so the chunk's total weight read 53.68 against a budget of 60 and the packer produced a single chunk. Regenerated from a passing full-suite run, per the remedy the script itself documents. 700 to 770 entries, 70 added, 0 dropped -- verified, since gen-test-timings.cjs replaces the table wholesale rather than merging. Proven against the real packer: the same 22 files now weigh 103.91 and split into two chunks. No logic, budget, or timeout was changed; raising a budget to make a red gate pass is not a fix. --------- Co-authored-by: sim <sim@local>
652 lines
27 KiB
JavaScript
652 lines
27 KiB
JavaScript
/**
|
|
* Phase 4 installer migration integration tests.
|
|
*
|
|
* These exercise the public install() entry point so the migration runner is
|
|
* pinned at the install/update seam, not just as a standalone library.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
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 crypto = require('node:crypto');
|
|
|
|
const { runNode } = require('./helpers/process-seam.cjs');
|
|
|
|
const installModule = require('../bin/install.js');
|
|
const pkg = require('../package.json');
|
|
const { install } = installModule;
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
|
|
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
|
|
const { INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
|
|
const installScript = path.join(__dirname, '..', 'bin', 'install.js');
|
|
const SUPPORTED_RUNTIMES = installModule.allRuntimes;
|
|
const RUNTIME_INSTALL_CONTRACTS = {
|
|
claude: { surface: 'flat-skills', settings: true, hooksPackageJson: true },
|
|
antigravity: { surface: 'flat-skills', settings: true, hooksPackageJson: true },
|
|
augment: { surface: 'flat-skills', settings: true, hooksPackageJson: true },
|
|
cline: { surface: 'clinerules', settings: false, hooksPackageJson: false },
|
|
codebuddy: { surface: 'flat-skills', settings: true, hooksPackageJson: true },
|
|
// codex/cursor/windsurf: #2717 stages their .js hooks via dedicated paths
|
|
// (skipSharedHooksInstall / the !isCodex gate keep them out of the shared
|
|
// bundle) and writes the marker beside those scripts, so hooks/package.json
|
|
// is expected for all three. Measured on this tree: codex stages 3 .js hooks,
|
|
// cursor 6, windsurf 2 — each with the marker.
|
|
codex: { surface: 'flat-skills', settings: false, hooksPackageJson: true, codexConfig: true },
|
|
copilot: { surface: 'flat-skills', settings: false, hooksPackageJson: false, copilotInstructions: true },
|
|
cursor: { surface: 'flat-skills', settings: false, hooksPackageJson: true },
|
|
gemini: { surface: 'commands-gsd', settings: true, hooksPackageJson: true },
|
|
hermes: { surface: 'hermes-skills', settings: true, hooksPackageJson: true },
|
|
kimi: { surface: 'kimi-skills-agents', settings: false, hooksPackageJson: false },
|
|
// #2454: Kimi Code (Node CLI) has NO custom named subagents (per official
|
|
// docs), so its install surface is skills-only (flat-skills), NOT
|
|
// kimi-skills-agents. The kimi-agents YAML layout is Python kimi-cli only.
|
|
'kimi-code': { surface: 'flat-skills', settings: false, hooksPackageJson: false },
|
|
// #2305: Kilo's native plugin spawns the staged guard hooks, so it receives
|
|
// the shared hooks bundle + the CommonJS package.json marker, like OpenCode.
|
|
// (#1821 excluded Kilo on the false premise that it had no plugin surface.)
|
|
kilo: { surface: 'flat-command', settings: false, hooksPackageJson: true },
|
|
// #2329: OpenCode discovers commands from the PLURAL `commands/` dir — the
|
|
// singular `command/` (still correct for Kilo) made all /gsd-* commands
|
|
// invisible to OpenCode. commandDirName overrides the flat-command default.
|
|
opencode: { surface: 'flat-command', settings: true, hooksPackageJson: true, commandDirName: 'commands' },
|
|
// #2102 Stage 1/2: pi is a PLUGIN-ONLY install (hostBehaviors.pluginOnlyInstall)
|
|
// for commands/agents/skills — NO commands/, agents/, or skills/ dir. pi's
|
|
// /gsd command is registered programmatically by the native extension
|
|
// (extensions/gsd.cjs) and dispatches via a bounded subprocess to
|
|
// gsd-tools.cjs; it has no host-read markdown surface. Stage 2 (adversarial-
|
|
// review fix): pi's native extension DOES spawn the shared hooks/*.js bundle
|
|
// as bounded subprocesses (session_start/before_agent_start/session_before_
|
|
// compact bridges) and its /gsd tokenizer requires hooks/lib/git-cmd.js, so
|
|
// `hostBehaviors.skipSharedHooksInstall` was removed — pi now receives
|
|
// hooks/ + hooks/lib/ + the {"type":"commonjs"} package.json marker, exactly
|
|
// like OpenCode and (since #2305) Kilo — architecturally identical:
|
|
// hooksSurface:'none' + a native plugin that spawns the staged hooks — NOT
|
|
// like ZCode (no plugin surface, where the same hooks are dead weight).
|
|
pi: { surface: 'plugin-only', settings: false, hooksPackageJson: true },
|
|
qwen: { surface: 'flat-skills', settings: true, hooksPackageJson: true },
|
|
trae: { surface: 'flat-skills', settings: false, hooksPackageJson: false },
|
|
windsurf: { surface: 'global-artifacts-noop', settings: false, hooksPackageJson: true },
|
|
// #1821: ZCode (hooksSurface:none, no plugin surface) no longer receives the
|
|
// dead hook scripts or the CommonJS package.json marker.
|
|
zcode: { surface: 'flat-skills', settings: false, hooksPackageJson: false },
|
|
};
|
|
|
|
function sha256(content) {
|
|
return crypto.createHash('sha256').update(content).digest('hex');
|
|
}
|
|
|
|
function writeFile(root, relPath, content) {
|
|
const fullPath = path.join(root, relPath);
|
|
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
fs.writeFileSync(fullPath, content, 'utf8');
|
|
}
|
|
|
|
function writeManifest(root, files) {
|
|
fs.writeFileSync(
|
|
path.join(root, 'gsd-file-manifest.json'),
|
|
JSON.stringify({
|
|
version: '1.49.0',
|
|
timestamp: '2026-05-10T00:00:00.000Z',
|
|
mode: 'full',
|
|
files,
|
|
}, null, 2),
|
|
'utf8'
|
|
);
|
|
}
|
|
|
|
function withEnv(key, value, fn) {
|
|
const previous = process.env[key];
|
|
process.env[key] = value;
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
if (previous == null) delete process.env[key];
|
|
else process.env[key] = previous;
|
|
}
|
|
}
|
|
|
|
// #2088: Codex CLI skills install to `$HOME/.agents/skills` (resolved via
|
|
// os.homedir()), not `$CODEX_HOME/skills`. In-process codex installs must
|
|
// sandbox HOME (and USERPROFILE, for Windows os.homedir() resolution) to the
|
|
// test's codexHome dir, or skills get materialized into the real developer
|
|
// home directory. withEnv saves/restores a single key, so nesting is safe.
|
|
function withCodexEnv(codexHome, fn) {
|
|
return withEnv('CODEX_HOME', codexHome, () =>
|
|
withEnv('HOME', codexHome, () =>
|
|
withEnv('USERPROFILE', codexHome, fn)
|
|
)
|
|
);
|
|
}
|
|
|
|
function captureConsole(fn) {
|
|
const originalLog = console.log;
|
|
const originalWarn = console.warn;
|
|
const lines = [];
|
|
console.log = (...args) => { lines.push(args.join(' ')); };
|
|
console.warn = (...args) => { lines.push(args.join(' ')); };
|
|
try {
|
|
return { value: fn(), output: lines.join('\n') };
|
|
} finally {
|
|
console.log = originalLog;
|
|
console.warn = originalWarn;
|
|
}
|
|
}
|
|
|
|
function withWriteFailure(matchPath, fn) {
|
|
const originalWriteFileSync = fs.writeFileSync;
|
|
fs.writeFileSync = (filePath, ...args) => {
|
|
if (path.resolve(String(filePath)) === path.resolve(matchPath)) {
|
|
throw new Error(`injected write failure for ${path.basename(matchPath)}`);
|
|
}
|
|
return originalWriteFileSync.call(fs, filePath, ...args);
|
|
};
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
fs.writeFileSync = originalWriteFileSync;
|
|
}
|
|
}
|
|
|
|
function withSdkDistPresent(fn) {
|
|
const sdkCliPath = path.join(__dirname, '..', 'sdk', 'dist', 'cli.js');
|
|
const originalExistsSync = fs.existsSync;
|
|
const originalStatSync = fs.statSync;
|
|
const originalChmodSync = fs.chmodSync;
|
|
fs.existsSync = (filePath) => {
|
|
if (path.resolve(String(filePath)) === path.resolve(sdkCliPath)) return true;
|
|
return originalExistsSync.call(fs, filePath);
|
|
};
|
|
fs.statSync = (filePath, ...args) => {
|
|
if (path.resolve(String(filePath)) === path.resolve(sdkCliPath)) {
|
|
return { mode: 0o755 };
|
|
}
|
|
return originalStatSync.call(fs, filePath, ...args);
|
|
};
|
|
fs.chmodSync = (filePath, ...args) => {
|
|
if (path.resolve(String(filePath)) === path.resolve(sdkCliPath)) return;
|
|
return originalChmodSync.call(fs, filePath, ...args);
|
|
};
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
fs.existsSync = originalExistsSync;
|
|
fs.statSync = originalStatSync;
|
|
fs.chmodSync = originalChmodSync;
|
|
}
|
|
}
|
|
|
|
function stripAnsi(value) {
|
|
// eslint-disable-next-line no-control-regex -- \x1b (ESC) is the required leading byte of ANSI SGR color sequences; matching it is the purpose of stripping ANSI codes from captured CLI/console output
|
|
return value.replace(/\x1b\[[0-9;]*m/g, '');
|
|
}
|
|
|
|
function runInstallerCli(runtime, targetDir, options = {}) {
|
|
const { minimal = true } = options;
|
|
const env = { ...process.env };
|
|
delete env.GSD_TEST_MODE;
|
|
env.HOME = path.join(path.dirname(targetDir), 'home');
|
|
env.USERPROFILE = env.HOME;
|
|
|
|
return runNode(
|
|
[
|
|
installScript,
|
|
`--${runtime}`,
|
|
'--global',
|
|
'--config-dir',
|
|
targetDir,
|
|
...(minimal ? ['--minimal'] : []),
|
|
'--no-sdk',
|
|
],
|
|
{
|
|
env,
|
|
timeoutMs: INSTALL_TIMEOUT_MS,
|
|
}
|
|
);
|
|
}
|
|
|
|
function listDirNames(root, relPath) {
|
|
const dir = path.join(root, relPath);
|
|
if (!fs.existsSync(dir)) return [];
|
|
return fs.readdirSync(dir, { withFileTypes: true }).map((entry) => entry.name);
|
|
}
|
|
|
|
function assertHasGsdDirectory(root, relPath) {
|
|
assert.ok(
|
|
listDirNames(root, relPath).some((name) => name.startsWith('gsd-')),
|
|
`${relPath} should contain generated GSD entries`
|
|
);
|
|
}
|
|
|
|
function assertFreshInstallContract(runtime, targetDir) {
|
|
const contract = RUNTIME_INSTALL_CONTRACTS[runtime];
|
|
assert.ok(contract, `missing runtime install contract for ${runtime}`);
|
|
// #3023: the shared hooks bundle's staged directory name is per-runtime
|
|
// (hostBehaviors.sharedHooksDirName; pi renames it to `gsd-hooks/` to avoid
|
|
// pi's own host-reserved `hooks/`) — resolve it the same way the installer
|
|
// does rather than hardcoding 'hooks', which is only the default.
|
|
const hooksDirName = installModule.resolveSharedHooksDirName(runtime);
|
|
|
|
if (contract.workflowPayload !== false) {
|
|
assert.equal(
|
|
fs.readFileSync(path.join(targetDir, 'gsd-core', 'VERSION'), 'utf8'),
|
|
pkg.version,
|
|
`${runtime} should install the package VERSION`
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(path.join(targetDir, 'gsd-core', 'bin', 'gsd-tools.cjs')),
|
|
`${runtime} should install the GSD tool payload`
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(path.join(targetDir, 'gsd-file-manifest.json')),
|
|
`${runtime} should write the install manifest`
|
|
);
|
|
|
|
const manifest = JSON.parse(fs.readFileSync(path.join(targetDir, 'gsd-file-manifest.json'), 'utf8'));
|
|
assert.equal(manifest.version, pkg.version, `${runtime} manifest should record the package version`);
|
|
assert.equal(manifest.mode, 'full', `${runtime} manifest should record a full install`);
|
|
assert.ok(
|
|
manifest.files['gsd-core/VERSION'],
|
|
`${runtime} manifest should track the installed VERSION file`
|
|
);
|
|
} else {
|
|
assert.equal(
|
|
fs.existsSync(path.join(targetDir, 'gsd-core')),
|
|
false,
|
|
`${runtime} should not install the GSD workflow payload`
|
|
);
|
|
}
|
|
|
|
if (contract.surface === 'flat-skills') {
|
|
if (runtime === 'codex') {
|
|
// #2088: Codex CLI skills install to the canonical `$HOME/.agents/skills`
|
|
// root (resolved via os.homedir()), NOT `<config-dir>/skills`. Here
|
|
// runInstallerCli sandboxes HOME to <dirname(targetDir)>/home, so assert
|
|
// the skill dir under that sandboxed home instead of under targetDir.
|
|
const codexSandboxHome = path.join(path.dirname(targetDir), 'home');
|
|
assertHasGsdDirectory(path.join(codexSandboxHome, '.agents'), 'skills');
|
|
} else {
|
|
// Pre-#3562: codex was special-cased to expect zero gsd-* skill dirs
|
|
// (assumption: Codex auto-discovers from workflows). That assumption
|
|
// does not hold for Codex CLI 0.130.0 — fresh installs now materialize
|
|
// the same flat-skills surface as the other runtimes.
|
|
assertHasGsdDirectory(targetDir, 'skills');
|
|
}
|
|
} else if (contract.surface === 'hermes-skills') {
|
|
// Hermes layout uses prefix: '' — skill dirs have bare stem names (no gsd- prefix).
|
|
// Assert that the category dir contains at least one skill dir with SKILL.md.
|
|
const hermesGsdDir = path.join(targetDir, 'skills', 'gsd');
|
|
const hermesSkillCount = fs.existsSync(hermesGsdDir)
|
|
? fs.readdirSync(hermesGsdDir, { withFileTypes: true })
|
|
.filter(e => e.isDirectory() && fs.existsSync(path.join(hermesGsdDir, e.name, 'SKILL.md')))
|
|
.length
|
|
: 0;
|
|
assert.ok(hermesSkillCount > 0, `skills/gsd should contain generated GSD entries (got ${hermesSkillCount})`);
|
|
assert.ok(
|
|
fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'DESCRIPTION.md')),
|
|
'Hermes should install the nested GSD category description'
|
|
);
|
|
} else if (contract.surface === 'flat-command') {
|
|
// #2329: dir name is per-runtime (opencode='commands', kilo stays 'command').
|
|
const commandDirName = contract.commandDirName || 'command';
|
|
assert.ok(
|
|
listDirNames(targetDir, commandDirName).some((name) => name.startsWith('gsd-') && name.endsWith('.md')),
|
|
`${runtime} should install flattened command markdown files in ${commandDirName}/`
|
|
);
|
|
} else if (contract.surface === 'plugin-only') {
|
|
// #2102 Stage 1/2: pi — PLUGIN-ONLY install for commands/agents/skills
|
|
// (hostBehaviors.pluginOnlyInstall). pi's /gsd command is registered
|
|
// programmatically by the native extension and dispatches via a bounded
|
|
// subprocess to gsd-tools.cjs — pi has no host-read markdown surface, so
|
|
// NO commands/, agents/, or skills/ dir is written. The extension DOES
|
|
// spawn the shared hooks bundle as bounded subprocesses (Stage 2
|
|
// adversarial-review fix — hooksSurface:'none' no longer implies
|
|
// skipSharedHooksInstall for pi, mirroring OpenCode), so the bundle + the
|
|
// git-cmd.js tokenizer helper ARE part of the artifact surface now. #3023:
|
|
// that bundle is staged under `gsd-hooks/` for pi (hooksDirName above), not
|
|
// the generic `hooks/` — pi reserves `hooks/` for its own deprecated
|
|
// extension location.
|
|
// #2470: the dest filename comes from pi's descriptor, and must satisfy
|
|
// pi's isExtensionFile() auto-discovery filter (.ts/.js only) — otherwise
|
|
// the file installs but pi never loads it and /gsd never registers.
|
|
const piNativePlugin = JSON.parse(
|
|
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'pi', 'capability.json'), 'utf8')
|
|
).runtime.hostBehaviors.nativePlugin;
|
|
assert.ok(
|
|
piNativePlugin.file.endsWith('.ts') || piNativePlugin.file.endsWith('.js'),
|
|
`${runtime}'s extension "${piNativePlugin.file}" must end in .ts or .js for pi to discover it (#2470)`
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(path.join(targetDir, piNativePlugin.dir, piNativePlugin.file)),
|
|
`${runtime} should install the native extension file at ${piNativePlugin.dir}/${piNativePlugin.file}`
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(path.join(targetDir, hooksDirName, 'gsd-ensure-canonical-path.js')),
|
|
`${runtime} should install the shared ${hooksDirName}/ bundle (spawned by the native extension's event bridges)`
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(path.join(targetDir, hooksDirName, 'lib', 'git-cmd.js')),
|
|
`${runtime} should install ${hooksDirName}/lib/git-cmd.js (the /gsd command tokenizer)`
|
|
);
|
|
assert.equal(
|
|
fs.existsSync(path.join(targetDir, 'commands')),
|
|
false,
|
|
`${runtime} should NOT install a commands/ dir (plugin-only, no host-read markdown surface)`
|
|
);
|
|
assert.equal(
|
|
fs.existsSync(path.join(targetDir, 'agents')),
|
|
false,
|
|
`${runtime} should NOT install an agents/ dir (plugin-only, no named-dispatch toolkit)`
|
|
);
|
|
assert.equal(
|
|
fs.existsSync(path.join(targetDir, 'skills')),
|
|
false,
|
|
`${runtime} should NOT install a skills/ dir (plugin-only)`
|
|
);
|
|
} else if (contract.surface === 'commands-gsd') {
|
|
assert.ok(
|
|
listDirNames(targetDir, path.join('commands', 'gsd')).length > 0,
|
|
`${runtime} should install commands/gsd entries`
|
|
);
|
|
} else if (contract.surface === 'kimi-skills-agents') {
|
|
assertHasGsdDirectory(targetDir, 'skills');
|
|
assert.ok(
|
|
fs.existsSync(path.join(targetDir, 'agents', 'gsd.yaml')),
|
|
'Kimi should install the root agent YAML'
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(path.join(targetDir, 'agents', 'gsd.md')),
|
|
'Kimi should install the root agent prompt'
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(path.join(targetDir, 'agents', 'subagents', 'gsd-executor.yaml')),
|
|
'Kimi should install GSD subagent YAML'
|
|
);
|
|
} else if (contract.surface === 'clinerules') {
|
|
// #787: Cline now uses the .clinerules/ directory form (rules at gsd.md).
|
|
assert.match(
|
|
fs.readFileSync(path.join(targetDir, '.clinerules', 'gsd.md'), 'utf8'),
|
|
/GSD workflows live in `gsd-core\/workflows\/`/,
|
|
'Cline should install .clinerules/gsd.md guidance'
|
|
);
|
|
} else if (contract.surface === 'global-artifacts-noop') {
|
|
assert.equal(
|
|
fs.existsSync(path.join(targetDir, 'skills')),
|
|
false,
|
|
`${runtime} should not install unsupported global skills artifacts`
|
|
);
|
|
assert.equal(
|
|
fs.existsSync(path.join(targetDir, 'workflows')),
|
|
false,
|
|
`${runtime} should not install unsupported global workflow artifacts`
|
|
);
|
|
}
|
|
|
|
if (contract.surface !== 'kimi-skills-agents' && contract.surface !== 'global-artifacts-noop' && contract.surface !== 'plugin-only') {
|
|
assert.ok(
|
|
listDirNames(targetDir, 'agents').some((name) => name.startsWith('gsd-')),
|
|
`${runtime} full install should install agents`
|
|
);
|
|
}
|
|
|
|
assert.equal(
|
|
fs.existsSync(path.join(targetDir, 'settings.json')),
|
|
contract.settings,
|
|
`${runtime} settings.json presence should match the runtime contract`
|
|
);
|
|
// #2544: the CommonJS marker lives in the shared hooks dir (the dir GSD
|
|
// fills with its own .js scripts — `gsd-hooks/` for pi, `hooks/` for every
|
|
// other runtime, #3023), never at the config root — that file is user-owned
|
|
// territory on OpenCode/Kilo and was being clobbered on every install.
|
|
assert.equal(
|
|
fs.existsSync(path.join(targetDir, hooksDirName, 'package.json')),
|
|
contract.hooksPackageJson,
|
|
`${runtime} ${hooksDirName}/package.json presence should match the runtime contract`
|
|
);
|
|
assert.equal(
|
|
fs.existsSync(path.join(targetDir, 'package.json')),
|
|
false,
|
|
`${runtime} must not receive a GSD package.json at the config root (#2544)`
|
|
);
|
|
|
|
if (contract.codexConfig) {
|
|
assert.match(
|
|
fs.readFileSync(path.join(targetDir, 'config.toml'), 'utf8'),
|
|
/GSD Agent Configuration/,
|
|
'Codex should install config.toml with the GSD marker'
|
|
);
|
|
}
|
|
|
|
if (contract.copilotInstructions) {
|
|
assert.match(
|
|
fs.readFileSync(path.join(targetDir, 'copilot-instructions.md'), 'utf8'),
|
|
/GSD Configuration/,
|
|
'Copilot should install managed copilot instructions'
|
|
);
|
|
}
|
|
}
|
|
|
|
describe('installer migration install integration', { concurrency: false }, () => {
|
|
let tmpRoot;
|
|
let codexHome;
|
|
|
|
beforeEach(() => {
|
|
tmpRoot = createTempDir('gsd-install-migrations-');
|
|
codexHome = path.join(tmpRoot, '.codex');
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpRoot);
|
|
});
|
|
|
|
test('reports applied migration actions before package materialization', () => {
|
|
writeFile(codexHome, 'hooks/statusline.js', 'legacy managed hook\n');
|
|
writeManifest(codexHome, {
|
|
'hooks/statusline.js': sha256('legacy managed hook\n'),
|
|
});
|
|
|
|
const { output } = captureConsole(() =>
|
|
withCodexEnv(codexHome, () => install(true, 'codex'))
|
|
);
|
|
|
|
const plainOutput = stripAnsi(output);
|
|
assert.match(plainOutput, /Installer migrations/);
|
|
assert.match(plainOutput, /removed\s+hooks\/statusline\.js/);
|
|
assert.ok(
|
|
plainOutput.indexOf('Installer migrations') < plainOutput.indexOf('Installed workflow assets'),
|
|
'migration report should appear before package materialization'
|
|
);
|
|
assert.equal(fs.existsSync(path.join(codexHome, 'hooks/statusline.js')), false);
|
|
});
|
|
|
|
test('blocks install before materialization when baseline needs explicit user choice', () => {
|
|
writeFile(codexHome, 'hooks/gsd-retired-hook.txt', 'old gsd hook\n');
|
|
|
|
assert.throws(
|
|
() => captureConsole(() =>
|
|
withCodexEnv(codexHome, () => install(true, 'codex'))
|
|
),
|
|
/installer migration blocked/
|
|
);
|
|
|
|
assert.equal(fs.readFileSync(path.join(codexHome, 'hooks/gsd-retired-hook.txt'), 'utf8'), 'old gsd hook\n');
|
|
assert.equal(fs.existsSync(path.join(codexHome, 'skills')), false);
|
|
// #2088: with HOME sandboxed to codexHome via withCodexEnv, Codex's
|
|
// canonical skill root ($HOME/.agents/skills) resolves under codexHome
|
|
// too — assert nothing was materialized there when the install is blocked.
|
|
assert.equal(fs.existsSync(path.join(codexHome, '.agents', 'skills')), false);
|
|
assert.equal(fs.existsSync(path.join(codexHome, 'gsd-core', 'VERSION')), false);
|
|
});
|
|
|
|
test('rolls back applied migrations when package materialization fails for non-Codex installs', () => {
|
|
const claudeHome = path.join(tmpRoot, '.claude');
|
|
fs.mkdirSync(claudeHome, { recursive: true });
|
|
writeFile(claudeHome, 'hooks/statusline.js', 'legacy managed hook\n');
|
|
writeManifest(claudeHome, {
|
|
'hooks/statusline.js': sha256('legacy managed hook\n'),
|
|
});
|
|
|
|
assert.throws(
|
|
() => captureConsole(() =>
|
|
withEnv('CLAUDE_CONFIG_DIR', claudeHome, () =>
|
|
withWriteFailure(path.join(claudeHome, 'gsd-core', 'VERSION'), () => install(true, 'claude'))
|
|
)
|
|
),
|
|
/injected write failure for VERSION/
|
|
);
|
|
|
|
assert.equal(
|
|
fs.readFileSync(path.join(claudeHome, 'hooks/statusline.js'), 'utf8'),
|
|
'legacy managed hook\n'
|
|
);
|
|
assert.equal(fs.existsSync(path.join(claudeHome, 'gsd-install-state.json')), false);
|
|
});
|
|
|
|
test('rolls back applied migrations when multi-runtime finalization fails', () => {
|
|
const claudeHome = path.join(tmpRoot, '.claude');
|
|
fs.mkdirSync(claudeHome, { recursive: true });
|
|
writeFile(claudeHome, 'hooks/statusline.js', 'legacy managed hook\n');
|
|
writeManifest(claudeHome, {
|
|
'hooks/statusline.js': sha256('legacy managed hook\n'),
|
|
});
|
|
|
|
assert.throws(
|
|
() => captureConsole(() =>
|
|
withEnv('CLAUDE_CONFIG_DIR', claudeHome, () =>
|
|
withSdkDistPresent(() =>
|
|
withWriteFailure(path.join(claudeHome, 'settings.json'), () =>
|
|
installModule.installAllRuntimes(['claude'], true, false)
|
|
)
|
|
)
|
|
)
|
|
),
|
|
/injected write failure for settings\.json/
|
|
);
|
|
|
|
assert.equal(
|
|
fs.readFileSync(path.join(claudeHome, 'hooks/statusline.js'), 'utf8'),
|
|
'legacy managed hook\n'
|
|
);
|
|
assert.equal(fs.existsSync(path.join(claudeHome, 'gsd-install-state.json')), false);
|
|
});
|
|
|
|
test('rolls back completed runtime migrations when a later runtime install fails', () => {
|
|
const claudeHome = path.join(tmpRoot, '.claude');
|
|
fs.mkdirSync(claudeHome, { recursive: true });
|
|
writeFile(claudeHome, 'hooks/statusline.js', 'legacy managed hook\n');
|
|
writeManifest(claudeHome, {
|
|
'hooks/statusline.js': sha256('legacy managed hook\n'),
|
|
});
|
|
|
|
writeFile(codexHome, 'hooks/statusline.js', 'legacy managed hook\n');
|
|
writeManifest(codexHome, {
|
|
'hooks/statusline.js': sha256('legacy managed hook\n'),
|
|
});
|
|
|
|
assert.throws(
|
|
() => captureConsole(() =>
|
|
withEnv('CLAUDE_CONFIG_DIR', claudeHome, () =>
|
|
withCodexEnv(codexHome, () =>
|
|
withWriteFailure(path.join(codexHome, 'gsd-core', 'VERSION'), () =>
|
|
installModule.installAllRuntimes(['claude', 'codex'], true, false)
|
|
)
|
|
)
|
|
)
|
|
),
|
|
/injected write failure for VERSION/
|
|
);
|
|
|
|
assert.equal(
|
|
fs.readFileSync(path.join(claudeHome, 'hooks/statusline.js'), 'utf8'),
|
|
'legacy managed hook\n'
|
|
);
|
|
assert.equal(fs.existsSync(path.join(claudeHome, 'gsd-install-state.json')), false);
|
|
assert.equal(
|
|
fs.readFileSync(path.join(codexHome, 'hooks/statusline.js'), 'utf8'),
|
|
'legacy managed hook\n'
|
|
);
|
|
assert.equal(fs.existsSync(path.join(codexHome, 'gsd-install-state.json')), false);
|
|
});
|
|
|
|
for (const runtime of SUPPORTED_RUNTIMES) {
|
|
test(`runs a full end-to-end install for ${runtime}`, () => {
|
|
const targetDir = path.join(tmpRoot, `.${runtime}-full-install`);
|
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
writeFile(targetDir, 'hooks/statusline.js', 'legacy managed hook\n');
|
|
writeManifest(targetDir, {
|
|
'hooks/statusline.js': sha256('legacy managed hook\n'),
|
|
});
|
|
|
|
const result = runInstallerCli(runtime, targetDir, { minimal: false });
|
|
|
|
assert.equal(result.exitCode, 0, result.stderr || result.stdout);
|
|
const output = stripAnsi(`${result.stdout}\n${result.stderr}`);
|
|
assert.match(output, /Installing for /);
|
|
assert.match(output, /Installer migrations/);
|
|
assert.match(output, /removed\s+hooks\/statusline\.js/);
|
|
if (runtime === 'kimi') {
|
|
assert.match(output, /Generated Kimi root agent/);
|
|
} else {
|
|
assert.match(output, /Installed workflow assets/);
|
|
}
|
|
assert.match(output, /Done!/);
|
|
assert.equal(fs.existsSync(path.join(targetDir, 'hooks/statusline.js')), false);
|
|
|
|
const installState = JSON.parse(fs.readFileSync(path.join(targetDir, 'gsd-install-state.json'), 'utf8'));
|
|
assert.ok(
|
|
installState.appliedMigrations.some((entry) => entry.id === '2026-05-11-legacy-orphan-files'),
|
|
`${runtime} should track the applied cleanup migration in install state`
|
|
);
|
|
assertFreshInstallContract(runtime, targetDir);
|
|
});
|
|
|
|
test(`runs managed cleanup migrations for ${runtime}`, () => {
|
|
const targetDir = path.join(tmpRoot, `.${runtime}-managed-cleanup`);
|
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
writeFile(targetDir, 'hooks/statusline.js', 'legacy managed hook\n');
|
|
writeManifest(targetDir, {
|
|
'hooks/statusline.js': sha256('legacy managed hook\n'),
|
|
});
|
|
|
|
const result = runInstallerCli(runtime, targetDir);
|
|
|
|
assert.equal(result.exitCode, 0, result.stderr || result.stdout);
|
|
const output = stripAnsi(`${result.stdout}\n${result.stderr}`);
|
|
assert.match(output, /Installer migrations/);
|
|
assert.match(output, /removed\s+hooks\/statusline\.js/);
|
|
assert.equal(fs.existsSync(path.join(targetDir, 'hooks/statusline.js')), false);
|
|
const installState = JSON.parse(fs.readFileSync(path.join(targetDir, 'gsd-install-state.json'), 'utf8'));
|
|
assert.ok(
|
|
installState.appliedMigrations.some((entry) => entry.id === '2026-05-11-legacy-orphan-files'),
|
|
'successful install should write install state for the applied cleanup migration'
|
|
);
|
|
});
|
|
|
|
test(`blocks ambiguous GSD-looking user-choice artifacts for ${runtime}`, () => {
|
|
const targetDir = path.join(tmpRoot, `.${runtime}-blocked`);
|
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
writeFile(targetDir, 'gsd-core/gsd-retired-tool.cjs', 'old ambiguous artifact\n');
|
|
|
|
const result = runInstallerCli(runtime, targetDir);
|
|
|
|
assert.notEqual(result.exitCode, 0, 'install should fail before materialization');
|
|
const output = stripAnsi(`${result.stdout}\n${result.stderr}`);
|
|
assert.match(output, /Installer migrations/);
|
|
assert.match(output, /blocked\s+gsd-core\/gsd-retired-tool\.cjs/);
|
|
assert.match(output, /installer migration blocked/);
|
|
assert.equal(
|
|
fs.readFileSync(path.join(targetDir, 'gsd-core/gsd-retired-tool.cjs'), 'utf8'),
|
|
'old ambiguous artifact\n'
|
|
);
|
|
assert.equal(fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION')), false);
|
|
});
|
|
}
|
|
});
|