Skip to content
File

Blob: tests/e2e/specs/10-shared-rapid-navigation.spec.ts

typescript380 lines
1import type { Page as PlaywrightPage } from "@playwright/test";
2import {
3 test,
4 expect,
5 createTestPage,
6 createShareLink,
7 waitForPersistedSnapshot,
8 type TestWorkspace,
9} from "../fixtures/bland-test";
10 
11interface SharedPageNode {
12 id: string;
13 title: string;
14}
15 
16interface SharedNavigationFixture {
17 root: {
18 pageId: string;
19 workspaceId: string;
20 workspaceSlug: string;
21 };
22 share: {
23 token: string;
24 permission: "view" | "edit";
25 };
26 children: [SharedPageNode, SharedPageNode, SharedPageNode];
27 rootBodyText: string;
28}
29 
30const ROOT_TITLE = "Shared Root";
31const ROOT_BODY_TEXT = "Root remains view only";
32const CHILD_TITLES = ["Child Alpha", "Child Beta", "Child Gamma"] as const;
33const SHARED_PAGE_METADATA_DELAY_MS = 1_200;
34 
35async function createChildPage(
36 page: PlaywrightPage,
37 accessToken: string,
38 workspaceId: string,
39 parentId: string,
40 title: string,
41): Promise<SharedPageNode> {
42 const res = await page.request.post(`/api/v1/workspaces/${workspaceId}/pages`, {
43 data: { title, parent_id: parentId },
44 headers: { Authorization: `Bearer ${accessToken}` },
45 });
46 if (!res.ok()) {
47 throw new Error(`Failed to create child page "${title}": ${res.status()}`);
48 }
49 
50 const data = (await res.json()) as { page: { id: string } };
51 return { id: data.page.id, title };
52}
53 
54async function expectSharedUrl(page: PlaywrightPage, token: string, pageId?: string) {
55 await expect.poll(() => new URL(page.url()).pathname, { timeout: 10_000 }).toBe(`/s/${token}`);
56 await expect
57 .poll(() => new URL(page.url()).searchParams.get("page"), {
58 timeout: 10_000,
59 message: pageId ? `URL should settle on shared child ${pageId}` : "URL should drop ?page= on shared root",
60 })
61 .toBe(pageId ?? null);
62}
63 
64function sharedTitleField(page: PlaywrightPage) {
65 return page.locator("main textarea[placeholder='Untitled']");
66}
67 
68function isSharedPageMetadataRequest(url: string, workspaceId: string, pageId: string, shareToken: string): boolean {
69 const parsed = new URL(url);
70 return (
71 parsed.pathname === `/api/v1/workspaces/${workspaceId}/pages/${pageId}` &&
72 parsed.searchParams.get("share") === shareToken
73 );
74}
75 
76function isSharedPageAncestorsRequest(url: string, workspaceId: string, pageId: string, shareToken: string): boolean {
77 const parsed = new URL(url);
78 return (
79 parsed.pathname === `/api/v1/workspaces/${workspaceId}/pages/${pageId}/ancestors` &&
80 parsed.searchParams.get("share") === shareToken
81 );
82}
83 
84async function setupSharedNavigationFixture(
85 page: PlaywrightPage,
86 accessToken: string,
87 workspace: TestWorkspace,
88): Promise<SharedNavigationFixture> {
89 const root = await createTestPage(page, accessToken, ROOT_TITLE, workspace);
90 const children = (await Promise.all(
91 CHILD_TITLES.map((title) => createChildPage(page, accessToken, root.workspaceId, root.pageId, title)),
92 )) as [SharedPageNode, SharedPageNode, SharedPageNode];
93 
94 await page.goto(`/${root.workspaceSlug}/${root.pageId}`);
95 
96 const rootEditor = page.locator(".tiptap[contenteditable='true']");
97 await rootEditor.waitFor({ timeout: 30_000 });
98 await rootEditor.click();
99 await page.keyboard.type(ROOT_BODY_TEXT);
100 await expect(rootEditor).toContainText(ROOT_BODY_TEXT);
101 await expect(page.getByText("Connected")).toBeVisible({ timeout: 15_000 });
102 await waitForPersistedSnapshot(page, accessToken, { ...root, expectedText: ROOT_BODY_TEXT });
103 
104 const share = await createShareLink(page, accessToken, root.pageId, "view");
105 
106 return { root, share, children, rootBodyText: ROOT_BODY_TEXT };
107}
108 
109test.describe("rapid page navigation - shared view", () => {
110 test("anonymous shared navigation settles without freezing", async ({
111 authenticatedPage: { page, accessToken },
112 e2eWorkspace,
113 browser,
114 }) => {
115 const { share, children } = await setupSharedNavigationFixture(page, accessToken, e2eWorkspace);
116 const [childAlpha, childBeta] = children;
117 
118 const anonContext = await browser.newContext();
119 const anonPage = await anonContext.newPage();
120 
121 const pageErrors: string[] = [];
122 anonPage.on("pageerror", (err) => pageErrors.push(err.message));
123 
124 await anonPage.goto(`/s/${share.token}`);
125 
126 const sharedEditor = anonPage.locator(".tiptap");
127 const sharedTitle = sharedTitleField(anonPage);
128 await sharedEditor.waitFor({ timeout: 30_000 });
129 await expectSharedUrl(anonPage, share.token);
130 await expect(sharedTitle).toHaveValue(ROOT_TITLE, { timeout: 10_000 });
131 
132 const childAlphaLink = anonPage.locator("button").filter({ hasText: childAlpha.title }).first();
133 const childBetaLink = anonPage.locator("button").filter({ hasText: childBeta.title }).first();
134 await childAlphaLink.waitFor({ timeout: 15_000 });
135 
136 await childAlphaLink.click();
137 await expectSharedUrl(anonPage, share.token, childAlpha.id);
138 
139 await childBetaLink.click();
140 await expectSharedUrl(anonPage, share.token, childBeta.id);
141 
142 await sharedEditor.waitFor({ timeout: 30_000 });
143 await expect(sharedTitle).toHaveValue(childBeta.title, { timeout: 10_000 });
144 
145 const rootLink = anonPage.locator("button").filter({ hasText: ROOT_TITLE }).first();
146 await rootLink.click();
147 
148 await expectSharedUrl(anonPage, share.token);
149 await expect(sharedTitle).toHaveValue(ROOT_TITLE, { timeout: 10_000 });
150 await expect(sharedEditor).toBeVisible({ timeout: 15_000 });
151 expect(pageErrors).toEqual([]);
152 
153 await anonContext.close();
154 });
155 
156 test("direct nested shared load expands the active sidebar branch", async ({
157 authenticatedPage: { page, accessToken },
158 e2eWorkspace,
159 browser,
160 }) => {
161 const { root, share, children } = await setupSharedNavigationFixture(page, accessToken, e2eWorkspace);
162 const [parent] = children;
163 const grandchild = await createChildPage(
164 page,
165 accessToken,
166 root.workspaceId,
167 parent.id,
168 "Nested Auto Expand Target",
169 );
170 const leaf = await createChildPage(page, accessToken, root.workspaceId, grandchild.id, "Nested Auto Expand Leaf");
171 
172 const anonContext = await browser.newContext();
173 const anonPage = await anonContext.newPage();
174 
175 const pageErrors: string[] = [];
176 anonPage.on("pageerror", (err) => pageErrors.push(err.message));
177 const ancestorRequests: string[] = [];
178 anonPage.on("request", (request) => {
179 if (isSharedPageAncestorsRequest(request.url(), root.workspaceId, grandchild.id, share.token)) {
180 ancestorRequests.push(request.url());
181 }
182 });
183 
184 await anonPage.goto(`/s/${share.token}?page=${grandchild.id}`);
185 
186 const sharedEditor = anonPage.locator(".tiptap");
187 const sharedTitle = sharedTitleField(anonPage);
188 await sharedEditor.waitFor({ timeout: 30_000 });
189 await expectSharedUrl(anonPage, share.token, grandchild.id);
190 await expect(sharedTitle).toHaveValue(grandchild.title, { timeout: 10_000 });
191 
192 const sidebarButton = (title: string) => anonPage.locator("nav button").filter({ hasText: title }).first();
193 await expect(sidebarButton(parent.title)).toBeVisible({ timeout: 15_000 });
194 await expect(sidebarButton(grandchild.title)).toBeVisible({ timeout: 15_000 });
195 await expect(sidebarButton(leaf.title)).toBeVisible({ timeout: 15_000 });
196 expect(ancestorRequests).toHaveLength(1);
197 expect(pageErrors).toEqual([]);
198 
199 await anonContext.close();
200 });
201 
202 test("shared sidebar preserves expanded branches across navigation", async ({
203 authenticatedPage: { page, accessToken },
204 e2eWorkspace,
205 browser,
206 }) => {
207 const { root, share, children } = await setupSharedNavigationFixture(page, accessToken, e2eWorkspace);
208 const [parent, sibling] = children;
209 const nested = await createChildPage(page, accessToken, root.workspaceId, parent.id, "Persistent Sidebar Nested");
210 
211 const anonContext = await browser.newContext();
212 const anonPage = await anonContext.newPage();
213 
214 const pageErrors: string[] = [];
215 anonPage.on("pageerror", (err) => pageErrors.push(err.message));
216 
217 await anonPage.goto(`/s/${share.token}`);
218 
219 const sharedEditor = anonPage.locator(".tiptap");
220 const sharedTitle = sharedTitleField(anonPage);
221 await sharedEditor.waitFor({ timeout: 30_000 });
222 await expectSharedUrl(anonPage, share.token);
223 
224 const sidebarButton = (title: string) => anonPage.locator("nav button").filter({ hasText: title }).first();
225 const parentButton = sidebarButton(parent.title);
226 await expect(parentButton).toBeVisible({ timeout: 15_000 });
227 
228 await parentButton.locator("xpath=..").getByRole("button", { name: "Expand" }).click();
229 await expect(sidebarButton(nested.title)).toBeVisible({ timeout: 15_000 });
230 
231 await sidebarButton(sibling.title).click();
232 await expectSharedUrl(anonPage, share.token, sibling.id);
233 await expect(sharedTitle).toHaveValue(sibling.title, { timeout: 10_000 });
234 await expect(sidebarButton(nested.title)).toBeVisible({ timeout: 15_000 });
235 expect(pageErrors).toEqual([]);
236 
237 await anonContext.close();
238 });
239 
240 test("authenticated member on view-only share stays read-only end to end across root and subpages", async ({
241 authenticatedPage: { page, accessToken },
242 e2eWorkspace,
243 }) => {
244 // Regression: workspace members opening their own view-only share link
245 // used to flip subpages into an editable state because `GET /pages/:id`
246 // resolved them via the canonical member fast path. Share semantics must
247 // win on `/s/:token`, even for members.
248 const { share, children, rootBodyText } = await setupSharedNavigationFixture(page, accessToken, e2eWorkspace);
249 const [childAlpha, childBeta, childGamma] = children;
250 
251 const pageErrors: string[] = [];
252 page.on("pageerror", (err) => pageErrors.push(err.message));
253 
254 await page.goto(`/s/${share.token}`);
255 
256 const rootEditor = page.locator(".tiptap[contenteditable='false']");
257 const sharedTitle = sharedTitleField(page);
258 await rootEditor.waitFor({ timeout: 30_000 });
259 await expectSharedUrl(page, share.token);
260 await expect(sharedTitle).toHaveValue(ROOT_TITLE, { timeout: 10_000 });
261 await expect(rootEditor).toContainText(rootBodyText);
262 
263 const rootContentBefore = await rootEditor.textContent();
264 await rootEditor.click();
265 await page.keyboard.type("should not appear on root");
266 await expect(rootEditor).toHaveAttribute("contenteditable", "false");
267 expect(await rootEditor.textContent()).toBe(rootContentBefore);
268 
269 const childAlphaLink = page.locator("button").filter({ hasText: childAlpha.title }).first();
270 const childBetaLink = page.locator("button").filter({ hasText: childBeta.title }).first();
271 const childGammaLink = page.locator("button").filter({ hasText: childGamma.title }).first();
272 await childAlphaLink.waitFor({ timeout: 15_000 });
273 
274 await childAlphaLink.click();
275 await expectSharedUrl(page, share.token, childAlpha.id);
276 
277 await childBetaLink.click();
278 await childGammaLink.click();
279 await expectSharedUrl(page, share.token, childGamma.id);
280 
281 // Child page is view-only too: the editor never flips to contenteditable=true.
282 const readOnlyChildEditor = page.locator("main .tiptap[contenteditable='false']");
283 await readOnlyChildEditor.waitFor({ timeout: 30_000 });
284 await expect(sharedTitle).toHaveValue(childGamma.title, { timeout: 10_000 });
285 await expect(page.locator("main .tiptap[contenteditable='true']")).toHaveCount(0);
286 
287 const childContentBefore = (await readOnlyChildEditor.textContent()) ?? "";
288 await readOnlyChildEditor.click();
289 await page.keyboard.type("Editable child content");
290 await expect(readOnlyChildEditor).toHaveAttribute("contenteditable", "false");
291 expect(await readOnlyChildEditor.textContent()).toBe(childContentBefore);
292 
293 // URL must stay shared-scoped; no jump into /$workspaceSlug/$pageId.
294 expect(new URL(page.url()).pathname).toBe(`/s/${share.token}`);
295 
296 const rootLink = page.locator("button").filter({ hasText: ROOT_TITLE }).first();
297 await rootLink.click();
298 
299 await expectSharedUrl(page, share.token);
300 const returnedRootEditor = page.locator(".tiptap[contenteditable='false']");
301 await returnedRootEditor.waitFor({ timeout: 30_000 });
302 await expect(sharedTitle).toHaveValue(ROOT_TITLE, { timeout: 10_000 });
303 await expect(returnedRootEditor).toContainText(rootBodyText);
304 expect(pageErrors).toEqual([]);
305 });
306 
307 test("authenticated member root-child-root races keep the shared root responsive", async ({
308 authenticatedPage: { page, accessToken },
309 e2eWorkspace,
310 }) => {
311 const { root, share, children, rootBodyText } = await setupSharedNavigationFixture(page, accessToken, e2eWorkspace);
312 const [delayedChild] = children;
313 
314 const pageErrors: string[] = [];
315 page.on("pageerror", (err) => pageErrors.push(err.message));
316 
317 await page.route("**/api/v1/workspaces/**", async (route) => {
318 if (route.request().method() !== "GET") {
319 await route.continue();
320 return;
321 }
322 
323 if (isSharedPageMetadataRequest(route.request().url(), root.workspaceId, delayedChild.id, share.token)) {
324 await new Promise<void>((resolve) => {
325 setTimeout(resolve, SHARED_PAGE_METADATA_DELAY_MS);
326 });
327 await route.continue();
328 return;
329 }
330 
331 await route.continue();
332 });
333 
334 await page.goto(`/s/${share.token}`);
335 
336 const rootEditor = page.locator(".tiptap[contenteditable='false']");
337 const sharedTitle = sharedTitleField(page);
338 const childLink = page.locator("button").filter({ hasText: delayedChild.title }).first();
339 const rootLink = page.locator("button").filter({ hasText: ROOT_TITLE }).first();
340 
341 await rootEditor.waitFor({ timeout: 30_000 });
342 await expectSharedUrl(page, share.token);
343 await expect(sharedTitle).toHaveValue(ROOT_TITLE, { timeout: 10_000 });
344 await expect(rootEditor).toContainText(rootBodyText);
345 await childLink.waitFor({ timeout: 15_000 });
346 
347 for (let attempt = 0; attempt < 3; attempt += 1) {
348 await childLink.click();
349 await expectSharedUrl(page, share.token, delayedChild.id);
350 
351 await rootLink.click();
352 await expectSharedUrl(page, share.token);
353 await expect(sharedTitle).toHaveValue(ROOT_TITLE, { timeout: 10_000 });
354 await expect(rootEditor).toBeVisible({ timeout: 30_000 });
355 }
356 
357 await rootEditor.waitFor({ timeout: 30_000 });
358 const rootContentBefore = await rootEditor.textContent();
359 await rootEditor.click();
360 await page.keyboard.type("still read only on root");
361 await expect(rootEditor).toHaveAttribute("contenteditable", "false");
362 expect(await rootEditor.textContent()).toBe(rootContentBefore);
363 
364 await childLink.click();
365 await expectSharedUrl(page, share.token, delayedChild.id);
366 
367 // Delayed child on a view-only share must stay read-only for the member.
368 const readOnlyDelayedChild = page.locator("main .tiptap[contenteditable='false']");
369 await readOnlyDelayedChild.waitFor({ timeout: 30_000 });
370 await expect(page.locator("main .tiptap[contenteditable='true']")).toHaveCount(0);
371 const childContentBefore = (await readOnlyDelayedChild.textContent()) ?? "";
372 await readOnlyDelayedChild.click();
373 await page.keyboard.type("Delayed child is still read only");
374 await expect(readOnlyDelayedChild).toHaveAttribute("contenteditable", "false");
375 expect(await readOnlyDelayedChild.textContent()).toBe(childContentBefore);
376 
377 expect(pageErrors).toEqual([]);
378 });
379});