/** * Sidecar process integration test. * * Spawns `src/server.ts` as a child process (the same way Tauri does), * validates the full sidecar contract: * 1. Parse P2PDS_READY from stdout to get the port * 2. GET /xrpc/_health returns 200 {"status":"ok"} * 3. GET / returns 200 HTML * 4. SIGTERM → process exits with code 0 */ import { describe, it, expect, afterEach } from "vitest"; import { spawn, type ChildProcess } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; const STARTUP_TIMEOUT_MS = 30_000; const POLL_INTERVAL_MS = 200; interface ReadyInfo { port: number; url: string; } function parseReadyLine(line: string): ReadyInfo | null { const prefix = "P2PDS_READY "; if (!line.startsWith(prefix)) return null; try { return JSON.parse(line.slice(prefix.length)) as ReadyInfo; } catch { return null; } } function waitForReady(proc: ChildProcess, timeoutMs: number): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(`P2PDS_READY not seen within ${timeoutMs}ms`)); }, timeoutMs); let buffer = ""; proc.stdout?.on("data", (chunk: Buffer) => { buffer += chunk.toString(); const lines = buffer.split("\n"); buffer = lines.pop()!; // keep incomplete trailing line for (const line of lines) { const info = parseReadyLine(line); if (info) { clearTimeout(timer); resolve(info); return; } } }); proc.on("error", (err) => { clearTimeout(timer); reject(err); }); proc.on("exit", (code) => { clearTimeout(timer); reject(new Error(`Process exited with code ${code} before P2PDS_READY`)); }); }); } async function pollHealth(url: string, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { const res = await fetch(`${url}/xrpc/_health`); if (res.ok) return; } catch { // not ready yet } await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); } throw new Error(`Health check did not pass within ${timeoutMs}ms`); } describe("sidecar process", () => { let proc: ChildProcess | undefined; let tmpDir: string | undefined; afterEach(async () => { if (proc && !proc.killed) { proc.kill("SIGTERM"); // Wait for exit (up to 5s) await new Promise((resolve) => { const timer = setTimeout(() => { proc?.kill("SIGKILL"); resolve(); }, 5_000); proc!.on("exit", () => { clearTimeout(timer); resolve(); }); }); } proc = undefined; if (tmpDir) { rmSync(tmpDir, { recursive: true, force: true }); tmpDir = undefined; } }); it("full sidecar lifecycle: ready → health → app → shutdown", async () => { tmpDir = mkdtempSync(join(tmpdir(), "sidecar-test-")); proc = spawn("npx", ["tsx", "src/server.ts"], { env: { ...process.env, PORT: "0", DATA_DIR: tmpDir, IPFS_ENABLED: "true", IPFS_NETWORKING: "false", FIREHOSE_ENABLED: "false", RATE_LIMIT_ENABLED: "false", OAUTH_ENABLED: "false", PDS_HOSTNAME: "localhost", AUTH_TOKEN: "test-token", SIGNING_KEY: "0000000000000000000000000000000000000000000000000000000000000001", SIGNING_KEY_PUBLIC: "zQ3shP2mWsZYWgvZM9GJ3EvMfRXQJwuTh6BdXLvJB9gFhT3Lr", JWT_SECRET: "test-secret", PASSWORD_HASH: "$2a$10$test", DID: "did:plc:sidecartest", }, stdio: ["ignore", "pipe", "pipe"], }); // Collect stderr for debugging let stderr = ""; proc.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString(); }); // 1. Parse P2PDS_READY from stdout const ready = await waitForReady(proc, STARTUP_TIMEOUT_MS); expect(ready.port).toBeGreaterThan(0); expect(ready.url).toMatch(/^http:\/\/localhost:\d+$/); // 2. Poll health endpoint await pollHealth(ready.url, 5_000); const healthRes = await fetch(`${ready.url}/xrpc/_health`); expect(healthRes.status).toBe(200); const healthBody = (await healthRes.json()) as { status: string }; expect(healthBody.status).toBe("ok"); // 3. App returns HTML const dashRes = await fetch(`${ready.url}/`); expect(dashRes.status).toBe(200); const html = await dashRes.text(); expect(html).toContain("P2PDS"); // 4. Graceful shutdown via SIGTERM const exitPromise = new Promise((resolve) => { proc!.on("exit", (code) => resolve(code)); }); proc.kill("SIGTERM"); const exitCode = await exitPromise; expect(exitCode).toBe(0); proc = undefined; // prevent double-kill in afterEach }, STARTUP_TIMEOUT_MS + 10_000); });