* test(#3336): fold the installer & runtime surface issue-* cluster — Wave 4 Folds 10 legacy issue-*.test.cjs regression files (79 test() blocks) into their module's main suite, per H3 (#3315) of the test-hygiene epic (#3053). First of 4 issue-* waves (following the 3 fix-* waves, all merged). - 1 file with no prior target coverage: renamed (git mv) into legacy-cleanup.test.cjs (sole comprehensive suite for that module). - 9 files merged into 6 pre-existing suites: golden-parity-single-source, runtime-artifact-layout-surface, codex-config (4 sources merged jointly in one pass per the issue's own instruction, to catch overlap between the 4 sources themselves, not just against the pre-existing target — zero overlap found, all 20 blocks additive), runtime-config-adapter-registry (1 of 10 source blocks dropped as a proven subset of existing coverage), cline-install, install.test.cjs. Incidental fixes required to keep this wave's own ratchets green: - Fixed a stale ADR doc reference (docs/adr/1235) to a folded-away filename. - scripts/lint-allow-test-rule-refs: pruned 4 stale allowlist entries for renamed/merged-away files, cited 2 previously-uncited allow-test-rule comments that surfaced as "new" only because their file path changed, added 1 fresh allowlist entry for a pre-existing uncited comment that predates this PR, and tightened the exemption-file ceiling 309 -> 305 to match the real post-fold high-water mark. Zero net test-coverage loss. No production code changed. * test(#3336): fix orthogonal-review findings — Wave 4 fold Standards-axis review + Memtrace graph pass found real issues in the just-folded suites, all fixed here: - Standardized the fold-wrapper convention (block-scoped __foldDescribe) across golden-parity-single-source.test.cjs, runtime-artifact-layout- surface.test.cjs, runtime-config-adapter-registry.test.cjs, and cline-install.test.cjs to match the pattern already used by codex-config.test.cjs and install.test.cjs in this same wave (and by earlier folds elsewhere in the epic) — repeats the exact inconsistency Wave 3 (#3335) already fixed once in this epic. - Fixed a stale allowlist entry's alphabetical position (cosmetic, not tool-gated, caught by review anyway). - Fixed two stale test-filename references in PRODUCTION code comments (src/capability-writer.cts, src/runtime-config-adapter-registry.cts) caught by lint-removed-but-needed — a class of stale reference this wave's fold agents didn't check for, since they were scoped to docs/ and gsd-core/references/ only, not src/. First fix attempt wrongly edited the gitignored gsd-core/bin/lib/*.cjs BUILD OUTPUT instead of the tracked .cts source; caught and corrected before commit. - Fixed one remaining stale doc reference in docs/adr/1235 (a prior partial fix in this same wave missed it). No test() count changed in any file. No production code BEHAVIOR changed — comment-only fixes in src/. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -82,7 +82,7 @@ Cross-cutting steps (a, b, c) are applied by the descriptor pipeline for the app
|
||||
|
||||
### Cutover progress (#1575)
|
||||
|
||||
- **Step 0 (parity harness):** shipped in `tests/issue-1575-agent-descriptor-parity.test.cjs`. Asserts `applySurface` output is byte-identical to `installRuntimeArtifacts` for all descriptor-driven runtimes. Covers stale-cleanup convergence (pre-existing legacy `.agent.md` pruned correctly).
|
||||
- **Step 0 (parity harness):** shipped in `tests/golden-parity-single-source.test.cjs` (folded as `folded:issue-1575-agent-descriptor-parity` in the #3336 test-hygiene sweep). Asserts `applySurface` output is byte-identical to `installRuntimeArtifacts` for all descriptor-driven runtimes. Covers stale-cleanup convergence (pre-existing legacy `.agent.md` pruned correctly).
|
||||
- **Step 1 (trivial converters):** cursor, windsurf, augment, trae, codebuddy — install-path cutover complete (PR #1438); surface-path parity shipped (#1575: `applySurface` now builds `agentCtx` and passes it to `kind.stage()` for agents, applying path-rewrite + attribution + converter + normalize).
|
||||
- **Step 2 (scope-aware):** copilot and antigravity — cutover complete (#1575: declared `agents` kind in `capability.json`, added to `_DESCRIPTOR_AGENTS_RUNTIMES`, copilot `.agent.md` rename handled in both `_copyStaged` and `_syncGsdDir`).
|
||||
- **Cline:** deferred — rules-only local branch + local/global complication not handled by the descriptor-driven path.
|
||||
|
||||
@@ -77,14 +77,10 @@
|
||||
"tests/intel.test.cjs :: source-text-is-the-product",
|
||||
"tests/inventory-headings-countfree.test.cjs :: source-text-is-the-product",
|
||||
"tests/ios-scaffold-safety.test.cjs :: source-text-is-the-product",
|
||||
"tests/issue-2639-codex-toml-neutralization.test.cjs :: source-text-is-the-product",
|
||||
"tests/issue-429-comment-text-gate.test.cjs :: source-text-is-the-product",
|
||||
"tests/issue-498-update-backup-runtime-dir.test.cjs :: source-text-is-the-product",
|
||||
"tests/issue-57-runtime-install-no-drift.test.cjs :: structural-regression-guard",
|
||||
"tests/issue-607-installer-dry-run.install.test.cjs :: integration-test-input",
|
||||
"tests/issue-607-legacy-cleanup.test.cjs :: integration-test-input",
|
||||
"tests/issue-787-cline-hooks-agents.test.cjs :: source-text-is-the-product",
|
||||
"tests/issue-815-update-next-channel.test.cjs :: source-text-is-the-product",
|
||||
"tests/legacy-cleanup.test.cjs :: integration-test-input",
|
||||
"tests/locking-bugs-1909-1916-1925-1927.test.cjs :: architectural-invariant",
|
||||
"tests/mcp-tool-inheritance.test.cjs :: source-text-is-the-product",
|
||||
"tests/milestone-summary.test.cjs :: source-text-is-the-product",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"maxFiles": 309,
|
||||
"maxFiles": 305,
|
||||
"grace": 3
|
||||
}
|
||||
|
||||
@@ -372,8 +372,9 @@ function setCapabilityState(
|
||||
// access to getCommitAttribution (which lives in bin/install.js). Until that
|
||||
// is refactored into a shared module, surface-path agents for descriptor-
|
||||
// driven runtimes will lack the Co-Authored-By trailer that install adds.
|
||||
// Parity is proven when resolveAttribution IS provided (see
|
||||
// tests/issue-1575-agent-descriptor-parity.test.cjs).
|
||||
// Parity is proven when resolveAttribution IS provided (see the
|
||||
// folded:issue-1575-agent-descriptor-parity describe block in
|
||||
// tests/golden-parity-single-source.test.cjs).
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
applySurface(resolvedConfigDir, layout, manifest, undefined, registry, opts?.materialize?.resolveAttribution
|
||||
? { resolveAttribution: opts.materialize.resolveAttribution }
|
||||
|
||||
@@ -101,8 +101,9 @@ type RuntimeDescriptorMap = Record<string, { runtime: Record<string, unknown> |
|
||||
* (Marketplace/VSIX-distributed, never dispatched through
|
||||
* install()/finishInstall()), so it is not a "config-adapter runtime" by
|
||||
* definition. This keeps this set in lockstep with bin/install.js's
|
||||
* `allRuntimes` (see tests/issue-57-runtime-install-no-drift.test.cjs) without
|
||||
* needing a separate hand-kept exclusion list.
|
||||
* `allRuntimes` (see the folded:issue-57-runtime-install-no-drift describe
|
||||
* block in tests/runtime-config-adapter-registry.test.cjs) without needing a
|
||||
* separate hand-kept exclusion list.
|
||||
*/
|
||||
const ALLOWED_CONFIG_RUNTIMES: ReadonlySet<string> = new Set(
|
||||
Object.entries(runtimes)
|
||||
|
||||
@@ -27,6 +27,11 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { runNode } = require('./helpers/process-seam.cjs');
|
||||
// #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs.
|
||||
const { PROBE_TIMEOUT_MS, INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
|
||||
const {
|
||||
getDirName,
|
||||
@@ -34,6 +39,14 @@ const {
|
||||
convertClaudeToCliineMarkdown,
|
||||
install,
|
||||
finishInstall,
|
||||
uninstall,
|
||||
buildClineRulesBody,
|
||||
buildClinePreToolUseHook,
|
||||
buildClineAgentsMdBody,
|
||||
mergeGsdAgentsMd,
|
||||
stripGsdFromAgentsMd,
|
||||
GSD_AGENTS_MD_MARKER,
|
||||
GSD_AGENTS_MD_CLOSE_MARKER,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
@@ -209,3 +222,275 @@ describe('Cline install (local)', () => {
|
||||
scanDir(engineDir);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Folded from tests/issue-787-cline-hooks-agents.test.cjs ────────────────────
|
||||
// Issue #787 — elevate Cline: write hooks (.clinerules/hooks/) + AGENTS.md.
|
||||
// Verifies the installer emits Cline directory-form rules, a PreToolUse
|
||||
// lifecycle hook (Cline JSON stdin -> {cancel,errorMessage,contextModification}
|
||||
// protocol), and a global ~/.agents/AGENTS.md instruction target.
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe('folded:issue-787-cline-hooks-agents', () => {
|
||||
|
||||
describe('#787 Cline pure helpers', () => {
|
||||
test('buildClineRulesBody returns GSD directory-form rules markdown', () => {
|
||||
const body = buildClineRulesBody();
|
||||
assert.equal(typeof body, 'string');
|
||||
assert.match(body, /GSD workflows live in `gsd-core\/workflows\/`/);
|
||||
assert.ok(body.endsWith('\n'), 'rules body should end with a trailing newline');
|
||||
});
|
||||
|
||||
test('buildClinePreToolUseHook returns a syntactically valid Node script', () => {
|
||||
const script = buildClinePreToolUseHook();
|
||||
assert.match(script, /^#!\/usr\/bin\/env node/, 'must carry a node shebang');
|
||||
// Cline protocol fields must be present in the emitted decision surface.
|
||||
assert.match(script, /cancel/);
|
||||
assert.match(script, /errorMessage/);
|
||||
const tmp = createTempDir('gsd-787-hookcheck-');
|
||||
try {
|
||||
const p = path.join(tmp, 'PreToolUse');
|
||||
fs.writeFileSync(p, script);
|
||||
const res = runNode(['--check', p], { timeoutMs: PROBE_TIMEOUT_MS });
|
||||
assert.equal(res.exitCode, 0, `node --check failed: ${res.stderr}`);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('PreToolUse hook allows a normal tool call (cancel:false)', () => {
|
||||
const tmp = createTempDir('gsd-787-hookrun-');
|
||||
try {
|
||||
const p = path.join(tmp, 'PreToolUse');
|
||||
fs.writeFileSync(p, buildClinePreToolUseHook());
|
||||
const res = runNode([p], {
|
||||
input: JSON.stringify({ toolName: 'read_file', toolInput: { path: 'src/index.ts' } }),
|
||||
timeoutMs: PROBE_TIMEOUT_MS,
|
||||
});
|
||||
assert.equal(res.exitCode, 0);
|
||||
const out = JSON.parse(res.stdout);
|
||||
assert.equal(out.cancel, false);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('PreToolUse hook cancels a write into .planning/ with an errorMessage', () => {
|
||||
const tmp = createTempDir('gsd-787-hookguard-');
|
||||
try {
|
||||
const p = path.join(tmp, 'PreToolUse');
|
||||
fs.writeFileSync(p, buildClinePreToolUseHook());
|
||||
const res = runNode([p], {
|
||||
input: JSON.stringify({ toolName: 'write_to_file', toolInput: { path: '.planning/ROADMAP.md', content: 'x' } }),
|
||||
timeoutMs: PROBE_TIMEOUT_MS,
|
||||
});
|
||||
assert.equal(res.exitCode, 0);
|
||||
const out = JSON.parse(res.stdout);
|
||||
assert.equal(out.cancel, true);
|
||||
assert.match(out.errorMessage, /\.planning/);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('PreToolUse hook does NOT cancel a write to a non-planning path whose CONTENT mentions .planning/', () => {
|
||||
const tmp = createTempDir('gsd-787-hookfp-');
|
||||
try {
|
||||
const p = path.join(tmp, 'PreToolUse');
|
||||
fs.writeFileSync(p, buildClinePreToolUseHook());
|
||||
const res = runNode([p], {
|
||||
input: JSON.stringify({
|
||||
toolName: 'write_to_file',
|
||||
toolInput: { path: 'docs/guide.md', content: 'Edit your .planning/ROADMAP.md via /gsd commands.' },
|
||||
}),
|
||||
timeoutMs: PROBE_TIMEOUT_MS,
|
||||
});
|
||||
assert.equal(res.exitCode, 0);
|
||||
assert.equal(JSON.parse(res.stdout).cancel, false, 'content mentioning .planning must not trigger a cancel');
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('PreToolUse hook fails open on malformed stdin', () => {
|
||||
const tmp = createTempDir('gsd-787-hookbad-');
|
||||
try {
|
||||
const p = path.join(tmp, 'PreToolUse');
|
||||
fs.writeFileSync(p, buildClinePreToolUseHook());
|
||||
const res = runNode([p], { input: 'not json{', timeoutMs: PROBE_TIMEOUT_MS });
|
||||
assert.equal(res.exitCode, 0);
|
||||
assert.equal(JSON.parse(res.stdout).cancel, false);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('mergeGsdAgentsMd creates a marker-delimited block when no file exists', () => {
|
||||
const tmp = createTempDir('gsd-787-agents-new-');
|
||||
try {
|
||||
const p = path.join(tmp, 'AGENTS.md');
|
||||
mergeGsdAgentsMd(p, buildClineAgentsMdBody());
|
||||
const content = fs.readFileSync(p, 'utf8');
|
||||
assert.ok(content.includes(GSD_AGENTS_MD_MARKER));
|
||||
assert.ok(content.includes(GSD_AGENTS_MD_CLOSE_MARKER));
|
||||
assert.match(content, /GSD/);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('mergeGsdAgentsMd preserves pre-existing user content', () => {
|
||||
const tmp = createTempDir('gsd-787-agents-merge-');
|
||||
try {
|
||||
const p = path.join(tmp, 'AGENTS.md');
|
||||
fs.writeFileSync(p, '# My rules\n\nKeep me.\n');
|
||||
mergeGsdAgentsMd(p, buildClineAgentsMdBody());
|
||||
const content = fs.readFileSync(p, 'utf8');
|
||||
assert.match(content, /Keep me\./);
|
||||
assert.ok(content.includes(GSD_AGENTS_MD_MARKER));
|
||||
// Idempotent: second merge does not duplicate the block.
|
||||
mergeGsdAgentsMd(p, buildClineAgentsMdBody());
|
||||
const twice = fs.readFileSync(p, 'utf8');
|
||||
const occurrences = twice.split(GSD_AGENTS_MD_MARKER).length - 1;
|
||||
assert.equal(occurrences, 1, 'GSD block must not duplicate on re-merge');
|
||||
assert.match(twice, /Keep me\./);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('stripGsdFromAgentsMd returns null when file was GSD-only, else cleaned content', () => {
|
||||
const onlyGsd = `${GSD_AGENTS_MD_MARKER}\nhi\n${GSD_AGENTS_MD_CLOSE_MARKER}\n`;
|
||||
assert.equal(stripGsdFromAgentsMd(onlyGsd), null);
|
||||
const mixed = `# Keep\n\n${GSD_AGENTS_MD_MARKER}\nhi\n${GSD_AGENTS_MD_CLOSE_MARKER}\n`;
|
||||
const cleaned = stripGsdFromAgentsMd(mixed);
|
||||
assert.match(cleaned, /# Keep/);
|
||||
assert.ok(!cleaned.includes(GSD_AGENTS_MD_MARKER));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Local install: directory form + hook ───────────────────────────────────────
|
||||
|
||||
describe('#787 Cline local install — directory form + PreToolUse hook', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-787-cline-local-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('writes .clinerules/ as a directory containing gsd.md', () => {
|
||||
install(false, 'cline');
|
||||
const dir = path.join(tmpDir, '.clinerules');
|
||||
assert.ok(fs.statSync(dir).isDirectory(), '.clinerules must be a directory');
|
||||
const ruleFile = path.join(dir, 'gsd.md');
|
||||
assert.ok(fs.existsSync(ruleFile), '.clinerules/gsd.md must exist');
|
||||
assert.match(fs.readFileSync(ruleFile, 'utf8'), /gsd-core\/workflows\//);
|
||||
});
|
||||
|
||||
test('writes an executable PreToolUse hook with no extension', () => {
|
||||
install(false, 'cline');
|
||||
const hook = path.join(tmpDir, '.clinerules', 'hooks', 'PreToolUse');
|
||||
assert.ok(fs.existsSync(hook), '.clinerules/hooks/PreToolUse must exist');
|
||||
if (process.platform !== 'win32') {
|
||||
const mode = fs.statSync(hook).mode;
|
||||
assert.ok((mode & 0o111) !== 0, 'PreToolUse must be executable');
|
||||
}
|
||||
});
|
||||
|
||||
test('migrates a legacy single-file .clinerules into the directory form', () => {
|
||||
// Simulate a pre-#787 install that wrote a .clinerules FILE.
|
||||
fs.writeFileSync(path.join(tmpDir, '.clinerules'), '# legacy file\n');
|
||||
install(false, 'cline');
|
||||
const dir = path.join(tmpDir, '.clinerules');
|
||||
assert.ok(fs.statSync(dir).isDirectory(), 'legacy file must be replaced by a directory');
|
||||
assert.ok(fs.existsSync(path.join(dir, 'gsd.md')));
|
||||
});
|
||||
|
||||
test('does not follow a symlinked .clinerules (writes the real directory in place)', () => {
|
||||
if (process.platform === 'win32') return; // symlink perms differ on Windows
|
||||
// Point .clinerules at an external directory via symlink; install must NOT
|
||||
// write GSD files through the link.
|
||||
const external = path.join(tmpDir, 'external-target');
|
||||
fs.mkdirSync(external);
|
||||
fs.symlinkSync(external, path.join(tmpDir, '.clinerules'));
|
||||
install(false, 'cline');
|
||||
const dir = path.join(tmpDir, '.clinerules');
|
||||
assert.ok(fs.lstatSync(dir).isDirectory() && !fs.lstatSync(dir).isSymbolicLink(),
|
||||
'.clinerules must be a real directory, not the symlink');
|
||||
assert.ok(!fs.existsSync(path.join(external, 'gsd.md')), 'must not write through the symlink target');
|
||||
assert.ok(fs.existsSync(path.join(dir, 'gsd.md')));
|
||||
});
|
||||
|
||||
test('manifest tracks the new directory-form artifacts', () => {
|
||||
install(false, 'cline');
|
||||
const manifestPath = path.join(tmpDir, 'gsd-file-manifest.json');
|
||||
assert.ok(fs.existsSync(manifestPath));
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
assert.ok(manifest.files['.clinerules/gsd.md'], 'manifest should track .clinerules/gsd.md');
|
||||
assert.ok(manifest.files['.clinerules/hooks/PreToolUse'], 'manifest should track the hook');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Global install: ~/.agents/AGENTS.md (subprocess, HOME-isolated) ─────────────
|
||||
|
||||
describe('#787 Cline global install — ~/.agents/AGENTS.md', () => {
|
||||
function runGlobalClineInstall() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-787-cline-global-'));
|
||||
const env = { ...process.env, HOME: root, USERPROFILE: root };
|
||||
delete env.GSD_TEST_MODE;
|
||||
const res = runNode(
|
||||
[INSTALL_SCRIPT, '--cline', '--global', '--config-dir', path.join(root, '.cline')],
|
||||
{ cwd: root, env, timeoutMs: INSTALL_TIMEOUT_MS },
|
||||
);
|
||||
return { root, res };
|
||||
}
|
||||
|
||||
test('writes ~/.agents/AGENTS.md with a GSD marker block', () => {
|
||||
const { root, res } = runGlobalClineInstall();
|
||||
try {
|
||||
assert.equal(res.exitCode, 0, `installer failed: ${res.stderr}`);
|
||||
const agents = path.join(root, '.agents', 'AGENTS.md');
|
||||
assert.ok(fs.existsSync(agents), '~/.agents/AGENTS.md must exist after a global Cline install');
|
||||
const content = fs.readFileSync(agents, 'utf8');
|
||||
assert.ok(content.includes(GSD_AGENTS_MD_MARKER));
|
||||
assert.match(content, /GSD/);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Uninstall symmetry ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('#787 Cline uninstall removes managed artifacts', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-787-cline-uninstall-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('local uninstall removes .clinerules/gsd.md and the hook', () => {
|
||||
install(false, 'cline');
|
||||
assert.ok(fs.existsSync(path.join(tmpDir, '.clinerules', 'gsd.md')));
|
||||
uninstall(false, 'cline');
|
||||
assert.ok(!fs.existsSync(path.join(tmpDir, '.clinerules', 'gsd.md')), 'gsd.md should be removed');
|
||||
assert.ok(!fs.existsSync(path.join(tmpDir, '.clinerules', 'hooks', 'PreToolUse')), 'hook should be removed');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2050,6 +2050,217 @@ describe('mergeCodexConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/issue-2940-codex-config-merge-trailing.test.cjs — consolidation epic #1969 (H3 W4 #3336)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2940-codex-config-merge-trailing (consolidation epic #1969 H3 W4 #3336)", () => {
|
||||
'use strict';
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Regression test for #2940 — `gsd-update` overwrites `~/.codex/config.toml`,
|
||||
* removing any user/Codex-CLI settings added after the GSD-managed marker block.
|
||||
*
|
||||
* Root cause: `mergeCodexConfig`'s Case 2 (marker present) preserved content
|
||||
* BEFORE the marker but unconditionally discarded everything from the marker to
|
||||
* EOF, replacing it with a freshly generated GSD block. Since a fresh install
|
||||
* writes the GSD block as the file's entire content, any settings the user or
|
||||
* Codex CLI later adds (`[model]`, `[mcp_servers.*]`, `[profiles.*]`) land AFTER
|
||||
* the block, and every subsequent update wiped them.
|
||||
*
|
||||
* The fix preserves genuine trailing TOML by routing the post-marker region
|
||||
* through the existing `stripLeakedGsdCodexSections` (which removes GSD's own
|
||||
* managed/leaked sections while keeping user tables), then re-appending it after
|
||||
* the regenerated GSD block — without regressing #2406's de-dup.
|
||||
*
|
||||
* Matrix: .gsd/bug/fix/2940-codex-config-merge-preserves-trailing-content/50-test-matrix.md
|
||||
*
|
||||
* NOTE: this describe block covers trailing-content-after-the-marker preservation
|
||||
* ([model]/[mcp_servers.*]/[profiles.*] appended AFTER the GSD block) — a case the
|
||||
* pre-existing 'mergeCodexConfig' suite above does not exercise (that suite's cases
|
||||
* write user content BEFORE the marker/block, not after). Verified non-duplicate
|
||||
* against both the pre-existing target and the other three folded sources.
|
||||
*/
|
||||
|
||||
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 os = require('node:os');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
generateCodexConfigBlock,
|
||||
mergeCodexConfig,
|
||||
GSD_CODEX_MARKER,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
describe('mergeCodexConfig trailing-content preservation (#2940)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2940-merge-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
/** A GSD block with one agent (the shape installCodexConfig passes). */
|
||||
const block = () =>
|
||||
generateCodexConfigBlock([{ name: 'gsd-executor', description: 'Executes plans' }]);
|
||||
|
||||
test('trailingUserModelSectionPreserved', () => {
|
||||
// Row 1 (failing-first regression): a config with the GSD block FIRST, then a user
|
||||
// [model] section after it (the real-world layout — fresh install fills the file,
|
||||
// user settings land after). Re-merge must preserve [model] byte-for-byte.
|
||||
const configPath = path.join(tmpDir, 'config.toml');
|
||||
const trailing = '[model]\nname = "gpt-5.4"\n';
|
||||
// First write: GSD block + user content after it (no content before the marker).
|
||||
fs.writeFileSync(configPath, block() + '\n' + trailing);
|
||||
|
||||
mergeCodexConfig(configPath, block());
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
assert.ok(content.includes('[model]'), 'user [model] section preserved after re-merge');
|
||||
assert.ok(content.includes('name = "gpt-5.4"'), 'user model value preserved verbatim');
|
||||
assert.ok(content.includes(GSD_CODEX_MARKER), 'GSD marker still present');
|
||||
const markerCount = (content.match(new RegExp(GSD_CODEX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
|
||||
assert.strictEqual(markerCount, 1, 'exactly one marker (no duplication)');
|
||||
assert.ok(content.includes('max_depth ='), 'GSD-managed [agents] block regenerated');
|
||||
});
|
||||
|
||||
test('multipleTrailingTablesPreserved', () => {
|
||||
// Row 2: multiple trailing user tables ([mcp_servers.*], [profiles.*]).
|
||||
const configPath = path.join(tmpDir, 'config.toml');
|
||||
const trailing = [
|
||||
'[mcp_servers.figma]',
|
||||
'command = "npx"',
|
||||
'args = ["-y", "figma-mcp"]',
|
||||
'',
|
||||
'[profiles.dev]',
|
||||
'model = "o3"',
|
||||
'sandbox_mode = "workspace-write"',
|
||||
].join('\n');
|
||||
fs.writeFileSync(configPath, block() + '\n' + trailing + '\n');
|
||||
|
||||
mergeCodexConfig(configPath, block());
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
assert.ok(content.includes('[mcp_servers.figma]'), 'mcp_servers table preserved');
|
||||
assert.ok(content.includes('[profiles.dev]'), 'profiles table preserved');
|
||||
assert.ok(content.includes('sandbox_mode = "workspace-write"'), 'profile value preserved');
|
||||
assert.ok(content.includes(GSD_CODEX_MARKER), 'GSD block regenerated');
|
||||
});
|
||||
|
||||
test('reMergeIsIdempotent', () => {
|
||||
// Row 3 (acceptance #2): merging the result of a merge again yields identical content.
|
||||
const configPath = path.join(tmpDir, 'config.toml');
|
||||
fs.writeFileSync(configPath, block() + '\n[model]\nname = "o3"\n');
|
||||
|
||||
mergeCodexConfig(configPath, block());
|
||||
const afterFirst = fs.readFileSync(configPath, 'utf8');
|
||||
|
||||
mergeCodexConfig(configPath, block());
|
||||
const afterSecond = fs.readFileSync(configPath, 'utf8');
|
||||
|
||||
assert.strictEqual(afterSecond, afterFirst, 'second merge is idempotent (no further change)');
|
||||
});
|
||||
|
||||
test('leakedGsdSectionAfterMarkerStillStripped', () => {
|
||||
// Row 4 (#2406 non-regression): a leaked GSD-managed [agents.gsd-*] section AFTER the
|
||||
// marker is still REMOVED (not regrown), while genuine user content after it is preserved.
|
||||
const configPath = path.join(tmpDir, 'config.toml');
|
||||
const leakedAndUser = [
|
||||
'[agents.gsd-executor]',
|
||||
'description = "stale leaked"',
|
||||
'config_file = "agents/gsd-executor.toml"',
|
||||
'',
|
||||
'[model]',
|
||||
'name = "o3"',
|
||||
].join('\n');
|
||||
fs.writeFileSync(configPath, block() + '\n' + leakedAndUser + '\n');
|
||||
|
||||
mergeCodexConfig(configPath, block());
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const gsdStructCount = (content.match(/^\[agents\.gsd-executor\]\s*$/gm) || []).length;
|
||||
assert.strictEqual(gsdStructCount, 0, 'leaked [agents.gsd-executor] after marker is stripped (not regrown)');
|
||||
assert.ok(content.includes('[model]'), 'genuine user [model] after the leaked section still preserved');
|
||||
});
|
||||
|
||||
test('bareAgentsAfterMarkerHandled', () => {
|
||||
// Row 5: a user AgentsToml scalar (max_threads) the user folded INTO the managed [agents]
|
||||
// block (the valid, realistic shape — two [agents] tables would be invalid TOML), PLUS a
|
||||
// separate trailing [model] section. The fix must preserve the user scalar via the existing
|
||||
// spliceCodexAgentsScalars path AND preserve the trailing [model] via the new trailing-region
|
||||
// logic, while regenerating exactly one managed [agents] table.
|
||||
const configPath = path.join(tmpDir, 'config.toml');
|
||||
// Simulate: fresh install wrote the GSD block; the user then added max_threads into the
|
||||
// [agents] table and added a [model] section after it.
|
||||
const existing = [
|
||||
GSD_CODEX_MARKER,
|
||||
'',
|
||||
'[agents]',
|
||||
'max_depth = 1',
|
||||
'max_threads = 4',
|
||||
'',
|
||||
'[model]',
|
||||
'name = "o3"',
|
||||
].join('\n');
|
||||
fs.writeFileSync(configPath, existing + '\n');
|
||||
|
||||
mergeCodexConfig(configPath, block());
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
// The user's max_threads scalar is preserved (spliced into the regenerated managed [agents]);
|
||||
// there is exactly one [agents] table (the managed one).
|
||||
assert.ok(content.includes('max_threads = 4'), 'user AgentsToml scalar (max_threads) preserved in managed block');
|
||||
const agentsHeaders = (content.match(/^\[agents\]\s*$/gm) || []).length;
|
||||
assert.strictEqual(agentsHeaders, 1, 'exactly one [agents] table (the managed one)');
|
||||
assert.ok(content.includes('max_depth = 1'), 'GSD-managed max_depth still present');
|
||||
assert.ok(content.includes('[model]'), 'trailing [model] still preserved');
|
||||
});
|
||||
|
||||
test('beforeAndAfterMarkerBothPreserved', () => {
|
||||
// Row 6: content both BEFORE and AFTER the marker is preserved; GSD block regenerated once.
|
||||
const configPath = path.join(tmpDir, 'config.toml');
|
||||
const before = '[profiles.work]\nmodel = "gpt-5.4"\n';
|
||||
const after = '[mcp_servers.github]\ncommand = "gh-mcp"\n';
|
||||
fs.writeFileSync(configPath, before + '\n' + block() + '\n' + after + '\n');
|
||||
|
||||
mergeCodexConfig(configPath, block());
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
assert.ok(content.includes('[profiles.work]'), 'content before marker preserved');
|
||||
assert.ok(content.includes('[mcp_servers.github]'), 'content after marker preserved');
|
||||
const markerCount = (content.match(new RegExp(GSD_CODEX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
|
||||
assert.strictEqual(markerCount, 1, 'exactly one marker');
|
||||
});
|
||||
|
||||
test('noTrailingContentUnchanged', () => {
|
||||
// Row 7 (zero-trailing boundary): a config with ONLY the GSD block (fresh-install case)
|
||||
// re-merges to just the regenerated block — no spurious blank-line artifacts introduced
|
||||
// by the trailing-preservation logic.
|
||||
const configPath = path.join(tmpDir, 'config.toml');
|
||||
fs.writeFileSync(configPath, block() + '\n');
|
||||
|
||||
mergeCodexConfig(configPath, block());
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
// No spurious trailing blank lines beyond the single trailing newline. Use a CRLF-safe
|
||||
// pattern (\r?\n) so the assertion holds under Windows git-autocrlf line endings.
|
||||
assert.ok(!/(?:\r?\n){3,}$/.test(content), 'no spurious run of blank lines at end of file');
|
||||
assert.strictEqual(content.trim(), block().trim(), 'content is exactly the regenerated block (whitespace-trimmed)');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ─── Integration: installCodexConfig ────────────────────────────────────────────
|
||||
|
||||
describe('installCodexConfig (integration)', () => {
|
||||
@@ -2151,6 +2362,191 @@ describe('installCodexConfig (integration)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/issue-2834-codex-install-model-ordering.test.cjs — consolidation epic #1969 (H3 W4 #3336)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2834-codex-install-model-ordering (consolidation epic #1969 H3 W4 #3336)", () => {
|
||||
// allow-test-rule: structural-implementation-guard (#2834)
|
||||
'use strict';
|
||||
|
||||
// Regression guard for #2834: on a clean Codex install, agent TOMLs contained no
|
||||
// model-routing fields because defaults.json (resolve_model_ids + runtime) was written
|
||||
// AFTER installCodexConfig generated the TOMLs. The fix extracts writeNonClaudeDefaults
|
||||
// and calls it BEFORE installCodexConfig. This test asserts the ordering invariant in
|
||||
// the install source so a future edit can't silently re-introduce the gap.
|
||||
//
|
||||
// Verified non-duplicate: no existing coverage in this file asserts on
|
||||
// writeNonClaudeDefaults / the install-flow call ordering (source-text guard), and
|
||||
// none of the other three folded sources touch this.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
|
||||
test('writeNonClaudeDefaults is called before installCodexConfig in the Codex install flow (#2834)', () => {
|
||||
const src = fs.readFileSync(INSTALL_JS, 'utf8');
|
||||
|
||||
// Find the call to writeNonClaudeDefaults that precedes installCodexConfig.
|
||||
const writeIdx = src.indexOf('writeNonClaudeDefaults(runtime);');
|
||||
assert.ok(writeIdx !== -1, 'writeNonClaudeDefaults(runtime) must be called in the install flow');
|
||||
|
||||
// Find the FIRST installCodexConfig call AFTER the writeNonClaudeDefaults call.
|
||||
const codexGenIdx = src.indexOf('installCodexConfig(targetDir', writeIdx);
|
||||
assert.ok(codexGenIdx !== -1 && codexGenIdx > writeIdx,
|
||||
'installCodexConfig must be called AFTER writeNonClaudeDefaults so defaults.json ' +
|
||||
'(resolve_model_ids + runtime) exists before agent TOML generation reads it (#2834)');
|
||||
|
||||
// The #2834 comment must be present at the call site.
|
||||
const callSite = src.slice(writeIdx - 300, writeIdx + 100);
|
||||
assert.ok(/#2834/.test(callSite), 'the writeNonClaudeDefaults call must carry the #2834 rationale comment');
|
||||
});
|
||||
|
||||
test('writeNonClaudeDefaults function exists and is a no-op for Claude (#2834)', () => {
|
||||
const src = fs.readFileSync(INSTALL_JS, 'utf8');
|
||||
const fnIdx = src.indexOf('function writeNonClaudeDefaults(');
|
||||
assert.ok(fnIdx !== -1, 'writeNonClaudeDefaults must be defined as a function');
|
||||
const fnBody = src.slice(fnIdx, fnIdx + 1200);
|
||||
assert.ok(/nativeModelAliases/.test(fnBody), 'writeNonClaudeDefaults must early-return for Claude (nativeModelAliases check)');
|
||||
assert.ok(/resolve_model_ids/.test(fnBody), 'writeNonClaudeDefaults must write resolve_model_ids');
|
||||
assert.ok(/defaults\.runtime/.test(fnBody), 'writeNonClaudeDefaults must write runtime');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/issue-2639-codex-toml-neutralization.test.cjs — consolidation epic #1969 (H3 W4 #3336)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2639-codex-toml-neutralization (consolidation epic #1969 H3 W4 #3336)", () => {
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression: issue #2639 — Codex install generated agent TOMLs with stale
|
||||
* Claude-specific references (CLAUDE.md, .claude/skills/, .claudeignore).
|
||||
*
|
||||
* RCA: `installCodexConfig()` applied a narrow path-only regex pass before
|
||||
* calling `generateCodexAgentToml()`, bypassing the full
|
||||
* `convertClaudeToCodexMarkdown()` + `neutralizeAgentReferences(..., 'AGENTS.md')`
|
||||
* pipeline used on the .md emit path. Fix routes the TOML path through the
|
||||
* same pipeline and extends the pipeline to cover bare `.claude/skills/`,
|
||||
* `.claude/commands/`, `.claude/agents/`, and `.claudeignore`.
|
||||
*
|
||||
* Verified non-duplicate: the pre-existing 'generateCodexAgentToml' suite covers
|
||||
* model_overrides/sandbox_mode/reasoning-effort, not CLAUDE.md/.claudeignore/skills-path
|
||||
* neutralization in the emitted TOML; the '#570 — Codex leak scanner sub-bugs' suite
|
||||
* covers ~/.claude path leaks via convertClaudeToCodexMarkdown but not the
|
||||
* installCodexConfig()-level TOML-emit pipeline this regression targets.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const { installCodexConfig } = require('../bin/install.js');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
function makeTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2639-'));
|
||||
}
|
||||
|
||||
function writeAgentFixture(agentsSrc, name, body) {
|
||||
const content = `---
|
||||
name: ${name}
|
||||
description: Test agent for #2639
|
||||
---
|
||||
|
||||
${body}
|
||||
`;
|
||||
fs.writeFileSync(path.join(agentsSrc, `${name}.md`), content);
|
||||
}
|
||||
|
||||
describe('#2639 — Codex TOML emit routes through full neutralization pipeline', () => {
|
||||
let tmpDir;
|
||||
let agentsSrc;
|
||||
let targetDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTempDir();
|
||||
agentsSrc = path.join(tmpDir, 'agents');
|
||||
targetDir = path.join(tmpDir, 'codex');
|
||||
fs.mkdirSync(agentsSrc, { recursive: true });
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('strips CLAUDE.md, .claude/skills/, .claude/commands/, .claude/agents/, and .claudeignore from emitted TOML', () => {
|
||||
writeAgentFixture(agentsSrc, 'gsd-code-reviewer', [
|
||||
'**Project instructions:** Read `./CLAUDE.md` if it exists.',
|
||||
'',
|
||||
'**CLAUDE.md enforcement:** If `./CLAUDE.md` exists, treat it as hard constraints.',
|
||||
'',
|
||||
'**Project skills:** Check `.claude/skills/` or `.agents/skills/` directory.',
|
||||
'',
|
||||
'Also check `.claude/commands/` and `.claude/agents/` for definitions.',
|
||||
'',
|
||||
'DO respect .gitignore and .claudeignore. Do not review ignored files.',
|
||||
'',
|
||||
'Claude will refuse the task if policy violated.',
|
||||
].join('\n'));
|
||||
|
||||
installCodexConfig(targetDir, agentsSrc);
|
||||
|
||||
const tomlPath = path.join(targetDir, 'agents', 'gsd-code-reviewer.toml');
|
||||
assert.ok(fs.existsSync(tomlPath), 'per-agent TOML written');
|
||||
const toml = fs.readFileSync(tomlPath, 'utf8');
|
||||
|
||||
assert.ok(!toml.includes('CLAUDE.md'), 'no CLAUDE.md references remain in TOML');
|
||||
assert.ok(!toml.includes('.claude/skills/'), 'no .claude/skills/ references remain');
|
||||
assert.ok(!toml.includes('.claude/commands/'), 'no .claude/commands/ references remain');
|
||||
assert.ok(!toml.includes('.claude/agents/'), 'no .claude/agents/ references remain');
|
||||
assert.ok(!toml.includes('.claudeignore'), 'no .claudeignore references remain');
|
||||
|
||||
assert.ok(toml.includes('AGENTS.md'), 'AGENTS.md substituted for CLAUDE.md');
|
||||
assert.ok(
|
||||
toml.includes('.codex/skills/') || toml.includes('.agents/skills/'),
|
||||
'skills path neutralized'
|
||||
);
|
||||
|
||||
// Standalone "Claude" agent-name references replaced
|
||||
assert.ok(!/\bClaude\b(?! Code| Opus| Sonnet| Haiku| native| based)/.test(toml),
|
||||
'standalone Claude agent-name references replaced');
|
||||
});
|
||||
|
||||
test('preserves Claude product/model names (Claude Code, Claude Opus) in TOML', () => {
|
||||
writeAgentFixture(agentsSrc, 'gsd-executor', [
|
||||
'This agent runs under Claude Code with the Claude Opus 4 model.',
|
||||
'Do not confuse with Claude Sonnet or Claude Haiku.',
|
||||
].join('\n'));
|
||||
|
||||
installCodexConfig(targetDir, agentsSrc);
|
||||
const toml = fs.readFileSync(path.join(targetDir, 'agents', 'gsd-executor.toml'), 'utf8');
|
||||
|
||||
assert.ok(toml.includes('Claude Code'), 'Claude Code product name preserved');
|
||||
assert.ok(toml.includes('Claude Opus'), 'Claude Opus model name preserved');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ─── Codex config.toml [features] safety (#1202) ─────────────────────────────
|
||||
|
||||
describe('codex features section safety', () => {
|
||||
@@ -2920,6 +3316,363 @@ describe('Codex install hook configuration (e2e)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/issue-2695-codex-hook-set.test.cjs — consolidation epic #1969 (H3 W4 #3336)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2695-codex-hook-set (consolidation epic #1969 H3 W4 #3336)", () => {
|
||||
// Regression tests for #2695 — Codex native updates omit the update-hook worker
|
||||
// and the managed-hooks registry.
|
||||
//
|
||||
// The Codex install branch in bin/install.js used to allowlist only two of the
|
||||
// four hook files the shipped build emits (gsd-check-update.js +
|
||||
// gsd-context-monitor.js), and gated the entire branch on !isMinimalMode so the
|
||||
// `core` profile installed none of them. The parent SessionStart hook spawn()s
|
||||
// the worker, which require()s the registry — so Codex was wired to a dependency
|
||||
// chain the same installer never delivered.
|
||||
//
|
||||
// These tests drive the real installer (bin/install.js) behaviorally into an
|
||||
// isolated temp config dir and assert the complete four-file set is delivered
|
||||
// for both profiles, the registry is byte-for-byte, the version stamps resolve
|
||||
// to the installed package version, and unrelated user files are preserved.
|
||||
//
|
||||
// Verified non-duplicate: the pre-existing 'Codex install hook configuration
|
||||
// (e2e)' suite above only asserts gsd-check-update.js delivery/wiring — it never
|
||||
// asserts on gsd-check-update-worker.js, managed-hooks-registry.cjs, or
|
||||
// gsd-context-monitor.js delivery, the core/full profile matrix, upgrade-refresh,
|
||||
// byte-for-byte registry copy, idempotency of the four-file set, user-file
|
||||
// preservation, or the core-profile negative-space (no agent files) — all
|
||||
// genuinely distinct assertions this fold adds.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { runNode } = require('./helpers/process-seam.cjs');
|
||||
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const {
|
||||
INSTALL_SCRIPT,
|
||||
BUILD_SCRIPT,
|
||||
HOOKS_DIST,
|
||||
installerEnv,
|
||||
} = require('./helpers/install-shared.cjs');
|
||||
|
||||
const PKG_VERSION = require('../package.json').version;
|
||||
|
||||
// #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs.
|
||||
const {
|
||||
BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS,
|
||||
INSTALL_TIMEOUT_MS,
|
||||
} = require('./helpers/timeouts.cjs');
|
||||
|
||||
// The four-file hook set the Codex surface must deliver together (#2695).
|
||||
const CODEX_HOOK_FILES = [
|
||||
'gsd-check-update.js',
|
||||
'gsd-check-update-worker.js',
|
||||
'managed-hooks-registry.cjs',
|
||||
'gsd-context-monitor.js',
|
||||
];
|
||||
|
||||
// Build hooks/dist before any install runs (the installer copies from there).
|
||||
before(() => {
|
||||
const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS });
|
||||
throwIfFailed(r, `node ${BUILD_SCRIPT}`);
|
||||
});
|
||||
|
||||
function hooksDirOf(configDir) {
|
||||
return path.join(configDir, 'hooks');
|
||||
}
|
||||
|
||||
/** Run the Codex installer into an isolated temp config dir. */
|
||||
function runCodexInstall({ profile, preseed }) {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-2695-${profile}-`));
|
||||
if (preseed) {
|
||||
const hooksDest = hooksDirOf(configDir);
|
||||
fs.mkdirSync(hooksDest, { recursive: true });
|
||||
for (const [name, body] of Object.entries(preseed)) {
|
||||
fs.writeFileSync(path.join(hooksDest, name), body);
|
||||
}
|
||||
}
|
||||
// Sandbox HOME/USERPROFILE to configDir: Codex's skills-kind `home: ".agents"`
|
||||
// override resolves via os.homedir(); sandboxing keeps the spawn self-contained
|
||||
// (mirrors tests/install-minimal-hooks.test.cjs Codex downgrade test).
|
||||
const result = runNode(
|
||||
[INSTALL_SCRIPT, '--codex', '--global', '--config-dir', configDir, `--profile=${profile}`],
|
||||
{ env: installerEnv({ HOME: configDir, USERPROFILE: configDir }), timeoutMs: INSTALL_TIMEOUT_MS },
|
||||
);
|
||||
return { configDir, result };
|
||||
}
|
||||
|
||||
// Older-version stamp used to pre-seed an "upgrade" scenario.
|
||||
const OLDER_VERSION = '1.7.0';
|
||||
|
||||
describe('#2695: fresh Codex installs deliver the complete four-file hook set', () => {
|
||||
for (const profile of ['core', 'full']) {
|
||||
test(`fresh --profile=${profile} installs all four hook files`, (t) => {
|
||||
const { configDir, result } = runCodexInstall({ profile });
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const hooksDir = hooksDirOf(configDir);
|
||||
for (const file of CODEX_HOOK_FILES) {
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(hooksDir, file)),
|
||||
`expected ${file} under <config>/hooks for --profile=${profile}\n` +
|
||||
`installer stdout: ${result.stdout}\ninstaller stderr: ${result.stderr}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('#2695: Codex upgrades refresh all four hook files to the current version', () => {
|
||||
// Pre-seed all four files stamped at OLDER_VERSION so an upgrade must overwrite them.
|
||||
function olderSeed() {
|
||||
const seed = {};
|
||||
for (const name of CODEX_HOOK_FILES) {
|
||||
// Registry carries no version token; seed it with a stale sentinel body.
|
||||
if (name.endsWith('.cjs')) {
|
||||
seed[name] = `// stale registry ${OLDER_VERSION}\nmodule.exports = {};\n`;
|
||||
} else {
|
||||
seed[name] = `// gsd-hook-version: ${OLDER_VERSION}\n// stale\n`;
|
||||
}
|
||||
}
|
||||
return seed;
|
||||
}
|
||||
|
||||
for (const profile of ['core', 'full']) {
|
||||
test(`--profile=${profile} upgrade refreshes all four hook files`, (t) => {
|
||||
const { configDir, result } = runCodexInstall({ profile, preseed: olderSeed() });
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const hooksDir = hooksDirOf(configDir);
|
||||
// All four must now carry the current version stamp where one exists, and
|
||||
// the registry must no longer be the stale sentinel.
|
||||
for (const name of CODEX_HOOK_FILES) {
|
||||
const dest = path.join(hooksDir, name);
|
||||
assert.ok(
|
||||
fs.existsSync(dest),
|
||||
`expected refreshed ${name} for --profile=${profile}\n` +
|
||||
`installer stdout: ${result.stdout}\ninstaller stderr: ${result.stderr}`,
|
||||
);
|
||||
}
|
||||
// The registry must be REFRESHED on upgrade, not merely present: assert it no
|
||||
// longer carries the stale sentinel and now matches the shipped dist byte-for-byte
|
||||
// (the raw-copy fallback must overwrite an existing dest, not skip it).
|
||||
const registryDest = path.join(hooksDir, 'managed-hooks-registry.cjs');
|
||||
const registryBytes = fs.readFileSync(registryDest, 'utf8');
|
||||
assert.ok(
|
||||
!registryBytes.includes(`stale registry ${OLDER_VERSION}`),
|
||||
`registry must be refreshed on upgrade for --profile=${profile} (still carries the stale sentinel)`,
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
fs.readFileSync(registryDest),
|
||||
fs.readFileSync(path.join(HOOKS_DIST, 'managed-hooks-registry.cjs')),
|
||||
`refreshed registry must match hooks/dist byte-for-byte for --profile=${profile}`,
|
||||
);
|
||||
// Version stamps resolved (acceptance #2/#3).
|
||||
const workerStamp = readHookVersionLine(path.join(hooksDir, 'gsd-check-update-worker.js'));
|
||||
assert.strictEqual(
|
||||
workerStamp, PKG_VERSION,
|
||||
`worker gsd-hook-version stamp must be the installed package version (${PKG_VERSION}), ` +
|
||||
`got "${workerStamp}" for --profile=${profile}`,
|
||||
);
|
||||
const parentStamp = readHookVersionLine(path.join(hooksDir, 'gsd-check-update.js'));
|
||||
assert.strictEqual(
|
||||
parentStamp, PKG_VERSION,
|
||||
`parent gsd-check-update stamp must be the installed package version (${PKG_VERSION}), ` +
|
||||
`got "${parentStamp}" for --profile=${profile}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('#2695: managed-hooks-registry.cjs is copied byte-for-byte', () => {
|
||||
for (const profile of ['core', 'full']) {
|
||||
test(`--profile=${profile} registry matches hooks/dist byte-for-byte`, (t) => {
|
||||
const { configDir, result } = runCodexInstall({ profile });
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const dest = path.join(hooksDirOf(configDir), 'managed-hooks-registry.cjs');
|
||||
assert.ok(fs.existsSync(dest), `registry missing for --profile=${profile}\nstdout: ${result.stdout}`);
|
||||
const distBytes = fs.readFileSync(path.join(HOOKS_DIST, 'managed-hooks-registry.cjs'));
|
||||
const destBytes = fs.readFileSync(dest);
|
||||
assert.deepStrictEqual(
|
||||
destBytes, distBytes,
|
||||
`managed-hooks-registry.cjs must be copied byte-for-byte (no version/path transform) for --profile=${profile}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('#2695: worker hook-version stamp is a literal install-time value', () => {
|
||||
test('the stamp is the literal package version, never a placeholder or a runtime lookup', (t) => {
|
||||
const { configDir } = runCodexInstall({ profile: 'full' });
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const workerPath = path.join(hooksDirOf(configDir), 'gsd-check-update-worker.js');
|
||||
const content = fs.readFileSync(workerPath, 'utf8');
|
||||
// The placeholder must have been replaced — a leftover {{GSD_VERSION}} is the bug shape.
|
||||
assert.ok(
|
||||
!content.includes('{{GSD_VERSION}}'),
|
||||
'worker still carries an unresolved {{GSD_VERSION}} placeholder — stamping did not run',
|
||||
);
|
||||
// And the resolved value must be the literal version, present on the version-comment line.
|
||||
const stamp = readHookVersionLine(workerPath);
|
||||
assert.strictEqual(stamp, PKG_VERSION, `worker stamp must equal package.json version, got "${stamp}"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2695: unrelated user-owned hook files are preserved', () => {
|
||||
for (const profile of ['core', 'full']) {
|
||||
test(`--profile=${profile} leaves a pre-existing user hook untouched`, (t) => {
|
||||
const userOwned = 'my-custom-hook.js';
|
||||
const userBody = '// user-owned hook — do not touch\nconsole.log("mine");\n';
|
||||
const { configDir, result } = runCodexInstall({ profile, preseed: { [userOwned]: userBody } });
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const dest = path.join(hooksDirOf(configDir), userOwned);
|
||||
assert.ok(fs.existsSync(dest), `user-owned ${userOwned} must be preserved for --profile=${profile}\nstdout: ${result.stdout}`);
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(dest, 'utf8'), userBody,
|
||||
`user-owned ${userOwned} bytes must be unchanged for --profile=${profile}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('#2695: re-running the installer is idempotent for the four-file set', () => {
|
||||
test('a second full install leaves all four files present and correctly stamped', (t) => {
|
||||
const first = runCodexInstall({ profile: 'full' });
|
||||
t.after(() => cleanup(first.configDir));
|
||||
// Second run into the SAME config dir.
|
||||
const result2 = runNode(
|
||||
[INSTALL_SCRIPT, '--codex', '--global', '--config-dir', first.configDir, '--profile=full'],
|
||||
{ env: installerEnv({ HOME: first.configDir, USERPROFILE: first.configDir }), timeoutMs: INSTALL_TIMEOUT_MS },
|
||||
);
|
||||
assert.ok(result2.stdout || result2.stderr);
|
||||
|
||||
const hooksDir = hooksDirOf(first.configDir);
|
||||
for (const name of CODEX_HOOK_FILES) {
|
||||
assert.ok(fs.existsSync(path.join(hooksDir, name)), `${name} must survive a second install`);
|
||||
}
|
||||
assert.strictEqual(
|
||||
readHookVersionLine(path.join(hooksDir, 'gsd-check-update-worker.js')),
|
||||
PKG_VERSION,
|
||||
'worker stamp must remain correct after a second install',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2695: the core profile enables the hook feature and wires SessionStart (intended)', () => {
|
||||
// For the update-check/context-monitor hooks to actually fire, Codex needs both
|
||||
// the feature flag in config.toml AND the hooks.json routing — copying inert
|
||||
// files alone would leave `core` with scripts Codex never invokes. Entering the
|
||||
// codex-toml branch for `core` (the #2695 gate change) synthesizes `[features]
|
||||
// hooks = true` via ensureCodexHooksFeature, writes config.toml, and registers
|
||||
// the hooks. This is the intended behavior of the fix, not a side effect — these
|
||||
// assertions pin it so a future re-gating cannot silently regress it.
|
||||
test('--profile=core writes config.toml enabling the hooks feature', (t) => {
|
||||
const { configDir } = runCodexInstall({ profile: 'core' });
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const configPath = path.join(configDir, 'config.toml');
|
||||
assert.ok(fs.existsSync(configPath), 'core must write config.toml so the hooks feature is enabled');
|
||||
const config = fs.readFileSync(configPath, 'utf8');
|
||||
assert.ok(/^\s*hooks\s*=\s*true\s*$/m.test(config), 'config.toml must enable hooks = true for core');
|
||||
});
|
||||
|
||||
test('--profile=core wires the SessionStart update-check hook in hooks.json', (t) => {
|
||||
const { configDir } = runCodexInstall({ profile: 'core' });
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const hooksJsonPath = path.join(configDir, 'hooks.json');
|
||||
assert.ok(fs.existsSync(hooksJsonPath), 'core must write hooks.json');
|
||||
const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
||||
const sessionStartCmds = collectHookCommands(hooksJson, 'SessionStart');
|
||||
// The command points at the gsd-check-update hook script. Its extension is
|
||||
// platform-specific — Windows routes through a .cmd shim, POSIX through .js —
|
||||
// so assert on the basename prefix, not a hardcoded extension (Windows parity).
|
||||
const routedToUpdateHook = sessionStartCmds.some((c) => {
|
||||
const token = c.replace(/"/g, '').replace(/\\/g, '/');
|
||||
const segs = token.split('/');
|
||||
const last = segs[segs.length - 1];
|
||||
return last.startsWith('gsd-check-update.');
|
||||
});
|
||||
assert.ok(
|
||||
routedToUpdateHook,
|
||||
`core must route SessionStart to the gsd-check-update hook in hooks.json; got: ${JSON.stringify(sessionStartCmds)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2695: the core profile still installs no agent files (negative space)', () => {
|
||||
test('--profile=core delivers hooks but no gsd-* agent files', (t) => {
|
||||
const { configDir } = runCodexInstall({ profile: 'core' });
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
// Hooks delivered (the fix)…
|
||||
for (const name of CODEX_HOOK_FILES) {
|
||||
assert.ok(fs.existsSync(path.join(hooksDirOf(configDir), name)), `${name} delivered for core`);
|
||||
}
|
||||
// …but the full agent surface is still absent (core stays minimal). Codex agents
|
||||
// are .toml ([agents.gsd-*] in config.toml + agents/gsd-*.toml), so check both
|
||||
// extensions — a .md-only filter would miss a Codex agent-surface regression.
|
||||
const agentsDir = path.join(configDir, 'agents');
|
||||
if (fs.existsSync(agentsDir)) {
|
||||
const gsdAgents = fs.readdirSync(agentsDir).filter(
|
||||
(f) => f.startsWith('gsd-') && (f.endsWith('.md') || f.endsWith('.toml')),
|
||||
);
|
||||
assert.deepStrictEqual(gsdAgents, [], 'core must not install the full agent surface');
|
||||
}
|
||||
// And config.toml must carry no agent role sections.
|
||||
const configPath = path.join(configDir, 'config.toml');
|
||||
if (fs.existsSync(configPath)) {
|
||||
const config = fs.readFileSync(configPath, 'utf8');
|
||||
assert.ok(
|
||||
!/^\[agents\.gsd-/m.test(config),
|
||||
'core config.toml must not declare [agents.gsd-*] roles (full agent surface stays a full-profile concern)',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Read the `// gsd-hook-version: <value>` comment value from a hook file.
|
||||
* Returns the trimmed literal. Used so tests assert on the structured stamp,
|
||||
* not on raw `.includes()` prose (CONTRIBUTING raw-text-matching rule).
|
||||
*/
|
||||
function readHookVersionLine(hookPath) {
|
||||
const content = fs.readFileSync(hookPath, 'utf8');
|
||||
const m = content.match(/^\/\/ gsd-hook-version:\s*(.+?)\s*$/m);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every hook command string registered under a given Codex hooks.json
|
||||
* event key. Used so the SessionStart-wiring test asserts on the structured
|
||||
* hook entries (commands), not on raw text matching against the whole file.
|
||||
*/
|
||||
function collectHookCommands(hooksJson, eventName) {
|
||||
const entries = (hooksJson && hooksJson.hooks && Array.isArray(hooksJson.hooks[eventName]))
|
||||
? hooksJson.hooks[eventName]
|
||||
: [];
|
||||
return entries.flatMap((entry) =>
|
||||
(entry && Array.isArray(entry.hooks) ? entry.hooks : [])
|
||||
.map((h) => (h && typeof h.command === 'string' ? h.command : null))
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
describe('Codex uninstall symmetry for hook-enabled configs', () => {
|
||||
let tmpDir;
|
||||
let codexHome;
|
||||
|
||||
@@ -28,10 +28,11 @@
|
||||
* function or the exclusion constants.
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
@@ -110,3 +111,174 @@ for (const consumer of CONSUMERS) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/issue-1575-agent-descriptor-parity.test.cjs — consolidation epic #1969 (H3 #3336)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe('folded:issue-1575-agent-descriptor-parity', () => {
|
||||
|
||||
// --- #1575 — surface/install byte-identical agent-output parity (ADR-1235 §0) ---
|
||||
// Folded in from tests/issue-1575-agent-descriptor-parity.test.cjs (H3 wave 4,
|
||||
// #3336). Distinct anti-divergence concern from the manifest-builder guards
|
||||
// above: this asserts applySurface() and installRuntimeArtifacts() produce
|
||||
// byte-identical agent output for every descriptor-driven runtime, run against
|
||||
// the SAME configDir so pathPrefix/attribution/converter outputs must match.
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs');
|
||||
const { applySurface } = require('../gsd-core/bin/lib/surface.cjs');
|
||||
const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const COMMANDS_GSD = path.join(ROOT, 'commands', 'gsd');
|
||||
|
||||
// The 7 descriptor-driven agent runtimes (cline deferred per code comment:
|
||||
// rules-only local branch + local/global complication).
|
||||
const DESCRIPTOR_RUNTIMES = [
|
||||
'cursor',
|
||||
'windsurf',
|
||||
'augment',
|
||||
'trae',
|
||||
'codebuddy',
|
||||
'copilot',
|
||||
'antigravity',
|
||||
];
|
||||
|
||||
function snapshotAgents(agentsDir) {
|
||||
const snap = new Map();
|
||||
if (!fs.existsSync(agentsDir)) return snap;
|
||||
for (const name of fs.readdirSync(agentsDir)) {
|
||||
if (!name.startsWith('gsd-')) continue;
|
||||
if (!name.endsWith('.md') && !name.endsWith('.agent.md')) continue;
|
||||
snap.set(name, fs.readFileSync(path.join(agentsDir, name), 'utf8'));
|
||||
}
|
||||
return snap;
|
||||
}
|
||||
|
||||
// Shared manifest + profile so both paths see the same source agents.
|
||||
const parity1575Manifest = loadSkillsManifest(COMMANDS_GSD);
|
||||
const parity1575Profile = resolveProfile({ modes: ['full'], manifest: parity1575Manifest });
|
||||
// Same attribution resolver for both paths (undefined → no Co-Authored-By mutation).
|
||||
const resolveAttribution1575 = () => undefined;
|
||||
|
||||
describe('#1575 — golden-parity: surface path matches install path for descriptor-driven agents', () => {
|
||||
|
||||
for (const runtime of DESCRIPTOR_RUNTIMES) {
|
||||
test(`${runtime}: surface agents byte-identical to install agents`, (t) => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-1575-${runtime}-`));
|
||||
t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } });
|
||||
|
||||
// Step 1: install path writes agents
|
||||
installRuntimeArtifacts(runtime, configDir, 'global', parity1575Profile, resolveAttribution1575);
|
||||
|
||||
// Step 2: snapshot agent files
|
||||
const agentsDir = path.join(configDir, 'agents');
|
||||
const installSnap = snapshotAgents(agentsDir);
|
||||
assert.ok(installSnap.size > 0, `${runtime}: install must produce at least one gsd-* agent`);
|
||||
|
||||
// Step 3: surface path re-materializes into the SAME configDir
|
||||
const layout = resolveRuntimeArtifactLayout(runtime, configDir, 'global');
|
||||
applySurface(configDir, layout, parity1575Manifest, undefined, undefined, { resolveAttribution: resolveAttribution1575 });
|
||||
|
||||
// Step 4: compare byte-for-byte
|
||||
const surfaceSnap = snapshotAgents(agentsDir);
|
||||
|
||||
// File lists must match
|
||||
const installFiles = [...installSnap.keys()].sort();
|
||||
const surfaceFiles = [...surfaceSnap.keys()].sort();
|
||||
assert.deepEqual(
|
||||
surfaceFiles,
|
||||
installFiles,
|
||||
`${runtime}: file lists must match after surface. Install: [${installFiles.join(', ')}] Surface: [${surfaceFiles.join(', ')}]`,
|
||||
);
|
||||
|
||||
// Content must match byte-for-byte
|
||||
for (const [fileName, installContent] of installSnap) {
|
||||
const surfaceContent = surfaceSnap.get(fileName);
|
||||
assert.strictEqual(
|
||||
surfaceContent,
|
||||
installContent,
|
||||
`${runtime}/${fileName}: surface content must be byte-identical to install content`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('cursor with non-undefined attribution: surface agents byte-identical to install agents (M2 coverage)', (t) => {
|
||||
// M2 regression guard: verify parity holds when resolveAttribution returns
|
||||
// a real value. Source agents don't carry Co-Authored-By, so processAttribution
|
||||
// is a no-op (it replaces existing lines, doesn't add new ones). But this test
|
||||
// proves the agentCtx threading is correct for both paths regardless.
|
||||
const attrResolver = () => 'Test Bot <test@example.com>';
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1575-attr-'));
|
||||
t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } });
|
||||
|
||||
installRuntimeArtifacts('cursor', configDir, 'global', parity1575Profile, attrResolver);
|
||||
|
||||
const agentsDir = path.join(configDir, 'agents');
|
||||
const installSnap = snapshotAgents(agentsDir);
|
||||
assert.ok(installSnap.size > 0, 'install must produce agents');
|
||||
|
||||
const layout = resolveRuntimeArtifactLayout('cursor', configDir, 'global');
|
||||
applySurface(configDir, layout, parity1575Manifest, undefined, undefined, { resolveAttribution: attrResolver });
|
||||
|
||||
const surfaceSnap = snapshotAgents(agentsDir);
|
||||
for (const [fileName, installContent] of installSnap) {
|
||||
assert.strictEqual(surfaceSnap.get(fileName), installContent,
|
||||
`cursor/${fileName}: content must be byte-identical with non-undefined attribution`);
|
||||
}
|
||||
});
|
||||
|
||||
test('copilot: agents installed as .agent.md (filename rename parity)', (t) => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1575-copilot-rename-'));
|
||||
t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } });
|
||||
|
||||
installRuntimeArtifacts('copilot', configDir, 'global', parity1575Profile, resolveAttribution1575);
|
||||
|
||||
const agentsDir = path.join(configDir, 'agents');
|
||||
assert.ok(fs.existsSync(agentsDir), 'copilot agents dir must exist');
|
||||
const agentFiles = fs.readdirSync(agentsDir).filter((f) => f.startsWith('gsd-'));
|
||||
assert.ok(agentFiles.length > 0, 'copilot must have installed agents');
|
||||
assert.ok(
|
||||
agentFiles.every((f) => f.endsWith('.agent.md')),
|
||||
`copilot agents must be .agent.md, got: [${agentFiles.slice(0, 3).join(', ')}]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#1575 — surface path: no prune data-loss over pre-existing legacy agents', () => {
|
||||
test('pre-existing gsd-* agents not in staged set are pruned; user agents preserved', (t) => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1575-prune-'));
|
||||
t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } });
|
||||
|
||||
// Seed a pre-existing legacy .agent.md (simulating a prior install)
|
||||
const agentsDir = path.join(configDir, 'agents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(agentsDir, 'gsd-old-defunct.agent.md'), '# Old\n');
|
||||
fs.writeFileSync(path.join(agentsDir, 'user-custom.md'), '# User\n');
|
||||
|
||||
// Install (should prune stale gsd-*, preserve user agents)
|
||||
installRuntimeArtifacts('copilot', configDir, 'global', parity1575Profile, resolveAttribution1575);
|
||||
|
||||
const afterInstall = fs.readdirSync(agentsDir);
|
||||
assert.ok(!afterInstall.includes('gsd-old-defunct.agent.md'), 'stale gsd-* agent must be pruned');
|
||||
assert.ok(afterInstall.includes('user-custom.md'), 'user agent must be preserved');
|
||||
|
||||
// Now surface over the install — must converge to the same state
|
||||
const layout = resolveRuntimeArtifactLayout('copilot', configDir, 'global');
|
||||
applySurface(configDir, layout, parity1575Manifest, undefined, undefined, { resolveAttribution: resolveAttribution1575 });
|
||||
|
||||
const afterSurface = fs.readdirSync(agentsDir);
|
||||
// Same set of agent files as after install
|
||||
const installAgents = afterInstall.filter((f) => f.startsWith('gsd-')).sort();
|
||||
const surfaceAgents = afterSurface.filter((f) => f.startsWith('gsd-')).sort();
|
||||
assert.deepEqual(surfaceAgents, installAgents, 'surface must converge to same agent set as install');
|
||||
assert.ok(afterSurface.includes('user-custom.md'), 'user agent still preserved after surface');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7444,3 +7444,315 @@ describe('#3184: scripts/lib/ and scripts/changeset/ install/uninstall parity',
|
||||
'array so uninstall() removes it (otherwise it ships to every install and orphans on uninstall).');
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/issue-607-installer-dry-run.install.test.cjs — test-hygiene
|
||||
// sweep (H3 Wave 4, #3336)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:issue-607-installer-dry-run (test-hygiene sweep #3336 H3 Wave 4)", () => {
|
||||
// allow-test-rule: integration-test-input (#607)
|
||||
// Test-created temp dirs are the only filesystem reads here — not repo source files.
|
||||
// This is an integration test that seeds fixture files in OS temp dirs and
|
||||
// asserts that the installer correctly handles --dry-run and the
|
||||
// cleanupLegacyGsdCc exported helper.
|
||||
|
||||
/**
|
||||
* #607 — --dry-run flag and cleanupLegacyGsdCc wiring.
|
||||
*
|
||||
* Covers:
|
||||
* 1. Spawning `node bin/install.js --claude --global --dry-run` with an
|
||||
* isolated HOME that contains a seeded legacy artifact. Asserts exit 0,
|
||||
* stdout names the artifact and contains "dry" (case-insensitive), and
|
||||
* no files are mutated (artifact still present; no .claude install).
|
||||
* Also asserts the per-package cache path appears AT MOST ONCE (no
|
||||
* double-print regression).
|
||||
* 2. Spawning `node bin/install.js --claude --dry-run --uninstall` asserts
|
||||
* the "does not preview --uninstall" warning prints and exits 0 without
|
||||
* uninstalling anything.
|
||||
* 3. Direct unit call to the exported cleanupLegacyGsdCc helper:
|
||||
* - dryRun:true → plan lists the artifact, removes nothing.
|
||||
* - dryRun:false → seeded leftover removed, dev-preferences.md preserved.
|
||||
*/
|
||||
|
||||
'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 os = require('node:os');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const INSTALL_BIN = path.join(REPO_ROOT, 'bin', 'install.js');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function mkTmp(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function writeFile(filePath, content) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
}
|
||||
|
||||
// The assembled signal string used as file content to trigger
|
||||
// content-references-old-package detection.
|
||||
const LEGACY_PKG_SIGNAL = 'gsd-core' + '-cc';
|
||||
|
||||
// ─── Suite 1: spawn --dry-run, assert no mutations ───────────────────────────
|
||||
|
||||
describe('#607 --dry-run flag: spawned installer exits 0 and mutates nothing', () => {
|
||||
let tmpHome;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = mkTmp('gsd-607-dryhome-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpHome);
|
||||
});
|
||||
|
||||
test('exits 0; stdout names artifact and contains "dry"; no install; artifact preserved; no double-print', () => {
|
||||
// Seed a legacy artifact: a .cjs hook file under HOME/.gemini/hooks/ whose
|
||||
// content contains the old package name (content-signal, not orphan-by-name).
|
||||
// This exercises the content-references-old-package reason exclusively.
|
||||
const legacyHook = path.join(tmpHome, '.gemini', 'hooks', 'gsd-old-update-worker.cjs');
|
||||
writeFile(legacyHook, `// installed via ${LEGACY_PKG_SIGNAL}\nconsole.log("old worker");`);
|
||||
|
||||
// Seed the legacy shared cache file
|
||||
const legacyCache = path.join(tmpHome, '.cache', 'gsd', 'gsd-update-check.json');
|
||||
writeFile(legacyCache, JSON.stringify({ legacy: true }));
|
||||
|
||||
// Spawn the installer with --dry-run
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_BIN, '--claude', '--global', '--dry-run'],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: tmpHome,
|
||||
USERPROFILE: tmpHome,
|
||||
// Redirect Claude config dir into isolated tmp home
|
||||
CLAUDE_CONFIG_DIR: path.join(tmpHome, '.claude'),
|
||||
// Suppress slow stale-SDK npm check
|
||||
GSD_SKIP_STALE_SDK_CHECK: '1',
|
||||
// Do NOT set GSD_TEST_MODE — we want the main() block to run
|
||||
GSD_TEST_MODE: undefined,
|
||||
},
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
}
|
||||
);
|
||||
|
||||
// Exit code must be 0
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`Expected exit 0 but got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
|
||||
);
|
||||
|
||||
const stdout = result.stdout + result.stderr;
|
||||
|
||||
// stdout must contain the word "dry" (case-insensitive)
|
||||
assert.match(
|
||||
stdout,
|
||||
/dry/i,
|
||||
`Expected stdout to contain "dry". Got:\n${stdout}`
|
||||
);
|
||||
|
||||
// stdout must mention the seeded legacy artifact path
|
||||
assert.ok(
|
||||
stdout.includes(legacyHook),
|
||||
`Expected stdout to mention ${legacyHook}.\nGot:\n${stdout}`
|
||||
);
|
||||
|
||||
// The seeded artifact must STILL EXIST (no mutations)
|
||||
assert.ok(
|
||||
fs.existsSync(legacyHook),
|
||||
`Legacy hook must still exist after --dry-run: ${legacyHook}`
|
||||
);
|
||||
|
||||
// The legacy cache must STILL EXIST
|
||||
assert.ok(
|
||||
fs.existsSync(legacyCache),
|
||||
`Legacy cache must still exist after --dry-run: ${legacyCache}`
|
||||
);
|
||||
|
||||
// No actual install happened — .claude/gsd-core must not exist
|
||||
const installDir = path.join(tmpHome, '.claude', 'gsd-core');
|
||||
assert.equal(
|
||||
fs.existsSync(installDir),
|
||||
false,
|
||||
`No install should happen during --dry-run; found: ${installDir}`
|
||||
);
|
||||
|
||||
// Regression: the per-package cache path must appear AT MOST ONCE
|
||||
// (guard against the duplicate-print bug where it was printed both inside
|
||||
// cleanupLegacyGsdCc and again in the outer --dry-run block).
|
||||
const updateCacheFileName = require(
|
||||
path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'package-identity.cjs')
|
||||
).updateCacheFileName;
|
||||
const perPkgCacheFile = path.join(tmpHome, '.cache', 'gsd', updateCacheFileName);
|
||||
const occurrences = stdout.split(perPkgCacheFile).length - 1;
|
||||
assert.ok(
|
||||
occurrences <= 1,
|
||||
`Per-package cache path must appear at most once in stdout; found ${occurrences} times.\nstdout:\n${stdout}`
|
||||
);
|
||||
});
|
||||
|
||||
test('--uninstall --dry-run prints "does not preview --uninstall" warning and exits 0', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_BIN, '--claude', '--uninstall', '--dry-run'],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: tmpHome,
|
||||
USERPROFILE: tmpHome,
|
||||
CLAUDE_CONFIG_DIR: path.join(tmpHome, '.claude'),
|
||||
GSD_SKIP_STALE_SDK_CHECK: '1',
|
||||
GSD_TEST_MODE: undefined,
|
||||
},
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`Expected exit 0 but got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
|
||||
);
|
||||
|
||||
const stdout = result.stdout + result.stderr;
|
||||
|
||||
// Must print the warning about --uninstall not being previewed
|
||||
assert.ok(
|
||||
stdout.includes('does not preview --uninstall'),
|
||||
`Expected "does not preview --uninstall" warning.\nGot:\n${stdout}`
|
||||
);
|
||||
|
||||
// No uninstall occurred — .claude/gsd-core must not have been removed
|
||||
// (it never existed, but we confirm the installer didn't blow up)
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
'Process must exit 0'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 2: direct helper unit tests ───────────────────────────────────────
|
||||
|
||||
describe('#607 cleanupLegacyGsdCc: exported helper unit tests', () => {
|
||||
// GSD_TEST_MODE is already set at the top so requiring install.js is safe.
|
||||
const { cleanupLegacyGsdCc } = require(INSTALL_BIN);
|
||||
|
||||
let tmpRoot;
|
||||
let homeDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = mkTmp('gsd-607-unit-');
|
||||
homeDir = path.join(tmpRoot, 'home');
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpRoot);
|
||||
});
|
||||
|
||||
test('dryRun:true — plan lists seeded artifact; nothing removed', () => {
|
||||
// Seed a content-signal code file under homeDir/.gemini/hooks/
|
||||
const legacyHook = path.join(homeDir, '.gemini', 'hooks', 'gsd-old-update-worker.cjs');
|
||||
writeFile(legacyHook, `// installed via ${LEGACY_PKG_SIGNAL}\nconsole.log("old worker");`);
|
||||
|
||||
const logMessages = [];
|
||||
const mockLogger = { log: (msg) => logMessages.push(msg) };
|
||||
|
||||
const { plan, result } = cleanupLegacyGsdCc({
|
||||
homeDir,
|
||||
dryRun: true,
|
||||
logger: mockLogger,
|
||||
});
|
||||
|
||||
// Plan must include the seeded artifact
|
||||
const planEntry = plan.find((p) => p.path === legacyHook);
|
||||
assert.ok(planEntry, `Plan must list seeded artifact: ${legacyHook}\nActual plan: ${JSON.stringify(plan)}`);
|
||||
|
||||
// dryRun result must flag it as skipped, not removed
|
||||
assert.equal(result.dryRun, true);
|
||||
assert.equal(result.removed.length, 0, 'dryRun must remove nothing');
|
||||
|
||||
// The artifact must still exist
|
||||
assert.ok(
|
||||
fs.existsSync(legacyHook),
|
||||
`Artifact must survive dry-run: ${legacyHook}`
|
||||
);
|
||||
|
||||
// Logger should have been called at least once
|
||||
assert.ok(logMessages.length > 0, 'Logger should have been called');
|
||||
});
|
||||
|
||||
test('dryRun:false — seeded leftover removed; dev-preferences.md preserved', () => {
|
||||
// Seed a content-signal code file
|
||||
const legacyHook = path.join(homeDir, '.gemini', 'hooks', 'gsd-old-update-worker.cjs');
|
||||
writeFile(legacyHook, `// installed via ${LEGACY_PKG_SIGNAL}\nconsole.log("old worker");`);
|
||||
|
||||
// Seed a dev-preferences.md that must NOT be removed
|
||||
const devPrefs = path.join(homeDir, '.gemini', 'gsd-core', 'dev-preferences.md');
|
||||
writeFile(devPrefs, '# My prefs\n\nSome user content — must not be touched.');
|
||||
|
||||
const { plan, result } = cleanupLegacyGsdCc({
|
||||
homeDir,
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
// The legacy hook must be in the plan
|
||||
const planEntry = plan.find((p) => p.path === legacyHook);
|
||||
assert.ok(planEntry, `Legacy hook must appear in plan: ${legacyHook}\nActual plan: ${JSON.stringify(plan)}`);
|
||||
|
||||
// The legacy hook must have been removed
|
||||
assert.equal(
|
||||
fs.existsSync(legacyHook),
|
||||
false,
|
||||
`Legacy hook must be removed: ${legacyHook}`
|
||||
);
|
||||
|
||||
// The removed list must include the legacy hook
|
||||
assert.ok(
|
||||
result.removed.includes(legacyHook),
|
||||
`removed[] must include legacy hook\nActual removed: ${JSON.stringify(result.removed)}`
|
||||
);
|
||||
|
||||
// dev-preferences.md must NOT be in the plan and must still exist
|
||||
const devPrefsInPlan = plan.find((p) => p.path === devPrefs);
|
||||
assert.equal(devPrefsInPlan, undefined, 'dev-preferences.md must never appear in plan');
|
||||
assert.ok(
|
||||
fs.existsSync(devPrefs),
|
||||
`dev-preferences.md must be preserved: ${devPrefs}`
|
||||
);
|
||||
});
|
||||
|
||||
test('dryRun:true — returns plan and result without error (no files present)', () => {
|
||||
// homeDir exists but no legacy artifacts seeded
|
||||
const { plan, result } = cleanupLegacyGsdCc({
|
||||
homeDir,
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
assert.ok(Array.isArray(plan), 'plan must be an array');
|
||||
assert.equal(result.dryRun, true);
|
||||
assert.equal(result.removed.length, 0, 'nothing to remove');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// #1575 — Golden-parity harness (ADR-1235 §0).
|
||||
//
|
||||
// Asserts that the surface path (applySurface) produces byte-for-byte identical
|
||||
// agent output to the install path (installRuntimeArtifacts) for every
|
||||
// descriptor-driven runtime. Both paths run against the SAME configDir so
|
||||
// pathPrefix, attribution, and converter outputs match.
|
||||
//
|
||||
// The harness:
|
||||
// 1. installRuntimeArtifacts(runtime, configDir, 'global', profile, resolveAttribution)
|
||||
// 2. Snapshot every gsd-* agent file in configDir/agents/
|
||||
// 3. applySurface(configDir, layout, manifest, ..., opts)
|
||||
// 4. Compare every agent file byte-for-byte: snapshot === current
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const COMMANDS_GSD = path.join(ROOT, 'commands', 'gsd');
|
||||
|
||||
const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs');
|
||||
const { applySurface } = require('../gsd-core/bin/lib/surface.cjs');
|
||||
const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// The 7 descriptor-driven agent runtimes (cline deferred per code comment:
|
||||
// rules-only local branch + local/global complication).
|
||||
const DESCRIPTOR_RUNTIMES = [
|
||||
'cursor',
|
||||
'windsurf',
|
||||
'augment',
|
||||
'trae',
|
||||
'codebuddy',
|
||||
'copilot',
|
||||
'antigravity',
|
||||
];
|
||||
|
||||
function snapshotAgents(agentsDir) {
|
||||
const snap = new Map();
|
||||
if (!fs.existsSync(agentsDir)) return snap;
|
||||
for (const name of fs.readdirSync(agentsDir)) {
|
||||
if (!name.startsWith('gsd-')) continue;
|
||||
if (!name.endsWith('.md') && !name.endsWith('.agent.md')) continue;
|
||||
snap.set(name, fs.readFileSync(path.join(agentsDir, name), 'utf8'));
|
||||
}
|
||||
return snap;
|
||||
}
|
||||
|
||||
// Shared manifest + profile so both paths see the same source agents.
|
||||
const manifest = loadSkillsManifest(COMMANDS_GSD);
|
||||
const profile = resolveProfile({ modes: ['full'], manifest });
|
||||
// Same attribution resolver for both paths (undefined → no Co-Authored-By mutation).
|
||||
const resolveAttribution = () => undefined;
|
||||
|
||||
describe('#1575 — golden-parity: surface path matches install path for descriptor-driven agents', () => {
|
||||
|
||||
for (const runtime of DESCRIPTOR_RUNTIMES) {
|
||||
test(`${runtime}: surface agents byte-identical to install agents`, (t) => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-1575-${runtime}-`));
|
||||
t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } });
|
||||
|
||||
// Step 1: install path writes agents
|
||||
installRuntimeArtifacts(runtime, configDir, 'global', profile, resolveAttribution);
|
||||
|
||||
// Step 2: snapshot agent files
|
||||
const agentsDir = path.join(configDir, 'agents');
|
||||
const installSnap = snapshotAgents(agentsDir);
|
||||
assert.ok(installSnap.size > 0, `${runtime}: install must produce at least one gsd-* agent`);
|
||||
|
||||
// Step 3: surface path re-materializes into the SAME configDir
|
||||
const layout = resolveRuntimeArtifactLayout(runtime, configDir, 'global');
|
||||
applySurface(configDir, layout, manifest, undefined, undefined, { resolveAttribution });
|
||||
|
||||
// Step 4: compare byte-for-byte
|
||||
const surfaceSnap = snapshotAgents(agentsDir);
|
||||
|
||||
// File lists must match
|
||||
const installFiles = [...installSnap.keys()].sort();
|
||||
const surfaceFiles = [...surfaceSnap.keys()].sort();
|
||||
assert.deepEqual(
|
||||
surfaceFiles,
|
||||
installFiles,
|
||||
`${runtime}: file lists must match after surface. Install: [${installFiles.join(', ')}] Surface: [${surfaceFiles.join(', ')}]`,
|
||||
);
|
||||
|
||||
// Content must match byte-for-byte
|
||||
for (const [fileName, installContent] of installSnap) {
|
||||
const surfaceContent = surfaceSnap.get(fileName);
|
||||
assert.strictEqual(
|
||||
surfaceContent,
|
||||
installContent,
|
||||
`${runtime}/${fileName}: surface content must be byte-identical to install content`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('cursor with non-undefined attribution: surface agents byte-identical to install agents (M2 coverage)', (t) => {
|
||||
// M2 regression guard: verify parity holds when resolveAttribution returns
|
||||
// a real value. Source agents don't carry Co-Authored-By, so processAttribution
|
||||
// is a no-op (it replaces existing lines, doesn't add new ones). But this test
|
||||
// proves the agentCtx threading is correct for both paths regardless.
|
||||
const attrResolver = () => 'Test Bot <test@example.com>';
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1575-attr-'));
|
||||
t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } });
|
||||
|
||||
installRuntimeArtifacts('cursor', configDir, 'global', profile, attrResolver);
|
||||
|
||||
const agentsDir = path.join(configDir, 'agents');
|
||||
const installSnap = snapshotAgents(agentsDir);
|
||||
assert.ok(installSnap.size > 0, 'install must produce agents');
|
||||
|
||||
const layout = resolveRuntimeArtifactLayout('cursor', configDir, 'global');
|
||||
applySurface(configDir, layout, manifest, undefined, undefined, { resolveAttribution: attrResolver });
|
||||
|
||||
const surfaceSnap = snapshotAgents(agentsDir);
|
||||
for (const [fileName, installContent] of installSnap) {
|
||||
assert.strictEqual(surfaceSnap.get(fileName), installContent,
|
||||
`cursor/${fileName}: content must be byte-identical with non-undefined attribution`);
|
||||
}
|
||||
});
|
||||
|
||||
test('copilot: agents installed as .agent.md (filename rename parity)', (t) => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1575-copilot-rename-'));
|
||||
t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } });
|
||||
|
||||
installRuntimeArtifacts('copilot', configDir, 'global', profile, resolveAttribution);
|
||||
|
||||
const agentsDir = path.join(configDir, 'agents');
|
||||
assert.ok(fs.existsSync(agentsDir), 'copilot agents dir must exist');
|
||||
const agentFiles = fs.readdirSync(agentsDir).filter((f) => f.startsWith('gsd-'));
|
||||
assert.ok(agentFiles.length > 0, 'copilot must have installed agents');
|
||||
assert.ok(
|
||||
agentFiles.every((f) => f.endsWith('.agent.md')),
|
||||
`copilot agents must be .agent.md, got: [${agentFiles.slice(0, 3).join(', ')}]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#1575 — surface path: no prune data-loss over pre-existing legacy agents', () => {
|
||||
test('pre-existing gsd-* agents not in staged set are pruned; user agents preserved', (t) => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1575-prune-'));
|
||||
t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } });
|
||||
|
||||
// Seed a pre-existing legacy .agent.md (simulating a prior install)
|
||||
const agentsDir = path.join(configDir, 'agents');
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(agentsDir, 'gsd-old-defunct.agent.md'), '# Old\n');
|
||||
fs.writeFileSync(path.join(agentsDir, 'user-custom.md'), '# User\n');
|
||||
|
||||
// Install (should prune stale gsd-*, preserve user agents)
|
||||
installRuntimeArtifacts('copilot', configDir, 'global', profile, resolveAttribution);
|
||||
|
||||
const afterInstall = fs.readdirSync(agentsDir);
|
||||
assert.ok(!afterInstall.includes('gsd-old-defunct.agent.md'), 'stale gsd-* agent must be pruned');
|
||||
assert.ok(afterInstall.includes('user-custom.md'), 'user agent must be preserved');
|
||||
|
||||
// Now surface over the install — must converge to the same state
|
||||
const layout = resolveRuntimeArtifactLayout('copilot', configDir, 'global');
|
||||
applySurface(configDir, layout, manifest, undefined, undefined, { resolveAttribution });
|
||||
|
||||
const afterSurface = fs.readdirSync(agentsDir);
|
||||
// Same set of agent files as after install
|
||||
const installAgents = afterInstall.filter((f) => f.startsWith('gsd-')).sort();
|
||||
const surfaceAgents = afterSurface.filter((f) => f.startsWith('gsd-')).sort();
|
||||
assert.deepEqual(surfaceAgents, installAgents, 'surface must converge to same agent set as install');
|
||||
assert.ok(afterSurface.includes('user-custom.md'), 'user agent still preserved after surface');
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression: issue #2639 — Codex install generated agent TOMLs with stale
|
||||
* Claude-specific references (CLAUDE.md, .claude/skills/, .claudeignore).
|
||||
*
|
||||
* RCA: `installCodexConfig()` applied a narrow path-only regex pass before
|
||||
* calling `generateCodexAgentToml()`, bypassing the full
|
||||
* `convertClaudeToCodexMarkdown()` + `neutralizeAgentReferences(..., 'AGENTS.md')`
|
||||
* pipeline used on the .md emit path. Fix routes the TOML path through the
|
||||
* same pipeline and extends the pipeline to cover bare `.claude/skills/`,
|
||||
* `.claude/commands/`, `.claude/agents/`, and `.claudeignore`.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const { installCodexConfig } = require('../bin/install.js');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
function makeTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2639-'));
|
||||
}
|
||||
|
||||
function writeAgentFixture(agentsSrc, name, body) {
|
||||
const content = `---
|
||||
name: ${name}
|
||||
description: Test agent for #2639
|
||||
---
|
||||
|
||||
${body}
|
||||
`;
|
||||
fs.writeFileSync(path.join(agentsSrc, `${name}.md`), content);
|
||||
}
|
||||
|
||||
describe('#2639 — Codex TOML emit routes through full neutralization pipeline', () => {
|
||||
let tmpDir;
|
||||
let agentsSrc;
|
||||
let targetDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTempDir();
|
||||
agentsSrc = path.join(tmpDir, 'agents');
|
||||
targetDir = path.join(tmpDir, 'codex');
|
||||
fs.mkdirSync(agentsSrc, { recursive: true });
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('strips CLAUDE.md, .claude/skills/, .claude/commands/, .claude/agents/, and .claudeignore from emitted TOML', () => {
|
||||
writeAgentFixture(agentsSrc, 'gsd-code-reviewer', [
|
||||
'**Project instructions:** Read `./CLAUDE.md` if it exists.',
|
||||
'',
|
||||
'**CLAUDE.md enforcement:** If `./CLAUDE.md` exists, treat it as hard constraints.',
|
||||
'',
|
||||
'**Project skills:** Check `.claude/skills/` or `.agents/skills/` directory.',
|
||||
'',
|
||||
'Also check `.claude/commands/` and `.claude/agents/` for definitions.',
|
||||
'',
|
||||
'DO respect .gitignore and .claudeignore. Do not review ignored files.',
|
||||
'',
|
||||
'Claude will refuse the task if policy violated.',
|
||||
].join('\n'));
|
||||
|
||||
installCodexConfig(targetDir, agentsSrc);
|
||||
|
||||
const tomlPath = path.join(targetDir, 'agents', 'gsd-code-reviewer.toml');
|
||||
assert.ok(fs.existsSync(tomlPath), 'per-agent TOML written');
|
||||
const toml = fs.readFileSync(tomlPath, 'utf8');
|
||||
|
||||
assert.ok(!toml.includes('CLAUDE.md'), 'no CLAUDE.md references remain in TOML');
|
||||
assert.ok(!toml.includes('.claude/skills/'), 'no .claude/skills/ references remain');
|
||||
assert.ok(!toml.includes('.claude/commands/'), 'no .claude/commands/ references remain');
|
||||
assert.ok(!toml.includes('.claude/agents/'), 'no .claude/agents/ references remain');
|
||||
assert.ok(!toml.includes('.claudeignore'), 'no .claudeignore references remain');
|
||||
|
||||
assert.ok(toml.includes('AGENTS.md'), 'AGENTS.md substituted for CLAUDE.md');
|
||||
assert.ok(
|
||||
toml.includes('.codex/skills/') || toml.includes('.agents/skills/'),
|
||||
'skills path neutralized'
|
||||
);
|
||||
|
||||
// Standalone "Claude" agent-name references replaced
|
||||
assert.ok(!/\bClaude\b(?! Code| Opus| Sonnet| Haiku| native| based)/.test(toml),
|
||||
'standalone Claude agent-name references replaced');
|
||||
});
|
||||
|
||||
test('preserves Claude product/model names (Claude Code, Claude Opus) in TOML', () => {
|
||||
writeAgentFixture(agentsSrc, 'gsd-executor', [
|
||||
'This agent runs under Claude Code with the Claude Opus 4 model.',
|
||||
'Do not confuse with Claude Sonnet or Claude Haiku.',
|
||||
].join('\n'));
|
||||
|
||||
installCodexConfig(targetDir, agentsSrc);
|
||||
const toml = fs.readFileSync(path.join(targetDir, 'agents', 'gsd-executor.toml'), 'utf8');
|
||||
|
||||
assert.ok(toml.includes('Claude Code'), 'Claude Code product name preserved');
|
||||
assert.ok(toml.includes('Claude Opus'), 'Claude Opus model name preserved');
|
||||
});
|
||||
});
|
||||
@@ -1,338 +0,0 @@
|
||||
// Regression tests for #2695 — Codex native updates omit the update-hook worker
|
||||
// and the managed-hooks registry.
|
||||
//
|
||||
// The Codex install branch in bin/install.js used to allowlist only two of the
|
||||
// four hook files the shipped build emits (gsd-check-update.js +
|
||||
// gsd-context-monitor.js), and gated the entire branch on !isMinimalMode so the
|
||||
// `core` profile installed none of them. The parent SessionStart hook spawn()s
|
||||
// the worker, which require()s the registry — so Codex was wired to a dependency
|
||||
// chain the same installer never delivered.
|
||||
//
|
||||
// These tests drive the real installer (bin/install.js) behaviorally into an
|
||||
// isolated temp config dir and assert the complete four-file set is delivered
|
||||
// for both profiles, the registry is byte-for-byte, the version stamps resolve
|
||||
// to the installed package version, and unrelated user files are preserved.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { runNode } = require('./helpers/process-seam.cjs');
|
||||
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const {
|
||||
INSTALL_SCRIPT,
|
||||
BUILD_SCRIPT,
|
||||
HOOKS_DIST,
|
||||
installerEnv,
|
||||
} = require('./helpers/install-shared.cjs');
|
||||
|
||||
const PKG_VERSION = require('../package.json').version;
|
||||
|
||||
// #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs.
|
||||
const {
|
||||
BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS,
|
||||
INSTALL_TIMEOUT_MS,
|
||||
} = require('./helpers/timeouts.cjs');
|
||||
|
||||
// The four-file hook set the Codex surface must deliver together (#2695).
|
||||
const CODEX_HOOK_FILES = [
|
||||
'gsd-check-update.js',
|
||||
'gsd-check-update-worker.js',
|
||||
'managed-hooks-registry.cjs',
|
||||
'gsd-context-monitor.js',
|
||||
];
|
||||
|
||||
// Build hooks/dist before any install runs (the installer copies from there).
|
||||
before(() => {
|
||||
const r = runNode([BUILD_SCRIPT], { timeoutMs: BUILD_HOOKS_TIMEOUT_MS });
|
||||
throwIfFailed(r, `node ${BUILD_SCRIPT}`);
|
||||
});
|
||||
|
||||
function hooksDirOf(configDir) {
|
||||
return path.join(configDir, 'hooks');
|
||||
}
|
||||
|
||||
/** Run the Codex installer into an isolated temp config dir. */
|
||||
function runCodexInstall({ profile, preseed }) {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-2695-${profile}-`));
|
||||
if (preseed) {
|
||||
const hooksDest = hooksDirOf(configDir);
|
||||
fs.mkdirSync(hooksDest, { recursive: true });
|
||||
for (const [name, body] of Object.entries(preseed)) {
|
||||
fs.writeFileSync(path.join(hooksDest, name), body);
|
||||
}
|
||||
}
|
||||
// Sandbox HOME/USERPROFILE to configDir: Codex's skills-kind `home: ".agents"`
|
||||
// override resolves via os.homedir(); sandboxing keeps the spawn self-contained
|
||||
// (mirrors tests/install-minimal-hooks.test.cjs Codex downgrade test).
|
||||
const result = runNode(
|
||||
[INSTALL_SCRIPT, '--codex', '--global', '--config-dir', configDir, `--profile=${profile}`],
|
||||
{ env: installerEnv({ HOME: configDir, USERPROFILE: configDir }), timeoutMs: INSTALL_TIMEOUT_MS },
|
||||
);
|
||||
return { configDir, result };
|
||||
}
|
||||
|
||||
// Older-version stamp used to pre-seed an "upgrade" scenario.
|
||||
const OLDER_VERSION = '1.7.0';
|
||||
|
||||
describe('#2695: fresh Codex installs deliver the complete four-file hook set', () => {
|
||||
for (const profile of ['core', 'full']) {
|
||||
test(`fresh --profile=${profile} installs all four hook files`, (t) => {
|
||||
const { configDir, result } = runCodexInstall({ profile });
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const hooksDir = hooksDirOf(configDir);
|
||||
for (const file of CODEX_HOOK_FILES) {
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(hooksDir, file)),
|
||||
`expected ${file} under <config>/hooks for --profile=${profile}\n` +
|
||||
`installer stdout: ${result.stdout}\ninstaller stderr: ${result.stderr}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('#2695: Codex upgrades refresh all four hook files to the current version', () => {
|
||||
// Pre-seed all four files stamped at OLDER_VERSION so an upgrade must overwrite them.
|
||||
function olderSeed() {
|
||||
const seed = {};
|
||||
for (const name of CODEX_HOOK_FILES) {
|
||||
// Registry carries no version token; seed it with a stale sentinel body.
|
||||
if (name.endsWith('.cjs')) {
|
||||
seed[name] = `// stale registry ${OLDER_VERSION}\nmodule.exports = {};\n`;
|
||||
} else {
|
||||
seed[name] = `// gsd-hook-version: ${OLDER_VERSION}\n// stale\n`;
|
||||
}
|
||||
}
|
||||
return seed;
|
||||
}
|
||||
|
||||
for (const profile of ['core', 'full']) {
|
||||
test(`--profile=${profile} upgrade refreshes all four hook files`, (t) => {
|
||||
const { configDir, result } = runCodexInstall({ profile, preseed: olderSeed() });
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const hooksDir = hooksDirOf(configDir);
|
||||
// All four must now carry the current version stamp where one exists, and
|
||||
// the registry must no longer be the stale sentinel.
|
||||
for (const name of CODEX_HOOK_FILES) {
|
||||
const dest = path.join(hooksDir, name);
|
||||
assert.ok(
|
||||
fs.existsSync(dest),
|
||||
`expected refreshed ${name} for --profile=${profile}\n` +
|
||||
`installer stdout: ${result.stdout}\ninstaller stderr: ${result.stderr}`,
|
||||
);
|
||||
}
|
||||
// The registry must be REFRESHED on upgrade, not merely present: assert it no
|
||||
// longer carries the stale sentinel and now matches the shipped dist byte-for-byte
|
||||
// (the raw-copy fallback must overwrite an existing dest, not skip it).
|
||||
const registryDest = path.join(hooksDir, 'managed-hooks-registry.cjs');
|
||||
const registryBytes = fs.readFileSync(registryDest, 'utf8');
|
||||
assert.ok(
|
||||
!registryBytes.includes(`stale registry ${OLDER_VERSION}`),
|
||||
`registry must be refreshed on upgrade for --profile=${profile} (still carries the stale sentinel)`,
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
fs.readFileSync(registryDest),
|
||||
fs.readFileSync(path.join(HOOKS_DIST, 'managed-hooks-registry.cjs')),
|
||||
`refreshed registry must match hooks/dist byte-for-byte for --profile=${profile}`,
|
||||
);
|
||||
// Version stamps resolved (acceptance #2/#3).
|
||||
const workerStamp = readHookVersionLine(path.join(hooksDir, 'gsd-check-update-worker.js'));
|
||||
assert.strictEqual(
|
||||
workerStamp, PKG_VERSION,
|
||||
`worker gsd-hook-version stamp must be the installed package version (${PKG_VERSION}), ` +
|
||||
`got "${workerStamp}" for --profile=${profile}`,
|
||||
);
|
||||
const parentStamp = readHookVersionLine(path.join(hooksDir, 'gsd-check-update.js'));
|
||||
assert.strictEqual(
|
||||
parentStamp, PKG_VERSION,
|
||||
`parent gsd-check-update stamp must be the installed package version (${PKG_VERSION}), ` +
|
||||
`got "${parentStamp}" for --profile=${profile}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('#2695: managed-hooks-registry.cjs is copied byte-for-byte', () => {
|
||||
for (const profile of ['core', 'full']) {
|
||||
test(`--profile=${profile} registry matches hooks/dist byte-for-byte`, (t) => {
|
||||
const { configDir, result } = runCodexInstall({ profile });
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const dest = path.join(hooksDirOf(configDir), 'managed-hooks-registry.cjs');
|
||||
assert.ok(fs.existsSync(dest), `registry missing for --profile=${profile}\nstdout: ${result.stdout}`);
|
||||
const distBytes = fs.readFileSync(path.join(HOOKS_DIST, 'managed-hooks-registry.cjs'));
|
||||
const destBytes = fs.readFileSync(dest);
|
||||
assert.deepStrictEqual(
|
||||
destBytes, distBytes,
|
||||
`managed-hooks-registry.cjs must be copied byte-for-byte (no version/path transform) for --profile=${profile}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('#2695: worker hook-version stamp is a literal install-time value', () => {
|
||||
test('the stamp is the literal package version, never a placeholder or a runtime lookup', (t) => {
|
||||
const { configDir } = runCodexInstall({ profile: 'full' });
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const workerPath = path.join(hooksDirOf(configDir), 'gsd-check-update-worker.js');
|
||||
const content = fs.readFileSync(workerPath, 'utf8');
|
||||
// The placeholder must have been replaced — a leftover {{GSD_VERSION}} is the bug shape.
|
||||
assert.ok(
|
||||
!content.includes('{{GSD_VERSION}}'),
|
||||
'worker still carries an unresolved {{GSD_VERSION}} placeholder — stamping did not run',
|
||||
);
|
||||
// And the resolved value must be the literal version, present on the version-comment line.
|
||||
const stamp = readHookVersionLine(workerPath);
|
||||
assert.strictEqual(stamp, PKG_VERSION, `worker stamp must equal package.json version, got "${stamp}"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2695: unrelated user-owned hook files are preserved', () => {
|
||||
for (const profile of ['core', 'full']) {
|
||||
test(`--profile=${profile} leaves a pre-existing user hook untouched`, (t) => {
|
||||
const userOwned = 'my-custom-hook.js';
|
||||
const userBody = '// user-owned hook — do not touch\nconsole.log("mine");\n';
|
||||
const { configDir, result } = runCodexInstall({ profile, preseed: { [userOwned]: userBody } });
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const dest = path.join(hooksDirOf(configDir), userOwned);
|
||||
assert.ok(fs.existsSync(dest), `user-owned ${userOwned} must be preserved for --profile=${profile}\nstdout: ${result.stdout}`);
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(dest, 'utf8'), userBody,
|
||||
`user-owned ${userOwned} bytes must be unchanged for --profile=${profile}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('#2695: re-running the installer is idempotent for the four-file set', () => {
|
||||
test('a second full install leaves all four files present and correctly stamped', (t) => {
|
||||
const first = runCodexInstall({ profile: 'full' });
|
||||
t.after(() => cleanup(first.configDir));
|
||||
// Second run into the SAME config dir.
|
||||
const result2 = runNode(
|
||||
[INSTALL_SCRIPT, '--codex', '--global', '--config-dir', first.configDir, '--profile=full'],
|
||||
{ env: installerEnv({ HOME: first.configDir, USERPROFILE: first.configDir }), timeoutMs: INSTALL_TIMEOUT_MS },
|
||||
);
|
||||
assert.ok(result2.stdout || result2.stderr);
|
||||
|
||||
const hooksDir = hooksDirOf(first.configDir);
|
||||
for (const name of CODEX_HOOK_FILES) {
|
||||
assert.ok(fs.existsSync(path.join(hooksDir, name)), `${name} must survive a second install`);
|
||||
}
|
||||
assert.strictEqual(
|
||||
readHookVersionLine(path.join(hooksDir, 'gsd-check-update-worker.js')),
|
||||
PKG_VERSION,
|
||||
'worker stamp must remain correct after a second install',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2695: the core profile enables the hook feature and wires SessionStart (intended)', () => {
|
||||
// For the update-check/context-monitor hooks to actually fire, Codex needs both
|
||||
// the feature flag in config.toml AND the hooks.json routing — copying inert
|
||||
// files alone would leave `core` with scripts Codex never invokes. Entering the
|
||||
// codex-toml branch for `core` (the #2695 gate change) synthesizes `[features]
|
||||
// hooks = true` via ensureCodexHooksFeature, writes config.toml, and registers
|
||||
// the hooks. This is the intended behavior of the fix, not a side effect — these
|
||||
// assertions pin it so a future re-gating cannot silently regress it.
|
||||
test('--profile=core writes config.toml enabling the hooks feature', (t) => {
|
||||
const { configDir } = runCodexInstall({ profile: 'core' });
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const configPath = path.join(configDir, 'config.toml');
|
||||
assert.ok(fs.existsSync(configPath), 'core must write config.toml so the hooks feature is enabled');
|
||||
const config = fs.readFileSync(configPath, 'utf8');
|
||||
assert.ok(/^\s*hooks\s*=\s*true\s*$/m.test(config), 'config.toml must enable hooks = true for core');
|
||||
});
|
||||
|
||||
test('--profile=core wires the SessionStart update-check hook in hooks.json', (t) => {
|
||||
const { configDir } = runCodexInstall({ profile: 'core' });
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const hooksJsonPath = path.join(configDir, 'hooks.json');
|
||||
assert.ok(fs.existsSync(hooksJsonPath), 'core must write hooks.json');
|
||||
const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
||||
const sessionStartCmds = collectHookCommands(hooksJson, 'SessionStart');
|
||||
// The command points at the gsd-check-update hook script. Its extension is
|
||||
// platform-specific — Windows routes through a .cmd shim, POSIX through .js —
|
||||
// so assert on the basename prefix, not a hardcoded extension (Windows parity).
|
||||
const routedToUpdateHook = sessionStartCmds.some((c) => {
|
||||
const token = c.replace(/"/g, '').replace(/\\/g, '/');
|
||||
const segs = token.split('/');
|
||||
const last = segs[segs.length - 1];
|
||||
return last.startsWith('gsd-check-update.');
|
||||
});
|
||||
assert.ok(
|
||||
routedToUpdateHook,
|
||||
`core must route SessionStart to the gsd-check-update hook in hooks.json; got: ${JSON.stringify(sessionStartCmds)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2695: the core profile still installs no agent files (negative space)', () => {
|
||||
test('--profile=core delivers hooks but no gsd-* agent files', (t) => {
|
||||
const { configDir } = runCodexInstall({ profile: 'core' });
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
// Hooks delivered (the fix)…
|
||||
for (const name of CODEX_HOOK_FILES) {
|
||||
assert.ok(fs.existsSync(path.join(hooksDirOf(configDir), name)), `${name} delivered for core`);
|
||||
}
|
||||
// …but the full agent surface is still absent (core stays minimal). Codex agents
|
||||
// are .toml ([agents.gsd-*] in config.toml + agents/gsd-*.toml), so check both
|
||||
// extensions — a .md-only filter would miss a Codex agent-surface regression.
|
||||
const agentsDir = path.join(configDir, 'agents');
|
||||
if (fs.existsSync(agentsDir)) {
|
||||
const gsdAgents = fs.readdirSync(agentsDir).filter(
|
||||
(f) => f.startsWith('gsd-') && (f.endsWith('.md') || f.endsWith('.toml')),
|
||||
);
|
||||
assert.deepStrictEqual(gsdAgents, [], 'core must not install the full agent surface');
|
||||
}
|
||||
// And config.toml must carry no agent role sections.
|
||||
const configPath = path.join(configDir, 'config.toml');
|
||||
if (fs.existsSync(configPath)) {
|
||||
const config = fs.readFileSync(configPath, 'utf8');
|
||||
assert.ok(
|
||||
!/^\[agents\.gsd-/m.test(config),
|
||||
'core config.toml must not declare [agents.gsd-*] roles (full agent surface stays a full-profile concern)',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Read the `// gsd-hook-version: <value>` comment value from a hook file.
|
||||
* Returns the trimmed literal. Used so tests assert on the structured stamp,
|
||||
* not on raw `.includes()` prose (CONTRIBUTING raw-text-matching rule).
|
||||
*/
|
||||
function readHookVersionLine(hookPath) {
|
||||
const content = fs.readFileSync(hookPath, 'utf8');
|
||||
const m = content.match(/^\/\/ gsd-hook-version:\s*(.+?)\s*$/m);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every hook command string registered under a given Codex hooks.json
|
||||
* event key. Used so the SessionStart-wiring test asserts on the structured
|
||||
* hook entries (commands), not on raw text matching against the whole file.
|
||||
*/
|
||||
function collectHookCommands(hooksJson, eventName) {
|
||||
const entries = (hooksJson && hooksJson.hooks && Array.isArray(hooksJson.hooks[eventName]))
|
||||
? hooksJson.hooks[eventName]
|
||||
: [];
|
||||
return entries.flatMap((entry) =>
|
||||
(entry && Array.isArray(entry.hooks) ? entry.hooks : [])
|
||||
.map((h) => (h && typeof h.command === 'string' ? h.command : null))
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// allow-test-rule: structural-implementation-guard (#2834)
|
||||
'use strict';
|
||||
|
||||
// Regression guard for #2834: on a clean Codex install, agent TOMLs contained no
|
||||
// model-routing fields because defaults.json (resolve_model_ids + runtime) was written
|
||||
// AFTER installCodexConfig generated the TOMLs. The fix extracts writeNonClaudeDefaults
|
||||
// and calls it BEFORE installCodexConfig. This test asserts the ordering invariant in
|
||||
// the install source so a future edit can't silently re-introduce the gap.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
|
||||
test('writeNonClaudeDefaults is called before installCodexConfig in the Codex install flow (#2834)', () => {
|
||||
const src = fs.readFileSync(INSTALL_JS, 'utf8');
|
||||
|
||||
// Find the call to writeNonClaudeDefaults that precedes installCodexConfig.
|
||||
const writeIdx = src.indexOf('writeNonClaudeDefaults(runtime);');
|
||||
assert.ok(writeIdx !== -1, 'writeNonClaudeDefaults(runtime) must be called in the install flow');
|
||||
|
||||
// Find the FIRST installCodexConfig call AFTER the writeNonClaudeDefaults call.
|
||||
const codexGenIdx = src.indexOf('installCodexConfig(targetDir', writeIdx);
|
||||
assert.ok(codexGenIdx !== -1 && codexGenIdx > writeIdx,
|
||||
'installCodexConfig must be called AFTER writeNonClaudeDefaults so defaults.json ' +
|
||||
'(resolve_model_ids + runtime) exists before agent TOML generation reads it (#2834)');
|
||||
|
||||
// The #2834 comment must be present at the call site.
|
||||
const callSite = src.slice(writeIdx - 300, writeIdx + 100);
|
||||
assert.ok(/#2834/.test(callSite), 'the writeNonClaudeDefaults call must carry the #2834 rationale comment');
|
||||
});
|
||||
|
||||
test('writeNonClaudeDefaults function exists and is a no-op for Claude (#2834)', () => {
|
||||
const src = fs.readFileSync(INSTALL_JS, 'utf8');
|
||||
const fnIdx = src.indexOf('function writeNonClaudeDefaults(');
|
||||
assert.ok(fnIdx !== -1, 'writeNonClaudeDefaults must be defined as a function');
|
||||
const fnBody = src.slice(fnIdx, fnIdx + 1200);
|
||||
assert.ok(/nativeModelAliases/.test(fnBody), 'writeNonClaudeDefaults must early-return for Claude (nativeModelAliases check)');
|
||||
assert.ok(/resolve_model_ids/.test(fnBody), 'writeNonClaudeDefaults must write resolve_model_ids');
|
||||
assert.ok(/defaults\.runtime/.test(fnBody), 'writeNonClaudeDefaults must write runtime');
|
||||
});
|
||||
@@ -1,194 +0,0 @@
|
||||
'use strict';
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Regression test for #2940 — `gsd-update` overwrites `~/.codex/config.toml`,
|
||||
* removing any user/Codex-CLI settings added after the GSD-managed marker block.
|
||||
*
|
||||
* Root cause: `mergeCodexConfig`'s Case 2 (marker present) preserved content
|
||||
* BEFORE the marker but unconditionally discarded everything from the marker to
|
||||
* EOF, replacing it with a freshly generated GSD block. Since a fresh install
|
||||
* writes the GSD block as the file's entire content, any settings the user or
|
||||
* Codex CLI later adds (`[model]`, `[mcp_servers.*]`, `[profiles.*]`) land AFTER
|
||||
* the block, and every subsequent update wiped them.
|
||||
*
|
||||
* The fix preserves genuine trailing TOML by routing the post-marker region
|
||||
* through the existing `stripLeakedGsdCodexSections` (which removes GSD's own
|
||||
* managed/leaked sections while keeping user tables), then re-appending it after
|
||||
* the regenerated GSD block — without regressing #2406's de-dup.
|
||||
*
|
||||
* Matrix: .gsd/bug/fix/2940-codex-config-merge-preserves-trailing-content/50-test-matrix.md
|
||||
*/
|
||||
|
||||
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 os = require('node:os');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
generateCodexConfigBlock,
|
||||
mergeCodexConfig,
|
||||
GSD_CODEX_MARKER,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
describe('mergeCodexConfig trailing-content preservation (#2940)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2940-merge-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
/** A GSD block with one agent (the shape installCodexConfig passes). */
|
||||
const block = () =>
|
||||
generateCodexConfigBlock([{ name: 'gsd-executor', description: 'Executes plans' }]);
|
||||
|
||||
test('trailingUserModelSectionPreserved', () => {
|
||||
// Row 1 (failing-first regression): a config with the GSD block FIRST, then a user
|
||||
// [model] section after it (the real-world layout — fresh install fills the file,
|
||||
// user settings land after). Re-merge must preserve [model] byte-for-byte.
|
||||
const configPath = path.join(tmpDir, 'config.toml');
|
||||
const trailing = '[model]\nname = "gpt-5.4"\n';
|
||||
// First write: GSD block + user content after it (no content before the marker).
|
||||
fs.writeFileSync(configPath, block() + '\n' + trailing);
|
||||
|
||||
mergeCodexConfig(configPath, block());
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
assert.ok(content.includes('[model]'), 'user [model] section preserved after re-merge');
|
||||
assert.ok(content.includes('name = "gpt-5.4"'), 'user model value preserved verbatim');
|
||||
assert.ok(content.includes(GSD_CODEX_MARKER), 'GSD marker still present');
|
||||
const markerCount = (content.match(new RegExp(GSD_CODEX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
|
||||
assert.strictEqual(markerCount, 1, 'exactly one marker (no duplication)');
|
||||
assert.ok(content.includes('max_depth ='), 'GSD-managed [agents] block regenerated');
|
||||
});
|
||||
|
||||
test('multipleTrailingTablesPreserved', () => {
|
||||
// Row 2: multiple trailing user tables ([mcp_servers.*], [profiles.*]).
|
||||
const configPath = path.join(tmpDir, 'config.toml');
|
||||
const trailing = [
|
||||
'[mcp_servers.figma]',
|
||||
'command = "npx"',
|
||||
'args = ["-y", "figma-mcp"]',
|
||||
'',
|
||||
'[profiles.dev]',
|
||||
'model = "o3"',
|
||||
'sandbox_mode = "workspace-write"',
|
||||
].join('\n');
|
||||
fs.writeFileSync(configPath, block() + '\n' + trailing + '\n');
|
||||
|
||||
mergeCodexConfig(configPath, block());
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
assert.ok(content.includes('[mcp_servers.figma]'), 'mcp_servers table preserved');
|
||||
assert.ok(content.includes('[profiles.dev]'), 'profiles table preserved');
|
||||
assert.ok(content.includes('sandbox_mode = "workspace-write"'), 'profile value preserved');
|
||||
assert.ok(content.includes(GSD_CODEX_MARKER), 'GSD block regenerated');
|
||||
});
|
||||
|
||||
test('reMergeIsIdempotent', () => {
|
||||
// Row 3 (acceptance #2): merging the result of a merge again yields identical content.
|
||||
const configPath = path.join(tmpDir, 'config.toml');
|
||||
fs.writeFileSync(configPath, block() + '\n[model]\nname = "o3"\n');
|
||||
|
||||
mergeCodexConfig(configPath, block());
|
||||
const afterFirst = fs.readFileSync(configPath, 'utf8');
|
||||
|
||||
mergeCodexConfig(configPath, block());
|
||||
const afterSecond = fs.readFileSync(configPath, 'utf8');
|
||||
|
||||
assert.strictEqual(afterSecond, afterFirst, 'second merge is idempotent (no further change)');
|
||||
});
|
||||
|
||||
test('leakedGsdSectionAfterMarkerStillStripped', () => {
|
||||
// Row 4 (#2406 non-regression): a leaked GSD-managed [agents.gsd-*] section AFTER the
|
||||
// marker is still REMOVED (not regrown), while genuine user content after it is preserved.
|
||||
const configPath = path.join(tmpDir, 'config.toml');
|
||||
const leakedAndUser = [
|
||||
'[agents.gsd-executor]',
|
||||
'description = "stale leaked"',
|
||||
'config_file = "agents/gsd-executor.toml"',
|
||||
'',
|
||||
'[model]',
|
||||
'name = "o3"',
|
||||
].join('\n');
|
||||
fs.writeFileSync(configPath, block() + '\n' + leakedAndUser + '\n');
|
||||
|
||||
mergeCodexConfig(configPath, block());
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const gsdStructCount = (content.match(/^\[agents\.gsd-executor\]\s*$/gm) || []).length;
|
||||
assert.strictEqual(gsdStructCount, 0, 'leaked [agents.gsd-executor] after marker is stripped (not regrown)');
|
||||
assert.ok(content.includes('[model]'), 'genuine user [model] after the leaked section still preserved');
|
||||
});
|
||||
|
||||
test('bareAgentsAfterMarkerHandled', () => {
|
||||
// Row 5: a user AgentsToml scalar (max_threads) the user folded INTO the managed [agents]
|
||||
// block (the valid, realistic shape — two [agents] tables would be invalid TOML), PLUS a
|
||||
// separate trailing [model] section. The fix must preserve the user scalar via the existing
|
||||
// spliceCodexAgentsScalars path AND preserve the trailing [model] via the new trailing-region
|
||||
// logic, while regenerating exactly one managed [agents] table.
|
||||
const configPath = path.join(tmpDir, 'config.toml');
|
||||
// Simulate: fresh install wrote the GSD block; the user then added max_threads into the
|
||||
// [agents] table and added a [model] section after it.
|
||||
const existing = [
|
||||
GSD_CODEX_MARKER,
|
||||
'',
|
||||
'[agents]',
|
||||
'max_depth = 1',
|
||||
'max_threads = 4',
|
||||
'',
|
||||
'[model]',
|
||||
'name = "o3"',
|
||||
].join('\n');
|
||||
fs.writeFileSync(configPath, existing + '\n');
|
||||
|
||||
mergeCodexConfig(configPath, block());
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
// The user's max_threads scalar is preserved (spliced into the regenerated managed [agents]);
|
||||
// there is exactly one [agents] table (the managed one).
|
||||
assert.ok(content.includes('max_threads = 4'), 'user AgentsToml scalar (max_threads) preserved in managed block');
|
||||
const agentsHeaders = (content.match(/^\[agents\]\s*$/gm) || []).length;
|
||||
assert.strictEqual(agentsHeaders, 1, 'exactly one [agents] table (the managed one)');
|
||||
assert.ok(content.includes('max_depth = 1'), 'GSD-managed max_depth still present');
|
||||
assert.ok(content.includes('[model]'), 'trailing [model] still preserved');
|
||||
});
|
||||
|
||||
test('beforeAndAfterMarkerBothPreserved', () => {
|
||||
// Row 6: content both BEFORE and AFTER the marker is preserved; GSD block regenerated once.
|
||||
const configPath = path.join(tmpDir, 'config.toml');
|
||||
const before = '[profiles.work]\nmodel = "gpt-5.4"\n';
|
||||
const after = '[mcp_servers.github]\ncommand = "gh-mcp"\n';
|
||||
fs.writeFileSync(configPath, before + '\n' + block() + '\n' + after + '\n');
|
||||
|
||||
mergeCodexConfig(configPath, block());
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
assert.ok(content.includes('[profiles.work]'), 'content before marker preserved');
|
||||
assert.ok(content.includes('[mcp_servers.github]'), 'content after marker preserved');
|
||||
const markerCount = (content.match(new RegExp(GSD_CODEX_MARKER.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length;
|
||||
assert.strictEqual(markerCount, 1, 'exactly one marker');
|
||||
});
|
||||
|
||||
test('noTrailingContentUnchanged', () => {
|
||||
// Row 7 (zero-trailing boundary): a config with ONLY the GSD block (fresh-install case)
|
||||
// re-merges to just the regenerated block — no spurious blank-line artifacts introduced
|
||||
// by the trailing-preservation logic.
|
||||
const configPath = path.join(tmpDir, 'config.toml');
|
||||
fs.writeFileSync(configPath, block() + '\n');
|
||||
|
||||
mergeCodexConfig(configPath, block());
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
// No spurious trailing blank lines beyond the single trailing newline. Use a CRLF-safe
|
||||
// pattern (\r?\n) so the assertion holds under Windows git-autocrlf line endings.
|
||||
assert.ok(!/(?:\r?\n){3,}$/.test(content), 'no spurious run of blank lines at end of file');
|
||||
assert.strictEqual(content.trim(), block().trim(), 'content is exactly the regenerated block (whitespace-trimmed)');
|
||||
});
|
||||
});
|
||||
@@ -1,234 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// Issue #57 — Runtime Install No-Drift Tests.
|
||||
//
|
||||
// Protects the Runtime Install Policy Module boundary (ADR-58) and the explicit
|
||||
// Runtime Config Adapter Registry (#60) now that the policy boundary (#58),
|
||||
// explicit adapter registry (#60), and legacy directory-helper retirement (#56)
|
||||
// have landed. These guards FAIL when:
|
||||
//
|
||||
// (AC1) supported-runtime metadata is added to an installer/query call site
|
||||
// without going through the runtime registry projection, or
|
||||
// (AC2) config-mutation dispatch bypasses the explicit adapter registry.
|
||||
//
|
||||
// (AC3) Assertions are behavioral (require + reflect on live exports) wherever
|
||||
// behavior can cover the contract; the two source-text assertions are structural
|
||||
// guards that behavioral checks cannot replace, and are annotated per repo
|
||||
// convention. (AC4) The existing installer / runtime-policy / runtime-global-skills
|
||||
// suites must stay green — verified by running them alongside this file, not
|
||||
// asserted here.
|
||||
//
|
||||
// Known INTENTIONAL asymmetries — these are not drift; do not "fix" them by
|
||||
// tightening the invariants:
|
||||
// - `grok` appears in runtime-homes.cjs's getGlobalConfigDir switch but NOT in
|
||||
// the registry / artifact-layout supported sets (it resolves a config-dir home
|
||||
// but is not an installable artifact target). So runtime-homes' full switch set
|
||||
// is never tied into the equality invariant — it is only probed forward, per
|
||||
// installable runtime.
|
||||
// - getGlobalConfigDir() falls back to ~/.claude for an UNKNOWN runtime instead
|
||||
// of throwing (a deliberately liberal projection). Only the registry and
|
||||
// artifact-layout projections are loud gates, so only those are asserted to
|
||||
// throw on an unknown runtime.
|
||||
//
|
||||
// Coverage boundary (deliberate, see #57 follow-up): the structural guard below
|
||||
// catches a NEW inline `runtime === '...'` branch against an UNREGISTERED runtime.
|
||||
// It cannot catch a duplicate inline config write added for an ALREADY-registered
|
||||
// runtime — distinguishing that from the ~169 legitimate per-runtime comparisons in
|
||||
// the installer requires driving install()/finishInstall() against a mocked
|
||||
// filesystem and asserting the written surfaces match resolveRuntimeConfigIntent().
|
||||
// That behavioral install-driver harness is out of scope for this no-drift pass.
|
||||
//
|
||||
// The forward invariant `allRuntimes ⊆ artifact-layout` is already covered by
|
||||
// tests/install-runtime-artifacts.test.cjs; this file does not duplicate it.
|
||||
|
||||
process.env.GSD_TEST_MODE = '1'; // must precede require of bin/install.js
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const LIB = path.join(ROOT, 'gsd-core', 'bin', 'lib');
|
||||
|
||||
const { allRuntimes, runtimeMap } = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
const {
|
||||
resolveRuntimeConfigIntent,
|
||||
ALLOWED_CONFIG_RUNTIMES,
|
||||
INSTALL_SURFACES,
|
||||
} = require(path.join(LIB, 'runtime-config-adapter-registry.cjs'));
|
||||
const { resolveRuntimeArtifactLayout } = require(
|
||||
path.join(LIB, 'runtime-artifact-layout.cjs'),
|
||||
);
|
||||
const { getGlobalConfigDir } = require(path.join(LIB, 'runtime-homes.cjs'));
|
||||
|
||||
const sorted = (iterable) => [...iterable].sort();
|
||||
|
||||
// A runtime name that is deliberately not real and is not a prototype-chain key.
|
||||
const SENTINEL = '__drift_sentinel_runtime__';
|
||||
|
||||
describe('issue-57 AC1 — supported-runtime metadata has one projected source of truth', () => {
|
||||
test('installer allRuntimes, interactive runtimeMap, and registry agree on the supported set', () => {
|
||||
const installable = sorted(allRuntimes);
|
||||
assert.deepStrictEqual(
|
||||
installable,
|
||||
sorted(Object.values(runtimeMap)),
|
||||
'Drift: bin/install.js `allRuntimes` and the interactive `runtimeMap` selection menu '
|
||||
+ 'diverged. A runtime selectable in the prompt but absent from allRuntimes (or vice '
|
||||
+ 'versa) is a supported-runtime call site that skipped the projection.',
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
installable,
|
||||
sorted(ALLOWED_CONFIG_RUNTIMES),
|
||||
'Drift: bin/install.js `allRuntimes` and `ALLOWED_CONFIG_RUNTIMES` (runtime config '
|
||||
+ 'adapter registry) diverged. A runtime added to an installer call site without a '
|
||||
+ 'registry adapter entry bypasses the registry projection — register it in '
|
||||
+ 'src/runtime-config-adapter-registry.cts.',
|
||||
);
|
||||
});
|
||||
|
||||
test('every installable runtime resolves a config intent through the registry', () => {
|
||||
for (const runtime of allRuntimes) {
|
||||
const intent = resolveRuntimeConfigIntent(runtime);
|
||||
assert.equal(
|
||||
intent.runtime,
|
||||
runtime,
|
||||
`${runtime} must resolve its own config intent through resolveRuntimeConfigIntent`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every installable runtime resolves a global config dir through runtime-homes', () => {
|
||||
for (const runtime of allRuntimes) {
|
||||
const dir = getGlobalConfigDir(runtime);
|
||||
assert.equal(typeof dir, 'string', `${runtime} config dir must be a string`);
|
||||
assert.ok(dir.length > 0, `${runtime} must resolve a non-empty global config dir`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue-57 AC2 — config-mutation dispatch is closed over the explicit registry', () => {
|
||||
test('every config intent uses a registry-declared install surface', () => {
|
||||
const surfaces = new Set(INSTALL_SURFACES);
|
||||
for (const runtime of allRuntimes) {
|
||||
const { installSurface } = resolveRuntimeConfigIntent(runtime);
|
||||
assert.ok(
|
||||
surfaces.has(installSurface),
|
||||
`${runtime} dispatches config via unregistered surface "${installSurface}" — add it `
|
||||
+ 'to INSTALL_SURFACES in the registry instead of branching on it inline.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every finishInstall permission writer is null or a registry-known runtime', () => {
|
||||
// Registry-derived (no hand-maintained vocabulary): a permission writer either
|
||||
// names a runtime that is itself in the registry, or is null. A writer pointing
|
||||
// at an unregistered runtime would mean finishInstall dispatches a config mutation
|
||||
// outside the registry's known set.
|
||||
for (const runtime of allRuntimes) {
|
||||
const { finishPermissionWriter } = resolveRuntimeConfigIntent(runtime);
|
||||
assert.ok(
|
||||
finishPermissionWriter === null || ALLOWED_CONFIG_RUNTIMES.has(finishPermissionWriter),
|
||||
`${runtime} uses finishPermissionWriter "${finishPermissionWriter}", which is neither `
|
||||
+ 'null nor a registry-known runtime — route it through a registered adapter.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown runtime fails loudly through both strict projections (no silent fallthrough)', () => {
|
||||
assert.throws(
|
||||
() => resolveRuntimeConfigIntent(SENTINEL),
|
||||
TypeError,
|
||||
'config adapter registry must reject an unknown runtime, not dispatch it silently',
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveRuntimeArtifactLayout(SENTINEL, path.join(os.tmpdir(), 'gsd-57'), 'global'),
|
||||
TypeError,
|
||||
'artifact-layout projection must reject an unknown runtime',
|
||||
);
|
||||
});
|
||||
|
||||
test('registry rejects prototype-chain keys (no proto-pollution dispatch bypass)', () => {
|
||||
for (const key of ['__proto__', 'constructor', 'prototype', 'toString']) {
|
||||
assert.throws(
|
||||
() => resolveRuntimeConfigIntent(key),
|
||||
TypeError,
|
||||
`${key} must throw, not resolve via the prototype chain`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// allow-test-rule: structural-regression-guard
|
||||
// structural guard over bin/install.js source. Behavioral assertions
|
||||
// cannot observe inline `runtime === '...'` config branching, so this enforces that
|
||||
// every inline per-runtime branch references a runtime the adapter registry knows
|
||||
// about — a NEW branch against an unregistered runtime name fails here. It matches
|
||||
// positive equality only (`runtime === '<name>'` / `runtime === "<name>"`, both quote
|
||||
// styles), so `runtime !== 'string'`-style type guards are not implicated. See the
|
||||
// "coverage boundary" note at the top of the file for what this can and cannot catch.
|
||||
test('every inline `runtime === "..."` branch references a registry-known runtime', () => {
|
||||
const src = fs.readFileSync(path.join(ROOT, 'bin', 'install.js'), 'utf8');
|
||||
const literals = new Set(
|
||||
[...src.matchAll(/runtime === (?:'([a-z][a-z0-9-]*)'|"([a-z][a-z0-9-]*)")/g)]
|
||||
.map((m) => m[1] ?? m[2]),
|
||||
);
|
||||
assert.ok(literals.size > 0, 'expected to find inline runtime comparisons in bin/install.js');
|
||||
const unregistered = [...literals].filter((r) => !ALLOWED_CONFIG_RUNTIMES.has(r));
|
||||
assert.deepStrictEqual(
|
||||
unregistered,
|
||||
[],
|
||||
`inline 'runtime === "..."' branch(es) reference runtimes absent from the config adapter `
|
||||
+ `registry: ${unregistered.join(', ')} — register them in `
|
||||
+ 'src/runtime-config-adapter-registry.cts or route the logic through '
|
||||
+ 'resolveRuntimeConfigIntent instead of branching inline.',
|
||||
);
|
||||
});
|
||||
|
||||
// allow-test-rule: structural-regression-guard (#2103)
|
||||
// structural guard over bin/install.js source. VS Code
|
||||
// (capabilities/vscode/capability.json) is a registry runtime (role:runtime, for
|
||||
// validator/host-integration coverage) but is NEVER CLI-installed — it is a
|
||||
// Marketplace/VSIX extension with no --vscode flag and no allRuntimes membership
|
||||
// (see NON_INSTALLABLE_RUNTIMES in tests/runtime-flags.test.cjs). It must stay
|
||||
// fully descriptor-driven: bin/install.js must never special-case it by name.
|
||||
// This is a stricter, clearer-failure-message sibling of the generic
|
||||
// "every inline runtime === ..." guard above (which would also catch this, but
|
||||
// with a misleading "register it in the adapter registry" suggestion — vscode
|
||||
// must never be registered there at all, see the ALLOWED_CONFIG_RUNTIMES filter
|
||||
// in src/runtime-config-adapter-registry.cts).
|
||||
test('#2103: bin/install.js has ZERO runtime === "vscode" / isVscode branches (vscode stays fully descriptor-driven)', () => {
|
||||
const src = fs.readFileSync(path.join(ROOT, 'bin', 'install.js'), 'utf8');
|
||||
const runtimeComparisons = [...src.matchAll(/runtime === (?:'vscode'|"vscode")/g)];
|
||||
assert.deepStrictEqual(
|
||||
runtimeComparisons.map((m) => m[0]),
|
||||
[],
|
||||
'bin/install.js must not special-case vscode via `runtime === "vscode"` — vscode has no '
|
||||
+ 'install surface at all (installSurface: "none") and is never CLI-installed; any '
|
||||
+ 'vscode-specific behavior belongs in capabilities/vscode/capability.json, not an inline branch.',
|
||||
);
|
||||
const isVscodeRefs = [...src.matchAll(/\bisVscode\b/g)];
|
||||
assert.deepStrictEqual(
|
||||
isVscodeRefs.map((m) => m[0]),
|
||||
[],
|
||||
'bin/install.js must not introduce an isVscode flag — vscode is intentionally excluded '
|
||||
+ 'from runtimeFlags (Marketplace-distributed, never CLI-installed).',
|
||||
);
|
||||
});
|
||||
|
||||
// allow-test-rule: structural-regression-guard
|
||||
// delegation-presence guard. Catches wholesale removal of the registry
|
||||
// dispatch (a regression to scattered per-runtime config branching). Presence-style, not
|
||||
// absence-grep, so it does not bite on incidental non-config `runtime === '...'` checks.
|
||||
test('bin/install.js requires the config adapter registry and dispatches through it', () => {
|
||||
const src = fs.readFileSync(path.join(ROOT, 'bin', 'install.js'), 'utf8');
|
||||
assert.ok(
|
||||
src.includes('runtime-config-adapter-registry'),
|
||||
'bin/install.js no longer requires the runtime config adapter registry',
|
||||
);
|
||||
assert.ok(
|
||||
src.includes('resolveInstallPlan('),
|
||||
'bin/install.js no longer dispatches config through resolveInstallPlan',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,302 +0,0 @@
|
||||
// allow-test-rule: integration-test-input
|
||||
// Test-created temp dirs are the only filesystem reads here — not repo source files.
|
||||
// This is an integration test that seeds fixture files in OS temp dirs and
|
||||
// asserts that the installer correctly handles --dry-run and the
|
||||
// cleanupLegacyGsdCc exported helper.
|
||||
|
||||
/**
|
||||
* #607 — --dry-run flag and cleanupLegacyGsdCc wiring.
|
||||
*
|
||||
* Covers:
|
||||
* 1. Spawning `node bin/install.js --claude --global --dry-run` with an
|
||||
* isolated HOME that contains a seeded legacy artifact. Asserts exit 0,
|
||||
* stdout names the artifact and contains "dry" (case-insensitive), and
|
||||
* no files are mutated (artifact still present; no .claude install).
|
||||
* Also asserts the per-package cache path appears AT MOST ONCE (no
|
||||
* double-print regression).
|
||||
* 2. Spawning `node bin/install.js --claude --dry-run --uninstall` asserts
|
||||
* the "does not preview --uninstall" warning prints and exits 0 without
|
||||
* uninstalling anything.
|
||||
* 3. Direct unit call to the exported cleanupLegacyGsdCc helper:
|
||||
* - dryRun:true → plan lists the artifact, removes nothing.
|
||||
* - dryRun:false → seeded leftover removed, dev-preferences.md preserved.
|
||||
*/
|
||||
|
||||
'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 os = require('node:os');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const INSTALL_BIN = path.join(REPO_ROOT, 'bin', 'install.js');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function mkTmp(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function writeFile(filePath, content) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content, 'utf8');
|
||||
}
|
||||
|
||||
// The assembled signal string used as file content to trigger
|
||||
// content-references-old-package detection.
|
||||
const LEGACY_PKG_SIGNAL = 'gsd-core' + '-cc';
|
||||
|
||||
// ─── Suite 1: spawn --dry-run, assert no mutations ───────────────────────────
|
||||
|
||||
describe('#607 --dry-run flag: spawned installer exits 0 and mutates nothing', () => {
|
||||
let tmpHome;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = mkTmp('gsd-607-dryhome-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpHome);
|
||||
});
|
||||
|
||||
test('exits 0; stdout names artifact and contains "dry"; no install; artifact preserved; no double-print', () => {
|
||||
// Seed a legacy artifact: a .cjs hook file under HOME/.gemini/hooks/ whose
|
||||
// content contains the old package name (content-signal, not orphan-by-name).
|
||||
// This exercises the content-references-old-package reason exclusively.
|
||||
const legacyHook = path.join(tmpHome, '.gemini', 'hooks', 'gsd-old-update-worker.cjs');
|
||||
writeFile(legacyHook, `// installed via ${LEGACY_PKG_SIGNAL}\nconsole.log("old worker");`);
|
||||
|
||||
// Seed the legacy shared cache file
|
||||
const legacyCache = path.join(tmpHome, '.cache', 'gsd', 'gsd-update-check.json');
|
||||
writeFile(legacyCache, JSON.stringify({ legacy: true }));
|
||||
|
||||
// Spawn the installer with --dry-run
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_BIN, '--claude', '--global', '--dry-run'],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: tmpHome,
|
||||
USERPROFILE: tmpHome,
|
||||
// Redirect Claude config dir into isolated tmp home
|
||||
CLAUDE_CONFIG_DIR: path.join(tmpHome, '.claude'),
|
||||
// Suppress slow stale-SDK npm check
|
||||
GSD_SKIP_STALE_SDK_CHECK: '1',
|
||||
// Do NOT set GSD_TEST_MODE — we want the main() block to run
|
||||
GSD_TEST_MODE: undefined,
|
||||
},
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
}
|
||||
);
|
||||
|
||||
// Exit code must be 0
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`Expected exit 0 but got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
|
||||
);
|
||||
|
||||
const stdout = result.stdout + result.stderr;
|
||||
|
||||
// stdout must contain the word "dry" (case-insensitive)
|
||||
assert.match(
|
||||
stdout,
|
||||
/dry/i,
|
||||
`Expected stdout to contain "dry". Got:\n${stdout}`
|
||||
);
|
||||
|
||||
// stdout must mention the seeded legacy artifact path
|
||||
assert.ok(
|
||||
stdout.includes(legacyHook),
|
||||
`Expected stdout to mention ${legacyHook}.\nGot:\n${stdout}`
|
||||
);
|
||||
|
||||
// The seeded artifact must STILL EXIST (no mutations)
|
||||
assert.ok(
|
||||
fs.existsSync(legacyHook),
|
||||
`Legacy hook must still exist after --dry-run: ${legacyHook}`
|
||||
);
|
||||
|
||||
// The legacy cache must STILL EXIST
|
||||
assert.ok(
|
||||
fs.existsSync(legacyCache),
|
||||
`Legacy cache must still exist after --dry-run: ${legacyCache}`
|
||||
);
|
||||
|
||||
// No actual install happened — .claude/gsd-core must not exist
|
||||
const installDir = path.join(tmpHome, '.claude', 'gsd-core');
|
||||
assert.equal(
|
||||
fs.existsSync(installDir),
|
||||
false,
|
||||
`No install should happen during --dry-run; found: ${installDir}`
|
||||
);
|
||||
|
||||
// Regression: the per-package cache path must appear AT MOST ONCE
|
||||
// (guard against the duplicate-print bug where it was printed both inside
|
||||
// cleanupLegacyGsdCc and again in the outer --dry-run block).
|
||||
const updateCacheFileName = require(
|
||||
path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'package-identity.cjs')
|
||||
).updateCacheFileName;
|
||||
const perPkgCacheFile = path.join(tmpHome, '.cache', 'gsd', updateCacheFileName);
|
||||
const occurrences = stdout.split(perPkgCacheFile).length - 1;
|
||||
assert.ok(
|
||||
occurrences <= 1,
|
||||
`Per-package cache path must appear at most once in stdout; found ${occurrences} times.\nstdout:\n${stdout}`
|
||||
);
|
||||
});
|
||||
|
||||
test('--uninstall --dry-run prints "does not preview --uninstall" warning and exits 0', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_BIN, '--claude', '--uninstall', '--dry-run'],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: tmpHome,
|
||||
USERPROFILE: tmpHome,
|
||||
CLAUDE_CONFIG_DIR: path.join(tmpHome, '.claude'),
|
||||
GSD_SKIP_STALE_SDK_CHECK: '1',
|
||||
GSD_TEST_MODE: undefined,
|
||||
},
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`Expected exit 0 but got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
|
||||
);
|
||||
|
||||
const stdout = result.stdout + result.stderr;
|
||||
|
||||
// Must print the warning about --uninstall not being previewed
|
||||
assert.ok(
|
||||
stdout.includes('does not preview --uninstall'),
|
||||
`Expected "does not preview --uninstall" warning.\nGot:\n${stdout}`
|
||||
);
|
||||
|
||||
// No uninstall occurred — .claude/gsd-core must not have been removed
|
||||
// (it never existed, but we confirm the installer didn't blow up)
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
'Process must exit 0'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 2: direct helper unit tests ───────────────────────────────────────
|
||||
|
||||
describe('#607 cleanupLegacyGsdCc: exported helper unit tests', () => {
|
||||
// GSD_TEST_MODE is already set at the top so requiring install.js is safe.
|
||||
const { cleanupLegacyGsdCc } = require(INSTALL_BIN);
|
||||
|
||||
let tmpRoot;
|
||||
let homeDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = mkTmp('gsd-607-unit-');
|
||||
homeDir = path.join(tmpRoot, 'home');
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpRoot);
|
||||
});
|
||||
|
||||
test('dryRun:true — plan lists seeded artifact; nothing removed', () => {
|
||||
// Seed a content-signal code file under homeDir/.gemini/hooks/
|
||||
const legacyHook = path.join(homeDir, '.gemini', 'hooks', 'gsd-old-update-worker.cjs');
|
||||
writeFile(legacyHook, `// installed via ${LEGACY_PKG_SIGNAL}\nconsole.log("old worker");`);
|
||||
|
||||
const logMessages = [];
|
||||
const mockLogger = { log: (msg) => logMessages.push(msg) };
|
||||
|
||||
const { plan, result } = cleanupLegacyGsdCc({
|
||||
homeDir,
|
||||
dryRun: true,
|
||||
logger: mockLogger,
|
||||
});
|
||||
|
||||
// Plan must include the seeded artifact
|
||||
const planEntry = plan.find((p) => p.path === legacyHook);
|
||||
assert.ok(planEntry, `Plan must list seeded artifact: ${legacyHook}\nActual plan: ${JSON.stringify(plan)}`);
|
||||
|
||||
// dryRun result must flag it as skipped, not removed
|
||||
assert.equal(result.dryRun, true);
|
||||
assert.equal(result.removed.length, 0, 'dryRun must remove nothing');
|
||||
|
||||
// The artifact must still exist
|
||||
assert.ok(
|
||||
fs.existsSync(legacyHook),
|
||||
`Artifact must survive dry-run: ${legacyHook}`
|
||||
);
|
||||
|
||||
// Logger should have been called at least once
|
||||
assert.ok(logMessages.length > 0, 'Logger should have been called');
|
||||
});
|
||||
|
||||
test('dryRun:false — seeded leftover removed; dev-preferences.md preserved', () => {
|
||||
// Seed a content-signal code file
|
||||
const legacyHook = path.join(homeDir, '.gemini', 'hooks', 'gsd-old-update-worker.cjs');
|
||||
writeFile(legacyHook, `// installed via ${LEGACY_PKG_SIGNAL}\nconsole.log("old worker");`);
|
||||
|
||||
// Seed a dev-preferences.md that must NOT be removed
|
||||
const devPrefs = path.join(homeDir, '.gemini', 'gsd-core', 'dev-preferences.md');
|
||||
writeFile(devPrefs, '# My prefs\n\nSome user content — must not be touched.');
|
||||
|
||||
const { plan, result } = cleanupLegacyGsdCc({
|
||||
homeDir,
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
// The legacy hook must be in the plan
|
||||
const planEntry = plan.find((p) => p.path === legacyHook);
|
||||
assert.ok(planEntry, `Legacy hook must appear in plan: ${legacyHook}\nActual plan: ${JSON.stringify(plan)}`);
|
||||
|
||||
// The legacy hook must have been removed
|
||||
assert.equal(
|
||||
fs.existsSync(legacyHook),
|
||||
false,
|
||||
`Legacy hook must be removed: ${legacyHook}`
|
||||
);
|
||||
|
||||
// The removed list must include the legacy hook
|
||||
assert.ok(
|
||||
result.removed.includes(legacyHook),
|
||||
`removed[] must include legacy hook\nActual removed: ${JSON.stringify(result.removed)}`
|
||||
);
|
||||
|
||||
// dev-preferences.md must NOT be in the plan and must still exist
|
||||
const devPrefsInPlan = plan.find((p) => p.path === devPrefs);
|
||||
assert.equal(devPrefsInPlan, undefined, 'dev-preferences.md must never appear in plan');
|
||||
assert.ok(
|
||||
fs.existsSync(devPrefs),
|
||||
`dev-preferences.md must be preserved: ${devPrefs}`
|
||||
);
|
||||
});
|
||||
|
||||
test('dryRun:true — returns plan and result without error (no files present)', () => {
|
||||
// homeDir exists but no legacy artifacts seeded
|
||||
const { plan, result } = cleanupLegacyGsdCc({
|
||||
homeDir,
|
||||
dryRun: true,
|
||||
});
|
||||
|
||||
assert.ok(Array.isArray(plan), 'plan must be an array');
|
||||
assert.equal(result.dryRun, true);
|
||||
assert.equal(result.removed.length, 0, 'nothing to remove');
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
// #69 regression: applySurface must NOT re-flatten the nested skill layout
|
||||
//
|
||||
// Bug: stageSkillsForRuntimeAsSkills gated nesting on `resolvedProfile.skills === '*'`
|
||||
// (the sentinel). applySurface → resolveSurface materializes the full profile into a
|
||||
// concrete Set<string>, so the sentinel check was never true on the surface path.
|
||||
// Result: applySurface called kind.stage(resolved) → stageSkillsForRuntimeAsSkills with
|
||||
// a concrete Set → doNest = false → flat layout, overwriting the nested install.
|
||||
//
|
||||
// Fix (install-profiles.cts): gate nesting on full OR full-equivalent (all routerStems
|
||||
// present in the concrete Set) so that the surface path preserves nesting.
|
||||
//
|
||||
// NOTE: As of #924 Claude has been REVERTED to FLAT. This test now uses Cline as the
|
||||
// representative nested runtime. The original claude-global test below is updated to
|
||||
// assert the flat layout (>= 60 top-level gsd-* entries, concrete skills discoverable).
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const COMMANDS_GSD = path.join(ROOT, 'commands', 'gsd');
|
||||
|
||||
const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs');
|
||||
const { applySurface } = require('../gsd-core/bin/lib/surface.cjs');
|
||||
const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
describe('issue-69: applySurface preserves nested skill layout (no re-flatten)', () => {
|
||||
// #924: Claude is now flat; use Cline as the representative nested runtime.
|
||||
test('cline global full: applySurface keeps 6 router dirs and nested gsd-ns-manage/skills/help/SKILL.md', (t) => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-69-surface-'));
|
||||
t.after(() => { try { cleanup(tmpDir); } catch { /* best-effort */ } });
|
||||
|
||||
// Step 1: full install
|
||||
const manifest = loadSkillsManifest(COMMANDS_GSD);
|
||||
const resolved = resolveProfile({ modes: ['full'], manifest });
|
||||
installRuntimeArtifacts('cline', tmpDir, 'global', resolved);
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
|
||||
// Sanity: install must produce nested layout (6 top-level router dirs)
|
||||
const topLevelAfterInstall = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-'));
|
||||
assert.strictEqual(
|
||||
topLevelAfterInstall.length,
|
||||
6,
|
||||
`Install must produce exactly 6 gsd-* top-level dirs (routers). Got ${topLevelAfterInstall.length}: [${topLevelAfterInstall.join(', ')}]`,
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(skillsDir, 'gsd-ns-workflow', 'skills', 'plan-phase', 'SKILL.md')),
|
||||
'After install: gsd-ns-workflow/skills/plan-phase/SKILL.md must exist',
|
||||
);
|
||||
|
||||
// Step 2: applySurface (full surface, no surface state file → resolves to full)
|
||||
const layout = resolveRuntimeArtifactLayout('cline', tmpDir, 'global');
|
||||
applySurface(tmpDir, layout, manifest);
|
||||
|
||||
// Step 3: assert nested layout is preserved after applySurface
|
||||
const topLevelAfterSurface = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-'));
|
||||
assert.strictEqual(
|
||||
topLevelAfterSurface.length,
|
||||
6,
|
||||
`After applySurface: expected exactly 6 gsd-* top-level dirs (routers only). Got ${topLevelAfterSurface.length}: [${topLevelAfterSurface.join(', ')}]. ` +
|
||||
'Re-flattening detected: applySurface must preserve nested layout (#69 regression).',
|
||||
);
|
||||
|
||||
// The nested SKILL.md must still exist (not re-flattened to top-level concrete dir)
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(skillsDir, 'gsd-ns-workflow', 'skills', 'plan-phase', 'SKILL.md')),
|
||||
'After applySurface: gsd-ns-workflow/skills/plan-phase/SKILL.md must still exist (nested layout preserved)',
|
||||
);
|
||||
|
||||
// The concrete skill must NOT have been promoted to a top-level flat dir
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(skillsDir, 'gsd-plan-phase', 'SKILL.md')),
|
||||
'After applySurface: gsd-plan-phase/ must NOT exist at top level (#69 re-flatten regression guard)',
|
||||
);
|
||||
});
|
||||
|
||||
// #924 companion: Claude must use FLAT layout and applySurface must NOT re-nest it.
|
||||
test('claude global full: install produces flat layout and applySurface preserves it (#924)', (t) => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-924-69-'));
|
||||
t.after(() => { try { cleanup(tmpDir); } catch { /* best-effort */ } });
|
||||
|
||||
const manifest = loadSkillsManifest(COMMANDS_GSD);
|
||||
const resolved = resolveProfile({ modes: ['full'], manifest });
|
||||
installRuntimeArtifacts('claude', tmpDir, 'global', resolved);
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
|
||||
// Install must produce FLAT layout (>= 60 gsd-* dirs)
|
||||
const topLevelAfterInstall = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-'));
|
||||
assert.ok(
|
||||
topLevelAfterInstall.length >= 60,
|
||||
`Claude install must produce >= 60 gsd-* top-level dirs (flat, #924). Got ${topLevelAfterInstall.length}.`,
|
||||
);
|
||||
|
||||
// gsd-plan-phase must be directly at top level
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(skillsDir, 'gsd-plan-phase', 'SKILL.md')),
|
||||
'After claude install: gsd-plan-phase/SKILL.md must be at top level (flat layout, #924)',
|
||||
);
|
||||
|
||||
// No nested skills/ subdirs under gsd-ns-* in Claude
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(skillsDir, 'gsd-ns-workflow', 'skills')),
|
||||
'After claude install: gsd-ns-workflow/skills/ must NOT exist (flat layout, no nesting, #924)',
|
||||
);
|
||||
|
||||
// applySurface must preserve flat layout
|
||||
const layout = resolveRuntimeArtifactLayout('claude', tmpDir, 'global');
|
||||
applySurface(tmpDir, layout, manifest);
|
||||
|
||||
const topLevelAfterSurface = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-'));
|
||||
assert.ok(
|
||||
topLevelAfterSurface.length >= 60,
|
||||
`After applySurface: claude must still have >= 60 gsd-* dirs (flat preserved). Got ${topLevelAfterSurface.length}.`,
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(skillsDir, 'gsd-plan-phase', 'SKILL.md')),
|
||||
'After applySurface: gsd-plan-phase/SKILL.md must remain at top level (#924)',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,315 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// The Cline rules markdown, the PreToolUse hook script, and the AGENTS.md block
|
||||
// ARE the deployed contract that the Cline runtime loads/executes — testing their
|
||||
// text/behavior tests the shipped artifact. Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Issue #787 — elevate Cline: write hooks (.clinerules/hooks/) + AGENTS.md.
|
||||
*
|
||||
* Verifies the installer now emits the Cline directory-form rules, a
|
||||
* PreToolUse lifecycle hook (Cline JSON stdin → {cancel,errorMessage,
|
||||
* contextModification} protocol), and a global ~/.agents/AGENTS.md instruction
|
||||
* target. Self-contained: does NOT depend on the #782 Cline skills work.
|
||||
*
|
||||
* Primary sources adjudicated:
|
||||
* - https://cline.bot/blog/cline-v3-36-hooks
|
||||
* hooks live at .clinerules/hooks/<EventName> (project) and
|
||||
* ~/Documents/Cline/Rules/Hooks/ (global); executable scripts named
|
||||
* exactly after the event with no extension; JSON stdin → JSON stdout
|
||||
* with cancel / errorMessage / contextModification.
|
||||
* - https://docs.cline.bot/customization/cline-rules
|
||||
* Cline processes all .md/.txt files inside a .clinerules/ directory and
|
||||
* reads cross-tool global instructions from ~/.agents/AGENTS.md.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { runNode } = require('./helpers/process-seam.cjs');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
|
||||
// #3145: class-norm timeouts, not per-suite values — see helpers/timeouts.cjs.
|
||||
const { PROBE_TIMEOUT_MS, INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||||
|
||||
const {
|
||||
install,
|
||||
uninstall,
|
||||
buildClineRulesBody,
|
||||
buildClinePreToolUseHook,
|
||||
buildClineAgentsMdBody,
|
||||
mergeGsdAgentsMd,
|
||||
stripGsdFromAgentsMd,
|
||||
GSD_AGENTS_MD_MARKER,
|
||||
GSD_AGENTS_MD_CLOSE_MARKER,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('#787 Cline pure helpers', () => {
|
||||
test('buildClineRulesBody returns GSD directory-form rules markdown', () => {
|
||||
const body = buildClineRulesBody();
|
||||
assert.equal(typeof body, 'string');
|
||||
assert.match(body, /GSD workflows live in `gsd-core\/workflows\/`/);
|
||||
assert.ok(body.endsWith('\n'), 'rules body should end with a trailing newline');
|
||||
});
|
||||
|
||||
test('buildClinePreToolUseHook returns a syntactically valid Node script', () => {
|
||||
const script = buildClinePreToolUseHook();
|
||||
assert.match(script, /^#!\/usr\/bin\/env node/, 'must carry a node shebang');
|
||||
// Cline protocol fields must be present in the emitted decision surface.
|
||||
assert.match(script, /cancel/);
|
||||
assert.match(script, /errorMessage/);
|
||||
const tmp = createTempDir('gsd-787-hookcheck-');
|
||||
try {
|
||||
const p = path.join(tmp, 'PreToolUse');
|
||||
fs.writeFileSync(p, script);
|
||||
const res = runNode(['--check', p], { timeoutMs: PROBE_TIMEOUT_MS });
|
||||
assert.equal(res.exitCode, 0, `node --check failed: ${res.stderr}`);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('PreToolUse hook allows a normal tool call (cancel:false)', () => {
|
||||
const tmp = createTempDir('gsd-787-hookrun-');
|
||||
try {
|
||||
const p = path.join(tmp, 'PreToolUse');
|
||||
fs.writeFileSync(p, buildClinePreToolUseHook());
|
||||
const res = runNode([p], {
|
||||
input: JSON.stringify({ toolName: 'read_file', toolInput: { path: 'src/index.ts' } }),
|
||||
timeoutMs: PROBE_TIMEOUT_MS,
|
||||
});
|
||||
assert.equal(res.exitCode, 0);
|
||||
const out = JSON.parse(res.stdout);
|
||||
assert.equal(out.cancel, false);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('PreToolUse hook cancels a write into .planning/ with an errorMessage', () => {
|
||||
const tmp = createTempDir('gsd-787-hookguard-');
|
||||
try {
|
||||
const p = path.join(tmp, 'PreToolUse');
|
||||
fs.writeFileSync(p, buildClinePreToolUseHook());
|
||||
const res = runNode([p], {
|
||||
input: JSON.stringify({ toolName: 'write_to_file', toolInput: { path: '.planning/ROADMAP.md', content: 'x' } }),
|
||||
timeoutMs: PROBE_TIMEOUT_MS,
|
||||
});
|
||||
assert.equal(res.exitCode, 0);
|
||||
const out = JSON.parse(res.stdout);
|
||||
assert.equal(out.cancel, true);
|
||||
assert.match(out.errorMessage, /\.planning/);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('PreToolUse hook does NOT cancel a write to a non-planning path whose CONTENT mentions .planning/', () => {
|
||||
const tmp = createTempDir('gsd-787-hookfp-');
|
||||
try {
|
||||
const p = path.join(tmp, 'PreToolUse');
|
||||
fs.writeFileSync(p, buildClinePreToolUseHook());
|
||||
const res = runNode([p], {
|
||||
input: JSON.stringify({
|
||||
toolName: 'write_to_file',
|
||||
toolInput: { path: 'docs/guide.md', content: 'Edit your .planning/ROADMAP.md via /gsd commands.' },
|
||||
}),
|
||||
timeoutMs: PROBE_TIMEOUT_MS,
|
||||
});
|
||||
assert.equal(res.exitCode, 0);
|
||||
assert.equal(JSON.parse(res.stdout).cancel, false, 'content mentioning .planning must not trigger a cancel');
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('PreToolUse hook fails open on malformed stdin', () => {
|
||||
const tmp = createTempDir('gsd-787-hookbad-');
|
||||
try {
|
||||
const p = path.join(tmp, 'PreToolUse');
|
||||
fs.writeFileSync(p, buildClinePreToolUseHook());
|
||||
const res = runNode([p], { input: 'not json{', timeoutMs: PROBE_TIMEOUT_MS });
|
||||
assert.equal(res.exitCode, 0);
|
||||
assert.equal(JSON.parse(res.stdout).cancel, false);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('mergeGsdAgentsMd creates a marker-delimited block when no file exists', () => {
|
||||
const tmp = createTempDir('gsd-787-agents-new-');
|
||||
try {
|
||||
const p = path.join(tmp, 'AGENTS.md');
|
||||
mergeGsdAgentsMd(p, buildClineAgentsMdBody());
|
||||
const content = fs.readFileSync(p, 'utf8');
|
||||
assert.ok(content.includes(GSD_AGENTS_MD_MARKER));
|
||||
assert.ok(content.includes(GSD_AGENTS_MD_CLOSE_MARKER));
|
||||
assert.match(content, /GSD/);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('mergeGsdAgentsMd preserves pre-existing user content', () => {
|
||||
const tmp = createTempDir('gsd-787-agents-merge-');
|
||||
try {
|
||||
const p = path.join(tmp, 'AGENTS.md');
|
||||
fs.writeFileSync(p, '# My rules\n\nKeep me.\n');
|
||||
mergeGsdAgentsMd(p, buildClineAgentsMdBody());
|
||||
const content = fs.readFileSync(p, 'utf8');
|
||||
assert.match(content, /Keep me\./);
|
||||
assert.ok(content.includes(GSD_AGENTS_MD_MARKER));
|
||||
// Idempotent: second merge does not duplicate the block.
|
||||
mergeGsdAgentsMd(p, buildClineAgentsMdBody());
|
||||
const twice = fs.readFileSync(p, 'utf8');
|
||||
const occurrences = twice.split(GSD_AGENTS_MD_MARKER).length - 1;
|
||||
assert.equal(occurrences, 1, 'GSD block must not duplicate on re-merge');
|
||||
assert.match(twice, /Keep me\./);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('stripGsdFromAgentsMd returns null when file was GSD-only, else cleaned content', () => {
|
||||
const onlyGsd = `${GSD_AGENTS_MD_MARKER}\nhi\n${GSD_AGENTS_MD_CLOSE_MARKER}\n`;
|
||||
assert.equal(stripGsdFromAgentsMd(onlyGsd), null);
|
||||
const mixed = `# Keep\n\n${GSD_AGENTS_MD_MARKER}\nhi\n${GSD_AGENTS_MD_CLOSE_MARKER}\n`;
|
||||
const cleaned = stripGsdFromAgentsMd(mixed);
|
||||
assert.match(cleaned, /# Keep/);
|
||||
assert.ok(!cleaned.includes(GSD_AGENTS_MD_MARKER));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Local install: directory form + hook ───────────────────────────────────────
|
||||
|
||||
describe('#787 Cline local install — directory form + PreToolUse hook', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-787-cline-local-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('writes .clinerules/ as a directory containing gsd.md', () => {
|
||||
install(false, 'cline');
|
||||
const dir = path.join(tmpDir, '.clinerules');
|
||||
assert.ok(fs.statSync(dir).isDirectory(), '.clinerules must be a directory');
|
||||
const ruleFile = path.join(dir, 'gsd.md');
|
||||
assert.ok(fs.existsSync(ruleFile), '.clinerules/gsd.md must exist');
|
||||
assert.match(fs.readFileSync(ruleFile, 'utf8'), /gsd-core\/workflows\//);
|
||||
});
|
||||
|
||||
test('writes an executable PreToolUse hook with no extension', () => {
|
||||
install(false, 'cline');
|
||||
const hook = path.join(tmpDir, '.clinerules', 'hooks', 'PreToolUse');
|
||||
assert.ok(fs.existsSync(hook), '.clinerules/hooks/PreToolUse must exist');
|
||||
if (process.platform !== 'win32') {
|
||||
const mode = fs.statSync(hook).mode;
|
||||
assert.ok((mode & 0o111) !== 0, 'PreToolUse must be executable');
|
||||
}
|
||||
});
|
||||
|
||||
test('migrates a legacy single-file .clinerules into the directory form', () => {
|
||||
// Simulate a pre-#787 install that wrote a .clinerules FILE.
|
||||
fs.writeFileSync(path.join(tmpDir, '.clinerules'), '# legacy file\n');
|
||||
install(false, 'cline');
|
||||
const dir = path.join(tmpDir, '.clinerules');
|
||||
assert.ok(fs.statSync(dir).isDirectory(), 'legacy file must be replaced by a directory');
|
||||
assert.ok(fs.existsSync(path.join(dir, 'gsd.md')));
|
||||
});
|
||||
|
||||
test('does not follow a symlinked .clinerules (writes the real directory in place)', () => {
|
||||
if (process.platform === 'win32') return; // symlink perms differ on Windows
|
||||
// Point .clinerules at an external directory via symlink; install must NOT
|
||||
// write GSD files through the link.
|
||||
const external = path.join(tmpDir, 'external-target');
|
||||
fs.mkdirSync(external);
|
||||
fs.symlinkSync(external, path.join(tmpDir, '.clinerules'));
|
||||
install(false, 'cline');
|
||||
const dir = path.join(tmpDir, '.clinerules');
|
||||
assert.ok(fs.lstatSync(dir).isDirectory() && !fs.lstatSync(dir).isSymbolicLink(),
|
||||
'.clinerules must be a real directory, not the symlink');
|
||||
assert.ok(!fs.existsSync(path.join(external, 'gsd.md')), 'must not write through the symlink target');
|
||||
assert.ok(fs.existsSync(path.join(dir, 'gsd.md')));
|
||||
});
|
||||
|
||||
test('manifest tracks the new directory-form artifacts', () => {
|
||||
install(false, 'cline');
|
||||
const manifestPath = path.join(tmpDir, 'gsd-file-manifest.json');
|
||||
assert.ok(fs.existsSync(manifestPath));
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
assert.ok(manifest.files['.clinerules/gsd.md'], 'manifest should track .clinerules/gsd.md');
|
||||
assert.ok(manifest.files['.clinerules/hooks/PreToolUse'], 'manifest should track the hook');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Global install: ~/.agents/AGENTS.md (subprocess, HOME-isolated) ─────────────
|
||||
|
||||
describe('#787 Cline global install — ~/.agents/AGENTS.md', () => {
|
||||
function runGlobalClineInstall() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-787-cline-global-'));
|
||||
const env = { ...process.env, HOME: root, USERPROFILE: root };
|
||||
delete env.GSD_TEST_MODE;
|
||||
const res = runNode(
|
||||
[INSTALL_SCRIPT, '--cline', '--global', '--config-dir', path.join(root, '.cline')],
|
||||
{ cwd: root, env, timeoutMs: INSTALL_TIMEOUT_MS },
|
||||
);
|
||||
return { root, res };
|
||||
}
|
||||
|
||||
test('writes ~/.agents/AGENTS.md with a GSD marker block', () => {
|
||||
const { root, res } = runGlobalClineInstall();
|
||||
try {
|
||||
assert.equal(res.exitCode, 0, `installer failed: ${res.stderr}`);
|
||||
const agents = path.join(root, '.agents', 'AGENTS.md');
|
||||
assert.ok(fs.existsSync(agents), '~/.agents/AGENTS.md must exist after a global Cline install');
|
||||
const content = fs.readFileSync(agents, 'utf8');
|
||||
assert.ok(content.includes(GSD_AGENTS_MD_MARKER));
|
||||
assert.match(content, /GSD/);
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Uninstall symmetry ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('#787 Cline uninstall removes managed artifacts', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-787-cline-uninstall-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('local uninstall removes .clinerules/gsd.md and the hook', () => {
|
||||
install(false, 'cline');
|
||||
assert.ok(fs.existsSync(path.join(tmpDir, '.clinerules', 'gsd.md')));
|
||||
uninstall(false, 'cline');
|
||||
assert.ok(!fs.existsSync(path.join(tmpDir, '.clinerules', 'gsd.md')), 'gsd.md should be removed');
|
||||
assert.ok(!fs.existsSync(path.join(tmpDir, '.clinerules', 'hooks', 'PreToolUse')), 'hook should be removed');
|
||||
});
|
||||
});
|
||||
@@ -1415,3 +1415,123 @@ describe('installOpencodeFamilySkills destination parity (#2911 sibling coverage
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/issue-69-surface-keeps-nested.test.cjs — consolidation epic #1969 (H3 #3336)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe('folded:issue-69-surface-keeps-nested', () => {
|
||||
|
||||
// #69 regression, folded from issue-69-surface-keeps-nested.test.cjs:
|
||||
// stageSkillsForRuntimeAsSkills gated nesting on `resolvedProfile.skills === '*'`
|
||||
// (the sentinel). applySurface → resolveSurface materializes the full profile
|
||||
// into a concrete Set<string>, so the sentinel check was never true on the
|
||||
// surface path, causing applySurface to re-flatten a nested install. Fix
|
||||
// (install-profiles.cts): gate nesting on full OR full-equivalent (all
|
||||
// routerStems present in the concrete Set).
|
||||
//
|
||||
// #924: Claude was reverted to FLAT, so the claude case below asserts the
|
||||
// flat layout is preserved (not re-nested) rather than a nested one.
|
||||
describe('issue-69: applySurface preserves nested skill layout (no re-flatten)', () => {
|
||||
test('cline global full: applySurface keeps 6 router dirs and nested gsd-ns-manage/skills/help/SKILL.md', (t) => {
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs');
|
||||
const dir = tmpDir('gsd-69-surface-');
|
||||
t.after(() => { try { cleanup(dir); } catch { /* best-effort */ } });
|
||||
|
||||
// Step 1: full install
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const resolved = resolveProfile({ modes: ['full'], manifest });
|
||||
installRuntimeArtifacts('cline', dir, 'global', resolved);
|
||||
|
||||
const skillsDir = path.join(dir, 'skills');
|
||||
|
||||
// Sanity: install must produce nested layout (6 top-level router dirs)
|
||||
const topLevelAfterInstall = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-'));
|
||||
assert.strictEqual(
|
||||
topLevelAfterInstall.length,
|
||||
6,
|
||||
`Install must produce exactly 6 gsd-* top-level dirs (routers). Got ${topLevelAfterInstall.length}: [${topLevelAfterInstall.join(', ')}]`,
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(skillsDir, 'gsd-ns-workflow', 'skills', 'plan-phase', 'SKILL.md')),
|
||||
'After install: gsd-ns-workflow/skills/plan-phase/SKILL.md must exist',
|
||||
);
|
||||
|
||||
// Step 2: applySurface (full surface, no surface state file → resolves to full)
|
||||
const layout = resolveRuntimeArtifactLayout('cline', dir, 'global');
|
||||
applySurface(dir, layout, manifest);
|
||||
|
||||
// Step 3: assert nested layout is preserved after applySurface
|
||||
const topLevelAfterSurface = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-'));
|
||||
assert.strictEqual(
|
||||
topLevelAfterSurface.length,
|
||||
6,
|
||||
`After applySurface: expected exactly 6 gsd-* top-level dirs (routers only). Got ${topLevelAfterSurface.length}: [${topLevelAfterSurface.join(', ')}]. ` +
|
||||
'Re-flattening detected: applySurface must preserve nested layout (#69 regression).',
|
||||
);
|
||||
|
||||
// The nested SKILL.md must still exist (not re-flattened to top-level concrete dir)
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(skillsDir, 'gsd-ns-workflow', 'skills', 'plan-phase', 'SKILL.md')),
|
||||
'After applySurface: gsd-ns-workflow/skills/plan-phase/SKILL.md must still exist (nested layout preserved)',
|
||||
);
|
||||
|
||||
// The concrete skill must NOT have been promoted to a top-level flat dir
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(skillsDir, 'gsd-plan-phase', 'SKILL.md')),
|
||||
'After applySurface: gsd-plan-phase/ must NOT exist at top level (#69 re-flatten regression guard)',
|
||||
);
|
||||
});
|
||||
|
||||
// #924 companion: Claude must use FLAT layout and applySurface must NOT re-nest it.
|
||||
test('claude global full: install produces flat layout and applySurface preserves it (#924)', (t) => {
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs');
|
||||
const dir = tmpDir('gsd-924-69-');
|
||||
t.after(() => { try { cleanup(dir); } catch { /* best-effort */ } });
|
||||
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const resolved = resolveProfile({ modes: ['full'], manifest });
|
||||
installRuntimeArtifacts('claude', dir, 'global', resolved);
|
||||
|
||||
const skillsDir = path.join(dir, 'skills');
|
||||
|
||||
// Install must produce FLAT layout (>= 60 gsd-* dirs)
|
||||
const topLevelAfterInstall = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-'));
|
||||
assert.ok(
|
||||
topLevelAfterInstall.length >= 60,
|
||||
`Claude install must produce >= 60 gsd-* top-level dirs (flat, #924). Got ${topLevelAfterInstall.length}.`,
|
||||
);
|
||||
|
||||
// gsd-plan-phase must be directly at top level
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(skillsDir, 'gsd-plan-phase', 'SKILL.md')),
|
||||
'After claude install: gsd-plan-phase/SKILL.md must be at top level (flat layout, #924)',
|
||||
);
|
||||
|
||||
// No nested skills/ subdirs under gsd-ns-* in Claude
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(skillsDir, 'gsd-ns-workflow', 'skills')),
|
||||
'After claude install: gsd-ns-workflow/skills/ must NOT exist (flat layout, no nesting, #924)',
|
||||
);
|
||||
|
||||
// applySurface must preserve flat layout
|
||||
const layout = resolveRuntimeArtifactLayout('claude', dir, 'global');
|
||||
applySurface(dir, layout, manifest);
|
||||
|
||||
const topLevelAfterSurface = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-'));
|
||||
assert.ok(
|
||||
topLevelAfterSurface.length >= 60,
|
||||
`After applySurface: claude must still have >= 60 gsd-* dirs (flat preserved). Got ${topLevelAfterSurface.length}.`,
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(skillsDir, 'gsd-plan-phase', 'SKILL.md')),
|
||||
'After applySurface: gsd-plan-phase/SKILL.md must remain at top level (#924)',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,6 +26,18 @@ const {
|
||||
} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-config-adapter-registry.cjs'));
|
||||
const registry = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'capability-registry.cjs'));
|
||||
|
||||
// Folded from tests/issue-57-runtime-install-no-drift.test.cjs (issue #57).
|
||||
process.env.GSD_TEST_MODE = '1'; // must precede require of bin/install.js
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const { allRuntimes, runtimeMap } = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
const { resolveRuntimeArtifactLayout } = require(
|
||||
path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-artifact-layout.cjs'),
|
||||
);
|
||||
const { getGlobalConfigDir } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs'));
|
||||
|
||||
const sorted = (iterable) => [...iterable].sort();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source-of-truth table — DERIVED from the capability registry descriptors.
|
||||
// Each row is the descriptor projection of one runtime's config intent. This is
|
||||
@@ -373,3 +385,169 @@ describe('resolveInstallPlan — descriptor-projection contract (count-agnostic)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe('folded:issue-57-runtime-install-no-drift', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Folded from tests/issue-57-runtime-install-no-drift.test.cjs (issue #57,
|
||||
// H3 Wave 4 consolidation). Protects the Runtime Install Policy Module
|
||||
// boundary (ADR-58) and the explicit Runtime Config Adapter Registry (#60):
|
||||
// these guards fail when supported-runtime metadata bypasses the registry
|
||||
// projection (AC1) or config-mutation dispatch bypasses the explicit adapter
|
||||
// registry (AC2). Known intentional asymmetry: `grok` appears in
|
||||
// runtime-homes.cjs's getGlobalConfigDir switch but not in the registry /
|
||||
// artifact-layout supported sets, and getGlobalConfigDir() falls back to
|
||||
// ~/.claude for an unknown runtime instead of throwing (deliberately
|
||||
// liberal) — only the registry and artifact-layout projections are loud
|
||||
// gates. One source case was dropped as subsumed (see note below).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('issue-57 AC1 — supported-runtime metadata has one projected source of truth', () => {
|
||||
test('installer allRuntimes, interactive runtimeMap, and registry agree on the supported set', () => {
|
||||
const installable = sorted(allRuntimes);
|
||||
assert.deepStrictEqual(
|
||||
installable,
|
||||
sorted(Object.values(runtimeMap)),
|
||||
'Drift: bin/install.js `allRuntimes` and the interactive `runtimeMap` selection menu '
|
||||
+ 'diverged. A runtime selectable in the prompt but absent from allRuntimes (or vice '
|
||||
+ 'versa) is a supported-runtime call site that skipped the projection.',
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
installable,
|
||||
sorted(ALLOWED_CONFIG_RUNTIMES),
|
||||
'Drift: bin/install.js `allRuntimes` and `ALLOWED_CONFIG_RUNTIMES` (runtime config '
|
||||
+ 'adapter registry) diverged. A runtime added to an installer call site without a '
|
||||
+ 'registry adapter entry bypasses the registry projection — register it in '
|
||||
+ 'src/runtime-config-adapter-registry.cts.',
|
||||
);
|
||||
});
|
||||
|
||||
// NOTE: source also asserted `resolveRuntimeConfigIntent(runtime).runtime === runtime`
|
||||
// for every `allRuntimes` entry — dropped here as subsumed by the
|
||||
// descriptor-projection contract test above (deep-equal over every registry
|
||||
// runtime, a strict superset of `allRuntimes` per the equality just asserted).
|
||||
|
||||
test('every installable runtime resolves a global config dir through runtime-homes', () => {
|
||||
for (const runtime of allRuntimes) {
|
||||
const dir = getGlobalConfigDir(runtime);
|
||||
assert.equal(typeof dir, 'string', `${runtime} config dir must be a string`);
|
||||
assert.ok(dir.length > 0, `${runtime} must resolve a non-empty global config dir`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('issue-57 AC2 — config-mutation dispatch is closed over the explicit registry', () => {
|
||||
test('every config intent uses a registry-declared install surface', () => {
|
||||
const surfaces = new Set(INSTALL_SURFACES);
|
||||
for (const runtime of allRuntimes) {
|
||||
const { installSurface } = resolveRuntimeConfigIntent(runtime);
|
||||
assert.ok(
|
||||
surfaces.has(installSurface),
|
||||
`${runtime} dispatches config via unregistered surface "${installSurface}" — add it `
|
||||
+ 'to INSTALL_SURFACES in the registry instead of branching on it inline.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('every finishInstall permission writer is null or a registry-known runtime', () => {
|
||||
for (const runtime of allRuntimes) {
|
||||
const { finishPermissionWriter } = resolveRuntimeConfigIntent(runtime);
|
||||
assert.ok(
|
||||
finishPermissionWriter === null || ALLOWED_CONFIG_RUNTIMES.has(finishPermissionWriter),
|
||||
`${runtime} uses finishPermissionWriter "${finishPermissionWriter}", which is neither `
|
||||
+ 'null nor a registry-known runtime — route it through a registered adapter.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('unknown runtime fails loudly through both strict projections (no silent fallthrough)', () => {
|
||||
const SENTINEL = '__drift_sentinel_runtime__';
|
||||
assert.throws(
|
||||
() => resolveRuntimeConfigIntent(SENTINEL),
|
||||
TypeError,
|
||||
'config adapter registry must reject an unknown runtime, not dispatch it silently',
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveRuntimeArtifactLayout(SENTINEL, path.join(os.tmpdir(), 'gsd-57'), 'global'),
|
||||
TypeError,
|
||||
'artifact-layout projection must reject an unknown runtime',
|
||||
);
|
||||
});
|
||||
|
||||
test('registry rejects prototype-chain keys (no proto-pollution dispatch bypass)', () => {
|
||||
// Overlaps __proto__/constructor/toString with the individually-named
|
||||
// cases above; folded anyway to keep the 'prototype' key covered (not
|
||||
// asserted individually elsewhere in this file).
|
||||
for (const key of ['__proto__', 'constructor', 'prototype', 'toString']) {
|
||||
assert.throws(
|
||||
() => resolveRuntimeConfigIntent(key),
|
||||
TypeError,
|
||||
`${key} must throw, not resolve via the prototype chain`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// allow-test-rule: structural-regression-guard (#3336)
|
||||
// structural guard over bin/install.js source. Behavioral assertions
|
||||
// cannot observe inline `runtime === '...'` config branching, so this enforces that
|
||||
// every inline per-runtime branch references a runtime the adapter registry knows
|
||||
// about — a NEW branch against an unregistered runtime name fails here.
|
||||
test('every inline `runtime === "..."` branch references a registry-known runtime', () => {
|
||||
const src = fs.readFileSync(path.join(ROOT, 'bin', 'install.js'), 'utf8');
|
||||
const literals = new Set(
|
||||
[...src.matchAll(/runtime === (?:'([a-z][a-z0-9-]*)'|"([a-z][a-z0-9-]*)")/g)]
|
||||
.map((m) => m[1] ?? m[2]),
|
||||
);
|
||||
assert.ok(literals.size > 0, 'expected to find inline runtime comparisons in bin/install.js');
|
||||
const unregistered = [...literals].filter((r) => !ALLOWED_CONFIG_RUNTIMES.has(r));
|
||||
assert.deepStrictEqual(
|
||||
unregistered,
|
||||
[],
|
||||
`inline 'runtime === "..."' branch(es) reference runtimes absent from the config adapter `
|
||||
+ `registry: ${unregistered.join(', ')} — register them in `
|
||||
+ 'src/runtime-config-adapter-registry.cts or route the logic through '
|
||||
+ 'resolveRuntimeConfigIntent instead of branching inline.',
|
||||
);
|
||||
});
|
||||
|
||||
// allow-test-rule: structural-regression-guard (#2103)
|
||||
// VS Code is a registry runtime but is NEVER CLI-installed (Marketplace/VSIX
|
||||
// extension); it must stay fully descriptor-driven — bin/install.js must
|
||||
// never special-case it by name.
|
||||
test('#2103: bin/install.js has ZERO runtime === "vscode" / isVscode branches (vscode stays fully descriptor-driven)', () => {
|
||||
const src = fs.readFileSync(path.join(ROOT, 'bin', 'install.js'), 'utf8');
|
||||
const runtimeComparisons = [...src.matchAll(/runtime === (?:'vscode'|"vscode")/g)];
|
||||
assert.deepStrictEqual(
|
||||
runtimeComparisons.map((m) => m[0]),
|
||||
[],
|
||||
'bin/install.js must not special-case vscode via `runtime === "vscode"` — vscode has no '
|
||||
+ 'install surface at all (installSurface: "none") and is never CLI-installed; any '
|
||||
+ 'vscode-specific behavior belongs in capabilities/vscode/capability.json, not an inline branch.',
|
||||
);
|
||||
const isVscodeRefs = [...src.matchAll(/\bisVscode\b/g)];
|
||||
assert.deepStrictEqual(
|
||||
isVscodeRefs.map((m) => m[0]),
|
||||
[],
|
||||
'bin/install.js must not introduce an isVscode flag — vscode is intentionally excluded '
|
||||
+ 'from runtimeFlags (Marketplace-distributed, never CLI-installed).',
|
||||
);
|
||||
});
|
||||
|
||||
// allow-test-rule: structural-regression-guard (#3336)
|
||||
// delegation-presence guard. Catches wholesale removal of the registry
|
||||
// dispatch (a regression to scattered per-runtime config branching).
|
||||
test('bin/install.js requires the config adapter registry and dispatches through it', () => {
|
||||
const src = fs.readFileSync(path.join(ROOT, 'bin', 'install.js'), 'utf8');
|
||||
assert.ok(
|
||||
src.includes('runtime-config-adapter-registry'),
|
||||
'bin/install.js no longer requires the runtime config adapter registry',
|
||||
);
|
||||
assert.ok(
|
||||
src.includes('resolveInstallPlan('),
|
||||
'bin/install.js no longer dispatches config through resolveInstallPlan',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user