Files
Codewalkers/apps/server/server/index.test.ts
Lukas May 34578d39c6 refactor: Restructure monorepo to apps/server/ and apps/web/ layout
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
2026-03-03 11:22:53 +01:00

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();
});
});
});