* feat(#788): expand Qwen Code hook-event coverage to 4 new events Register SubagentStop, Stop, PreCompact (gsd-context-monitor.js) and UserPromptSubmit (gsd-prompt-guard.js) in the Qwen Code installer. Guard is isQwen-only — Claude Code and all other runtimes are unchanged. Uninstall loop extended to include the 4 new event names. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#788): reconcile to 3 Qwen-only events — defer UserPromptSubmit gsd-prompt-guard exits unless tool_name is Write|Edit (PreToolUse payload shape); UserPromptSubmit carries raw user-prompt text with no tool_name field, so wiring it would be a silent no-op. Deferred to a follow-on issue. Artifacts made consistent: - bin/install.js: drop UserPromptSubmit registration block; uninstall loop drops UPS from event list - .changeset/788-qwen-hook-events.md: corrected to 3 events + rationale - docs/how-to/install-on-your-runtime.md: remove UPS row from hook table - tests/enh-788-qwen-hook-events.test.cjs: assert UPS NOT registered; fix idempotency suite to persist settings between installs; drop UPS-specific assertions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(#788): update changeset PR number to #807 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(#788): prune stale install-bucket allowlist entry for enh-788 test --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
269 lines
9.8 KiB
JavaScript
269 lines
9.8 KiB
JavaScript
'use strict';
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
/**
|
|
* Enhancement #788: Expand Qwen Code hook-event coverage.
|
|
*
|
|
* Qwen Code supports 15 hook events; gsd previously registered only
|
|
* SessionStart and PostToolUse. This suite asserts that a Qwen install
|
|
* registers the 3 new high-value events:
|
|
* - SubagentStop — subagent lifecycle finalisation (context tracking)
|
|
* - Stop — model stop / final-response hook (context tracking)
|
|
* - PreCompact — pre-compaction awareness (context tracking)
|
|
*
|
|
* All three are wired to gsd-context-monitor.js — the same hook used for
|
|
* PostToolUse — so context headroom warnings surface at these moments too.
|
|
*
|
|
* Note: UserPromptSubmit is NOT wired — gsd-prompt-guard exits unless
|
|
* tool_name is Write|Edit (PreToolUse shape), so it would be a no-op for
|
|
* the UserPromptSubmit payload. Deferred to a follow-on issue.
|
|
*
|
|
* Also asserts the inverse: Claude Code installs do NOT gain these events
|
|
* (strict isQwen scope guard).
|
|
*
|
|
* Source: https://qwenlm.github.io/qwen-code-docs/en/users/features/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)
|
|
);
|
|
}
|
|
|
|
// Stub JS hook files that the installer checks with fs.existsSync() so hook
|
|
// registration guards pass even when hooks/dist/ isn't built.
|
|
const HOOKS_SRC = path.join(__dirname, '..', 'hooks');
|
|
const STUB_HOOKS = [
|
|
'gsd-context-monitor.js',
|
|
'gsd-prompt-guard.js',
|
|
'gsd-check-update.js',
|
|
];
|
|
|
|
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 */ }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Persist in-memory settings to disk, simulating what finishInstall() does
|
|
* (finishInstall is not exported). Required for tests that call install()
|
|
* twice and need the second call to read the first call's hook registrations.
|
|
*/
|
|
function persistSettings(settingsPath, settings) {
|
|
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
fs.writeFileSync(settingsPath, JSON.stringify(validateHookFields(settings), null, 2) + '\n', 'utf8');
|
|
}
|
|
|
|
// ─── Suite 1: Qwen — new events are registered ───────────────────────────────
|
|
|
|
describe('enh-788: Qwen install registers 3 new hook events', () => {
|
|
let tmpDir;
|
|
let previousCwd;
|
|
let settings;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir('gsd-788-qwen-');
|
|
previousCwd = process.cwd();
|
|
process.chdir(tmpDir);
|
|
|
|
const targetDir = path.join(tmpDir, '.qwen');
|
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
// Pre-populate hook files so installer registration guards (fs.existsSync)
|
|
// pass and hooks are actually registered in settings.json.
|
|
stubHooksIntoTarget(targetDir);
|
|
|
|
const result = install(false, 'qwen');
|
|
settings = result.settings;
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.chdir(previousCwd);
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('install returns a settings object (not null)', () => {
|
|
assert.ok(settings !== null && typeof settings === 'object',
|
|
'Qwen 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('UserPromptSubmit is NOT registered (handler not yet implemented for that payload shape)', () => {
|
|
// gsd-prompt-guard exits unless tool_name is Write|Edit — it is a no-op
|
|
// for UserPromptSubmit payloads. Registration is deferred until a
|
|
// dedicated hook can process the user-prompt payload shape.
|
|
const cmds = hooksForEvent(settings, 'UserPromptSubmit');
|
|
assert.strictEqual(cmds.length, 0,
|
|
`UserPromptSubmit should NOT be registered yet; got: ${JSON.stringify(cmds)}`);
|
|
});
|
|
|
|
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 install does NOT get the new events ─────────────────────
|
|
|
|
describe('enh-788: Claude install does NOT register Qwen-only hook events', () => {
|
|
let tmpDir;
|
|
let previousCwd;
|
|
let settings;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir('gsd-788-claude-');
|
|
previousCwd = process.cwd();
|
|
process.chdir(tmpDir);
|
|
|
|
const result = install(false, 'claude');
|
|
settings = result && result.settings;
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.chdir(previousCwd);
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('Claude install does not register SubagentStop', () => {
|
|
const cmds = hooksForEvent(settings, 'SubagentStop');
|
|
assert.strictEqual(cmds.length, 0,
|
|
`Claude should NOT have SubagentStop; got: ${JSON.stringify(cmds)}`);
|
|
});
|
|
|
|
test('Claude install does not register Stop', () => {
|
|
const cmds = hooksForEvent(settings, 'Stop');
|
|
assert.strictEqual(cmds.length, 0,
|
|
`Claude should NOT have Stop; got: ${JSON.stringify(cmds)}`);
|
|
});
|
|
|
|
test('Claude install does not register PreCompact', () => {
|
|
const cmds = hooksForEvent(settings, 'PreCompact');
|
|
assert.strictEqual(cmds.length, 0,
|
|
`Claude should NOT have PreCompact; got: ${JSON.stringify(cmds)}`);
|
|
});
|
|
});
|
|
|
|
// ─── Suite 3: Idempotency — persisted reinstall does not duplicate hooks ──────
|
|
|
|
describe('enh-788: Qwen install is idempotent across persisted reinstalls', () => {
|
|
let tmpDir;
|
|
let previousCwd;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir('gsd-788-idem-');
|
|
previousCwd = process.cwd();
|
|
process.chdir(tmpDir);
|
|
|
|
const targetDir = path.join(tmpDir, '.qwen');
|
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
stubHooksIntoTarget(targetDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.chdir(previousCwd);
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('re-running after persisted first install does not duplicate hook entries', () => {
|
|
// First install: get settings and persist to disk (simulating finishInstall)
|
|
const result1 = install(false, 'qwen');
|
|
persistSettings(result1.settingsPath, result1.settings);
|
|
|
|
// Second install: reads the persisted settings.json — dedup guards apply
|
|
process.chdir(tmpDir);
|
|
const result2 = install(false, 'qwen');
|
|
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 command after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Suite 4: Uninstall removes the new event registrations ──────────────────
|
|
|
|
describe('enh-788: Qwen uninstall removes new hook event entries', () => {
|
|
let tmpDir;
|
|
let previousCwd;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir('gsd-788-uninstall-');
|
|
previousCwd = process.cwd();
|
|
process.chdir(tmpDir);
|
|
|
|
const targetDir = path.join(tmpDir, '.qwen');
|
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
stubHooksIntoTarget(targetDir);
|
|
|
|
// Install and persist to disk so uninstall has a settings.json to clean
|
|
const result = install(false, 'qwen');
|
|
persistSettings(result.settingsPath, result.settings);
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.chdir(previousCwd);
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('settings.json hook entries are removed on uninstall', () => {
|
|
uninstall(false, 'qwen');
|
|
const settingsPath = path.join(tmpDir, '.qwen', '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']) {
|
|
const cmds = hooksForEvent(settings, event);
|
|
assert.strictEqual(cmds.length, 0,
|
|
`After uninstall, ${event} should have 0 hooks; got: ${JSON.stringify(cmds)}`);
|
|
}
|
|
});
|
|
});
|