Files
claude-mem/tests/cursor-hook-outputs.test.ts
T
Alex Newman 129c22c48d Add comprehensive tests for Cursor functionality
- 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.
2025-12-29 22:58:42 -05:00

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