diff --git a/tests/cursor-dispatch-upgrade.test.cjs b/tests/cursor-dispatch-upgrade.test.cjs new file mode 100644 index 000000000..f99d8d608 --- /dev/null +++ b/tests/cursor-dispatch-upgrade.test.cjs @@ -0,0 +1,71 @@ +'use strict'; + +/** + * cursor dispatch UPGRADE — ADR-1239 / #2089 AC4b. + * + * Proves GSD's wave-based execution drives Cursor's native named/background + * nested subagent dispatch (background:true, backgroundDispatch:true, + * nested:true, maxDepth:2) instead of flattening to inline sequential calls. + * + * Cite: + * https://cursor.com/docs/subagents — named + background dispatch + * https://cursor.com/docs/sdk/typescript — nested subagent depth-2 constraint + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { shouldFlattenDispatch } = require('../gsd-core/bin/lib/host-integration.cjs'); + +const CUR_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cursor', 'capability.json'), 'utf8'), +); +const CUR_DISPATCH = CUR_CAP.runtime.hostIntegration.dispatch; + +// -- AC4b: cursor dispatch axes ---------------------------------------------- + +test('cursor dispatch declares namedDispatch + nested + background + backgroundDispatch', () => { + assert.equal(CUR_DISPATCH.namedDispatch, true, + 'cite https://cursor.com/docs/subagents — named subagent invocation'); + assert.equal(CUR_DISPATCH.nested, true, + 'cite https://cursor.com/docs/sdk/typescript — nested subagents'); + assert.equal(CUR_DISPATCH.background, true, + 'cite https://cursor.com/docs/subagents — background dispatch'); + assert.equal(CUR_DISPATCH.backgroundDispatch, true, + 'cite https://cursor.com/docs/subagents FAQ — subagents can launch child subagents'); +}); + +test('cursor dispatch respects maxDepth: 2 (the documented constraint)', () => { + assert.equal(CUR_DISPATCH.maxDepth, 2, + 'cite https://cursor.com/docs/sdk/typescript — "a subagent launched by another subagent can\'t launch further"'); +}); + +// -- AC4b: shouldFlattenDispatch returns false (NOT force-flattened) ---------- + +test('shouldFlattenDispatch(cursor) is false — GSD uses native background dispatch', () => { + assert.equal(shouldFlattenDispatch(CUR_DISPATCH), false, + 'cursor has background:true + backgroundDispatch:true → GSD must NOT force-flatten'); +}); + +test('pre-upgrade cursor axes (background:false) DID force-flatten', () => { + const preUpgrade = { ...CUR_DISPATCH, background: false, backgroundDispatch: 'undocumented' }; + assert.equal(shouldFlattenDispatch(preUpgrade), true, + 'pre-upgrade cursor (no background dispatch) was force-flattened — the behavioral change #2089 lands'); +}); + +test('shouldFlattenDispatch is true when backgroundDispatch is false (depth-2 but no bg dispatch)', () => { + const noBgDispatch = { ...CUR_DISPATCH, backgroundDispatch: false }; + assert.equal(shouldFlattenDispatch(noBgDispatch), true, + 'background without backgroundDispatch still flattens (the #853 rule)'); +}); + +// -- AC4b: boundary — maxDepth 2 is the discriminator vs unbounded ----------- + +test('maxDepth 2 is the documented constraint (not -1 unbounded)', () => { + assert.notEqual(CUR_DISPATCH.maxDepth, -1, + 'cursor is NOT unbounded — depth-2 is the documented hard limit'); + assert.ok(CUR_DISPATCH.maxDepth > 0 && CUR_DISPATCH.maxDepth <= 2, + 'maxDepth must be a positive integer ≤ 2 per cursor docs'); +}); diff --git a/tests/cursor-hook-bus-upgrade.test.cjs b/tests/cursor-hook-bus-upgrade.test.cjs new file mode 100644 index 000000000..23ca22e4d --- /dev/null +++ b/tests/cursor-hook-bus-upgrade.test.cjs @@ -0,0 +1,181 @@ +'use strict'; + +/** + * cursor hook-bus UPGRADE — ADR-1239 / #2089 AC4a. + * + * Proves the expanded hook-bus coverage: GSD registers for subagentStart, + * subagentStop, preToolUse, and stop IN ADDITION to the baseline sessionStart + * and postToolUse. Cite: https://cursor.com/docs/hooks + * + * Tests the descriptor-driven adapter module (pure) + the reconcile behavior + * (all 6 managed events in the generated hooks.json). + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const { + CURSOR_HOOK_EVENTS, + CURSOR_EVENT_SCRIPT_MAP, + resolveManagedHookEvents, + resolveHookScripts, +} = require('../gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs'); + +const { + reconcileCursorHooksJson, + GSD_CURSOR_HOOK_MARKER, +} = require('../bin/install.js'); + +const { cleanup } = require('./helpers.cjs'); + +const CUR_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cursor', 'capability.json'), 'utf8'), +); + +const EXPECTED_EVENTS = [ + 'sessionStart', + 'postToolUse', + 'preToolUse', + 'stop', + 'subagentStart', + 'subagentStop', +]; + +// -- AC4a: the adapter declares all 6 managed events ------------------------- + +test('CURSOR_HOOK_EVENTS contains all 6 managed events', () => { + for (const ev of EXPECTED_EVENTS) { + assert.ok(CURSOR_HOOK_EVENTS.includes(ev), + `CURSOR_HOOK_EVENTS must include ${ev}`); + } + assert.equal(CURSOR_HOOK_EVENTS.length, 6, + 'exactly 6 managed events (no extras)'); +}); + +test('CURSOR_EVENT_SCRIPT_MAP maps every event to a script', () => { + for (const ev of EXPECTED_EVENTS) { + const script = CURSOR_EVENT_SCRIPT_MAP[ev]; + assert.ok(typeof script === 'string' && script.endsWith('.js'), + `${ev} must map to a .js script, got: ${script}`); + } +}); + +test('descriptor managedHookEvents matches the adapter event list', () => { + const declared = CUR_CAP.runtime.hostBehaviors.managedHookEvents; + assert.deepEqual(declared.sort(), [...EXPECTED_EVENTS].sort(), + 'descriptor managedHookEvents must match the 6-event managed set'); +}); + +// -- AC4a: resolveManagedHookEvents + resolveHookScripts --------------------- + +test('resolveManagedHookEvents returns all 6 from the descriptor list', () => { + const resolved = resolveManagedHookEvents(CUR_CAP.runtime.hostBehaviors.managedHookEvents); + assert.equal(resolved.length, 6); + for (const ev of EXPECTED_EVENTS) { + assert.ok(resolved.includes(ev), `resolveManagedHookEvents must include ${ev}`); + } +}); + +test('resolveManagedHookEvents filters unknown events (fail-closed)', () => { + const resolved = resolveManagedHookEvents(['sessionStart', 'bogusEvent', 'stop']); + assert.deepEqual([...resolved].sort(), ['sessionStart', 'stop']); +}); + +test('resolveManagedHookEvents falls back to full set when descriptor is empty', () => { + const resolved = resolveManagedHookEvents(null); + assert.equal(resolved.length, 6); +}); + +test('resolveHookScripts returns a script for every managed event', () => { + const scripts = resolveHookScripts(EXPECTED_EVENTS); + assert.equal(scripts.length, 6); + for (const s of scripts) { + assert.ok(s.startsWith('gsd-cursor-') && s.endsWith('.js'), + `script must follow gsd-cursor-*.js convention: ${s}`); + } +}); + +// -- AC4a: hook scripts exist on disk --------------------------------------- + +test('all 6 hook scripts exist under hooks/', () => { + for (const ev of EXPECTED_EVENTS) { + const script = CURSOR_EVENT_SCRIPT_MAP[ev]; + const scriptPath = path.join(__dirname, '..', 'hooks', script); + assert.ok(fs.existsSync(scriptPath), + `hook script must exist: hooks/${script} (event: ${ev})`); + } +}); + +// -- AC4a: reconcile generates hooks.json with all 6 events ------------------ + +test('reconcileCursorHooksJson writes all 6 managed events into hooks.json', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cursor-hook-bus-')); + t.after(() => cleanup(tmpDir)); + try { + const hooksJsonPath = path.join(tmpDir, 'hooks.json'); + const managedEntries = {}; + for (const ev of EXPECTED_EVENTS) { + managedEntries[ev] = { + type: 'command', + command: `node /fake/${ev}.js`, + [GSD_CURSOR_HOOK_MARKER]: true, + }; + } + const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries); + assert.ok(result.changed, 'first write must report changed=true'); + + const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + const hookTable = written.hooks; + assert.ok(hookTable && typeof hookTable === 'object'); + for (const ev of EXPECTED_EVENTS) { + assert.ok(Array.isArray(hookTable[ev]), + `hooks.json must have a ${ev} array`); + assert.equal(hookTable[ev].length, 1, + `${ev} must have exactly 1 managed entry`); + assert.equal(hookTable[ev][0][GSD_CURSOR_HOOK_MARKER], true, + `${ev} entry must carry the GSD managed marker`); + } + } finally { + cleanup(tmpDir); + } +}); + +test('reconcileCursorHooksJson preserves user entries across all 6 events', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cursor-hook-bus-')); + t.after(() => cleanup(tmpDir)); + try { + const hooksJsonPath = path.join(tmpDir, 'hooks.json'); + // Seed with user-owned entries in two events. + const seed = { + version: 1, + hooks: { + sessionStart: [{ type: 'command', command: 'user-start.sh' }], + preToolUse: [{ type: 'command', command: 'user-pre.sh' }], + }, + }; + fs.writeFileSync(hooksJsonPath, JSON.stringify(seed, null, 2) + '\n'); + + const managedEntries = {}; + for (const ev of EXPECTED_EVENTS) { + managedEntries[ev] = { + type: 'command', + command: `node /gsd/${ev}.js`, + [GSD_CURSOR_HOOK_MARKER]: true, + }; + } + reconcileCursorHooksJson(hooksJsonPath, managedEntries); + + const written = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + // sessionStart: 1 user + 1 managed + assert.equal(written.hooks.sessionStart.length, 2); + // preToolUse: 1 user + 1 managed + assert.equal(written.hooks.preToolUse.length, 2); + // postToolUse: 1 managed only + assert.equal(written.hooks.postToolUse.length, 1); + } finally { + cleanup(tmpDir); + } +}); diff --git a/tests/cursor-imperative-reference.test.cjs b/tests/cursor-imperative-reference.test.cjs new file mode 100644 index 000000000..a13f2970a --- /dev/null +++ b/tests/cursor-imperative-reference.test.cjs @@ -0,0 +1,126 @@ +// allow-test-rule: AC2 requires asserting no `runtime === 'cursor'` string-equality branch remains in bin/install.js/src — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2089) +'use strict'; + +/** + * cursor imperative reference host — ADR-1239 Phase D / #2089 (EoS/cursor). + * + * Proves cursor is driven through the PUBLIC Host-Integration Interface (the + * imperative adapter), that its negotiated axes classify + negotiate correctly, + * that negotiation fails CLOSED on a corrupted descriptor, that the Context7- + * verified dispatch UPGRADE (named/background nested subagents) changes + * `shouldFlattenDispatch`, and that the migration retired the hardcoded + * `runtime === 'cursor'` / `isCursor` branches (folded into descriptor-driven + * `runtime.hostBehaviors`). + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs'); +const { + profileOf, + negotiateHostCapabilities, + shouldFlattenDispatch, + PROFILE_BASELINES, + UNDOCUMENTED, +} = require('../gsd-core/bin/lib/host-integration.cjs'); + +const CUR_CAP = JSON.parse( + fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cursor', 'capability.json'), 'utf8'), +); +const CUR_AXES = CUR_CAP.runtime.hostIntegration; + +// -- AC2: driven through the public interface (imperative adapter) ----------- + +test('createImperativeAdapter classifies cursor as imperative + composes the registry', () => { + const adapter = createImperativeAdapter({ runtime: 'cursor' }); + assert.equal(adapter.kind, 'imperative'); + assert.equal(adapter.runtime, 'cursor'); + assert.ok(adapter.registry && typeof adapter.registry === 'object'); + assert.equal(typeof adapter.install, 'function'); + assert.equal(typeof adapter.uninstall, 'function'); +}); + +test('cursor axes classify as the programmatic-cli reference profile', () => { + assert.equal(profileOf(CUR_AXES), 'programmatic-cli'); +}); + +// -- AC3: all axes populated + validated ------------------------------------- + +test('cursor descriptor declares all 8 axes + 6 dispatch sub-axes (no undocumented)', () => { + assert.equal(CUR_AXES.embeddingMode, 'imperative'); + assert.equal(CUR_AXES.commandSurface, 'slash-file'); + assert.equal(CUR_AXES.modelMode, 'passive'); + assert.equal(CUR_AXES.hookBus, 'host'); + assert.equal(CUR_AXES.stateIO, 'filesystem'); + assert.equal(CUR_AXES.transport, 'mcp'); + assert.equal(CUR_AXES.runtime, 'node'); + const d = CUR_AXES.dispatch; + assert.equal(d.namedDispatch, true); + assert.equal(d.nested, true); + assert.equal(d.maxDepth, 2); + assert.equal(d.background, true); + assert.equal(d.subagentToolkit, 'full'); + assert.equal(d.backgroundDispatch, true); +}); + +// -- AC4b: the Context3-verified dispatch UPGRADE (named/background nested) --- + +test('cursor descriptor declares background dispatch true/true + nested + maxDepth 2', () => { + assert.equal(CUR_AXES.dispatch.background, true); + assert.equal(CUR_AXES.dispatch.backgroundDispatch, true); + assert.equal(CUR_AXES.dispatch.nested, true); + assert.equal(CUR_AXES.dispatch.maxDepth, 2, 'cite https://cursor.com/docs/sdk/typescript'); +}); + +test('dispatch UPGRADE changes shouldFlattenDispatch: false now (may background), true for pre-upgrade axes', () => { + assert.equal(shouldFlattenDispatch(CUR_AXES.dispatch), false, + 'with background:true+backgroundDispatch:true, GSD must NOT force-flatten cursor dispatch'); + const preUpgrade = { ...CUR_AXES.dispatch, background: false, backgroundDispatch: 'undocumented' }; + assert.equal(shouldFlattenDispatch(preUpgrade), true, + 'pre-upgrade (background:false) cursor was force-flattened — this is the behavioral change #2089 lands'); +}); + +// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------ + +test('negotiateHostCapabilities never throws for cursor, even fully corrupted', () => { + assert.doesNotThrow(() => negotiateHostCapabilities({})); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...CUR_AXES, embeddingMode: UNDOCUMENTED })); + assert.doesNotThrow(() => negotiateHostCapabilities({ ...CUR_AXES, embeddingMode: 'future-unknown' })); +}); + +test('a partial/empty cursor descriptor degrades to the safe floor, not the programmatic-cli baseline', () => { + const result = negotiateHostCapabilities({}); + assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed'); + assert.equal(result.effective.hookBus, 'none'); + assert.notDeepEqual(result.effective, PROFILE_BASELINES['programmatic-cli']); + assert.ok(result.warnings.length > 0); +}); + +// -- AC2: the hardcoded branches are retired --------------------------------- + +test('cursor descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => { + const hb = CUR_CAP.runtime.hostBehaviors; + assert.ok(hb && typeof hb === 'object'); + assert.equal(hb.reapplyCommand, 'gsd-update --reapply (mention the skill name)'); + assert.equal(hb.frontmatterDialect, 'cursor'); + assert.equal(hb.hooksJsonSurface, true); + assert.equal(hb.skipSharedHooksInstall, true); + assert.equal(hb.reportCommandsDir, true); + assert.ok(Array.isArray(hb.managedHookEvents) && hb.managedHookEvents.length >= 6, + 'managedHookEvents must list at least 6 events (AC4a)'); +}); + +test('no `runtime === "cursor"` string-equality branch remains in the install source (AC2)', () => { + const strip = (src) => src + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/[^\r\n]*/g, '') + .replace(/`[^`]*`/g, ''); + for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts']) { + const src = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8'); + const offenders = strip(src).match(/runtime\s*[!=]==\s*'cursor'/g) || []; + assert.deepEqual(offenders, [], `AC2: no hardcoded runtime==='cursor' branch may remain in ${rel}; found: ${offenders.join(', ')}`); + } +});