Skip to content
File

Blob: tests/e2e/harness.ts

typescript269 lines
1import { spawn, execFile as execFileCallback, type ChildProcess } from "node:child_process";
2import { createWriteStream, type WriteStream } from "node:fs";
3import { readFile, rm, writeFile } from "node:fs/promises";
4import net from "node:net";
5import { join, resolve } from "node:path";
6import { setTimeout as sleep } from "node:timers/promises";
7import { fileURLToPath } from "node:url";
8import { promisify } from "node:util";
9 
10import { encodeBase64Url, sha256 } from "./helpers/token";
11 
12// Pattern is borrowed from sister projects (anvil, bland) โ€” spin up an
13// isolated `vite dev` per E2E run with TESSERA_PERSIST_STATE_PATH pointing
14// at a fresh tempdir, write `.dev.vars.{unique-env}` next to the user's
15// `.dev.vars`, and pass CLOUDFLARE_ENV={env} so wrangler's bridge loads
16// the e2e file *instead of* the user's. The user's working copy is never
17// touched, even if the run crashes โ€” the only side effect is the
18// per-run override file, which we delete on teardown.
19 
20const execFile = promisify(execFileCallback);
21 
22const REPO_ROOT = resolve(fileURLToPath(new URL("../..", import.meta.url)));
23const NPX_COMMAND = process.platform === "win32" ? "npx.cmd" : "npx";
24const VITE_BIN_PATH = resolve(REPO_ROOT, "node_modules/vite/bin/vite.js");
25const SERVER_READY_TIMEOUT_MS = 90_000;
26const POLL_INTERVAL_MS = 500;
27const DEV_VARS_PATH = resolve(REPO_ROOT, ".dev.vars");
28const DEV_VARS_EXAMPLE_PATH = resolve(REPO_ROOT, ".dev.vars.example");
29 
30export interface E2eContext {
31 tempDir: string;
32 baseUrl: string;
33 port: number;
34 cloudflareEnv: string;
35 devVarsPath: string;
36 serverProcess: ChildProcess;
37 stdoutLogPath: string;
38 stderrLogPath: string;
39 stdoutStream: WriteStream;
40 stderrStream: WriteStream;
41}
42 
43const closeWriteStream = async (stream: WriteStream): Promise<void> =>
44 new Promise<void>((resolveClose, reject) => {
45 if (stream.destroyed || stream.closed || stream.writableFinished) {
46 resolveClose();
47 return;
48 }
49 stream.end((error?: Error | null) => (error ? reject(error) : resolveClose()));
50 });
51 
52export const getFreePort = async (): Promise<number> =>
53 new Promise<number>((resolvePort, reject) => {
54 const server = net.createServer();
55 server.unref();
56 server.once("error", reject);
57 server.listen(0, "127.0.0.1", () => {
58 const address = server.address();
59 if (!address || typeof address === "string") {
60 server.close(() => reject(new Error("Failed to allocate a free localhost port.")));
61 return;
62 }
63 server.close((error?: Error | null) => (error ? reject(error) : resolvePort(address.port)));
64 });
65 });
66 
67const execCommand = async (command: string, args: string[], env: NodeJS.ProcessEnv) =>
68 await execFile(command, args, { cwd: REPO_ROOT, env, maxBuffer: 10 * 1024 * 1024 });
69 
70const fetchStatus = async (url: string): Promise<number> => {
71 const res = await fetch(url, { signal: AbortSignal.timeout(3_000) });
72 await res.arrayBuffer();
73 return res.status;
74};
75 
76const waitForServerReady = async (baseUrl: string): Promise<void> => {
77 const deadline = Date.now() + SERVER_READY_TIMEOUT_MS;
78 let lastError: unknown = null;
79 while (Date.now() < deadline) {
80 try {
81 // The asset shell answers 200 once vite finishes its first compile;
82 // /api/auth/jwks confirms the worker side is wired up too. Both must
83 // be green before we hand the URL to Playwright.
84 const root = await fetchStatus(baseUrl);
85 const jwks = await fetchStatus(`${baseUrl}/api/auth/jwks`);
86 if (root === 200 && jwks === 200) return;
87 } catch (e) {
88 lastError = e;
89 }
90 await sleep(POLL_INTERVAL_MS);
91 }
92 throw new Error(
93 `dev server readiness timed out: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
94 );
95};
96 
97const loadDevVarsTemplate = async (): Promise<string> => {
98 for (const path of [DEV_VARS_PATH, DEV_VARS_EXAMPLE_PATH]) {
99 try {
100 return await readFile(path, "utf8");
101 } catch (error) {
102 if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
103 }
104 }
105 throw new Error("Missing .dev.vars and .dev.vars.example โ€” cannot seed e2e env");
106};
107 
108const createE2eDevVarsFile = async (baseUrl: string): Promise<{ cloudflareEnv: string; devVarsPath: string }> => {
109 // Unique per process so parallel runs (e.g. `playwright test` invoked
110 // twice) don't stomp each other's e2e env files.
111 const cloudflareEnv = `e2e-${process.pid}-${Date.now()}`;
112 const devVarsPath = `${DEV_VARS_PATH}.${cloudflareEnv}`;
113 const template = await loadDevVarsTemplate();
114 // Strip URLs from the template; we re-emit them at the bottom pinned to
115 // the ephemeral port. Anything else (BETTER_AUTH_SECRET, TURNSTILE_*,
116 // BOOTSTRAP_ADMIN_EMAIL) carries through as-is.
117 const stripped = template
118 .replace(/^BETTER_AUTH_URL=.*$/gm, "")
119 .replace(/^OIDC_ISSUER=.*$/gm, "")
120 .trimEnd();
121 const content = `${stripped}\nBETTER_AUTH_URL=${baseUrl}\nOIDC_ISSUER=${baseUrl}\n`;
122 await writeFile(devVarsPath, content);
123 return { cloudflareEnv, devVarsPath };
124};
125 
126export const applyMigrations = async (persistTo: string): Promise<void> => {
127 await execCommand(
128 NPX_COMMAND,
129 ["wrangler", "d1", "migrations", "apply", "tessera-prod", "--local", "--persist-to", persistTo],
130 { ...process.env, CI: "1", NO_D1_WARNING: "true" },
131 );
132};
133 
134export interface SeededInvite {
135 email: string;
136 token: string;
137 password: string;
138}
139 
140const sqlString = (s: string) => `'${s.replace(/'/g, "''")}'`;
141
142export const seedInvite = async (persistTo: string, email: string): Promise<SeededInvite> => {
143 const tokenBytes = new Uint8Array(32);
144 crypto.getRandomValues(tokenBytes);
145 const token = encodeBase64Url(tokenBytes);
146 const tokenHash = await sha256(token);
147 const id = crypto.randomUUID();
148 const expiresAt = new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString();
149
150 await execCommand(
151 NPX_COMMAND,
152 [
153 "wrangler",
154 "d1",
155 "execute",
156 "tessera-prod",
157 "--local",
158 "--persist-to",
159 persistTo,
160 "--command",
161 `INSERT INTO invites (id, token_hash, email, created_by, expires_at) VALUES (${sqlString(id)}, ${sqlString(tokenHash)}, ${sqlString(email)}, 'e2e-setup', ${sqlString(expiresAt)})`,
162 ],
163 { ...process.env, CI: "1", NO_D1_WARNING: "true" },
164 );
165
166 return { email, token, password: "correct-horse-battery-staple" };
167};
168
169export const startDevServer = async (persistTo: string): Promise<E2eContext> => {
170 const port = await getFreePort();
171 const baseUrl = `http://127.0.0.1:${port}`;
172 const stdoutLogPath = join(persistTo, "dev.stdout.log");
173 const stderrLogPath = join(persistTo, "dev.stderr.log");
174 const stdoutStream = createWriteStream(stdoutLogPath, { flags: "a" });
175 const stderrStream = createWriteStream(stderrLogPath, { flags: "a" });
176
177 const { cloudflareEnv, devVarsPath } = await createE2eDevVarsFile(baseUrl);
178
179 const serverProcess = spawn(process.execPath, [VITE_BIN_PATH, "dev", "--host", "127.0.0.1", "--port", String(port)], {
180 cwd: REPO_ROOT,
181 env: {
182 ...process.env,
183 TESSERA_PERSIST_STATE_PATH: persistTo,
184 // wrangler/vite-plugin-cloudflare sees this and loads
185 // `.dev.vars.{cloudflareEnv}` instead of `.dev.vars`.
186 CLOUDFLARE_ENV: cloudflareEnv,
187 },
188 stdio: ["ignore", "pipe", "pipe"],
189 detached: process.platform !== "win32",
190 });
191
192 serverProcess.stdout?.pipe(stdoutStream);
193 serverProcess.stderr?.pipe(stderrStream);
194
195 try {
196 await Promise.race([
197 waitForServerReady(baseUrl),
198 new Promise<never>((_, reject) => {
199 serverProcess.once("exit", (code, signal) => {
200 reject(new Error(`dev server exited before readiness (code=${code}, signal=${signal})`));
201 });
202 }),
203 ]);
204 } catch (error) {
205 await stopDevServer(serverProcess);
206 await closeWriteStream(stdoutStream);
207 await closeWriteStream(stderrStream);
208 await rm(devVarsPath, { force: true });
209 throw error;
210 }
211
212 return {
213 tempDir: persistTo,
214 baseUrl,
215 port,
216 cloudflareEnv,
217 devVarsPath,
218 serverProcess,
219 stdoutLogPath,
220 stderrLogPath,
221 stdoutStream,
222 stderrStream,
223 };
224};
225
226export const stopDevServer = async (serverProcess: ChildProcess): Promise<void> => {
227 if (serverProcess.exitCode !== null || serverProcess.signalCode !== null) return;
228
229 const killServer = (signal: NodeJS.Signals): void => {
230 if (serverProcess.pid === undefined) return;
231 if (process.platform === "win32") {
232 serverProcess.kill(signal);
233 return;
234 }
235 process.kill(-serverProcess.pid, signal);
236 };
237
238 killServer("SIGTERM");
239 const exited = await Promise.race([
240 new Promise<boolean>((resolveExit) => serverProcess.once("exit", () => resolveExit(true))),
241 sleep(10_000).then(() => false),
242 ]);
243 if (!exited && serverProcess.exitCode === null && serverProcess.signalCode === null) {
244 killServer("SIGKILL");
245 await new Promise<void>((resolveExit) => serverProcess.once("exit", () => resolveExit()));
246 }
247};
248
249export const closeContextLogs = async (context: E2eContext): Promise<void> => {
250 context.serverProcess.stdout?.unpipe(context.stdoutStream);
251 context.serverProcess.stderr?.unpipe(context.stderrStream);
252 await closeWriteStream(context.stdoutStream);
253 await closeWriteStream(context.stderrStream);
254};
255
256export const removeE2eDevVars = async (context: E2eContext): Promise<void> => {
257 await rm(context.devVarsPath, { force: true });
258};
259
260export const printFailureContext = (context: E2eContext): void => {
261 console.error(`Preserved temp state: ${context.tempDir}`);
262 console.error(`Dev server stdout: ${context.stdoutLogPath}`);
263 console.error(`Dev server stderr: ${context.stderrLogPath}`);
264 console.error(`Cloudflare env vars: ${context.devVarsPath}`);
265 console.error(
266 `Reopen preserved state: CLOUDFLARE_ENV=${context.cloudflareEnv} TESSERA_PERSIST_STATE_PATH=${context.tempDir} npm run dev -- --host 127.0.0.1 --port ${context.port}`,
267 );
268};
269