Skip to content
File

Blob: tests/e2e/global-setup.ts

typescript156 lines
1import { spawn, type ChildProcess } from "node:child_process";
2import { execFile } from "node:child_process";
3import { mkdtemp, rm, writeFile } from "node:fs/promises";
4import { createServer as createNetServer } from "node:net";
5import { tmpdir } from "node:os";
6import { fileURLToPath } from "node:url";
7import path from "node:path";
8import { promisify } from "node:util";
9 
10const execFileAsync = promisify(execFile);
11 
12const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
13const VITE_BIN = path.resolve(REPO_ROOT, "node_modules/vite/bin/vite.js");
14const OIDC_MOCK_PROVIDER_BIN = path.resolve(
15 REPO_ROOT,
16 "node_modules/@mongodb-js/oidc-mock-provider/bin/oidc-mock-provider.js",
17);
18const STATE_PATH_ENV = "FLAMEMAIL_E2E_PERSIST_PATH";
19const E2E_OPERATOR_SUB = "00000000-0000-4000-8000-000000000001";
20 
21interface E2eContext {
22 tempDir: string;
23 cloudflareEnv: string;
24 devVarsPath: string;
25 vite: ChildProcess;
26 fakeProvider: ChildProcess;
27}
28 
29let context: E2eContext | null = null;
30 
31async function getFreePort(): Promise<number> {
32 return new Promise((resolve, reject) => {
33 const srv = createNetServer();
34 srv.unref();
35 srv.on("error", reject);
36 srv.listen(0, "127.0.0.1", () => {
37 const addr = srv.address();
38 const port = typeof addr === "object" && addr ? addr.port : 0;
39 srv.close(() => resolve(port));
40 });
41 });
42}
43 
44async function waitForUrl(url: string, label: string, timeoutMs = 30_000): Promise<void> {
45 const deadline = Date.now() + timeoutMs;
46 while (Date.now() < deadline) {
47 try {
48 const response = await fetch(url, { redirect: "manual" });
49 if (response.status > 0) return;
50 } catch {
51 // not yet ready
52 }
53 await new Promise((resolve) => setTimeout(resolve, 250));
54 }
55 throw new Error(`${label} never became reachable at ${url}`);
56}
57 
58async function killProcess(child: ChildProcess): Promise<void> {
59 if (child.exitCode !== null || child.signalCode !== null) return;
60 child.kill("SIGTERM");
61 await new Promise<void>((resolve) => {
62 const onExit = () => resolve();
63 child.once("exit", onExit);
64 setTimeout(() => {
65 if (child.exitCode === null) child.kill("SIGKILL");
66 resolve();
67 }, 5_000);
68 });
69}
70 
71export default async function globalSetup(): Promise<() => Promise<void>> {
72 const tempDir = await mkdtemp(path.join(tmpdir(), "flamemail-e2e-"));
73 const cloudflareEnv = `e2e-${process.pid}-${Date.now()}`;
74 const devVarsPath = path.join(REPO_ROOT, `.dev.vars.${cloudflareEnv}`);
75 const fakePort = await getFreePort();
76 const vitePort = await getFreePort();
77 const fakeIssuer = `http://127.0.0.1:${fakePort}`;
78 const baseURL = `http://127.0.0.1:${vitePort}`;
79 
80 // Per-run .dev.vars file consumed by the cloudflare-vite plugin when
81 // CLOUDFLARE_ENV is set on the spawned process. Keeps the user's
82 // committed .dev.vars/.dev.vars.example untouched.
83 const devVarsContent = [
84 `TESSERA_OIDC_ISSUER=${fakeIssuer}`,
85 "TESSERA_OIDC_CLIENT_ID=local-flamemail",
86 "TESSERA_OIDC_CLIENT_SECRET=local-flamemail-secret-change-me",
87 `TESSERA_OPERATOR_SUBS=${E2E_OPERATOR_SUB}`,
88 "TURNSTILE_SITE_KEY=1x00000000000000000000AA",
89 "TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA",
90 "",
91 ].join("\n");
92 await writeFile(devVarsPath, devVarsContent);
93 
94 // Apply migrations into the temp persist-to path so the dev server
95 // boots against an empty, fully-migrated D1 each run.
96 await execFileAsync(
97 "npx",
98 ["wrangler", "d1", "migrations", "apply", "flamemail-db", "--local", "--persist-to", tempDir],
99 {
100 cwd: REPO_ROOT,
101 env: { ...process.env, CI: "1", NO_D1_WARNING: "true" },
102 },
103 );
104 
105 const fakeProvider = spawn(
106 process.execPath,
107 [
108 OIDC_MOCK_PROVIDER_BIN,
109 "--host",
110 "127.0.0.1",
111 "--port",
112 String(fakePort),
113 "--payload",
114 JSON.stringify({ sub: E2E_OPERATOR_SUB }),
115 "--expiry",
116 "300",
117 "--skip-refresh-token",
118 ],
119 {
120 cwd: REPO_ROOT,
121 env: process.env,
122 stdio: ["ignore", "inherit", "inherit"],
123 },
124 );
125 
126 await waitForUrl(`${fakeIssuer}/.well-known/openid-configuration`, "fake oidc provider");
127 
128 const vite = spawn(process.execPath, [VITE_BIN, "--host", "127.0.0.1", "--port", String(vitePort), "--strictPort"], {
129 cwd: REPO_ROOT,
130 env: {
131 ...process.env,
132 CLOUDFLARE_ENV: cloudflareEnv,
133 [STATE_PATH_ENV]: tempDir,
134 },
135 stdio: ["ignore", "inherit", "inherit"],
136 });
137 
138 await waitForUrl(baseURL, "flamemail dev server", 60_000);
139 
140 context = { tempDir, cloudflareEnv, devVarsPath, vite, fakeProvider };
141 process.env.PLAYWRIGHT_BASE_URL = baseURL;
142 process.env.FAKE_OIDC_ISSUER = fakeIssuer;
143 
144 return async () => {
145 await teardown(context);
146 context = null;
147 };
148}
149 
150async function teardown(ctx: E2eContext | null): Promise<void> {
151 if (!ctx) return;
152 await Promise.allSettled([killProcess(ctx.vite), killProcess(ctx.fakeProvider)]);
153 await rm(ctx.devVarsPath, { force: true });
154 await rm(ctx.tempDir, { recursive: true, force: true });
155}