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` /** What the bar and the forecast read. A flow of its own, so the graph the * editor lays out above stays the shape those assertions were written for. */ const feedName = `${flowName}_feed` /** A read-only dashboard, on its own so the width checks above keep a live one. */ const lockedName = `${flowName}_locked` 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) } /** * The same rule, one level in. * * A widget body is a scroll container now — content taller than its card is * reachable rather than painted over the title — and a scroller absorbs a * sideways overflow before `document.scrollWidth` ever sees it. So the boxes * are checked for themselves. * * Only a box the user can actually drag sideways counts: `truncate` is * `overflow: hidden`, and hidden content reports a wider `scrollWidth` too * without anyone being able to reach it. Of the ones that can, only a box that * asked for it — `overflow-x-auto`, per DESIGN-GUIDELINES.md -> Responsive — * is allowed to. */ async function expectNoInnerScroll(page: Page, where: string) { const wide = await page.evaluate(() => [ ...document.querySelectorAll( "[data-testid=dashboard-canvas] *, main *", ), ] .filter( (el) => !el.classList.contains("overflow-x-auto") && ["auto", "scroll"].includes(getComputedStyle(el).overflowX) && el.scrollWidth > el.clientWidth + 1, ) .map((el) => `${el.tagName}.${el.className}`.slice(0, 120)), ) expect(wide, `${where} has a sideways scroller: ${wide.join(" | ")}`).toEqual( [], ) } 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, `/flows/${feedName}`, { method: "PUT", data: { name: feedName, title: "Feed", version: 1, nodes: [ { id: "emit", type: "python", provides: [ { name: "level", dtype: "float" }, { name: "pv", dtype: "float" }, { name: "days", dtype: "list", item: "record" }, // Long, and with nothing to break at: an unlabelled series puts // this whole name in the chart's legend. { name: "climate_series_reading", dtype: "float" }, { name: "mode", dtype: "str" }, { name: "lamp", dtype: "bool" }, ], }, ], }, }) const feed = await (await api(page, `/flows/${feedName}?draft=true`)).json() await api(page, `/flows/${feedName}/publish`, { method: "POST", data: { version: feed.definition.version }, }) // A bar needs a reading to draw and a forecast needs its days, or neither // widget is on the page the width check is run against. await api(page, `/messages/${feedName}.level`, { method: "POST", data: { value: 62 }, }) await api(page, `/messages/${feedName}.pv`, { method: "POST", data: { value: 24 }, }) // A chart only builds once it has a reading, and an unbuilt chart has no // legend to overflow. for (const value of [12, 14, 13]) { await api(page, `/messages/${feedName}.climate_series_reading`, { method: "POST", data: { value }, }) } await api(page, `/messages/${feedName}.days`, { method: "POST", data: { value: [ { label: "Mon", icon: "sun", value: "21°" }, { label: "Tue", icon: "cloudy", value: "18°" }, { label: "Wed", icon: "cloud-rain", value: "15°" }, { label: "Thu", icon: "wind", value: "16°" }, { label: "Fri", icon: "snowflake", value: "2°" }, ], }, }) const dashboard = await ( await api(page, `/dashboards/${dashboardName}`, { method: "POST" }) ).json() dashboard.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` }, }, { id: "level", type: "bar", title: "Level", layout: { lg: { x: 0, y: 2, w: 4, h: 2 } }, config: { message: `${feedName}.level`, dtype: "float", inner: `${feedName}.pv`, inner_dtype: "float", min: 0, max: 100, unit: " kW", }, }, { // A strip of columns is the widget most likely to widen the page. id: "week", type: "forecast", title: "Week", layout: { lg: { x: 0, y: 4, w: 6, h: 2 } }, config: { message: `${feedName}.days`, dtype: "list", count: 5 }, }, { // No label, so uPlot's legend carries the message name — a table cell // holding one unbroken token. id: "trend", type: "chart", title: "Trend", layout: { lg: { x: 0, y: 6, w: 6, h: 4 } }, config: { series: [ { message: `${feedName}.climate_series_reading`, dtype: "float" }, ], }, }, { // Five segments of prose in a pill that has to fit a phone. id: "mode", type: "dropdown", title: "Mode", layout: { lg: { x: 0, y: 10, w: 4, h: 2 } }, config: { target: `${feedName}.mode`, dtype: "str", style: "segmented", options: [ { label: "Comfort heating", value: "comfort" }, { label: "Economy overnight", value: "economy" }, { label: "Away from home", value: "away" }, { label: "Boost for an hour", value: "boost" }, { label: "Frost protection only", value: "frost" }, ], }, }, { // Far taller than the card it is given: the body has to scroll rather // than run out under the title. id: "notes", type: "markdown", title: "Notes", layout: { lg: { x: 0, y: 12, w: 6, h: 2 } }, config: { content: Array.from( { length: 40 }, (_, index) => `- Line ${index + 1}`, ).join("\n"), }, }, ] 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 }, }) const locked = await ( await api(page, `/dashboards/${lockedName}`, { method: "POST" }) ).json() locked.settings = { ...(locked.settings ?? {}), locked: { value: true } } locked.widgets = [ { id: "lamp", type: "switch", title: "Lamp", layout: { lg: { x: 0, y: 0, w: 3, h: 2 } }, config: { target: `${feedName}.lamp`, dtype: "bool", style: "button" }, }, ] const lockedDraft = await ( await api(page, `/dashboards/${lockedName}`, { method: "PUT", data: locked, }) ).json() await api(page, `/dashboards/${lockedName}/publish`, { method: "POST", data: { version: lockedDraft.version }, }) await page.close() }) test.afterAll(async ({ browser }) => { await deleteAll(browser, [ `/dashboards/${dashboardName}`, `/dashboards/${lockedName}`, `/flows/${flowName}`, `/flows/${feedName}`, ]) }) 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") await expectNoInnerScroll(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) }) /** * A read-only dashboard, read and arranged at a phone's width. * * Stacked, both modes draw through `DashboardView` — where the canvas hands * editing to its own grid instead — so the lock has to follow the mode rather * than the page. Arranging a dashboard is not using it. */ test("a locked dashboard locks when read, not while arranged", async ({ page, }) => { const lamp = page.getByRole("button", { name: "Lamp" }) await page.goto(`/dashboards/${lockedName}`) await lamp.waitFor({ timeout: 15000 }) await expect(lamp, "a locked dashboard being read").toBeDisabled() await page.goto(`/dashboards/${lockedName}?edit=true`) await lamp.waitFor({ timeout: 15000 }) await expect(lamp, "a locked dashboard being arranged").toBeEnabled() }) test("the panel view fits the viewport", async ({ page }) => { await page.goto(`/view/${dashboardName}`) await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 }) // uPlot's legend is the widest thing on the page and only exists once the // chart has drawn, so there is nothing to measure until it does. await page.locator(".u-legend").first().waitFor({ timeout: 15000 }) await expectFits(page, "the panel view") await expectNoInnerScroll(page, "the panel view") }) test("a widget scrolls rather than running out under its title", async ({ page, }) => { await page.goto(`/view/${dashboardName}`) const notes = page .getByTestId("widget-frame") .filter({ hasText: "Notes" }) .first() await notes.waitFor({ timeout: 15000 }) // The body, not the card: the card clips, and clipping is what used to let // the lines paint over the header rather than scroll under it. const scrolls = await notes.evaluate((frame) => [...frame.children].some( (child) => child.scrollHeight > child.clientHeight + 1, ), ) expect(scrolls, "forty lines fit a two-row card").toBe(true) const title = await notes.getByText("Notes", { exact: true }).boundingBox() const first = await notes.getByText("• Line 1", { exact: true }).boundingBox() expect(title).not.toBeNull() expect(first).not.toBeNull() expect( first!.y, "the first line is drawn over the title", ).toBeGreaterThanOrEqual(title!.y + title!.height - 1) })