129c22c48d
- Implement tests for cursor context updates in `cursor-context-update.test.ts`, validating context file creation, content structure, and edge cases. - Create tests for cursor hook outputs in `cursor-hook-outputs.test.ts`, ensuring correct JSON output from hook scripts and handling of various input scenarios. - Add tests for JSON utility functions in `cursor-hooks-json-utils.test.ts`, covering parsing, project name extraction, and URL encoding. - Introduce tests for MCP configuration in `cursor-mcp-config.test.ts`, verifying configuration creation, updates, and format validation. - Develop tests for the cursor project registry in `cursor-registry.test.ts`, ensuring correct registration, unregistration, and JSON format compliance.
345 lines
9.7 KiB
TypeScript
345 lines
9.7 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { execSync, spawn } from 'child_process';
|
|
import { mkdirSync, writeFileSync, existsSync, rmSync, readFileSync, chmodSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir, homedir } from 'os';
|
|
|
|
/**
|
|
* Tests for Cursor Hook Script Outputs
|
|
*
|
|
* These tests validate that hook scripts produce the correct JSON output
|
|
* required by Cursor's hook system.
|
|
*
|
|
* Critical requirements:
|
|
* - beforeSubmitPrompt hooks MUST output {"continue": true}
|
|
* - stop hooks MUST output valid JSON (usually {} or {"followup_message": "..."})
|
|
*
|
|
* If these outputs are wrong, Cursor will block prompts or fail silently.
|
|
*/
|
|
|
|
// Skip these tests if jq is not installed (required by the scripts)
|
|
function hasJq(): boolean {
|
|
try {
|
|
execSync('which jq', { stdio: 'pipe' });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Skip these tests on Windows (bash scripts)
|
|
function isUnix(): boolean {
|
|
return process.platform !== 'win32';
|
|
}
|
|
|
|
const describeOrSkip = (hasJq() && isUnix()) ? describe : describe.skip;
|
|
|
|
describeOrSkip('Cursor Hook Script Outputs', () => {
|
|
let tempDir: string;
|
|
let cursorHooksDir: string;
|
|
|
|
beforeEach(() => {
|
|
// Create unique temp directory for each test
|
|
tempDir = join(tmpdir(), `cursor-hook-output-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
mkdirSync(tempDir, { recursive: true });
|
|
|
|
// Find cursor-hooks directory
|
|
cursorHooksDir = join(process.cwd(), 'cursor-hooks');
|
|
if (!existsSync(cursorHooksDir)) {
|
|
throw new Error('cursor-hooks directory not found');
|
|
}
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Clean up temp directory
|
|
try {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Run a hook script with input and return the output
|
|
*/
|
|
function runHookScript(scriptName: string, input: object): string {
|
|
const scriptPath = join(cursorHooksDir, scriptName);
|
|
|
|
if (!existsSync(scriptPath)) {
|
|
throw new Error(`Script not found: ${scriptPath}`);
|
|
}
|
|
|
|
// Make sure script is executable
|
|
chmodSync(scriptPath, 0o755);
|
|
|
|
const result = execSync(`bash "${scriptPath}"`, {
|
|
input: JSON.stringify(input),
|
|
cwd: tempDir,
|
|
env: {
|
|
...process.env,
|
|
HOME: homedir(), // Ensure HOME is set for ~/.claude-mem access
|
|
},
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
});
|
|
|
|
return result.trim();
|
|
}
|
|
|
|
describe('session-init.sh (beforeSubmitPrompt)', () => {
|
|
it('outputs {"continue": true} for valid input', () => {
|
|
const input = {
|
|
conversation_id: 'test-conv-123',
|
|
prompt: 'Hello world',
|
|
workspace_roots: [tempDir]
|
|
};
|
|
|
|
const output = runHookScript('session-init.sh', input);
|
|
const parsed = JSON.parse(output);
|
|
|
|
expect(parsed.continue).toBe(true);
|
|
});
|
|
|
|
it('outputs {"continue": true} even with empty input', () => {
|
|
const output = runHookScript('session-init.sh', {});
|
|
const parsed = JSON.parse(output);
|
|
|
|
expect(parsed.continue).toBe(true);
|
|
});
|
|
|
|
it('outputs {"continue": true} even with invalid JSON-like input', () => {
|
|
const input = {
|
|
conversation_id: null,
|
|
workspace_roots: null
|
|
};
|
|
|
|
const output = runHookScript('session-init.sh', input);
|
|
const parsed = JSON.parse(output);
|
|
|
|
expect(parsed.continue).toBe(true);
|
|
});
|
|
|
|
it('output is valid JSON', () => {
|
|
const input = {
|
|
conversation_id: 'test-123',
|
|
prompt: 'Test prompt'
|
|
};
|
|
|
|
const output = runHookScript('session-init.sh', input);
|
|
|
|
// Should not throw
|
|
expect(() => JSON.parse(output)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('context-inject.sh (beforeSubmitPrompt)', () => {
|
|
it('outputs {"continue": true} for valid input', () => {
|
|
const input = {
|
|
workspace_roots: [tempDir]
|
|
};
|
|
|
|
const output = runHookScript('context-inject.sh', input);
|
|
const parsed = JSON.parse(output);
|
|
|
|
expect(parsed.continue).toBe(true);
|
|
});
|
|
|
|
it('outputs {"continue": true} even with empty input', () => {
|
|
const output = runHookScript('context-inject.sh', {});
|
|
const parsed = JSON.parse(output);
|
|
|
|
expect(parsed.continue).toBe(true);
|
|
});
|
|
|
|
it('output is valid JSON', () => {
|
|
const output = runHookScript('context-inject.sh', {});
|
|
|
|
expect(() => JSON.parse(output)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('session-summary.sh (stop)', () => {
|
|
it('outputs valid JSON for typical input', () => {
|
|
const input = {
|
|
conversation_id: 'test-conv-456',
|
|
workspace_roots: [tempDir],
|
|
status: 'completed'
|
|
};
|
|
|
|
const output = runHookScript('session-summary.sh', input);
|
|
|
|
// Should be valid JSON
|
|
expect(() => JSON.parse(output)).not.toThrow();
|
|
});
|
|
|
|
it('outputs empty object {} when nothing to report', () => {
|
|
const input = {
|
|
// No conversation_id - should exit early with {}
|
|
};
|
|
|
|
const output = runHookScript('session-summary.sh', input);
|
|
const parsed = JSON.parse(output);
|
|
|
|
expect(parsed).toEqual({});
|
|
});
|
|
|
|
it('output is valid JSON even with minimal input', () => {
|
|
const output = runHookScript('session-summary.sh', {});
|
|
|
|
expect(() => JSON.parse(output)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('save-observation.sh (afterMCPExecution)', () => {
|
|
it('exits cleanly with no output for valid MCP input', () => {
|
|
const input = {
|
|
conversation_id: 'test-conv-789',
|
|
hook_event_name: 'afterMCPExecution',
|
|
tool_name: 'Bash',
|
|
tool_input: { command: 'ls' },
|
|
result_json: { output: 'file1.txt' },
|
|
workspace_roots: [tempDir]
|
|
};
|
|
|
|
// This script should exit with 0 and produce no output
|
|
const scriptPath = join(cursorHooksDir, 'save-observation.sh');
|
|
const result = execSync(`bash "${scriptPath}"`, {
|
|
input: JSON.stringify(input),
|
|
cwd: tempDir,
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Should be empty or just whitespace
|
|
expect(result.trim()).toBe('');
|
|
});
|
|
|
|
it('exits cleanly for shell execution input', () => {
|
|
const input = {
|
|
conversation_id: 'test-conv-101',
|
|
hook_event_name: 'afterShellExecution',
|
|
command: 'ls -la',
|
|
output: 'file1.txt\nfile2.txt',
|
|
workspace_roots: [tempDir]
|
|
};
|
|
|
|
const scriptPath = join(cursorHooksDir, 'save-observation.sh');
|
|
const result = execSync(`bash "${scriptPath}"`, {
|
|
input: JSON.stringify(input),
|
|
cwd: tempDir,
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Should be empty or just whitespace
|
|
expect(result.trim()).toBe('');
|
|
});
|
|
|
|
it('exits cleanly with no session_id', () => {
|
|
const input = {
|
|
hook_event_name: 'afterMCPExecution',
|
|
tool_name: 'Bash'
|
|
// No conversation_id or generation_id
|
|
};
|
|
|
|
const scriptPath = join(cursorHooksDir, 'save-observation.sh');
|
|
const result = execSync(`bash "${scriptPath}"`, {
|
|
input: JSON.stringify(input),
|
|
cwd: tempDir,
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Should exit cleanly
|
|
expect(result.trim()).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('save-file-edit.sh (afterFileEdit)', () => {
|
|
it('exits cleanly with valid file edit input', () => {
|
|
const input = {
|
|
conversation_id: 'test-conv-edit',
|
|
file_path: '/path/to/file.ts',
|
|
edits: [
|
|
{ old_string: 'old code', new_string: 'new code' }
|
|
],
|
|
workspace_roots: [tempDir]
|
|
};
|
|
|
|
const scriptPath = join(cursorHooksDir, 'save-file-edit.sh');
|
|
const result = execSync(`bash "${scriptPath}"`, {
|
|
input: JSON.stringify(input),
|
|
cwd: tempDir,
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Should be empty or just whitespace
|
|
expect(result.trim()).toBe('');
|
|
});
|
|
|
|
it('exits cleanly with no file_path', () => {
|
|
const input = {
|
|
conversation_id: 'test-conv-edit',
|
|
edits: []
|
|
// No file_path - should exit early
|
|
};
|
|
|
|
const scriptPath = join(cursorHooksDir, 'save-file-edit.sh');
|
|
const result = execSync(`bash "${scriptPath}"`, {
|
|
input: JSON.stringify(input),
|
|
cwd: tempDir,
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Should exit cleanly
|
|
expect(result.trim()).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('script error handling', () => {
|
|
it('session-init.sh never outputs error to stdout', () => {
|
|
// Even with completely broken input, should still output valid JSON
|
|
const scriptPath = join(cursorHooksDir, 'session-init.sh');
|
|
|
|
// Pass invalid input that might cause jq errors
|
|
const result = execSync(`echo '{}' | bash "${scriptPath}"`, {
|
|
cwd: tempDir,
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Output should still be valid JSON with continue: true
|
|
const parsed = JSON.parse(result.trim());
|
|
expect(parsed.continue).toBe(true);
|
|
});
|
|
|
|
it('context-inject.sh never outputs error to stdout', () => {
|
|
const scriptPath = join(cursorHooksDir, 'context-inject.sh');
|
|
|
|
const result = execSync(`echo '{}' | bash "${scriptPath}"`, {
|
|
cwd: tempDir,
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
});
|
|
|
|
const parsed = JSON.parse(result.trim());
|
|
expect(parsed.continue).toBe(true);
|
|
});
|
|
|
|
it('session-summary.sh never outputs error to stdout', () => {
|
|
const scriptPath = join(cursorHooksDir, 'session-summary.sh');
|
|
|
|
const result = execSync(`echo '{}' | bash "${scriptPath}"`, {
|
|
cwd: tempDir,
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
});
|
|
|
|
// Should be valid JSON
|
|
expect(() => JSON.parse(result.trim())).not.toThrow();
|
|
});
|
|
});
|
|
});
|