Skip to content
File

Blob: tests/e2e/specs/13-document-outline.spec.ts

typescript197 lines
1import type { Page as PlaywrightPage } from "@playwright/test";
2import { test, expect, createTestPage, createShareLink, waitForPersistedSnapshot } from "../fixtures/bland-test";
3 
4const 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.
7const RAIL_VIEWPORT = { width: 1440, height: 900 };
8const OUTLINE_FILLER =
9 "Document outline filler text keeps the section tall enough to exercise viewport-based heading visibility. ".repeat(
10 10,
11 );
12 
13async function insertHeading(page: PlaywrightPage, text: string) {
14 await page.keyboard.type(`# ${text}`);
15 await page.keyboard.press("Enter");
16}
17 
18async 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 
25async 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 
43async 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 
51async 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 
57async 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 
64async 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 
71async 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 
98async 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 
105async 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 
110test.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});