Files
app/frontend/tests/mobile.spec.ts
T
stroblme 4c2df681d3 Flow canvas: label and edge popovers follow pan and zoom, the editors' dock is a full-width bar on a phone
- InputInspector hangs off the label's element and EdgeInspector off a point in flow coordinates (ViewportPortal), both re-measured every frame while open and hidden once the anchor leaves the view. A label that leaves the canvas closes its popover.
- Dock: below md a bar across the width, flush with the bottom edge (bg-card, top border, safe-area inset, no shadow); from md the floating pill as before. The flow dock drops the Run caption and the dashboard editor clears the bar with its own padding, so the editing state fits one row from 360px.
- Tests: the search spec checks the popover rides along with a pan; the mobile spec checks both docks are a full-width bar flush with the bottom.
2026-10-28 07:38:04 +01:00

473 lines
15 KiB
TypeScript

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<HTMLElement>(
"[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(/activity over the last/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 }
}
/**
* A phone's dock is a bar across the whole width, flush with the bottom edge,
* not a pill floating over the canvas. The id is any button in it.
*/
async function expectBar(page: Page, id: string) {
const bar = await page.getByTestId(id).locator("..").boundingBox()
const { width, height } = page.viewportSize() ?? { width: 0, height: 0 }
expect(bar, "the dock is not on screen").not.toBeNull()
expect(bar!.x, "the dock starts off the left edge").toBeCloseTo(0, 0)
expect(bar!.width, "the dock is not as wide as the phone").toBeCloseTo(
width,
0,
)
expect(bar!.y + bar!.height, "the dock floats above the bottom").toBeCloseTo(
height,
0,
)
}
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<number, number>()
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)
}
await expectBar(page, "add-node")
})
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)
await expectBar(page, "toggle-edit")
})
/**
* 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)
})