mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
feat: introduce debug panel for performance monitoring
- Added a debug panel to monitor server performance, including memory and CPU metrics. - Implemented debug services for real-time tracking of processes and performance metrics. - Created API endpoints for metrics collection and process management. - Enhanced UI components for displaying metrics and process statuses. - Updated documentation to include new debug API details. This feature is intended for development use and can be toggled with the `ENABLE_DEBUG_PANEL` environment variable.
This commit is contained in:
318
apps/server/tests/unit/routes/debug/metrics.test.ts
Normal file
318
apps/server/tests/unit/routes/debug/metrics.test.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
createGetMetricsHandler,
|
||||
createStartMetricsHandler,
|
||||
createStopMetricsHandler,
|
||||
createForceGCHandler,
|
||||
createClearHistoryHandler,
|
||||
} from '@/routes/debug/routes/metrics.js';
|
||||
import type { PerformanceMonitorService } from '@/services/performance-monitor-service.js';
|
||||
import type { DebugMetricsConfig, DebugMetricsSnapshot } from '@automaker/types';
|
||||
import { DEFAULT_DEBUG_METRICS_CONFIG } from '@automaker/types';
|
||||
|
||||
describe('Debug Metrics Routes', () => {
|
||||
let mockPerformanceMonitor: Partial<PerformanceMonitorService>;
|
||||
let mockReq: Partial<Request>;
|
||||
let mockRes: Partial<Response>;
|
||||
let jsonFn: ReturnType<typeof vi.fn>;
|
||||
let statusFn: ReturnType<typeof vi.fn>;
|
||||
|
||||
const mockConfig: DebugMetricsConfig = { ...DEFAULT_DEBUG_METRICS_CONFIG };
|
||||
const mockSnapshot: DebugMetricsSnapshot = {
|
||||
timestamp: Date.now(),
|
||||
memory: {
|
||||
timestamp: Date.now(),
|
||||
server: {
|
||||
heapTotal: 100 * 1024 * 1024,
|
||||
heapUsed: 50 * 1024 * 1024,
|
||||
external: 5 * 1024 * 1024,
|
||||
rss: 150 * 1024 * 1024,
|
||||
arrayBuffers: 1 * 1024 * 1024,
|
||||
},
|
||||
},
|
||||
cpu: {
|
||||
timestamp: Date.now(),
|
||||
server: {
|
||||
percentage: 25.5,
|
||||
user: 1000,
|
||||
system: 500,
|
||||
},
|
||||
eventLoopLag: 5,
|
||||
},
|
||||
processes: [],
|
||||
processSummary: {
|
||||
total: 0,
|
||||
running: 0,
|
||||
idle: 0,
|
||||
stopped: 0,
|
||||
errored: 0,
|
||||
byType: { agent: 0, cli: 0, terminal: 0, worker: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jsonFn = vi.fn();
|
||||
statusFn = vi.fn(() => ({ json: jsonFn }));
|
||||
|
||||
mockPerformanceMonitor = {
|
||||
getLatestSnapshot: vi.fn(() => mockSnapshot),
|
||||
getConfig: vi.fn(() => mockConfig),
|
||||
isActive: vi.fn(() => true),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
updateConfig: vi.fn(),
|
||||
forceGC: vi.fn(() => true),
|
||||
clearHistory: vi.fn(),
|
||||
};
|
||||
|
||||
mockReq = {
|
||||
body: {},
|
||||
query: {},
|
||||
params: {},
|
||||
};
|
||||
|
||||
mockRes = {
|
||||
json: jsonFn,
|
||||
status: statusFn,
|
||||
};
|
||||
});
|
||||
|
||||
describe('GET /api/debug/metrics', () => {
|
||||
it('should return current metrics snapshot', () => {
|
||||
const handler = createGetMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
active: true,
|
||||
config: mockConfig,
|
||||
snapshot: mockSnapshot,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined snapshot when no data available', () => {
|
||||
(mockPerformanceMonitor.getLatestSnapshot as ReturnType<typeof vi.fn>).mockReturnValue(null);
|
||||
|
||||
const handler = createGetMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
active: true,
|
||||
config: mockConfig,
|
||||
snapshot: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return active status correctly', () => {
|
||||
(mockPerformanceMonitor.isActive as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
|
||||
const handler = createGetMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(jsonFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
active: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/debug/metrics/start', () => {
|
||||
it('should start metrics collection', () => {
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.start).toHaveBeenCalled();
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
active: true,
|
||||
config: mockConfig,
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply config overrides when provided', () => {
|
||||
mockReq.body = {
|
||||
config: {
|
||||
collectionInterval: 5000,
|
||||
maxDataPoints: 500,
|
||||
},
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
|
||||
collectionInterval: 5000,
|
||||
maxDataPoints: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it('should sanitize config values - clamp collectionInterval to min 100ms', () => {
|
||||
mockReq.body = {
|
||||
config: {
|
||||
collectionInterval: 10, // Below minimum of 100ms
|
||||
},
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
|
||||
collectionInterval: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('should sanitize config values - clamp collectionInterval to max 60000ms', () => {
|
||||
mockReq.body = {
|
||||
config: {
|
||||
collectionInterval: 100000, // Above maximum of 60000ms
|
||||
},
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
|
||||
collectionInterval: 60000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should sanitize config values - clamp maxDataPoints to bounds', () => {
|
||||
mockReq.body = {
|
||||
config: {
|
||||
maxDataPoints: 5, // Below minimum of 10
|
||||
},
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
|
||||
maxDataPoints: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should sanitize config values - clamp maxDataPoints to max', () => {
|
||||
mockReq.body = {
|
||||
config: {
|
||||
maxDataPoints: 50000, // Above maximum of 10000
|
||||
},
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
|
||||
maxDataPoints: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore non-object config', () => {
|
||||
mockReq.body = {
|
||||
config: 'not-an-object',
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore empty config object', () => {
|
||||
mockReq.body = {
|
||||
config: {},
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should only accept boolean flags as actual booleans', () => {
|
||||
mockReq.body = {
|
||||
config: {
|
||||
memoryEnabled: 'true', // String, not boolean - should be ignored
|
||||
cpuEnabled: true, // Boolean - should be accepted
|
||||
},
|
||||
};
|
||||
|
||||
const handler = createStartMetricsHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.updateConfig).toHaveBeenCalledWith({
|
||||
cpuEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/debug/metrics/stop', () => {
|
||||
it('should stop metrics collection', () => {
|
||||
const handler = createStopMetricsHandler(mockPerformanceMonitor as PerformanceMonitorService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.stop).toHaveBeenCalled();
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
active: false,
|
||||
config: mockConfig,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/debug/metrics/gc', () => {
|
||||
it('should trigger garbage collection when available', () => {
|
||||
const handler = createForceGCHandler(mockPerformanceMonitor as PerformanceMonitorService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.forceGC).toHaveBeenCalled();
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
message: 'Garbage collection triggered',
|
||||
});
|
||||
});
|
||||
|
||||
it('should report when garbage collection is not available', () => {
|
||||
(mockPerformanceMonitor.forceGC as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
|
||||
const handler = createForceGCHandler(mockPerformanceMonitor as PerformanceMonitorService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
message: 'Garbage collection not available (start Node.js with --expose-gc flag)',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/debug/metrics/clear', () => {
|
||||
it('should clear metrics history', () => {
|
||||
const handler = createClearHistoryHandler(
|
||||
mockPerformanceMonitor as PerformanceMonitorService
|
||||
);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockPerformanceMonitor.clearHistory).toHaveBeenCalled();
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
message: 'Metrics history cleared',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
293
apps/server/tests/unit/routes/debug/processes.test.ts
Normal file
293
apps/server/tests/unit/routes/debug/processes.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
createGetProcessesHandler,
|
||||
createGetProcessHandler,
|
||||
createGetSummaryHandler,
|
||||
} from '@/routes/debug/routes/processes.js';
|
||||
import type { ProcessRegistryService } from '@/services/process-registry-service.js';
|
||||
import type { TrackedProcess, ProcessSummary } from '@automaker/types';
|
||||
|
||||
describe('Debug Processes Routes', () => {
|
||||
let mockProcessRegistry: Partial<ProcessRegistryService>;
|
||||
let mockReq: Partial<Request>;
|
||||
let mockRes: Partial<Response>;
|
||||
let jsonFn: ReturnType<typeof vi.fn>;
|
||||
let statusFn: ReturnType<typeof vi.fn>;
|
||||
|
||||
const mockProcesses: TrackedProcess[] = [
|
||||
{
|
||||
id: 'process-1',
|
||||
pid: 1234,
|
||||
type: 'agent',
|
||||
name: 'Agent 1',
|
||||
status: 'running',
|
||||
startedAt: Date.now() - 60000,
|
||||
featureId: 'feature-1',
|
||||
sessionId: 'session-1',
|
||||
},
|
||||
{
|
||||
id: 'process-2',
|
||||
pid: 5678,
|
||||
type: 'terminal',
|
||||
name: 'Terminal 1',
|
||||
status: 'idle',
|
||||
startedAt: Date.now() - 30000,
|
||||
sessionId: 'session-1',
|
||||
},
|
||||
{
|
||||
id: 'process-3',
|
||||
pid: 9012,
|
||||
type: 'cli',
|
||||
name: 'CLI 1',
|
||||
status: 'stopped',
|
||||
startedAt: Date.now() - 120000,
|
||||
stoppedAt: Date.now() - 60000,
|
||||
exitCode: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const mockSummary: ProcessSummary = {
|
||||
total: 3,
|
||||
running: 1,
|
||||
idle: 1,
|
||||
stopped: 1,
|
||||
errored: 0,
|
||||
byType: {
|
||||
agent: 1,
|
||||
cli: 1,
|
||||
terminal: 1,
|
||||
worker: 0,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jsonFn = vi.fn();
|
||||
statusFn = vi.fn(() => ({ json: jsonFn }));
|
||||
|
||||
mockProcessRegistry = {
|
||||
getProcesses: vi.fn(() => mockProcesses),
|
||||
getProcess: vi.fn((id: string) => mockProcesses.find((p) => p.id === id)),
|
||||
getProcessSummary: vi.fn(() => mockSummary),
|
||||
};
|
||||
|
||||
mockReq = {
|
||||
body: {},
|
||||
query: {},
|
||||
params: {},
|
||||
};
|
||||
|
||||
mockRes = {
|
||||
json: jsonFn,
|
||||
status: statusFn,
|
||||
};
|
||||
});
|
||||
|
||||
describe('GET /api/debug/processes', () => {
|
||||
it('should return list of processes with summary', () => {
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalled();
|
||||
expect(mockProcessRegistry.getProcessSummary).toHaveBeenCalled();
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
processes: mockProcesses,
|
||||
summary: mockSummary,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass type filter to service', () => {
|
||||
mockReq.query = { type: 'agent' };
|
||||
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'agent',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass status filter to service', () => {
|
||||
mockReq.query = { status: 'running' };
|
||||
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'running',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass includeStopped flag when set to "true"', () => {
|
||||
mockReq.query = { includeStopped: 'true' };
|
||||
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
includeStopped: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not pass includeStopped when not "true"', () => {
|
||||
mockReq.query = { includeStopped: 'false' };
|
||||
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
includeStopped: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass sessionId filter to service', () => {
|
||||
mockReq.query = { sessionId: 'session-1' };
|
||||
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: 'session-1',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass featureId filter to service', () => {
|
||||
mockReq.query = { featureId: 'feature-1' };
|
||||
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
featureId: 'feature-1',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple filters', () => {
|
||||
mockReq.query = {
|
||||
type: 'agent',
|
||||
status: 'running',
|
||||
sessionId: 'session-1',
|
||||
includeStopped: 'true',
|
||||
};
|
||||
|
||||
const handler = createGetProcessesHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcesses).toHaveBeenCalledWith({
|
||||
type: 'agent',
|
||||
status: 'running',
|
||||
sessionId: 'session-1',
|
||||
includeStopped: true,
|
||||
featureId: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/debug/processes/:id', () => {
|
||||
it('should return a specific process by ID', () => {
|
||||
mockReq.params = { id: 'process-1' };
|
||||
|
||||
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcess).toHaveBeenCalledWith('process-1');
|
||||
expect(jsonFn).toHaveBeenCalledWith(mockProcesses[0]);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent process', () => {
|
||||
mockReq.params = { id: 'non-existent' };
|
||||
(mockProcessRegistry.getProcess as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
|
||||
|
||||
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(statusFn).toHaveBeenCalledWith(404);
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
error: 'Process not found',
|
||||
id: 'non-existent',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for empty process ID', () => {
|
||||
mockReq.params = { id: '' };
|
||||
|
||||
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(statusFn).toHaveBeenCalledWith(400);
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
error: 'Invalid process ID format',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for process ID exceeding max length', () => {
|
||||
mockReq.params = { id: 'a'.repeat(257) };
|
||||
|
||||
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(statusFn).toHaveBeenCalledWith(400);
|
||||
expect(jsonFn).toHaveBeenCalledWith({
|
||||
error: 'Invalid process ID format',
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept process ID at max length', () => {
|
||||
mockReq.params = { id: 'a'.repeat(256) };
|
||||
(mockProcessRegistry.getProcess as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
|
||||
|
||||
const handler = createGetProcessHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
// Should pass validation but process not found
|
||||
expect(statusFn).toHaveBeenCalledWith(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/debug/processes/summary', () => {
|
||||
it('should return process summary', () => {
|
||||
const handler = createGetSummaryHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(mockProcessRegistry.getProcessSummary).toHaveBeenCalled();
|
||||
expect(jsonFn).toHaveBeenCalledWith(mockSummary);
|
||||
});
|
||||
|
||||
it('should return correct counts', () => {
|
||||
const customSummary: ProcessSummary = {
|
||||
total: 10,
|
||||
running: 5,
|
||||
idle: 2,
|
||||
stopped: 2,
|
||||
errored: 1,
|
||||
byType: {
|
||||
agent: 4,
|
||||
cli: 3,
|
||||
terminal: 2,
|
||||
worker: 1,
|
||||
},
|
||||
};
|
||||
|
||||
(mockProcessRegistry.getProcessSummary as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
customSummary
|
||||
);
|
||||
|
||||
const handler = createGetSummaryHandler(mockProcessRegistry as ProcessRegistryService);
|
||||
handler(mockReq as Request, mockRes as Response);
|
||||
|
||||
expect(jsonFn).toHaveBeenCalledWith(customSummary);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { PerformanceMonitorService } from '@/services/performance-monitor-service.js';
|
||||
import { createEventEmitter } from '@/lib/events.js';
|
||||
import type { EventEmitter } from '@/lib/events.js';
|
||||
import type { TrackedProcess, DebugMetricsConfig } from '@automaker/types';
|
||||
import { DEFAULT_DEBUG_METRICS_CONFIG } from '@automaker/types';
|
||||
|
||||
// Mock the logger to prevent console output during tests
|
||||
vi.mock('@automaker/utils', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('PerformanceMonitorService', () => {
|
||||
let service: PerformanceMonitorService;
|
||||
let events: EventEmitter;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
events = createEventEmitter();
|
||||
service = new PerformanceMonitorService(events);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.stop();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with default configuration', () => {
|
||||
const config = service.getConfig();
|
||||
expect(config.collectionInterval).toBe(DEFAULT_DEBUG_METRICS_CONFIG.collectionInterval);
|
||||
expect(config.maxDataPoints).toBe(DEFAULT_DEBUG_METRICS_CONFIG.maxDataPoints);
|
||||
expect(config.memoryEnabled).toBe(DEFAULT_DEBUG_METRICS_CONFIG.memoryEnabled);
|
||||
expect(config.cpuEnabled).toBe(DEFAULT_DEBUG_METRICS_CONFIG.cpuEnabled);
|
||||
});
|
||||
|
||||
it('should accept custom configuration on initialization', () => {
|
||||
const customConfig: Partial<DebugMetricsConfig> = {
|
||||
collectionInterval: 5000,
|
||||
maxDataPoints: 500,
|
||||
memoryEnabled: false,
|
||||
};
|
||||
|
||||
const customService = new PerformanceMonitorService(events, customConfig);
|
||||
const config = customService.getConfig();
|
||||
|
||||
expect(config.collectionInterval).toBe(5000);
|
||||
expect(config.maxDataPoints).toBe(500);
|
||||
expect(config.memoryEnabled).toBe(false);
|
||||
expect(config.cpuEnabled).toBe(DEFAULT_DEBUG_METRICS_CONFIG.cpuEnabled);
|
||||
|
||||
customService.stop();
|
||||
});
|
||||
|
||||
it('should not be running initially', () => {
|
||||
expect(service.isActive()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('start/stop', () => {
|
||||
it('should start metrics collection', () => {
|
||||
service.start();
|
||||
expect(service.isActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('should stop metrics collection', () => {
|
||||
service.start();
|
||||
expect(service.isActive()).toBe(true);
|
||||
|
||||
service.stop();
|
||||
expect(service.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not start again if already running', () => {
|
||||
service.start();
|
||||
const isActive1 = service.isActive();
|
||||
|
||||
service.start(); // Should log warning but not throw
|
||||
const isActive2 = service.isActive();
|
||||
|
||||
expect(isActive1).toBe(true);
|
||||
expect(isActive2).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle stop when not running', () => {
|
||||
// Should not throw
|
||||
expect(() => service.stop()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration updates', () => {
|
||||
it('should update configuration', () => {
|
||||
service.updateConfig({ collectionInterval: 2000 });
|
||||
expect(service.getConfig().collectionInterval).toBe(2000);
|
||||
});
|
||||
|
||||
it('should restart collection if running when config is updated', () => {
|
||||
service.start();
|
||||
expect(service.isActive()).toBe(true);
|
||||
|
||||
service.updateConfig({ collectionInterval: 5000 });
|
||||
|
||||
// Should still be running after config update
|
||||
expect(service.isActive()).toBe(true);
|
||||
expect(service.getConfig().collectionInterval).toBe(5000);
|
||||
});
|
||||
|
||||
it('should resize data buffers when maxDataPoints changes', () => {
|
||||
// Start and collect some data
|
||||
service.start();
|
||||
|
||||
// Collect multiple data points
|
||||
for (let i = 0; i < 50; i++) {
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
}
|
||||
|
||||
// Reduce max data points
|
||||
service.updateConfig({ maxDataPoints: 10 });
|
||||
|
||||
const history = service.getMemoryHistory();
|
||||
expect(history.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('metrics collection', () => {
|
||||
it('should emit debug:metrics event on collection', () => {
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
service.start();
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
const [eventType, eventData] = callback.mock.calls[0];
|
||||
expect(eventType).toBe('debug:metrics');
|
||||
expect(eventData).toHaveProperty('timestamp');
|
||||
expect(eventData).toHaveProperty('metrics');
|
||||
});
|
||||
|
||||
it('should collect memory metrics when memoryEnabled is true', () => {
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
service.start();
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
|
||||
const [, eventData] = callback.mock.calls[0];
|
||||
expect(eventData.metrics.memory.server).toBeDefined();
|
||||
expect(eventData.metrics.memory.server.heapUsed).toBeGreaterThan(0);
|
||||
expect(eventData.metrics.memory.server.heapTotal).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not collect memory metrics when memoryEnabled is false', () => {
|
||||
const customService = new PerformanceMonitorService(events, { memoryEnabled: false });
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
customService.start();
|
||||
vi.advanceTimersByTime(customService.getConfig().collectionInterval);
|
||||
|
||||
const [, eventData] = callback.mock.calls[0];
|
||||
expect(eventData.metrics.memory.server).toBeUndefined();
|
||||
|
||||
customService.stop();
|
||||
});
|
||||
|
||||
it('should collect CPU metrics when cpuEnabled is true', () => {
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
service.start();
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
|
||||
// Need at least 2 collections for CPU diff
|
||||
const lastCall = callback.mock.calls[callback.mock.calls.length - 1];
|
||||
const [, eventData] = lastCall;
|
||||
expect(eventData.metrics.cpu.server).toBeDefined();
|
||||
});
|
||||
|
||||
it('should track event loop lag', () => {
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
service.start();
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
|
||||
const [, eventData] = callback.mock.calls[0];
|
||||
expect(eventData.metrics.cpu.eventLoopLag).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory history', () => {
|
||||
it('should return empty history initially', () => {
|
||||
const history = service.getMemoryHistory();
|
||||
expect(history).toEqual([]);
|
||||
});
|
||||
|
||||
it('should accumulate memory history over time', () => {
|
||||
service.start();
|
||||
|
||||
// Collect multiple data points
|
||||
for (let i = 0; i < 5; i++) {
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
}
|
||||
|
||||
const history = service.getMemoryHistory();
|
||||
expect(history.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should limit history to maxDataPoints', () => {
|
||||
const maxPoints = 10;
|
||||
const customService = new PerformanceMonitorService(events, { maxDataPoints: maxPoints });
|
||||
customService.start();
|
||||
|
||||
// Collect more data points than max
|
||||
for (let i = 0; i < maxPoints + 10; i++) {
|
||||
vi.advanceTimersByTime(customService.getConfig().collectionInterval);
|
||||
}
|
||||
|
||||
const history = customService.getMemoryHistory();
|
||||
expect(history.length).toBeLessThanOrEqual(maxPoints);
|
||||
|
||||
customService.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CPU history', () => {
|
||||
it('should return empty CPU history initially', () => {
|
||||
const history = service.getCPUHistory();
|
||||
expect(history).toEqual([]);
|
||||
});
|
||||
|
||||
it('should accumulate CPU history over time', () => {
|
||||
service.start();
|
||||
|
||||
// Collect multiple data points (need at least 2 for CPU diff)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
}
|
||||
|
||||
const history = service.getCPUHistory();
|
||||
expect(history.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('process provider', () => {
|
||||
it('should use provided process provider', () => {
|
||||
const mockProcesses: TrackedProcess[] = [
|
||||
{
|
||||
id: 'test-1',
|
||||
type: 'agent',
|
||||
name: 'TestAgent',
|
||||
status: 'running',
|
||||
startedAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
type: 'terminal',
|
||||
name: 'TestTerminal',
|
||||
status: 'idle',
|
||||
startedAt: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const provider = vi.fn(() => mockProcesses);
|
||||
service.setProcessProvider(provider);
|
||||
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
service.start();
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
|
||||
const [, eventData] = callback.mock.calls[0];
|
||||
expect(eventData.metrics.processes).toEqual(mockProcesses);
|
||||
expect(eventData.metrics.processSummary.total).toBe(2);
|
||||
expect(eventData.metrics.processSummary.running).toBe(1);
|
||||
expect(eventData.metrics.processSummary.idle).toBe(1);
|
||||
expect(eventData.metrics.processSummary.byType.agent).toBe(1);
|
||||
expect(eventData.metrics.processSummary.byType.terminal).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatestSnapshot', () => {
|
||||
it('should return null when no data collected', () => {
|
||||
const snapshot = service.getLatestSnapshot();
|
||||
expect(snapshot).toBeNull();
|
||||
});
|
||||
|
||||
it('should return snapshot after data collection', () => {
|
||||
service.start();
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
|
||||
const snapshot = service.getLatestSnapshot();
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(snapshot).toHaveProperty('timestamp');
|
||||
expect(snapshot).toHaveProperty('memory');
|
||||
expect(snapshot).toHaveProperty('cpu');
|
||||
expect(snapshot).toHaveProperty('processes');
|
||||
expect(snapshot).toHaveProperty('processSummary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearHistory', () => {
|
||||
it('should clear all history', () => {
|
||||
service.start();
|
||||
|
||||
// Collect some data
|
||||
for (let i = 0; i < 5; i++) {
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
}
|
||||
|
||||
expect(service.getMemoryHistory().length).toBeGreaterThan(0);
|
||||
|
||||
service.clearHistory();
|
||||
|
||||
expect(service.getMemoryHistory().length).toBe(0);
|
||||
expect(service.getCPUHistory().length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forceGC', () => {
|
||||
it('should return false when gc is not available', () => {
|
||||
const originalGc = global.gc;
|
||||
global.gc = undefined;
|
||||
|
||||
const result = service.forceGC();
|
||||
expect(result).toBe(false);
|
||||
|
||||
// Restore
|
||||
global.gc = originalGc;
|
||||
});
|
||||
|
||||
it('should return true and call gc when available', () => {
|
||||
const mockGc = vi.fn();
|
||||
global.gc = mockGc;
|
||||
|
||||
const result = service.forceGC();
|
||||
expect(result).toBe(true);
|
||||
expect(mockGc).toHaveBeenCalled();
|
||||
|
||||
// Cleanup
|
||||
global.gc = undefined;
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory trend analysis', () => {
|
||||
it('should not calculate trend with insufficient data', () => {
|
||||
service.start();
|
||||
|
||||
// Collect only a few data points
|
||||
for (let i = 0; i < 5; i++) {
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
}
|
||||
|
||||
const snapshot = service.getLatestSnapshot();
|
||||
// Trend requires at least 10 samples
|
||||
expect(snapshot?.memoryTrend).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should calculate trend with sufficient data', () => {
|
||||
service.start();
|
||||
|
||||
// Collect enough data points for trend analysis
|
||||
for (let i = 0; i < 15; i++) {
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
}
|
||||
|
||||
const snapshot = service.getLatestSnapshot();
|
||||
expect(snapshot?.memoryTrend).toBeDefined();
|
||||
expect(snapshot?.memoryTrend).toHaveProperty('growthRate');
|
||||
expect(snapshot?.memoryTrend).toHaveProperty('isLeaking');
|
||||
expect(snapshot?.memoryTrend).toHaveProperty('confidence');
|
||||
expect(snapshot?.memoryTrend).toHaveProperty('sampleCount');
|
||||
});
|
||||
});
|
||||
|
||||
describe('process summary calculation', () => {
|
||||
it('should correctly categorize processes by status', () => {
|
||||
const mockProcesses: TrackedProcess[] = [
|
||||
{ id: '1', type: 'agent', name: 'A1', status: 'running', startedAt: Date.now() },
|
||||
{ id: '2', type: 'agent', name: 'A2', status: 'starting', startedAt: Date.now() },
|
||||
{ id: '3', type: 'terminal', name: 'T1', status: 'idle', startedAt: Date.now() },
|
||||
{ id: '4', type: 'terminal', name: 'T2', status: 'stopped', startedAt: Date.now() },
|
||||
{ id: '5', type: 'cli', name: 'C1', status: 'stopping', startedAt: Date.now() },
|
||||
{ id: '6', type: 'worker', name: 'W1', status: 'error', startedAt: Date.now() },
|
||||
];
|
||||
|
||||
service.setProcessProvider(() => mockProcesses);
|
||||
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
service.start();
|
||||
vi.advanceTimersByTime(service.getConfig().collectionInterval);
|
||||
|
||||
const [, eventData] = callback.mock.calls[0];
|
||||
const summary = eventData.metrics.processSummary;
|
||||
|
||||
expect(summary.total).toBe(6);
|
||||
expect(summary.running).toBe(2); // running + starting
|
||||
expect(summary.idle).toBe(1);
|
||||
expect(summary.stopped).toBe(2); // stopped + stopping
|
||||
expect(summary.errored).toBe(1);
|
||||
expect(summary.byType.agent).toBe(2);
|
||||
expect(summary.byType.terminal).toBe(2);
|
||||
expect(summary.byType.cli).toBe(1);
|
||||
expect(summary.byType.worker).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
538
apps/server/tests/unit/services/process-registry-service.test.ts
Normal file
538
apps/server/tests/unit/services/process-registry-service.test.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
ProcessRegistryService,
|
||||
getProcessRegistryService,
|
||||
resetProcessRegistryService,
|
||||
} from '@/services/process-registry-service.js';
|
||||
import { createEventEmitter } from '@/lib/events.js';
|
||||
import type { EventEmitter } from '@/lib/events.js';
|
||||
import type { TrackedProcess, ProcessType, ProcessStatus } from '@automaker/types';
|
||||
|
||||
// Mock the logger to prevent console output during tests
|
||||
vi.mock('@automaker/utils', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ProcessRegistryService', () => {
|
||||
let service: ProcessRegistryService;
|
||||
let events: EventEmitter;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
events = createEventEmitter();
|
||||
service = new ProcessRegistryService(events);
|
||||
resetProcessRegistryService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.stop();
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with default configuration', () => {
|
||||
const config = service.getConfig();
|
||||
expect(config.stoppedProcessRetention).toBe(5 * 60 * 1000);
|
||||
expect(config.cleanupInterval).toBe(60 * 1000);
|
||||
expect(config.maxStoppedProcesses).toBe(100);
|
||||
});
|
||||
|
||||
it('should accept custom configuration', () => {
|
||||
const customService = new ProcessRegistryService(events, {
|
||||
stoppedProcessRetention: 10000,
|
||||
maxStoppedProcesses: 50,
|
||||
});
|
||||
|
||||
const config = customService.getConfig();
|
||||
expect(config.stoppedProcessRetention).toBe(10000);
|
||||
expect(config.maxStoppedProcesses).toBe(50);
|
||||
expect(config.cleanupInterval).toBe(60 * 1000);
|
||||
|
||||
customService.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('start/stop', () => {
|
||||
it('should start the service', () => {
|
||||
expect(() => service.start()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should stop the service', () => {
|
||||
service.start();
|
||||
expect(() => service.stop()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not start again if already running', () => {
|
||||
service.start();
|
||||
// Should log warning but not throw
|
||||
expect(() => service.start()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('process registration', () => {
|
||||
it('should register a new process', () => {
|
||||
const process = service.registerProcess({
|
||||
id: 'test-1',
|
||||
pid: 1234,
|
||||
type: 'agent',
|
||||
name: 'TestAgent',
|
||||
});
|
||||
|
||||
expect(process.id).toBe('test-1');
|
||||
expect(process.pid).toBe(1234);
|
||||
expect(process.type).toBe('agent');
|
||||
expect(process.name).toBe('TestAgent');
|
||||
expect(process.status).toBe('starting');
|
||||
expect(process.startedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should register a process with all optional fields', () => {
|
||||
const process = service.registerProcess({
|
||||
id: 'test-2',
|
||||
pid: 5678,
|
||||
type: 'terminal',
|
||||
name: 'TestTerminal',
|
||||
featureId: 'feature-123',
|
||||
sessionId: 'session-456',
|
||||
command: 'bash',
|
||||
cwd: '/home/user',
|
||||
});
|
||||
|
||||
expect(process.featureId).toBe('feature-123');
|
||||
expect(process.sessionId).toBe('session-456');
|
||||
expect(process.command).toBe('bash');
|
||||
expect(process.cwd).toBe('/home/user');
|
||||
});
|
||||
|
||||
it('should emit debug:process-spawned event on registration', () => {
|
||||
const callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
|
||||
service.registerProcess({
|
||||
id: 'test-3',
|
||||
pid: 111,
|
||||
type: 'cli',
|
||||
name: 'TestCLI',
|
||||
});
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
const [eventType, eventData] = callback.mock.calls[0];
|
||||
expect(eventType).toBe('debug:process-spawned');
|
||||
expect(eventData.process.id).toBe('test-3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('process retrieval', () => {
|
||||
beforeEach(() => {
|
||||
// Register test processes
|
||||
service.registerProcess({
|
||||
id: 'p1',
|
||||
pid: 1,
|
||||
type: 'agent',
|
||||
name: 'Agent1',
|
||||
featureId: 'f1',
|
||||
sessionId: 's1',
|
||||
});
|
||||
service.registerProcess({
|
||||
id: 'p2',
|
||||
pid: 2,
|
||||
type: 'terminal',
|
||||
name: 'Terminal1',
|
||||
sessionId: 's1',
|
||||
});
|
||||
service.registerProcess({ id: 'p3', pid: 3, type: 'cli', name: 'CLI1', featureId: 'f2' });
|
||||
});
|
||||
|
||||
it('should get a process by ID', () => {
|
||||
const process = service.getProcess('p1');
|
||||
expect(process).toBeDefined();
|
||||
expect(process?.name).toBe('Agent1');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent process', () => {
|
||||
const process = service.getProcess('non-existent');
|
||||
expect(process).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should check if process exists', () => {
|
||||
expect(service.hasProcess('p1')).toBe(true);
|
||||
expect(service.hasProcess('non-existent')).toBe(false);
|
||||
});
|
||||
|
||||
it('should get all processes without filters', () => {
|
||||
const processes = service.getProcesses({ includeStopped: true });
|
||||
expect(processes.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should filter by type', () => {
|
||||
const agents = service.getProcesses({ type: 'agent', includeStopped: true });
|
||||
expect(agents.length).toBe(1);
|
||||
expect(agents[0].type).toBe('agent');
|
||||
});
|
||||
|
||||
it('should filter by session ID', () => {
|
||||
const sessionProcesses = service.getProcesses({ sessionId: 's1', includeStopped: true });
|
||||
expect(sessionProcesses.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should filter by feature ID', () => {
|
||||
const featureProcesses = service.getProcesses({ featureId: 'f1', includeStopped: true });
|
||||
expect(featureProcesses.length).toBe(1);
|
||||
expect(featureProcesses[0].id).toBe('p1');
|
||||
});
|
||||
|
||||
it('should exclude stopped processes by default', () => {
|
||||
service.markStopped('p1');
|
||||
const processes = service.getProcesses();
|
||||
expect(processes.length).toBe(2);
|
||||
expect(processes.find((p) => p.id === 'p1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include stopped processes when requested', () => {
|
||||
service.markStopped('p1');
|
||||
const processes = service.getProcesses({ includeStopped: true });
|
||||
expect(processes.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should sort processes by start time (most recent first)', () => {
|
||||
// Re-register processes with different timestamps
|
||||
service.clear();
|
||||
|
||||
// Register p1 at time 0
|
||||
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'Agent1' });
|
||||
|
||||
// Advance time and register p2
|
||||
vi.advanceTimersByTime(1000);
|
||||
service.registerProcess({ id: 'p2', pid: 2, type: 'terminal', name: 'Terminal1' });
|
||||
|
||||
// Advance time and register p3
|
||||
vi.advanceTimersByTime(1000);
|
||||
service.registerProcess({ id: 'p3', pid: 3, type: 'cli', name: 'CLI1' });
|
||||
|
||||
const processes = service.getProcesses({ includeStopped: true });
|
||||
// p3 was registered last (most recent), so it should be first
|
||||
expect(processes[0].id).toBe('p3');
|
||||
expect(processes[1].id).toBe('p2');
|
||||
expect(processes[2].id).toBe('p1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('process status updates', () => {
|
||||
let process: TrackedProcess;
|
||||
|
||||
beforeEach(() => {
|
||||
process = service.registerProcess({
|
||||
id: 'test-proc',
|
||||
pid: 100,
|
||||
type: 'agent',
|
||||
name: 'TestProcess',
|
||||
});
|
||||
});
|
||||
|
||||
it('should update process status', () => {
|
||||
const updated = service.updateProcess('test-proc', { status: 'running' });
|
||||
expect(updated?.status).toBe('running');
|
||||
});
|
||||
|
||||
it('should update memory usage', () => {
|
||||
const updated = service.updateProcess('test-proc', { memoryUsage: 1024 * 1024 });
|
||||
expect(updated?.memoryUsage).toBe(1024 * 1024);
|
||||
});
|
||||
|
||||
it('should update CPU usage', () => {
|
||||
const updated = service.updateProcess('test-proc', { cpuUsage: 45.5 });
|
||||
expect(updated?.cpuUsage).toBe(45.5);
|
||||
});
|
||||
|
||||
it('should return null for non-existent process', () => {
|
||||
const updated = service.updateProcess('non-existent', { status: 'running' });
|
||||
expect(updated).toBeNull();
|
||||
});
|
||||
|
||||
it('should set stoppedAt when status is stopped', () => {
|
||||
const updated = service.markStopped('test-proc');
|
||||
expect(updated?.stoppedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should set stoppedAt when status is error', () => {
|
||||
const updated = service.markError('test-proc', 'Something went wrong');
|
||||
expect(updated?.stoppedAt).toBeDefined();
|
||||
expect(updated?.error).toBe('Something went wrong');
|
||||
});
|
||||
});
|
||||
|
||||
describe('status shortcut methods', () => {
|
||||
beforeEach(() => {
|
||||
service.registerProcess({
|
||||
id: 'test-proc',
|
||||
pid: 100,
|
||||
type: 'agent',
|
||||
name: 'TestProcess',
|
||||
});
|
||||
});
|
||||
|
||||
it('should mark process as running', () => {
|
||||
const updated = service.markRunning('test-proc');
|
||||
expect(updated?.status).toBe('running');
|
||||
});
|
||||
|
||||
it('should mark process as idle', () => {
|
||||
const updated = service.markIdle('test-proc');
|
||||
expect(updated?.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('should mark process as stopping', () => {
|
||||
const updated = service.markStopping('test-proc');
|
||||
expect(updated?.status).toBe('stopping');
|
||||
});
|
||||
|
||||
it('should mark process as stopped with exit code', () => {
|
||||
const updated = service.markStopped('test-proc', 0);
|
||||
expect(updated?.status).toBe('stopped');
|
||||
expect(updated?.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('should mark process as error with message', () => {
|
||||
const updated = service.markError('test-proc', 'Process crashed');
|
||||
expect(updated?.status).toBe('error');
|
||||
expect(updated?.error).toBe('Process crashed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('event emissions', () => {
|
||||
let callback: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
callback = vi.fn();
|
||||
events.subscribe(callback);
|
||||
service.registerProcess({
|
||||
id: 'test-proc',
|
||||
pid: 100,
|
||||
type: 'agent',
|
||||
name: 'TestProcess',
|
||||
});
|
||||
callback.mockClear();
|
||||
});
|
||||
|
||||
it('should emit debug:process-stopped when stopped', () => {
|
||||
service.markStopped('test-proc', 0);
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
const [eventType] = callback.mock.calls[0];
|
||||
expect(eventType).toBe('debug:process-stopped');
|
||||
});
|
||||
|
||||
it('should emit debug:process-error when errored', () => {
|
||||
service.markError('test-proc', 'Error message');
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
const [eventType, eventData] = callback.mock.calls[0];
|
||||
expect(eventType).toBe('debug:process-error');
|
||||
expect(eventData.message).toContain('Error message');
|
||||
});
|
||||
|
||||
it('should emit debug:process-updated for other status changes', () => {
|
||||
service.markRunning('test-proc');
|
||||
|
||||
expect(callback).toHaveBeenCalled();
|
||||
const [eventType] = callback.mock.calls[0];
|
||||
expect(eventType).toBe('debug:process-updated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('process unregistration', () => {
|
||||
it('should unregister an existing process', () => {
|
||||
service.registerProcess({
|
||||
id: 'test-proc',
|
||||
pid: 100,
|
||||
type: 'agent',
|
||||
name: 'TestProcess',
|
||||
});
|
||||
|
||||
const result = service.unregisterProcess('test-proc');
|
||||
expect(result).toBe(true);
|
||||
expect(service.getProcess('test-proc')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return false for non-existent process', () => {
|
||||
const result = service.unregisterProcess('non-existent');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('process summary', () => {
|
||||
beforeEach(() => {
|
||||
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
|
||||
service.registerProcess({ id: 'p2', pid: 2, type: 'agent', name: 'A2' });
|
||||
service.registerProcess({ id: 'p3', pid: 3, type: 'terminal', name: 'T1' });
|
||||
service.registerProcess({ id: 'p4', pid: 4, type: 'cli', name: 'C1' });
|
||||
service.registerProcess({ id: 'p5', pid: 5, type: 'worker', name: 'W1' });
|
||||
|
||||
// Update statuses
|
||||
service.markRunning('p1');
|
||||
service.markIdle('p2');
|
||||
service.markStopped('p3');
|
||||
service.markError('p4', 'error');
|
||||
service.markRunning('p5');
|
||||
});
|
||||
|
||||
it('should calculate correct summary statistics', () => {
|
||||
const summary = service.getProcessSummary();
|
||||
|
||||
expect(summary.total).toBe(5);
|
||||
expect(summary.running).toBe(2); // p1 running, p5 running
|
||||
expect(summary.idle).toBe(1); // p2 idle
|
||||
expect(summary.stopped).toBe(1); // p3 stopped
|
||||
expect(summary.errored).toBe(1); // p4 error
|
||||
});
|
||||
|
||||
it('should count processes by type', () => {
|
||||
const summary = service.getProcessSummary();
|
||||
|
||||
expect(summary.byType.agent).toBe(2);
|
||||
expect(summary.byType.terminal).toBe(1);
|
||||
expect(summary.byType.cli).toBe(1);
|
||||
expect(summary.byType.worker).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('active count', () => {
|
||||
beforeEach(() => {
|
||||
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
|
||||
service.registerProcess({ id: 'p2', pid: 2, type: 'agent', name: 'A2' });
|
||||
service.registerProcess({ id: 'p3', pid: 3, type: 'terminal', name: 'T1' });
|
||||
|
||||
service.markRunning('p1');
|
||||
service.markStopped('p2');
|
||||
service.markIdle('p3');
|
||||
});
|
||||
|
||||
it('should return count of active processes', () => {
|
||||
expect(service.getActiveCount()).toBe(2); // p1 running, p3 idle
|
||||
});
|
||||
|
||||
it('should return count by type', () => {
|
||||
expect(service.getCountByType('agent')).toBe(2);
|
||||
expect(service.getCountByType('terminal')).toBe(1);
|
||||
expect(service.getCountByType('cli')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('process provider', () => {
|
||||
it('should return a process provider function', () => {
|
||||
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
|
||||
|
||||
const provider = service.getProcessProvider();
|
||||
expect(typeof provider).toBe('function');
|
||||
|
||||
const processes = provider();
|
||||
expect(processes.length).toBe(1);
|
||||
expect(processes[0].id).toBe('p1');
|
||||
});
|
||||
|
||||
it('should return all processes including stopped', () => {
|
||||
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
|
||||
service.registerProcess({ id: 'p2', pid: 2, type: 'agent', name: 'A2' });
|
||||
service.markStopped('p2');
|
||||
|
||||
const provider = service.getProcessProvider();
|
||||
const processes = provider();
|
||||
|
||||
expect(processes.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup', () => {
|
||||
it('should clean up old stopped processes', () => {
|
||||
// Register and stop a process
|
||||
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
|
||||
service.markStopped('p1');
|
||||
|
||||
// Start service to enable cleanup
|
||||
service.start();
|
||||
|
||||
// Advance time past retention period
|
||||
vi.advanceTimersByTime(6 * 60 * 1000); // 6 minutes (past default 5 min retention)
|
||||
|
||||
// Process should be cleaned up
|
||||
expect(service.getProcess('p1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should enforce max stopped processes limit', () => {
|
||||
const customService = new ProcessRegistryService(events, {
|
||||
maxStoppedProcesses: 3,
|
||||
cleanupInterval: 1000,
|
||||
});
|
||||
|
||||
// Register and stop more processes than max
|
||||
for (let i = 0; i < 5; i++) {
|
||||
customService.registerProcess({ id: `p${i}`, pid: i, type: 'agent', name: `A${i}` });
|
||||
customService.markStopped(`p${i}`);
|
||||
}
|
||||
|
||||
customService.start();
|
||||
|
||||
// Trigger cleanup
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
// Should only have max stopped processes
|
||||
const allProcesses = customService.getAllProcesses();
|
||||
expect(allProcesses.length).toBeLessThanOrEqual(3);
|
||||
|
||||
customService.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration update', () => {
|
||||
it('should update configuration', () => {
|
||||
service.updateConfig({ maxStoppedProcesses: 200 });
|
||||
expect(service.getConfig().maxStoppedProcesses).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear all tracked processes', () => {
|
||||
service.registerProcess({ id: 'p1', pid: 1, type: 'agent', name: 'A1' });
|
||||
service.registerProcess({ id: 'p2', pid: 2, type: 'terminal', name: 'T1' });
|
||||
|
||||
service.clear();
|
||||
|
||||
expect(service.getAllProcesses().length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('singleton pattern', () => {
|
||||
beforeEach(() => {
|
||||
resetProcessRegistryService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetProcessRegistryService();
|
||||
});
|
||||
|
||||
it('should create singleton instance', () => {
|
||||
const instance1 = getProcessRegistryService(events);
|
||||
const instance2 = getProcessRegistryService();
|
||||
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
|
||||
it('should throw if no events provided on first call', () => {
|
||||
expect(() => getProcessRegistryService()).toThrow();
|
||||
});
|
||||
|
||||
it('should reset singleton', () => {
|
||||
const instance1 = getProcessRegistryService(events);
|
||||
resetProcessRegistryService();
|
||||
const instance2 = getProcessRegistryService(events);
|
||||
|
||||
expect(instance1).not.toBe(instance2);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user