A new dashboard is a draft, so reading it back published answers 404. The POST already returns the document. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
401 lines
13 KiB
TypeScript
401 lines
13 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`
|
|
|
|
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" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
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.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` },
|
|
},
|
|
{
|
|
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 },
|
|
})
|
|
await page.close()
|
|
})
|
|
|
|
test.afterAll(async ({ browser }) => {
|
|
await deleteAll(browser, [
|
|
`/dashboards/${dashboardName}`,
|
|
`/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)
|
|
})
|
|
|
|
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)
|
|
})
|