diff --git a/src/services/worker-service.ts b/src/services/worker-service.ts index 95ff6042..d8d3839d 100644 --- a/src/services/worker-service.ts +++ b/src/services/worker-service.ts @@ -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 }); } diff --git a/src/utils/cursor-utils.ts b/src/utils/cursor-utils.ts new file mode 100644 index 00000000..c171bcf8 --- /dev/null +++ b/src/utils/cursor-utils.ts @@ -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; + }; + }; +} + +// ============================================================================ +// 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, 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); +} diff --git a/tests/cursor-context-update.test.ts b/tests/cursor-context-update.test.ts new file mode 100644 index 00000000..44e21c72 --- /dev/null +++ b/tests/cursor-context-update.test.ts @@ -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_ $variable @mention #tag'; + + writeContextFile(workspacePath, specialContext); + + const content = readContextFile(workspacePath); + expect(content).toContain('`code`'); + expect(content).toContain('**bold**'); + expect(content).toContain(''); + }); + + 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'); + }); + }); +}); diff --git a/tests/cursor-hook-outputs.test.ts b/tests/cursor-hook-outputs.test.ts new file mode 100644 index 00000000..4f6bd739 --- /dev/null +++ b/tests/cursor-hook-outputs.test.ts @@ -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(); + }); + }); +}); diff --git a/tests/cursor-hooks-json-utils.test.ts b/tests/cursor-hooks-json-utils.test.ts new file mode 100644 index 00000000..2cf00329 --- /dev/null +++ b/tests/cursor-hooks-json-utils.test.ts @@ -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)'); + }); + }); +}); diff --git a/tests/cursor-mcp-config.test.ts b/tests/cursor-mcp-config.test.ts new file mode 100644 index 00000000..0d0bbc99 --- /dev/null +++ b/tests/cursor-mcp-config.test.ts @@ -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); + }); + }); +}); diff --git a/tests/cursor-registry.test.ts b/tests/cursor-registry.test.ts new file mode 100644 index 00000000..71e8031b --- /dev/null +++ b/tests/cursor-registry.test.ts @@ -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'); + }); + }); +});