Skip to content
File

Blob: tests/e2e/fixtures/bland-test.ts

typescript348 lines
1import { expect, test as base, type Page } from "@playwright/test";
2import { readFile } from "node:fs/promises";
3import { isDeepStrictEqual } from "node:util";
4import * as Y from "yjs";
5import { extractPlaintext } from "@/shared/editor/yjs-text";
6import { E2E_CONTEXT_PATH_ENV, type E2eContextFile } from "../global-setup";
7import { TEST_CREDENTIALS } from "../harness";
8 
9export interface TestPage {
10 pageId: string;
11 workspaceId: string;
12 workspaceSlug: string;
13}
14 
15export interface TestWorkspace {
16 workspaceId: string;
17 workspaceSlug: string;
18}
19 
20export interface ShareLink {
21 token: string;
22 permission: "view" | "edit";
23}
24 
25export interface AuthenticatedPage {
26 page: Page;
27 accessToken: string;
28}
29 
30interface BlandFixtures {
31 e2eContext: E2eContextFile;
32 authenticatedPage: AuthenticatedPage;
33 e2eWorkspace: TestWorkspace;
34}
35 
36export const test = base.extend<BlandFixtures>({
37 // eslint-disable-next-line no-empty-pattern -- Playwright fixture callbacks require object destructuring here.
38 e2eContext: async ({}, use: (ctx: E2eContextFile) => Promise<void>) => {
39 const contextPath = process.env[E2E_CONTEXT_PATH_ENV]!;
40 const raw = await readFile(contextPath, "utf8");
41 await use(JSON.parse(raw) as E2eContextFile);
42 },
43 
44 authenticatedPage: async ({ page }, use) => {
45 const { accessToken } = await loginPage(page);
46 await use({ page, accessToken });
47 },
48 
49 e2eWorkspace: async ({ authenticatedPage }, use) => {
50 const workspace = await createTestWorkspace(authenticatedPage.page, authenticatedPage.accessToken);
51 await use(workspace);
52 },
53});
54 
55export { expect };
56 
57/**
58 * Drive a tessera sign-in via the browser context's request surface. The mock
59 * OIDC provider auto-approves the authorize request and the callback issues a
60 * refresh cookie, so by the time we call /auth/refresh the cookie jar is
61 * authenticated and we can capture the access token for downstream API helpers.
62 */
63export async function loginPage(page: Page): Promise<{ accessToken: string }> {
64 await runOidcFlow(page, "/");
65 return refreshAndCaptureToken(page);
66}
67 
68export interface FreshTesseraIdentity {
69 sub: string;
70 email: string;
71 name?: string;
72}
73 
74/**
75 * Configure the mock OIDC provider to return the given identity for the next
76 * authorization request, then drive a sign-in. Used by specs that exercise
77 * first-time-login or sub-swap behavior without colliding with the baseline
78 * identity seeded in global-setup.
79 */
80export async function loginAsFreshTesseraUser(
81 page: Page,
82 identity: FreshTesseraIdentity,
83 returnTo = "/",
84): Promise<{ accessToken: string }> {
85 await setMockOidcIdentity(page, { ...identity, email_verified: true });
86 await runOidcFlow(page, returnTo);
87 return refreshAndCaptureToken(page);
88}
89 
90async function runOidcFlow(page: Page, returnTo: string): Promise<void> {
91 // page.goto exercises the real browser cookie jar, which treats
92 // http://127.0.0.1 as a secure context and accepts __Host- cookies. The
93 // page-context request API does not, so it would drop the tx cookie.
94 const startUrl = `/api/v1/oidc/start?return_to=${encodeURIComponent(returnTo)}`;
95 const response = await page.goto(startUrl, { waitUntil: "load" });
96 if (!response) {
97 throw new Error("OIDC flow returned no response");
98 }
99 if (!response.ok()) {
100 throw new Error(`OIDC flow failed: ${response.status()} ${await response.text()}`);
101 }
102}
103 
104async function refreshAndCaptureToken(page: Page): Promise<{ accessToken: string }> {
105 // Run the refresh from inside the page so the browser sends the Secure
106 // bland_refresh cookie. page.request would otherwise drop the cookie since
107 // Node fetch does not honor the loopback secure-context exception.
108 const result = (await page.evaluate(async () => {
109 const res = await fetch("/api/v1/auth/refresh", { method: "POST", credentials: "include" });
110 const text = await res.text();
111 return { ok: res.ok, status: res.status, body: text };
112 })) as { ok: boolean; status: number; body: string };
113 
114 if (!result.ok) {
115 throw new Error(`Auth refresh failed: ${result.status} ${result.body}`);
116 }
117 const parsed = JSON.parse(result.body) as { accessToken: string };
118 return { accessToken: parsed.accessToken };
119}
120 
121async function setMockOidcIdentity(
122 page: Page,
123 identity: { sub: string; email: string; name?: string; email_verified: boolean },
124): Promise<void> {
125 const contextPath = process.env[E2E_CONTEXT_PATH_ENV]!;
126 const raw = await readFile(contextPath, "utf8");
127 const ctx = JSON.parse(raw) as E2eContextFile;
128 const params = new URLSearchParams({
129 sub: identity.sub,
130 email: identity.email,
131 email_verified: identity.email_verified ? "true" : "false",
132 });
133 if (identity.name) params.set("name", identity.name);
134 const res = await page.request.get(`${ctx.oidcIssuer}/__test/identity?${params.toString()}`);
135 if (!res.ok()) {
136 throw new Error(`Failed to set mock OIDC identity: ${res.status()} ${await res.text()}`);
137 }
138}
139 
140function authHeaders(token: string): Record<string, string> {
141 return { Authorization: `Bearer ${token}` };
142}
143 
144async function getWorkspaceBySlug(page: Page, accessToken: string, workspaceSlug: string): Promise<TestWorkspace> {
145 const wsRes = await page.request.get("/api/v1/workspaces", {
146 headers: authHeaders(accessToken),
147 });
148 if (!wsRes.ok()) throw new Error(`Failed to list workspaces: ${wsRes.status()}`);
149 const wsData = (await wsRes.json()) as { workspaces: Array<{ id: string; slug: string }> };
150 const workspace = wsData.workspaces.find((candidate) => candidate.slug === workspaceSlug);
151 if (!workspace) throw new Error(`Workspace not found: ${workspaceSlug}`);
152 
153 return {
154 workspaceId: workspace.id,
155 workspaceSlug: workspace.slug,
156 };
157}
158 
159export async function createTestWorkspace(
160 page: Page,
161 accessToken: string,
162 namePrefix = "E2E Test Workspace",
163): Promise<TestWorkspace> {
164 const uniqueSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
165 const workspaceName = `${namePrefix} ${uniqueSuffix}`;
166 const workspaceSlug = `e2e-${uniqueSuffix}`;
167 const res = await page.request.post("/api/v1/workspaces", {
168 data: {
169 name: workspaceName,
170 slug: workspaceSlug,
171 },
172 headers: authHeaders(accessToken),
173 });
174 if (!res.ok()) throw new Error(`Failed to create workspace: ${res.status()}`);
175 const body = (await res.json()) as { workspace: { id: string; slug: string } };
176 
177 return {
178 workspaceId: body.workspace.id,
179 workspaceSlug: body.workspace.slug,
180 };
181}
182 
183/** Create a page via API and return its metadata. */
184export async function createTestPage(
185 page: Page,
186 accessToken: string,
187 title?: string,
188 workspace?: TestWorkspace,
189 kind: "doc" | "canvas" = "doc",
190): Promise<TestPage> {
191 const targetWorkspace = workspace ?? (await getWorkspaceBySlug(page, accessToken, TEST_CREDENTIALS.workspaceSlug));
192 
193 // Create a page
194 const pageRes = await page.request.post(`/api/v1/workspaces/${targetWorkspace.workspaceId}/pages`, {
195 data: { kind, title: title ?? `E2E Test Page ${Date.now()}` },
196 headers: authHeaders(accessToken),
197 });
198 if (!pageRes.ok()) throw new Error(`Failed to create page: ${pageRes.status()}`);
199 const pageData = (await pageRes.json()) as { page: { id: string } };
200 
201 return {
202 pageId: pageData.page.id,
203 workspaceId: targetWorkspace.workspaceId,
204 workspaceSlug: targetWorkspace.workspaceSlug,
205 };
206}
207 
208/** Create a share link on a page and return the token. */
209export async function createShareLink(
210 page: Page,
211 accessToken: string,
212 pageId: string,
213 permission: "view" | "edit",
214): Promise<ShareLink> {
215 const res = await page.request.post(`/api/v1/pages/${pageId}/share`, {
216 data: {
217 grantee_type: "link",
218 permission,
219 },
220 headers: authHeaders(accessToken),
221 });
222 if (!res.ok()) throw new Error(`Failed to create share: ${res.status()}`);
223 const data = (await res.json()) as { share: { link_token: string } };
224 
225 return { token: data.share.link_token, permission };
226}
227 
228export async function waitForDocEditorReady(page: Page, options: { editable?: boolean; connected?: boolean } = {}) {
229 const selector =
230 options.editable === undefined ? ".tiptap" : `.tiptap[contenteditable='${options.editable ? "true" : "false"}']`;
231 const editor = page.locator(selector).first();
232 await editor.waitFor({ timeout: 30_000 });
233 if (options.connected) {
234 await expect(page.getByText("Connected")).toBeVisible({ timeout: 15_000 });
235 }
236 return editor;
237}
238 
239export async function waitForPersistedSnapshot(
240 page: Page,
241 accessToken: string,
242 options: { workspaceId: string; pageId: string; minBytes?: number; expectedText?: string | string[] },
243): Promise<void> {
244 const minBytes = options.minBytes ?? 1;
245 const expectedTexts =
246 typeof options.expectedText === "string"
247 ? [options.expectedText]
248 : (options.expectedText ?? []).filter((text) => text.length > 0);
249 
250 const readSnapshot = async (): Promise<{ byteLength: number; text: string } | null> => {
251 const res = await page.request.get(`/api/v1/workspaces/${options.workspaceId}/pages/${options.pageId}/snapshot`, {
252 headers: authHeaders(accessToken),
253 });
254 if (res.status() === 204) return null;
255 if (res.status() !== 200) {
256 throw new Error(`Snapshot poll failed: ${res.status()} ${await res.text()}`);
257 }
258 const body = await res.body();
259 return { byteLength: body.byteLength, text: expectedTexts.length > 0 ? extractSnapshotDocumentText(body) : "" };
260 };
261 
262 if (expectedTexts.length > 0) {
263 await expect
264 .poll(
265 async () => {
266 const snapshot = await readSnapshot();
267 if (!snapshot || snapshot.byteLength < minBytes) return false;
268 return expectedTexts.every((text) => snapshot.text.includes(text));
269 },
270 {
271 timeout: 30_000,
272 intervals: [500, 1000, 1500, 2000],
273 message: `persisted snapshot should include ${expectedTexts.join(", ")}`,
274 },
275 )
276 .toBe(true);
277 return;
278 }
279 
280 await expect
281 .poll(
282 async () => {
283 const snapshot = await readSnapshot();
284 return snapshot?.byteLength ?? 0;
285 },
286 { timeout: 30_000, intervals: [500, 1000, 1500, 2000] },
287 )
288 .toBeGreaterThanOrEqual(minBytes);
289}
290 
291function extractSnapshotDocumentText(bytes: Uint8Array): string {
292 const doc = new Y.Doc();
293 try {
294 Y.applyUpdate(doc, bytes);
295 return extractPlaintext(doc).bodyText;
296 } finally {
297 doc.destroy();
298 }
299}
300 
301export async function waitForTitleProjection(
302 page: Page,
303 accessToken: string,
304 options: { workspaceId: string; pageId: string; title: string },
305): Promise<void> {
306 await expect
307 .poll(
308 async () => {
309 const res = await page.request.get(`/api/v1/workspaces/${options.workspaceId}/pages/${options.pageId}`, {
310 headers: authHeaders(accessToken),
311 });
312 if (res.status() !== 200) {
313 throw new Error(`Title projection poll failed: ${res.status()} ${await res.text()}`);
314 }
315 const body = (await res.json()) as { page: { title: string | null } };
316 return body.page.title;
317 },
318 { timeout: 30_000, intervals: [500, 1000, 1500, 2000] },
319 )
320 .toBe(options.title);
321}
322 
323export async function waitForCanvasSceneCount(page: Page, minCount: number): Promise<void> {
324 await expect
325 .poll(
326 () =>
327 page.evaluate(() => {
328 const fn = (window as unknown as { __E2E_CANVAS_SCENE_COUNT__?: () => number }).__E2E_CANVAS_SCENE_COUNT__;
329 return typeof fn === "function" ? fn() : null;
330 }),
331 { timeout: 15_000 },
332 )
333 .toBeGreaterThanOrEqual(minCount);
334}
335 
336export async function expectNoChangeFor<T>(readValue: () => Promise<T> | T, durationMs = 1_000): Promise<void> {
337 const initial = await readValue();
338 const deadline = Date.now() + durationMs;
339 
340 while (Date.now() < deadline) {
341 await new Promise((resolve) => setTimeout(resolve, Math.min(100, Math.max(0, deadline - Date.now()))));
342 const next = await readValue();
343 if (!isDeepStrictEqual(next, initial)) {
344 throw new Error(`Expected value to remain unchanged for ${durationMs}ms.`);
345 }
346 }
347}