Docs / docs (push) Successful in 23s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m18s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m49s
pre-commit / pre-commit (push) Failing after 2m5s
Test Backend / test-backend (push) Successful in 2m39s
Compose Smoke Test / test-compose (push) Successful in 33s
Playwright Tests / merge-reports (push) Successful in 1m11s
Runs: a run can now be deleted (DELETE /runs/{id}, cancelling a live one
first), exported as csv from the screen's own filters, and its flow label
opens the flow. Its "Parameters" panel became "Inputs" and lists every
input the flow declares, marking the ones that took the flow's own value
rather than the run's — the comparison table resolves the same defaults
instead of printing "unset".
Home reads brain, dashboards, health, flows: the mosaic is one full-width
scrolling strip, and the flows list and the flow-activity rollups merged
into a single left-joined table so a flow's state and its numbers sit on
one row.
Charts take a drag to narrow the x window and a double click or tap to
come back out. UplotChart holds the scale and passes resetScales:false
while a window is held, which is what the old comment said made this
impossible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LrWVRguqbk33YzfEeUx5W
450 lines
15 KiB
TypeScript
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(/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 }
|
|
}
|
|
|
|
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)
|
|
})
|