1
35import {
36 test as base,
37 expect,
38 request as pwRequest,
39 type APIRequestContext,
40 type Page
41} from "@playwright/test";
42import * as fs from "node:fs";
43import * as path from "node:path";
44
45
46
47
48
49export const API_BASE = process.env.E2E_API_URL ?? "http://localhost:4000";
50const WEB_BASE = process.env.E2E_BASE_URL ?? "http://localhost:3000";
51const AUTH_FILE = path.join(__dirname, ".auth", "user.json");
52
53
54export const DEMO_EMAIL = process.env.E2E_DEMO_EMAIL ?? "demo@factory.local";
55export const DEMO_PASSWORD = process.env.E2E_DEMO_PASSWORD ?? "Factory!2345";
56
57
58
59
60
61export interface AuthState {
62
67 accessToken?: string;
68 cookies: {
69 name: string;
70 value: string;
71 domain: string;
72 path: string;
73 expires: number;
74 httpOnly: boolean;
75 secure: boolean;
76 sameSite: "Strict" | "Lax" | "None";
77 }[];
78 origins: { origin: string; localStorage: { name: string; value: string }[] }[];
79}
80
81export interface Workspace {
82 id: string;
83 name: string;
84 slug: string;
85}
86
87export interface PageDto {
88 id: string;
89 workspaceId: string;
90 parentId: string | null;
91 title: string;
92 position: string;
93}
94
95export interface BlockDto {
96 id: string;
97 pageId: string;
98 type: string;
99 position: string;
100 content: unknown;
101 properties: unknown;
102}
103
104export interface RecordDto {
105 id: string;
106 workspaceId: string;
107 databaseId: string;
108 pageId: string | null;
109 position: string;
110 properties: { propertyId: string; value: unknown }[];
111}
112
113export interface CommentDto {
114 id: string;
115 workspaceId: string;
116 pageId: string | null;
117 blockId: string | null;
118 body: string;
119 status: string;
120 createdBy: string;
121}
122
123
124
125
126
127type FactoryFixtures = {
128
129 user: { email: string; password: string };
130
131 api: APIRequestContext;
132
133 authPage: Page;
134
138 secondUser: () => Promise<{
139 user: { email: string; password: string };
140 page: Page;
141 api: APIRequestContext;
142 }>;
143
147 freshWorkspace: (name?: string) => Promise<Workspace>;
148};
149
150export function loadAuthState(): AuthState {
151 if (!fs.existsSync(AUTH_FILE)) {
152 throw new Error(
153 `Missing auth state at ${AUTH_FILE} — did global-setup run? ` +
154 "(Check the API is up: pnpm docker:up && pnpm dev)",
155 );
156 }
157 return JSON.parse(fs.readFileSync(AUTH_FILE, "utf8")) as AuthState;
158}
159
160
161
162
163
164
165export async function bearerApi(token: string): Promise<APIRequestContext> {
166
167
168
169
170
171 return pwRequest.newContext({
172 baseURL: API_BASE,
173 timeout: 30_000,
174 extraHTTPHeaders: { Authorization: `Bearer ${token}` },
175 });
176}
177
178export async function freshLoginToken(email: string, password: string): Promise<string> {
179 const req = await pwRequest.newContext({ baseURL: API_BASE, timeout: 30_000 });
180 try {
181 const res = await req.post("/api/v1/auth/login", { data: { email, password } });
182 if (res.status() !== 200) {
183 throw new Error(`login ${email} failed (${res.status()}): ${await res.text()}`);
184 }
185 const body = (await res.json()) as { accessToken?: string };
186 if (!body.accessToken) throw new Error(`login ${email}: no accessToken in body`);
187 return body.accessToken;
188 } finally {
189 await req.dispose();
190 }
191}
192
193function uniqueEmail(): string {
194 return `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@factory.local`;
195}
196
197export const test = base.extend<FactoryFixtures>({
198 user: async ({}, use) => {
199 await use({ email: DEMO_EMAIL, password: DEMO_PASSWORD });
200 },
201
202
203
204 api: async ({}, use) => {
205 const token = await freshLoginToken(DEMO_EMAIL, DEMO_PASSWORD);
206 const ctx = await bearerApi(token);
207 await use(ctx);
208 await ctx.dispose();
209 },
210
211
212
213
214
215
216
217
218
219
220 authPage: async ({ browser }, use) => {
221 const plain = await browser.newContext({ baseURL: API_BASE });
222 const res = await plain.request.post("/api/v1/auth/login", {
223 data: { email: DEMO_EMAIL, password: DEMO_PASSWORD },
224 });
225 if (res.status() !== 200) {
226 throw new Error(`authPage: login failed (${res.status()}): ${await res.text()}`);
227 }
228 const body = (await res.json()) as { accessToken?: string };
229 const jar = await plain.cookies();
230 await plain.close();
231 const state: AuthState = {
232 accessToken: body.accessToken ?? "",
233 cookies: jar.map((c) => ({
234 name: c.name,
235 value: c.value,
236 domain: c.domain,
237 path: c.path,
238 expires: c.expires,
239 httpOnly: c.httpOnly,
240 secure: c.secure,
241 sameSite: c.sameSite,
242 })),
243 origins: [],
244 };
245 const context = await browser.newContext({ storageState: state });
246 const page = await context.newPage();
247 await use(page);
248 await context.close();
249 },
250
251 secondUser: async ({ browser }, use) => {
252 let registered:
253 | {
254 user: { email: string; password: string };
255 page: Page;
256 api: APIRequestContext;
257 }
258 | undefined;
259 await use(async () => {
260 if (registered) return registered;
261 const email = uniqueEmail();
262 const password = `e2e-${Date.now().toString(36)}`;
263
264
265
266
267
268
269
270
271
272
273
274
275 const plain = await browser.newContext({ baseURL: API_BASE });
276 const regApi = plain.request;
277 const reg = await regApi.post("/api/v1/auth/register", {
278 data: { email, password, name: "E2E Second User" },
279 });
280 if (![200, 201].includes(reg.status())) {
281 throw new Error(
282 `secondUser: register failed (${reg.status()}): ${await reg.text()}`,
283 );
284 }
285 const login = await regApi.post("/api/v1/auth/login", {
286 data: { email, password },
287 });
288 if (login.status() !== 200) {
289 throw new Error(
290 `secondUser: login failed (${login.status()}): ${await login.text()}`,
291 );
292 }
293
294
295
296
297
298 const jar = await plain.cookies();
299 const state: AuthState = {
300 accessToken: "",
301 cookies: jar.map((c) => ({
302 name: c.name,
303 value: c.value,
304 domain: c.domain,
305 path: c.path,
306 expires: c.expires,
307 httpOnly: c.httpOnly,
308 secure: c.secure,
309 sameSite: c.sameSite,
310 })),
311 origins: [],
312 };
313 let token = "";
314 try {
315 const body = (await login.json()) as { accessToken?: string };
316 token = body.accessToken ?? "";
317 state.accessToken = token;
318 } catch {
319
320 }
321 await plain.close();
322
323 const context = await browser.newContext({ storageState: state });
324 const page = await context.newPage();
325
326
327
328 const api = token
329 ? await bearerApi(token)
330 : await pwRequest.newContext({ baseURL: API_BASE, timeout: 30_000 });
331 registered = { user: { email, password }, page, api };
332 return registered;
333 });
334 if (registered) {
335 await registered.api.dispose().catch(() => {});
336
337
338 }
339 },
340
341 freshWorkspace: async ({ api }, use) => {
342 const created: Workspace[] = [];
343 await use(async (name?: string) => {
344 const res = await api.post("/api/v1/workspaces", {
345 data: { name: name ?? `E2E WS ${Date.now()}` },
346 });
347 const text = await res.text();
348 expect(
349 res.status(),
350 `freshWorkspace: create failed (${res.status()}): ${text}`,
351 ).toBe(201);
352
353
354
355
356 const ws = JSON.parse(text) as Workspace;
357 created.push(ws);
358 return ws;
359 });
360
361
362
363 for (const ws of created.reverse()) {
364 await api.delete(`/api/v1/workspaces/${ws.id}`).catch(() => {});
365 }
366 },
367});
368
369export { expect };
370