mirror of
https://github.com/thedotmack/claude-mem
synced 2026-04-25 17:15:04 +02:00
- 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.
266 lines
8.2 KiB
TypeScript
266 lines
8.2 KiB
TypeScript
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)');
|
|
});
|
|
});
|
|
});
|