test(#2089): add cursor EoS migration test scaffolding (red)

This commit is contained in:
Tom Boucher
2026-07-09 00:21:28 -04:00
parent 96a6cf7160
commit c5e5211db0
3 changed files with 378 additions and 0 deletions

View File

@@ -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');
});

View File

@@ -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);
}
});

View File

@@ -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(', ')}`);
}
});