Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt standard monorepo conventions (apps/ for runnable apps, packages/ for reusable libraries). Update all config files, shared package imports, test fixtures, and documentation to reflect new paths. Key fixes: - Update workspace config to ["apps/*", "packages/*"] - Update tsconfig.json rootDir/include for apps/server/ - Add apps/web/** to vitest exclude list - Update drizzle.config.ts schema path - Fix ensure-schema.ts migration path detection (3 levels up in dev, 2 levels up in dist) - Fix tests/integration/cli-server.test.ts import paths - Update packages/shared imports to apps/server/ paths - Update all docs/ files with new paths
290 lines
8.5 KiB
TypeScript
290 lines
8.5 KiB
TypeScript
/**
|
|
* CoordinationServer Tests
|
|
*
|
|
* Tests for the HTTP coordination server lifecycle, endpoints, and PID file management.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { CoordinationServer } from './index.js';
|
|
import { writeFile, unlink, mkdir, readFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { randomUUID } from 'node:crypto';
|
|
import type { ProcessManager } from '../process/index.js';
|
|
import type { LogManager } from '../logging/index.js';
|
|
import type { EventBus, ServerStartedEvent, ServerStoppedEvent } from '../events/index.js';
|
|
|
|
/**
|
|
* Creates a mock ProcessManager for testing.
|
|
*/
|
|
function createMockProcessManager(): ProcessManager {
|
|
return {
|
|
spawn: vi.fn(),
|
|
stop: vi.fn(),
|
|
stopAll: vi.fn().mockResolvedValue(undefined),
|
|
restart: vi.fn(),
|
|
isRunning: vi.fn(),
|
|
} as unknown as ProcessManager;
|
|
}
|
|
|
|
/**
|
|
* Creates a mock LogManager for testing.
|
|
*/
|
|
function createMockLogManager(): LogManager {
|
|
return {
|
|
ensureLogDir: vi.fn(),
|
|
ensureProcessDir: vi.fn(),
|
|
getProcessDir: vi.fn(),
|
|
getLogPath: vi.fn(),
|
|
listLogs: vi.fn(),
|
|
cleanOldLogs: vi.fn(),
|
|
getBaseDir: vi.fn(),
|
|
} as unknown as LogManager;
|
|
}
|
|
|
|
/**
|
|
* Creates a mock EventBus for testing.
|
|
*/
|
|
function createMockEventBus(): EventBus & { emit: ReturnType<typeof vi.fn> } {
|
|
return {
|
|
emit: vi.fn(),
|
|
on: vi.fn(),
|
|
off: vi.fn(),
|
|
once: vi.fn(),
|
|
} as unknown as EventBus & { emit: ReturnType<typeof vi.fn> };
|
|
}
|
|
|
|
/**
|
|
* Gets a random high port to avoid conflicts.
|
|
*/
|
|
function getRandomPort(): number {
|
|
return Math.floor(Math.random() * (65535 - 49152) + 49152);
|
|
}
|
|
|
|
/**
|
|
* Gets a temporary PID file path.
|
|
*/
|
|
function getTempPidFile(): string {
|
|
return join(tmpdir(), `cw-test-${randomUUID()}.pid`);
|
|
}
|
|
|
|
describe('CoordinationServer', () => {
|
|
let server: CoordinationServer;
|
|
let processManager: ProcessManager;
|
|
let logManager: LogManager;
|
|
let port: number;
|
|
let pidFile: string;
|
|
|
|
beforeEach(() => {
|
|
processManager = createMockProcessManager();
|
|
logManager = createMockLogManager();
|
|
port = getRandomPort();
|
|
pidFile = getTempPidFile();
|
|
server = new CoordinationServer(
|
|
{ port, pidFile },
|
|
processManager,
|
|
logManager
|
|
);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Clean up server if running
|
|
if (server.isRunning()) {
|
|
await server.stop();
|
|
}
|
|
// Clean up PID file if exists
|
|
try {
|
|
await unlink(pidFile);
|
|
} catch {
|
|
// Ignore if file doesn't exist
|
|
}
|
|
});
|
|
|
|
describe('lifecycle', () => {
|
|
it('should start listening on configured port', async () => {
|
|
await server.start();
|
|
|
|
expect(server.isRunning()).toBe(true);
|
|
expect(server.getPort()).toBe(port);
|
|
});
|
|
|
|
it('should throw if already running', async () => {
|
|
await server.start();
|
|
|
|
await expect(server.start()).rejects.toThrow('Server is already running');
|
|
});
|
|
|
|
it('should throw if PID file exists from another server', async () => {
|
|
// Create a PID file with current process PID (simulating another server)
|
|
await mkdir(join(tmpdir()), { recursive: true });
|
|
await writeFile(pidFile, process.pid.toString(), 'utf-8');
|
|
|
|
await expect(server.start()).rejects.toThrow(
|
|
/Another server appears to be running/
|
|
);
|
|
});
|
|
|
|
it('should stop the server', async () => {
|
|
await server.start();
|
|
expect(server.isRunning()).toBe(true);
|
|
|
|
await server.stop();
|
|
expect(server.isRunning()).toBe(false);
|
|
});
|
|
|
|
it('should be idempotent when stopping (no error if not running)', async () => {
|
|
expect(server.isRunning()).toBe(false);
|
|
|
|
// Should not throw
|
|
await server.stop();
|
|
expect(server.isRunning()).toBe(false);
|
|
});
|
|
|
|
it('should report correct running state via isRunning()', async () => {
|
|
expect(server.isRunning()).toBe(false);
|
|
|
|
await server.start();
|
|
expect(server.isRunning()).toBe(true);
|
|
|
|
await server.stop();
|
|
expect(server.isRunning()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('HTTP endpoints', () => {
|
|
beforeEach(async () => {
|
|
await server.start();
|
|
});
|
|
|
|
it('should return health status with uptime on GET /health', async () => {
|
|
const response = await fetch(`http://127.0.0.1:${port}/health`);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(data.status).toBe('ok');
|
|
expect(typeof data.uptime).toBe('number');
|
|
expect(data.uptime).toBeGreaterThanOrEqual(0);
|
|
expect(typeof data.processCount).toBe('number');
|
|
});
|
|
|
|
it('should return server info on GET /status', async () => {
|
|
const response = await fetch(`http://127.0.0.1:${port}/status`);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(data.server).toBeDefined();
|
|
expect(data.server.pid).toBe(process.pid);
|
|
expect(typeof data.server.uptime).toBe('number');
|
|
expect(typeof data.server.startedAt).toBe('string');
|
|
expect(Array.isArray(data.processes)).toBe(true);
|
|
});
|
|
|
|
it('should return 404 on GET /unknown', async () => {
|
|
const response = await fetch(`http://127.0.0.1:${port}/unknown`);
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(data.error).toBe('Not found');
|
|
});
|
|
|
|
it('should return 405 on POST /health', async () => {
|
|
const response = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
method: 'POST',
|
|
});
|
|
const data = await response.json();
|
|
|
|
expect(response.status).toBe(405);
|
|
expect(data.error).toBe('Method not allowed');
|
|
});
|
|
});
|
|
|
|
describe('PID file management', () => {
|
|
it('should create PID file on start', async () => {
|
|
await server.start();
|
|
|
|
const content = await readFile(pidFile, 'utf-8');
|
|
expect(parseInt(content.trim(), 10)).toBe(process.pid);
|
|
});
|
|
|
|
it('should remove PID file on stop', async () => {
|
|
await server.start();
|
|
await server.stop();
|
|
|
|
await expect(readFile(pidFile, 'utf-8')).rejects.toThrow(/ENOENT/);
|
|
});
|
|
|
|
it('should clean up stale PID file from dead process', async () => {
|
|
// Create a stale PID file with a non-existent PID
|
|
// Use a very high PID that's unlikely to exist
|
|
const stalePid = 999999;
|
|
await mkdir(join(tmpdir()), { recursive: true });
|
|
await writeFile(pidFile, stalePid.toString(), 'utf-8');
|
|
|
|
// Server should start successfully after cleaning up stale PID
|
|
await server.start();
|
|
|
|
expect(server.isRunning()).toBe(true);
|
|
|
|
// Verify PID file now has our PID
|
|
const content = await readFile(pidFile, 'utf-8');
|
|
expect(parseInt(content.trim(), 10)).toBe(process.pid);
|
|
});
|
|
});
|
|
|
|
describe('event emission', () => {
|
|
let eventBus: ReturnType<typeof createMockEventBus>;
|
|
let serverWithEvents: CoordinationServer;
|
|
|
|
beforeEach(() => {
|
|
eventBus = createMockEventBus();
|
|
serverWithEvents = new CoordinationServer(
|
|
{ port, pidFile },
|
|
processManager,
|
|
logManager,
|
|
eventBus
|
|
);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (serverWithEvents.isRunning()) {
|
|
await serverWithEvents.stop();
|
|
}
|
|
});
|
|
|
|
it('should emit ServerStarted event on start', async () => {
|
|
await serverWithEvents.start();
|
|
|
|
expect(eventBus.emit).toHaveBeenCalledOnce();
|
|
const emittedEvent = eventBus.emit.mock.calls[0][0] as ServerStartedEvent;
|
|
expect(emittedEvent.type).toBe('server:started');
|
|
expect(emittedEvent.payload.port).toBe(port);
|
|
expect(emittedEvent.payload.host).toBe('127.0.0.1');
|
|
expect(emittedEvent.payload.pid).toBe(process.pid);
|
|
expect(emittedEvent.timestamp).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it('should emit ServerStopped event on stop', async () => {
|
|
await serverWithEvents.start();
|
|
eventBus.emit.mockClear(); // Clear the start event
|
|
|
|
await serverWithEvents.stop();
|
|
|
|
expect(eventBus.emit).toHaveBeenCalledOnce();
|
|
const emittedEvent = eventBus.emit.mock.calls[0][0] as ServerStoppedEvent;
|
|
expect(emittedEvent.type).toBe('server:stopped');
|
|
expect(typeof emittedEvent.payload.uptime).toBe('number');
|
|
expect(emittedEvent.payload.uptime).toBeGreaterThanOrEqual(0);
|
|
expect(emittedEvent.timestamp).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it('should not emit events if eventBus is not provided', async () => {
|
|
// Use the original server without eventBus
|
|
await server.start();
|
|
await server.stop();
|
|
|
|
// eventBus.emit should never have been called since server doesn't have it
|
|
expect(eventBus.emit).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|