1
30import { request as pwRequest } from "@playwright/test";
31import * as fs from "node:fs";
32import * as path from "node:path";
33
34const API_BASE = process.env.E2E_API_URL ?? "http://localhost:4000";
35const AUTH_DIR = path.join(__dirname, ".auth");
36const AUTH_FILE = path.join(AUTH_DIR, "user.json");
37
38
39
40const DEMO_EMAIL = process.env.E2E_DEMO_EMAIL ?? "demo@factory.local";
41const DEMO_PASSWORD = process.env.E2E_DEMO_PASSWORD ?? "Factory!2345";
42
43interface StorageState {
44
45 accessToken: string;
46 cookies: {
47 name: string;
48 value: string;
49 domain: string;
50 path: string;
51 expires: number;
52 httpOnly: boolean;
53 secure: boolean;
54 sameSite: "Strict" | "Lax" | "None";
55 }[];
56 origins: {
57 origin: string;
58 localStorage: { name: string; value: string }[];
59 }[];
60}
61
62interface SetCookieParsed {
63 name: string;
64 value: string;
65 path: string;
66 httpOnly: boolean;
67 secure: boolean;
68 sameSite: "Strict" | "Lax" | "None";
69}
70
71
78function parseSetCookie(header: string): SetCookieParsed[] {
79
80
81
82
83 const parts = header.split(/,(?=[^;]*=)/);
84 return parts.map((raw) => {
85 const firstSemi = raw.indexOf(";");
86 const pair = firstSemi === -1 ? raw : raw.slice(0, firstSemi);
87 const eq = pair.indexOf("=");
88 const name = eq === -1 ? pair.trim() : pair.slice(0, eq).trim();
89 const value = eq === -1 ? "" : pair.slice(eq + 1).trim();
90 const attrs = firstSemi === -1 ? "" : raw.slice(firstSemi + 1);
91 const lower = attrs.toLowerCase();
92 return {
93 name,
94 value,
95 path: /path=([^;]+)/i.exec(attrs)?.[1] ?? "/",
96 httpOnly: /httponly/i.test(lower),
97 secure: /secure/i.test(lower),
98 sameSite: (/samesite=strict/i.test(lower)
99 ? "Strict"
100 : /samesite=none/i.test(lower)
101 ? "None"
102 : "Lax") as "Strict" | "Lax" | "None",
103 };
104 });
105}
106
107async function apiReachable(): Promise<boolean> {
108 try {
109 const req = await pwRequest.newContext({
110 baseURL: API_BASE,
111 timeout: 5_000,
112 });
113
114 const res = await req.get("/api/v1/health", { timeout: 5_000 }).catch(() => null);
115 await req.dispose();
116 return res !== null;
117 } catch {
118 return false;
119 }
120}
121
122
129async function loginAndCapture(
130 email: string,
131 password: string,
132 registerFirst = false,
133): Promise<StorageState | null> {
134 const req = await pwRequest.newContext({ baseURL: API_BASE });
135 try {
136 if (registerFirst) {
137 const reg = await req.post("/api/v1/auth/register", {
138 data: { email, password, name: "E2E Demo" },
139 });
140 if (![200, 201].includes(reg.status())) {
141 console.error(`[global-setup] register failed: ${reg.status()} ${await reg.text()}`);
142 return null;
143 }
144 }
145
146 const login = await req.post("/api/v1/auth/login", {
147 data: { email, password },
148 });
149 if (login.status() !== 200) {
150 console.error(`[global-setup] login failed: ${login.status()} ${await login.text()}`);
151 return null;
152 }
153
154
155
156
157
158
159
160
161
162
163 const setCookie: string | undefined = login.headers()["set-cookie"];
164 const cookies: StorageState["cookies"] = (setCookie
165 ? parseSetCookie(setCookie)
166 : []
167 ).map((c) => ({
168 name: c.name,
169 value: c.value,
170 domain: "",
171 path: c.path,
172 expires: -1,
173 httpOnly: c.httpOnly,
174 secure: c.secure,
175 sameSite: c.sameSite,
176 }));
177
178
179
180 let accessToken = "";
181 try {
182 const body = (await login.json()) as { accessToken?: string };
183 accessToken = body.accessToken ?? "";
184 } catch {
185
186 }
187
188 return { accessToken, cookies, origins: [] };
189 } finally {
190 await req.dispose();
191 }
192}
193
194
201async function stateIsAlive(state: StorageState): Promise<boolean> {
202 const token = state.accessToken;
203 if (!token) return false;
204 const req = await pwRequest.newContext({
205 baseURL: API_BASE,
206 timeout: 5_000,
207 extraHTTPHeaders: { Authorization: `Bearer ${token}` },
208 });
209 try {
210 const res = await req.get("/api/v1/auth/me");
211 return res.status() === 200;
212 } catch {
213 return false;
214 } finally {
215 await req.dispose();
216 }
217}
218
219export default async function globalSetup(): Promise<void> {
220 const apiUp = await apiReachable();
221 if (!apiUp) {
222 throw new Error(
223 `[global-setup] API is not reachable at ${API_BASE}.` +
224 " Start the stack first (pnpm docker:up && pnpm dev), or set E2E_API_URL.",
225 );
226 }
227
228
229 if (fs.existsSync(AUTH_FILE)) {
230 try {
231 const cached: StorageState = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8"));
232 if (await stateIsAlive(cached)) {
233 console.log("[global-setup] reusing existing auth state (.auth/user.json)");
234 return;
235 }
236 console.log("[global-setup] cached auth state expired — re-authenticating");
237 } catch {
238 console.log("[global-setup] cached auth state unreadable — re-authenticating");
239 }
240 }
241
242
243 let state = await loginAndCapture(DEMO_EMAIL, DEMO_PASSWORD);
244
245
246
247 if (!state) {
248 const email = `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@factory.local`;
249 console.log(`[global-setup] demo login failed — registering ${email}`);
250 state = await loginAndCapture(email, DEMO_PASSWORD, true);
251 }
252
253 if (!state) {
254 throw new Error(
255 "[global-setup] could not authenticate (demo login and fresh register both failed).",
256 );
257 }
258
259 fs.mkdirSync(AUTH_DIR, { recursive: true });
260 fs.writeFileSync(AUTH_FILE, JSON.stringify(state, null, 2));
261 console.log(`[global-setup] auth state written to ${AUTH_FILE}`);
262}
263