import { expect, test, type Page } from "@playwright/test"; import { CHANNEL_SLUG, VIDEO_TRANSCRIPT_ONLY } from "./fixtures/data"; import { expectModalOpen, installRoutes, showAll } from "./helpers"; import { INSTANCES_URL } from "../../common/lib/project"; // The phone. Every other spec in this suite runs at the project's 1440×1200 // desktop viewport, which is exactly why the export site could ship a header // that wrapped out of its own background, a footer that overflowed at 360px and // a transcript modal with ~100px of cue list: nothing was ever looking. // // A file-level `test.use` overrides the Desktop Chrome project for this file // only, so these assertions are about the narrow layout and nothing else. test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, }); // The one invariant worth asserting on every route: the page must never scroll // sideways. `scrollWidth > clientWidth` is what a reader feels as the layout // being slightly wider than their phone. async function expectNoHorizontalOverflow(page: Page) { const over = await page.evaluate( () => document.documentElement.scrollWidth - document.documentElement.clientWidth, ); expect(over).toBeLessThanOrEqual(0); } const CORS = { "access-control-allow-origin": "*", "access-control-allow-headers": "*", "access-control-allow-methods": "*", }; function sse(text: string): string { return ( `data: ${JSON.stringify({ type: "content_block_delta", delta: { type: "text_delta", text }, })}\n\n` + `data: ${JSON.stringify({ type: "message_stop" })}\n\n` ); } // A one-turn provider: no retrieval round trip (the gather step is told DONE // immediately), then a streamed answer. Enough to put a message on screen and // see where the composer ends up. async function mockAI(page: Page) { await page.route("https://api.anthropic.com/**", async (route) => { if (route.request().method() === "OPTIONS") { await route.fulfill({ status: 204, headers: CORS }); return; } const body = route.request().postDataJSON() as { system?: string }; const answering = (body.system ?? "").includes("Markdown"); await route.fulfill({ status: 200, headers: { ...CORS, "content-type": "text/event-stream" }, body: sse(answering ? "A short answer about alpha." : "DONE"), }); }); } test.describe("phone layout", () => { test.beforeEach(async ({ page }) => { await installRoutes(page); }); // /duplicates.json is 404-mocked by installRoutes, so the duplicates route // renders its empty state — which is still a full page of chrome to overflow. for (const route of [ "/", "/ask/", "/downloads/", "/duplicates/", "/changelog/", ]) { test(`no horizontal overflow on ${route}`, async ({ page }) => { // KNOWN, and expected to fail until fixed: /changelog/ (checked here // since release 16, in place of the removed /use-with-ai/) overflows a // 390 px phone by ~600 px. Long inline `code` in the released entries // (file lists like `export/app/ask/{MessageBubble,…}.tsx/ts`) has no // break opportunity. When the changelog wraps them, this flips red: // delete the line. test.fail(route === "/changelog/", "the changelog's long inline code overflows a phone"); await page.goto(route); await page.waitForLoadState("networkidle"); await expectNoHorizontalOverflow(page); }); } test("the header menu carries the nav that the wide header shows inline, and nothing else", async ({ page, }) => { await page.goto("/"); // The theme is the bar's toggle, beside the trigger. await expect(page.getByRole("banner").getByRole("button", { name: /^switch to /i })).toBeVisible(); await page.getByRole("button", { name: "Open menu" }).click(); const menu = page.getByRole("dialog"); await expect(menu.getByRole("link", { name: "Ask AI" })).toBeVisible(); await expect(menu.getByRole("link", { name: "Search" })).toBeVisible(); // The Archilyzer home's official instances, where the sites list was. await expect( menu.getByRole("link", { name: "Archilyzer — official instances", exact: true }), ).toHaveAttribute("href", INSTANCES_URL); // No theme radios; Changelog, the sibling sites and the hub are not here. await expect(menu.getByRole("radiogroup")).toHaveCount(0); await expect(menu.getByRole("radio")).toHaveCount(0); await expect(menu.getByRole("link", { name: "Changelog" })).toHaveCount(0); await expect(menu.getByRole("link", { name: "Hub" })).toHaveCount(0); await expect(menu.getByText("Sites", { exact: true })).toHaveCount(0); }); test("the filters sheet applies a filter and the results change", async ({ page, }) => { await page.goto("/"); await showAll(page); const summary = page.getByTestId("results-summary"); await expect(summary).toContainText("All videos (3)"); await page.getByTestId("filters-trigger").click(); const sheet = page.getByRole("dialog"); await sheet.getByRole("checkbox", { name: "Videos" }).uncheck(); await sheet.getByRole("button", { name: "Apply filters" }).click(); // Apply commits and closes; nothing is left covering the results. await expect(sheet).toBeHidden(); await expect(summary).toContainText("All videos (0)"); }); test("the transcript modal is a full-screen player view", async ({ page }) => { await page.goto(`/?v=${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}`); await expectModalOpen(page); // The cue list is the point of the modal. It used to get ~100px at this // width, under a player slot sized for a desktop card. const box = await page.getByTestId("cue-list").boundingBox(); expect(box).not.toBeNull(); expect(box!.height).toBeGreaterThanOrEqual(300); // Every control is a real button with a visible label or an icon, and the // three that close the thing never scroll out of reach. await expect( page.getByRole("button", { name: "Close player" }), ).toBeInViewport(); await expect( page.getByRole("button", { name: "Show live chat" }), ).toBeInViewport(); }); test("the selection toolbar rides the bottom of the screen while a selection exists", async ({ page, }) => { await page.goto("/"); await showAll(page); const toolbar = page.getByTestId("selection-toolbar"); // Nothing selected: it is an ordinary row above the cards, as the 1440 // specs see it. await expect(toolbar).toContainText("Select videos to ask AI about a subset"); expect(await toolbar.evaluate((el) => getComputedStyle(el).position)).toBe( "static", ); await page .getByRole("checkbox", { name: /^Select "Transcript only/ }) .click(); await expect(toolbar).toContainText("1 selected for AI"); // Scroll to the end of the results — the handoff must still be one tap // away rather than forty cards back up. await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); expect(await toolbar.evaluate((el) => getComputedStyle(el).position)).toBe( "fixed", ); await expect(toolbar).toBeInViewport(); await expect(toolbar.getByRole("button", { name: "Ask AI" })).toBeInViewport(); await expect(toolbar.getByRole("button", { name: "Clear" })).toBeInViewport(); const geo = await page.evaluate(() => { const bar = document .querySelector('[data-testid="selection-toolbar"]')! .getBoundingClientRect(); const cards = document.querySelectorAll("[data-card-header]"); const last = cards[cards.length - 1]!.getBoundingClientRect(); return { barBottom: Math.round(bar.bottom), barTop: Math.round(bar.top), lastCardBottom: Math.round(last.bottom), vh: window.innerHeight, }; }); // Pinned to the bottom edge, not merely somewhere on screen… expect(Math.abs(geo.barBottom - geo.vh)).toBeLessThanOrEqual(2); // …and nothing is stranded underneath it. expect(geo.lastCardBottom).toBeLessThanOrEqual(geo.barTop); await toolbar.getByRole("button", { name: "Clear" }).click(); await expect(toolbar).toContainText("Select videos to ask AI about a subset"); expect(await toolbar.evaluate((el) => getComputedStyle(el).position)).toBe( "static", ); }); test("the composer stays on screen once the chat has an answer", async ({ page, }) => { await mockAI(page); await page.goto("/ask/"); await page.getByRole("button", { name: "Scripted" }).click(); await page.locator('input[placeholder^="sk-ant"]').fill("sk-ant-test"); const box = page.getByPlaceholder(/Ask about the transcripts/); await box.fill("what is alpha"); await page.getByRole("button", { name: "Ask", exact: true }).click(); await expect(page.getByText("A short answer about alpha.")).toBeVisible(); // The whole point of the sticky composer: after an answer arrives, the box // you type the next question into has not scrolled away. await expect(box).toBeInViewport(); await expectNoHorizontalOverflow(page); }); });