File
Blob: tests/e2e/specs/10-shared-rapid-navigation.spec.ts
| 1 | import type { Page as PlaywrightPage } from "@playwright/test"; |
| 2 | import { |
| 3 | test, |
| 4 | expect, |
| 5 | createTestPage, |
| 6 | createShareLink, |
| 7 | waitForPersistedSnapshot, |
| 8 | type TestWorkspace, |
| 9 | } from "../fixtures/bland-test"; |
| 10 | |
| 11 | interface SharedPageNode { |
| 12 | id: string; |
| 13 | title: string; |
| 14 | } |
| 15 | |
| 16 | interface 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 | |
| 30 | const ROOT_TITLE = "Shared Root"; |
| 31 | const ROOT_BODY_TEXT = "Root remains view only"; |
| 32 | const CHILD_TITLES = ["Child Alpha", "Child Beta", "Child Gamma"] as const; |
| 33 | const SHARED_PAGE_METADATA_DELAY_MS = 1_200; |
| 34 | |
| 35 | async 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 | |
| 54 | async 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 | |
| 64 | function sharedTitleField(page: PlaywrightPage) { |
| 65 | return page.locator("main textarea[placeholder='Untitled']"); |
| 66 | } |
| 67 | |
| 68 | function 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 | |
| 76 | function 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 | |
| 84 | async 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 | |
| 109 | test.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 | }); |