|
|
|
@@ -0,0 +1,164 @@
|
|
|
|
|
import { execFile, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
|
|
|
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
|
|
|
import { createServer } from 'node:net';
|
|
|
|
|
import { tmpdir } from 'node:os';
|
|
|
|
|
import { join, resolve } from 'node:path';
|
|
|
|
|
import { promisify } from 'node:util';
|
|
|
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
|
|
|
import { GATEWAY_AUTH_HEADER } from './runtime-config.js';
|
|
|
|
|
|
|
|
|
|
const repositoryRoot = resolve(import.meta.dirname, '../../..');
|
|
|
|
|
const gatewayToken = 'synthetic-runtime-test-token-at-least-32-bytes';
|
|
|
|
|
const cleanup: Array<() => Promise<void>> = [];
|
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
|
|
|
|
|
|
interface RunningCommand {
|
|
|
|
|
readonly child: ChildProcessWithoutNullStreams;
|
|
|
|
|
output(): string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startPackageCommand(
|
|
|
|
|
script: 'canary' | 'start:production',
|
|
|
|
|
environment: NodeJS.ProcessEnv,
|
|
|
|
|
) {
|
|
|
|
|
const child = spawn('corepack', ['pnpm', '--filter', '@multi-simadmin/api', 'run', script], {
|
|
|
|
|
cwd: repositoryRoot,
|
|
|
|
|
env: { ...process.env, ...environment, FORCE_COLOR: '0' },
|
|
|
|
|
detached: true,
|
|
|
|
|
stdio: 'pipe',
|
|
|
|
|
});
|
|
|
|
|
let output = '';
|
|
|
|
|
child.stdout.on('data', (chunk: Buffer) => (output += chunk.toString()));
|
|
|
|
|
child.stderr.on('data', (chunk: Buffer) => (output += chunk.toString()));
|
|
|
|
|
const command: RunningCommand = { child, output: () => output };
|
|
|
|
|
cleanup.push(() => stopCommand(command));
|
|
|
|
|
return command;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function stopCommand(command: RunningCommand): Promise<void> {
|
|
|
|
|
if (command.child.exitCode !== null || command.child.signalCode !== null) return;
|
|
|
|
|
const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolveExit) =>
|
|
|
|
|
command.child.once('exit', (code, signal) => resolveExit({ code, signal })),
|
|
|
|
|
);
|
|
|
|
|
if (command.child.pid === undefined) throw new Error('Runtime command has no pid');
|
|
|
|
|
process.kill(-command.child.pid, 'SIGTERM');
|
|
|
|
|
const result = await Promise.race([
|
|
|
|
|
exit,
|
|
|
|
|
new Promise<never>((_, reject) =>
|
|
|
|
|
setTimeout(
|
|
|
|
|
() => reject(new Error(`Runtime did not stop after SIGTERM:\n${command.output()}`)),
|
|
|
|
|
5_000,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
// pnpm's wrapper reports 143 when its child handles the group SIGTERM cleanly.
|
|
|
|
|
expect(result.code === 0 || result.code === 143 || result.signal === 'SIGTERM').toBe(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForOutput(command: RunningCommand, text: string): Promise<void> {
|
|
|
|
|
const deadline = Date.now() + 10_000;
|
|
|
|
|
while (!command.output().includes(text)) {
|
|
|
|
|
if (command.child.exitCode !== null || command.child.signalCode !== null) {
|
|
|
|
|
throw new Error(`Runtime exited before becoming ready:\n${command.output()}`);
|
|
|
|
|
}
|
|
|
|
|
if (Date.now() >= deadline)
|
|
|
|
|
throw new Error(`Runtime readiness timed out:\n${command.output()}`);
|
|
|
|
|
await new Promise((resolveWait) => setTimeout(resolveWait, 25));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function listenOn(port: number): Promise<ReturnType<typeof createServer>> {
|
|
|
|
|
const server = createServer();
|
|
|
|
|
await new Promise<void>((resolveListen, reject) => {
|
|
|
|
|
server.once('error', reject);
|
|
|
|
|
server.listen(port, '127.0.0.1', resolveListen);
|
|
|
|
|
});
|
|
|
|
|
return server;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function closeServer(server: ReturnType<typeof createServer>): Promise<void> {
|
|
|
|
|
await new Promise<void>((resolveClose, reject) =>
|
|
|
|
|
server.close((error) => (error ? reject(error) : resolveClose())),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function expectPortFree(port: number): Promise<void> {
|
|
|
|
|
const server = await listenOn(port);
|
|
|
|
|
await closeServer(server);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function listenerPids(port: number): Promise<readonly string[]> {
|
|
|
|
|
try {
|
|
|
|
|
const { stdout } = await execFileAsync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']);
|
|
|
|
|
return stdout.trim().split(/\s+/u).filter(Boolean).sort();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const code = (error as { code?: unknown }).code;
|
|
|
|
|
if (code === 1 || code === '1') return [];
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await Promise.all(cleanup.splice(0).map((fn) => fn()));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe.sequential('executable package runtimes', () => {
|
|
|
|
|
it('starts the exact production command on 8790 and shuts down on SIGTERM', async () => {
|
|
|
|
|
await expectPortFree(8790);
|
|
|
|
|
const legacyListeners = await listenerPids(8788);
|
|
|
|
|
const directory = await mkdtemp(join(tmpdir(), 'multi-simadmin-production-cli-'));
|
|
|
|
|
cleanup.push(() => rm(directory, { recursive: true, force: true }));
|
|
|
|
|
const dataRoot = join(directory, 'data');
|
|
|
|
|
await mkdir(dataRoot);
|
|
|
|
|
|
|
|
|
|
const command = startPackageCommand('start:production', {
|
|
|
|
|
MULTI_SIMADMIN_DATA_ROOT: dataRoot,
|
|
|
|
|
MULTI_SIMADMIN_DATABASE_PATH: join(dataRoot, 'synthetic.sqlite'),
|
|
|
|
|
MULTI_SIMADMIN_GATEWAY_TOKEN: gatewayToken,
|
|
|
|
|
API_HOST: '127.0.0.1',
|
|
|
|
|
API_PORT: '8790',
|
|
|
|
|
});
|
|
|
|
|
await waitForOutput(command, 'API listening at http://127.0.0.1:8790');
|
|
|
|
|
|
|
|
|
|
const response = await fetch('http://127.0.0.1:8790/healthz', {
|
|
|
|
|
headers: { [GATEWAY_AUTH_HEADER]: gatewayToken },
|
|
|
|
|
});
|
|
|
|
|
expect(response.status).toBe(200);
|
|
|
|
|
expect(await response.json()).toEqual({ status: 'ok' });
|
|
|
|
|
expect(await listenerPids(8788)).toEqual(legacyListeners);
|
|
|
|
|
expect(command.output()).not.toContain(':8788');
|
|
|
|
|
expect(command.output()).not.toContain(gatewayToken);
|
|
|
|
|
|
|
|
|
|
await stopCommand(command);
|
|
|
|
|
await expectPortFree(8790);
|
|
|
|
|
expect(command.output()).not.toContain(gatewayToken);
|
|
|
|
|
}, 20_000);
|
|
|
|
|
|
|
|
|
|
it('starts the exact canary command on 8789 with synthetic dist and shuts down on SIGTERM', async () => {
|
|
|
|
|
const port = 8789;
|
|
|
|
|
await expectPortFree(port);
|
|
|
|
|
const legacyListeners = await listenerPids(8788);
|
|
|
|
|
const directory = await mkdtemp(join(tmpdir(), 'multi-simadmin-canary-cli-'));
|
|
|
|
|
cleanup.push(() => rm(directory, { recursive: true, force: true }));
|
|
|
|
|
await writeFile(join(directory, 'index.html'), '<main>synthetic canary</main>');
|
|
|
|
|
|
|
|
|
|
const command = startPackageCommand('canary', {
|
|
|
|
|
CANARY_DIST_DIR: directory,
|
|
|
|
|
CANARY_PORT: String(port),
|
|
|
|
|
CANARY_UPSTREAM_PORT: '8790',
|
|
|
|
|
MULTI_SIMADMIN_GATEWAY_TOKEN: gatewayToken,
|
|
|
|
|
});
|
|
|
|
|
await waitForOutput(command, `Canary gateway listening at http://127.0.0.1:${port}`);
|
|
|
|
|
|
|
|
|
|
const response = await fetch(`http://127.0.0.1:${port}/`);
|
|
|
|
|
expect(response.status).toBe(200);
|
|
|
|
|
expect(await response.text()).toBe('<main>synthetic canary</main>');
|
|
|
|
|
expect(await listenerPids(8788)).toEqual(legacyListeners);
|
|
|
|
|
expect(command.output()).not.toContain(':8788');
|
|
|
|
|
expect(command.output()).not.toContain(gatewayToken);
|
|
|
|
|
|
|
|
|
|
await stopCommand(command);
|
|
|
|
|
await expectPortFree(port);
|
|
|
|
|
expect(command.output()).not.toContain(gatewayToken);
|
|
|
|
|
}, 20_000);
|
|
|
|
|
});
|