import { content, expect, test } from "./room"; import { readDocument } from "./database"; import * as Room from "../apps/server/src/plan/room"; import { expectNoHorizontalOverflow, RESPONSIVE_IMAGE_URL, RESPONSIVE_SOURCE, RESPONSIVE_VIEWPORTS, } from "./responsive"; import type { Plan } from "../packages/protocol/index"; import type { Page } from "@playwright/test"; async function routeResponsiveImage(page: Page): Promise { await page.route(RESPONSIVE_IMAGE_URL, route => route.fulfill({ body: `Responsive workspace reference`, contentType: "image/svg+xml", })); } async function injectAgentChange( page: Page, checkpoint: { epoch: string; source: string; update: Uint8Array }, ) { let send: ((frame: Plan.Changes) => void) | undefined; await page.routeWebSocket("**/ws?**", route => { let server = route.connectToServer(); send = frame => route.send(JSON.stringify(frame)); route.onMessage(message => server.send(message)); server.onMessage(message => route.send(message)); }); return { async addedAtEnd() { let document = await Room.restore( checkpoint.epoch, checkpoint.update, checkpoint.source, [], ); try { let digests = Room.digests(document); let index = digests.length - 1; send?.({ kind: "plan:changes", ts: 0, epoch: checkpoint.epoch, changes: [{ kind: "added", at: Room.anchorAt(document, index, digests[index]!), preview: "AgentChangeExcerptThatIsDeliberatelyLongEnoughToRequireTheChangeListToScrollOrTruncateInsideItsOwnContainedChrome", type: "paragraph", }], }); } finally { document.doc.destroy(); } }, }; } async function expectInsideDocumentWidth( name: string, surface: ReturnType, ): Promise { await expect.poll(() => surface.count(), { message: `${name} must be rendered` }).toBeGreaterThan( 0, ); await expect.poll(async () => await surface.evaluateAll(nodes => { let documentBox = globalThis.document.querySelector("[data-plan-scroll]") ?.getBoundingClientRect(); if (!documentBox) return [{ reason: "missing document" }]; return nodes.flatMap(node => { let element = node as HTMLElement; let style = getComputedStyle(element); let rectangle = element.getBoundingClientRect(); let bounds = { document: { left: documentBox.left, right: documentBox.right }, left: rectangle.left, right: rectangle.right, }; if ( style.display === "none" || style.visibility === "hidden" || rectangle.width === 0 || rectangle.height === 0 ) return [{ ...bounds, reason: "not visible" }]; return rectangle.left >= documentBox.left && rectangle.right <= documentBox.right ? [] : [{ ...bounds, reason: "outside document" }]; }); }), { message: `${name} must have visible bounds inside the document` }).toEqual([]); } async function expectIntrinsicWidth( name: string, surface: ReturnType, ): Promise { let widths = await surface.evaluateAll(nodes => { let host = nodes[0]?.closest('[contenteditable="true"]'); if (!host) return []; let documentWidth = host.getBoundingClientRect().width; return nodes.map(node => { let copy = node.cloneNode(true) as HTMLElement; copy.style.setProperty("display", "inline-block", "important"); copy.style.setProperty("inline-size", "max-content", "important"); copy.style.setProperty("max-inline-size", "none", "important"); copy.style.setProperty("white-space", "nowrap", "important"); copy.style.setProperty("visibility", "hidden", "important"); copy.style.setProperty("position", "absolute", "important"); host.append(copy); let width = copy.getBoundingClientRect().width; copy.remove(); return { documentWidth, width }; }); }); expect(widths.length, `${name} must be rendered before measuring its intrinsic width`) .toBeGreaterThan(0); expect( widths.every(value => value.width > value.documentWidth), `${name} must be intrinsically wide`, ).toBe(true); } async function expectVisibleMermaidSvg(page: Page): Promise { let result = await content(page).locator("[data-plan-language='mermaid'] svg").evaluateAll( nodes => { let documentBox = globalThis.document.querySelector("[data-plan-scroll]") ?.getBoundingClientRect(); let visible = nodes.flatMap(node => { let element = node as SVGSVGElement; let style = getComputedStyle(element); let rectangle = element.getBoundingClientRect(); if ( style.display === "none" || style.visibility === "hidden" || rectangle.width === 0 || rectangle.height === 0 ) return []; return [{ inside: !!documentBox && rectangle.left >= documentBox.left && rectangle.right <= documentBox.right, intrinsicWidth: element.viewBox.baseVal.width || element.width.baseVal.value, documentWidth: documentBox?.width ?? 0, }]; }); return { all: nodes.length, visible }; }, ); expect(result.all, "Mermaid must create SVG output").toBeGreaterThan(0); expect(result.visible.length, "Mermaid must paint a visible SVG").toBeGreaterThan(0); expect(result.visible.every(value => value.inside), "visible Mermaid SVGs must fit the document") .toBe(true); expect( result.visible.some(value => value.intrinsicWidth > value.documentWidth), "Mermaid fixture must be intrinsically wider than the document", ).toBe(true); } async function expectOwnScroller( name: string, surface: ReturnType, ): Promise { expect(await surface.count(), `${name} must be rendered`).toBeGreaterThan(0); expect( await surface.evaluateAll(nodes => nodes.every(node => node.scrollWidth > node.clientWidth)), `${name} must overflow internally`, ).toBe(true); } type Join = (handle: string) => Promise; type Seed = (source: string) => Promise; const LONG_HANDLE = "collaborator-with-a-name-long-enough-to-overflow-the-contained-cursor-label"; async function expectRepresentativeSurfaces( baseURL: string, join: Join, page: Page, room: string, seed: Seed, ): Promise { await routeResponsiveImage(page); await seed(RESPONSIVE_SOURCE); let checkpoint = await readDocument(Number(new URL(baseURL).port), room); let changes = await injectAgentChange(page, checkpoint); let ana = await join("ana"); let document = content(ana); let surfaces = [ { name: "long link", locator: document.getByRole("link", { name: "TheCompleteArchitectureDecisionRecordForResponsiveWorkspaceContainmentMustWrapWithoutWideningTheDocument", }), }, { name: "wide image", locator: document.getByRole("img", { name: "Responsive workspace reference" }), }, { name: "Mermaid preview", locator: document.locator("[data-plan-language='mermaid'] [data-plan-preview]"), }, { name: "display math", locator: document.locator('[data-plan-inline="false"]') }, { name: "tab strip", locator: document.getByRole("tablist") }, { name: "tab panels", locator: document.locator("[data-plan-panels]") }, { name: "callout", locator: document.locator("[data-plan-type='warning']") }, { name: "callout title", locator: document.getByRole("textbox", { name: "Callout title" }), }, { name: "inline code", locator: document.locator(":not(pre) > code") }, ]; for (let surface of surfaces) await expectInsideDocumentWidth(surface.name, surface.locator); await expectIntrinsicWidth("long link", surfaces[0]!.locator); await expectIntrinsicWidth("inline code", surfaces.at(-1)!.locator); let image = surfaces[1]!.locator; expect( await image.evaluate(node => { let documentWidth = node.closest("[data-plan-scroll]")!.getBoundingClientRect().width; return (node as HTMLImageElement).naturalWidth > documentWidth; }), "image fixture must be intrinsically wider than the document", ).toBe(true); await expectVisibleMermaidSvg(ana); await expectOwnScroller( "display math", document.locator('[data-plan-inline="false"] [data-plan-preview]'), ); await expectOwnScroller("tab strip", document.getByRole("tablist")); let table = document.locator("table"); await expect(table).toHaveCount(1); await expectOwnScroller("wide table", table); await expect(document.locator("[data-plan-source]")).not.toHaveCount(0); await expect(ana.getByRole("button", { name: /Comment on/ })).toBeAttached(); await changes.addedAtEnd(); let chip = ana.locator("[data-side]"); await expect(chip).toBeVisible(); await expectInsideDocumentWidth("agent change chip", chip); await chip.getByRole("button", { name: "What the agent changed" }).click(); await expectOwnScroller( "agent change excerpt", chip.getByText( "AgentChangeExcerptThatIsDeliberatelyLongEnoughToRequireTheChangeListToScrollOrTruncateInsideItsOwnContainedChrome", ), ); let bo = await join(LONG_HANDLE); await content(bo).getByText( "This deliberately long opening paragraph gives the fixture comment injector enough ordinary prose to quote while a reader can still find its argument among the richer blocks below.", { exact: true }, ).click({ position: { x: 1, y: 8 } }); await bo.keyboard.type(" "); let cursor = ana.getByText(LONG_HANDLE, { exact: true }); await expect(cursor).toHaveText(LONG_HANDLE); await expectInsideDocumentWidth("remote cursor label", cursor); await expectOwnScroller("remote cursor label", cursor); } for (let viewport of RESPONSIVE_VIEWPORTS) { test(`${viewport.name} exposes every required responsive surface`, async ({ baseURL, join, page, room, seed }) => { await page.setViewportSize(viewport); await expectRepresentativeSurfaces(baseURL!, join, page, room, seed); }); } test("compact and desktop documents render the intended gutter and reading measure", async ({ join, seed }) => { await seed(RESPONSIVE_SOURCE); let compact = await join("ana", { viewport: { width: 390, height: 844 } }); let compactGeometry = await content(compact).evaluate(node => { let style = getComputedStyle(node); return { content: node.getBoundingClientRect().width, left: Number.parseFloat(style.paddingLeft), prose: node.querySelector("p")!.getBoundingClientRect().width, right: Number.parseFloat(style.paddingRight), }; }); expect(compactGeometry.left).toBe(16); expect(compactGeometry.right).toBe(16); expect(compactGeometry.prose).toBeCloseTo( compactGeometry.content - compactGeometry.left - compactGeometry.right, 0, ); let desktop = await join("bo", { viewport: { width: 1440, height: 900 } }); let desktopGeometry = await content(desktop).evaluate(node => { let style = getComputedStyle(node); let contentBox = node.getBoundingClientRect(); let documentBox = node.closest("[data-plan-scroll]")!.getBoundingClientRect(); return { content: contentBox.width, documentLeft: contentBox.left - documentBox.left, documentRight: documentBox.right - contentBox.right, left: Number.parseFloat(style.paddingLeft), prose: node.querySelector("p")!.getBoundingClientRect().width, right: Number.parseFloat(style.paddingRight), }; }); expect(desktopGeometry.left).toBe(32); expect(desktopGeometry.right).toBe(32); expect(desktopGeometry.prose).toBeCloseTo(640, 0); expect(desktopGeometry.content).toBeCloseTo(704, 0); expect(desktopGeometry.documentLeft).toBeCloseTo(desktopGeometry.documentRight, 0); }); test("narrow documents keep equal inline gutters", async ({ join, seed }) => { await seed(RESPONSIVE_SOURCE); for (let width of [390, 611]) { let page = await join(`reader-${width}`, { viewport: { width, height: 844 } }); let padding = await content(page).evaluate(node => { let style = getComputedStyle(node); return { left: Number.parseFloat(style.paddingLeft), right: Number.parseFloat(style.paddingRight), }; }); expect(padding.left).toBeGreaterThanOrEqual(16); expect(padding.left).toBeLessThanOrEqual(24); expect(padding.right).toBe(padding.left); } }); for (let viewport of RESPONSIVE_VIEWPORTS) { test(`${viewport.name} keeps wide content in its own scrollers`, async ({ join, page, seed }) => { await page.setViewportSize(viewport); await routeResponsiveImage(page); await seed(RESPONSIVE_SOURCE); page = await join("ana"); let table = content(page).locator("table"); expect(await table.evaluate(node => node.scrollWidth > node.clientWidth)).toBe(true); expect((await table.locator("th").first().boundingBox())!.width).toBeGreaterThanOrEqual(112); await expectNoHorizontalOverflow(page); await expectInsideDocumentWidth("wide table", table); }); }