/** * HTTP serve startup — proves createServer() boots under Express 5. * * Spawns the built CLI (`gitnexus serve`) and probes GET /api/health. * Catches regressions like invalid route patterns that throw at registration * time before LadybugDB or MCP initialize. */ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import fs from 'node:fs'; import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, '..', '..'); const DIST_CLI = path.join(REPO_ROOT, 'dist', 'cli', 'index.js'); const STARTUP_BUDGET_MS = process.env.CI ? 30_000 : 15_000; const allocateFreePort = (): Promise => new Promise((resolve, reject) => { const probe = http.createServer(); probe.once('error', reject); probe.listen(0, '127.0.0.1', () => { const addr = probe.address(); if (typeof addr !== 'object' || !addr) { probe.close(); reject(new Error('could not allocate ephemeral port')); return; } const port = addr.port; probe.close((err) => (err ? reject(err) : resolve(port))); }); }); const probeHealth = (port: number): Promise<{ status: number; body: string }> => new Promise((resolve, reject) => { const req = http.get(`http://127.0.0.1:${port}/api/health`, (res) => { const chunks: Buffer[] = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8'), }); }); }); req.on('error', reject); req.setTimeout(5_000, () => { req.destroy(); reject(new Error('health probe timed out')); }); }); // Child-process serve + health probe is reliable on Linux CI (where e2e failed). // On Windows, spawned `serve` can print "running" before the listen socket is // reachable from the parent; unit tests in server-cors-stack.test.ts cover the // Express 5 registration path on all platforms. const describeServeStartup = process.platform === 'win32' ? describe.skip : describe; describeServeStartup('gitnexus serve HTTP startup (Express 5)', () => { let proc: ChildProcessWithoutNullStreams | undefined; let homeDir: string | undefined; afterEach(async () => { if (proc && !proc.killed) { proc.kill('SIGTERM'); await new Promise((resolve) => { const timer = setTimeout(() => { proc?.kill('SIGKILL'); resolve(); }, 3_000); proc?.on('exit', () => { clearTimeout(timer); resolve(); }); }); } proc = undefined; if (homeDir) { fs.rmSync(homeDir, { recursive: true, force: true }); homeDir = undefined; } }); /** * Spawn the built CLI's `serve` on an ephemeral port with a throwaway * GITNEXUS_HOME, registering both for afterEach cleanup. The streams are * captured separately so a failure message can attribute output to the right * one, and returned as getters because they fill in after this resolves. */ const spawnServe = async ( host: string, extraEnv: Record = {}, ): Promise<{ child: ChildProcessWithoutNullStreams; port: number; stdout: () => string; stderr: () => string; }> => { if (!fs.existsSync(DIST_CLI)) { throw new Error(`Missing ${DIST_CLI} — run npm run build before integration tests`); } const port = await allocateFreePort(); homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-serve-home-')); const child = spawn( process.execPath, [DIST_CLI, 'serve', '--port', String(port), '--host', host], { cwd: REPO_ROOT, env: { ...process.env, GITNEXUS_HOME: homeDir, NODE_OPTIONS: '', ...extraEnv }, stdio: ['ignore', 'pipe', 'pipe'], }, ); proc = child; let stdout = ''; let stderr = ''; child.stdout.on('data', (buf) => { stdout += buf.toString(); }); child.stderr.on('data', (buf) => { stderr += buf.toString(); }); return { child, port, stdout: () => stdout, stderr: () => stderr }; }; it('serve boots and GET /api/health returns ok', async () => { const { child, port, stdout, stderr } = await spawnServe('127.0.0.1'); const startedAt = Date.now(); let status = 0; let body = ''; while (Date.now() - startedAt < STARTUP_BUDGET_MS) { if (child.exitCode !== null) { throw new Error( `serve exited ${child.exitCode} before ready.\nstdout:\n${stdout()}\nstderr:\n${stderr()}`, ); } try { ({ status, body } = await probeHealth(port)); if (status === 200) { break; } } catch { // Server still starting — retry until budget expires. } await new Promise((r) => setTimeout(r, 100)); } expect(status).toBe(200); expect(body).toContain('"status":"ok"'); }, 60_000); // GITNEXUS_PUBLIC_ORIGIN is what makes a public bind usable, and `serve` has // no authentication — so it must not boot at all. Asserted end-to-end rather // than at the unit level, because what matters is the exit code the operator // (or a platform health check) sees, not that a function threw. it('serve refuses to start when GITNEXUS_PUBLIC_ORIGIN is set', async () => { const { child, port, stdout, stderr } = await spawnServe('0.0.0.0', { GITNEXUS_PUBLIC_ORIGIN: 'https://gitnexus.example.com', }); const exitCode = await new Promise((resolve, reject) => { const timer = setTimeout( () => reject( new Error( `serve did not exit within budget.\nstdout:\n${stdout()}\nstderr:\n${stderr()}`, ), ), STARTUP_BUDGET_MS, ); child.on('exit', (code) => { clearTimeout(timer); resolve(code); }); child.on('error', reject); }); expect(exitCode).toBe(1); // The CLI prints the failure through cliError, so it lands on stderr; assert // against both streams rather than pinning which one. const output = stdout() + stderr(); expect(output).toContain('GITNEXUS_PUBLIC_ORIGIN'); expect(output).toContain('no authentication'); // And it never got as far as listening. await expect(probeHealth(port)).rejects.toThrow(); }, 60_000); });