mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
204 lines
6.1 KiB
TypeScript
204 lines
6.1 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
import * as http from 'http';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { spawn } from 'child_process';
|
|
import { isDaemonRunning, startDaemon, stopDaemon } from '../../../src/copilot/copilot-daemon';
|
|
import { DEFAULT_COPILOT_CONFIG } from '../../../src/config/unified-config-types';
|
|
import { getCcsDir } from '../../../src/utils/config-manager';
|
|
|
|
const activeServers: http.Server[] = [];
|
|
let originalCcsHome: string | undefined;
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
originalCcsHome = process.env.CCS_HOME;
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-copilot-daemon-test-'));
|
|
process.env.CCS_HOME = tempDir;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
activeServers.splice(0).map(
|
|
(server) =>
|
|
new Promise<void>((resolve) => {
|
|
server.close(() => resolve());
|
|
})
|
|
)
|
|
);
|
|
|
|
if (originalCcsHome !== undefined) {
|
|
process.env.CCS_HOME = originalCcsHome;
|
|
} else {
|
|
delete process.env.CCS_HOME;
|
|
}
|
|
|
|
try {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
});
|
|
|
|
async function createServer(
|
|
handler: (req: http.IncomingMessage, res: http.ServerResponse<http.IncomingMessage>) => void
|
|
): Promise<number> {
|
|
const server = http.createServer(handler);
|
|
activeServers.push(server);
|
|
|
|
await new Promise<void>((resolve) => {
|
|
server.listen(0, '127.0.0.1', () => resolve());
|
|
});
|
|
|
|
const address = server.address();
|
|
if (!address || typeof address === 'string') {
|
|
throw new Error('Unable to resolve server port');
|
|
}
|
|
|
|
return address.port;
|
|
}
|
|
|
|
function writeCopilotPortConfig(port: number): void {
|
|
const configPath = path.join(getCcsDir(), 'config.yaml');
|
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
fs.writeFileSync(configPath, `version: 8\ncopilot:\n port: ${port}\n`);
|
|
}
|
|
|
|
describe('copilot daemon health detection', () => {
|
|
it('returns false for invalid port inputs', async () => {
|
|
expect(await isDaemonRunning(0)).toBe(false);
|
|
expect(await isDaemonRunning(65536)).toBe(false);
|
|
});
|
|
|
|
it('returns false when no daemon is running on port', async () => {
|
|
const running = await isDaemonRunning(19998);
|
|
expect(running).toBe(false);
|
|
});
|
|
|
|
it('returns true when daemon root endpoint confirms server is running', async () => {
|
|
const port = await createServer((_req, res) => {
|
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
res.end('Server running');
|
|
});
|
|
|
|
const running = await isDaemonRunning(port);
|
|
expect(running).toBe(true);
|
|
});
|
|
|
|
it('returns false when root endpoint returns 200 but unexpected body', async () => {
|
|
const port = await createServer((_req, res) => {
|
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
res.end('ok');
|
|
});
|
|
|
|
const running = await isDaemonRunning(port);
|
|
expect(running).toBe(false);
|
|
});
|
|
|
|
it('returns false when root endpoint returns 200 with empty body', async () => {
|
|
const port = await createServer((_req, res) => {
|
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
res.end('');
|
|
});
|
|
|
|
const running = await isDaemonRunning(port);
|
|
expect(running).toBe(false);
|
|
});
|
|
|
|
it('returns false when root endpoint is non-200', async () => {
|
|
const port = await createServer((_req, res) => {
|
|
res.writeHead(503, { 'Content-Type': 'text/plain' });
|
|
res.end('unavailable');
|
|
});
|
|
|
|
const running = await isDaemonRunning(port);
|
|
expect(running).toBe(false);
|
|
});
|
|
|
|
it('fails fast when startDaemon is called with invalid port', async () => {
|
|
const result = await startDaemon({
|
|
...DEFAULT_COPILOT_CONFIG,
|
|
port: 70000,
|
|
});
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('Invalid Copilot daemon port');
|
|
});
|
|
});
|
|
|
|
describe('copilot daemon stop safety', () => {
|
|
it('returns failure when stale PID points to unrelated process but daemon is still live', async () => {
|
|
const port = await createServer((_req, res) => {
|
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
res.end('Server running');
|
|
});
|
|
|
|
writeCopilotPortConfig(port);
|
|
|
|
const unrelatedProcess = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000);'], {
|
|
detached: true,
|
|
stdio: 'ignore',
|
|
});
|
|
unrelatedProcess.unref();
|
|
|
|
const unrelatedPid = unrelatedProcess.pid;
|
|
expect(unrelatedPid).toBeDefined();
|
|
if (!unrelatedPid) {
|
|
throw new Error('Failed to spawn unrelated process');
|
|
}
|
|
|
|
const pidFile = path.join(getCcsDir(), 'copilot', 'daemon.pid');
|
|
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
|
|
fs.writeFileSync(pidFile, String(unrelatedPid));
|
|
|
|
try {
|
|
const result = await stopDaemon();
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toContain('daemon is still responding on port');
|
|
|
|
// Unrelated process should still be alive.
|
|
expect(() => process.kill(unrelatedPid, 0)).not.toThrow();
|
|
} finally {
|
|
try {
|
|
process.kill(unrelatedPid, 'SIGTERM');
|
|
} catch {
|
|
// Process already exited.
|
|
}
|
|
}
|
|
});
|
|
|
|
it('does not terminate unrelated process from stale PID file', async () => {
|
|
writeCopilotPortConfig(65534);
|
|
|
|
const unrelatedProcess = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000);'], {
|
|
detached: true,
|
|
stdio: 'ignore',
|
|
});
|
|
unrelatedProcess.unref();
|
|
|
|
const unrelatedPid = unrelatedProcess.pid;
|
|
expect(unrelatedPid).toBeDefined();
|
|
if (!unrelatedPid) {
|
|
throw new Error('Failed to spawn unrelated process');
|
|
}
|
|
|
|
const pidFile = path.join(getCcsDir(), 'copilot', 'daemon.pid');
|
|
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
|
|
fs.writeFileSync(pidFile, String(unrelatedPid));
|
|
|
|
try {
|
|
const result = await stopDaemon();
|
|
expect(result.success).toBe(true);
|
|
|
|
// Unrelated process should still be alive.
|
|
expect(() => process.kill(unrelatedPid, 0)).not.toThrow();
|
|
} finally {
|
|
try {
|
|
process.kill(unrelatedPid, 'SIGTERM');
|
|
} catch {
|
|
// Process already exited.
|
|
}
|
|
}
|
|
});
|
|
});
|