atproto user agency toolkit for individuals and groups
1/**
2 * Sidecar process integration test.
3 *
4 * Spawns `src/server.ts` as a child process (the same way Tauri does),
5 * validates the full sidecar contract:
6 * 1. Parse P2PDS_READY from stdout to get the port
7 * 2. GET /xrpc/_health returns 200 {"status":"ok"}
8 * 3. GET / returns 200 HTML
9 * 4. SIGTERM → process exits with code 0
10 */
11
12import { describe, it, expect, afterEach } from "vitest";
13import { spawn, type ChildProcess } from "node:child_process";
14import { mkdtempSync, rmSync } from "node:fs";
15import { tmpdir } from "node:os";
16import { join } from "node:path";
17
18const STARTUP_TIMEOUT_MS = 30_000;
19const POLL_INTERVAL_MS = 200;
20
21interface ReadyInfo {
22 port: number;
23 url: string;
24}
25
26function parseReadyLine(line: string): ReadyInfo | null {
27 const prefix = "P2PDS_READY ";
28 if (!line.startsWith(prefix)) return null;
29 try {
30 return JSON.parse(line.slice(prefix.length)) as ReadyInfo;
31 } catch {
32 return null;
33 }
34}
35
36function waitForReady(proc: ChildProcess, timeoutMs: number): Promise<ReadyInfo> {
37 return new Promise((resolve, reject) => {
38 const timer = setTimeout(() => {
39 reject(new Error(`P2PDS_READY not seen within ${timeoutMs}ms`));
40 }, timeoutMs);
41
42 let buffer = "";
43 proc.stdout?.on("data", (chunk: Buffer) => {
44 buffer += chunk.toString();
45 const lines = buffer.split("\n");
46 buffer = lines.pop()!; // keep incomplete trailing line
47 for (const line of lines) {
48 const info = parseReadyLine(line);
49 if (info) {
50 clearTimeout(timer);
51 resolve(info);
52 return;
53 }
54 }
55 });
56
57 proc.on("error", (err) => {
58 clearTimeout(timer);
59 reject(err);
60 });
61
62 proc.on("exit", (code) => {
63 clearTimeout(timer);
64 reject(new Error(`Process exited with code ${code} before P2PDS_READY`));
65 });
66 });
67}
68
69async function pollHealth(url: string, timeoutMs: number): Promise<void> {
70 const deadline = Date.now() + timeoutMs;
71 while (Date.now() < deadline) {
72 try {
73 const res = await fetch(`${url}/xrpc/_health`);
74 if (res.ok) return;
75 } catch {
76 // not ready yet
77 }
78 await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
79 }
80 throw new Error(`Health check did not pass within ${timeoutMs}ms`);
81}
82
83describe("sidecar process", () => {
84 let proc: ChildProcess | undefined;
85 let tmpDir: string | undefined;
86
87 afterEach(async () => {
88 if (proc && !proc.killed) {
89 proc.kill("SIGTERM");
90 // Wait for exit (up to 5s)
91 await new Promise<void>((resolve) => {
92 const timer = setTimeout(() => {
93 proc?.kill("SIGKILL");
94 resolve();
95 }, 5_000);
96 proc!.on("exit", () => {
97 clearTimeout(timer);
98 resolve();
99 });
100 });
101 }
102 proc = undefined;
103 if (tmpDir) {
104 rmSync(tmpDir, { recursive: true, force: true });
105 tmpDir = undefined;
106 }
107 });
108
109 it("full sidecar lifecycle: ready → health → app → shutdown", async () => {
110 tmpDir = mkdtempSync(join(tmpdir(), "sidecar-test-"));
111
112 proc = spawn("npx", ["tsx", "src/server.ts"], {
113 env: {
114 ...process.env,
115 PORT: "0",
116 DATA_DIR: tmpDir,
117 IPFS_ENABLED: "true",
118 IPFS_NETWORKING: "false",
119 FIREHOSE_ENABLED: "false",
120 RATE_LIMIT_ENABLED: "false",
121 OAUTH_ENABLED: "false",
122 PDS_HOSTNAME: "localhost",
123 AUTH_TOKEN: "test-token",
124 SIGNING_KEY: "0000000000000000000000000000000000000000000000000000000000000001",
125 SIGNING_KEY_PUBLIC: "zQ3shP2mWsZYWgvZM9GJ3EvMfRXQJwuTh6BdXLvJB9gFhT3Lr",
126 JWT_SECRET: "test-secret",
127 PASSWORD_HASH: "$2a$10$test",
128 DID: "did:plc:sidecartest",
129 },
130 stdio: ["ignore", "pipe", "pipe"],
131 });
132
133 // Collect stderr for debugging
134 let stderr = "";
135 proc.stderr?.on("data", (chunk: Buffer) => {
136 stderr += chunk.toString();
137 });
138
139 // 1. Parse P2PDS_READY from stdout
140 const ready = await waitForReady(proc, STARTUP_TIMEOUT_MS);
141 expect(ready.port).toBeGreaterThan(0);
142 expect(ready.url).toMatch(/^http:\/\/localhost:\d+$/);
143
144 // 2. Poll health endpoint
145 await pollHealth(ready.url, 5_000);
146 const healthRes = await fetch(`${ready.url}/xrpc/_health`);
147 expect(healthRes.status).toBe(200);
148 const healthBody = (await healthRes.json()) as { status: string };
149 expect(healthBody.status).toBe("ok");
150
151 // 3. App returns HTML
152 const dashRes = await fetch(`${ready.url}/`);
153 expect(dashRes.status).toBe(200);
154 const html = await dashRes.text();
155 expect(html).toContain("P2PDS");
156
157 // 4. Graceful shutdown via SIGTERM
158 const exitPromise = new Promise<number | null>((resolve) => {
159 proc!.on("exit", (code) => resolve(code));
160 });
161 proc.kill("SIGTERM");
162 const exitCode = await exitPromise;
163 expect(exitCode).toBe(0);
164 proc = undefined; // prevent double-kill in afterEach
165 }, STARTUP_TIMEOUT_MS + 10_000);
166});