* chore(tests): lint rule — cap test files per production module at 2 Adds scripts/lint-test-file-count.cjs with a ratcheted allowlist (scripts/lint-test-file-count.allowlist.json) capturing today's 30 violating clusters as a ceiling. New entries blocked at PR time; reductions ratchet automatically. Wires into .github/workflows/test.yml as a new step in lint-tests. Refs #3737 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(tests): add docs-exempt to changeset fragment Internal CI lint rule — no user-facing docs impact. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(tests): consolidate Installer Module — 11 files → 2 Closes #3758 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(changeset): replace invalid type 'Chore' with 'Changed' — ALLOWED_TYPES only accepts Added|Changed|Deprecated|Removed|Fixed|Security Fragment already had a docs-exempt marker; only the type value was wrong. Refs #3758 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(tests): split install.test.cjs along section seams — keep files ≤ 800 LOC 1828-LOC monolith split into 3 files by semantic boundary: install.test.cjs (602 LOC) — S1-5: dir resolution, install/uninstall spot-checks, Kilo install-runtime-artifacts.test.cjs (347 LOC) — S6-8+12: layout loop, Contract 6, legacy migrations install-minimal-hooks.test.cjs (782 LOC) — S9-11+13: profiles, minimal E2E, hooks copy Shared constants and helpers extracted to tests/helpers/install-shared.cjs (216 LOC). Total test count unchanged: 210 tests (68+37+105). Refs #3758 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(tests): ratchet state test-file ceiling to 9 — actual count at baseline The allowlist was initialised with state=8 but the repo already had 9 files matching the 'state' prefix at the time the lint rule was created, causing lint-test-file-count.test.cjs to fail on the real repo immediately. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
348 lines
14 KiB
JavaScript
348 lines
14 KiB
JavaScript
// allow-test-rule: source-text-is-the-product
|
||
// Reads .md/.json/.yml product files whose deployed text IS what the
|
||
// runtime loads — testing text content tests the deployed contract.
|
||
|
||
/**
|
||
* Installer Module — Sections 6–8 + 12.
|
||
*
|
||
* Covers: installRuntimeArtifacts parameterised layout loop,
|
||
* uninstallRuntimeArtifacts all runtimes, Contract 6 counter-test
|
||
* (unknown runtime rejected), and legacy migration tests.
|
||
*
|
||
* Consolidates (original sources from #3758):
|
||
* install-uninstall-layout-loop.test.cjs
|
||
*
|
||
* Closes #3758
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
process.env.GSD_TEST_MODE = '1';
|
||
|
||
const { test, describe } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
|
||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||
|
||
const {
|
||
installRuntimeArtifacts,
|
||
parseRuntimeInput,
|
||
allRuntimes,
|
||
} = require('../bin/install.js');
|
||
|
||
const {
|
||
resolveRuntimeArtifactLayout,
|
||
} = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs');
|
||
|
||
const {
|
||
loadSkillsManifest,
|
||
resolveProfile,
|
||
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
|
||
|
||
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||
const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||
const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST });
|
||
|
||
// ─── Section 6: installRuntimeArtifacts — parameterised layout loop ──────────
|
||
|
||
const SKILLS_RUNTIMES_LAYOUT = [
|
||
'claude', 'cursor', 'codex', 'copilot', 'antigravity',
|
||
'windsurf', 'augment', 'trae', 'qwen', 'codebuddy',
|
||
];
|
||
|
||
const ALL_RUNTIMES_LAYOUT = [
|
||
'claude', 'cursor', 'gemini', 'codex', 'copilot', 'antigravity',
|
||
'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy',
|
||
'cline', 'opencode', 'kilo',
|
||
];
|
||
|
||
function countPrefixedEntries(destDir, prefix) {
|
||
if (!fs.existsSync(destDir)) return 0;
|
||
return fs.readdirSync(destDir).filter(n => n.startsWith(prefix)).length;
|
||
}
|
||
|
||
function writeSkillEntry(destDir, prefix, stem) {
|
||
const entryDir = path.join(destDir, `${prefix}${stem}`);
|
||
fs.mkdirSync(entryDir, { recursive: true });
|
||
fs.writeFileSync(path.join(entryDir, 'SKILL.md'), `# ${stem}\n`);
|
||
}
|
||
|
||
function writeCommandEntry(destDir, prefix, stem) {
|
||
fs.mkdirSync(destDir, { recursive: true });
|
||
fs.writeFileSync(path.join(destDir, `${prefix}${stem}.md`), `# ${stem}\n`);
|
||
}
|
||
|
||
describe('installRuntimeArtifacts — skills runtimes write gsd-prefixed skill dirs', () => {
|
||
for (const runtime of SKILLS_RUNTIMES_LAYOUT) {
|
||
test(`${runtime}: gsd-prefixed skill dirs in skills/`, (t) => {
|
||
const configDir = createTempDir(`gsd-ial-${runtime}-`);
|
||
t.after(() => cleanup(configDir));
|
||
|
||
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
|
||
installRuntimeArtifacts(runtime, configDir, 'global', RESOLVED_CORE);
|
||
|
||
const layout = resolveRuntimeArtifactLayout(runtime, configDir, 'global');
|
||
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
||
assert.ok(skillsKind, `${runtime} must have skills kind`);
|
||
|
||
const destDir = path.join(configDir, skillsKind.destSubpath);
|
||
assert.ok(fs.existsSync(destDir));
|
||
assert.ok(
|
||
fs.existsSync(path.join(destDir, `${skillsKind.prefix}help`, 'SKILL.md')),
|
||
`${runtime}: ${skillsKind.prefix}help/SKILL.md must exist`
|
||
);
|
||
|
||
if (RESOLVED_CORE.skills !== '*') {
|
||
const prefixedCount = countPrefixedEntries(destDir, skillsKind.prefix || 'gsd-');
|
||
assert.strictEqual(prefixedCount, RESOLVED_CORE.skills.size,
|
||
`${runtime}: installed skill count must match profile`);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
describe('installRuntimeArtifacts — hermes nested layout', () => {
|
||
test('hermes: skills/gsd/<stem>/SKILL.md, no gsd- prefix in name', (t) => {
|
||
const configDir = createTempDir('gsd-ial-hermes-');
|
||
t.after(() => cleanup(configDir));
|
||
|
||
installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_CORE);
|
||
|
||
const nestedDir = path.join(configDir, 'skills', 'gsd');
|
||
assert.ok(fs.existsSync(nestedDir));
|
||
assert.ok(fs.existsSync(path.join(nestedDir, 'help', 'SKILL.md')));
|
||
assert.ok(!fs.existsSync(path.join(nestedDir, 'gsd-help')),
|
||
'hermes must NOT have gsd-help prefix');
|
||
});
|
||
});
|
||
|
||
describe('installRuntimeArtifacts — gemini commands layout', () => {
|
||
test('gemini: commands/gsd/ created, no skills/', (t) => {
|
||
const configDir = createTempDir('gsd-ial-gemini-');
|
||
t.after(() => cleanup(configDir));
|
||
|
||
installRuntimeArtifacts('gemini', configDir, 'global', RESOLVED_CORE);
|
||
|
||
assert.ok(fs.existsSync(path.join(configDir, 'commands', 'gsd')));
|
||
assert.ok(fs.existsSync(path.join(configDir, 'commands', 'gsd', 'help.md')));
|
||
assert.ok(!fs.existsSync(path.join(configDir, 'skills')));
|
||
});
|
||
});
|
||
|
||
describe('installRuntimeArtifacts — cline no-op', () => {
|
||
test('cline: no kinds — call succeeds, no dirs created', (t) => {
|
||
const configDir = createTempDir('gsd-ial-cline-');
|
||
t.after(() => cleanup(configDir));
|
||
|
||
assert.doesNotThrow(() => installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE));
|
||
assert.ok(!fs.existsSync(path.join(configDir, 'skills')));
|
||
assert.ok(!fs.existsSync(path.join(configDir, 'commands')));
|
||
});
|
||
});
|
||
|
||
describe('installRuntimeArtifacts — opencode / kilo flat commands', () => {
|
||
for (const runtime of ['opencode', 'kilo']) {
|
||
test(`${runtime}: command/gsd-help.md exists`, (t) => {
|
||
const configDir = createTempDir(`gsd-ial-${runtime}-`);
|
||
t.after(() => cleanup(configDir));
|
||
|
||
installRuntimeArtifacts(runtime, configDir, 'global', RESOLVED_CORE);
|
||
|
||
const commandDir = path.join(configDir, 'command');
|
||
assert.ok(fs.existsSync(commandDir));
|
||
assert.ok(fs.existsSync(path.join(commandDir, 'gsd-help.md')));
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── Section 7: uninstallRuntimeArtifacts — all runtimes ─────────────────────
|
||
|
||
describe('uninstallRuntimeArtifacts — removes gsd-owned entries, preserves foreign', () => {
|
||
for (const runtime of ALL_RUNTIMES_LAYOUT) {
|
||
test(`${runtime}: gsd entries removed, foreign preserved`, (t) => {
|
||
const configDir = createTempDir(`gsd-ual-${runtime}-`);
|
||
t.after(() => cleanup(configDir));
|
||
|
||
const { uninstallRuntimeArtifacts } = require('../bin/install.js');
|
||
assert.strictEqual(typeof uninstallRuntimeArtifacts, 'function');
|
||
|
||
const layout = resolveRuntimeArtifactLayout(runtime, configDir, 'global');
|
||
|
||
if (layout.kinds.length === 0) {
|
||
const foreignDir = path.join(configDir, 'foreign-dir');
|
||
fs.mkdirSync(foreignDir, { recursive: true });
|
||
fs.writeFileSync(path.join(foreignDir, 'keep.md'), '# keep\n');
|
||
assert.doesNotThrow(() => uninstallRuntimeArtifacts(runtime, configDir, 'global'));
|
||
assert.ok(fs.existsSync(path.join(foreignDir, 'keep.md')));
|
||
return;
|
||
}
|
||
|
||
if (runtime === 'hermes') {
|
||
const kind = layout.kinds[0];
|
||
const destDir = path.join(configDir, kind.destSubpath);
|
||
fs.mkdirSync(path.join(destDir, 'help'), { recursive: true });
|
||
fs.writeFileSync(path.join(destDir, 'help', 'SKILL.md'), '# help\n');
|
||
const siblingDir = path.join(configDir, 'skills', 'user-skill');
|
||
fs.mkdirSync(siblingDir, { recursive: true });
|
||
fs.writeFileSync(path.join(siblingDir, 'SKILL.md'), '# user\n');
|
||
|
||
uninstallRuntimeArtifacts(runtime, configDir, 'global');
|
||
|
||
assert.ok(!fs.existsSync(destDir));
|
||
assert.ok(fs.existsSync(path.join(siblingDir, 'SKILL.md')));
|
||
return;
|
||
}
|
||
|
||
for (const kind of layout.kinds) {
|
||
const destDir = path.join(configDir, kind.destSubpath);
|
||
fs.mkdirSync(destDir, { recursive: true });
|
||
if (kind.kind === 'skills') {
|
||
writeSkillEntry(destDir, kind.prefix, 'help');
|
||
writeSkillEntry(destDir, kind.prefix, 'phase');
|
||
const foreignDir = path.join(destDir, 'user-custom-skill');
|
||
fs.mkdirSync(foreignDir, { recursive: true });
|
||
fs.writeFileSync(path.join(foreignDir, 'SKILL.md'), '# user\n');
|
||
} else {
|
||
writeCommandEntry(destDir, kind.prefix, 'help');
|
||
writeCommandEntry(destDir, kind.prefix, 'phase');
|
||
fs.writeFileSync(path.join(destDir, 'user-custom.md'), '# user\n');
|
||
}
|
||
}
|
||
|
||
uninstallRuntimeArtifacts(runtime, configDir, 'global');
|
||
|
||
for (const kind of layout.kinds) {
|
||
const destDir = path.join(configDir, kind.destSubpath);
|
||
if (kind.kind === 'skills') {
|
||
assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}help`)));
|
||
assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}phase`)));
|
||
assert.ok(fs.existsSync(path.join(destDir, 'user-custom-skill', 'SKILL.md')));
|
||
} else {
|
||
assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}help.md`)));
|
||
assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}phase.md`)));
|
||
assert.ok(fs.existsSync(path.join(destDir, 'user-custom.md')));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// ─── Section 8: Counter-test — unknown runtime is rejected (Contract 6) ──────
|
||
|
||
describe('Contract 6: unknown runtime is rejected', () => {
|
||
test('resolveRuntimeArtifactLayout throws TypeError for unknown runtime', () => {
|
||
assert.throws(
|
||
() => resolveRuntimeArtifactLayout('unknown-runtime-xyz', '/tmp/test', 'global'),
|
||
(err) => {
|
||
assert.ok(err instanceof TypeError, 'must be TypeError');
|
||
assert.ok(err.message.includes('Unknown runtime'), `message: ${err.message}`);
|
||
return true;
|
||
}
|
||
);
|
||
});
|
||
|
||
test('parseRuntimeInput returns ["claude"] for unrecognised string (safe default)', () => {
|
||
// parseRuntimeInput processes menu numbers, not runtime names directly;
|
||
// an unrecognised token falls through to the default ["claude"].
|
||
const result = parseRuntimeInput('unknown-xyz');
|
||
assert.deepStrictEqual(result, ['claude']);
|
||
});
|
||
|
||
test('allRuntimes does not include any unrecognised value', () => {
|
||
// Every entry in allRuntimes must be recognised by resolveRuntimeArtifactLayout
|
||
for (const runtime of allRuntimes) {
|
||
assert.doesNotThrow(
|
||
() => resolveRuntimeArtifactLayout(runtime, '/tmp/test', 'global'),
|
||
`${runtime} must be a recognised runtime`
|
||
);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ─── Section 12: Legacy migrations in installRuntimeArtifacts ────────────────
|
||
|
||
describe('installRuntimeArtifacts — legacy migrations run before layout copy', () => {
|
||
test('claude: legacy commands/gsd/dev-preferences.md migrated AND new skills written', (t) => {
|
||
const configDir = createTempDir('gsd-legacy-install-');
|
||
t.after(() => cleanup(configDir));
|
||
|
||
const legacyDir = path.join(configDir, 'commands', 'gsd');
|
||
fs.mkdirSync(legacyDir, { recursive: true });
|
||
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# My dev prefs\n');
|
||
|
||
installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE);
|
||
|
||
assert.ok(!fs.existsSync(legacyDir));
|
||
assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd-dev-preferences', 'SKILL.md')));
|
||
assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md')));
|
||
});
|
||
|
||
test('hermes: legacy flat skills/gsd-*/ migrated AND new nested skills/gsd/<stem>/ written', (t) => {
|
||
const configDir = createTempDir('gsd-legacy-hermes-install-');
|
||
t.after(() => cleanup(configDir));
|
||
|
||
const legacyFlatHelp = path.join(configDir, 'skills', 'gsd-help');
|
||
fs.mkdirSync(legacyFlatHelp, { recursive: true });
|
||
fs.writeFileSync(path.join(legacyFlatHelp, 'SKILL.md'), '# legacy help\n');
|
||
|
||
installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_CORE);
|
||
|
||
assert.ok(!fs.existsSync(legacyFlatHelp));
|
||
assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd', 'help', 'SKILL.md')));
|
||
});
|
||
});
|
||
|
||
describe('uninstallRuntimeArtifacts — legacy cleanup runs before layout removal', () => {
|
||
test('hermes: both flat and nested layouts removed', (t) => {
|
||
const { uninstallRuntimeArtifacts } = require('../bin/install.js');
|
||
const configDir = createTempDir('gsd-legacy-uninstall-hermes-');
|
||
t.after(() => cleanup(configDir));
|
||
|
||
const skillsDir = path.join(configDir, 'skills');
|
||
const flatHelp = path.join(skillsDir, 'gsd-help');
|
||
fs.mkdirSync(flatHelp, { recursive: true });
|
||
fs.writeFileSync(path.join(flatHelp, 'SKILL.md'), '# legacy flat\n');
|
||
|
||
const nestedGsd = path.join(skillsDir, 'gsd');
|
||
fs.mkdirSync(path.join(nestedGsd, 'help'), { recursive: true });
|
||
fs.writeFileSync(path.join(nestedGsd, 'help', 'SKILL.md'), '# nested help\n');
|
||
|
||
const userSkill = path.join(skillsDir, 'user-skill');
|
||
fs.mkdirSync(userSkill, { recursive: true });
|
||
fs.writeFileSync(path.join(userSkill, 'SKILL.md'), '# user\n');
|
||
|
||
uninstallRuntimeArtifacts('hermes', configDir, 'global');
|
||
|
||
assert.ok(!fs.existsSync(flatHelp));
|
||
assert.ok(!fs.existsSync(nestedGsd));
|
||
assert.ok(fs.existsSync(path.join(userSkill, 'SKILL.md')));
|
||
});
|
||
|
||
test('claude: legacy commands/gsd/ cleaned AND new skills/ entries removed', (t) => {
|
||
const { uninstallRuntimeArtifacts } = require('../bin/install.js');
|
||
const configDir = createTempDir('gsd-legacy-uninstall-claude-');
|
||
t.after(() => cleanup(configDir));
|
||
|
||
const skillsDir = path.join(configDir, 'skills');
|
||
const gsdHelp = path.join(skillsDir, 'gsd-help');
|
||
fs.mkdirSync(gsdHelp, { recursive: true });
|
||
fs.writeFileSync(path.join(gsdHelp, 'SKILL.md'), '# help\n');
|
||
|
||
const legacyDir = path.join(configDir, 'commands', 'gsd');
|
||
fs.mkdirSync(legacyDir, { recursive: true });
|
||
fs.writeFileSync(path.join(legacyDir, 'help.md'), '# legacy\n');
|
||
|
||
const userSkill = path.join(skillsDir, 'user-skill');
|
||
fs.mkdirSync(userSkill, { recursive: true });
|
||
fs.writeFileSync(path.join(userSkill, 'SKILL.md'), '# user\n');
|
||
|
||
uninstallRuntimeArtifacts('claude', configDir, 'global');
|
||
|
||
assert.ok(!fs.existsSync(gsdHelp));
|
||
assert.ok(!fs.existsSync(legacyDir));
|
||
assert.ok(fs.existsSync(path.join(userSkill, 'SKILL.md')));
|
||
});
|
||
});
|