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.
This commit is contained in:
@@ -19,6 +19,12 @@ import { homedir } from 'os';
|
||||
import { existsSync, writeFileSync, readFileSync, unlinkSync, mkdirSync } from 'fs';
|
||||
import * as readline from 'readline';
|
||||
import { promisify } from 'util';
|
||||
import {
|
||||
readCursorRegistry as readCursorRegistryFromFile,
|
||||
writeCursorRegistry as writeCursorRegistryToFile,
|
||||
writeContextFile,
|
||||
type CursorProjectRegistry
|
||||
} from '../utils/cursor-utils.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@@ -67,27 +73,15 @@ function removePidFile(): void {
|
||||
// ============================================================================
|
||||
// Cursor Project Registry
|
||||
// Tracks which projects have Cursor hooks installed for auto-context updates
|
||||
// Uses pure functions from cursor-utils.ts for testability
|
||||
// ============================================================================
|
||||
|
||||
interface CursorProjectRegistry {
|
||||
[projectName: string]: {
|
||||
workspacePath: string;
|
||||
installedAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
function readCursorRegistry(): CursorProjectRegistry {
|
||||
try {
|
||||
if (!existsSync(CURSOR_REGISTRY_FILE)) return {};
|
||||
return JSON.parse(readFileSync(CURSOR_REGISTRY_FILE, 'utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
return readCursorRegistryFromFile(CURSOR_REGISTRY_FILE);
|
||||
}
|
||||
|
||||
function writeCursorRegistry(registry: CursorProjectRegistry): void {
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
writeFileSync(CURSOR_REGISTRY_FILE, JSON.stringify(registry, null, 2));
|
||||
writeCursorRegistryToFile(CURSOR_REGISTRY_FILE, registry);
|
||||
}
|
||||
|
||||
function registerCursorProject(projectName: string, workspacePath: string): void {
|
||||
@@ -130,32 +124,9 @@ export async function updateCursorContextForProject(projectName: string, port: n
|
||||
const context = await response.text();
|
||||
if (!context || !context.trim()) return;
|
||||
|
||||
// Write to the project's Cursor rules file
|
||||
const rulesDir = path.join(entry.workspacePath, '.cursor', 'rules');
|
||||
const rulesFile = path.join(rulesDir, 'claude-mem-context.mdc');
|
||||
|
||||
mkdirSync(rulesDir, { recursive: true });
|
||||
|
||||
// Write to temp file first, then atomically move (prevents corruption)
|
||||
const tempFile = `${rulesFile}.tmp`;
|
||||
const content = `---
|
||||
alwaysApply: true
|
||||
description: "Claude-mem context from past sessions (auto-updated)"
|
||||
---
|
||||
|
||||
# Memory Context from Past Sessions
|
||||
|
||||
The following context is from claude-mem, a persistent memory system that tracks your coding sessions.
|
||||
|
||||
${context}
|
||||
|
||||
---
|
||||
*Updated after last session. Use claude-mem's MCP search tools for more detailed queries.*
|
||||
`;
|
||||
|
||||
writeFileSync(tempFile, content);
|
||||
fs.renameSync(tempFile, rulesFile);
|
||||
logger.debug('CURSOR', 'Updated context file', { projectName, rulesFile });
|
||||
// Write to the project's Cursor rules file using shared utility
|
||||
writeContextFile(entry.workspacePath, context);
|
||||
logger.debug('CURSOR', 'Updated context file', { projectName, workspacePath: entry.workspacePath });
|
||||
} catch (error) {
|
||||
logger.warn('CURSOR', 'Failed to update context file', { projectName, error: (error as Error).message });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Cursor Integration Utilities
|
||||
*
|
||||
* Pure functions for Cursor project registry, context files, and MCP configuration.
|
||||
* Designed for testability - all file paths are passed as parameters.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs';
|
||||
import { join, basename } from 'path';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface CursorProjectRegistry {
|
||||
[projectName: string]: {
|
||||
workspacePath: string;
|
||||
installedAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CursorMcpConfig {
|
||||
mcpServers: {
|
||||
[name: string]: {
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Project Registry Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Read the Cursor project registry from a file
|
||||
*/
|
||||
export function readCursorRegistry(registryFile: string): CursorProjectRegistry {
|
||||
try {
|
||||
if (!existsSync(registryFile)) return {};
|
||||
return JSON.parse(readFileSync(registryFile, 'utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the Cursor project registry to a file
|
||||
*/
|
||||
export function writeCursorRegistry(registryFile: string, registry: CursorProjectRegistry): void {
|
||||
const dir = join(registryFile, '..');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(registryFile, JSON.stringify(registry, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a project in the Cursor registry
|
||||
*/
|
||||
export function registerCursorProject(
|
||||
registryFile: string,
|
||||
projectName: string,
|
||||
workspacePath: string
|
||||
): void {
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
registry[projectName] = {
|
||||
workspacePath,
|
||||
installedAt: new Date().toISOString()
|
||||
};
|
||||
writeCursorRegistry(registryFile, registry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a project from the Cursor registry
|
||||
*/
|
||||
export function unregisterCursorProject(registryFile: string, projectName: string): void {
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
if (registry[projectName]) {
|
||||
delete registry[projectName];
|
||||
writeCursorRegistry(registryFile, registry);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Context File Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Write context file to a Cursor project's .cursor/rules directory
|
||||
* Uses atomic write (temp file + rename) to prevent corruption
|
||||
*/
|
||||
export function writeContextFile(workspacePath: string, context: string): void {
|
||||
const rulesDir = join(workspacePath, '.cursor', 'rules');
|
||||
const rulesFile = join(rulesDir, 'claude-mem-context.mdc');
|
||||
const tempFile = `${rulesFile}.tmp`;
|
||||
|
||||
mkdirSync(rulesDir, { recursive: true });
|
||||
|
||||
const content = `---
|
||||
alwaysApply: true
|
||||
description: "Claude-mem context from past sessions (auto-updated)"
|
||||
---
|
||||
|
||||
# Memory Context from Past Sessions
|
||||
|
||||
The following context is from claude-mem, a persistent memory system that tracks your coding sessions.
|
||||
|
||||
${context}
|
||||
|
||||
---
|
||||
*Updated after last session. Use claude-mem's MCP search tools for more detailed queries.*
|
||||
`;
|
||||
|
||||
// Atomic write: temp file + rename
|
||||
writeFileSync(tempFile, content);
|
||||
renameSync(tempFile, rulesFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read context file from a Cursor project's .cursor/rules directory
|
||||
*/
|
||||
export function readContextFile(workspacePath: string): string | null {
|
||||
const rulesFile = join(workspacePath, '.cursor', 'rules', 'claude-mem-context.mdc');
|
||||
if (!existsSync(rulesFile)) return null;
|
||||
return readFileSync(rulesFile, 'utf-8');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MCP Configuration Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Configure claude-mem MCP server in Cursor's mcp.json
|
||||
* Preserves existing MCP servers
|
||||
*/
|
||||
export function configureCursorMcp(mcpJsonPath: string, mcpServerScriptPath: string): void {
|
||||
const dir = join(mcpJsonPath, '..');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
// Load existing config or create new
|
||||
let config: CursorMcpConfig = { mcpServers: {} };
|
||||
if (existsSync(mcpJsonPath)) {
|
||||
try {
|
||||
config = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
if (!config.mcpServers) {
|
||||
config.mcpServers = {};
|
||||
}
|
||||
} catch {
|
||||
// Start fresh if corrupt
|
||||
config = { mcpServers: {} };
|
||||
}
|
||||
}
|
||||
|
||||
// Add claude-mem MCP server
|
||||
config.mcpServers['claude-mem'] = {
|
||||
command: 'node',
|
||||
args: [mcpServerScriptPath]
|
||||
};
|
||||
|
||||
writeFileSync(mcpJsonPath, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove claude-mem MCP server from Cursor's mcp.json
|
||||
* Preserves other MCP servers
|
||||
*/
|
||||
export function removeMcpConfig(mcpJsonPath: string): void {
|
||||
if (!existsSync(mcpJsonPath)) return;
|
||||
|
||||
try {
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
if (config.mcpServers && config.mcpServers['claude-mem']) {
|
||||
delete config.mcpServers['claude-mem'];
|
||||
writeFileSync(mcpJsonPath, JSON.stringify(config, null, 2));
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// JSON Utility Functions (mirrors common.sh logic)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parse array field syntax like "workspace_roots[0]"
|
||||
* Returns null for simple fields
|
||||
*/
|
||||
export function parseArrayField(field: string): { field: string; index: number } | null {
|
||||
const match = field.match(/^(.+)\[(\d+)\]$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
field: match[1],
|
||||
index: parseInt(match[2], 10)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract JSON field with fallback (mirrors common.sh json_get)
|
||||
* Supports array access like "field[0]"
|
||||
*/
|
||||
export function jsonGet(json: Record<string, unknown>, field: string, fallback: string = ''): string {
|
||||
const arrayAccess = parseArrayField(field);
|
||||
|
||||
if (arrayAccess) {
|
||||
const arr = json[arrayAccess.field];
|
||||
if (!Array.isArray(arr)) return fallback;
|
||||
const value = arr[arrayAccess.index];
|
||||
if (value === undefined || value === null) return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
const value = json[field];
|
||||
if (value === undefined || value === null) return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project name from workspace path (mirrors common.sh get_project_name)
|
||||
*/
|
||||
export function getProjectName(workspacePath: string): string {
|
||||
if (!workspacePath) return 'unknown-project';
|
||||
|
||||
// Handle Windows drive root (C:\ or C:)
|
||||
const driveMatch = workspacePath.match(/^([A-Za-z]):[\\\/]?$/);
|
||||
if (driveMatch) {
|
||||
return `drive-${driveMatch[1].toUpperCase()}`;
|
||||
}
|
||||
|
||||
// Normalize to forward slashes for cross-platform support
|
||||
const normalized = workspacePath.replace(/\\/g, '/');
|
||||
const name = basename(normalized);
|
||||
|
||||
if (!name) {
|
||||
return 'unknown-project';
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if string is empty/null (mirrors common.sh is_empty)
|
||||
* Also treats jq's literal "null" string as empty
|
||||
*/
|
||||
export function isEmpty(str: string | null | undefined): boolean {
|
||||
if (str === null || str === undefined) return true;
|
||||
if (str === '') return true;
|
||||
if (str === 'null') return true;
|
||||
if (str === 'empty') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL encode a string (mirrors common.sh url_encode)
|
||||
*/
|
||||
export function urlEncode(str: string): string {
|
||||
return encodeURIComponent(str);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdirSync, writeFileSync, existsSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { writeContextFile, readContextFile } from '../src/utils/cursor-utils';
|
||||
|
||||
/**
|
||||
* Tests for Cursor Context Update functionality
|
||||
*
|
||||
* These tests validate that context files are correctly written to
|
||||
* .cursor/rules/claude-mem-context.mdc for registered projects.
|
||||
*
|
||||
* The context file uses Cursor's MDC format with frontmatter.
|
||||
*/
|
||||
|
||||
describe('Cursor Context Update', () => {
|
||||
let tempDir: string;
|
||||
let workspacePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test
|
||||
tempDir = join(tmpdir(), `cursor-context-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
workspacePath = join(tempDir, 'my-project');
|
||||
mkdirSync(workspacePath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('writeContextFile', () => {
|
||||
it('creates .cursor/rules directory structure', () => {
|
||||
writeContextFile(workspacePath, 'test context');
|
||||
|
||||
const rulesDir = join(workspacePath, '.cursor', 'rules');
|
||||
expect(existsSync(rulesDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('creates claude-mem-context.mdc file', () => {
|
||||
writeContextFile(workspacePath, 'test context');
|
||||
|
||||
const rulesFile = join(workspacePath, '.cursor', 'rules', 'claude-mem-context.mdc');
|
||||
expect(existsSync(rulesFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('includes alwaysApply: true in frontmatter', () => {
|
||||
writeContextFile(workspacePath, 'test context');
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain('alwaysApply: true');
|
||||
});
|
||||
|
||||
it('includes description in frontmatter', () => {
|
||||
writeContextFile(workspacePath, 'test context');
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain('description: "Claude-mem context from past sessions (auto-updated)"');
|
||||
});
|
||||
|
||||
it('includes the provided context in the file body', () => {
|
||||
const testContext = `## Recent Session
|
||||
|
||||
- Fixed authentication bug
|
||||
- Added new feature`;
|
||||
|
||||
writeContextFile(workspacePath, testContext);
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain('Fixed authentication bug');
|
||||
expect(content).toContain('Added new feature');
|
||||
});
|
||||
|
||||
it('includes Memory Context header', () => {
|
||||
writeContextFile(workspacePath, 'test');
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain('# Memory Context from Past Sessions');
|
||||
});
|
||||
|
||||
it('includes footer with MCP tools mention', () => {
|
||||
writeContextFile(workspacePath, 'test');
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain("Use claude-mem's MCP search tools for more detailed queries");
|
||||
});
|
||||
|
||||
it('uses atomic write (no temp file left behind)', () => {
|
||||
writeContextFile(workspacePath, 'test context');
|
||||
|
||||
const tempFile = join(workspacePath, '.cursor', 'rules', 'claude-mem-context.mdc.tmp');
|
||||
expect(existsSync(tempFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('overwrites existing context file', () => {
|
||||
writeContextFile(workspacePath, 'first context');
|
||||
writeContextFile(workspacePath, 'second context');
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).not.toContain('first context');
|
||||
expect(content).toContain('second context');
|
||||
});
|
||||
|
||||
it('handles empty context gracefully', () => {
|
||||
writeContextFile(workspacePath, '');
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toBeDefined();
|
||||
expect(content).toContain('alwaysApply: true');
|
||||
});
|
||||
|
||||
it('preserves multi-line context with proper formatting', () => {
|
||||
const multilineContext = `Line 1
|
||||
Line 2
|
||||
Line 3
|
||||
|
||||
Paragraph 2`;
|
||||
|
||||
writeContextFile(workspacePath, multilineContext);
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain('Line 1\nLine 2\nLine 3');
|
||||
expect(content).toContain('Paragraph 2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MDC format validation', () => {
|
||||
it('has valid YAML frontmatter delimiters', () => {
|
||||
writeContextFile(workspacePath, 'test');
|
||||
|
||||
const content = readContextFile(workspacePath)!;
|
||||
const lines = content.split('\n');
|
||||
|
||||
// First line should be ---
|
||||
expect(lines[0]).toBe('---');
|
||||
|
||||
// Should have closing --- for frontmatter
|
||||
const secondDashIndex = lines.indexOf('---', 1);
|
||||
expect(secondDashIndex).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('frontmatter is parseable as YAML', () => {
|
||||
writeContextFile(workspacePath, 'test');
|
||||
|
||||
const content = readContextFile(workspacePath)!;
|
||||
const lines = content.split('\n');
|
||||
const frontmatterEnd = lines.indexOf('---', 1);
|
||||
|
||||
const frontmatter = lines.slice(1, frontmatterEnd).join('\n');
|
||||
|
||||
// Should contain valid YAML key-value pairs
|
||||
expect(frontmatter).toMatch(/alwaysApply:\s*true/);
|
||||
expect(frontmatter).toMatch(/description:\s*"/);
|
||||
});
|
||||
|
||||
it('content after frontmatter is proper markdown', () => {
|
||||
writeContextFile(workspacePath, 'test');
|
||||
|
||||
const content = readContextFile(workspacePath)!;
|
||||
|
||||
// Should have markdown header
|
||||
expect(content).toMatch(/^# Memory Context/m);
|
||||
|
||||
// Should have horizontal rule (---)
|
||||
// Note: The footer uses --- which is also a horizontal rule in markdown
|
||||
const bodyPart = content.split('---')[2]; // After frontmatter
|
||||
expect(bodyPart).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles special characters in context', () => {
|
||||
const specialContext = '`code` **bold** _italic_ <html> $variable @mention #tag';
|
||||
|
||||
writeContextFile(workspacePath, specialContext);
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain('`code`');
|
||||
expect(content).toContain('**bold**');
|
||||
expect(content).toContain('<html>');
|
||||
});
|
||||
|
||||
it('handles unicode in context', () => {
|
||||
const unicodeContext = 'Emoji: 🚀 Japanese: 日本語 Arabic: العربية';
|
||||
|
||||
writeContextFile(workspacePath, unicodeContext);
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain('🚀');
|
||||
expect(content).toContain('日本語');
|
||||
expect(content).toContain('العربية');
|
||||
});
|
||||
|
||||
it('handles very long context', () => {
|
||||
// 100KB of context
|
||||
const longContext = 'x'.repeat(100 * 1024);
|
||||
|
||||
writeContextFile(workspacePath, longContext);
|
||||
|
||||
const content = readContextFile(workspacePath);
|
||||
expect(content).toContain(longContext);
|
||||
});
|
||||
|
||||
it('works when .cursor directory already exists', () => {
|
||||
// Pre-create .cursor with other content
|
||||
mkdirSync(join(workspacePath, '.cursor', 'other'), { recursive: true });
|
||||
writeFileSync(join(workspacePath, '.cursor', 'other', 'file.txt'), 'existing');
|
||||
|
||||
writeContextFile(workspacePath, 'new context');
|
||||
|
||||
// Should not destroy existing content
|
||||
expect(existsSync(join(workspacePath, '.cursor', 'other', 'file.txt'))).toBe(true);
|
||||
expect(readContextFile(workspacePath)).toContain('new context');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import {
|
||||
parseArrayField,
|
||||
jsonGet,
|
||||
getProjectName,
|
||||
isEmpty,
|
||||
urlEncode
|
||||
} from '../src/utils/cursor-utils';
|
||||
|
||||
/**
|
||||
* Tests for Cursor Hooks JSON/Utility Functions
|
||||
*
|
||||
* These tests validate the logic used in common.sh bash utilities.
|
||||
* The TypeScript implementations in cursor-utils.ts mirror the bash logic,
|
||||
* allowing us to verify correct behavior and catch edge cases.
|
||||
*
|
||||
* The bash scripts use these functions:
|
||||
* - json_get: Extract fields from JSON, including array access
|
||||
* - get_project_name: Extract project name from workspace path
|
||||
* - is_empty: Check if a string is empty/null
|
||||
* - url_encode: URL-encode a string
|
||||
*/
|
||||
|
||||
describe('Cursor Hooks JSON Utilities', () => {
|
||||
describe('parseArrayField', () => {
|
||||
it('parses simple array access', () => {
|
||||
const result = parseArrayField('workspace_roots[0]');
|
||||
expect(result).toEqual({ field: 'workspace_roots', index: 0 });
|
||||
});
|
||||
|
||||
it('parses array access with higher index', () => {
|
||||
const result = parseArrayField('items[42]');
|
||||
expect(result).toEqual({ field: 'items', index: 42 });
|
||||
});
|
||||
|
||||
it('returns null for simple field', () => {
|
||||
const result = parseArrayField('conversation_id');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
const result = parseArrayField('');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for malformed array syntax', () => {
|
||||
expect(parseArrayField('field[]')).toBeNull();
|
||||
expect(parseArrayField('field[-1]')).toBeNull();
|
||||
expect(parseArrayField('[0]')).toBeNull();
|
||||
});
|
||||
|
||||
it('handles underscores in field name', () => {
|
||||
const result = parseArrayField('my_array_field[5]');
|
||||
expect(result).toEqual({ field: 'my_array_field', index: 5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('jsonGet', () => {
|
||||
const testJson = {
|
||||
conversation_id: 'conv-123',
|
||||
workspace_roots: ['/path/to/project', '/another/path'],
|
||||
nested: { value: 'nested-value' },
|
||||
empty_string: '',
|
||||
null_value: null
|
||||
};
|
||||
|
||||
it('gets simple field', () => {
|
||||
expect(jsonGet(testJson, 'conversation_id')).toBe('conv-123');
|
||||
});
|
||||
|
||||
it('gets array element with [0]', () => {
|
||||
expect(jsonGet(testJson, 'workspace_roots[0]')).toBe('/path/to/project');
|
||||
});
|
||||
|
||||
it('gets array element with higher index', () => {
|
||||
expect(jsonGet(testJson, 'workspace_roots[1]')).toBe('/another/path');
|
||||
});
|
||||
|
||||
it('returns fallback for missing field', () => {
|
||||
expect(jsonGet(testJson, 'nonexistent', 'default')).toBe('default');
|
||||
});
|
||||
|
||||
it('returns fallback for out-of-bounds array access', () => {
|
||||
expect(jsonGet(testJson, 'workspace_roots[99]', 'default')).toBe('default');
|
||||
});
|
||||
|
||||
it('returns fallback for array access on non-array', () => {
|
||||
expect(jsonGet(testJson, 'conversation_id[0]', 'default')).toBe('default');
|
||||
});
|
||||
|
||||
it('returns empty string fallback by default', () => {
|
||||
expect(jsonGet(testJson, 'nonexistent')).toBe('');
|
||||
});
|
||||
|
||||
it('returns fallback for null value', () => {
|
||||
expect(jsonGet(testJson, 'null_value', 'fallback')).toBe('fallback');
|
||||
});
|
||||
|
||||
it('returns empty string value (not fallback)', () => {
|
||||
// Empty string is a valid value, should not trigger fallback
|
||||
expect(jsonGet(testJson, 'empty_string', 'fallback')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProjectName', () => {
|
||||
it('extracts basename from Unix path', () => {
|
||||
expect(getProjectName('/Users/alex/projects/my-project')).toBe('my-project');
|
||||
});
|
||||
|
||||
it('extracts basename from Windows path', () => {
|
||||
expect(getProjectName('C:\\Users\\alex\\projects\\my-project')).toBe('my-project');
|
||||
});
|
||||
|
||||
it('handles path with trailing slash', () => {
|
||||
expect(getProjectName('/path/to/project/')).toBe('project');
|
||||
});
|
||||
|
||||
it('returns unknown-project for empty string', () => {
|
||||
expect(getProjectName('')).toBe('unknown-project');
|
||||
});
|
||||
|
||||
it('handles Windows drive root C:\\', () => {
|
||||
expect(getProjectName('C:\\')).toBe('drive-C');
|
||||
});
|
||||
|
||||
it('handles Windows drive root C:', () => {
|
||||
expect(getProjectName('C:')).toBe('drive-C');
|
||||
});
|
||||
|
||||
it('handles lowercase drive letter', () => {
|
||||
expect(getProjectName('d:\\')).toBe('drive-D');
|
||||
});
|
||||
|
||||
it('handles project name with dots', () => {
|
||||
expect(getProjectName('/path/to/my.project.v2')).toBe('my.project.v2');
|
||||
});
|
||||
|
||||
it('handles project name with spaces', () => {
|
||||
expect(getProjectName('/path/to/My Project')).toBe('My Project');
|
||||
});
|
||||
|
||||
it('handles project name with special characters', () => {
|
||||
expect(getProjectName('/path/to/project-name_v2.0')).toBe('project-name_v2.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEmpty', () => {
|
||||
it('returns true for null', () => {
|
||||
expect(isEmpty(null)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for undefined', () => {
|
||||
expect(isEmpty(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for empty string', () => {
|
||||
expect(isEmpty('')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for literal "null" string', () => {
|
||||
// This is important - jq returns "null" as string when value is null
|
||||
expect(isEmpty('null')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for literal "empty" string', () => {
|
||||
expect(isEmpty('empty')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-empty string', () => {
|
||||
expect(isEmpty('some-value')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for whitespace-only string', () => {
|
||||
// Whitespace is not empty
|
||||
expect(isEmpty(' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for "0" string', () => {
|
||||
expect(isEmpty('0')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for "false" string', () => {
|
||||
expect(isEmpty('false')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('urlEncode', () => {
|
||||
it('encodes spaces', () => {
|
||||
expect(urlEncode('hello world')).toBe('hello%20world');
|
||||
});
|
||||
|
||||
it('encodes special characters', () => {
|
||||
expect(urlEncode('a&b=c')).toBe('a%26b%3Dc');
|
||||
});
|
||||
|
||||
it('encodes unicode', () => {
|
||||
const encoded = urlEncode('日本語');
|
||||
expect(encoded).toContain('%');
|
||||
expect(decodeURIComponent(encoded)).toBe('日本語');
|
||||
});
|
||||
|
||||
it('preserves alphanumeric characters', () => {
|
||||
expect(urlEncode('abc123')).toBe('abc123');
|
||||
});
|
||||
|
||||
it('preserves dashes and underscores', () => {
|
||||
expect(urlEncode('my-project_name')).toBe('my-project_name');
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(urlEncode('')).toBe('');
|
||||
});
|
||||
|
||||
it('encodes forward slash', () => {
|
||||
expect(urlEncode('path/to/file')).toBe('path%2Fto%2Ffile');
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration: hook payload parsing', () => {
|
||||
// Simulates parsing a real Cursor hook payload
|
||||
|
||||
it('extracts all fields from typical beforeSubmitPrompt payload', () => {
|
||||
const payload = {
|
||||
conversation_id: 'abc-123',
|
||||
generation_id: 'gen-456',
|
||||
prompt: 'Fix the bug',
|
||||
workspace_roots: ['/Users/alex/projects/my-project'],
|
||||
hook_event_name: 'beforeSubmitPrompt'
|
||||
};
|
||||
|
||||
const conversationId = jsonGet(payload, 'conversation_id');
|
||||
const workspaceRoot = jsonGet(payload, 'workspace_roots[0]');
|
||||
const projectName = getProjectName(workspaceRoot);
|
||||
const hookEvent = jsonGet(payload, 'hook_event_name');
|
||||
|
||||
expect(conversationId).toBe('abc-123');
|
||||
expect(workspaceRoot).toBe('/Users/alex/projects/my-project');
|
||||
expect(projectName).toBe('my-project');
|
||||
expect(hookEvent).toBe('beforeSubmitPrompt');
|
||||
});
|
||||
|
||||
it('handles payload with missing optional fields', () => {
|
||||
const payload = {
|
||||
generation_id: 'gen-456',
|
||||
// No conversation_id, no workspace_roots
|
||||
};
|
||||
|
||||
const conversationId = jsonGet(payload, 'conversation_id', '');
|
||||
const workspaceRoot = jsonGet(payload, 'workspace_roots[0]', '');
|
||||
|
||||
expect(isEmpty(conversationId)).toBe(true);
|
||||
expect(isEmpty(workspaceRoot)).toBe(true);
|
||||
});
|
||||
|
||||
it('constructs valid API URL with encoded project name', () => {
|
||||
const projectName = 'my project (v2)';
|
||||
const port = 37777;
|
||||
const encoded = urlEncode(projectName);
|
||||
|
||||
const url = `http://127.0.0.1:${port}/api/context/inject?project=${encoded}`;
|
||||
|
||||
expect(url).toBe('http://127.0.0.1:37777/api/context/inject?project=my%20project%20(v2)');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
configureCursorMcp,
|
||||
removeMcpConfig,
|
||||
type CursorMcpConfig
|
||||
} from '../src/utils/cursor-utils';
|
||||
|
||||
/**
|
||||
* Tests for Cursor MCP Configuration
|
||||
*
|
||||
* These tests validate the MCP server configuration that gets written
|
||||
* to .cursor/mcp.json (project-level) or ~/.cursor/mcp.json (user-level).
|
||||
*
|
||||
* The config must match Cursor's expected format for MCP servers.
|
||||
*/
|
||||
|
||||
describe('Cursor MCP Configuration', () => {
|
||||
let tempDir: string;
|
||||
let mcpJsonPath: string;
|
||||
const mcpServerPath = '/path/to/mcp-server.cjs';
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test
|
||||
tempDir = join(tmpdir(), `cursor-mcp-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
mcpJsonPath = join(tempDir, '.cursor', 'mcp.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('configureCursorMcp', () => {
|
||||
it('creates mcp.json if it does not exist', () => {
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
expect(existsSync(mcpJsonPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('creates .cursor directory if it does not exist', () => {
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
expect(existsSync(join(tempDir, '.cursor'))).toBe(true);
|
||||
});
|
||||
|
||||
it('adds claude-mem server with correct structure', () => {
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
|
||||
expect(config.mcpServers).toBeDefined();
|
||||
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||
expect(config.mcpServers['claude-mem'].command).toBe('node');
|
||||
expect(config.mcpServers['claude-mem'].args).toEqual([mcpServerPath]);
|
||||
});
|
||||
|
||||
it('preserves existing MCP servers when adding claude-mem', () => {
|
||||
// Pre-create config with another server
|
||||
mkdirSync(join(tempDir, '.cursor'), { recursive: true });
|
||||
const existingConfig = {
|
||||
mcpServers: {
|
||||
'other-server': {
|
||||
command: 'python',
|
||||
args: ['/path/to/other.py']
|
||||
}
|
||||
}
|
||||
};
|
||||
writeFileSync(mcpJsonPath, JSON.stringify(existingConfig));
|
||||
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
|
||||
// Both servers should exist
|
||||
expect(config.mcpServers['other-server']).toBeDefined();
|
||||
expect(config.mcpServers['other-server'].command).toBe('python');
|
||||
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||
});
|
||||
|
||||
it('updates existing claude-mem server path', () => {
|
||||
// First config
|
||||
configureCursorMcp(mcpJsonPath, '/old/path.cjs');
|
||||
|
||||
// Update with new path
|
||||
const newPath = '/new/path.cjs';
|
||||
configureCursorMcp(mcpJsonPath, newPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
|
||||
expect(config.mcpServers['claude-mem'].args).toEqual([newPath]);
|
||||
});
|
||||
|
||||
it('recovers from corrupt mcp.json', () => {
|
||||
// Create corrupt file
|
||||
mkdirSync(join(tempDir, '.cursor'), { recursive: true });
|
||||
writeFileSync(mcpJsonPath, 'not valid json {{{{');
|
||||
|
||||
// Should not throw, should overwrite
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles mcp.json with missing mcpServers key', () => {
|
||||
// Create file with empty object
|
||||
mkdirSync(join(tempDir, '.cursor'), { recursive: true });
|
||||
writeFileSync(mcpJsonPath, '{}');
|
||||
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP config format validation', () => {
|
||||
it('produces valid JSON', () => {
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const content = readFileSync(mcpJsonPath, 'utf-8');
|
||||
|
||||
// Should not throw
|
||||
expect(() => JSON.parse(content)).not.toThrow();
|
||||
});
|
||||
|
||||
it('uses pretty-printed JSON (2-space indent)', () => {
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const content = readFileSync(mcpJsonPath, 'utf-8');
|
||||
|
||||
// Should contain newlines and indentation
|
||||
expect(content).toContain('\n');
|
||||
expect(content).toContain(' "mcpServers"');
|
||||
});
|
||||
|
||||
it('matches Cursor MCP server schema', () => {
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const config = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
|
||||
// Top-level must have mcpServers
|
||||
expect(config).toHaveProperty('mcpServers');
|
||||
expect(typeof config.mcpServers).toBe('object');
|
||||
|
||||
// Each server must have command (string) and optionally args (array)
|
||||
for (const [name, server] of Object.entries(config.mcpServers)) {
|
||||
expect(typeof name).toBe('string');
|
||||
expect((server as { command: string }).command).toBeDefined();
|
||||
expect(typeof (server as { command: string }).command).toBe('string');
|
||||
|
||||
const args = (server as { args?: string[] }).args;
|
||||
if (args !== undefined) {
|
||||
expect(Array.isArray(args)).toBe(true);
|
||||
args.forEach((arg: string) => expect(typeof arg).toBe('string'));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMcpConfig', () => {
|
||||
it('removes claude-mem server from config', () => {
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
removeMcpConfig(mcpJsonPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(config.mcpServers['claude-mem']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves other servers when removing claude-mem', () => {
|
||||
// Setup: both servers
|
||||
mkdirSync(join(tempDir, '.cursor'), { recursive: true });
|
||||
const config = {
|
||||
mcpServers: {
|
||||
'other-server': { command: 'python', args: ['/path.py'] },
|
||||
'claude-mem': { command: 'node', args: ['/mcp.cjs'] }
|
||||
}
|
||||
};
|
||||
writeFileSync(mcpJsonPath, JSON.stringify(config));
|
||||
|
||||
removeMcpConfig(mcpJsonPath);
|
||||
|
||||
const updated: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(updated.mcpServers['other-server']).toBeDefined();
|
||||
expect(updated.mcpServers['claude-mem']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does nothing if mcp.json does not exist', () => {
|
||||
// Should not throw
|
||||
expect(() => removeMcpConfig(mcpJsonPath)).not.toThrow();
|
||||
expect(existsSync(mcpJsonPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('does nothing if claude-mem not in config', () => {
|
||||
mkdirSync(join(tempDir, '.cursor'), { recursive: true });
|
||||
const config = {
|
||||
mcpServers: {
|
||||
'other-server': { command: 'python', args: ['/path.py'] }
|
||||
}
|
||||
};
|
||||
writeFileSync(mcpJsonPath, JSON.stringify(config));
|
||||
|
||||
removeMcpConfig(mcpJsonPath);
|
||||
|
||||
const updated: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(updated.mcpServers['other-server']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('path handling', () => {
|
||||
it('handles absolute path with spaces', () => {
|
||||
const pathWithSpaces = '/path/to/my project/mcp-server.cjs';
|
||||
configureCursorMcp(mcpJsonPath, pathWithSpaces);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(config.mcpServers['claude-mem'].args).toEqual([pathWithSpaces]);
|
||||
});
|
||||
|
||||
it('handles Windows-style path', () => {
|
||||
const windowsPath = 'C:\\Users\\alex\\.claude\\plugins\\mcp-server.cjs';
|
||||
configureCursorMcp(mcpJsonPath, windowsPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(config.mcpServers['claude-mem'].args).toEqual([windowsPath]);
|
||||
});
|
||||
|
||||
it('handles path with special characters', () => {
|
||||
const specialPath = "/path/to/project-name_v2.0 (beta)/mcp-server.cjs";
|
||||
configureCursorMcp(mcpJsonPath, specialPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(config.mcpServers['claude-mem'].args).toEqual([specialPath]);
|
||||
|
||||
// Verify it survives JSON round-trip
|
||||
const reread: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(reread.mcpServers['claude-mem'].args![0]).toBe(specialPath);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
readCursorRegistry,
|
||||
writeCursorRegistry,
|
||||
registerCursorProject,
|
||||
unregisterCursorProject
|
||||
} from '../src/utils/cursor-utils';
|
||||
|
||||
/**
|
||||
* Tests for Cursor Project Registry functionality
|
||||
*
|
||||
* These tests validate the file-based registry that tracks which projects
|
||||
* have Cursor hooks installed for automatic context updates.
|
||||
*
|
||||
* The registry is stored at ~/.claude-mem/cursor-projects.json
|
||||
*/
|
||||
|
||||
describe('Cursor Project Registry', () => {
|
||||
let tempDir: string;
|
||||
let registryFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test
|
||||
tempDir = join(tmpdir(), `cursor-registry-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
registryFile = join(tempDir, 'cursor-projects.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('readCursorRegistry', () => {
|
||||
it('returns empty object when registry file does not exist', () => {
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(registry).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty object when registry file is corrupt JSON', () => {
|
||||
writeFileSync(registryFile, 'not valid json {{{');
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(registry).toEqual({});
|
||||
});
|
||||
|
||||
it('returns parsed registry when file exists', () => {
|
||||
const expected = {
|
||||
'my-project': {
|
||||
workspacePath: '/home/user/projects/my-project',
|
||||
installedAt: '2025-01-01T00:00:00.000Z'
|
||||
}
|
||||
};
|
||||
writeFileSync(registryFile, JSON.stringify(expected));
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(registry).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerCursorProject', () => {
|
||||
it('creates registry file if it does not exist', () => {
|
||||
registerCursorProject(registryFile, 'new-project', '/path/to/project');
|
||||
|
||||
expect(existsSync(registryFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('stores project with workspacePath and installedAt', () => {
|
||||
const before = Date.now();
|
||||
registerCursorProject(registryFile, 'test-project', '/workspace/test');
|
||||
const after = Date.now();
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(registry['test-project']).toBeDefined();
|
||||
expect(registry['test-project'].workspacePath).toBe('/workspace/test');
|
||||
|
||||
// Verify installedAt is a valid ISO timestamp within the test window
|
||||
const installedAt = new Date(registry['test-project'].installedAt).getTime();
|
||||
expect(installedAt).toBeGreaterThanOrEqual(before);
|
||||
expect(installedAt).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it('preserves existing projects when registering new one', () => {
|
||||
registerCursorProject(registryFile, 'project-a', '/path/a');
|
||||
registerCursorProject(registryFile, 'project-b', '/path/b');
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(Object.keys(registry)).toHaveLength(2);
|
||||
expect(registry['project-a'].workspacePath).toBe('/path/a');
|
||||
expect(registry['project-b'].workspacePath).toBe('/path/b');
|
||||
});
|
||||
|
||||
it('overwrites existing project with same name', () => {
|
||||
registerCursorProject(registryFile, 'my-project', '/old/path');
|
||||
registerCursorProject(registryFile, 'my-project', '/new/path');
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(Object.keys(registry)).toHaveLength(1);
|
||||
expect(registry['my-project'].workspacePath).toBe('/new/path');
|
||||
});
|
||||
|
||||
it('handles special characters in project name', () => {
|
||||
const projectName = 'my-project_v2.0 (beta)';
|
||||
registerCursorProject(registryFile, projectName, '/path/to/project');
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(registry[projectName]).toBeDefined();
|
||||
expect(registry[projectName].workspacePath).toBe('/path/to/project');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unregisterCursorProject', () => {
|
||||
it('removes specified project from registry', () => {
|
||||
registerCursorProject(registryFile, 'project-a', '/path/a');
|
||||
registerCursorProject(registryFile, 'project-b', '/path/b');
|
||||
|
||||
unregisterCursorProject(registryFile, 'project-a');
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(registry['project-a']).toBeUndefined();
|
||||
expect(registry['project-b']).toBeDefined();
|
||||
});
|
||||
|
||||
it('does nothing when unregistering non-existent project', () => {
|
||||
registerCursorProject(registryFile, 'existing', '/path');
|
||||
|
||||
// Should not throw
|
||||
unregisterCursorProject(registryFile, 'non-existent');
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
expect(registry['existing']).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles unregister when registry file does not exist', () => {
|
||||
// Should not throw even when file doesn't exist
|
||||
unregisterCursorProject(registryFile, 'any-project');
|
||||
|
||||
// File should not be created by unregister
|
||||
expect(existsSync(registryFile)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registry format validation', () => {
|
||||
it('stores registry as pretty-printed JSON', () => {
|
||||
registerCursorProject(registryFile, 'test', '/path');
|
||||
|
||||
const content = readFileSync(registryFile, 'utf-8');
|
||||
// Should be indented (pretty-printed)
|
||||
expect(content).toContain('\n');
|
||||
expect(content).toContain(' ');
|
||||
});
|
||||
|
||||
it('registry file is valid JSON that can be read by other tools', () => {
|
||||
registerCursorProject(registryFile, 'project-1', '/path/1');
|
||||
registerCursorProject(registryFile, 'project-2', '/path/2');
|
||||
|
||||
// Read raw and parse with JSON.parse (not our helper)
|
||||
const content = readFileSync(registryFile, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
expect(parsed).toHaveProperty('project-1');
|
||||
expect(parsed).toHaveProperty('project-2');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user