1
55import { createHmac, timingSafeEqual } from "node:crypto";
56import { Buffer } from "node:buffer";
57import { createServer, type Server } from "node:http";
58import {
59 request as pwRequest,
60 type APIRequestContext,
61 type BrowserContext,
62 type Page,
63 expect,
64 test
65} from "@playwright/test";
66import { API_BASE, type Workspace } from "../fixtures";
67import { createPage, type UserDto } from "../utils/api";
68import { dragBlockAbove } from "../utils/dnd";
69
70const STAGING = process.env.E2E_STAGING === "1";
71const RUN_ID = process.env.E2E_RUN_ID ?? `local-${Date.now().toString(36)}`;
72const WEB_BASE = process.env.E2E_BASE_URL ?? "http://localhost:3000";
73const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
74
75
79async function apiCall(
80 ctx: APIRequestContext,
81 method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE",
82 path: string,
83 data?: unknown,
84 what = "api",
85): Promise<{ status: number; json: any }> {
86 let last = "";
87 for (let attempt = 0; attempt < 5; attempt++) {
88
89
90
91 const lower = method.toLowerCase() as "get" | "post" | "patch" | "put" | "delete";
92 const res = await ctx[lower](path, data === undefined ? undefined : { data });
93 if (res.status() < 500) {
94 const json = await res.json().catch(() => ({}));
95 return { status: res.status(), json };
96 }
97 last = `${res.status()}: ${(await res.text().catch(() => "")).slice(0, 200)}`;
98 await sleep(700 * (attempt + 1));
99 }
100 throw new Error(`${what} ${method} ${path} failed after retries: ${last}`);
101}
102
103
110async function stripCsp(ctx: BrowserContext): Promise<void> {
111 await ctx.route("**/*", async (route) => {
112 let res;
113 try {
114 res = await route.fetch();
115 } catch {
116 await route.continue().catch(() => {});
117 return;
118 }
119 const headers: Record<string, string> = {};
120 for (const [k, v] of Object.entries(res.headers())) headers[k] = v;
121 delete headers["content-security-policy"];
122 await route.fulfill({ response: res, headers }).catch(async () => {
123 await route.continue().catch(() => {});
124 });
125 });
126}
127
128
138async function slashPick(page: Page, commandText: string): Promise<void> {
139 const trailing = page.locator("div[data-testid=\"new-block\"]").last();
140
141
142
143
144 const editorReady = async (ms: number) =>
145 await expect
146 .poll(async () => (await trailing.count()) > 0 || (await page.locator(".block-row").count()) > 0, { timeout: ms })
147 .toBe(true);
148 await editorReady(10_000).catch(async () => {
149 await page.reload({ waitUntil: "domcontentloaded" });
150 await editorReady(20_000);
151 });
152 await trailing.click();
153 await expect
154 .poll(
155 async () =>
156 page.evaluate(() => {
157 const rows = document.querySelectorAll<HTMLElement>(".block-row");
158 const last = rows[rows.length - 1];
159 if (!last) return false;
160 const editable = last.querySelector<HTMLElement>('[contenteditable="true"]');
161 if (!editable) return false;
162 const active = document.activeElement;
163 return active instanceof HTMLElement && editable.contains(active);
164 }),
165 { timeout: 10_000 },
166 )
167 .toBe(true);
168
169
170
171
172
173
174
175
176 await page.keyboard.type(`/`);
177 const slash = page.getByRole("dialog", { name: "Slash commands" });
178 await expect(slash).toBeVisible({ timeout: 10_000 });
179 await page.keyboard.type(commandText);
180 await expect(slash.getByRole("option").first()).toBeVisible({ timeout: 5_000 });
181 await page.keyboard.press("Enter");
182
183
184
185
186
187 await expect(slash).toHaveCount(0, { timeout: 3_000 }).catch(async () => {
188 await page.keyboard.press("Escape");
189 await expect(slash).toHaveCount(0, { timeout: 10_000 });
190 });
191}
192
193
196async function goto(page: Page, url: string, opts: { waitUntil?: "load" | "domcontentloaded" } = {}, attempts = 3): Promise<void> {
197 let lastErr: unknown;
198 for (let i = 0; i < attempts; i++) {
199 try {
200 await page.goto(url, opts);
201 return;
202 } catch (e) {
203 lastErr = e;
204 await sleep(1500 * (i + 1));
205 }
206 }
207 throw lastErr;
208}
209
210
211async function listProperties(
212 api: APIRequestContext,
213 databaseId: string,
214): Promise<any[]> {
215 const r = await apiCall(api, "GET", `/api/v1/databases/${databaseId}/properties`, undefined, "props");
216 return Array.isArray(r.json?.data) ? r.json.data : Array.isArray(r.json) ? r.json : [];
217}
218
219interface FlowState {
220 token: string;
221 user?: UserDto;
222 ws?: Workspace;
223 pageId?: string;
224 taskId?: string;
225 peopleId?: string;
226 relPropId?: string;
227 formulaId?: string;
228 apiKey?: { id: string; key: string };
229 webhook?: { url: string; secret: string };
230 server?: Server;
231 context?: BrowserContext;
232 api?: APIRequestContext;
233}
234const st: FlowState = { token: "" };
235
236test.describe("Release v1.0 final E2E flow (13 steps)", () => {
237
238
239 test.setTimeout(600_000);
240 test.slow();
241
242 test("the 13-step release flow (state carries, sequential)", async ({ browser }) => {
243
244
245
246
247
248 const stagingOnly = (name: string, fn: () => Promise<void>) =>
249 test.step(name, async () => {
250 if (!STAGING) {
251 test.info().annotations.push({
252 type: "env",
253 description: `staging-only no-op locally: ${name}`,
254 });
255 return;
256 }
257 await fn();
258 });
259
260
261
262
263
264
265
266 const email = `final-e2e-${RUN_ID}@factory.local`;
267 const password = `Final!e2e-${RUN_ID}-pw`;
268 let cookies: import("@playwright/test").Cookie[] = [];
269 const authCtx = await pwRequest.newContext({ baseURL: API_BASE, timeout: 30_000 });
270 await test.step("1. register a unique user (final-e2e-<runId>@factory.local)", async () => {
271 const reg = await authCtx.post("/api/v1/auth/register", {
272 data: { email, password, name: "Final E2E User" },
273 });
274 expect(reg.status(), `step1 register 201: ${await reg.text()}`).toBe(201);
275
276
277 const login = await authCtx.post("/api/v1/auth/login", {
278 data: { email, password },
279 });
280 expect(login.status(), `login after register: ${await login.text()}`).toBe(200);
281 st.token = ((await login.json()) as { accessToken?: string }).accessToken ?? "";
282 expect(st.token, "login returned an access token").toBeTruthy();
283
284
285 cookies = (await authCtx.storageState()).cookies;
286 });
287 await authCtx.dispose().catch(() => {});
288
289
290
291 const api: APIRequestContext = await pwRequest.newContext({
292 baseURL: API_BASE,
293 timeout: 60_000,
294 extraHTTPHeaders: { Authorization: `Bearer ${st.token}` },
295 });
296 st.api = api;
297
298
299
300 const context: BrowserContext = await browser.newContext({
301 baseURL: WEB_BASE,
302 storageState: { cookies, origins: [] },
303 });
304 st.context = context;
305 await stripCsp(context);
306 const page: Page = await context.newPage();
307 st.user = ((await apiCall(api, "GET", "/api/v1/auth/me", undefined, "me")).json ?? {}).user;
308
309 try {
310
311
312
313 await stagingOnly("2. verify email (mailcatcher link)", async () => {
314 const mc = process.env.E2E_MAILCATCHER_URL ?? "http://localhost:1080";
315 const list = (await (
316 await fetch(`${mc}/api/v1/messages?search=${encodeURIComponent(st.user!.email)}`)
317 ).json()) as { messages: { html_part: string }[] };
318 const msg = list.messages[0];
319 expect(msg, "mailcatcher captured the verification mail").toBeTruthy();
320 const html = msg?.html_part ?? "";
321 const link = /https?:\/\/[^\s"'<>]+/u.exec(html)?.[0] ?? "";
322 expect(link, "a verification link is present").toBeTruthy();
323 const verifyCtx = await browser.newContext();
324 await verifyCtx.request.get(link);
325 await verifyCtx.close();
326 });
327
328
329 await test.step("3. create workspace 'Release Co'", async () => {
330 const r = await apiCall(api, "POST", "/api/v1/workspaces", { name: "Release Co" }, "createWorkspace");
331 expect([200, 201].includes(r.status), `workspace created: ${JSON.stringify(r.json)}`).toBe(true);
332 st.ws = r.json as Workspace;
333 expect(st.ws?.id && st.ws?.slug, "workspace has id + slug").toBeTruthy();
334 await goto(page, `/w/${st.ws!.slug}`);
335 await expect(page).toHaveURL(new RegExp(`/w/${st.ws!.slug}`));
336
337 await expect(page.locator("#workspace-main")).toBeVisible({ timeout: 15_000 });
338 });
339
340
341 let pageId = "";
342 await test.step("4. create page 'Release Plan'", async () => {
343 const p = await createPage(api, st.ws!.id, { title: "Release Plan" });
344 pageId = p.id;
345 st.pageId = pageId;
346 await goto(page, `/w/${st.ws!.slug}/page/${pageId}`, { waitUntil: "domcontentloaded" });
347
348
349 await expect(page.getByLabel("Page title")).toHaveValue("Release Plan", { timeout: 20_000 });
350 });
351
352
353 const TEXTS = ["Alpha", "Bravo", "Charlie", "Delta", "Echo"];
354 await test.step("5. editor: 5 blocks, DnD reorder (persists across reload)", async () => {
355 for (let i = 1; i <= 5; i++) {
356 const b = await apiCall(
357 api,
358 "POST",
359 `/api/v1/pages/${pageId}/blocks`,
360 { type: "paragraph", content: { text: TEXTS[i - 1] } },
361 `createBlock ${i}`,
362 );
363 expect([200, 201].includes(b.status), `block ${i} created`).toBe(true);
364 }
365
366
367
368 const before = (await apiCall(api, "GET", `/api/v1/pages/${pageId}/blocks`, undefined, "blocks")).json;
369 const ordered: { id: string; content?: { text?: string } }[] = before?.data ?? [];
370 const byText = (t: string) => ordered.find((b) => b.content?.text === t)?.id;
371 const aId = byText("Alpha");
372 const cId = byText("Charlie");
373 expect(aId && cId, "Alpha + Charlie blocks exist").toBeTruthy();
374
375
376
377
378
379 await goto(page, `/w/${st.ws!.slug}/page/${pageId}`, { waitUntil: "domcontentloaded" });
380 await dragBlockAbove(page, cId!, aId!);
381 await page.reload({ waitUntil: "domcontentloaded" });
382
383
384 const after = (await apiCall(api, "GET", `/api/v1/pages/${pageId}/blocks`, undefined, "blocks")).json;
385 const ids: string[] = (after?.data ?? []).map((b: { id: string }) => b.id);
386 expect(ids.indexOf(cId!), "Charlie present after reload").toBeGreaterThanOrEqual(0);
387 expect(
388 ids.indexOf(cId!) < ids.indexOf(aId!),
389 "Charlie (3rd) now precedes Alpha (1st) after reload",
390 ).toBe(true);
391 });
392
393
394 let taskId = "";
395 let titlePropId = "";
396 let statusPropId = "";
397 let duePropId = "";
398 await test.step("6. database 'Tasks' + 3 records (3 rows)", async () => {
399
400 await goto(page, `/w/${st.ws!.slug}/page/${pageId}`, { waitUntil: "domcontentloaded" });
401 await slashPick(page, "database");
402 const insert = page.locator("#db-title");
403 await expect(insert).toBeVisible({ timeout: 10_000 });
404 await insert.fill("Tasks");
405 await page.getByRole("button", { name: "Create database" }).click();
406
407
408
409
410
411
412
413
414
415 await expect
416 .poll(async () => {
417 const blocks = (await apiCall(api, "GET", `/api/v1/pages/${pageId}/blocks`, undefined, "blocks")).json;
418 const db = (blocks?.data ?? []).find((b: any) => b.type === "DATABASE");
419 return Boolean(db?.properties?.databaseId);
420 }, { timeout: 20_000 })
421 .toBe(true);
422 await expect(page.locator('[data-testid^="record-row-"], button[aria-label="Add property"]'))
423 .toBeVisible({ timeout: 20_000 });
424
425
426
427 const dbBlocks = (await apiCall(api, "GET", `/api/v1/pages/${pageId}/blocks`, undefined, "blocks")).json;
428 const dbBlock = (dbBlocks?.data ?? []).find((b: any) => b.type === "DATABASE");
429 taskId = dbBlock?.properties?.databaseId ?? "";
430 st.taskId = taskId;
431 expect(taskId, "Tasks database id resolved from the page's database block").toBeTruthy();
432
433
434
435
436
437
438
439
440
441
442 const existing = await listProperties(api, taskId);
443 duePropId = existing.find((p) => p.name === "Due")?.id ?? "";
444 if (!duePropId) {
445 const due = await apiCall(
446 api,
447 "POST",
448 `/api/v1/databases/${taskId}/properties`,
449 { name: "Due", type: "DATE", config: {}, isVisible: true, required: false, default: null },
450 "Due property",
451 );
452 expect(due.status, `Due DATE property created: ${JSON.stringify(due.json)}`).toBe(201);
453 duePropId = due.json?.id ?? "";
454 }
455 const propsAfter = await listProperties(api, taskId);
456 titlePropId = propsAfter.find((p) => p.isTitle || p.type === "TITLE")?.id ?? "";
457 statusPropId = propsAfter.find((p) => p.type === "SELECT")?.id ?? "";
458 duePropId = propsAfter.find((p) => p.name === "Due")?.id ?? "";
459 expect(titlePropId && statusPropId && duePropId, "Title + Status + Due props exist").toBeTruthy();
460
461
462
463
464
465
466 const titleCellWrapper = (row: import("@playwright/test").Locator) =>
467 row.locator("button[aria-label$='cell (click to edit)']").first();
468 for (const title of ["Ship release", "Write notes", "Tag v1.0.0"]) {
469
470
471
472
473
474 const rowsBefore = await page.locator('[data-testid^="record-row-"]').count();
475 await page.getByTestId("add-row").click();
476 await expect
477 .poll(() => page.locator('[data-testid^="record-row-"]').count(), { timeout: 10_000 })
478 .toBeGreaterThan(rowsBefore);
479 const newRow = page.locator('[data-testid^="record-row-"]').last();
480 await expect(newRow).toBeVisible({ timeout: 10_000 });
481 const cellBtn = titleCellWrapper(newRow);
482 await expect(cellBtn).toBeVisible({ timeout: 10_000 });
483 await cellBtn.focus();
484 await page.keyboard.press("Enter");
485 const cellInput = newRow.locator('input[placeholder="Add a title…"]').first();
486 await expect(cellInput).toBeVisible({ timeout: 5_000 });
487 await cellInput.fill(title);
488 await page.keyboard.press("Enter");
489 await sleep(500);
490 }
491
492 const rows = page.locator('[data-testid^="record-row-"]');
493 await expect(rows).toHaveCount(3, { timeout: 15_000 });
494 const recs = (await apiCall(api, "GET", `/api/v1/databases/${taskId}/records`, undefined, "records")).json;
495 expect(recs?.data?.length, "3 records exist").toBe(3);
496 });
497
498
499 await test.step("7. relation + formula (value via API)", async () => {
500 const peoplePage = await apiCall(
501 api,
502 "POST",
503 `/api/v1/workspaces/${st.ws!.id}/pages`,
504 { title: "People", type: "database" },
505 "peoplePage",
506 );
507 st.peopleId = peoplePage.json?.databaseId ?? "";
508 expect(st.peopleId, "People database id").toBeTruthy();
509 const jane = await apiCall(api, "POST", `/api/v1/databases/${st.peopleId}/records`, { title: "Jane" }, "jane");
510 expect(jane.status, "Jane record created").toBe(201);
511
512 const rel = await apiCall(api, "POST", `/api/v1/databases/${taskId}/relations`, {
513 name: "Assignee",
514 targetDatabaseId: st.peopleId,
515 twoWay: true,
516 }, "relation");
517 expect([200, 201].includes(rel.status), `relation created: ${JSON.stringify(rel.json)}`).toBe(true);
518 st.relPropId = rel.json?.propertyId ?? "";
519 expect(st.relPropId, "relation propertyId").toBeTruthy();
520
521
522 const f = await apiCall(api, "POST", `/api/v1/databases/${taskId}/formulas`, {
523 name: "isOverdue",
524 expression: "dateBetween(prop(\"Due\"), now()) > 0",
525 }, "formula");
526 expect([200, 201].includes(f.status), `formula created: ${JSON.stringify(f.json)}`).toBe(true);
527 st.formulaId = f.json?.id ?? "";
528 expect(st.formulaId, "formula id").toBeTruthy();
529
530 const recs = (await apiCall(api, "GET", `/api/v1/databases/${taskId}/records`, undefined, "records")).json;
531 const recA = (recs?.data ?? [])[0];
532 expect(recA?.id, "a record exists to evaluate the formula on").toBeTruthy();
533
534
535 const relKey = st.relPropId as string;
536 const upd = await apiCall(api, "PATCH", `/api/v1/records/${recA.id}`, {
537 properties: { [duePropId]: "2026-09-01" },
538 links: { [relKey]: [jane.json?.id] },
539 }, "recA update");
540 expect([200, 201].includes(upd.status), `record updated: ${JSON.stringify(upd.json)}`).toBe(true);
541
542 const fv = await apiCall(api, "GET", `/api/v1/records/${recA.id}/formula/${st.formulaId}`, undefined, "formula eval");
543 expect(fv.status, `formula evaluation: ${JSON.stringify(fv.json)}`).toBe(200);
544 expect(fv.json?.value, "isOverdue formula value is true on the overdue record").toBe(true);
545
546 await goto(page, `/w/${st.ws!.slug}/databases/${taskId}`, { waitUntil: "domcontentloaded" });
547 const table = page.locator('[data-testid^="record-row-"]').first();
548 await expect(table).toBeVisible({ timeout: 15_000 });
549 const tableRegion = page.locator("#workspace-main");
550 await expect(tableRegion.getByText("Jane", { exact: true }).first()).toBeVisible({ timeout: 15_000 });
551 });
552
553
554 await test.step("8. global search (page + record)", async () => {
555 await goto(page, `/w/${st.ws!.slug}/search`, { waitUntil: "domcontentloaded" });
556
557
558
559 const input = page.getByLabel("Search", { exact: true });
560 await expect(input).toBeVisible({ timeout: 15_000 });
561
562
563 await input.fill("Release Plan");
564 const pageRow = page.getByTestId("search-result").filter({ hasText: "Release Plan" });
565 await expect(pageRow.first()).toBeVisible({ timeout: 30_000 });
566 await expect(pageRow.first().getByTestId("search-result-type-page")).toBeVisible();
567
568 await pageRow.first().click();
569 await expect(page).toHaveURL(new RegExp(`/w/${st.ws!.slug}/page/${pageId}`), { timeout: 15_000 });
570
571
572 await goto(page, `/w/${st.ws!.slug}/search`, { waitUntil: "domcontentloaded" });
573 const input2 = page.getByLabel("Search", { exact: true });
574 await expect(input2).toBeVisible({ timeout: 15_000 });
575 await input2.fill("Tag v1.0.0");
576 const recRow = page.getByTestId("search-result").filter({ hasText: "Tag v1.0.0" });
577 await expect(recRow.first()).toBeVisible({ timeout: 30_000 });
578 await expect(recRow.first().getByTestId("search-result-type-record")).toBeVisible();
579 await recRow.first().click();
580 await expect(page).toHaveURL(new RegExp(`/w/${st.ws!.slug}/databases/`), { timeout: 15_000 });
581 });
582
583
584 await stagingOnly("9. upload 10 MB file (metadata via API)", async () => {
585
586
587 let storageUp = false;
588 try {
589 const s3 = process.env.E2E_S3_ENDPOINT ?? "http://localhost:9000";
590 const h = await fetch(`${s3}/minio/health/live`);
591 storageUp = h.ok;
592 } catch {
593 storageUp = false;
594 }
595 if (!storageUp) {
596 test.info().annotations.push({ type: "env", description: "step9 skipped: S3/MinIO not reachable" });
597 return;
598 }
599 const bytes = Buffer.alloc(10 * 1024 * 1024);
600 for (let i = 0; i < bytes.length; i++) bytes[i] = i % 251;
601
602 await goto(page, `/w/${st.ws!.slug}/page/${pageId}`, { waitUntil: "domcontentloaded" });
603 await slashPick(page, "file");
604 const fileInput = page.locator('[data-testid="file-input"]');
605
606
607 await expect(fileInput).toBeAttached({ timeout: 15_000 });
608 await fileInput.setInputFiles({ name: "release-assets.bin", mimeType: "application/octet-stream", buffer: bytes });
609 const card = page.locator('[data-testid^="file-card-"]').first();
610 await expect(card).toBeVisible({ timeout: 60_000 });
611
612 const list = (await apiCall(api, "GET", "/api/v1/files", undefined, "files")).json;
613 const file = (list?.data ?? []).find((f: any) => f.name === "release-assets.bin");
614 expect(file, "the uploaded file is in the API file list").toBeTruthy();
615 expect(file.size, "file size is 10 MB").toBe(10 * 1024 * 1024);
616 expect(file.mimeType, "mime recorded").toBe("application/octet-stream");
617 expect(file.status, "file READY").toBe("READY");
618 });
619
620
621 await stagingOnly("10. anonymous public form submit", async () => {
622
623
624
625 const qLabel = "Release feedback?";
626 const form = await apiCall(api, "POST", `/api/v1/databases/${taskId}/forms`, {
627 name: "Release feedback",
628 slug: `release-feedback-${RUN_ID}`,
629 questions: [{ type: "SHORT_TEXT", label: qLabel, propertyId: titlePropId }],
630 }, "form");
631 expect(form.status, `form created: ${JSON.stringify(form.json)}`).toBe(201);
632 const formId = form.json?.id as string;
633 const qId = form.json?.questions?.[0]?.id as string;
634 expect(formId && qId, "form + question ids").toBeTruthy();
635 const pub = await apiCall(api, "PATCH", `/api/v1/forms/${formId}`, { status: "PUBLISHED" }, "publish");
636 expect(pub.status, `form published: ${JSON.stringify(pub.json)}`).toBe(200);
637
638 const anon = await browser.newContext({ baseURL: WEB_BASE });
639 await stripCsp(anon);
640 const anonPage = await anon.newPage();
641 await goto(anonPage, `/f/release-feedback-${RUN_ID}`, { waitUntil: "domcontentloaded" });
642 await anonPage.getByLabel(qLabel).fill("Anonymous Fan");
643 await anonPage.getByRole("button", { name: "Submit" }).click();
644 await expect(anonPage.getByText(/Thanks|recorded/i).first()).toBeVisible({ timeout: 15_000 });
645 await anon.close();
646
647 const recs = (await apiCall(api, "GET", `/api/v1/databases/${taskId}/records`, undefined, "records")).json;
648 expect(
649 (recs?.data ?? []).some((r: any) => r.title === "Anonymous Fan"),
650 "the anonymous submission created a record",
651 ).toBe(true);
652 const audit = (await apiCall(api, "GET", `/api/v1/workspaces/${st.ws!.id}/audit`, undefined, "audit")).json;
653 expect(
654 (audit?.data ?? []).some((a: any) => a.action === "form.submitted"),
655 "a form.submitted audit row exists",
656 ).toBe(true);
657 });
658
659
660 await test.step("11. automation → in-app notify (SUCCEEDED)", async () => {
661
662
663 const auto = await apiCall(api, "POST", `/api/v1/workspaces/${st.ws!.id}/automations`, {
664 name: "Notify on new task",
665 databaseId: taskId,
666 triggers: [{ type: "RECORD_CREATED", config: { databaseId: taskId } }],
667 actions: [
668 { type: "CREATE_NOTIFICATION", config: { to: "automation_creator", type: "DATABASE_CHANGE", body: "new task recorded" } },
669 ],
670 }, "automation");
671 expect([200, 201].includes(auto.status), `automation created: ${JSON.stringify(auto.json)}`).toBe(true);
672 const autoId = auto.json?.id as string;
673
674 const act = await apiCall(api, "POST", `/api/v1/automations/${autoId}/status`, { status: "ACTIVE" }, "activate");
675 expect([200, 204].includes(act.status), `automation activated: ${JSON.stringify(act.json)}`).toBe(true);
676
677 const created = await apiCall(api, "POST", `/api/v1/databases/${taskId}/records`, { title: "Trigger record" }, "trigger record");
678 expect(created.status, "trigger record created").toBe(201);
679
680 let status = "";
681 const deadline = Date.now() + 40_000;
682 while (Date.now() < deadline) {
683 const runs = (await apiCall(api, "GET", `/api/v1/automations/${autoId}/runs?limit=1`, undefined, "runs")).json;
684 status = runs?.data?.[0]?.status ?? "";
685 if (status === "SUCCEEDED" || status === "FAILED" || status === "DEAD_LETTER") break;
686 await sleep(1000);
687 }
688 expect(status, "automation run reached a terminal state").not.toBe("");
689 expect(status, "automation run SUCCEEDED").toBe("SUCCEEDED");
690
691 let found = false;
692 const nDeadline = Date.now() + 15_000;
693 while (Date.now() < nDeadline && !found) {
694 const notes = (await apiCall(api, "GET", "/api/v1/notifications", undefined, "notifications")).json;
695 found = (notes?.data ?? []).some((n: any) => n.title === "Notify on new task" && n.body === "new task recorded");
696 if (!found) await sleep(1000);
697 }
698 expect(found, "the automation notification is in the Inbox").toBe(true);
699 });
700
701
702 await stagingOnly("12. AI (mock) streamed answer + citation", async () => {
703 const QUESTION = "Summarize the content on this page";
704 const ECHO = QUESTION.slice(0, 40);
705 await goto(page, `/w/${st.ws!.slug}/page/${pageId}`, { waitUntil: "domcontentloaded" });
706 await page.getByTestId("ask-ai-button").click();
707 const panel = page.getByTestId("ai-panel");
708 await expect(panel).toBeVisible({ timeout: 15_000 });
709
710 await expect(page.getByTestId("ai-context-badge")).toContainText("Release Plan");
711 const input = page.getByTestId("ai-input");
712 await expect(input).toBeVisible();
713 await input.fill(QUESTION);
714 await page.getByTestId("ai-send").click();
715
716
717 const live = page.getByTestId("ai-message-live");
718 const seen = new Set<string>();
719 await expect
720 .poll(
721 async () => {
722 if ((await live.count()) > 0) {
723 const t = (await live.first().textContent())?.trim() ?? "";
724 if (t && !/thinking/i.test(t)) seen.add(t);
725 }
726 return seen.size;
727 },
728 { timeout: 20_000, intervals: [25] },
729 )
730 .toBeGreaterThanOrEqual(3);
731 const assistant = page.getByTestId("ai-message-assistant").last();
732 await expect(assistant).toBeVisible({ timeout: 20_000 });
733 await expect(assistant).toContainText(ECHO);
734
735
736
737
738 await goto(page, `/w/${st.ws!.slug}/ask`, { waitUntil: "domcontentloaded" });
739 const askInput = page.getByTestId("ask-ai-input");
740 await expect(askInput).toBeVisible({ timeout: 15_000 });
741 await askInput.fill("What is the Release Plan page about?");
742 await page.getByTestId("ask-ai-submit").click();
743 const answer = page.getByTestId("ask-ai-answer");
744 await expect(answer).toContainText("Release Plan", { timeout: 30_000 });
745 await expect(page.getByTestId("ask-ai-sources")).toBeVisible();
746 await expect(page.getByTestId("ask-ai-source-1")).toBeVisible();
747 const chip = page.getByTestId("ask-ai-chip-1");
748 await expect(chip).toBeVisible();
749 const href = await chip.getAttribute("href");
750 expect(href, "the citation chip must be a link").toBeTruthy();
751 expect(href, `chip href should target the page (got ${href})`).toBe(`/w/${st.ws!.slug}/page/${pageId}`);
752 });
753
754
755 await test.step("13. API key + signed webhook (HMAC) + logout", async () => {
756
757
758 await goto(page, `/w/${st.ws!.slug}/settings/developers`, { waitUntil: "domcontentloaded" });
759
760
761
762
763 const createBtn = page.getByRole("button", { name: "Create API key" }).first();
764 await expect(createBtn).toBeVisible({ timeout: 15_000 });
765 await createBtn.click().catch(async () => {
766 await expect(createBtn).toBeAttached();
767 await createBtn.evaluate((el) => (el as HTMLElement).click());
768 });
769 const create = page.getByRole("dialog", { name: "Create API key" });
770 await expect(create).toBeVisible({ timeout: 10_000 });
771 await create.locator("#apikey-name").fill("final-e2e-key");
772
773
774 await create.getByRole("checkbox", { name: /READ/ }).check();
775 await create.getByRole("checkbox", { name: /WRITE/ }).check();
776 await create.getByRole("button", { name: "Create key" }).click();
777 const reveal = page.locator("#apikey-raw");
778 await expect(reveal).toBeVisible({ timeout: 10_000 });
779 const rawKey = (await reveal.inputValue().catch(() => "")).trim();
780 expect(/^fk_live_/.test(rawKey), `the raw API key is revealed (got ${rawKey.slice(0, 12)}…)`).toBe(true);
781 st.apiKey = { id: "", key: rawKey };
782
783 await page.getByTestId("apikey-stored-confirm").check();
784 await page.getByRole("button", { name: "I have stored it" }).click();
785 await expect(reveal).toHaveCount(0);
786
787
788
789
790
791 const started = new Promise<void>((resolve) => {
792 const server = createServer((req, res) => {
793 const chunks: Buffer[] = [];
794 req.on("data", (c) => chunks.push(c));
795 req.on("end", () => {
796 res.writeHead(200).end("ok");
797 resolveDelivery({
798 headers: req.headers as Record<string, string>,
799 body: Buffer.concat(chunks).toString("utf8"),
800 });
801 });
802 });
803 st.server = server;
804 server.listen(0, "127.0.0.1", () => {
805 const a = server.address();
806 const port = typeof a === "object" && a ? a.port : 0;
807 st.webhook = { url: `http://127.0.0.1:${port}/hook`, secret: `whsec_${RUN_ID}` };
808 resolve();
809 });
810 });
811 let resolveDelivery: (d: { headers: Record<string, string>; body: string }) => void = () => {};
812 const delivery = new Promise<{ headers: Record<string, string>; body: string }>((r) => {
813 resolveDelivery = r;
814 });
815 await started;
816 const wh = await apiCall(api, "POST", `/api/v1/workspaces/${st.ws!.id}/webhooks`, {
817 url: st.webhook!.url,
818 secret: st.webhook!.secret,
819 events: ["page.created"],
820 }, "webhook");
821 expect([200, 201].includes(wh.status), `webhook registered: ${JSON.stringify(wh.json)}`).toBe(true);
822
823
824 const created = await fetch(`${API_BASE}/api/v1/workspaces/${st.ws!.id}/pages`, {
825 method: "POST",
826 headers: { "Content-Type": "application/json", Authorization: `Bearer ${rawKey}` },
827 body: JSON.stringify({ title: "Webhook trigger page" }),
828 });
829 expect(created.status, "fetch with the API key → 201").toBe(201);
830
831
832 const got = await Promise.race([
833 delivery,
834 new Promise<never>((_, rej) => setTimeout(() => rej(new Error("webhook delivery > 15 s")), 15_000)),
835 ]);
836 const parsed = JSON.parse(got.body) as { type?: string };
837
838
839
840
841 expect(parsed.type, "the webhook payload carries the event").toBe("page.created");
842 const sig = got.headers["x-factory-signature"] ?? "";
843 const t = /t=(\d+)/.exec(sig)?.[1] ?? "";
844 const v1 = /v1=([0-9a-f]+)/.exec(sig)?.[1] ?? "";
845 expect(t, "signature carries a timestamp").toBeTruthy();
846 const expectV1 = createHmac("sha256", st.webhook!.secret).update(`${t}.${got.body}`).digest("hex");
847 expect(
848 v1.length === expectV1.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expectV1)),
849 "HMAC v1 matches the recomputed signature",
850 ).toBe(true);
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869 const vp = page.viewportSize() ?? { width: 1280, height: 800 };
870 if (vp.width < 768) {
871
872
873
874 const drawer = page.getByRole("dialog", { name: "Navigation" });
875 await page.getByRole("button", { name: "Toggle sidebar" }).click();
876 await expect(drawer.getByTestId("user-menu-logout")).toBeVisible({ timeout: 10_000 });
877 await drawer.getByTestId("user-menu-logout").click();
878 } else {
879 await page.getByTestId("user-menu-logout").click();
880 }
881 await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
882 await sleep(1000);
883 const refresh = await context.request.post(`${API_BASE}/api/v1/auth/refresh`, { data: {} });
884 expect(refresh.status(), "refresh after logout → 401 (session revoked)").toBe(401);
885 });
886 } finally {
887 st.server?.close();
888 st.server?.closeAllConnections?.();
889 await api.dispose().catch(() => {});
890
891
892 await Promise.race([
893 context.close().catch(() => {}),
894 sleep(15_000).then(() => context.close({ reason: "teardown-timeout" }).catch(() => {})),
895 ]);
896 }
897 });
898});
899