import { expect, type Page, test } from "@playwright/test" import { api, apiPage, deleteAll } from "./utils/api" /** * Every screen at a phone's width, checking the one thing that is easy to * break and hard to notice: a page that scrolls sideways. * * The rules this enforces are in the root `DESIGN-GUIDELINES.md` → Responsive. * A wide table, chart or dock is allowed to scroll inside its own box; none of * them may widen the page around it. */ const flowName = `test_mobile_${Date.now().toString(36)}` const dashboardName = `${flowName}_panel` test.describe.configure({ mode: "serial" }) /** * Does anything on this page stick out past the viewport? * * Two signals, because either can hide the other. A page that scrolls * sideways fails `scrollWidth`; one that merely *contains* something too wide * makes the browser widen the layout viewport and zoom the whole page out * instead, which shows up only as `innerWidth` no longer being the device's. */ async function fits(page: Page): Promise<{ inner: number; scroll: number }> { return page.evaluate(() => ({ inner: window.innerWidth, scroll: document.documentElement.scrollWidth, })) } async function expectFits(page: Page, where: string) { const width = page.viewportSize()?.width ?? 0 const { inner, scroll } = await fits(page) expect(inner, `${where} is too wide for the viewport`).toBe(width) expect(scroll, `${where} scrolls sideways`).toBeLessThanOrEqual(inner) } test.beforeAll(async ({ browser }) => { const page = await apiPage(browser) // A chain and a four-way fan: enough of a graph to lay out, and wider than // a phone can take abreast, so the wrapping is exercised. await api(page, `/flows/${flowName}`, { method: "PUT", data: { name: flowName, title: "Mobile", nodes: [ { id: "source", type: "python", provides: [{ name: "reading", dtype: "float" }], }, { id: "scale", type: "python", requires: [{ name: "reading", dtype: "float" }], provides: [{ name: "scaled", dtype: "float" }], }, ...["one", "two", "three", "four"].map((id) => ({ id, type: "python", requires: [{ name: "scaled", dtype: "float" }], })), ], version: 1, }, }) const saved = await (await api(page, `/flows/${flowName}`)).json() await api(page, `/flows/${flowName}/publish`, { method: "POST", data: { version: saved.definition.version }, }) await api(page, `/dashboards/${dashboardName}`, { method: "POST" }) const dashboard = await ( await api(page, `/dashboards/${dashboardName}`) ).json() dashboard.pages[0].sections[0].widgets = [ { id: "top", type: "stat", title: "Top", layout: { lg: { x: 0, y: 0, w: 3, h: 2 } }, config: { message: `${flowName}.reading` }, }, { id: "beside", type: "stat", title: "Beside", layout: { lg: { x: 3, y: 0, w: 3, h: 2 } }, config: { message: `${flowName}.scaled` }, }, ] const draft = await ( await api(page, `/dashboards/${dashboardName}`, { method: "PUT", data: dashboard, }) ).json() await api(page, `/dashboards/${dashboardName}/publish`, { method: "POST", data: { version: draft.version }, }) await page.close() }) test.afterAll(async ({ browser }) => { await deleteAll(browser, [ `/dashboards/${dashboardName}`, `/flows/${flowName}`, ]) }) test("home fits the viewport", async ({ page }) => { await page.goto("/") await page .getByText(/Flow activity/i) .first() .waitFor({ timeout: 15000 }) await expectFits(page, "home") }) test("the overviews fit the viewport", async ({ page }) => { for (const path of ["/flows", "/dashboards"]) { await page.goto(path) await page.waitForLoadState("networkidle") await expectFits(page, path) } }) /** Where React Flow put a node in the graph, out of its wrapper transform. */ async function nodeAt(page: Page, id: string) { const style = await page .locator(`.react-flow__node[data-id="${id}"]`) .evaluate((el) => (el as HTMLElement).style.transform) const [x, y] = [...style.matchAll(/-?[\d.]+/g)].map((m) => Number(m[0])) return { x, y } } test("the flow editor fits, and its dock is reachable", async ({ page }) => { await page.goto(`/flows/${flowName}`) await page.waitForSelector(".react-flow__node") await expectFits(page, "the flow editor") // A phone has height to spare and no width, so the graph runs downwards: // what a node feeds sits below it, not beside it. const source = await nodeAt(page, "source") const scale = await nodeAt(page, "scale") expect(scale.y, "the graph does not run top to bottom").toBeGreaterThan( source.y, ) expect(Math.abs(scale.x - source.x)).toBeLessThan(200) // …and it grows downwards rather than sideways: the four consumers of one // message wrap onto rows instead of standing eight hundred pixels abreast. const rows = new Map() for (const id of ["source", "scale", "one", "two", "three", "four"]) { const { y } = await nodeAt(page, id) rows.set(y, (rows.get(y) ?? 0) + 1) } expect( Math.max(...rows.values()), "a row is wider than a phone", ).toBeLessThanOrEqual(2) // The dock used to overflow a phone, which put the buttons at its ends // outside the shell's `overflow-hidden` and made them unclickable. for (const id of ["add-node", "run-flow", "edit-flow", "publish-flow"]) { const box = await page.getByTestId(id).boundingBox() expect(box, `${id} is not on screen`).not.toBeNull() const width = page.viewportSize()?.width ?? 0 expect(box!.x, `${id} starts off the left edge`).toBeGreaterThanOrEqual(0) expect( box!.x + box!.width, `${id} runs off the right edge`, ).toBeLessThanOrEqual(width) } }) test("a dashboard stacks instead of shrinking", async ({ page }) => { await page.goto(`/dashboards/${dashboardName}`) await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 }) await expectFits(page, "the dashboard editor") // Side by side on a panel, one under the other here. const first = await page.getByTestId("widget-frame").first().boundingBox() const second = await page.getByTestId("widget-frame").nth(1).boundingBox() expect(first).not.toBeNull() expect(second).not.toBeNull() expect(second!.y).toBeGreaterThanOrEqual(first!.y + first!.height - 1) }) test("the panel view fits the viewport", async ({ page }) => { await page.goto(`/view/${dashboardName}`) await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 }) await expectFits(page, "the panel view") })