Merge branch 'next' into kimi-runtime-support
This commit is contained in:
@@ -93,3 +93,62 @@ describe('Bug #2992: error paths', () => {
|
||||
assert.deepEqual(r, { ok: true, version: '1.40.0-rc.1', reason: CHECK_REASON.OK });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Issue #815: --next dist-tag support', () => {
|
||||
const { buildViewArgs, resolveTag, ALLOWED_TAGS } = require(
|
||||
path.join(ROOT, 'gsd-core', 'bin', 'check-latest-version.cjs'),
|
||||
);
|
||||
|
||||
test('ALLOWED_TAGS is the sanctioned channel allowlist (latest, next)', () => {
|
||||
assert.deepEqual([...ALLOWED_TAGS].sort(), ['latest', 'next']);
|
||||
});
|
||||
|
||||
test('buildViewArgs() defaults to the bare latest spec (byte-for-byte unchanged)', () => {
|
||||
assert.deepEqual(buildViewArgs(), ['view', '@opengsd/gsd-core', 'version']);
|
||||
assert.deepEqual(buildViewArgs('latest'), ['view', '@opengsd/gsd-core', 'version']);
|
||||
});
|
||||
|
||||
test('buildViewArgs("next") targets the @next dist-tag', () => {
|
||||
assert.deepEqual(buildViewArgs('next'), ['view', '@opengsd/gsd-core@next', 'version']);
|
||||
});
|
||||
|
||||
test('resolveTag defaults to latest when no --tag flag', () => {
|
||||
assert.equal(resolveTag(['--json']), 'latest');
|
||||
});
|
||||
|
||||
test('resolveTag reads --tag next', () => {
|
||||
assert.equal(resolveTag(['--json', '--tag', 'next']), 'next');
|
||||
});
|
||||
|
||||
test('resolveTag rejects an unknown tag (typo guard)', () => {
|
||||
assert.throws(() => resolveTag(['--tag', 'nightly']), /invalid --tag 'nightly'/);
|
||||
});
|
||||
|
||||
test('resolveTag rejects --tag with no value', () => {
|
||||
assert.throws(() => resolveTag(['--tag']), /invalid --tag ''/);
|
||||
});
|
||||
|
||||
test('checkLatestVersion accepts an RC under the next tag', () => {
|
||||
const r = checkLatestVersion({ tag: 'next', spawn: () => ({ status: 0, stdout: '1.4.0-rc.1\n', stderr: '' }) });
|
||||
assert.deepEqual(r, { ok: true, version: '1.4.0-rc.1', reason: CHECK_REASON.OK });
|
||||
});
|
||||
|
||||
test('buildViewArgs rejects a tag outside the allowlist (exported-API guard)', () => {
|
||||
assert.throws(() => buildViewArgs('nightly'), /invalid dist-tag 'nightly'/);
|
||||
});
|
||||
|
||||
test('checkLatestVersion rejects an out-of-allowlist tag even with an injected spawn', () => {
|
||||
assert.throws(
|
||||
() => checkLatestVersion({ tag: 'nightly', spawn: () => ({ status: 0, stdout: '9.9.9\n', stderr: '' }) }),
|
||||
/invalid dist-tag 'nightly'/,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveTag handles the --tag=next equals form', () => {
|
||||
assert.equal(resolveTag(['--json', '--tag=next']), 'next');
|
||||
});
|
||||
|
||||
test('resolveTag rejects an unknown --tag=value equals form (no silent fallback)', () => {
|
||||
assert.throws(() => resolveTag(['--tag=nightly']), /invalid --tag 'nightly'/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,18 +4,20 @@
|
||||
// Regression guard for bug #3130.
|
||||
//
|
||||
// Two failure modes were observed with the pre-fix npx invocation form:
|
||||
// 1. Cache-stale: bare `npx -y @opengsd/gsd-core@latest` hits npx's local
|
||||
// cache and may pull an older version instead of @latest.
|
||||
// 1. Cache-stale: bare `npx -y @opengsd/gsd-core@<tag>` hits npx's local
|
||||
// cache and may pull an older version instead of the target tag.
|
||||
// 2. Token-routing: Bash-tool wrappers misroute the `@` token in
|
||||
// `@opengsd/gsd-core@latest`, causing npm to error with
|
||||
// "Unknown command: @opengsd/gsd-core@latest".
|
||||
// `@opengsd/gsd-core@<tag>`, causing npm to error with
|
||||
// "Unknown command: @opengsd/gsd-core@<tag>".
|
||||
//
|
||||
// The robust form is:
|
||||
// npx -y --package=@opengsd/gsd-core@latest -- gsd-core $ARGS
|
||||
// npx -y --package=@opengsd/gsd-core@"$TAG" -- gsd-core $ARGS
|
||||
//
|
||||
// `--package=` forces a fresh registry fetch, bypassing the npx cache.
|
||||
// `--` clearly delineates npx flags from the run-command, preventing
|
||||
// Bash-tool @-token misrouting.
|
||||
// `$TAG` is a shell variable (latest by default, next under --next/--rc),
|
||||
// set by the parse_update_channel step (#815).
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
@@ -28,9 +30,9 @@ const UPDATE_WF = path.join(ROOT, 'gsd-core', 'workflows', 'update.md');
|
||||
const src = fs.readFileSync(UPDATE_WF, 'utf8');
|
||||
|
||||
test('bug #3130: update.md contains no bare npx invocations (cache-stale form)', () => {
|
||||
// Any occurrence of `npx -y @opengsd/gsd-core@latest` without `--package=`
|
||||
// Any occurrence of `npx -y @opengsd/gsd-core@<something>` without `--package=`
|
||||
// is the stale form that triggers the two failure modes.
|
||||
const stale = (src.match(/npx -y @opengsd\/gsd-core@latest[^\n]*/g) || []);
|
||||
const stale = (src.match(/npx -y @opengsd\/gsd-core@\S+[^\n]*/g) || []);
|
||||
assert.deepEqual(
|
||||
stale,
|
||||
[],
|
||||
@@ -40,7 +42,8 @@ test('bug #3130: update.md contains no bare npx invocations (cache-stale form)',
|
||||
|
||||
test('bug #3130: update.md has >=3 robust npx invocations (--package= + -- separator)', () => {
|
||||
// Three sibling invocations: local, global, and unknown/fallback.
|
||||
const robust = (src.match(/npx -y --package=@opengsd\/gsd-core@latest -- gsd-core/g) || []);
|
||||
// The tag is now a $TAG variable (latest by default, next under --next/--rc).
|
||||
const robust = (src.match(/npx -y --package=@opengsd\/gsd-core@\S+ -- gsd-core/g) || []);
|
||||
assert.ok(
|
||||
robust.length >= 3,
|
||||
`Expected >=3 robust npx invocations in update.md, found ${robust.length}`,
|
||||
|
||||
@@ -32,6 +32,20 @@ const { cleanup } = require('./helpers.cjs');
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js');
|
||||
const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist');
|
||||
const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js');
|
||||
|
||||
/**
|
||||
* Ensure hooks/dist is populated before any suite that reads it.
|
||||
* hooks/dist/ is gitignored and only produced by `npm run build:hooks`.
|
||||
* In CI the scoped/windows test jobs do NOT run build:hooks before running
|
||||
* tests, so the first test that needs hooks/dist would fail. This mirrors
|
||||
* the pattern used in bug-3357-codex-legacy-hooks-json-migration.test.cjs.
|
||||
*/
|
||||
function ensureHooksDist() {
|
||||
if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) {
|
||||
execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -92,6 +106,12 @@ function colonRefs(content) {
|
||||
// Prerequisite: hooks/dist must exist (built by `npm run build:hooks`)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('bug #376 — prerequisite: hooks/dist is present', () => {
|
||||
before(() => {
|
||||
// hooks/dist is gitignored; build it on demand so this test is
|
||||
// deterministic in CI scoped/windows jobs that don't pre-run build:hooks.
|
||||
ensureHooksDist();
|
||||
});
|
||||
|
||||
test('hooks/dist directory exists (run npm run build:hooks if missing)', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(HOOKS_DIST_DIR),
|
||||
@@ -189,11 +209,13 @@ describe('bug #376 — Suite 1: Claude install rewrites /gsd: → /gsd- in hook
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suite 2 — Cursor install regression: /gsd: → /gsd- still works (pre-existing)
|
||||
//
|
||||
// Note: Cursor does NOT install hooks/dist files (Cursor skips the hooks
|
||||
// install step entirely — see install.js gate around line 8829). The Cursor
|
||||
// /gsd: rewrite applies in `copyWithPathReplacement` to JS files under the
|
||||
// agent/skill tree (.cursor/gsd-core/*.js etc). We verify that Cursor's
|
||||
// installed .js files under .cursor/ have no /gsd: colon refs.
|
||||
// Note: Cursor installs its own hooks (gsd-cursor-session-start.js and
|
||||
// gsd-cursor-post-tool.js) via the cursor-hooks-json installSurface (issue #777).
|
||||
// It does NOT install the bundled Claude-style hooks/dist files (no gsd-session-state.sh
|
||||
// etc.). The Cursor /gsd: rewrite applies in `copyWithPathReplacement` to JS files
|
||||
// under the agent/skill tree (.cursor/gsd-core/*.js etc). We verify that Cursor's
|
||||
// installed .js files under .cursor/ have no /gsd: colon refs, and that the hooks/
|
||||
// directory contains only the Cursor-specific managed hooks.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('bug #376 — Suite 2: Cursor install still rewrites /gsd: → /gsd- (regression)', () => {
|
||||
let tmpDir;
|
||||
@@ -242,15 +264,32 @@ describe('bug #376 — Suite 2: Cursor install still rewrites /gsd: → /gsd- (r
|
||||
);
|
||||
});
|
||||
|
||||
test('2c: Cursor install does not install a hooks/ directory (hooks are cursor-skipped)', () => {
|
||||
// Cursor intentionally skips the hooks/dist copy step — verify this contract
|
||||
// is still honored so we know the regression only concerns hook .js files
|
||||
// for runtimes that DO install hooks (claude, qwen, hermes etc.).
|
||||
test('2c: Cursor install creates a hooks/ directory with only Cursor-specific managed hooks', () => {
|
||||
// Since issue #777, Cursor installs gsd-cursor-session-start.js and
|
||||
// gsd-cursor-post-tool.js into <configDir>/hooks/. These are Cursor-native
|
||||
// hooks — NOT the bundled Claude-style hooks (no gsd-session-state.sh etc.).
|
||||
// Verify: hooks/ exists AND does NOT contain any Claude-bundled hooks.
|
||||
const hooksDir = path.join(tmpDir, '.cursor', 'hooks');
|
||||
assert.strictEqual(
|
||||
assert.ok(
|
||||
fs.existsSync(hooksDir),
|
||||
false,
|
||||
'Cursor install must NOT create a hooks/ directory — Cursor skips the hooks install step',
|
||||
'Cursor install must create a hooks/ directory for its managed hook scripts (#777)',
|
||||
);
|
||||
const CLAUDE_BUNDLED_HOOKS = ['gsd-session-state.sh', 'gsd-context-monitor.js', 'gsd-statusline.js'];
|
||||
for (const hook of CLAUDE_BUNDLED_HOOKS) {
|
||||
assert.strictEqual(
|
||||
fs.existsSync(path.join(hooksDir, hook)),
|
||||
false,
|
||||
`Cursor hooks/ must NOT contain Claude-bundled hook ${hook} — only Cursor-native hooks are installed`,
|
||||
);
|
||||
}
|
||||
// The two Cursor-specific managed hooks must be present.
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(hooksDir, 'gsd-cursor-session-start.js')),
|
||||
'gsd-cursor-session-start.js must be installed in .cursor/hooks/ (#777)',
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(hooksDir, 'gsd-cursor-post-tool.js')),
|
||||
'gsd-cursor-post-tool.js must be installed in .cursor/hooks/ (#777)',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -262,6 +301,9 @@ describe('bug #376 — Suite 3: hooks/ source files are unchanged by install', (
|
||||
let snapshotBefore;
|
||||
|
||||
before(() => {
|
||||
// Ensure hooks/dist is built before snapshotting; it may be absent in CI
|
||||
// scoped/windows jobs that don't pre-run build:hooks (#777 fix).
|
||||
ensureHooksDist();
|
||||
// Snapshot hooks/dist JS files before any install in this suite
|
||||
snapshotBefore = {};
|
||||
if (fs.existsSync(HOOKS_DIST_DIR)) {
|
||||
|
||||
@@ -5,6 +5,8 @@ const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('node:os');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const SCRIPT = path.join(ROOT, 'scripts', 'ci-test-scope.cjs');
|
||||
@@ -207,6 +209,74 @@ describe('ci-test-scope.cjs', () => {
|
||||
assert.deepStrictEqual(result.targeted_tests, ['unit'],
|
||||
'targeted_tests must be [\'unit\'] when code changed but no rule matched');
|
||||
});
|
||||
|
||||
test('three-dot diff: docs-only PR on a stale base ignores product commits next gained after the merge-base', () => {
|
||||
// Reproduces #837: a docs-only PR branched from a slightly older `next`.
|
||||
// After the branch point, `next` advances with a PRODUCT commit. A two-dot
|
||||
// `git diff base head` would surface that product file (flipping product_changed/
|
||||
// full_matrix true); a three-dot `git diff base...head` (vs the merge-base, which is
|
||||
// GitHub's PR semantics) must see ONLY the docs change.
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-scope-837-'));
|
||||
try {
|
||||
const git = (...a) => {
|
||||
const r = spawnSync('git', a, { cwd: tmp, encoding: 'utf8' });
|
||||
assert.strictEqual(r.status, 0, `git ${a.join(' ')} failed: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
};
|
||||
git('init', '-q');
|
||||
git('config', 'user.email', 'test@example.com');
|
||||
git('config', 'user.name', 'Test');
|
||||
git('config', 'commit.gpgsign', 'false');
|
||||
|
||||
// merge-base: a docs file + a product file (package.json)
|
||||
fs.mkdirSync(path.join(tmp, 'tests'), { recursive: true }); // existingTests() reads tests/
|
||||
fs.mkdirSync(path.join(tmp, 'docs'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmp, 'docs', 'a.md'), 'base\n');
|
||||
fs.writeFileSync(path.join(tmp, 'package.json'), '{"name":"x","version":"1.0.0"}\n');
|
||||
git('add', '-A');
|
||||
git('commit', '-qm', 'merge-base');
|
||||
const baseBranch = git('rev-parse', '--abbrev-ref', 'HEAD');
|
||||
|
||||
// PR branch (head): docs-only change
|
||||
git('checkout', '-q', '-b', 'feature');
|
||||
fs.writeFileSync(path.join(tmp, 'docs', 'a.md'), 'base\nnew docs line\n');
|
||||
git('add', '-A');
|
||||
git('commit', '-qm', 'docs: add line');
|
||||
const head = git('rev-parse', 'HEAD');
|
||||
|
||||
// base advances (next gains a PRODUCT commit after the merge-base)
|
||||
git('checkout', '-q', baseBranch);
|
||||
fs.writeFileSync(path.join(tmp, 'package.json'), '{"name":"x","version":"2.0.0"}\n');
|
||||
git('add', '-A');
|
||||
git('commit', '-qm', 'chore: bump version on next');
|
||||
const base = git('rev-parse', 'HEAD');
|
||||
|
||||
const r = spawnSync(process.execPath, [SCRIPT, '--base', base, '--head', head], {
|
||||
cwd: tmp,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.strictEqual(r.status, 0, `script failed: stderr=${r.stderr}\nstdout=${r.stdout}`);
|
||||
const result = JSON.parse(r.stdout);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
result.changed_files,
|
||||
['docs/a.md'],
|
||||
`expected three-dot diff to see only the docs file, got: ${JSON.stringify(result.changed_files)}`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
result.product_changed,
|
||||
false,
|
||||
`docs-only PR must not set product_changed even on a stale base, got: ${JSON.stringify(result)}`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
result.full_matrix,
|
||||
false,
|
||||
`docs-only PR must not set full_matrix even on a stale base, got: ${JSON.stringify(result)}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ci-test-scope superset invariant (#494)', () => {
|
||||
@@ -335,6 +405,40 @@ describe('INERT_WORKFLOWS allowlist integrity guard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('test.yml changes job contract (#837)', () => {
|
||||
// ci-test-scope.cjs uses a three-dot `git diff base...head`, which requires the
|
||||
// merge-base commit to be locally present. The `changes` job in test.yml guarantees
|
||||
// this via `fetch-depth: 0` on its checkout step. This test pins that contract so
|
||||
// any future reduction of fetch-depth fails CI loudly (#837).
|
||||
test('changes job checkout step sets fetch-depth: 0 (required for three-dot diff merge-base)', () => {
|
||||
const workflowPath = path.join(WORKFLOWS_DIR, 'test.yml');
|
||||
const text = fs.readFileSync(workflowPath, 'utf8');
|
||||
const lines = text.split('\n');
|
||||
|
||||
// Locate the `changes:` job (two-space-indented top-level job key).
|
||||
const jobStart = lines.findIndex(l => /^ {2}changes:\s*$/.test(l));
|
||||
assert.ok(jobStart !== -1, 'Could not find ` changes:` job in test.yml');
|
||||
|
||||
// Find the next top-level job key at the same two-space indentation to bound the region.
|
||||
let jobEnd = lines.length;
|
||||
for (let i = jobStart + 1; i < lines.length; i++) {
|
||||
if (/^ {2}[A-Za-z0-9_-]+:\s*$/.test(lines[i])) {
|
||||
jobEnd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const changesJobText = lines.slice(jobStart, jobEnd).join('\n');
|
||||
|
||||
assert.ok(
|
||||
/fetch-depth:\s*0/.test(changesJobText),
|
||||
'changes job checkout must set `fetch-depth: 0` so the three-dot `git diff base...head` ' +
|
||||
'in ci-test-scope.cjs can resolve the merge-base locally (#837). ' +
|
||||
'Reducing fetch-depth breaks the three-dot diff and causes incorrect scope detection.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('code_changed=false implies clean output invariant', () => {
|
||||
// Fix 1: when code_changed is false, full_matrix, targeted_tests, windows_tests
|
||||
// must ALL be empty/false — even if a docs path coincidentally
|
||||
|
||||
@@ -39,6 +39,37 @@ async function runConfigEnsureSectionWithRetry(tmpDir, attempts = 4) {
|
||||
return last;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed `.planning/config.json` for a test and guarantee it lands on disk
|
||||
* before the test body runs.
|
||||
*
|
||||
* `config-ensure-section` is invoked through a spawned `gsd-tools.cjs` child.
|
||||
* On the scoped CI lane (`--test-concurrency=4`, config.test.cjs scheduled
|
||||
* alongside the heavy install/tarball suites) that child can be transiently
|
||||
* killed under resource pressure — surfacing as a non-zero exit with empty
|
||||
* stderr (an OS-level kill, not a gsd-tools application error; see the
|
||||
* `runGsdTools` catch). A bare `runGsdTools('config-ensure-section')` in
|
||||
* `beforeEach` swallows that failure, leaving config.json absent so the first
|
||||
* subtest's `readConfig()` throws a confusing ENOENT (#770 scoped-lane flake).
|
||||
*
|
||||
* This retries on ANY failure or missing file (not just the EPERM/EBUSY class
|
||||
* `runConfigEnsureSectionWithRetry` covers) and throws a clear diagnostic if it
|
||||
* still cannot create the file, so setup is deterministic under load.
|
||||
*/
|
||||
async function ensureConfigReady(tmpDir, attempts = 5) {
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
let last;
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
last = runGsdTools('config-ensure-section', tmpDir);
|
||||
if (last.success && fs.existsSync(configPath)) return last;
|
||||
if (i < attempts - 1) await delay(150 * (i + 1));
|
||||
}
|
||||
throw new Error(
|
||||
`config-ensure-section failed to create ${configPath} after ${attempts} attempts: ` +
|
||||
`${(last && last.error) || 'unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── config-ensure-section ───────────────────────────────────────────────────
|
||||
|
||||
describe('config-ensure-section command', () => {
|
||||
@@ -1106,10 +1137,12 @@ describe('config-path command (#2282)', () => {
|
||||
describe('config-set prototype-pollution guard (#663)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = createTempProject();
|
||||
// Initialise config so there is a config.json to write to.
|
||||
runGsdTools('config-ensure-section', tmpDir);
|
||||
// Initialise config so there is a config.json to write to. Retry + assert
|
||||
// so a transient config-ensure-section child failure under scoped-lane load
|
||||
// cannot leave config.json absent (#770).
|
||||
await ensureConfigReady(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -1163,10 +1196,12 @@ describe('config-set prototype-pollution guard (#663)', () => {
|
||||
describe('config-set prototype-pollution guard via dynamic-key prefixes (alert #26)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = createTempProject();
|
||||
// Initialise config so there is a config.json to write to.
|
||||
runGsdTools('config-ensure-section', tmpDir);
|
||||
// Initialise config so there is a config.json to write to. Retry + assert
|
||||
// so a transient config-ensure-section child failure under scoped-lane load
|
||||
// cannot leave config.json absent (#770).
|
||||
await ensureConfigReady(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
462
tests/cursor-hooks.test.cjs
Normal file
462
tests/cursor-hooks.test.cjs
Normal file
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* Tests for Cursor hooks.json lifecycle hook registration (issue #777).
|
||||
*
|
||||
* Cursor v2.4+ supports a hooks.json system with events including sessionStart
|
||||
* and postToolUse. GSD registers two managed command hooks so Cursor users get
|
||||
* baseline parity with Claude Code and Gemini.
|
||||
*
|
||||
* Test plan:
|
||||
* T1 reconcileCursorHooksJson — creates new hooks.json with both events
|
||||
* T2 reconcileCursorHooksJson — idempotent (re-run writes same content)
|
||||
* T3 reconcileCursorHooksJson — preserves user-owned entries in sessionStart
|
||||
* T4 reconcileCursorHooksJson — preserves user-owned entries in postToolUse
|
||||
* T5 reconcileCursorHooksJson — remove-only (managedEntries=null) strips managed, keeps user
|
||||
* T6 reconcileCursorHooksJson — handles nested { version, hooks: {...} } shape
|
||||
* T7 reconcileCursorHooksJson — handles flat (no version) shape
|
||||
* T8 reconcileCursorHooksJson — corrupted JSON throws descriptive error
|
||||
* T9 isManagedCursorHookEntry — returns true for GSD-marked entries
|
||||
* T10 isManagedCursorHookEntry — returns false for user entries
|
||||
* T11 buildCursorHookEntry — emits correct shape with marker
|
||||
* T12 removeCursorHooksJson — removes hooks.json when it becomes empty
|
||||
* T13 removeCursorHooksJson — preserves file when user entries remain
|
||||
* T14 runtime-config-adapter — cursor now has 'cursor-hooks-json' surface
|
||||
* T15 INSTALL_SURFACES — 'cursor-hooks-json' in the valid surfaces list
|
||||
* T16 Hook scripts exist in hooks/
|
||||
* T17 Hook script content — sessionStart script emits JSON with additional_context
|
||||
* T18 Hook script content — postToolUse script emits JSON {} for non-write tools
|
||||
* T19 GSD_CURSOR_HOOK_MARKER constant is exported
|
||||
* T20 GSD_CURSOR_SESSION_HOOK_SCRIPT / GSD_CURSOR_POST_TOOL_HOOK_SCRIPT constants
|
||||
*/
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Hook script text IS what Cursor loads. Testing script content tests the deployed contract.
|
||||
|
||||
'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 os = require('node:os');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
reconcileCursorHooksJson,
|
||||
isManagedCursorHookEntry,
|
||||
buildCursorHookEntry,
|
||||
removeCursorHooksJson,
|
||||
GSD_CURSOR_HOOK_MARKER,
|
||||
GSD_CURSOR_SESSION_HOOK_SCRIPT,
|
||||
GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
const {
|
||||
resolveRuntimeConfigIntent,
|
||||
INSTALL_SURFACES,
|
||||
} = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeHooksJson(obj) {
|
||||
return JSON.stringify(obj, null, 2) + '\n';
|
||||
}
|
||||
|
||||
function readHooksJson(dir) {
|
||||
const p = path.join(dir, 'hooks.json');
|
||||
if (!fs.existsSync(p)) return null;
|
||||
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||
}
|
||||
|
||||
function managedEntry(command) {
|
||||
return { type: 'command', command, [GSD_CURSOR_HOOK_MARKER]: true };
|
||||
}
|
||||
|
||||
function userEntry(command) {
|
||||
return { type: 'command', command };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T1: Creates new hooks.json with both events
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('reconcileCursorHooksJson', () => {
|
||||
test('T1: creates new hooks.json with sessionStart and postToolUse', (t) => {
|
||||
const dir = createTempDir();
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const hooksJsonPath = path.join(dir, 'hooks.json');
|
||||
const result = reconcileCursorHooksJson(hooksJsonPath, {
|
||||
sessionStart: managedEntry('/node /path/gsd-cursor-session-start.js'),
|
||||
postToolUse: managedEntry('/node /path/gsd-cursor-post-tool.js'),
|
||||
});
|
||||
|
||||
assert.equal(result.wrote, true);
|
||||
assert.equal(result.changed, true);
|
||||
|
||||
const parsed = readHooksJson(dir);
|
||||
assert.ok(parsed, 'hooks.json must exist');
|
||||
|
||||
// Cursor requires the canonical nested { "version": 1, "hooks": { ... } } shape.
|
||||
assert.equal(parsed.version, 1, 'hooks.json must have version: 1');
|
||||
assert.ok(parsed.hooks && typeof parsed.hooks === 'object', 'hooks.json must have top-level hooks object');
|
||||
const hookTable = parsed.hooks;
|
||||
assert.ok(Array.isArray(hookTable.sessionStart), 'sessionStart must be an array');
|
||||
assert.ok(Array.isArray(hookTable.postToolUse), 'postToolUse must be an array');
|
||||
assert.equal(hookTable.sessionStart.length, 1);
|
||||
assert.equal(hookTable.postToolUse.length, 1);
|
||||
assert.equal(hookTable.sessionStart[0][GSD_CURSOR_HOOK_MARKER], true);
|
||||
assert.equal(hookTable.postToolUse[0][GSD_CURSOR_HOOK_MARKER], true);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T2: Idempotent
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T2: idempotent — second run produces same content (no write)', (t) => {
|
||||
const dir = createTempDir();
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const hooksJsonPath = path.join(dir, 'hooks.json');
|
||||
const entries = {
|
||||
sessionStart: managedEntry('/node /gsd-cursor-session-start.js'),
|
||||
postToolUse: managedEntry('/node /gsd-cursor-post-tool.js'),
|
||||
};
|
||||
|
||||
reconcileCursorHooksJson(hooksJsonPath, entries);
|
||||
const result2 = reconcileCursorHooksJson(hooksJsonPath, entries);
|
||||
|
||||
assert.equal(result2.wrote, false, 'second run must not write (idempotent)');
|
||||
assert.equal(result2.changed, false, 'content must not change on second run');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T3: Preserves user-owned entries in sessionStart
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T3: preserves user-owned entries in sessionStart', (t) => {
|
||||
const dir = createTempDir();
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const hooksJsonPath = path.join(dir, 'hooks.json');
|
||||
// Pre-populate with user-owned entry.
|
||||
fs.writeFileSync(hooksJsonPath, makeHooksJson({
|
||||
version: 1,
|
||||
hooks: {
|
||||
sessionStart: [userEntry('/my-team/hook.sh')],
|
||||
},
|
||||
}));
|
||||
|
||||
reconcileCursorHooksJson(hooksJsonPath, {
|
||||
sessionStart: managedEntry('/node /gsd-cursor-session-start.js'),
|
||||
postToolUse: managedEntry('/node /gsd-cursor-post-tool.js'),
|
||||
});
|
||||
|
||||
const parsed = readHooksJson(dir);
|
||||
const hookTable = parsed.hooks && typeof parsed.hooks === 'object' ? parsed.hooks : parsed;
|
||||
assert.ok(Array.isArray(hookTable.sessionStart));
|
||||
// Both user and GSD entries must survive.
|
||||
assert.equal(hookTable.sessionStart.length, 2, 'user + GSD entry must coexist');
|
||||
const userStays = hookTable.sessionStart.some((e) => e.command === '/my-team/hook.sh');
|
||||
const gsdAdded = hookTable.sessionStart.some((e) => e[GSD_CURSOR_HOOK_MARKER]);
|
||||
assert.ok(userStays, 'user-owned entry must be preserved');
|
||||
assert.ok(gsdAdded, 'GSD managed entry must be present');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T4: Preserves user-owned entries in postToolUse
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T4: preserves user-owned entries in postToolUse', (t) => {
|
||||
const dir = createTempDir();
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const hooksJsonPath = path.join(dir, 'hooks.json');
|
||||
fs.writeFileSync(hooksJsonPath, makeHooksJson({
|
||||
hooks: {
|
||||
postToolUse: [userEntry('/user/post-tool.sh')],
|
||||
},
|
||||
}));
|
||||
|
||||
reconcileCursorHooksJson(hooksJsonPath, {
|
||||
sessionStart: managedEntry('/node /gsd-cursor-session-start.js'),
|
||||
postToolUse: managedEntry('/node /gsd-cursor-post-tool.js'),
|
||||
});
|
||||
|
||||
const parsed = readHooksJson(dir);
|
||||
const hookTable = parsed.hooks && typeof parsed.hooks === 'object' ? parsed.hooks : parsed;
|
||||
assert.ok(Array.isArray(hookTable.postToolUse));
|
||||
assert.equal(hookTable.postToolUse.length, 2);
|
||||
assert.ok(hookTable.postToolUse.some((e) => e.command === '/user/post-tool.sh'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T5: Remove-only (managedEntries=null) strips managed, keeps user entries
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T5: remove-only (null managedEntries) strips GSD entries, preserves user', (t) => {
|
||||
const dir = createTempDir();
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const hooksJsonPath = path.join(dir, 'hooks.json');
|
||||
fs.writeFileSync(hooksJsonPath, makeHooksJson({
|
||||
version: 1,
|
||||
hooks: {
|
||||
sessionStart: [
|
||||
userEntry('/user/session.sh'),
|
||||
managedEntry('/node /gsd-cursor-session-start.js'),
|
||||
],
|
||||
postToolUse: [
|
||||
managedEntry('/node /gsd-cursor-post-tool.js'),
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
reconcileCursorHooksJson(hooksJsonPath, null);
|
||||
|
||||
const parsed = readHooksJson(dir);
|
||||
const hookTable = parsed.hooks && typeof parsed.hooks === 'object' ? parsed.hooks : parsed;
|
||||
// sessionStart user entry survives; postToolUse key should be absent.
|
||||
assert.ok(Array.isArray(hookTable.sessionStart), 'sessionStart must remain (user entry)');
|
||||
assert.equal(hookTable.sessionStart.length, 1, 'only user entry remains');
|
||||
assert.equal(hookTable.sessionStart[0].command, '/user/session.sh');
|
||||
assert.equal(hookTable.postToolUse, undefined, 'postToolUse key must be removed (was GSD-only)');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T6: Handles nested { version, hooks: {...} } shape
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T6: handles nested { version, hooks } shape', (t) => {
|
||||
const dir = createTempDir();
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const hooksJsonPath = path.join(dir, 'hooks.json');
|
||||
fs.writeFileSync(hooksJsonPath, makeHooksJson({ version: 1, hooks: {} }));
|
||||
|
||||
reconcileCursorHooksJson(hooksJsonPath, {
|
||||
sessionStart: managedEntry('/node /gsd-cursor-session-start.js'),
|
||||
});
|
||||
|
||||
const parsed = readHooksJson(dir);
|
||||
assert.ok(parsed.hooks, 'top-level hooks key must be preserved');
|
||||
assert.equal(parsed.version, 1, 'version must be preserved');
|
||||
assert.ok(Array.isArray(parsed.hooks.sessionStart));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T7: Handles flat (no version) shape
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T7: handles flat shape (no wrapper object)', (t) => {
|
||||
const dir = createTempDir();
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const hooksJsonPath = path.join(dir, 'hooks.json');
|
||||
fs.writeFileSync(hooksJsonPath, makeHooksJson({ sessionStart: [] }));
|
||||
|
||||
reconcileCursorHooksJson(hooksJsonPath, {
|
||||
sessionStart: managedEntry('/node /gsd-cursor-session-start.js'),
|
||||
});
|
||||
|
||||
const parsed = readHooksJson(dir);
|
||||
// Flat input is migrated to nested canonical shape: { version: 1, hooks: { ... } }.
|
||||
assert.equal(parsed.version, 1, 'migrated flat shape must get version: 1');
|
||||
assert.ok(parsed.hooks && typeof parsed.hooks === 'object', 'migrated shape must have hooks object');
|
||||
assert.ok(Array.isArray(parsed.hooks.sessionStart), 'sessionStart must be in hooks object after migration');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T8: Corrupted JSON throws descriptive error
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T8: throws descriptive error for corrupted hooks.json', (t) => {
|
||||
const dir = createTempDir();
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const hooksJsonPath = path.join(dir, 'hooks.json');
|
||||
fs.writeFileSync(hooksJsonPath, '{ not valid json }');
|
||||
|
||||
assert.throws(
|
||||
() => reconcileCursorHooksJson(hooksJsonPath, { sessionStart: managedEntry('/cmd') }),
|
||||
(err) => {
|
||||
assert.match(err.message, /Cursor hooks\.json parse failed/i);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T9-T11: Entry helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('isManagedCursorHookEntry / buildCursorHookEntry', () => {
|
||||
test('T9: isManagedCursorHookEntry returns true for GSD-marked entry', () => {
|
||||
const entry = { type: 'command', command: '/x', [GSD_CURSOR_HOOK_MARKER]: true };
|
||||
assert.equal(isManagedCursorHookEntry(entry), true);
|
||||
});
|
||||
|
||||
test('T10: isManagedCursorHookEntry returns false for user-owned entry', () => {
|
||||
const entry = { type: 'command', command: '/user/hook.sh' };
|
||||
assert.equal(isManagedCursorHookEntry(entry), false);
|
||||
assert.equal(isManagedCursorHookEntry(null), false);
|
||||
assert.equal(isManagedCursorHookEntry({}), false);
|
||||
});
|
||||
|
||||
test('T11: buildCursorHookEntry emits correct shape', () => {
|
||||
const entry = buildCursorHookEntry('/usr/local/bin/node /path/to/hook.js');
|
||||
assert.equal(entry.type, 'command');
|
||||
assert.equal(entry[GSD_CURSOR_HOOK_MARKER], true);
|
||||
assert.ok(typeof entry.command === 'string');
|
||||
// Forward slashes only.
|
||||
assert.ok(!entry.command.includes('\\'), 'command must use forward slashes');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T12-T13: removeCursorHooksJson
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('removeCursorHooksJson', () => {
|
||||
test('T12: removes hooks.json when it becomes empty after GSD removal', (t) => {
|
||||
const dir = createTempDir();
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const hooksJsonPath = path.join(dir, 'hooks.json');
|
||||
// GSD-only file.
|
||||
fs.writeFileSync(hooksJsonPath, makeHooksJson({
|
||||
version: 1,
|
||||
hooks: {
|
||||
sessionStart: [managedEntry('/node /gsd-cursor-session-start.js')],
|
||||
postToolUse: [managedEntry('/node /gsd-cursor-post-tool.js')],
|
||||
},
|
||||
}));
|
||||
|
||||
const result = removeCursorHooksJson(dir);
|
||||
assert.equal(result.changed, true);
|
||||
// File should be removed (was GSD-only).
|
||||
assert.equal(fs.existsSync(hooksJsonPath), false, 'empty hooks.json must be removed');
|
||||
});
|
||||
|
||||
test('T13: preserves hooks.json when user entries remain', (t) => {
|
||||
const dir = createTempDir();
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
const hooksJsonPath = path.join(dir, 'hooks.json');
|
||||
fs.writeFileSync(hooksJsonPath, makeHooksJson({
|
||||
version: 1,
|
||||
hooks: {
|
||||
sessionStart: [
|
||||
managedEntry('/node /gsd-cursor-session-start.js'),
|
||||
userEntry('/user/session.sh'),
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
removeCursorHooksJson(dir);
|
||||
|
||||
assert.ok(fs.existsSync(hooksJsonPath), 'hooks.json must remain (user entries present)');
|
||||
const parsed = readHooksJson(dir);
|
||||
const hookTable = parsed.hooks && typeof parsed.hooks === 'object' ? parsed.hooks : parsed;
|
||||
assert.equal(hookTable.sessionStart.length, 1);
|
||||
assert.equal(hookTable.sessionStart[0].command, '/user/session.sh');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T14: runtime-config-adapter — cursor now has 'cursor-hooks-json' surface
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T14: cursor runtime has installSurface cursor-hooks-json', () => {
|
||||
const intent = resolveRuntimeConfigIntent('cursor');
|
||||
assert.equal(intent.installSurface, 'cursor-hooks-json');
|
||||
assert.equal(intent.writesSharedSettings, false);
|
||||
assert.equal(intent.finishPermissionWriter, null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T15: INSTALL_SURFACES includes 'cursor-hooks-json'
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T15: INSTALL_SURFACES includes cursor-hooks-json', () => {
|
||||
assert.ok(
|
||||
INSTALL_SURFACES.includes('cursor-hooks-json'),
|
||||
"'cursor-hooks-json' must be in INSTALL_SURFACES"
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T16: Hook script files exist in hooks/
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T16: Cursor hook script files exist in hooks/', () => {
|
||||
const hooksDir = path.join(__dirname, '..', 'hooks');
|
||||
const sessionStart = path.join(hooksDir, GSD_CURSOR_SESSION_HOOK_SCRIPT);
|
||||
const postTool = path.join(hooksDir, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT);
|
||||
assert.ok(fs.existsSync(sessionStart), `${GSD_CURSOR_SESSION_HOOK_SCRIPT} must exist in hooks/`);
|
||||
assert.ok(fs.existsSync(postTool), `${GSD_CURSOR_POST_TOOL_HOOK_SCRIPT} must exist in hooks/`);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T17: sessionStart script emits JSON with additional_context on stdin close
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T17: gsd-cursor-session-start.js emits JSON with additional_context', (t, done) => {
|
||||
const hooksDir = path.join(__dirname, '..', 'hooks');
|
||||
const scriptPath = path.join(hooksDir, GSD_CURSOR_SESSION_HOOK_SCRIPT);
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
const input = JSON.stringify({ session_id: 'test-123', composer_mode: 'agent' });
|
||||
const child = execFile(process.execPath, [scriptPath], {
|
||||
timeout: 10000,
|
||||
cwd: os.tmpdir(), // no .planning/ dir here — should get MSG_ABSENT
|
||||
}, (err, stdout) => {
|
||||
if (err && !stdout) { done(err); return; }
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(stdout); } catch (e) { done(new Error(`stdout not valid JSON: ${stdout}`)); return; }
|
||||
assert.ok('additional_context' in parsed, 'output must have additional_context field');
|
||||
assert.ok(typeof parsed.additional_context === 'string', 'additional_context must be a string');
|
||||
assert.ok(parsed.additional_context.length > 0, 'additional_context must not be empty');
|
||||
done();
|
||||
});
|
||||
child.stdin.write(input);
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T18: postToolUse script emits {} for non-write tools
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T18: gsd-cursor-post-tool.js emits {} for non-write tool names', (t, done) => {
|
||||
const hooksDir = path.join(__dirname, '..', 'hooks');
|
||||
const scriptPath = path.join(hooksDir, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT);
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
const input = JSON.stringify({
|
||||
tool_name: 'Read',
|
||||
tool_input: { path: '/some/file.js' },
|
||||
tool_output: 'contents',
|
||||
duration: 42,
|
||||
});
|
||||
|
||||
const child = execFile(process.execPath, [scriptPath], {
|
||||
timeout: 10000,
|
||||
cwd: os.tmpdir(),
|
||||
}, (err, stdout) => {
|
||||
if (err && !stdout) { done(err); return; }
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(stdout); } catch (e) { done(new Error(`stdout not valid JSON: ${stdout}`)); return; }
|
||||
// Non-write tool → empty response (no additional_context).
|
||||
assert.ok(typeof parsed === 'object', 'output must be an object');
|
||||
// additional_context should be absent for non-write, non-planning tool.
|
||||
assert.equal(parsed.additional_context, undefined, 'no additional_context for non-write tool');
|
||||
done();
|
||||
});
|
||||
child.stdin.write(input);
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T19: GSD_CURSOR_HOOK_MARKER is exported and is a non-empty string
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T19: GSD_CURSOR_HOOK_MARKER is exported and is a non-empty string', () => {
|
||||
assert.equal(typeof GSD_CURSOR_HOOK_MARKER, 'string');
|
||||
assert.ok(GSD_CURSOR_HOOK_MARKER.length > 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// T20: Script name constants are exported and correct
|
||||
// ---------------------------------------------------------------------------
|
||||
test('T20: GSD_CURSOR_SESSION_HOOK_SCRIPT and GSD_CURSOR_POST_TOOL_HOOK_SCRIPT are exported', () => {
|
||||
assert.equal(GSD_CURSOR_SESSION_HOOK_SCRIPT, 'gsd-cursor-session-start.js');
|
||||
assert.equal(GSD_CURSOR_POST_TOOL_HOOK_SCRIPT, 'gsd-cursor-post-tool.js');
|
||||
});
|
||||
379
tests/enh-770-claude-hook-events.test.cjs
Normal file
379
tests/enh-770-claude-hook-events.test.cjs
Normal file
@@ -0,0 +1,379 @@
|
||||
'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
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// 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)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
406
tests/enh-776-install-gemini-hook-events.test.cjs
Normal file
406
tests/enh-776-install-gemini-hook-events.test.cjs
Normal file
@@ -0,0 +1,406 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Enhancement #776: Adopt new Gemini hook events + detect hooksConfig.enabled:false.
|
||||
*
|
||||
* Gemini CLI exposes several hook events beyond BeforeTool/AfterTool that gsd
|
||||
* previously did not register. This suite asserts that a Gemini install
|
||||
* registers the 3 new high-value events:
|
||||
* - BeforeAgent — fires before the agent plans (context headroom tracking)
|
||||
* - AfterAgent — fires after final response generation (context tracking)
|
||||
* - BeforeModel — fires before each LLM call (per-turn context awareness)
|
||||
*
|
||||
* All three are wired to gsd-context-monitor.js — the same hook used for
|
||||
* AfterTool — so context headroom warnings surface at these lifecycle moments.
|
||||
*
|
||||
* Also asserts:
|
||||
* - Claude Code installs do NOT gain these Gemini-only events (strict scope guard).
|
||||
* - Reinstalls are idempotent (no hook duplication).
|
||||
* - Uninstall removes the new event registrations.
|
||||
* - hooksConfig.enabled:false warning is emitted during a Gemini install.
|
||||
*
|
||||
* Source: https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md
|
||||
*/
|
||||
|
||||
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.
|
||||
//
|
||||
// For Gemini, the installer migration baseline includes 'hooks/' in its surface
|
||||
// list (unlike Qwen which excludes it). Pre-install stubs placed in .gemini/hooks/
|
||||
// are classified as 'bundled-gsd-hook' and auto-removed by the migration before
|
||||
// registration can succeed. The workaround: run a first install (which writes the
|
||||
// gsd-file-manifest.json), then add the stubs, then run install again. On the
|
||||
// second install the manifest marks the hook files as managed, so migration keeps
|
||||
// them and registration guards (fs.existsSync) pass.
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-pass Gemini install.
|
||||
*
|
||||
* Pass 1: install() with no hook stubs — writes gsd-file-manifest.json.
|
||||
* Migration runs on an empty hooks/ so nothing gets auto-removed.
|
||||
* Pass 2: stub hooks into the target dir (now manifest-tracked on next scan),
|
||||
* then run install() again. Migration now classifies the hooks as
|
||||
* managed-unchanged and preserves them; registration guards pass.
|
||||
*
|
||||
* Returns the settings from pass 2.
|
||||
*/
|
||||
function twoPassGeminiInstall(tmpDir) {
|
||||
const targetDir = path.join(tmpDir, '.gemini');
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
// Pass 1 — no hook stubs yet; writes the manifest
|
||||
const result1 = install(false, 'gemini');
|
||||
persistSettings(result1.settingsPath, result1.settings);
|
||||
|
||||
// Inject stubs so the registration guards (fs.existsSync) pass on pass 2
|
||||
stubHooksIntoTarget(targetDir);
|
||||
|
||||
// Pass 2 — manifest exists, migration keeps stubs, registration succeeds
|
||||
process.chdir(tmpDir);
|
||||
const result2 = install(false, 'gemini');
|
||||
return result2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: Gemini — new events are registered ─────────────────────────────
|
||||
|
||||
describe('enh-776: Gemini install registers 3 new hook events', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-776-gemini-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// Two-pass install: first pass writes manifest, second pass with stubs
|
||||
// so registration guards (fs.existsSync) pass.
|
||||
const result = twoPassGeminiInstall(tmpDir);
|
||||
settings = result.settings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('install returns a settings object (not null)', () => {
|
||||
assert.ok(settings !== null && typeof settings === 'object',
|
||||
'Gemini install must return a non-null settings object');
|
||||
});
|
||||
|
||||
test('BeforeAgent event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeAgent');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected BeforeAgent hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('AfterAgent event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'AfterAgent');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected AfterAgent hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('BeforeModel event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeModel');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected BeforeModel hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('BeforeAgent / AfterAgent / BeforeModel all use gsd-context-monitor', () => {
|
||||
for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
|
||||
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: Non-Gemini installs do NOT get the new events ──────────────────
|
||||
//
|
||||
// Two runtimes are particularly important to guard:
|
||||
// Claude — the canonical non-Gemini runtime
|
||||
// Antigravity — shares Gemini-style BeforeTool/AfterTool naming and uses
|
||||
// isGemini-adjacent logic; a future accidental
|
||||
// `isGemini || isAntigravity` change must be caught here.
|
||||
|
||||
describe('enh-776: Claude install does NOT register Gemini-only hook events', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-776-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 BeforeAgent', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeAgent');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Claude should NOT have BeforeAgent; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('Claude install does not register AfterAgent', () => {
|
||||
const cmds = hooksForEvent(settings, 'AfterAgent');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Claude should NOT have AfterAgent; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('Claude install does not register BeforeModel', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeModel');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Claude should NOT have BeforeModel; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enh-776: Antigravity install does NOT register Gemini-only hook events', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-776-antigravity-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const result = install(false, 'antigravity');
|
||||
settings = result && result.settings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('Antigravity install does not register BeforeAgent', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeAgent');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Antigravity should NOT have BeforeAgent; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('Antigravity install does not register AfterAgent', () => {
|
||||
const cmds = hooksForEvent(settings, 'AfterAgent');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Antigravity should NOT have AfterAgent; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('Antigravity install does not register BeforeModel', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeModel');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Antigravity should NOT have BeforeModel; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 3: Idempotency — persisted reinstall does not duplicate hooks ──────
|
||||
|
||||
describe('enh-776: Gemini install is idempotent across persisted reinstalls', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-776-idem-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('re-running after persisted first install does not duplicate hook entries', () => {
|
||||
// Two-pass to get hooks installed.
|
||||
const result2 = twoPassGeminiInstall(tmpDir);
|
||||
const s2 = result2.settings;
|
||||
|
||||
// Assert hooks ARE registered after pass 2 (guards against false-pass where
|
||||
// hooks never registered and idempotency passes trivially at count=0).
|
||||
for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
|
||||
const cmds = hooksForEvent(s2, event);
|
||||
assert.strictEqual(cmds.length, 1,
|
||||
`Event ${event} should have exactly 1 hook after two-pass install; got ${cmds.length}: ${JSON.stringify(cmds)}`);
|
||||
}
|
||||
|
||||
persistSettings(result2.settingsPath, s2);
|
||||
|
||||
// Third install: reads the persisted settings.json — dedup guards apply
|
||||
process.chdir(tmpDir);
|
||||
const result3 = install(false, 'gemini');
|
||||
const s3 = result3.settings;
|
||||
|
||||
for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
|
||||
const cmds = hooksForEvent(s3, 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-776: Gemini uninstall removes new hook event entries', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-776-uninstall-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// Two-pass install and persist so uninstall has a settings.json to clean
|
||||
const result = twoPassGeminiInstall(tmpDir);
|
||||
persistSettings(result.settingsPath, result.settings);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('settings.json hook entries are removed on uninstall', () => {
|
||||
uninstall(false, 'gemini');
|
||||
const settingsPath = path.join(tmpDir, '.gemini', 'settings.json');
|
||||
if (!fs.existsSync(settingsPath)) return; // file removed entirely is fine
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
|
||||
const cmds = hooksForEvent(settings, event);
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`After uninstall, ${event} should have 0 hooks; got: ${JSON.stringify(cmds)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 5: hooksConfig.enabled:false warning ───────────────────────────────
|
||||
|
||||
describe('enh-776: hooksConfig.enabled:false warning during Gemini install', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let stderrLines;
|
||||
let originalWarn;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-776-hookscfg-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const targetDir = path.join(tmpDir, '.gemini');
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
// Capture console.warn output
|
||||
stderrLines = [];
|
||||
originalWarn = console.warn;
|
||||
console.warn = (...args) => { stderrLines.push(args.join(' ')); };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.warn = originalWarn;
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('emits a warning when hooksConfig.enabled is false', () => {
|
||||
// Write a settings.json with hooksConfig.enabled: false BEFORE install
|
||||
// (the check in install() reads the existing settings.json on disk).
|
||||
const targetDir = path.join(tmpDir, '.gemini');
|
||||
const settingsPath = path.join(targetDir, 'settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ hooksConfig: { enabled: false } }, null, 2) + '\n', 'utf8');
|
||||
|
||||
install(false, 'gemini');
|
||||
const warnText = stderrLines.join('\n');
|
||||
assert.ok(
|
||||
warnText.includes('hooksConfig.enabled is false'),
|
||||
`Expected hooksConfig.enabled warning; got console.warn output:\n${warnText}`
|
||||
);
|
||||
});
|
||||
|
||||
test('does NOT emit the hooksConfig warning when hooksConfig.enabled is true', () => {
|
||||
const targetDir = path.join(tmpDir, '.gemini');
|
||||
const settingsPath = path.join(targetDir, 'settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ hooksConfig: { enabled: true } }, null, 2) + '\n', 'utf8');
|
||||
|
||||
install(false, 'gemini');
|
||||
const warnText = stderrLines.join('\n');
|
||||
assert.ok(
|
||||
!warnText.includes('hooksConfig.enabled is false'),
|
||||
`Should NOT warn when hooksConfig.enabled is true; got:\n${warnText}`
|
||||
);
|
||||
});
|
||||
|
||||
test('does NOT emit the hooksConfig warning when hooksConfig is absent', () => {
|
||||
const targetDir = path.join(tmpDir, '.gemini');
|
||||
const settingsPath = path.join(targetDir, 'settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({}, null, 2) + '\n', 'utf8');
|
||||
|
||||
install(false, 'gemini');
|
||||
const warnText = stderrLines.join('\n');
|
||||
assert.ok(
|
||||
!warnText.includes('hooksConfig.enabled is false'),
|
||||
`Should NOT warn when hooksConfig is absent; got:\n${warnText}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,7 @@ const STUB_HOOKS = [
|
||||
'gsd-context-monitor.js',
|
||||
'gsd-prompt-guard.js',
|
||||
'gsd-check-update.js',
|
||||
'gsd-config-reload.js', // Added in #770
|
||||
];
|
||||
|
||||
function stubHooksIntoTarget(targetDir) {
|
||||
@@ -148,11 +149,21 @@ describe('enh-788: Qwen install registers 3 new hook events', () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('FileChanged is NOT registered for Qwen (Claude-only event)', () => {
|
||||
// gsd-config-reload / FileChanged is a Claude Code-only registration.
|
||||
// Qwen does not support the FileChanged hook event at all.
|
||||
const cmds = hooksForEvent(settings, 'FileChanged');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`FileChanged should NOT be registered for Qwen; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 2: Claude install does NOT get the new events ─────────────────────
|
||||
// ─── Suite 2: Claude install DOES get the context events (since #770) ───────
|
||||
// Note: Prior to #770, these were Qwen-only events. #770 extended them to
|
||||
// Claude Code. This suite is updated to match the new expected behavior.
|
||||
|
||||
describe('enh-788: Claude install does NOT register Qwen-only hook events', () => {
|
||||
describe('enh-788 (updated by #770): Claude install registers context lifecycle events', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
@@ -161,8 +172,9 @@ describe('enh-788: Claude install does NOT register Qwen-only hook events', () =
|
||||
tmpDir = createTempDir('gsd-788-claude-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
stubHooksIntoTarget(path.join(tmpDir, '.claude'));
|
||||
|
||||
const result = install(false, 'claude');
|
||||
const result = install(false, 'claude', { installerMigrations: [] });
|
||||
settings = result && result.settings;
|
||||
});
|
||||
|
||||
@@ -171,22 +183,22 @@ describe('enh-788: Claude install does NOT register Qwen-only hook events', () =
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('Claude install does not register SubagentStop', () => {
|
||||
test('Claude install registers SubagentStop (since #770)', () => {
|
||||
const cmds = hooksForEvent(settings, 'SubagentStop');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Claude should NOT have SubagentStop; got: ${JSON.stringify(cmds)}`);
|
||||
assert.ok(cmds.length > 0,
|
||||
`Claude should have SubagentStop since #770; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('Claude install does not register Stop', () => {
|
||||
test('Claude install registers Stop (since #770)', () => {
|
||||
const cmds = hooksForEvent(settings, 'Stop');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Claude should NOT have Stop; got: ${JSON.stringify(cmds)}`);
|
||||
assert.ok(cmds.length > 0,
|
||||
`Claude should have Stop since #770; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('Claude install does not register PreCompact', () => {
|
||||
test('Claude install registers PreCompact (since #770)', () => {
|
||||
const cmds = hooksForEvent(settings, 'PreCompact');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Claude should NOT have PreCompact; got: ${JSON.stringify(cmds)}`);
|
||||
assert.ok(cmds.length > 0,
|
||||
`Claude should have PreCompact since #770; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ const EXPECTED_SH_HOOKS = [
|
||||
|
||||
const EXPECTED_ALL_HOOKS = [
|
||||
'gsd-check-update.js',
|
||||
'gsd-config-reload.js',
|
||||
'gsd-context-monitor.js',
|
||||
'gsd-prompt-guard.js',
|
||||
'gsd-read-guard.js',
|
||||
|
||||
@@ -114,9 +114,15 @@ describe('B: hooks/hooks.json', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('every event name is one of: SessionStart, PreToolUse, PostToolUse', (t) => {
|
||||
test('every event name is a known Claude Code lifecycle event', (t) => {
|
||||
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
|
||||
const validEvents = new Set(['SessionStart', 'PreToolUse', 'PostToolUse']);
|
||||
// Complete set of Claude Code hook events as of #770 (SubagentStop, Stop,
|
||||
// PreCompact, FileChanged added in #770; prior set was SessionStart,
|
||||
// PreToolUse, PostToolUse from #766).
|
||||
const validEvents = new Set([
|
||||
'SessionStart', 'PreToolUse', 'PostToolUse',
|
||||
'SubagentStop', 'Stop', 'PreCompact', 'FileChanged',
|
||||
]);
|
||||
for (const eventName of Object.keys(hooksConfig.hooks)) {
|
||||
assert.ok(validEvents.has(eventName), `Unknown hook event: "${eventName}"`);
|
||||
}
|
||||
|
||||
44
tests/issue-815-update-next-channel.test.cjs
Normal file
44
tests/issue-815-update-next-channel.test.cjs
Normal file
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
// allow-test-rule: reads product workflow/command markdown to verify the --next RC channel contract — not a source-grep test
|
||||
|
||||
// Issue #815: `/gsd-update --next` (alias `--rc`) must thread the @next dist-tag
|
||||
// through the whole update flow (version check + install) while leaving the
|
||||
// default @latest path unchanged.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const WF = fs.readFileSync(path.join(ROOT, 'gsd-core', 'workflows', 'update.md'), 'utf8');
|
||||
const CMD = fs.readFileSync(path.join(ROOT, 'commands', 'gsd', 'update.md'), 'utf8');
|
||||
|
||||
test('issue #815: workflow parses --next/--rc into a TAG channel', () => {
|
||||
assert.match(WF, /--next/);
|
||||
assert.match(WF, /--rc/);
|
||||
assert.match(WF, /TAG="next"/);
|
||||
assert.match(WF, /TAG="latest"/);
|
||||
});
|
||||
|
||||
test('issue #815: version check threads the tag through check-latest-version.cjs', () => {
|
||||
// The script path is double-quoted in the shell command, so the line is:
|
||||
// node "$GSD_DIR/gsd-core/bin/check-latest-version.cjs" --json --tag "$TAG"
|
||||
// The closing " on the script path sits between .cjs and --json.
|
||||
assert.match(WF, /check-latest-version\.cjs"? --json --tag "\$TAG"/);
|
||||
});
|
||||
|
||||
test('issue #815: install uses the selected tag, not a hardcoded @latest', () => {
|
||||
const robust = WF.match(/npx -y --package=@opengsd\/gsd-core@"\$TAG" -- gsd-core/g) || [];
|
||||
assert.ok(robust.length >= 3, `expected >=3 tag-parameterized npx invocations, found ${robust.length}`);
|
||||
assert.doesNotMatch(WF, /--package=@opengsd\/gsd-core@latest -- gsd-core/,
|
||||
'install lines must not hardcode @latest once --next exists');
|
||||
assert.doesNotMatch(WF, /--package=@opengsd\/gsd-core@(?:latest|next|beta|canary|rc) -- gsd-core/,
|
||||
'install lines must use the $TAG variable, never a hardcoded dist-tag literal');
|
||||
});
|
||||
|
||||
test('issue #815: command documents --next/--rc and routes it to the update workflow', () => {
|
||||
assert.match(CMD, /--next/);
|
||||
assert.match(CMD, /--rc/);
|
||||
assert.match(CMD, /argument-hint:.*--next/);
|
||||
});
|
||||
236
tests/issue-844-manifest-version-sync.test.cjs
Normal file
236
tests/issue-844-manifest-version-sync.test.cjs
Normal file
@@ -0,0 +1,236 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression tests for issue #844: manifest version sync.
|
||||
*
|
||||
* Verifies that `scripts/sync-manifest-versions.cjs` correctly stamps the
|
||||
* package.json version into every registered runtime-integration manifest,
|
||||
* and that all currently-tracked manifests are in sync.
|
||||
*
|
||||
* Key deliverable: the regression guard (test d) asserts that any committed
|
||||
* JSON file with a top-level `version` field matching package.json is
|
||||
* registered in VERSIONED_MANIFESTS — forcing explicit opt-in for future
|
||||
* manifests.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const helpers = require(path.join(__dirname, 'helpers.cjs'));
|
||||
const {
|
||||
VERSIONED_MANIFESTS,
|
||||
syncManifestVersions,
|
||||
getPackageVersion,
|
||||
stageManifests,
|
||||
} = require(path.join(ROOT, 'scripts', 'sync-manifest-versions.cjs'));
|
||||
|
||||
// ─── A: RED→GREEN repro via temp fixture ─────────────────────────────────────
|
||||
describe('A: syncManifestVersions — temp fixture', () => {
|
||||
|
||||
let tmpRoot;
|
||||
|
||||
test('setup: create temp fixture with stale manifests', () => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-844-'));
|
||||
|
||||
// Write tmp package.json
|
||||
fs.writeFileSync(
|
||||
path.join(tmpRoot, 'package.json'),
|
||||
JSON.stringify({ name: 'x', version: '9.9.9-test.0' }, null, 2) + '\n'
|
||||
);
|
||||
|
||||
// Copy real manifests into tmp, stamped at OLD version
|
||||
for (const rel of VERSIONED_MANIFESTS) {
|
||||
const realAbs = path.join(ROOT, rel);
|
||||
const manifest = JSON.parse(fs.readFileSync(realAbs, 'utf8'));
|
||||
manifest.version = '0.0.0';
|
||||
|
||||
const destAbs = path.join(tmpRoot, rel);
|
||||
const destDir = path.dirname(destAbs);
|
||||
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(destAbs, JSON.stringify(manifest, null, 2) + '\n');
|
||||
}
|
||||
});
|
||||
|
||||
test('pre-sync: at least one manifest has stale version', () => {
|
||||
assert.ok(tmpRoot, 'tmpRoot must be set by setup test');
|
||||
const pkgVersion = getPackageVersion(tmpRoot);
|
||||
let anyStale = false;
|
||||
for (const rel of VERSIONED_MANIFESTS) {
|
||||
const m = JSON.parse(fs.readFileSync(path.join(tmpRoot, rel), 'utf8'));
|
||||
if (m.version !== pkgVersion) { anyStale = true; break; }
|
||||
}
|
||||
assert.ok(anyStale, 'At least one manifest should be stale before sync (version 0.0.0 != 9.9.9-test.0)');
|
||||
});
|
||||
|
||||
test('syncManifestVersions stamps all manifests to package.json version', () => {
|
||||
assert.ok(tmpRoot, 'tmpRoot must be set by setup test');
|
||||
const changed = syncManifestVersions({ root: tmpRoot });
|
||||
assert.ok(changed.length > 0, 'syncManifestVersions should report at least one changed file');
|
||||
|
||||
const pkgVersion = getPackageVersion(tmpRoot);
|
||||
assert.equal(pkgVersion, '9.9.9-test.0');
|
||||
|
||||
for (const rel of VERSIONED_MANIFESTS) {
|
||||
const abs = path.join(tmpRoot, rel);
|
||||
const m = JSON.parse(fs.readFileSync(abs, 'utf8'));
|
||||
assert.equal(
|
||||
m.version,
|
||||
'9.9.9-test.0',
|
||||
`${rel} version should be 9.9.9-test.0 after sync`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('non-version fields are preserved after sync', () => {
|
||||
assert.ok(tmpRoot, 'tmpRoot must be set by setup test');
|
||||
// Read from the real manifests to know what non-version fields should exist
|
||||
for (const rel of VERSIONED_MANIFESTS) {
|
||||
const real = JSON.parse(fs.readFileSync(path.join(ROOT, rel), 'utf8'));
|
||||
const tmp = JSON.parse(fs.readFileSync(path.join(tmpRoot, rel), 'utf8'));
|
||||
// Check that every non-version key from the real manifest exists in tmp
|
||||
for (const key of Object.keys(real)) {
|
||||
if (key === 'version') continue;
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(tmp, key),
|
||||
`${rel}: field "${key}" should be preserved after sync`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('each synced file ends with a single trailing newline', () => {
|
||||
assert.ok(tmpRoot, 'tmpRoot must be set by setup test');
|
||||
for (const rel of VERSIONED_MANIFESTS) {
|
||||
const raw = fs.readFileSync(path.join(tmpRoot, rel), 'utf8');
|
||||
assert.ok(raw.endsWith('\n'), `${rel} must end with a trailing newline`);
|
||||
assert.ok(!raw.endsWith('\n\n'), `${rel} must not end with a double newline`);
|
||||
}
|
||||
});
|
||||
|
||||
test('second syncManifestVersions call is idempotent (returns [])', () => {
|
||||
assert.ok(tmpRoot, 'tmpRoot must be set by setup test');
|
||||
const changed = syncManifestVersions({ root: tmpRoot });
|
||||
assert.deepEqual(changed, [], 'Second sync call should return [] (already in sync)');
|
||||
});
|
||||
|
||||
test('cleanup: remove temp fixture', () => {
|
||||
if (tmpRoot) {
|
||||
helpers.cleanup(tmpRoot);
|
||||
tmpRoot = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── B: Registry-in-sync: real manifests match package.json ──────────────────
|
||||
describe('B: real manifests match package.json version', () => {
|
||||
|
||||
const pkgVersion = getPackageVersion(ROOT);
|
||||
|
||||
for (const rel of VERSIONED_MANIFESTS) {
|
||||
test(`${rel} version === ${pkgVersion}`, () => {
|
||||
const abs = path.join(ROOT, rel);
|
||||
assert.ok(fs.existsSync(abs), `${rel} must exist at ${abs}`);
|
||||
const m = JSON.parse(fs.readFileSync(abs, 'utf8'));
|
||||
assert.equal(
|
||||
m.version,
|
||||
pkgVersion,
|
||||
`${rel} version (${m.version}) must match package.json version (${pkgVersion}). ` +
|
||||
'Run `node scripts/sync-manifest-versions.cjs` to fix.'
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── C: Regression guard — all version-bearing JSON files are registered ──────
|
||||
describe('C: regression guard — version-bearing JSON files must be registered', () => {
|
||||
|
||||
// package.json is the version source; package-lock.json is npm-managed.
|
||||
// Both inherently track the version without the sync script.
|
||||
const ALLOWED = new Set([...VERSIONED_MANIFESTS, 'package.json', 'package-lock.json']);
|
||||
|
||||
// Semver-ish: matches X.Y.Z with optional pre-release/build metadata.
|
||||
const SEMVER = /^\d+\.\d+\.\d+(?:[-+].+)?$/;
|
||||
|
||||
// Paths to exclude from the guard
|
||||
const EXCLUDED_PREFIXES = ['tests/', 'node_modules/', '.changeset/', 'docs/'];
|
||||
|
||||
test('every committed JSON with a semver top-level version is registered or explicitly allowed', (t) => {
|
||||
// Enumerate committed JSON files via git (no pathspec to avoid recursion quirks;
|
||||
// filter to .json in JS instead).
|
||||
let lines;
|
||||
try {
|
||||
const out = execFileSync('git', ['ls-files'], { cwd: ROOT });
|
||||
lines = out.toString().split('\n').filter((f) => f.endsWith('.json'));
|
||||
} catch (err) {
|
||||
t.skip('git unavailable: ' + err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const rel of lines) {
|
||||
if (ALLOWED.has(rel)) continue;
|
||||
if (EXCLUDED_PREFIXES.some(prefix => rel.startsWith(prefix))) continue;
|
||||
|
||||
const abs = path.join(ROOT, rel);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(fs.readFileSync(abs, 'utf8'));
|
||||
} catch (_) {
|
||||
continue; // skip invalid JSON (shouldn't exist, but be safe)
|
||||
}
|
||||
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
!Array.isArray(parsed) &&
|
||||
typeof parsed.version === 'string' &&
|
||||
SEMVER.test(parsed.version)
|
||||
) {
|
||||
assert.ok(
|
||||
ALLOWED.has(rel),
|
||||
`${rel} has a semver top-level "version" but is not registered in ` +
|
||||
'scripts/sync-manifest-versions.cjs VERSIONED_MANIFESTS (nor an npm-managed file). ' +
|
||||
"Register it so 'npm version' keeps it in sync (issue #844)."
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── E: stageManifests in a non-git dir must not throw ───────────────────────
|
||||
describe('E: stageManifests — non-git dir is a no-op, not a throw', () => {
|
||||
|
||||
test('stageManifests({root}) with a non-git tempdir warns and returns without throwing', () => {
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-844-nogit-'));
|
||||
try {
|
||||
// Write a minimal package.json so getPackageVersion doesn't error if called
|
||||
fs.writeFileSync(
|
||||
path.join(tmpRoot, 'package.json'),
|
||||
JSON.stringify({ name: 'x', version: '0.0.0' }, null, 2) + '\n'
|
||||
);
|
||||
// Must not throw even though tmpRoot is not a git repo
|
||||
assert.doesNotThrow(() => {
|
||||
stageManifests({ root: tmpRoot });
|
||||
}, 'stageManifests must not throw outside a git work tree');
|
||||
} finally {
|
||||
helpers.cleanup(tmpRoot);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── D: CLI --check exits 0 when in sync ─────────────────────────────────────
|
||||
describe('D: CLI --check exits 0 when manifests are in sync', () => {
|
||||
|
||||
test('node scripts/sync-manifest-versions.cjs --check exits 0', () => {
|
||||
// Will throw if exit code != 0
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[path.join(ROOT, 'scripts', 'sync-manifest-versions.cjs'), '--check'],
|
||||
{ cwd: ROOT }
|
||||
);
|
||||
});
|
||||
});
|
||||
624
tests/issue-dedupe.test.cjs
Normal file
624
tests/issue-dedupe.test.cjs
Normal file
@@ -0,0 +1,624 @@
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
POSSIBLE_DUPLICATE_LABEL,
|
||||
HUMAN_REVIEW_LABEL,
|
||||
CHALLENGE_MARKER,
|
||||
DEFAULT_WINDOW_HOURS,
|
||||
DEFAULT_THRESHOLD,
|
||||
DEFAULT_MAX_CANDIDATES,
|
||||
MIN_TOKEN_LENGTH,
|
||||
EXEMPT_LABELS,
|
||||
STOPWORDS,
|
||||
tokenize,
|
||||
diceSimilarity,
|
||||
scoreCandidates,
|
||||
renderChallengeComment,
|
||||
isChallengeComment,
|
||||
hasExemptLabel,
|
||||
shouldClose,
|
||||
} = require('../scripts/issue-dedupe.cjs');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('issue-dedupe constants', () => {
|
||||
test('POSSIBLE_DUPLICATE_LABEL is correct string', () => {
|
||||
assert.equal(POSSIBLE_DUPLICATE_LABEL, 'possible-duplicate');
|
||||
});
|
||||
|
||||
test('HUMAN_REVIEW_LABEL is correct string', () => {
|
||||
assert.equal(HUMAN_REVIEW_LABEL, 'needs-maintainer-review');
|
||||
});
|
||||
|
||||
test('CHALLENGE_MARKER is an HTML comment string', () => {
|
||||
assert.ok(typeof CHALLENGE_MARKER === 'string');
|
||||
assert.ok(CHALLENGE_MARKER.startsWith('<!--'));
|
||||
assert.ok(CHALLENGE_MARKER.endsWith('-->'));
|
||||
});
|
||||
|
||||
test('DEFAULT_WINDOW_HOURS is 24', () => {
|
||||
assert.equal(DEFAULT_WINDOW_HOURS, 24);
|
||||
});
|
||||
|
||||
test('DEFAULT_THRESHOLD is 0.6', () => {
|
||||
assert.equal(DEFAULT_THRESHOLD, 0.6);
|
||||
});
|
||||
|
||||
test('DEFAULT_MAX_CANDIDATES is 5', () => {
|
||||
assert.equal(DEFAULT_MAX_CANDIDATES, 5);
|
||||
});
|
||||
|
||||
test('MIN_TOKEN_LENGTH is 3', () => {
|
||||
assert.equal(MIN_TOKEN_LENGTH, 3);
|
||||
});
|
||||
|
||||
test('EXEMPT_LABELS includes required entries', () => {
|
||||
assert.ok(Array.isArray(EXEMPT_LABELS));
|
||||
for (const label of ['priority: critical', 'pinned', 'confirmed-bug', 'needs-maintainer-review']) {
|
||||
assert.ok(EXEMPT_LABELS.includes(label), `EXEMPT_LABELS must contain "${label}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test('STOPWORDS is a Set containing common words', () => {
|
||||
assert.ok(STOPWORDS instanceof Set, 'STOPWORDS must be a Set');
|
||||
for (const word of ['the', 'and', 'bug', 'issue', 'please']) {
|
||||
assert.ok(STOPWORDS.has(word), `STOPWORDS must contain "${word}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tokenize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('tokenize', () => {
|
||||
test('lowercases, strips punctuation, drops stopwords, dedupes', () => {
|
||||
// 'CLI', 'crashes', '--flag', 'input' survive; 'Bug', 'on', 'with' are stopwords
|
||||
const tokens = tokenize('Bug: CLI crashes on --flag with input!!!');
|
||||
assert.ok(Array.isArray(tokens));
|
||||
assert.ok(tokens.includes('cli'));
|
||||
assert.ok(tokens.includes('crashes'));
|
||||
assert.ok(tokens.includes('flag'));
|
||||
assert.ok(tokens.includes('input'));
|
||||
// stopwords must be absent
|
||||
assert.ok(!tokens.includes('bug'));
|
||||
assert.ok(!tokens.includes('on'));
|
||||
assert.ok(!tokens.includes('with'));
|
||||
// all lowercase
|
||||
for (const t of tokens) assert.equal(t, t.toLowerCase());
|
||||
});
|
||||
|
||||
test('drops tokens shorter than MIN_TOKEN_LENGTH', () => {
|
||||
// 'ab' is 2 chars, 'xy' is 2 chars — both under MIN_TOKEN_LENGTH=3
|
||||
const tokens = tokenize('ab xy hello');
|
||||
assert.ok(!tokens.includes('ab'));
|
||||
assert.ok(!tokens.includes('xy'));
|
||||
assert.ok(tokens.includes('hello'));
|
||||
});
|
||||
|
||||
test('deduplicates tokens (stable first-occurrence order)', () => {
|
||||
const tokens = tokenize('crash crash crash happened happened');
|
||||
assert.equal(tokens.filter((t) => t === 'crash').length, 1);
|
||||
assert.equal(tokens.filter((t) => t === 'happened').length, 1);
|
||||
// first occurrence order: crash before happened
|
||||
assert.ok(tokens.indexOf('crash') < tokens.indexOf('happened'));
|
||||
});
|
||||
|
||||
test('emoji-only title returns empty array and does not throw', () => {
|
||||
const tokens = tokenize('🔥🔥');
|
||||
assert.ok(Array.isArray(tokens));
|
||||
assert.equal(tokens.length, 0);
|
||||
});
|
||||
|
||||
test('empty string returns empty array', () => {
|
||||
assert.deepEqual(tokenize(''), []);
|
||||
});
|
||||
|
||||
test('null returns empty array', () => {
|
||||
assert.deepEqual(tokenize(null), []);
|
||||
});
|
||||
|
||||
test('non-string number returns empty array', () => {
|
||||
assert.deepEqual(tokenize(123), []);
|
||||
});
|
||||
|
||||
test('all-stopword title returns empty array', () => {
|
||||
const tokens = tokenize('the and or but if');
|
||||
assert.deepEqual(tokens, []);
|
||||
});
|
||||
|
||||
test('complex real-world title with mixed punctuation and short words', () => {
|
||||
const tokens = tokenize('Bug: CLI crashes on --flag with空 input!!!');
|
||||
// non-ascii gets stripped along with punctuation — '空' becomes empty
|
||||
// remaining meaningful tokens should be present
|
||||
assert.ok(tokens.includes('cli'));
|
||||
assert.ok(tokens.includes('crashes'));
|
||||
assert.ok(tokens.includes('flag'));
|
||||
assert.ok(tokens.includes('input'));
|
||||
});
|
||||
|
||||
test('title with only very short words returns empty array', () => {
|
||||
// 'to', 'be', 'or', 'it' are all either stopwords or too short (<3 chars)
|
||||
const tokens = tokenize('to be or it');
|
||||
assert.deepEqual(tokens, []);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// diceSimilarity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('diceSimilarity', () => {
|
||||
test('identical token arrays return 1', () => {
|
||||
assert.equal(diceSimilarity(['foo', 'bar', 'baz'], ['foo', 'bar', 'baz']), 1);
|
||||
});
|
||||
|
||||
test('disjoint token arrays return 0', () => {
|
||||
assert.equal(diceSimilarity(['foo', 'bar'], ['baz', 'qux']), 0);
|
||||
});
|
||||
|
||||
test('partial overlap returns expected dice coefficient', () => {
|
||||
// A = {a,b,c}, B = {b,c,d}: intersection = {b,c} -> 2*2/(3+3) = 4/6 ≈ 0.6667
|
||||
const score = diceSimilarity(['a', 'b', 'c'], ['b', 'c', 'd']);
|
||||
assert.ok(Math.abs(score - (4 / 6)) < 0.001, `Expected ~0.6667, got ${score}`);
|
||||
});
|
||||
|
||||
test('empty first array returns 0', () => {
|
||||
assert.equal(diceSimilarity([], ['foo', 'bar']), 0);
|
||||
});
|
||||
|
||||
test('empty second array returns 0', () => {
|
||||
assert.equal(diceSimilarity(['foo', 'bar'], []), 0);
|
||||
});
|
||||
|
||||
test('both empty returns 0', () => {
|
||||
assert.equal(diceSimilarity([], []), 0);
|
||||
});
|
||||
|
||||
test('single common token: A={x}, B={x} -> 1', () => {
|
||||
assert.equal(diceSimilarity(['x'], ['x']), 1);
|
||||
});
|
||||
|
||||
test('single token each, different: 0', () => {
|
||||
assert.equal(diceSimilarity(['x'], ['y']), 0);
|
||||
});
|
||||
|
||||
test('treats token arrays as sets (duplicates in input do not inflate score)', () => {
|
||||
// Even if caller somehow passes duplicates, score should still be well-formed
|
||||
// A={foo,bar}, B={foo,bar}: expect 1 even with duplicates in input
|
||||
const score = diceSimilarity(['foo', 'foo', 'bar'], ['foo', 'bar', 'bar']);
|
||||
// The function works on sets internally; result should be 1
|
||||
assert.equal(score, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scoreCandidates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('scoreCandidates', () => {
|
||||
const candidates = [
|
||||
{ number: 10, title: 'CLI crashes on startup with bad config' },
|
||||
{ number: 11, title: 'Something entirely different here' },
|
||||
{ number: 12, title: 'CLI crashes with segfault on startup' },
|
||||
{ number: 13, title: 'Feature request add dark mode' },
|
||||
];
|
||||
|
||||
test('excludeNumber excludes self from results', () => {
|
||||
const results = scoreCandidates('CLI crashes on startup with bad config', candidates, { excludeNumber: 10 });
|
||||
assert.ok(!results.some((r) => r.number === 10));
|
||||
});
|
||||
|
||||
test('threshold filters out low-scoring candidates', () => {
|
||||
const results = scoreCandidates('CLI crashes on startup', candidates, { threshold: 0.9 });
|
||||
// Only very close matches should survive
|
||||
for (const r of results) {
|
||||
assert.ok(r.score >= 0.9, `Score ${r.score} for #${r.number} is below threshold`);
|
||||
}
|
||||
});
|
||||
|
||||
test('results are sorted descending by score, ascending by number on tie', () => {
|
||||
const results = scoreCandidates('CLI crashes startup config', candidates, { threshold: 0 });
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
const prev = results[i - 1];
|
||||
const curr = results[i];
|
||||
if (Math.abs(prev.score - curr.score) < 0.0001) {
|
||||
// tie-break: ascending number
|
||||
assert.ok(prev.number < curr.number, 'Tie-break should be ascending by number');
|
||||
} else {
|
||||
assert.ok(prev.score >= curr.score, 'Results should be sorted descending by score');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('limit caps the number of results', () => {
|
||||
const manyCandidates = Array.from({ length: 20 }, (_, i) => ({
|
||||
number: i + 1,
|
||||
title: `CLI crashes startup config issue ${i}`,
|
||||
}));
|
||||
const results = scoreCandidates('CLI crashes startup config', manyCandidates, { threshold: 0, limit: 3 });
|
||||
assert.ok(results.length <= 3);
|
||||
});
|
||||
|
||||
test('all-stopword newTitle returns empty array', () => {
|
||||
const results = scoreCandidates('the and or but', candidates, { threshold: 0 });
|
||||
assert.deepEqual(results, []);
|
||||
});
|
||||
|
||||
test('null candidate entries are tolerated (filtered out)', () => {
|
||||
const messyCandidates = [null, undefined, { number: 1, title: 'CLI crashes badly' }, { title: 'no number' }, { number: 2, title: null }];
|
||||
let results;
|
||||
assert.doesNotThrow(() => {
|
||||
results = scoreCandidates('CLI crashes', messyCandidates, { threshold: 0 });
|
||||
});
|
||||
// entry without number should be skipped; entry with null title should survive (tokenize(null) = [])
|
||||
assert.ok(!results.some((r) => r.number === undefined || r.number === null));
|
||||
});
|
||||
|
||||
test('each result has number, title, and score fields', () => {
|
||||
const results = scoreCandidates('CLI crashes on startup', candidates, { threshold: 0 });
|
||||
for (const r of results) {
|
||||
assert.ok('number' in r, 'result must have number');
|
||||
assert.ok('title' in r, 'result must have title');
|
||||
assert.ok('score' in r, 'result must have score');
|
||||
assert.ok(typeof r.score === 'number');
|
||||
assert.ok(r.score >= 0 && r.score <= 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('defaults: threshold=DEFAULT_THRESHOLD, limit=DEFAULT_MAX_CANDIDATES', () => {
|
||||
// With default opts, results should respect DEFAULT_THRESHOLD
|
||||
const results = scoreCandidates('CLI crashes', candidates);
|
||||
for (const r of results) {
|
||||
assert.ok(r.score >= DEFAULT_THRESHOLD);
|
||||
}
|
||||
assert.ok(results.length <= DEFAULT_MAX_CANDIDATES);
|
||||
});
|
||||
|
||||
test('candidate with missing number is skipped', () => {
|
||||
const withNoNumber = [{ title: 'CLI crashes badly' }, { number: 5, title: 'CLI crashes badly' }];
|
||||
const results = scoreCandidates('CLI crashes', withNoNumber, { threshold: 0 });
|
||||
assert.ok(!results.some((r) => r.number === undefined));
|
||||
assert.ok(results.some((r) => r.number === 5));
|
||||
});
|
||||
|
||||
test('default threshold 0.6 excludes near-miss ~0.545, but explicit 0.5 includes it', () => {
|
||||
// newTitle tokens: ['cli', 'crashes', 'startup'] (3 tokens — 'on' is stopword)
|
||||
// candidate tokens: ['cli', 'crashes', 'startup', 'mode', 'display', 'render', 'timeout'] (7 tokens)
|
||||
// intersection = 3, dice = 2*3/(3+7) = 6/10 = 0.6 exactly — adjust to get below 0.6
|
||||
// newTitle tokens: ['crashes', 'startup'] (2 tokens after removing 'cli' via excludeNumber not applicable here)
|
||||
// Use a 4-token new title and 7-token candidate with 3 shared for 6/11 ≈ 0.545
|
||||
// newTitle: 'CLI crashes startup config' → tokens: ['cli','crashes','startup','config'] (4 tokens)
|
||||
// candidate: 'CLI crashes startup mode display render timeout' → tokens: ['cli','crashes','startup','mode','display','render','timeout'] (7 tokens)
|
||||
// intersection = {cli,crashes,startup} = 3; dice = 2*3/(4+7) = 6/11 ≈ 0.5454
|
||||
const nearMissCandidate = [{ number: 99, title: 'CLI crashes startup mode display render timeout' }];
|
||||
const scoreVal = 6 / 11; // ≈ 0.5454
|
||||
assert.ok(scoreVal < 0.6, 'sanity: near-miss score is below 0.6');
|
||||
assert.ok(scoreVal > 0.5, 'sanity: near-miss score is above 0.5');
|
||||
|
||||
// Default threshold (0.6) should exclude it
|
||||
const withDefault = scoreCandidates('CLI crashes startup config', nearMissCandidate);
|
||||
assert.equal(withDefault.length, 0, 'default threshold 0.6 must exclude ~0.545 score');
|
||||
|
||||
// Explicit threshold 0.5 should include it
|
||||
const withLower = scoreCandidates('CLI crashes startup config', nearMissCandidate, { threshold: 0.5 });
|
||||
assert.equal(withLower.length, 1, 'explicit threshold 0.5 must include ~0.545 score');
|
||||
assert.ok(withLower[0].score > 0.5 && withLower[0].score < 0.6);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// renderChallengeComment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('renderChallengeComment', () => {
|
||||
const sampleCandidates = [
|
||||
{ number: 42, title: 'CLI crashes on startup', score: 0.83 },
|
||||
{ number: 7, title: 'Segfault when starting CLI', score: 0.66 },
|
||||
];
|
||||
|
||||
test('output starts with CHALLENGE_MARKER on its own line', () => {
|
||||
const output = renderChallengeComment(sampleCandidates, {});
|
||||
const firstLine = output.split('\n')[0];
|
||||
assert.equal(firstLine.trim(), CHALLENGE_MARKER);
|
||||
});
|
||||
|
||||
test('output contains each candidate number prefixed with #', () => {
|
||||
const output = renderChallengeComment(sampleCandidates, {});
|
||||
assert.ok(output.includes('#42'));
|
||||
assert.ok(output.includes('#7'));
|
||||
});
|
||||
|
||||
test('output contains each candidate title', () => {
|
||||
const output = renderChallengeComment(sampleCandidates, {});
|
||||
assert.ok(output.includes('CLI crashes on startup'));
|
||||
assert.ok(output.includes('Segfault when starting CLI'));
|
||||
});
|
||||
|
||||
test('output includes percentage similarity rounded correctly', () => {
|
||||
const output = renderChallengeComment(sampleCandidates, {});
|
||||
// 0.83 -> 83%, 0.66 -> 66%
|
||||
assert.ok(output.includes('83%'), 'Expected 83% in output');
|
||||
assert.ok(output.includes('66%'), 'Expected 66% in output');
|
||||
});
|
||||
|
||||
test('output mentions the windowHours', () => {
|
||||
const output = renderChallengeComment(sampleCandidates, { windowHours: 48 });
|
||||
assert.ok(output.includes('48'), 'Output must mention windowHours=48');
|
||||
});
|
||||
|
||||
test('output includes 👎 veto character', () => {
|
||||
const output = renderChallengeComment(sampleCandidates, {});
|
||||
assert.ok(output.includes('👎'), 'Output must include the 👎 veto emoji');
|
||||
});
|
||||
|
||||
test('output is deterministic (same input produces same output)', () => {
|
||||
const a = renderChallengeComment(sampleCandidates, {});
|
||||
const b = renderChallengeComment(sampleCandidates, {});
|
||||
assert.equal(a, b);
|
||||
});
|
||||
|
||||
test('isChallengeComment(renderChallengeComment(...)) is true', () => {
|
||||
const output = renderChallengeComment(sampleCandidates, {});
|
||||
assert.equal(isChallengeComment(output), true);
|
||||
});
|
||||
|
||||
test('uses DEFAULT_WINDOW_HOURS when windowHours not provided', () => {
|
||||
const output = renderChallengeComment(sampleCandidates, {});
|
||||
assert.ok(output.includes(String(DEFAULT_WINDOW_HOURS)));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isChallengeComment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isChallengeComment', () => {
|
||||
test('returns true when body contains CHALLENGE_MARKER', () => {
|
||||
assert.equal(isChallengeComment(`${CHALLENGE_MARKER}\nsome content`), true);
|
||||
});
|
||||
|
||||
test('returns false when body does not contain CHALLENGE_MARKER', () => {
|
||||
assert.equal(isChallengeComment('just a normal comment'), false);
|
||||
});
|
||||
|
||||
test('returns false for empty string', () => {
|
||||
assert.equal(isChallengeComment(''), false);
|
||||
});
|
||||
|
||||
test('returns false for non-string', () => {
|
||||
assert.equal(isChallengeComment(null), false);
|
||||
assert.equal(isChallengeComment(undefined), false);
|
||||
assert.equal(isChallengeComment(42), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hasExemptLabel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('hasExemptLabel', () => {
|
||||
test('returns true for string label matching an exempt label', () => {
|
||||
assert.equal(hasExemptLabel(['priority: critical']), true);
|
||||
});
|
||||
|
||||
test('returns true for object label {name} matching an exempt label', () => {
|
||||
assert.equal(hasExemptLabel([{ name: 'pinned' }]), true);
|
||||
});
|
||||
|
||||
test('returns true for confirmed-bug', () => {
|
||||
assert.equal(hasExemptLabel(['confirmed-bug']), true);
|
||||
});
|
||||
|
||||
test('returns true for needs-maintainer-review', () => {
|
||||
assert.equal(hasExemptLabel(['needs-maintainer-review']), true);
|
||||
});
|
||||
|
||||
test('returns false for non-exempt string label', () => {
|
||||
assert.equal(hasExemptLabel(['bug']), false);
|
||||
});
|
||||
|
||||
test('returns false for empty array', () => {
|
||||
assert.equal(hasExemptLabel([]), false);
|
||||
});
|
||||
|
||||
test('returns false for non-exempt object label', () => {
|
||||
assert.equal(hasExemptLabel([{ name: 'enhancement' }]), false);
|
||||
});
|
||||
|
||||
test('returns true when one of multiple labels is exempt', () => {
|
||||
assert.equal(hasExemptLabel(['bug', 'priority: critical', 'enhancement']), true);
|
||||
});
|
||||
|
||||
test('returns true for mixed string and object labels', () => {
|
||||
assert.equal(hasExemptLabel(['bug', { name: 'pinned' }]), true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shouldClose
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('shouldClose', () => {
|
||||
const BASE_NOW = new Date('2026-01-01T12:00:00Z');
|
||||
const CHALLENGE_CREATED_RECENT = new Date('2026-01-01T11:30:00Z'); // 30 min ago
|
||||
const CHALLENGE_CREATED_OLD = new Date('2026-01-01T09:00:00Z'); // 3 hours ago, >DEFAULT
|
||||
const CHALLENGE_CREATED_25H = new Date('2025-12-31T11:00:00Z'); // 25 hours ago
|
||||
|
||||
const baseChallenge = {
|
||||
createdAt: CHALLENGE_CREATED_OLD,
|
||||
downvoted: false,
|
||||
};
|
||||
|
||||
test('exempt-label short-circuits even when overdue', () => {
|
||||
const result = shouldClose({
|
||||
now: BASE_NOW,
|
||||
labels: ['priority: critical'],
|
||||
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_25H },
|
||||
laterUserComments: 0,
|
||||
});
|
||||
assert.equal(result.close, false);
|
||||
assert.equal(result.reason, 'exempt-label');
|
||||
});
|
||||
|
||||
test('no challenge comment returns no-challenge-comment', () => {
|
||||
const result = shouldClose({
|
||||
now: BASE_NOW,
|
||||
labels: [],
|
||||
challengeComment: null,
|
||||
laterUserComments: 0,
|
||||
});
|
||||
assert.equal(result.close, false);
|
||||
assert.equal(result.reason, 'no-challenge-comment');
|
||||
});
|
||||
|
||||
test('no challenge comment with undefined challengeComment returns no-challenge-comment', () => {
|
||||
const result = shouldClose({
|
||||
now: BASE_NOW,
|
||||
labels: [],
|
||||
laterUserComments: 0,
|
||||
});
|
||||
assert.equal(result.close, false);
|
||||
assert.equal(result.reason, 'no-challenge-comment');
|
||||
});
|
||||
|
||||
test('downvoted challenge returns vetoed', () => {
|
||||
const result = shouldClose({
|
||||
now: BASE_NOW,
|
||||
labels: [],
|
||||
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_25H, downvoted: true },
|
||||
laterUserComments: 0,
|
||||
});
|
||||
assert.equal(result.close, false);
|
||||
assert.equal(result.reason, 'vetoed');
|
||||
});
|
||||
|
||||
test('laterUserComments > 0 returns reporter-responded', () => {
|
||||
const result = shouldClose({
|
||||
now: BASE_NOW,
|
||||
labels: [],
|
||||
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_25H },
|
||||
laterUserComments: 1,
|
||||
});
|
||||
assert.equal(result.close, false);
|
||||
assert.equal(result.reason, 'reporter-responded');
|
||||
});
|
||||
|
||||
test('within window (1h age, 24h window) returns within-window', () => {
|
||||
const result = shouldClose({
|
||||
now: BASE_NOW,
|
||||
labels: [],
|
||||
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_RECENT },
|
||||
laterUserComments: 0,
|
||||
});
|
||||
assert.equal(result.close, false);
|
||||
assert.equal(result.reason, 'within-window');
|
||||
});
|
||||
|
||||
test('age > windowHours, no reply, not vetoed, not exempt -> close:true duplicate-no-response', () => {
|
||||
const result = shouldClose({
|
||||
now: BASE_NOW,
|
||||
labels: [],
|
||||
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_25H },
|
||||
laterUserComments: 0,
|
||||
});
|
||||
assert.equal(result.close, true);
|
||||
assert.equal(result.reason, 'duplicate-no-response');
|
||||
});
|
||||
|
||||
test('createdAt as ISO string works', () => {
|
||||
const result = shouldClose({
|
||||
now: BASE_NOW,
|
||||
labels: [],
|
||||
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_25H.toISOString() },
|
||||
laterUserComments: 0,
|
||||
});
|
||||
assert.equal(result.close, true);
|
||||
assert.equal(result.reason, 'duplicate-no-response');
|
||||
});
|
||||
|
||||
test('createdAt as Date object works', () => {
|
||||
const result = shouldClose({
|
||||
now: BASE_NOW,
|
||||
labels: [],
|
||||
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_25H },
|
||||
laterUserComments: 0,
|
||||
});
|
||||
assert.equal(result.close, true);
|
||||
assert.equal(result.reason, 'duplicate-no-response');
|
||||
});
|
||||
|
||||
test('createdAt as ms-number works', () => {
|
||||
const result = shouldClose({
|
||||
now: BASE_NOW,
|
||||
labels: [],
|
||||
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_25H.getTime() },
|
||||
laterUserComments: 0,
|
||||
});
|
||||
assert.equal(result.close, true);
|
||||
assert.equal(result.reason, 'duplicate-no-response');
|
||||
});
|
||||
|
||||
test('now as ms-number works', () => {
|
||||
const result = shouldClose({
|
||||
now: BASE_NOW.getTime(),
|
||||
labels: [],
|
||||
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_25H },
|
||||
laterUserComments: 0,
|
||||
});
|
||||
assert.equal(result.close, true);
|
||||
assert.equal(result.reason, 'duplicate-no-response');
|
||||
});
|
||||
|
||||
test('custom windowHours is respected', () => {
|
||||
// Challenge created 3 hours ago; with windowHours=1 it should be overdue
|
||||
const threeHoursAgo = new Date(BASE_NOW.getTime() - 3 * 3600000);
|
||||
const result = shouldClose({
|
||||
now: BASE_NOW,
|
||||
labels: [],
|
||||
challengeComment: { ...baseChallenge, createdAt: threeHoursAgo },
|
||||
laterUserComments: 0,
|
||||
windowHours: 1,
|
||||
});
|
||||
assert.equal(result.close, true);
|
||||
assert.equal(result.reason, 'duplicate-no-response');
|
||||
});
|
||||
|
||||
test('exempt check still short-circuits when within window', () => {
|
||||
const result = shouldClose({
|
||||
now: BASE_NOW,
|
||||
labels: ['confirmed'],
|
||||
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_RECENT },
|
||||
laterUserComments: 0,
|
||||
});
|
||||
assert.equal(result.close, false);
|
||||
assert.equal(result.reason, 'exempt-label');
|
||||
});
|
||||
|
||||
test('createdAt as unparseable string returns invalid-timestamp (fail-safe)', () => {
|
||||
const result = shouldClose({
|
||||
now: BASE_NOW,
|
||||
labels: [],
|
||||
challengeComment: { ...baseChallenge, createdAt: 'not-a-date' },
|
||||
laterUserComments: 0,
|
||||
});
|
||||
assert.equal(result.close, false);
|
||||
assert.equal(result.reason, 'invalid-timestamp');
|
||||
});
|
||||
|
||||
test('now as NaN returns invalid-timestamp (fail-safe)', () => {
|
||||
const result = shouldClose({
|
||||
now: NaN,
|
||||
labels: [],
|
||||
challengeComment: { ...baseChallenge, createdAt: CHALLENGE_CREATED_25H },
|
||||
laterUserComments: 0,
|
||||
});
|
||||
assert.equal(result.close, false);
|
||||
assert.equal(result.reason, 'invalid-timestamp');
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ const EXPECTED_TABLE = [
|
||||
{ runtime: 'codex', installSurface: 'codex-toml', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'copilot', installSurface: 'copilot-instructions', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'cline', installSurface: 'cline-rules', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'cursor', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'cursor', installSurface: 'cursor-hooks-json', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'windsurf', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'trae', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'kimi', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
@@ -161,8 +161,8 @@ describe('installSurface correctness', () => {
|
||||
assert.strictEqual(resolveRuntimeConfigIntent('cline').installSurface, 'cline-rules');
|
||||
});
|
||||
|
||||
test('cursor -> "profile-marker-only"', () => {
|
||||
assert.strictEqual(resolveRuntimeConfigIntent('cursor').installSurface, 'profile-marker-only');
|
||||
test('cursor -> "cursor-hooks-json"', () => {
|
||||
assert.strictEqual(resolveRuntimeConfigIntent('cursor').installSurface, 'cursor-hooks-json');
|
||||
});
|
||||
|
||||
test('windsurf -> "profile-marker-only"', () => {
|
||||
@@ -242,10 +242,11 @@ describe('INSTALL_SURFACES export', () => {
|
||||
'codex-toml',
|
||||
'copilot-instructions',
|
||||
'cline-rules',
|
||||
'cursor-hooks-json',
|
||||
'profile-marker-only',
|
||||
]);
|
||||
|
||||
test('INSTALL_SURFACES contains exactly the 5 surface strings', () => {
|
||||
test('INSTALL_SURFACES contains exactly the 6 surface strings', () => {
|
||||
assert.deepStrictEqual(new Set(INSTALL_SURFACES), EXPECTED_SURFACES);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user