Files
app/frontend/tests/mobile.spec.ts
T
stroblmeandClaude Opus 5 1feaa1f6cb Keep the lock off the stacked editor while a dashboard is arranged
The phone path draws through DashboardView in both modes, so a locked
dashboard came out with dead controls while it was being arranged — the
canvas path hands editing to its own grid and never mounts the lock.
DashboardView takes `editing` and skips LockedProvider for it; reading a
locked dashboard stacked still locks. Guarded in mobile.spec.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1moruzue2kTJd3uVisgNk
2026-08-28 19:27:55 +02:00

450 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(/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<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)
}
})
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)
})