wip: [01-stabilize] paused at task 1/1 - OCR Hallucination Immune logic via Semantic delta window and fret-isolation
This commit is contained in:
13
.agent/services/claude-mem/tests/infrastructure/CLAUDE.md
Normal file
13
.agent/services/claude-mem/tests/infrastructure/CLAUDE.md
Normal file
@@ -0,0 +1,13 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
### Jan 4, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36870 | 1:54 AM | 🟣 | Phase 2 Implementation Completed via Subagent | ~572 |
|
||||
| #36866 | 1:53 AM | 🔄 | WMIC Test Refactored to Use Direct Logic Testing | ~533 |
|
||||
| #36865 | 1:52 AM | ✅ | WMIC Test File Updated with Improved Mock Implementation | ~370 |
|
||||
| #36863 | 1:51 AM | 🟣 | WMIC Parsing Test File Created | ~581 |
|
||||
| #36861 | " | 🔵 | Existing ProcessManager Test File Structure Analyzed | ~516 |
|
||||
</claude-mem-context>
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import path from 'path';
|
||||
import http from 'http';
|
||||
import {
|
||||
performGracefulShutdown,
|
||||
writePidFile,
|
||||
readPidFile,
|
||||
removePidFile,
|
||||
type GracefulShutdownConfig,
|
||||
type ShutdownableService,
|
||||
type CloseableClient,
|
||||
type CloseableDatabase,
|
||||
type PidInfo
|
||||
} from '../../src/services/infrastructure/index.js';
|
||||
|
||||
const DATA_DIR = path.join(homedir(), '.claude-mem');
|
||||
const PID_FILE = path.join(DATA_DIR, 'worker.pid');
|
||||
|
||||
describe('GracefulShutdown', () => {
|
||||
// Store original PID file content if it exists
|
||||
let originalPidContent: string | null = null;
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
// Backup existing PID file if present
|
||||
if (existsSync(PID_FILE)) {
|
||||
originalPidContent = readFileSync(PID_FILE, 'utf-8');
|
||||
}
|
||||
|
||||
// Ensure we're testing on non-Windows to avoid child process enumeration
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original PID file or remove test one
|
||||
if (originalPidContent !== null) {
|
||||
const { writeFileSync } = require('fs');
|
||||
writeFileSync(PID_FILE, originalPidContent);
|
||||
originalPidContent = null;
|
||||
} else {
|
||||
removePidFile();
|
||||
}
|
||||
|
||||
// Restore platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
});
|
||||
|
||||
describe('performGracefulShutdown', () => {
|
||||
it('should call shutdown steps in correct order', async () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
const mockServer = {
|
||||
closeAllConnections: mock(() => {
|
||||
callOrder.push('closeAllConnections');
|
||||
}),
|
||||
close: mock((cb: (err?: Error) => void) => {
|
||||
callOrder.push('serverClose');
|
||||
cb();
|
||||
})
|
||||
} as unknown as http.Server;
|
||||
|
||||
const mockSessionManager: ShutdownableService = {
|
||||
shutdownAll: mock(async () => {
|
||||
callOrder.push('sessionManager.shutdownAll');
|
||||
})
|
||||
};
|
||||
|
||||
const mockMcpClient: CloseableClient = {
|
||||
close: mock(async () => {
|
||||
callOrder.push('mcpClient.close');
|
||||
})
|
||||
};
|
||||
|
||||
const mockDbManager: CloseableDatabase = {
|
||||
close: mock(async () => {
|
||||
callOrder.push('dbManager.close');
|
||||
})
|
||||
};
|
||||
|
||||
const mockChromaMcpManager = {
|
||||
stop: mock(async () => {
|
||||
callOrder.push('chromaMcpManager.stop');
|
||||
})
|
||||
};
|
||||
|
||||
// Create a PID file so we can verify it's removed
|
||||
writePidFile({ pid: 12345, port: 37777, startedAt: new Date().toISOString() });
|
||||
expect(existsSync(PID_FILE)).toBe(true);
|
||||
|
||||
const config: GracefulShutdownConfig = {
|
||||
server: mockServer,
|
||||
sessionManager: mockSessionManager,
|
||||
mcpClient: mockMcpClient,
|
||||
dbManager: mockDbManager,
|
||||
chromaMcpManager: mockChromaMcpManager
|
||||
};
|
||||
|
||||
await performGracefulShutdown(config);
|
||||
|
||||
// Verify order: PID removal happens first (synchronous), then server, then session, then MCP, then Chroma, then DB
|
||||
expect(callOrder).toContain('closeAllConnections');
|
||||
expect(callOrder).toContain('serverClose');
|
||||
expect(callOrder).toContain('sessionManager.shutdownAll');
|
||||
expect(callOrder).toContain('mcpClient.close');
|
||||
expect(callOrder).toContain('chromaMcpManager.stop');
|
||||
expect(callOrder).toContain('dbManager.close');
|
||||
|
||||
// Verify server closes before session manager
|
||||
expect(callOrder.indexOf('serverClose')).toBeLessThan(callOrder.indexOf('sessionManager.shutdownAll'));
|
||||
|
||||
// Verify session manager shuts down before MCP client
|
||||
expect(callOrder.indexOf('sessionManager.shutdownAll')).toBeLessThan(callOrder.indexOf('mcpClient.close'));
|
||||
|
||||
// Verify MCP closes before database
|
||||
expect(callOrder.indexOf('mcpClient.close')).toBeLessThan(callOrder.indexOf('dbManager.close'));
|
||||
|
||||
// Verify Chroma stops before DB closes
|
||||
expect(callOrder.indexOf('chromaMcpManager.stop')).toBeLessThan(callOrder.indexOf('dbManager.close'));
|
||||
});
|
||||
|
||||
it('should remove PID file during shutdown', async () => {
|
||||
const mockSessionManager: ShutdownableService = {
|
||||
shutdownAll: mock(async () => {})
|
||||
};
|
||||
|
||||
// Create PID file
|
||||
writePidFile({ pid: 99999, port: 37777, startedAt: new Date().toISOString() });
|
||||
expect(existsSync(PID_FILE)).toBe(true);
|
||||
|
||||
const config: GracefulShutdownConfig = {
|
||||
server: null,
|
||||
sessionManager: mockSessionManager
|
||||
};
|
||||
|
||||
await performGracefulShutdown(config);
|
||||
|
||||
// PID file should be removed
|
||||
expect(existsSync(PID_FILE)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle missing optional services gracefully', async () => {
|
||||
const mockSessionManager: ShutdownableService = {
|
||||
shutdownAll: mock(async () => {})
|
||||
};
|
||||
|
||||
const config: GracefulShutdownConfig = {
|
||||
server: null,
|
||||
sessionManager: mockSessionManager
|
||||
// mcpClient and dbManager are undefined
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
await expect(performGracefulShutdown(config)).resolves.toBeUndefined();
|
||||
|
||||
// Session manager should still be called
|
||||
expect(mockSessionManager.shutdownAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle null server gracefully', async () => {
|
||||
const mockSessionManager: ShutdownableService = {
|
||||
shutdownAll: mock(async () => {})
|
||||
};
|
||||
|
||||
const config: GracefulShutdownConfig = {
|
||||
server: null,
|
||||
sessionManager: mockSessionManager
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
await expect(performGracefulShutdown(config)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should call sessionManager.shutdownAll even without server', async () => {
|
||||
const mockSessionManager: ShutdownableService = {
|
||||
shutdownAll: mock(async () => {})
|
||||
};
|
||||
|
||||
const config: GracefulShutdownConfig = {
|
||||
server: null,
|
||||
sessionManager: mockSessionManager
|
||||
};
|
||||
|
||||
await performGracefulShutdown(config);
|
||||
|
||||
expect(mockSessionManager.shutdownAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should stop chroma server before database close', async () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
const mockSessionManager: ShutdownableService = {
|
||||
shutdownAll: mock(async () => {
|
||||
callOrder.push('sessionManager');
|
||||
})
|
||||
};
|
||||
|
||||
const mockMcpClient: CloseableClient = {
|
||||
close: mock(async () => {
|
||||
callOrder.push('mcpClient');
|
||||
})
|
||||
};
|
||||
|
||||
const mockDbManager: CloseableDatabase = {
|
||||
close: mock(async () => {
|
||||
callOrder.push('dbManager');
|
||||
})
|
||||
};
|
||||
|
||||
const mockChromaMcpManager = {
|
||||
stop: mock(async () => {
|
||||
callOrder.push('chromaMcpManager');
|
||||
})
|
||||
};
|
||||
|
||||
const config: GracefulShutdownConfig = {
|
||||
server: null,
|
||||
sessionManager: mockSessionManager,
|
||||
mcpClient: mockMcpClient,
|
||||
dbManager: mockDbManager,
|
||||
chromaMcpManager: mockChromaMcpManager
|
||||
};
|
||||
|
||||
await performGracefulShutdown(config);
|
||||
|
||||
expect(callOrder).toEqual(['sessionManager', 'mcpClient', 'chromaMcpManager', 'dbManager']);
|
||||
});
|
||||
|
||||
it('should handle shutdown when PID file does not exist', async () => {
|
||||
// Ensure PID file doesn't exist
|
||||
removePidFile();
|
||||
expect(existsSync(PID_FILE)).toBe(false);
|
||||
|
||||
const mockSessionManager: ShutdownableService = {
|
||||
shutdownAll: mock(async () => {})
|
||||
};
|
||||
|
||||
const config: GracefulShutdownConfig = {
|
||||
server: null,
|
||||
sessionManager: mockSessionManager
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
await expect(performGracefulShutdown(config)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
|
||||
import {
|
||||
isPortInUse,
|
||||
waitForHealth,
|
||||
waitForPortFree,
|
||||
getInstalledPluginVersion,
|
||||
checkVersionMatch
|
||||
} from '../../src/services/infrastructure/index.js';
|
||||
|
||||
describe('HealthMonitor', () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe('isPortInUse', () => {
|
||||
it('should return true for occupied port (health check succeeds)', async () => {
|
||||
global.fetch = mock(() => Promise.resolve({ ok: true } as Response));
|
||||
|
||||
const result = await isPortInUse(37777);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledWith('http://127.0.0.1:37777/api/health');
|
||||
});
|
||||
|
||||
it('should return false for free port (connection refused)', async () => {
|
||||
global.fetch = mock(() => Promise.reject(new Error('ECONNREFUSED')));
|
||||
|
||||
const result = await isPortInUse(39999);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when health check returns non-ok', async () => {
|
||||
global.fetch = mock(() => Promise.resolve({ ok: false, status: 503 } as Response));
|
||||
|
||||
const result = await isPortInUse(37777);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false on network timeout', async () => {
|
||||
global.fetch = mock(() => Promise.reject(new Error('ETIMEDOUT')));
|
||||
|
||||
const result = await isPortInUse(37777);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false on fetch failed error', async () => {
|
||||
global.fetch = mock(() => Promise.reject(new Error('fetch failed')));
|
||||
|
||||
const result = await isPortInUse(37777);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitForHealth', () => {
|
||||
it('should succeed immediately when server responds', async () => {
|
||||
global.fetch = mock(() => Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve('')
|
||||
} as unknown as Response));
|
||||
|
||||
const start = Date.now();
|
||||
const result = await waitForHealth(37777, 5000);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(result).toBe(true);
|
||||
// Should return quickly (within first poll cycle)
|
||||
expect(elapsed).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('should timeout when no server responds', async () => {
|
||||
global.fetch = mock(() => Promise.reject(new Error('ECONNREFUSED')));
|
||||
|
||||
const start = Date.now();
|
||||
const result = await waitForHealth(39999, 1500);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(result).toBe(false);
|
||||
// Should take close to timeout duration
|
||||
expect(elapsed).toBeGreaterThanOrEqual(1400);
|
||||
expect(elapsed).toBeLessThan(2500);
|
||||
});
|
||||
|
||||
it('should succeed after server becomes available', async () => {
|
||||
let callCount = 0;
|
||||
global.fetch = mock(() => {
|
||||
callCount++;
|
||||
// Fail first 2 calls, succeed on third
|
||||
if (callCount < 3) {
|
||||
return Promise.reject(new Error('ECONNREFUSED'));
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve('')
|
||||
} as unknown as Response);
|
||||
});
|
||||
|
||||
const result = await waitForHealth(37777, 5000);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(callCount).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should check health endpoint for liveness', async () => {
|
||||
const fetchMock = mock(() => Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve('')
|
||||
} as unknown as Response));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
await waitForHealth(37777, 1000);
|
||||
|
||||
// waitForHealth uses /api/health (liveness), not /api/readiness
|
||||
// This is because hooks have 15-second timeout but full initialization can take 5+ minutes
|
||||
// See: https://github.com/thedotmack/claude-mem/issues/811
|
||||
const calls = fetchMock.mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
expect(calls[0][0]).toBe('http://127.0.0.1:37777/api/health');
|
||||
});
|
||||
|
||||
it('should use default timeout when not specified', async () => {
|
||||
global.fetch = mock(() => Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve('')
|
||||
} as unknown as Response));
|
||||
|
||||
// Just verify it doesn't throw and returns quickly
|
||||
const result = await waitForHealth(37777);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInstalledPluginVersion', () => {
|
||||
it('should return a valid semver string', () => {
|
||||
const version = getInstalledPluginVersion();
|
||||
|
||||
// Should be a string matching semver pattern or 'unknown'
|
||||
if (version !== 'unknown') {
|
||||
expect(version).toMatch(/^\d+\.\d+\.\d+/);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not throw on ENOENT (graceful degradation)', () => {
|
||||
// The function handles ENOENT internally — should not throw
|
||||
// If package.json exists, it returns the version; if not, 'unknown'
|
||||
expect(() => getInstalledPluginVersion()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkVersionMatch', () => {
|
||||
it('should assume match when worker version is unavailable', async () => {
|
||||
global.fetch = mock(() => Promise.reject(new Error('ECONNREFUSED')));
|
||||
|
||||
const result = await checkVersionMatch(39999);
|
||||
|
||||
expect(result.matches).toBe(true);
|
||||
expect(result.workerVersion).toBeNull();
|
||||
});
|
||||
|
||||
it('should detect version mismatch', async () => {
|
||||
global.fetch = mock(() => Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ version: '0.0.0-definitely-wrong' }))
|
||||
} as unknown as Response));
|
||||
|
||||
const result = await checkVersionMatch(37777);
|
||||
|
||||
// Unless the plugin version is also '0.0.0-definitely-wrong', this should be a mismatch
|
||||
const pluginVersion = getInstalledPluginVersion();
|
||||
if (pluginVersion !== 'unknown' && pluginVersion !== '0.0.0-definitely-wrong') {
|
||||
expect(result.matches).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should detect version match', async () => {
|
||||
const pluginVersion = getInstalledPluginVersion();
|
||||
if (pluginVersion === 'unknown') return; // Skip if can't read plugin version
|
||||
|
||||
global.fetch = mock(() => Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(JSON.stringify({ version: pluginVersion }))
|
||||
} as unknown as Response));
|
||||
|
||||
const result = await checkVersionMatch(37777);
|
||||
|
||||
expect(result.matches).toBe(true);
|
||||
expect(result.pluginVersion).toBe(pluginVersion);
|
||||
expect(result.workerVersion).toBe(pluginVersion);
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitForPortFree', () => {
|
||||
it('should return true immediately when port is already free', async () => {
|
||||
global.fetch = mock(() => Promise.reject(new Error('ECONNREFUSED')));
|
||||
|
||||
const start = Date.now();
|
||||
const result = await waitForPortFree(39999, 5000);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(result).toBe(true);
|
||||
// Should return quickly
|
||||
expect(elapsed).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('should timeout when port remains occupied', async () => {
|
||||
global.fetch = mock(() => Promise.resolve({ ok: true } as Response));
|
||||
|
||||
const start = Date.now();
|
||||
const result = await waitForPortFree(37777, 1500);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(result).toBe(false);
|
||||
// Should take close to timeout duration
|
||||
expect(elapsed).toBeGreaterThanOrEqual(1400);
|
||||
expect(elapsed).toBeLessThan(2500);
|
||||
});
|
||||
|
||||
it('should succeed when port becomes free', async () => {
|
||||
let callCount = 0;
|
||||
global.fetch = mock(() => {
|
||||
callCount++;
|
||||
// Port occupied for first 2 checks, then free
|
||||
if (callCount < 3) {
|
||||
return Promise.resolve({ ok: true } as Response);
|
||||
}
|
||||
return Promise.reject(new Error('ECONNREFUSED'));
|
||||
});
|
||||
|
||||
const result = await waitForPortFree(37777, 5000);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(callCount).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should use default timeout when not specified', async () => {
|
||||
global.fetch = mock(() => Promise.reject(new Error('ECONNREFUSED')));
|
||||
|
||||
// Just verify it doesn't throw and returns quickly
|
||||
const result = await waitForPortFree(39999);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdirSync, writeFileSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { isPluginDisabledInClaudeSettings } from '../../src/shared/plugin-state.js';
|
||||
|
||||
/**
|
||||
* Tests for isPluginDisabledInClaudeSettings() (#781).
|
||||
*
|
||||
* The function reads CLAUDE_CONFIG_DIR/settings.json and checks if
|
||||
* enabledPlugins["claude-mem@thedotmack"] === false.
|
||||
*
|
||||
* We test by setting CLAUDE_CONFIG_DIR to a temp directory with mock settings.
|
||||
*/
|
||||
|
||||
let tempDir: string;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `plugin-disabled-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalClaudeConfigDir !== undefined) {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
} else {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('isPluginDisabledInClaudeSettings (#781)', () => {
|
||||
it('should return false when settings.json does not exist', () => {
|
||||
expect(isPluginDisabledInClaudeSettings()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when plugin is explicitly enabled', () => {
|
||||
const settings = {
|
||||
enabledPlugins: {
|
||||
'claude-mem@thedotmack': true
|
||||
}
|
||||
};
|
||||
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify(settings));
|
||||
expect(isPluginDisabledInClaudeSettings()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when plugin is explicitly disabled', () => {
|
||||
const settings = {
|
||||
enabledPlugins: {
|
||||
'claude-mem@thedotmack': false
|
||||
}
|
||||
};
|
||||
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify(settings));
|
||||
expect(isPluginDisabledInClaudeSettings()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when enabledPlugins key is missing', () => {
|
||||
const settings = {
|
||||
permissions: { allow: [] }
|
||||
};
|
||||
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify(settings));
|
||||
expect(isPluginDisabledInClaudeSettings()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when plugin key is absent from enabledPlugins', () => {
|
||||
const settings = {
|
||||
enabledPlugins: {
|
||||
'other-plugin@marketplace': true
|
||||
}
|
||||
};
|
||||
writeFileSync(join(tempDir, 'settings.json'), JSON.stringify(settings));
|
||||
expect(isPluginDisabledInClaudeSettings()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when settings.json contains invalid JSON', () => {
|
||||
writeFileSync(join(tempDir, 'settings.json'), '{ invalid json }}}');
|
||||
expect(isPluginDisabledInClaudeSettings()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when settings.json is empty', () => {
|
||||
writeFileSync(join(tempDir, 'settings.json'), '');
|
||||
expect(isPluginDisabledInClaudeSettings()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, '../..');
|
||||
|
||||
/**
|
||||
* Regression tests for plugin distribution completeness.
|
||||
* Ensures all required files (skills, hooks, manifests) are present
|
||||
* and correctly structured for end-user installs.
|
||||
*
|
||||
* Prevents issue #1187 (missing skills/ directory after install).
|
||||
*/
|
||||
describe('Plugin Distribution - Skills', () => {
|
||||
const skillPath = path.join(projectRoot, 'plugin/skills/mem-search/SKILL.md');
|
||||
|
||||
it('should include plugin/skills/mem-search/SKILL.md', () => {
|
||||
expect(existsSync(skillPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have valid YAML frontmatter with name and description', () => {
|
||||
const content = readFileSync(skillPath, 'utf-8');
|
||||
|
||||
// Must start with YAML frontmatter
|
||||
expect(content.startsWith('---\n')).toBe(true);
|
||||
|
||||
// Extract frontmatter
|
||||
const frontmatterEnd = content.indexOf('\n---\n', 4);
|
||||
expect(frontmatterEnd).toBeGreaterThan(0);
|
||||
|
||||
const frontmatter = content.slice(4, frontmatterEnd);
|
||||
expect(frontmatter).toContain('name:');
|
||||
expect(frontmatter).toContain('description:');
|
||||
});
|
||||
|
||||
it('should reference the 3-layer search workflow', () => {
|
||||
const content = readFileSync(skillPath, 'utf-8');
|
||||
// The skill must document the search → timeline → get_observations workflow
|
||||
expect(content).toContain('search');
|
||||
expect(content).toContain('timeline');
|
||||
expect(content).toContain('get_observations');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plugin Distribution - Required Files', () => {
|
||||
const requiredFiles = [
|
||||
'plugin/hooks/hooks.json',
|
||||
'plugin/.claude-plugin/plugin.json',
|
||||
'plugin/skills/mem-search/SKILL.md',
|
||||
];
|
||||
|
||||
for (const filePath of requiredFiles) {
|
||||
it(`should include ${filePath}`, () => {
|
||||
const fullPath = path.join(projectRoot, filePath);
|
||||
expect(existsSync(fullPath)).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Plugin Distribution - hooks.json Integrity', () => {
|
||||
it('should have valid JSON in hooks.json', () => {
|
||||
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
|
||||
const content = readFileSync(hooksPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.hooks).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reference CLAUDE_PLUGIN_ROOT in all hook commands', () => {
|
||||
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
|
||||
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
|
||||
|
||||
for (const [eventName, matchers] of Object.entries(parsed.hooks)) {
|
||||
for (const matcher of matchers as any[]) {
|
||||
for (const hook of matcher.hooks) {
|
||||
if (hook.type === 'command') {
|
||||
expect(hook.command).toContain('${CLAUDE_PLUGIN_ROOT}');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should include CLAUDE_PLUGIN_ROOT fallback in all hook commands (#1215)', () => {
|
||||
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
|
||||
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
|
||||
const expectedFallbackPath = '$HOME/.claude/plugins/marketplaces/thedotmack/plugin';
|
||||
|
||||
for (const [eventName, matchers] of Object.entries(parsed.hooks)) {
|
||||
for (const matcher of matchers as any[]) {
|
||||
for (const hook of matcher.hooks) {
|
||||
if (hook.type === 'command') {
|
||||
expect(hook.command).toContain(expectedFallbackPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plugin Distribution - package.json Files Field', () => {
|
||||
it('should include "plugin" in root package.json files field', () => {
|
||||
const packageJsonPath = path.join(projectRoot, 'package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
expect(packageJson.files).toBeDefined();
|
||||
expect(packageJson.files).toContain('plugin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plugin Distribution - Build Script Verification', () => {
|
||||
it('should verify distribution files in build-hooks.js', () => {
|
||||
const buildScriptPath = path.join(projectRoot, 'scripts/build-hooks.js');
|
||||
const content = readFileSync(buildScriptPath, 'utf-8');
|
||||
|
||||
// Build script must check for critical distribution files
|
||||
expect(content).toContain('plugin/skills/mem-search/SKILL.md');
|
||||
expect(content).toContain('plugin/hooks/hooks.json');
|
||||
expect(content).toContain('plugin/.claude-plugin/plugin.json');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,521 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { existsSync, readFileSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
writePidFile,
|
||||
readPidFile,
|
||||
removePidFile,
|
||||
getPlatformTimeout,
|
||||
parseElapsedTime,
|
||||
isProcessAlive,
|
||||
cleanStalePidFile,
|
||||
isPidFileRecent,
|
||||
touchPidFile,
|
||||
spawnDaemon,
|
||||
resolveWorkerRuntimePath,
|
||||
runOneTimeChromaMigration,
|
||||
type PidInfo
|
||||
} from '../../src/services/infrastructure/index.js';
|
||||
|
||||
const DATA_DIR = path.join(homedir(), '.claude-mem');
|
||||
const PID_FILE = path.join(DATA_DIR, 'worker.pid');
|
||||
|
||||
describe('ProcessManager', () => {
|
||||
// Store original PID file content if it exists
|
||||
let originalPidContent: string | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
// Backup existing PID file if present
|
||||
if (existsSync(PID_FILE)) {
|
||||
originalPidContent = readFileSync(PID_FILE, 'utf-8');
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original PID file or remove test one
|
||||
if (originalPidContent !== null) {
|
||||
writeFileSync(PID_FILE, originalPidContent);
|
||||
originalPidContent = null;
|
||||
} else {
|
||||
removePidFile();
|
||||
}
|
||||
});
|
||||
|
||||
describe('writePidFile', () => {
|
||||
it('should create file with PID info', () => {
|
||||
const testInfo: PidInfo = {
|
||||
pid: 12345,
|
||||
port: 37777,
|
||||
startedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
writePidFile(testInfo);
|
||||
|
||||
expect(existsSync(PID_FILE)).toBe(true);
|
||||
const content = JSON.parse(readFileSync(PID_FILE, 'utf-8'));
|
||||
expect(content.pid).toBe(12345);
|
||||
expect(content.port).toBe(37777);
|
||||
expect(content.startedAt).toBe(testInfo.startedAt);
|
||||
});
|
||||
|
||||
it('should overwrite existing PID file', () => {
|
||||
const firstInfo: PidInfo = {
|
||||
pid: 11111,
|
||||
port: 37777,
|
||||
startedAt: '2024-01-01T00:00:00.000Z'
|
||||
};
|
||||
const secondInfo: PidInfo = {
|
||||
pid: 22222,
|
||||
port: 37888,
|
||||
startedAt: '2024-01-02T00:00:00.000Z'
|
||||
};
|
||||
|
||||
writePidFile(firstInfo);
|
||||
writePidFile(secondInfo);
|
||||
|
||||
const content = JSON.parse(readFileSync(PID_FILE, 'utf-8'));
|
||||
expect(content.pid).toBe(22222);
|
||||
expect(content.port).toBe(37888);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readPidFile', () => {
|
||||
it('should return PidInfo object for valid file', () => {
|
||||
const testInfo: PidInfo = {
|
||||
pid: 54321,
|
||||
port: 37999,
|
||||
startedAt: '2024-06-15T12:00:00.000Z'
|
||||
};
|
||||
writePidFile(testInfo);
|
||||
|
||||
const result = readPidFile();
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.pid).toBe(54321);
|
||||
expect(result!.port).toBe(37999);
|
||||
expect(result!.startedAt).toBe('2024-06-15T12:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should return null for missing file', () => {
|
||||
// Ensure file doesn't exist
|
||||
removePidFile();
|
||||
|
||||
const result = readPidFile();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for corrupted JSON', () => {
|
||||
writeFileSync(PID_FILE, 'not valid json {{{');
|
||||
|
||||
const result = readPidFile();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removePidFile', () => {
|
||||
it('should delete existing file', () => {
|
||||
const testInfo: PidInfo = {
|
||||
pid: 99999,
|
||||
port: 37777,
|
||||
startedAt: new Date().toISOString()
|
||||
};
|
||||
writePidFile(testInfo);
|
||||
expect(existsSync(PID_FILE)).toBe(true);
|
||||
|
||||
removePidFile();
|
||||
|
||||
expect(existsSync(PID_FILE)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not throw for missing file', () => {
|
||||
// Ensure file doesn't exist
|
||||
removePidFile();
|
||||
expect(existsSync(PID_FILE)).toBe(false);
|
||||
|
||||
// Should not throw
|
||||
expect(() => removePidFile()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseElapsedTime', () => {
|
||||
it('should parse MM:SS format', () => {
|
||||
expect(parseElapsedTime('05:30')).toBe(5);
|
||||
expect(parseElapsedTime('00:45')).toBe(0);
|
||||
expect(parseElapsedTime('59:59')).toBe(59);
|
||||
});
|
||||
|
||||
it('should parse HH:MM:SS format', () => {
|
||||
expect(parseElapsedTime('01:30:00')).toBe(90);
|
||||
expect(parseElapsedTime('02:15:30')).toBe(135);
|
||||
expect(parseElapsedTime('00:05:00')).toBe(5);
|
||||
});
|
||||
|
||||
it('should parse DD-HH:MM:SS format', () => {
|
||||
expect(parseElapsedTime('1-00:00:00')).toBe(1440); // 1 day
|
||||
expect(parseElapsedTime('2-12:30:00')).toBe(3630); // 2 days + 12.5 hours
|
||||
expect(parseElapsedTime('0-01:00:00')).toBe(60); // 1 hour
|
||||
});
|
||||
|
||||
it('should return -1 for empty or invalid input', () => {
|
||||
expect(parseElapsedTime('')).toBe(-1);
|
||||
expect(parseElapsedTime(' ')).toBe(-1);
|
||||
expect(parseElapsedTime('invalid')).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlatformTimeout', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
});
|
||||
|
||||
it('should return same value on non-Windows platforms', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
const result = getPlatformTimeout(1000);
|
||||
|
||||
expect(result).toBe(1000);
|
||||
});
|
||||
|
||||
it('should return doubled value on Windows', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
const result = getPlatformTimeout(1000);
|
||||
|
||||
expect(result).toBe(2000);
|
||||
});
|
||||
|
||||
it('should apply 2.0x multiplier consistently on Windows', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
expect(getPlatformTimeout(500)).toBe(1000);
|
||||
expect(getPlatformTimeout(5000)).toBe(10000);
|
||||
expect(getPlatformTimeout(100)).toBe(200);
|
||||
});
|
||||
|
||||
it('should round Windows timeout values', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// 2.0x of 333 = 666 (rounds to 666)
|
||||
const result = getPlatformTimeout(333);
|
||||
|
||||
expect(result).toBe(666);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveWorkerRuntimePath', () => {
|
||||
it('should return current runtime on non-Windows platforms', () => {
|
||||
const resolved = resolveWorkerRuntimePath({
|
||||
platform: 'linux',
|
||||
execPath: '/usr/bin/node'
|
||||
});
|
||||
|
||||
expect(resolved).toBe('/usr/bin/node');
|
||||
});
|
||||
|
||||
it('should reuse execPath when already running under Bun on Windows', () => {
|
||||
const resolved = resolveWorkerRuntimePath({
|
||||
platform: 'win32',
|
||||
execPath: 'C:\\Users\\alice\\.bun\\bin\\bun.exe'
|
||||
});
|
||||
|
||||
expect(resolved).toBe('C:\\Users\\alice\\.bun\\bin\\bun.exe');
|
||||
});
|
||||
|
||||
it('should prefer configured Bun path from environment when available', () => {
|
||||
const resolved = resolveWorkerRuntimePath({
|
||||
platform: 'win32',
|
||||
execPath: 'C:\\Program Files\\nodejs\\node.exe',
|
||||
env: { BUN: 'C:\\tools\\bun.exe' } as NodeJS.ProcessEnv,
|
||||
pathExists: candidatePath => candidatePath === 'C:\\tools\\bun.exe',
|
||||
lookupInPath: () => null
|
||||
});
|
||||
|
||||
expect(resolved).toBe('C:\\tools\\bun.exe');
|
||||
});
|
||||
|
||||
it('should fall back to PATH lookup when no Bun candidate exists', () => {
|
||||
const resolved = resolveWorkerRuntimePath({
|
||||
platform: 'win32',
|
||||
execPath: 'C:\\Program Files\\nodejs\\node.exe',
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
pathExists: () => false,
|
||||
lookupInPath: () => 'C:\\Program Files\\Bun\\bun.exe'
|
||||
});
|
||||
|
||||
expect(resolved).toBe('C:\\Program Files\\Bun\\bun.exe');
|
||||
});
|
||||
|
||||
it('should return null when Bun cannot be resolved on Windows', () => {
|
||||
const resolved = resolveWorkerRuntimePath({
|
||||
platform: 'win32',
|
||||
execPath: 'C:\\Program Files\\nodejs\\node.exe',
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
pathExists: () => false,
|
||||
lookupInPath: () => null
|
||||
});
|
||||
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProcessAlive', () => {
|
||||
it('should return true for the current process', () => {
|
||||
expect(isProcessAlive(process.pid)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for a non-existent PID', () => {
|
||||
// Use a very high PID that's extremely unlikely to exist
|
||||
expect(isProcessAlive(2147483647)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for PID 0 (Windows WMIC sentinel)', () => {
|
||||
expect(isProcessAlive(0)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for negative PIDs', () => {
|
||||
expect(isProcessAlive(-1)).toBe(false);
|
||||
expect(isProcessAlive(-999)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-integer PIDs', () => {
|
||||
expect(isProcessAlive(1.5)).toBe(false);
|
||||
expect(isProcessAlive(NaN)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanStalePidFile', () => {
|
||||
it('should remove PID file when process is dead', () => {
|
||||
// Write a PID file with a non-existent PID
|
||||
const staleInfo: PidInfo = {
|
||||
pid: 2147483647,
|
||||
port: 37777,
|
||||
startedAt: '2024-01-01T00:00:00.000Z'
|
||||
};
|
||||
writePidFile(staleInfo);
|
||||
expect(existsSync(PID_FILE)).toBe(true);
|
||||
|
||||
cleanStalePidFile();
|
||||
|
||||
expect(existsSync(PID_FILE)).toBe(false);
|
||||
});
|
||||
|
||||
it('should keep PID file when process is alive', () => {
|
||||
// Write a PID file with the current process PID (definitely alive)
|
||||
const liveInfo: PidInfo = {
|
||||
pid: process.pid,
|
||||
port: 37777,
|
||||
startedAt: new Date().toISOString()
|
||||
};
|
||||
writePidFile(liveInfo);
|
||||
|
||||
cleanStalePidFile();
|
||||
|
||||
// PID file should still exist since process.pid is alive
|
||||
expect(existsSync(PID_FILE)).toBe(true);
|
||||
});
|
||||
|
||||
it('should do nothing when PID file does not exist', () => {
|
||||
removePidFile();
|
||||
expect(existsSync(PID_FILE)).toBe(false);
|
||||
|
||||
// Should not throw
|
||||
expect(() => cleanStalePidFile()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPidFileRecent', () => {
|
||||
it('should return true for a recently written PID file', () => {
|
||||
writePidFile({ pid: process.pid, port: 37777, startedAt: new Date().toISOString() });
|
||||
|
||||
// File was just written, should be very recent
|
||||
expect(isPidFileRecent(15000)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when PID file does not exist', () => {
|
||||
removePidFile();
|
||||
|
||||
expect(isPidFileRecent(15000)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for a very short threshold on a real file', () => {
|
||||
writePidFile({ pid: process.pid, port: 37777, startedAt: new Date().toISOString() });
|
||||
|
||||
// With a 0ms threshold, even a just-written file should be "too old"
|
||||
// (mtime is at least 1ms in the past by the time we check)
|
||||
// Use a negative threshold to guarantee false
|
||||
expect(isPidFileRecent(-1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('touchPidFile', () => {
|
||||
it('should update mtime of existing PID file', async () => {
|
||||
writePidFile({ pid: process.pid, port: 37777, startedAt: new Date().toISOString() });
|
||||
|
||||
// Wait a bit to ensure measurable mtime difference
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
|
||||
const statsBefore = require('fs').statSync(PID_FILE);
|
||||
const mtimeBefore = statsBefore.mtimeMs;
|
||||
|
||||
// Wait again to ensure mtime advances
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
|
||||
touchPidFile();
|
||||
|
||||
const statsAfter = require('fs').statSync(PID_FILE);
|
||||
const mtimeAfter = statsAfter.mtimeMs;
|
||||
|
||||
expect(mtimeAfter).toBeGreaterThanOrEqual(mtimeBefore);
|
||||
});
|
||||
|
||||
it('should not throw when PID file does not exist', () => {
|
||||
removePidFile();
|
||||
|
||||
expect(() => touchPidFile()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawnDaemon', () => {
|
||||
it('should use setsid on Linux when available', () => {
|
||||
// setsid should exist at /usr/bin/setsid on Linux
|
||||
if (process.platform === 'win32') return; // Skip on Windows
|
||||
|
||||
const setsidAvailable = existsSync('/usr/bin/setsid');
|
||||
if (!setsidAvailable) return; // Skip if setsid not installed
|
||||
|
||||
// Spawn a daemon with a non-existent script (it will fail to start, but we can verify the spawn attempt)
|
||||
// Use a harmless script path — the child will exit immediately
|
||||
const pid = spawnDaemon('/dev/null', 39999);
|
||||
|
||||
// setsid spawn should return a PID (the setsid process itself)
|
||||
expect(pid).toBeDefined();
|
||||
expect(typeof pid).toBe('number');
|
||||
|
||||
// Clean up: kill the spawned process if it's still alive
|
||||
if (pid !== undefined && pid > 0) {
|
||||
try { process.kill(pid, 'SIGKILL'); } catch { /* already exited */ }
|
||||
}
|
||||
});
|
||||
|
||||
it('should return undefined when spawn fails on Windows path', () => {
|
||||
// On non-Windows, this tests the Unix path which should succeed
|
||||
// The function should not throw, only return undefined on failure
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
// Spawning with a totally invalid script should still return a PID
|
||||
// (setsid/spawn succeeds even if the child will exit immediately)
|
||||
const result = spawnDaemon('/nonexistent/script.cjs', 39998);
|
||||
// spawn itself should succeed (returns PID), even if child exits
|
||||
expect(result).toBeDefined();
|
||||
|
||||
// Clean up
|
||||
if (result !== undefined && result > 0) {
|
||||
try { process.kill(result, 'SIGKILL'); } catch { /* already exited */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('SIGHUP handling', () => {
|
||||
it('should have SIGHUP listeners registered (integration check)', () => {
|
||||
// Verify that SIGHUP listener registration is possible on Unix
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
// Register a test handler, verify it works, then remove it
|
||||
let received = false;
|
||||
const testHandler = () => { received = true; };
|
||||
|
||||
process.on('SIGHUP', testHandler);
|
||||
expect(process.listenerCount('SIGHUP')).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Clean up the test handler
|
||||
process.removeListener('SIGHUP', testHandler);
|
||||
});
|
||||
|
||||
it('should ignore SIGHUP when --daemon is in process.argv', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
// Simulate the daemon SIGHUP handler logic
|
||||
const isDaemon = process.argv.includes('--daemon');
|
||||
// In test context, --daemon is not in argv, so this tests the branch logic
|
||||
expect(isDaemon).toBe(false);
|
||||
|
||||
// Verify the non-daemon path: SIGHUP should trigger shutdown (covered by registerSignalHandlers)
|
||||
// This is a logic verification test — actual signal delivery is tested manually
|
||||
});
|
||||
});
|
||||
|
||||
describe('runOneTimeChromaMigration', () => {
|
||||
let testDataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDataDir = path.join(tmpdir(), `claude-mem-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(testDataDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should wipe chroma directory and write marker file', () => {
|
||||
// Create a fake chroma directory with data
|
||||
const chromaDir = path.join(testDataDir, 'chroma');
|
||||
mkdirSync(chromaDir, { recursive: true });
|
||||
writeFileSync(path.join(chromaDir, 'test-data.bin'), 'fake chroma data');
|
||||
|
||||
runOneTimeChromaMigration(testDataDir);
|
||||
|
||||
// Chroma dir should be gone
|
||||
expect(existsSync(chromaDir)).toBe(false);
|
||||
// Marker file should exist
|
||||
expect(existsSync(path.join(testDataDir, '.chroma-cleaned-v10.3'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip when marker file already exists (idempotent)', () => {
|
||||
// Write marker file first
|
||||
writeFileSync(path.join(testDataDir, '.chroma-cleaned-v10.3'), 'already done');
|
||||
|
||||
// Create a chroma directory that should NOT be wiped
|
||||
const chromaDir = path.join(testDataDir, 'chroma');
|
||||
mkdirSync(chromaDir, { recursive: true });
|
||||
writeFileSync(path.join(chromaDir, 'important.bin'), 'should survive');
|
||||
|
||||
runOneTimeChromaMigration(testDataDir);
|
||||
|
||||
// Chroma dir should still exist (migration was skipped)
|
||||
expect(existsSync(chromaDir)).toBe(true);
|
||||
expect(existsSync(path.join(chromaDir, 'important.bin'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle missing chroma directory gracefully', () => {
|
||||
// No chroma dir exists — should just write marker without error
|
||||
expect(() => runOneTimeChromaMigration(testDataDir)).not.toThrow();
|
||||
expect(existsSync(path.join(testDataDir, '.chroma-cleaned-v10.3'))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, '../..');
|
||||
|
||||
/**
|
||||
* Test suite to ensure version consistency across all package.json files
|
||||
* and built artifacts.
|
||||
*
|
||||
* This prevents the infinite restart loop issue where:
|
||||
* - Plugin reads version from plugin/package.json
|
||||
* - Worker returns built-in version from bundled code
|
||||
* - Mismatch triggers restart on every hook call
|
||||
*/
|
||||
describe('Version Consistency', () => {
|
||||
let rootVersion: string;
|
||||
|
||||
it('should read version from root package.json', () => {
|
||||
const packageJsonPath = path.join(projectRoot, 'package.json');
|
||||
expect(existsSync(packageJsonPath)).toBe(true);
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
expect(packageJson.version).toBeDefined();
|
||||
expect(packageJson.version).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
|
||||
rootVersion = packageJson.version;
|
||||
});
|
||||
|
||||
it('should have matching version in plugin/package.json', () => {
|
||||
const pluginPackageJsonPath = path.join(projectRoot, 'plugin/package.json');
|
||||
expect(existsSync(pluginPackageJsonPath)).toBe(true);
|
||||
|
||||
const pluginPackageJson = JSON.parse(readFileSync(pluginPackageJsonPath, 'utf-8'));
|
||||
expect(pluginPackageJson.version).toBe(rootVersion);
|
||||
});
|
||||
|
||||
it('should have matching version in plugin/.claude-plugin/plugin.json', () => {
|
||||
const pluginJsonPath = path.join(projectRoot, 'plugin/.claude-plugin/plugin.json');
|
||||
expect(existsSync(pluginJsonPath)).toBe(true);
|
||||
|
||||
const pluginJson = JSON.parse(readFileSync(pluginJsonPath, 'utf-8'));
|
||||
expect(pluginJson.version).toBe(rootVersion);
|
||||
});
|
||||
|
||||
it('should have matching version in .claude-plugin/marketplace.json', () => {
|
||||
const marketplaceJsonPath = path.join(projectRoot, '.claude-plugin/marketplace.json');
|
||||
expect(existsSync(marketplaceJsonPath)).toBe(true);
|
||||
|
||||
const marketplaceJson = JSON.parse(readFileSync(marketplaceJsonPath, 'utf-8'));
|
||||
expect(marketplaceJson.plugins).toBeDefined();
|
||||
expect(marketplaceJson.plugins.length).toBeGreaterThan(0);
|
||||
|
||||
const claudeMemPlugin = marketplaceJson.plugins.find((p: any) => p.name === 'claude-mem');
|
||||
expect(claudeMemPlugin).toBeDefined();
|
||||
expect(claudeMemPlugin.version).toBe(rootVersion);
|
||||
});
|
||||
|
||||
it('should have version injected into built worker-service.cjs', () => {
|
||||
const workerServicePath = path.join(projectRoot, 'plugin/scripts/worker-service.cjs');
|
||||
|
||||
// Skip if file doesn't exist (e.g., before first build)
|
||||
if (!existsSync(workerServicePath)) {
|
||||
console.log('⚠️ worker-service.cjs not found - run npm run build first');
|
||||
return;
|
||||
}
|
||||
|
||||
const workerServiceContent = readFileSync(workerServicePath, 'utf-8');
|
||||
|
||||
// The build script injects version via esbuild define:
|
||||
// define: { '__DEFAULT_PACKAGE_VERSION__': `"${version}"` }
|
||||
// This becomes: const BUILT_IN_VERSION = "9.0.0" (or minified: Bre="9.0.0")
|
||||
|
||||
// Check for the version string in the minified code
|
||||
const versionPattern = new RegExp(`"${rootVersion.replace(/\./g, '\\.')}"`, 'g');
|
||||
const matches = workerServiceContent.match(versionPattern);
|
||||
|
||||
expect(matches).toBeTruthy();
|
||||
expect(matches!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have built mcp-server.cjs', () => {
|
||||
const mcpServerPath = path.join(projectRoot, 'plugin/scripts/mcp-server.cjs');
|
||||
|
||||
// Skip if file doesn't exist (e.g., before first build)
|
||||
if (!existsSync(mcpServerPath)) {
|
||||
console.log('⚠️ mcp-server.cjs not found - run npm run build first');
|
||||
return;
|
||||
}
|
||||
|
||||
// mcp-server.cjs doesn't use __DEFAULT_PACKAGE_VERSION__ - it's a search server
|
||||
// that doesn't need to expose version info. Just verify it exists and is built.
|
||||
const mcpServerContent = readFileSync(mcpServerPath, 'utf-8');
|
||||
expect(mcpServerContent.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should validate version format is semver compliant', () => {
|
||||
// Ensure version follows semantic versioning: MAJOR.MINOR.PATCH
|
||||
expect(rootVersion).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
|
||||
const [major, minor, patch] = rootVersion.split('.').map(Number);
|
||||
expect(major).toBeGreaterThanOrEqual(0);
|
||||
expect(minor).toBeGreaterThanOrEqual(0);
|
||||
expect(patch).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Additional test to ensure build script properly reads and injects version
|
||||
*/
|
||||
describe('Build Script Version Handling', () => {
|
||||
it('should read version from package.json in build-hooks.js', () => {
|
||||
const buildScriptPath = path.join(projectRoot, 'scripts/build-hooks.js');
|
||||
expect(existsSync(buildScriptPath)).toBe(true);
|
||||
|
||||
const buildScriptContent = readFileSync(buildScriptPath, 'utf-8');
|
||||
|
||||
// Verify build script reads from package.json
|
||||
expect(buildScriptContent).toContain("readFileSync('package.json'");
|
||||
expect(buildScriptContent).toContain('packageJson.version');
|
||||
|
||||
// Verify it generates plugin/package.json with the version
|
||||
expect(buildScriptContent).toContain('version: version');
|
||||
|
||||
// Verify it injects version into esbuild define
|
||||
expect(buildScriptContent).toContain('__DEFAULT_PACKAGE_VERSION__');
|
||||
expect(buildScriptContent).toContain('`"${version}"`');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
/**
|
||||
* Tests for PowerShell output parsing logic used in Windows process enumeration.
|
||||
*
|
||||
* This tests the parsing behavior directly since mocking promisified exec
|
||||
* is unreliable across module boundaries. The parsing logic matches exactly
|
||||
* what's in ProcessManager.getChildProcesses().
|
||||
*/
|
||||
|
||||
// Extract the parsing logic from ProcessManager for direct testing
|
||||
// This matches the implementation in src/services/infrastructure/ProcessManager.ts lines 95-100
|
||||
function parsePowerShellOutput(stdout: string): number[] {
|
||||
return stdout
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0 && /^\d+$/.test(line))
|
||||
.map(line => parseInt(line, 10))
|
||||
.filter(pid => pid > 0);
|
||||
}
|
||||
|
||||
// Validate parent PID - matches ProcessManager.getChildProcesses() lines 85-88
|
||||
function isValidParentPid(parentPid: number): boolean {
|
||||
return Number.isInteger(parentPid) && parentPid > 0;
|
||||
}
|
||||
|
||||
describe('PowerShell output parsing (Windows)', () => {
|
||||
describe('parsePowerShellOutput - simple number format parsing', () => {
|
||||
it('should parse simple number format correctly', () => {
|
||||
const stdout = '12345\r\n67890\r\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([12345, 67890]);
|
||||
});
|
||||
|
||||
it('should parse single PID from PowerShell output', () => {
|
||||
const stdout = '54321\r\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([54321]);
|
||||
});
|
||||
|
||||
it('should handle empty PowerShell output', () => {
|
||||
const stdout = '';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle PowerShell output with only whitespace', () => {
|
||||
const stdout = ' \r\n \r\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter invalid PIDs from PowerShell output', () => {
|
||||
const stdout = '12345\r\ninvalid\r\n67890\r\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([12345, 67890]);
|
||||
});
|
||||
|
||||
it('should filter negative PIDs from PowerShell output', () => {
|
||||
const stdout = '12345\r\n-1\r\n67890\r\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([12345, 67890]);
|
||||
});
|
||||
|
||||
it('should filter zero PIDs from PowerShell output', () => {
|
||||
const stdout = '0\r\n12345\r\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([12345]);
|
||||
});
|
||||
|
||||
it('should handle PowerShell output with extra lines and noise', () => {
|
||||
const stdout = '\r\n\r\n12345\r\n\r\nSome other output\r\n67890\r\n\r\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([12345, 67890]);
|
||||
});
|
||||
|
||||
it('should handle Windows line endings (CRLF)', () => {
|
||||
const stdout = '111\r\n222\r\n333\r\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([111, 222, 333]);
|
||||
});
|
||||
|
||||
it('should handle Unix line endings (LF)', () => {
|
||||
const stdout = '111\n222\n333\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([111, 222, 333]);
|
||||
});
|
||||
|
||||
it('should handle very large PIDs', () => {
|
||||
// Windows PIDs can be large but are still 32-bit integers
|
||||
const stdout = '2147483647\r\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([2147483647]);
|
||||
});
|
||||
|
||||
it('should handle typical PowerShell output with blank lines and extra spacing', () => {
|
||||
const stdout = `
|
||||
|
||||
1234
|
||||
|
||||
|
||||
5678
|
||||
|
||||
`;
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([1234, 5678]);
|
||||
});
|
||||
|
||||
it('should filter lines with text and numbers mixed', () => {
|
||||
const stdout = '12345\r\nPID: 67890\r\n11111\r\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
|
||||
expect(result).toEqual([12345, 11111]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parent PID validation', () => {
|
||||
it('should reject zero PID', () => {
|
||||
expect(isValidParentPid(0)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject negative PID', () => {
|
||||
expect(isValidParentPid(-1)).toBe(false);
|
||||
expect(isValidParentPid(-100)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject NaN', () => {
|
||||
expect(isValidParentPid(NaN)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-integer (float)', () => {
|
||||
expect(isValidParentPid(1.5)).toBe(false);
|
||||
expect(isValidParentPid(100.1)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject Infinity', () => {
|
||||
expect(isValidParentPid(Infinity)).toBe(false);
|
||||
expect(isValidParentPid(-Infinity)).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept valid positive integer PID', () => {
|
||||
expect(isValidParentPid(1)).toBe(true);
|
||||
expect(isValidParentPid(1000)).toBe(true);
|
||||
expect(isValidParentPid(12345)).toBe(true);
|
||||
expect(isValidParentPid(2147483647)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChildProcesses platform behavior', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array on non-Windows platforms (darwin)', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// Import fresh to get updated platform value
|
||||
const { getChildProcesses } = await import('../../src/services/infrastructure/ProcessManager.js');
|
||||
|
||||
const result = await getChildProcesses(1000);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array on non-Windows platforms (linux)', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
const { getChildProcesses } = await import('../../src/services/infrastructure/ProcessManager.js');
|
||||
|
||||
const result = await getChildProcesses(1000);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array for invalid parent PID regardless of platform', async () => {
|
||||
// Even on Windows, invalid parent PIDs should be rejected before exec
|
||||
const { getChildProcesses } = await import('../../src/services/infrastructure/ProcessManager.js');
|
||||
|
||||
expect(await getChildProcesses(0)).toEqual([]);
|
||||
expect(await getChildProcesses(-1)).toEqual([]);
|
||||
expect(await getChildProcesses(NaN)).toEqual([]);
|
||||
expect(await getChildProcesses(1.5)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* Tests for worker JSON status output structure
|
||||
*
|
||||
* Tests the buildStatusOutput pure function extracted from worker-service.ts
|
||||
* to ensure JSON output matches the hook framework contract.
|
||||
*
|
||||
* Also tests CLI output capture for the 'start' command to verify
|
||||
* actual JSON output matches expected structure.
|
||||
*
|
||||
* No mocks needed - tests a pure function directly and captures real CLI output.
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { buildStatusOutput, StatusOutput } from '../../src/services/worker-service.js';
|
||||
|
||||
const WORKER_SCRIPT = path.join(__dirname, '../../plugin/scripts/worker-service.cjs');
|
||||
|
||||
/**
|
||||
* Run worker CLI command and return stdout + exit code
|
||||
* Uses spawnSync for synchronous output capture
|
||||
*/
|
||||
function runWorkerStart(): { stdout: string; exitCode: number } {
|
||||
const result = spawnSync('bun', [WORKER_SCRIPT, 'start'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 60000
|
||||
});
|
||||
return { stdout: result.stdout?.trim() || '', exitCode: result.status || 0 };
|
||||
}
|
||||
|
||||
describe('worker-json-status', () => {
|
||||
describe('buildStatusOutput', () => {
|
||||
describe('ready status', () => {
|
||||
it('should return valid JSON with required fields for ready status', () => {
|
||||
const result = buildStatusOutput('ready');
|
||||
|
||||
expect(result.status).toBe('ready');
|
||||
expect(result.continue).toBe(true);
|
||||
expect(result.suppressOutput).toBe(true);
|
||||
});
|
||||
|
||||
it('should not include message field when not provided', () => {
|
||||
const result = buildStatusOutput('ready');
|
||||
|
||||
expect(result.message).toBeUndefined();
|
||||
expect('message' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('should include message field when explicitly provided for ready status', () => {
|
||||
const result = buildStatusOutput('ready', 'Worker started successfully');
|
||||
|
||||
expect(result.status).toBe('ready');
|
||||
expect(result.message).toBe('Worker started successfully');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error status', () => {
|
||||
it('should return valid JSON with required fields for error status', () => {
|
||||
const result = buildStatusOutput('error');
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.continue).toBe(true);
|
||||
expect(result.suppressOutput).toBe(true);
|
||||
});
|
||||
|
||||
it('should include message field when provided for error status', () => {
|
||||
const result = buildStatusOutput('error', 'Port in use but worker not responding');
|
||||
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.message).toBe('Port in use but worker not responding');
|
||||
});
|
||||
|
||||
it('should handle various error messages correctly', () => {
|
||||
const errorMessages = [
|
||||
'Port did not free after version mismatch restart',
|
||||
'Failed to spawn worker daemon',
|
||||
'Worker failed to start (health check timeout)'
|
||||
];
|
||||
|
||||
for (const msg of errorMessages) {
|
||||
const result = buildStatusOutput('error', msg);
|
||||
expect(result.message).toBe(msg);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('required fields always present', () => {
|
||||
it('should always include continue: true', () => {
|
||||
expect(buildStatusOutput('ready').continue).toBe(true);
|
||||
expect(buildStatusOutput('error').continue).toBe(true);
|
||||
expect(buildStatusOutput('ready', 'msg').continue).toBe(true);
|
||||
expect(buildStatusOutput('error', 'msg').continue).toBe(true);
|
||||
});
|
||||
|
||||
it('should always include suppressOutput: true', () => {
|
||||
expect(buildStatusOutput('ready').suppressOutput).toBe(true);
|
||||
expect(buildStatusOutput('error').suppressOutput).toBe(true);
|
||||
expect(buildStatusOutput('ready', 'msg').suppressOutput).toBe(true);
|
||||
expect(buildStatusOutput('error', 'msg').suppressOutput).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON serialization', () => {
|
||||
it('should produce valid JSON when stringified', () => {
|
||||
const readyResult = buildStatusOutput('ready');
|
||||
const errorResult = buildStatusOutput('error', 'Test error message');
|
||||
|
||||
expect(() => JSON.stringify(readyResult)).not.toThrow();
|
||||
expect(() => JSON.stringify(errorResult)).not.toThrow();
|
||||
|
||||
const parsedReady = JSON.parse(JSON.stringify(readyResult));
|
||||
expect(parsedReady.status).toBe('ready');
|
||||
expect(parsedReady.continue).toBe(true);
|
||||
|
||||
const parsedError = JSON.parse(JSON.stringify(errorResult));
|
||||
expect(parsedError.status).toBe('error');
|
||||
expect(parsedError.message).toBe('Test error message');
|
||||
});
|
||||
|
||||
it('should match expected JSON structure for hook framework', () => {
|
||||
const readyOutput = JSON.stringify(buildStatusOutput('ready'));
|
||||
const errorOutput = JSON.stringify(buildStatusOutput('error', 'error msg'));
|
||||
|
||||
// Verify exact structure (order may vary, but content must match)
|
||||
const parsedReady = JSON.parse(readyOutput);
|
||||
expect(parsedReady).toEqual({
|
||||
continue: true,
|
||||
suppressOutput: true,
|
||||
status: 'ready'
|
||||
});
|
||||
|
||||
const parsedError = JSON.parse(errorOutput);
|
||||
expect(parsedError).toEqual({
|
||||
continue: true,
|
||||
suppressOutput: true,
|
||||
status: 'error',
|
||||
message: 'error msg'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('type safety', () => {
|
||||
it('should only accept valid status values', () => {
|
||||
// TypeScript ensures these are the only valid values at compile time
|
||||
// This runtime test validates the behavior
|
||||
const readyResult: StatusOutput = buildStatusOutput('ready');
|
||||
const errorResult: StatusOutput = buildStatusOutput('error');
|
||||
|
||||
expect(['ready', 'error']).toContain(readyResult.status);
|
||||
expect(['ready', 'error']).toContain(errorResult.status);
|
||||
});
|
||||
|
||||
it('should have correct type structure', () => {
|
||||
const result = buildStatusOutput('ready');
|
||||
|
||||
// Verify literal types
|
||||
expect(result.continue).toBe(true as const);
|
||||
expect(result.suppressOutput).toBe(true as const);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty string message', () => {
|
||||
// Empty string is falsy, so message should NOT be included
|
||||
const result = buildStatusOutput('error', '');
|
||||
expect('message' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle message with special characters', () => {
|
||||
const specialMessage = 'Error: "quoted" & special <chars>';
|
||||
const result = buildStatusOutput('error', specialMessage);
|
||||
expect(result.message).toBe(specialMessage);
|
||||
|
||||
// Verify it serializes correctly
|
||||
const parsed = JSON.parse(JSON.stringify(result));
|
||||
expect(parsed.message).toBe(specialMessage);
|
||||
});
|
||||
|
||||
it('should handle very long message', () => {
|
||||
const longMessage = 'A'.repeat(10000);
|
||||
const result = buildStatusOutput('error', longMessage);
|
||||
expect(result.message).toBe(longMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('start command JSON output', () => {
|
||||
describe('when worker already healthy', () => {
|
||||
it('should output valid JSON with status: ready', () => {
|
||||
// Skip if worker script doesn't exist (not built)
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
return;
|
||||
}
|
||||
|
||||
const { stdout, exitCode } = runWorkerStart();
|
||||
|
||||
// The start command always exits with 0 (Windows Terminal compatibility)
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Should output valid JSON
|
||||
expect(() => JSON.parse(stdout)).not.toThrow();
|
||||
|
||||
const parsed = JSON.parse(stdout);
|
||||
|
||||
// Verify required fields per hook framework contract
|
||||
expect(parsed.continue).toBe(true);
|
||||
expect(parsed.suppressOutput).toBe(true);
|
||||
expect(['ready', 'error']).toContain(parsed.status);
|
||||
});
|
||||
|
||||
it('should match expected JSON structure when worker is healthy', () => {
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
return;
|
||||
}
|
||||
|
||||
const { stdout } = runWorkerStart();
|
||||
const parsed = JSON.parse(stdout);
|
||||
|
||||
// When worker is already healthy, status should be 'ready'
|
||||
// (or 'error' if something unexpected happens)
|
||||
if (parsed.status === 'ready') {
|
||||
// Ready status should not include message unless explicitly set
|
||||
expect(parsed.continue).toBe(true);
|
||||
expect(parsed.suppressOutput).toBe(true);
|
||||
} else if (parsed.status === 'error') {
|
||||
// Error status may include a message explaining the failure
|
||||
expect(typeof parsed.message).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('error scenarios', () => {
|
||||
// These tests require complex setup (mocking ports, killing processes)
|
||||
// Skipped for now - the pure function tests above cover the JSON structure
|
||||
it.skip('should output JSON with status: error when port in use but not responding', () => {
|
||||
// Would require: start a non-worker server on the port, then call start
|
||||
});
|
||||
|
||||
it.skip('should output JSON with status: error on spawn failure', () => {
|
||||
// Would require: mock spawnDaemon to fail
|
||||
});
|
||||
|
||||
it.skip('should output JSON with status: error on health check timeout', () => {
|
||||
// Would require: start worker that never becomes healthy
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Claude Code hook framework compatibility tests
|
||||
*
|
||||
* These tests verify that the worker 'start' command output conforms to
|
||||
* Claude Code's hook output contract. Key requirements:
|
||||
*
|
||||
* 1. Exit code 0 - Required for Windows Terminal compatibility (prevents
|
||||
* tab accumulation from spawned processes)
|
||||
*
|
||||
* 2. JSON on stdout - Claude Code parses stdout as JSON. Logs must go to
|
||||
* stderr to avoid breaking JSON parsing.
|
||||
*
|
||||
* 3. `continue: true` - CRITICAL: This field tells Claude Code to continue
|
||||
* processing. If missing or false, Claude Code stops after the hook.
|
||||
* Per docs: "If continue is false, Claude stops processing after the
|
||||
* hooks run."
|
||||
*
|
||||
* 4. `suppressOutput: true` - Hides output from transcript mode (Ctrl-R).
|
||||
* Optional but recommended for non-user-facing status.
|
||||
*
|
||||
* Reference: private/context/claude-code/hooks.md
|
||||
*/
|
||||
describe('Claude Code hook framework compatibility', () => {
|
||||
/**
|
||||
* Windows Terminal compatibility requirement
|
||||
*
|
||||
* When hooks run in Windows Terminal, each spawned process can open a
|
||||
* new tab. Exit code 0 tells the terminal the process completed
|
||||
* successfully and prevents tab accumulation.
|
||||
*
|
||||
* Even for error states (worker failed to start), we exit 0 and
|
||||
* communicate the error via JSON { status: 'error', message: '...' }
|
||||
*/
|
||||
it('should always exit with code 0', () => {
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
return;
|
||||
}
|
||||
|
||||
const { exitCode } = runWorkerStart();
|
||||
|
||||
// Per Windows Terminal compatibility requirement, exit code is always 0
|
||||
// Error states are communicated via JSON status field, not exit codes
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* JSON must go to stdout, not stderr
|
||||
*
|
||||
* Claude Code parses stdout as JSON for hook output. Any non-JSON on
|
||||
* stdout breaks parsing. Logs, warnings, and debug info must go to
|
||||
* stderr.
|
||||
*
|
||||
* Structure: { status, continue, suppressOutput, message? }
|
||||
*/
|
||||
it('should output JSON on stdout (not stderr)', () => {
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = spawnSync('bun', [WORKER_SCRIPT, 'start'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 60000
|
||||
});
|
||||
|
||||
const stdout = result.stdout?.trim() || '';
|
||||
const stderr = result.stderr?.trim() || '';
|
||||
|
||||
// stdout should contain valid JSON
|
||||
expect(() => JSON.parse(stdout)).not.toThrow();
|
||||
|
||||
// stderr should NOT contain the JSON output (it may have logs)
|
||||
// The JSON structure should only appear in stdout
|
||||
const parsed = JSON.parse(stdout);
|
||||
expect(parsed).toHaveProperty('status');
|
||||
expect(parsed).toHaveProperty('continue');
|
||||
|
||||
// Verify stderr doesn't accidentally contain the JSON output
|
||||
if (stderr) {
|
||||
try {
|
||||
const stderrParsed = JSON.parse(stderr);
|
||||
// If stderr parses as JSON with our structure, that's wrong
|
||||
expect(stderrParsed).not.toHaveProperty('suppressOutput');
|
||||
} catch {
|
||||
// stderr is not JSON, which is expected (logs, etc.)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* JSON must be parseable as valid JSON
|
||||
*
|
||||
* This seems obvious but is critical - any extraneous output (console.log
|
||||
* statements, warnings, etc.) will break JSON parsing and cause Claude
|
||||
* Code to fail processing the hook output.
|
||||
*/
|
||||
it('should be parseable as valid JSON', () => {
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
return;
|
||||
}
|
||||
|
||||
const { stdout } = runWorkerStart();
|
||||
|
||||
// Should not throw on parse
|
||||
let parsed: unknown;
|
||||
expect(() => {
|
||||
parsed = JSON.parse(stdout);
|
||||
}).not.toThrow();
|
||||
|
||||
// Should be an object, not a string, array, etc.
|
||||
expect(typeof parsed).toBe('object');
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(Array.isArray(parsed)).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* `continue: true` is CRITICAL
|
||||
*
|
||||
* From Claude Code docs: "If continue is false, Claude stops processing
|
||||
* after the hooks run."
|
||||
*
|
||||
* For SessionStart hooks (which start the worker), we MUST return
|
||||
* continue: true so Claude Code continues to process the user's prompt.
|
||||
* If we returned continue: false, Claude would stop immediately after
|
||||
* starting the worker and never respond to the user.
|
||||
*
|
||||
* This is why continue: true is a required literal in our StatusOutput
|
||||
* type - it can never be false.
|
||||
*/
|
||||
it('should always include continue: true (required for Claude Code to proceed)', () => {
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
return;
|
||||
}
|
||||
|
||||
const { stdout } = runWorkerStart();
|
||||
const parsed = JSON.parse(stdout);
|
||||
|
||||
// continue: true is CRITICAL - without it, Claude Code stops processing
|
||||
// This is not optional; it must always be true for our hooks
|
||||
expect(parsed.continue).toBe(true);
|
||||
|
||||
// Also verify it's the literal `true`, not a truthy value
|
||||
expect(parsed.continue).toStrictEqual(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* suppressOutput hides from transcript mode
|
||||
*
|
||||
* When suppressOutput: true, the hook output doesn't appear in transcript
|
||||
* mode (Ctrl-R). This is useful for status messages that aren't relevant
|
||||
* to the user's conversation history.
|
||||
*
|
||||
* For the worker start command, we suppress output since "worker started"
|
||||
* is infrastructure noise, not conversation content.
|
||||
*/
|
||||
it('should include suppressOutput: true to hide from transcript mode', () => {
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
return;
|
||||
}
|
||||
|
||||
const { stdout } = runWorkerStart();
|
||||
const parsed = JSON.parse(stdout);
|
||||
|
||||
// suppressOutput prevents infrastructure noise from polluting transcript
|
||||
expect(parsed.suppressOutput).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* status field communicates outcome
|
||||
*
|
||||
* The status field tells Claude Code (and debugging tools) whether the
|
||||
* hook succeeded. Valid values: 'ready' | 'error'
|
||||
*
|
||||
* Unlike exit codes (which are always 0), status can indicate failure.
|
||||
* This allows Claude Code to potentially take remedial action or log
|
||||
* the issue.
|
||||
*/
|
||||
it('should include a valid status field', () => {
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
return;
|
||||
}
|
||||
|
||||
const { stdout } = runWorkerStart();
|
||||
const parsed = JSON.parse(stdout);
|
||||
|
||||
expect(parsed).toHaveProperty('status');
|
||||
expect(['ready', 'error']).toContain(parsed.status);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user