Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s
The four widget types added this round had renderers but no way to configure them. The panel now offers a bar's nested reading, unit and range (no step — that is a slider's), the forecast's item count on the agenda's field, a chart's y axis title beside its y range, and the icon's mapping editor: a value, a glyph and a colour per row, first match wins, with a fallback glyph below. Adds tests/widgets.spec.ts, which asserts each of them on /view: the nested bar inside its outer fill, the glyph following the message, five forecast columns fading outwards, a clock that reads the wall without being flagged unbound, a segmented control and a latching button reading back what they published, and an unbound tile that says so instead of taking the page down. The axis title is drawn into uPlot's canvas, so it is checked by panel round-trip. mobile.spec.ts grows a bar and a forecast so the width check covers them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA
276 lines
8.7 KiB
TypeScript
276 lines
8.7 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)
|
|
}
|
|
|
|
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" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
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 },
|
|
})
|
|
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°" },
|
|
],
|
|
},
|
|
})
|
|
|
|
await api(page, `/dashboards/${dashboardName}`, { method: "POST" })
|
|
const dashboard = await (
|
|
await api(page, `/dashboards/${dashboardName}`)
|
|
).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 },
|
|
},
|
|
]
|
|
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")
|
|
|
|
// 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 })
|
|
await expectFits(page, "the panel view")
|
|
})
|