File
Blob: tests/e2e/specs/13-document-outline.spec.ts
| 1 | import type { Page as PlaywrightPage } from "@playwright/test"; |
| 2 | import { test, expect, createTestPage, createShareLink, waitForPersistedSnapshot } from "../fixtures/bland-test"; |
| 3 | |
| 4 | const MOBILE_VIEWPORT = { width: 900, height: 900 }; |
| 5 | // Wide viewport where expanded workspace layout can place the doc-owned |
| 6 | // outline beside the main column instead of inline. |
| 7 | const RAIL_VIEWPORT = { width: 1440, height: 900 }; |
| 8 | const OUTLINE_FILLER = |
| 9 | "Document outline filler text keeps the section tall enough to exercise viewport-based heading visibility. ".repeat( |
| 10 | 10, |
| 11 | ); |
| 12 | |
| 13 | async function insertHeading(page: PlaywrightPage, text: string) { |
| 14 | await page.keyboard.type(`# ${text}`); |
| 15 | await page.keyboard.press("Enter"); |
| 16 | } |
| 17 | |
| 18 | async function insertParagraphs(page: PlaywrightPage, prefix: string, count: number) { |
| 19 | for (let i = 1; i <= count; i += 1) { |
| 20 | await page.keyboard.insertText(`${prefix} paragraph ${i}. ${OUTLINE_FILLER}`); |
| 21 | await page.keyboard.press("Enter"); |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | async function seedOutlineDocument(page: PlaywrightPage) { |
| 26 | const editor = page.locator(".tiptap[contenteditable='true']"); |
| 27 | await editor.waitFor({ timeout: 30_000 }); |
| 28 | await editor.click(); |
| 29 | |
| 30 | await insertHeading(page, "Introduction"); |
| 31 | await insertParagraphs(page, "Introduction", 5); |
| 32 | await insertHeading(page, "Second section"); |
| 33 | await insertParagraphs(page, "Second section", 3); |
| 34 | |
| 35 | await expect(page.locator(".tiptap h1")).toHaveCount(2); |
| 36 | await expect(page.locator(".tiptap h1").filter({ hasText: "Introduction" })).toBeVisible(); |
| 37 | await expect(page.locator(".tiptap h1").filter({ hasText: "Second section" })).toHaveCount(1); |
| 38 | await expect(page.getByText("Connected")).toBeVisible({ timeout: 15_000 }); |
| 39 | |
| 40 | return editor; |
| 41 | } |
| 42 | |
| 43 | async function setWorkspaceLayout(page: PlaywrightPage, expanded: boolean) { |
| 44 | const button = page.getByRole("button", { name: expanded ? "Expand layout" : "Center layout" }); |
| 45 | if ((await button.count()) === 0) return; |
| 46 | await button.evaluate((node) => { |
| 47 | if (node instanceof HTMLElement) node.click(); |
| 48 | }); |
| 49 | } |
| 50 | |
| 51 | async function expectInlineOutline(page: PlaywrightPage) { |
| 52 | await expect(page.locator("aside[aria-label='Document outline']")).toHaveCount(0); |
| 53 | await expect(page.locator(".tiptap-outline")).toHaveCount(1); |
| 54 | await expect(page.locator(".tiptap-outline--rail")).toHaveCount(0); |
| 55 | } |
| 56 | |
| 57 | async function expectRailOutline(page: PlaywrightPage) { |
| 58 | const rail = page.locator("aside[aria-label='Document outline']"); |
| 59 | await expect(rail).toHaveCount(1); |
| 60 | await expect(rail.locator(".tiptap-outline--rail")).toBeVisible(); |
| 61 | await expect(page.locator(".tiptap-outline")).toHaveCount(1); |
| 62 | } |
| 63 | |
| 64 | async function scrollHeadingIntoView(page: PlaywrightPage, text: string) { |
| 65 | const heading = page.locator(".tiptap h1").filter({ hasText: text }); |
| 66 | await heading.evaluate((node) => { |
| 67 | node.scrollIntoView({ block: "start" }); |
| 68 | }); |
| 69 | } |
| 70 | |
| 71 | async function placeCaretInParagraph(page: PlaywrightPage, text: string) { |
| 72 | const success = await page.evaluate((targetText) => { |
| 73 | const editor = document.querySelector(".tiptap[contenteditable='true']"); |
| 74 | if (!(editor instanceof HTMLElement)) return false; |
| 75 | |
| 76 | const paragraph = Array.from(editor.querySelectorAll("p")).find((node) => node.textContent?.includes(targetText)); |
| 77 | if (!(paragraph instanceof HTMLParagraphElement)) return false; |
| 78 | |
| 79 | const walker = document.createTreeWalker(paragraph, NodeFilter.SHOW_TEXT); |
| 80 | const textNode = walker.nextNode(); |
| 81 | if (!(textNode instanceof Text)) return false; |
| 82 | |
| 83 | const range = document.createRange(); |
| 84 | range.setStart(textNode, Math.min(1, textNode.textContent?.length ?? 0)); |
| 85 | range.collapse(true); |
| 86 | |
| 87 | editor.focus({ preventScroll: true }); |
| 88 | const selection = window.getSelection(); |
| 89 | selection?.removeAllRanges(); |
| 90 | selection?.addRange(range); |
| 91 | document.dispatchEvent(new Event("selectionchange")); |
| 92 | return true; |
| 93 | }, text); |
| 94 | |
| 95 | expect(success).toBe(true); |
| 96 | } |
| 97 | |
| 98 | async function activeOutlineHeading(page: PlaywrightPage) { |
| 99 | const buttons = page.locator(".tiptap-outline__button[data-active='true'] .tiptap-outline__text"); |
| 100 | const count = await buttons.count(); |
| 101 | if (count === 0) return null; |
| 102 | return (await buttons.first().textContent())?.trim() ?? null; |
| 103 | } |
| 104 | |
| 105 | async function expectActiveOutlineHeading(page: PlaywrightPage, text: string) { |
| 106 | await expect.poll(() => activeOutlineHeading(page), { timeout: 10_000 }).toBe(text); |
| 107 | await expect(page.locator(".tiptap-outline__button[data-active='true']")).toHaveCount(1); |
| 108 | } |
| 109 | |
| 110 | test.describe("document outline", () => { |
| 111 | test("workspace outline stays inline in narrow layout and becomes a side rail in expanded layout on wide screens", async ({ |
| 112 | authenticatedPage: { page, accessToken }, |
| 113 | e2eWorkspace, |
| 114 | }) => { |
| 115 | await page.setViewportSize(MOBILE_VIEWPORT); |
| 116 | |
| 117 | const testPage = await createTestPage(page, accessToken, "Outline Placement Test", e2eWorkspace); |
| 118 | await page.goto(`/${testPage.workspaceSlug}/${testPage.pageId}`); |
| 119 | |
| 120 | await seedOutlineDocument(page); |
| 121 | await expectInlineOutline(page); |
| 122 | |
| 123 | await page.setViewportSize(RAIL_VIEWPORT); |
| 124 | await expectInlineOutline(page); |
| 125 | |
| 126 | await setWorkspaceLayout(page, true); |
| 127 | await expectRailOutline(page); |
| 128 | |
| 129 | await setWorkspaceLayout(page, false); |
| 130 | await expectInlineOutline(page); |
| 131 | |
| 132 | await page.setViewportSize(MOBILE_VIEWPORT); |
| 133 | await expectInlineOutline(page); |
| 134 | }); |
| 135 | |
| 136 | test("read-only shared outline tracks the heading that is visible in the viewport", async ({ |
| 137 | authenticatedPage: { page, accessToken }, |
| 138 | e2eWorkspace, |
| 139 | browser, |
| 140 | }) => { |
| 141 | await page.setViewportSize(RAIL_VIEWPORT); |
| 142 | |
| 143 | const testPage = await createTestPage(page, accessToken, "Outline Visibility Test", e2eWorkspace); |
| 144 | await page.goto(`/${testPage.workspaceSlug}/${testPage.pageId}`); |
| 145 | await seedOutlineDocument(page); |
| 146 | await waitForPersistedSnapshot(page, accessToken, { |
| 147 | ...testPage, |
| 148 | expectedText: ["Introduction", "Second section"], |
| 149 | }); |
| 150 | |
| 151 | const share = await createShareLink(page, accessToken, testPage.pageId, "view"); |
| 152 | |
| 153 | const anonContext = await browser.newContext({ viewport: RAIL_VIEWPORT }); |
| 154 | const anonPage = await anonContext.newPage(); |
| 155 | |
| 156 | try { |
| 157 | await anonPage.goto(`/s/${share.token}`); |
| 158 | |
| 159 | const sharedEditor = anonPage.locator(".tiptap[contenteditable='false']"); |
| 160 | await sharedEditor.waitFor({ timeout: 30_000 }); |
| 161 | await expectRailOutline(anonPage); |
| 162 | |
| 163 | await expectActiveOutlineHeading(anonPage, "Introduction"); |
| 164 | |
| 165 | await scrollHeadingIntoView(anonPage, "Second section"); |
| 166 | |
| 167 | await expectActiveOutlineHeading(anonPage, "Second section"); |
| 168 | |
| 169 | await scrollHeadingIntoView(anonPage, "Introduction"); |
| 170 | |
| 171 | await expectActiveOutlineHeading(anonPage, "Introduction"); |
| 172 | } finally { |
| 173 | await anonContext.close(); |
| 174 | } |
| 175 | }); |
| 176 | |
| 177 | test("focused workspace outline prioritizes selection over viewport visibility", async ({ |
| 178 | authenticatedPage: { page, accessToken }, |
| 179 | e2eWorkspace, |
| 180 | }) => { |
| 181 | await page.setViewportSize(RAIL_VIEWPORT); |
| 182 | |
| 183 | const testPage = await createTestPage(page, accessToken, "Outline Selection Priority Test", e2eWorkspace); |
| 184 | await page.goto(`/${testPage.workspaceSlug}/${testPage.pageId}`); |
| 185 | await seedOutlineDocument(page); |
| 186 | await setWorkspaceLayout(page, true); |
| 187 | await expectRailOutline(page); |
| 188 | |
| 189 | await scrollHeadingIntoView(page, "Second section"); |
| 190 | await expectActiveOutlineHeading(page, "Second section"); |
| 191 | |
| 192 | await placeCaretInParagraph(page, "Introduction paragraph 1."); |
| 193 | await expect(page.locator(".tiptap h1").filter({ hasText: "Second section" })).toBeVisible(); |
| 194 | await expectActiveOutlineHeading(page, "Introduction"); |
| 195 | }); |
| 196 | }); |