test(#1974): consolidate 27 installer/hooks remainder tests into module suites
Fold 27 issue-named installer/hooks/statusline/migration/reapply regression files into their canonical module suites (installer-migrations, installer-migration-report, gsd-statusline, reapply-verify-hunks, install-*, gsd-check-update-worker-platform-gate, etc.). Verbatim block-scoped describe wrappers; 276 subtests conserved 1:1. No new files. The one subdir origin (tests/installer-migrations/001-legacy-orphan-files) moved up one level into installer-migrations.test.cjs; its single ../../ module require corrected to ../ so it resolves from tests/ root (verified). Host-env pre-check: no CLI-receiving host sets a redirecting GSD_WORKSTREAM/GSD_PROJECT value. Regenerates regression-name allowlist (222->205), ratchets file-count allowlist (verify 11->8, validate entry removed), makes 16 relocated allow-test-rule exemptions issue-ref- compliant (ADR-456; prunes stale ids). Repoints 15 tests/ references across state-md.md (EN + ja/ko/pt/zh). lint:ci green. Part of epic #1969. Closes #1974. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2599,3 +2599,450 @@ describe('enh-788: Qwen uninstall removes new hook event entries', () => {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3735-profiles-core-includes-surface.test.cjs — consolidation epic #1969 (B5 #1974)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3735-profiles-core-includes-surface (consolidation epic #1969 B5 #1974)", () => {
|
||||
'use strict';
|
||||
/**
|
||||
* Regression test for #3735: PROFILES.core must include 'surface' in its
|
||||
* resolved closure so that --profile=core users can expand via
|
||||
* /gsd:surface enable <cluster> — the advertised use-case from ADR-0011.
|
||||
*
|
||||
* Stage 2 (RED): This test must fail before the fix is applied.
|
||||
* Stage 3 (GREEN): This test must pass after 'surface' is added to PROFILES.core.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
resolveProfile,
|
||||
loadSkillsManifest,
|
||||
} = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
|
||||
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
|
||||
describe('PROFILES.core — ADR-0011 expand contract', () => {
|
||||
test("PROFILES.core includes 'surface' so users can expand via /gsd:surface enable", () => {
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const result = resolveProfile({ modes: ['core'], manifest });
|
||||
|
||||
assert.ok(result.skills instanceof Set,
|
||||
'resolveProfile must return a skills Set for core profile');
|
||||
|
||||
// The primary assertion: surface must be in the resolved closure.
|
||||
// ADR-0011 documents that --profile=core users expand via /gsd:surface enable <cluster>.
|
||||
// That sub-command is only available if surface.md is staged — which requires it to be
|
||||
// in the resolved set for the core profile.
|
||||
assert.ok(result.skills.has('surface'),
|
||||
`PROFILES.core resolved closure must include 'surface'; got: [${[...result.skills].sort().join(', ')}]`);
|
||||
});
|
||||
|
||||
// Counter-test: 'forensics' is NOT in core — proves the assertion above is selective,
|
||||
// not vacuously true for all skills.
|
||||
test("PROFILES.core does NOT include 'forensics' (selective assertion counter-check)", () => {
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const result = resolveProfile({ modes: ['core'], manifest });
|
||||
|
||||
assert.ok(result.skills instanceof Set);
|
||||
assert.ok(!result.skills.has('forensics'),
|
||||
`'forensics' should NOT be in core closure — it is a specialist skill, not a core loop skill`);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/enh-770-claude-hook-events.test.cjs — consolidation epic #1969 (B5 #1974)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:enh-770-claude-hook-events (consolidation epic #1969 B5 #1974)", () => {
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Enhancement #770: Register Claude Code lifecycle hooks (SubagentStop / Stop /
|
||||
* PreCompact / FileChanged).
|
||||
*
|
||||
* Claude Code now supports the same SubagentStop, Stop, and PreCompact events
|
||||
* that were wired for Qwen Code in #788. This suite asserts:
|
||||
*
|
||||
* 1. Claude Code installs register SubagentStop, Stop, and PreCompact, each
|
||||
* wired to gsd-context-monitor.js (same as Qwen).
|
||||
* 2. Claude Code installs register a FileChanged hook for .planning/config.json
|
||||
* wired to gsd-config-reload.js (new hook; hot-reloads gsd config).
|
||||
* 3. All four registrations are idempotent (reinstall does not duplicate).
|
||||
* 4. Uninstall removes all four event registrations.
|
||||
* 5. The gsd-config-reload.js hook script exists in hooks/ and has the
|
||||
* expected structure (reads on stdin, emits additionalContext or exits 0).
|
||||
* 6. The hooks/hooks.json plugin manifest includes the new events.
|
||||
*
|
||||
* Source: https://code.claude.com/docs/en/hooks
|
||||
*/
|
||||
|
||||
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 { install, uninstall, validateHookFields } = require('../bin/install.js');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Extract all hook commands registered under `eventName` from settings. */
|
||||
function hooksForEvent(settings, eventName) {
|
||||
if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return [];
|
||||
return settings.hooks[eventName].flatMap(entry =>
|
||||
(entry && Array.isArray(entry.hooks) ? entry.hooks : [])
|
||||
.map(h => h && h.command)
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
/** Extract all matchers registered under `eventName` from settings. */
|
||||
function matchersForEvent(settings, eventName) {
|
||||
if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return [];
|
||||
return settings.hooks[eventName]
|
||||
.map(entry => entry && entry.matcher)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const HOOKS_SRC = path.join(__dirname, '..', 'hooks');
|
||||
// Hooks the installer existsSync-checks before registering; must be present
|
||||
// in targetDir/hooks/ so the registration guards pass.
|
||||
const STUB_HOOKS = [
|
||||
'gsd-context-monitor.js',
|
||||
'gsd-prompt-guard.js',
|
||||
'gsd-check-update.js',
|
||||
'gsd-config-reload.js',
|
||||
];
|
||||
|
||||
/**
|
||||
* Pre-populate targetDir/hooks/ with stub hook files so the installer's
|
||||
* fs.existsSync guards pass even when hooks/dist/ is absent (e.g. CI without
|
||||
* a build step). Each test suite passes its own per-test tmpDir/.claude path
|
||||
* so stubs are isolated to that test's temp directory — no shared filesystem
|
||||
* state, no cross-test races.
|
||||
*
|
||||
* When hooks/dist/ DOES exist (local dev with npm run build:hooks), the
|
||||
* installer copies real files over these stubs during install() — that is
|
||||
* fine and correct.
|
||||
*/
|
||||
function stubHooksIntoTarget(targetDir) {
|
||||
const hooksDest = path.join(targetDir, 'hooks');
|
||||
fs.mkdirSync(hooksDest, { recursive: true });
|
||||
for (const hookFile of STUB_HOOKS) {
|
||||
const src = path.join(HOOKS_SRC, hookFile);
|
||||
const dest = path.join(hooksDest, hookFile);
|
||||
if (fs.existsSync(src)) {
|
||||
fs.copyFileSync(src, dest);
|
||||
} else {
|
||||
// Minimal stub so existsSync passes
|
||||
fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n');
|
||||
}
|
||||
try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ }
|
||||
}
|
||||
}
|
||||
|
||||
function persistSettings(settingsPath, settings) {
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(validateHookFields(settings), null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
// ─── Suite 1: Claude — new context monitor events are registered ──────────────
|
||||
|
||||
describe('enh-770: Claude install registers SubagentStop / Stop / PreCompact context hooks', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-770-claude-ctx-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
stubHooksIntoTarget(path.join(tmpDir, '.claude'));
|
||||
|
||||
const result = install(false, 'claude', { installerMigrations: [] });
|
||||
settings = result && result.settings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('install returns a settings object (not null)', () => {
|
||||
assert.ok(settings !== null && typeof settings === 'object',
|
||||
'Claude install must return a non-null settings object');
|
||||
});
|
||||
|
||||
test('SubagentStop event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'SubagentStop');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected SubagentStop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('Stop event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'Stop');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected Stop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('PreCompact event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'PreCompact');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected PreCompact hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('SubagentStop / Stop / PreCompact all use gsd-context-monitor', () => {
|
||||
for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
|
||||
const cmds = hooksForEvent(settings, event);
|
||||
assert.ok(
|
||||
cmds.some(c => c.includes('gsd-context-monitor')),
|
||||
`Event ${event} should use gsd-context-monitor; got commands: ${JSON.stringify(cmds)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 2: Claude — FileChanged hook for config hot-reload ─────────────────
|
||||
|
||||
describe('enh-770: Claude install registers FileChanged hook for .planning/config.json', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-770-filechanged-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
stubHooksIntoTarget(path.join(tmpDir, '.claude'));
|
||||
|
||||
const result = install(false, 'claude', { installerMigrations: [] });
|
||||
settings = result && result.settings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('FileChanged event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'FileChanged');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected FileChanged hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('FileChanged hook uses gsd-config-reload', () => {
|
||||
const cmds = hooksForEvent(settings, 'FileChanged');
|
||||
assert.ok(
|
||||
cmds.some(c => c.includes('gsd-config-reload')),
|
||||
`FileChanged should use gsd-config-reload; got commands: ${JSON.stringify(cmds)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('FileChanged hook has a matcher targeting .planning/config.json', () => {
|
||||
const matchers = matchersForEvent(settings, 'FileChanged');
|
||||
assert.ok(
|
||||
matchers.some(m => m && m.includes('config.json')),
|
||||
`FileChanged matcher should target config.json; got matchers: ${JSON.stringify(matchers)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 3: Idempotency ─────────────────────────────────────────────────────
|
||||
|
||||
describe('enh-770: Claude install is idempotent for the new hook events', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-770-idem-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
stubHooksIntoTarget(path.join(tmpDir, '.claude'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('re-running after persisted first install does not duplicate context monitor hooks', () => {
|
||||
const result1 = install(false, 'claude', { installerMigrations: [] });
|
||||
persistSettings(result1.settingsPath, result1.settings);
|
||||
|
||||
process.chdir(tmpDir);
|
||||
const result2 = install(false, 'claude', { installerMigrations: [] });
|
||||
const s2 = result2.settings;
|
||||
|
||||
for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
|
||||
const cmds = hooksForEvent(s2, event);
|
||||
assert.strictEqual(cmds.length, 1,
|
||||
`Event ${event} should have exactly 1 hook after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('re-running after persisted first install does not duplicate FileChanged hook', () => {
|
||||
const result1 = install(false, 'claude', { installerMigrations: [] });
|
||||
persistSettings(result1.settingsPath, result1.settings);
|
||||
|
||||
process.chdir(tmpDir);
|
||||
const result2 = install(false, 'claude', { installerMigrations: [] });
|
||||
const s2 = result2.settings;
|
||||
|
||||
const cmds = hooksForEvent(s2, 'FileChanged');
|
||||
assert.strictEqual(cmds.length, 1,
|
||||
`FileChanged should have exactly 1 hook after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 4: Uninstall removes registrations ─────────────────────────────────
|
||||
|
||||
describe('enh-770: Uninstall removes new hook event entries', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-770-uninstall-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
stubHooksIntoTarget(path.join(tmpDir, '.claude'));
|
||||
|
||||
const result = install(false, 'claude', { installerMigrations: [] });
|
||||
persistSettings(result.settingsPath, result.settings);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('settings.json hook entries are removed on uninstall', () => {
|
||||
uninstall(false, 'claude', { installerMigrations: [] });
|
||||
const settingsPath = path.join(tmpDir, '.claude', 'settings.json');
|
||||
if (!fs.existsSync(settingsPath)) return; // file removed entirely is fine
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
for (const event of ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged']) {
|
||||
const cmds = hooksForEvent(settings, event);
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`After uninstall, ${event} should have 0 hooks; got: ${JSON.stringify(cmds)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 5: gsd-config-reload.js hook script exists and has correct shape ───
|
||||
|
||||
describe('enh-770: gsd-config-reload.js hook script', () => {
|
||||
const reloadScript = path.join(__dirname, '..', 'hooks', 'gsd-config-reload.js');
|
||||
|
||||
test('gsd-config-reload.js exists in hooks/', () => {
|
||||
assert.ok(fs.existsSync(reloadScript),
|
||||
`gsd-config-reload.js must exist at ${reloadScript}`);
|
||||
});
|
||||
|
||||
test('gsd-config-reload.js contains the gsd-hook-version stamp', () => {
|
||||
// allow-test-rule: runtime-contract-is-the-product — the stamp template token (see #770)
|
||||
// IS the product surface that the installer must find and replace with the
|
||||
// real version at copy time; asserting its presence is required.
|
||||
const content = fs.readFileSync(reloadScript, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('gsd-hook-version'),
|
||||
'gsd-config-reload.js must contain the gsd-hook-version stamp for installer stamping'
|
||||
);
|
||||
});
|
||||
|
||||
test('gsd-config-reload.js reads from stdin and emits JSON output', () => {
|
||||
// allow-test-rule: runtime-contract-is-the-product — the stdin-read and (see #770)
|
||||
// JSON-emit pattern IS the hook contract; asserting its presence is required.
|
||||
const content = fs.readFileSync(reloadScript, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('process.stdin') && content.includes('JSON.stringify'),
|
||||
'gsd-config-reload.js must read stdin and emit JSON output per hook protocol'
|
||||
);
|
||||
});
|
||||
|
||||
test('gsd-config-reload.js targets the FileChanged hook event', () => {
|
||||
// allow-test-rule: runtime-contract-is-the-product — the hookEventName is (see #770)
|
||||
// the protocol surface; asserting its presence verifies the contract.
|
||||
const content = fs.readFileSync(reloadScript, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('FileChanged'),
|
||||
'gsd-config-reload.js must reference FileChanged in its hookSpecificOutput'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 6: hooks.json plugin manifest includes new events ──────────────────
|
||||
|
||||
describe('enh-770: hooks/hooks.json plugin manifest includes new hook events', () => {
|
||||
const hooksJsonPath = path.join(__dirname, '..', 'hooks', 'hooks.json');
|
||||
|
||||
test('hooks.json exists', () => {
|
||||
assert.ok(fs.existsSync(hooksJsonPath), `hooks.json must exist at ${hooksJsonPath}`);
|
||||
});
|
||||
|
||||
test('hooks.json contains SubagentStop event', () => {
|
||||
// allow-test-rule: runtime-contract-is-the-product — hooks.json IS the (see #770)
|
||||
// plugin manifest surface that Claude Code reads at plugin load time.
|
||||
const content = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
||||
assert.ok(
|
||||
content.hooks && content.hooks.SubagentStop,
|
||||
'hooks.json must contain SubagentStop'
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks.json contains Stop event', () => {
|
||||
// allow-test-rule: runtime-contract-is-the-product — hooks.json IS the (see #770)
|
||||
// plugin manifest surface that Claude Code reads at plugin load time.
|
||||
const content = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
||||
assert.ok(
|
||||
content.hooks && content.hooks.Stop,
|
||||
'hooks.json must contain Stop'
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks.json contains PreCompact event', () => {
|
||||
// allow-test-rule: runtime-contract-is-the-product — hooks.json IS the (see #770)
|
||||
// plugin manifest surface that Claude Code reads at plugin load time.
|
||||
const content = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
||||
assert.ok(
|
||||
content.hooks && content.hooks.PreCompact,
|
||||
'hooks.json must contain PreCompact'
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks.json contains FileChanged event', () => {
|
||||
// allow-test-rule: runtime-contract-is-the-product — hooks.json IS the (see #770)
|
||||
// plugin manifest surface that Claude Code reads at plugin load time.
|
||||
const content = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
||||
assert.ok(
|
||||
content.hooks && content.hooks.FileChanged,
|
||||
'hooks.json must contain FileChanged'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 7: managed-hooks-registry includes gsd-config-reload.js ───────────
|
||||
|
||||
describe('enh-770: managed-hooks-registry includes gsd-config-reload.js', () => {
|
||||
test('MANAGED_HOOKS array includes gsd-config-reload.js', () => {
|
||||
const { MANAGED_HOOKS } = require('../hooks/managed-hooks-registry.cjs');
|
||||
assert.ok(
|
||||
MANAGED_HOOKS.includes('gsd-config-reload.js'),
|
||||
`MANAGED_HOOKS must include gsd-config-reload.js; got: ${JSON.stringify(MANAGED_HOOKS)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user