mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-18 22:13:45 +02:00
700 lines
25 KiB
JavaScript
700 lines
25 KiB
JavaScript
/**
|
|
* Contract tests for Cursor's event-level hook dispatchers.
|
|
*
|
|
* Issue #2419: Cursor installs must expose one command per event, preserve
|
|
* blocking exit codes while dispatching multiple checks, and resolve shared
|
|
* runtime modules from the installed .cursor/ tree.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const assert = require('assert');
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
|
|
const REPO_ROOT = path.join(__dirname, '..', '..');
|
|
const HOOKS_CONFIG = path.join(REPO_ROOT, '.cursor', 'hooks.json');
|
|
const BEFORE_SHELL = path.join(REPO_ROOT, '.cursor', 'hooks', 'before-shell-execution.js');
|
|
const STOP = path.join(REPO_ROOT, '.cursor', 'hooks', 'stop.js');
|
|
const BEFORE_MCP = path.join(REPO_ROOT, '.cursor', 'hooks', 'before-mcp-execution.js');
|
|
const AFTER_MCP = path.join(REPO_ROOT, '.cursor', 'hooks', 'after-mcp-execution.js');
|
|
const AFTER_SHELL = path.join(REPO_ROOT, '.cursor', 'hooks', 'after-shell-execution.js');
|
|
const AFTER_TAB_EDIT = path.join(REPO_ROOT, '.cursor', 'hooks', 'after-tab-file-edit.js');
|
|
const BEFORE_READ = path.join(REPO_ROOT, '.cursor', 'hooks', 'before-read-file.js');
|
|
const BEFORE_PROMPT = path.join(REPO_ROOT, '.cursor', 'hooks', 'before-submit-prompt.js');
|
|
const BEFORE_TAB_READ = path.join(REPO_ROOT, '.cursor', 'hooks', 'before-tab-file-read.js');
|
|
const PRE_COMPACT = path.join(REPO_ROOT, '.cursor', 'hooks', 'pre-compact.js');
|
|
const SUBAGENT_START = path.join(REPO_ROOT, '.cursor', 'hooks', 'subagent-start.js');
|
|
const SUBAGENT_STOP = path.join(REPO_ROOT, '.cursor', 'hooks', 'subagent-stop.js');
|
|
const AFTER_FILE_EDIT = path.join(REPO_ROOT, '.cursor', 'hooks', 'after-file-edit.js');
|
|
const { createStopFormatTypecheckOptions } = require('../../.cursor/hooks/adapter');
|
|
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
console.log(` ✓ ${name}`);
|
|
return true;
|
|
} catch (error) {
|
|
console.log(` ✗ ${name}`);
|
|
console.log(` ${error.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function runHook(scriptPath, input, env = {}, cwd = REPO_ROOT) {
|
|
const raw = typeof input === 'string' ? input : JSON.stringify(input);
|
|
const childEnv = {
|
|
...process.env,
|
|
ECC_HOOK_PROFILE: 'standard',
|
|
...env,
|
|
};
|
|
if (!Object.hasOwn(env, 'ECC_HOOKS_ENABLED')) {
|
|
delete childEnv.ECC_HOOKS_ENABLED;
|
|
}
|
|
if (!Object.hasOwn(env, 'ECC_DISABLED_HOOKS')) {
|
|
delete childEnv.ECC_DISABLED_HOOKS;
|
|
}
|
|
if (!Object.hasOwn(env, 'CLAUDE_SESSION_ID')) {
|
|
delete childEnv.CLAUDE_SESSION_ID;
|
|
}
|
|
if (!Object.hasOwn(env, 'ECC_SESSION_ID')) {
|
|
delete childEnv.ECC_SESSION_ID;
|
|
}
|
|
for (const key of [
|
|
'CLAUDE_PLUGIN_OPTION_HOOKS_ENABLED',
|
|
'CLAUDE_PLUGIN_OPTION_HOOK_PROFILE',
|
|
'ECC_HOOK_CONFIG',
|
|
'CLAUDE_PLUGIN_ROOT',
|
|
'ECC_PLUGIN_ROOT',
|
|
]) {
|
|
if (!Object.hasOwn(env, key)) {
|
|
delete childEnv[key];
|
|
}
|
|
}
|
|
return spawnSync(process.execPath, [scriptPath], {
|
|
cwd,
|
|
env: childEnv,
|
|
input: raw,
|
|
encoding: 'utf8',
|
|
timeout: 30000,
|
|
});
|
|
}
|
|
|
|
function accumulatorPath(sessionId, cwd) {
|
|
const raw = sessionId
|
|
|| crypto.createHash('sha1').update(cwd).digest('hex').slice(0, 12);
|
|
const safeId = raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
|
|
return path.join(os.tmpdir(), `ecc-edited-${safeId}.txt`);
|
|
}
|
|
|
|
function writeFile(filePath, content) {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
}
|
|
|
|
function createInstalledCursorFixture(options = {}) {
|
|
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-cursor-dispatcher-'));
|
|
const cursorRoot = path.join(projectRoot, '.cursor');
|
|
const hooksRoot = path.join(cursorRoot, 'hooks');
|
|
const scriptsRoot = path.join(cursorRoot, 'scripts');
|
|
|
|
fs.mkdirSync(hooksRoot, { recursive: true });
|
|
for (const fileName of [
|
|
'adapter.js',
|
|
'after-file-edit.js',
|
|
'before-shell-execution.js',
|
|
'session-start.js',
|
|
'stop.js',
|
|
]) {
|
|
fs.copyFileSync(
|
|
path.join(REPO_ROOT, '.cursor', 'hooks', fileName),
|
|
path.join(hooksRoot, fileName)
|
|
);
|
|
}
|
|
|
|
fs.mkdirSync(path.join(scriptsRoot, 'lib'), { recursive: true });
|
|
if (options.includeHookFlags !== false) {
|
|
fs.copyFileSync(
|
|
path.join(REPO_ROOT, 'scripts', 'lib', 'hook-flags.js'),
|
|
path.join(scriptsRoot, 'lib', 'hook-flags.js')
|
|
);
|
|
}
|
|
writeFile(
|
|
path.join(scriptsRoot, 'lib', 'shell-split.js'),
|
|
"module.exports = { splitShellSegments(value) { return [value]; } };\n"
|
|
);
|
|
writeFile(
|
|
path.join(scriptsRoot, 'hooks', 'block-no-verify.js'),
|
|
[
|
|
"'use strict';",
|
|
"const fs = require('fs');",
|
|
"const command = JSON.parse(fs.readFileSync(0, 'utf8')).tool_input.command;",
|
|
"if (command.includes('--no-verify')) {",
|
|
" process.stderr.write('BLOCKED: installed runtime\\n');",
|
|
' process.exit(2);',
|
|
'}',
|
|
'',
|
|
].join('\n')
|
|
);
|
|
writeFile(
|
|
path.join(scriptsRoot, 'hooks', 'pre-bash-dev-server-block.js'),
|
|
"process.stdout.write(require('fs').readFileSync(0, 'utf8'));\n"
|
|
);
|
|
writeFile(
|
|
path.join(scriptsRoot, 'hooks', 'stop-format-typecheck.js'),
|
|
[
|
|
"'use strict';",
|
|
"const fs = require('fs');",
|
|
"fs.appendFileSync(process.env.CURSOR_HOOK_LOG, `stop-format-typecheck:${process.env.ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS}\\n`);",
|
|
"process.stdout.write(require('fs').readFileSync(0, 'utf8'));",
|
|
'',
|
|
].join('\n')
|
|
);
|
|
writeFile(
|
|
path.join(scriptsRoot, 'hooks', 'session-end.js'),
|
|
[
|
|
"'use strict';",
|
|
"const fs = require('fs');",
|
|
"fs.appendFileSync(process.env.CURSOR_HOOK_LOG, 'session-end\\n');",
|
|
"process.stdout.write(require('fs').readFileSync(0, 'utf8'));",
|
|
'',
|
|
].join('\n')
|
|
);
|
|
writeFile(
|
|
path.join(scriptsRoot, 'hooks', 'post-edit-accumulator.js'),
|
|
[
|
|
"'use strict';",
|
|
"const fs = require('fs');",
|
|
"fs.appendFileSync(process.env.CURSOR_HOOK_LOG, 'post-edit-accumulator\\n');",
|
|
"process.stdout.write(require('fs').readFileSync(0, 'utf8'));",
|
|
'',
|
|
].join('\n')
|
|
);
|
|
writeFile(
|
|
path.join(scriptsRoot, 'hooks', 'post-edit-console-warn.js'),
|
|
"process.stdout.write(require('fs').readFileSync(0, 'utf8'));\n"
|
|
);
|
|
writeFile(
|
|
path.join(scriptsRoot, 'hooks', 'design-quality-check.js'),
|
|
"process.stdout.write(require('fs').readFileSync(0, 'utf8'));\n"
|
|
);
|
|
writeFile(
|
|
path.join(scriptsRoot, 'hooks', 'cursor-session-env.js'),
|
|
"process.stdout.write(JSON.stringify({ env: { ECC_AGENT_DATA_HOME: '/tmp/cursor-ecc' }, additional_context: 'cursor env' }));\n"
|
|
);
|
|
writeFile(
|
|
path.join(scriptsRoot, 'hooks', 'session-start.js'),
|
|
[
|
|
"const fs = require('fs');",
|
|
"const input = JSON.parse(fs.readFileSync(0, 'utf8'));",
|
|
"if (input.hook_event_name !== 'SessionStart' || input.source !== 'startup') process.exit(3);",
|
|
"if (process.env.ECC_AGENT_DATA_HOME !== '/tmp/cursor-ecc') process.exit(4);",
|
|
"process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'shared context' } }));",
|
|
'',
|
|
].join('\n')
|
|
);
|
|
|
|
return {
|
|
projectRoot,
|
|
beforeShell: path.join(hooksRoot, 'before-shell-execution.js'),
|
|
afterFileEdit: path.join(hooksRoot, 'after-file-edit.js'),
|
|
sessionStart: path.join(hooksRoot, 'session-start.js'),
|
|
stop: path.join(hooksRoot, 'stop.js'),
|
|
logPath: path.join(projectRoot, 'hook.log'),
|
|
};
|
|
}
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
console.log('\nCursor hook dispatcher contract');
|
|
console.log('-'.repeat(55));
|
|
|
|
if (test('registers at most one command for every Cursor event', () => {
|
|
const config = JSON.parse(fs.readFileSync(HOOKS_CONFIG, 'utf8'));
|
|
for (const [eventName, entries] of Object.entries(config.hooks)) {
|
|
assert.strictEqual(
|
|
entries.length,
|
|
1,
|
|
`${eventName} must use one event-level dispatcher`
|
|
);
|
|
}
|
|
assert.strictEqual(
|
|
config.hooks.beforeShellExecution[0].command,
|
|
'node .cursor/hooks/before-shell-execution.js'
|
|
);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('Cursor sessionStart scaffold points at the event dispatcher', () => {
|
|
const scaffold = JSON.parse(fs.readFileSync(
|
|
path.join(REPO_ROOT, 'scaffolds', 'cursor', 'hooks.json'),
|
|
'utf8'
|
|
));
|
|
assert.deepStrictEqual(scaffold.hooks.sessionStart, [{
|
|
command: 'node .cursor/hooks/session-start.js',
|
|
}]);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('beforeShellExecution dispatcher blocks git hook bypass attempts', () => {
|
|
const result = runHook(BEFORE_SHELL, { command: 'git commit --no-verify -m test' });
|
|
assert.strictEqual(result.status, 2, result.stderr);
|
|
assert.match(result.stderr, /BLOCKED/);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('beforeShellExecution dispatcher honors the master hook switch', () => {
|
|
const result = runHook(
|
|
BEFORE_SHELL,
|
|
{ command: 'git commit --no-verify -m test' },
|
|
{ ECC_HOOKS_ENABLED: 'false' }
|
|
);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('beforeShellExecution dispatcher preserves the dev-server guard', () => {
|
|
if (process.platform === 'win32') {
|
|
console.log(' (tmux guard is not supported on Windows; skipping)');
|
|
return;
|
|
}
|
|
const result = runHook(BEFORE_SHELL, { command: 'npm run dev' });
|
|
assert.strictEqual(result.status, 2, result.stderr);
|
|
assert.match(result.stderr, /Dev server must run in tmux/);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('Cursor-native security hooks honor the master hook switch', () => {
|
|
const disabledEnv = { ECC_HOOKS_ENABLED: 'false' };
|
|
const cases = [
|
|
[BEFORE_READ, { path: '.env' }],
|
|
[BEFORE_PROMPT, { prompt: 'token sk-123456789012345678901234' }],
|
|
[BEFORE_TAB_READ, { path: 'credentials.pem' }],
|
|
];
|
|
for (const [scriptPath, input] of cases) {
|
|
const result = runHook(scriptPath, input, disabledEnv);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.strictEqual(result.stderr, '', `${path.basename(scriptPath)} should be disabled`);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('Cursor-sensitive file hooks recognize the official file_path field', () => {
|
|
const warning = runHook(BEFORE_READ, { file_path: '.env' });
|
|
assert.strictEqual(warning.status, 0, warning.stderr);
|
|
assert.match(warning.stderr, /Reading sensitive file/);
|
|
|
|
const blocked = runHook(BEFORE_TAB_READ, { file_path: 'credentials.pem' });
|
|
assert.strictEqual(blocked.status, 2, blocked.stderr);
|
|
assert.match(blocked.stderr, /BLOCKED/);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('invalid path field types cannot shadow a valid string fallback', () => {
|
|
const input = { file_path: {}, path: '.env' };
|
|
const warning = runHook(BEFORE_READ, input);
|
|
assert.strictEqual(warning.status, 0, warning.stderr);
|
|
assert.match(warning.stderr, /Reading sensitive file/);
|
|
|
|
const blocked = runHook(BEFORE_TAB_READ, input);
|
|
assert.strictEqual(blocked.status, 2, blocked.stderr);
|
|
assert.match(blocked.stderr, /BLOCKED/);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('Cursor-native audit hooks stay disabled in the minimal profile', () => {
|
|
const cases = [
|
|
[BEFORE_MCP, { server: 'example', tool: 'read' }],
|
|
[AFTER_MCP, { server: 'example', tool: 'read' }],
|
|
[AFTER_SHELL, { command: 'gh pr create', output: 'https://github.com/a/b/pull/1' }],
|
|
[AFTER_TAB_EDIT, { path: 'src/app.js' }],
|
|
[PRE_COMPACT, {}],
|
|
[SUBAGENT_START, { agent_name: 'explore' }],
|
|
[SUBAGENT_STOP, { agent_name: 'explore' }],
|
|
];
|
|
for (const [scriptPath, input] of cases) {
|
|
const result = runHook(scriptPath, input, { ECC_HOOK_PROFILE: 'minimal' });
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.strictEqual(result.stderr, '', `${path.basename(scriptPath)} should be disabled`);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('disabled preCompact preserves malformed input unchanged', () => {
|
|
const raw = '{not-json';
|
|
const result = runHook(PRE_COMPACT, raw, { ECC_HOOK_PROFILE: 'minimal' });
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.strictEqual(result.stdout, raw);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('Stop dispatcher runs batch format/typecheck in standard profile', () => {
|
|
const sessionId = `cursor-stop-standard-${process.pid}`;
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-cursor-stop-'));
|
|
const editedFile = path.join(tempRoot, 'edited.js');
|
|
const accumFile = accumulatorPath(sessionId, tempRoot);
|
|
writeFile(editedFile, 'const answer = 42;\n');
|
|
writeFile(accumFile, `${editedFile}\n`);
|
|
|
|
try {
|
|
const result = runHook(STOP, { conversation_id: sessionId }, {
|
|
CLAUDE_SESSION_ID: sessionId,
|
|
ECC_DISABLED_HOOKS: [
|
|
'stop:check-console-log',
|
|
'stop:session-end',
|
|
'stop:evaluate-session',
|
|
'stop:cost-tracker',
|
|
].join(','),
|
|
}, tempRoot);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.ok(!fs.existsSync(accumFile), 'Stop should consume the edit accumulator');
|
|
} finally {
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
fs.rmSync(accumFile, { force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('afterFileEdit maps Cursor file_path and conversation_id', () => {
|
|
const conversationId = `cursor-edit-${process.pid}`;
|
|
const accumFile = accumulatorPath(conversationId, REPO_ROOT);
|
|
fs.rmSync(accumFile, { force: true });
|
|
|
|
try {
|
|
const editedFile = path.join(REPO_ROOT, 'src', 'cursor-edit.js');
|
|
const result = runHook(AFTER_FILE_EDIT, {
|
|
conversation_id: conversationId,
|
|
file_path: editedFile,
|
|
}, {
|
|
ECC_DISABLED_HOOKS: [
|
|
'post:edit:console-warn',
|
|
'post:edit:design-quality-check',
|
|
].join(','),
|
|
});
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.strictEqual(fs.readFileSync(accumFile, 'utf8'), `${editedFile}\n`);
|
|
} finally {
|
|
fs.rmSync(accumFile, { force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('parallel Cursor conversations keep separate edit accumulators', () => {
|
|
const firstId = `cursor-first-${process.pid}`;
|
|
const secondId = `cursor-second-${process.pid}`;
|
|
const firstAccum = accumulatorPath(firstId, REPO_ROOT);
|
|
const secondAccum = accumulatorPath(secondId, REPO_ROOT);
|
|
fs.rmSync(firstAccum, { force: true });
|
|
fs.rmSync(secondAccum, { force: true });
|
|
|
|
try {
|
|
const env = {
|
|
ECC_DISABLED_HOOKS: [
|
|
'post:edit:console-warn',
|
|
'post:edit:design-quality-check',
|
|
].join(','),
|
|
};
|
|
assert.strictEqual(runHook(AFTER_FILE_EDIT, {
|
|
conversation_id: firstId,
|
|
file_path: path.join(REPO_ROOT, 'src', 'first.js'),
|
|
}, env).status, 0);
|
|
assert.strictEqual(runHook(AFTER_FILE_EDIT, {
|
|
conversation_id: secondId,
|
|
file_path: path.join(REPO_ROOT, 'src', 'second.js'),
|
|
}, env).status, 0);
|
|
|
|
assert.ok(fs.existsSync(firstAccum), 'First conversation should own an accumulator');
|
|
assert.ok(fs.existsSync(secondAccum), 'Second conversation should own an accumulator');
|
|
|
|
const stopped = runHook(STOP, { conversation_id: firstId }, {
|
|
ECC_DISABLED_HOOKS: [
|
|
'stop:check-console-log',
|
|
'stop:session-end',
|
|
'stop:evaluate-session',
|
|
'stop:cost-tracker',
|
|
].join(','),
|
|
});
|
|
assert.strictEqual(stopped.status, 0, stopped.stderr);
|
|
assert.ok(!fs.existsSync(firstAccum), 'First Stop should consume only its accumulator');
|
|
assert.ok(fs.existsSync(secondAccum), 'Second conversation accumulator must remain');
|
|
} finally {
|
|
fs.rmSync(firstAccum, { force: true });
|
|
fs.rmSync(secondAccum, { force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('afterFileEdit forwards user-visible console warnings', () => {
|
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-cursor-warning-'));
|
|
const editedFile = path.join(tempRoot, 'warning.js');
|
|
writeFile(editedFile, 'console.log("debug");\n');
|
|
|
|
try {
|
|
const result = runHook(AFTER_FILE_EDIT, { file_path: editedFile }, {
|
|
ECC_DISABLED_HOOKS: [
|
|
'post:edit:accumulator',
|
|
'post:edit:design-quality-check',
|
|
].join(','),
|
|
}, tempRoot);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.match(result.stderr, /console\.log found/);
|
|
} finally {
|
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('Stop reserves time for lifecycle hooks before the format batch', () => {
|
|
const fixture = createInstalledCursorFixture();
|
|
try {
|
|
const result = runHook(fixture.stop, { conversation_id: 'ordered-stop' }, {
|
|
CURSOR_HOOK_LOG: fixture.logPath,
|
|
ECC_DISABLED_HOOKS: [
|
|
'stop:check-console-log',
|
|
'stop:evaluate-session',
|
|
'stop:cost-tracker',
|
|
].join(','),
|
|
}, fixture.projectRoot);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.deepStrictEqual(
|
|
fs.readFileSync(fixture.logPath, 'utf8').trim().split('\n'),
|
|
['session-end', 'stop-format-typecheck:210000']
|
|
);
|
|
assert.deepStrictEqual(createStopFormatTypecheckOptions({ CLAUDE_SESSION_ID: 'abc' }), {
|
|
timeout: 225000,
|
|
forwardStderr: true,
|
|
env: {
|
|
CLAUDE_SESSION_ID: 'abc',
|
|
ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS: '210000',
|
|
},
|
|
});
|
|
} finally {
|
|
fs.rmSync(fixture.projectRoot, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('Stop dispatcher leaves batch format/typecheck disabled in minimal profile', () => {
|
|
const sessionId = `cursor-stop-minimal-${process.pid}`;
|
|
const accumFile = accumulatorPath(sessionId, REPO_ROOT);
|
|
writeFile(accumFile, `${path.join(REPO_ROOT, 'README.md')}\n`);
|
|
|
|
try {
|
|
const result = runHook(STOP, { conversation_id: sessionId }, {
|
|
CLAUDE_SESSION_ID: sessionId,
|
|
ECC_HOOK_PROFILE: 'minimal',
|
|
ECC_DISABLED_HOOKS: [
|
|
'stop:session-end',
|
|
'stop:evaluate-session',
|
|
'stop:cost-tracker',
|
|
].join(','),
|
|
});
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.ok(fs.existsSync(accumFile), 'Minimal profile must not run format/typecheck');
|
|
} finally {
|
|
fs.rmSync(accumFile, { force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('afterFileEdit does not accumulate files in the minimal profile', () => {
|
|
const sessionId = `cursor-edit-minimal-${process.pid}`;
|
|
const accumFile = accumulatorPath(sessionId, REPO_ROOT);
|
|
fs.rmSync(accumFile, { force: true });
|
|
|
|
try {
|
|
const result = runHook(
|
|
AFTER_FILE_EDIT,
|
|
{ path: path.join(REPO_ROOT, 'example.js') },
|
|
{ CLAUDE_SESSION_ID: sessionId, ECC_HOOK_PROFILE: 'minimal' }
|
|
);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.ok(!fs.existsSync(accumFile), 'Minimal profile must not accumulate edit paths');
|
|
} finally {
|
|
fs.rmSync(accumFile, { force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('installed Cursor dispatchers resolve shared hooks inside .cursor/scripts', () => {
|
|
const fixture = createInstalledCursorFixture();
|
|
try {
|
|
const blocked = runHook(
|
|
fixture.beforeShell,
|
|
{ command: 'git commit --no-verify -m test' },
|
|
{},
|
|
fixture.projectRoot
|
|
);
|
|
assert.strictEqual(blocked.status, 2, blocked.stderr);
|
|
assert.match(blocked.stderr, /installed runtime/);
|
|
|
|
const edited = runHook(
|
|
fixture.afterFileEdit,
|
|
{ path: path.join(fixture.projectRoot, 'src', 'app.js') },
|
|
{ CURSOR_HOOK_LOG: fixture.logPath },
|
|
fixture.projectRoot
|
|
);
|
|
assert.strictEqual(edited.status, 0, edited.stderr);
|
|
assert.strictEqual(fs.readFileSync(fixture.logPath, 'utf8'), 'post-edit-accumulator\n');
|
|
} finally {
|
|
fs.rmSync(fixture.projectRoot, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('installed Cursor hooks keep profile controls without the shared helper', () => {
|
|
const fixture = createInstalledCursorFixture({ includeHookFlags: false });
|
|
try {
|
|
const disabled = runHook(
|
|
fixture.beforeShell,
|
|
{ command: 'git commit --no-verify -m test' },
|
|
{ ECC_HOOKS_ENABLED: 'false' },
|
|
fixture.projectRoot
|
|
);
|
|
assert.strictEqual(disabled.status, 0, disabled.stderr);
|
|
|
|
const enabled = runHook(
|
|
fixture.beforeShell,
|
|
{ command: 'git commit --no-verify -m test' },
|
|
{ ECC_HOOKS_ENABLED: 'true' },
|
|
fixture.projectRoot
|
|
);
|
|
assert.strictEqual(enabled.status, 2, enabled.stderr);
|
|
} finally {
|
|
fs.rmSync(fixture.projectRoot, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('installed Stop dispatcher resolves hooks inside .cursor/scripts', () => {
|
|
const fixture = createInstalledCursorFixture();
|
|
try {
|
|
const result = runHook(
|
|
fixture.stop,
|
|
{ conversation_id: 'installed-stop' },
|
|
{
|
|
CURSOR_HOOK_LOG: fixture.logPath,
|
|
ECC_DISABLED_HOOKS: [
|
|
'stop:check-console-log',
|
|
'stop:session-end',
|
|
'stop:evaluate-session',
|
|
'stop:cost-tracker',
|
|
].join(','),
|
|
},
|
|
fixture.projectRoot
|
|
);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.strictEqual(
|
|
fs.readFileSync(fixture.logPath, 'utf8'),
|
|
'stop-format-typecheck:210000\n'
|
|
);
|
|
} finally {
|
|
fs.rmSync(fixture.projectRoot, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('installed sessionStart dispatcher combines env and shared context', () => {
|
|
const fixture = createInstalledCursorFixture();
|
|
try {
|
|
const result = runHook(
|
|
fixture.sessionStart,
|
|
{ conversation_id: 'installed-session' },
|
|
{},
|
|
fixture.projectRoot
|
|
);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.deepStrictEqual(JSON.parse(result.stdout), {
|
|
env: { ECC_AGENT_DATA_HOME: '/tmp/cursor-ecc' },
|
|
additional_context: 'cursor env\n\nshared context',
|
|
});
|
|
} finally {
|
|
fs.rmSync(fixture.projectRoot, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('installed sessionStart keeps environment fallback for malformed input', () => {
|
|
const fixture = createInstalledCursorFixture();
|
|
try {
|
|
for (const raw of ['{not-json', 'null']) {
|
|
const result = runHook(fixture.sessionStart, raw, {}, fixture.projectRoot);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.strictEqual(JSON.parse(result.stdout).env.ECC_AGENT_DATA_HOME, '/tmp/cursor-ecc');
|
|
}
|
|
} finally {
|
|
fs.rmSync(fixture.projectRoot, { recursive: true, force: true });
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('sessionStart dispatcher honors the master hook switch', () => {
|
|
const result = runHook(
|
|
path.join(REPO_ROOT, '.cursor', 'hooks', 'session-start.js'),
|
|
{ conversation_id: 'disabled-session' },
|
|
{ ECC_HOOKS_ENABLED: 'false' }
|
|
);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.deepStrictEqual(JSON.parse(result.stdout), {});
|
|
})) passed++; else failed++;
|
|
|
|
if (test('oversized beforeShellExecution input fails closed', () => {
|
|
const oversizedPayloads = [
|
|
JSON.stringify({
|
|
command: 'git commit --no-verify',
|
|
padding: 'x'.repeat(1024 * 1024),
|
|
}),
|
|
JSON.stringify({
|
|
command: 'git commit --no-verify',
|
|
padding: '界'.repeat(400000),
|
|
}),
|
|
];
|
|
for (const oversized of oversizedPayloads) {
|
|
assert.ok(Buffer.byteLength(oversized, 'utf8') > 1024 * 1024);
|
|
const result = runHook(BEFORE_SHELL, oversized);
|
|
assert.strictEqual(result.status, 2, result.stderr);
|
|
assert.strictEqual(result.stdout, '');
|
|
assert.match(result.stderr, /stdin exceeded.*blocking/i);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('oversized beforeShellExecution input honors explicit guard disablement', () => {
|
|
const oversized = JSON.stringify({
|
|
command: 'git commit --no-verify',
|
|
padding: 'x'.repeat(1024 * 1024),
|
|
});
|
|
for (const env of [
|
|
{ ECC_HOOKS_ENABLED: 'false' },
|
|
{
|
|
ECC_DISABLED_HOOKS: [
|
|
'pre:bash:block-no-verify',
|
|
'pre:bash:dev-server-block',
|
|
].join(','),
|
|
},
|
|
]) {
|
|
const result = runHook(BEFORE_SHELL, oversized, env);
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.strictEqual(result.stdout, '');
|
|
assert.match(result.stderr, /stdin exceeded.*suppressing/i);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('oversized beforeTabFileRead input fails closed', () => {
|
|
const oversized = JSON.stringify({
|
|
file_path: '.env',
|
|
padding: '界'.repeat(400000),
|
|
});
|
|
const result = runHook(BEFORE_TAB_READ, oversized);
|
|
assert.strictEqual(result.status, 2, result.stderr);
|
|
assert.strictEqual(result.stdout, '');
|
|
assert.match(result.stderr, /stdin exceeded.*blocking/i);
|
|
})) passed++; else failed++;
|
|
|
|
if (test('readStdin exposes stream failures to blocking dispatchers', () => {
|
|
const probe = [
|
|
"const { EventEmitter } = require('events');",
|
|
`const { readStdin } = require(${JSON.stringify(path.join(REPO_ROOT, '.cursor', 'hooks', 'adapter.js'))});`,
|
|
"const stream = new EventEmitter();",
|
|
"stream.setEncoding = () => {};",
|
|
"readStdin({ includeMetadata: true, stream }).then(result => process.stdout.write(JSON.stringify(result)));",
|
|
"setImmediate(() => stream.emit('error', new Error('synthetic read failure')));",
|
|
].join('\n');
|
|
const result = spawnSync(process.execPath, ['-e', probe], {
|
|
encoding: 'utf8',
|
|
timeout: 5000,
|
|
});
|
|
assert.strictEqual(result.status, 0, result.stderr);
|
|
assert.deepStrictEqual(JSON.parse(result.stdout), {
|
|
raw: '',
|
|
truncated: false,
|
|
readError: true,
|
|
});
|
|
})) passed++; else failed++;
|
|
|
|
console.log('-'.repeat(55));
|
|
console.log(`Passed: ${passed}`);
|
|
console.log(`Failed: ${failed}`);
|
|
process.exit(failed > 0 ? 1 : 0);
|