Files
app/frontend/tests/widgets.spec.ts
T
stroblmeandClaude Opus 5 6f0b7c7eb4
Docs / docs (push) Successful in 21s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m9s
Playwright Tests / test-playwright (2, 2) (push) Failing after 11s
pre-commit / pre-commit (push) Failing after 2m6s
Test Backend / test-backend (push) Failing after 2m27s
Compose Smoke Test / test-compose (push) Failing after 1m54s
Playwright Tests / merge-reports (push) Failing after 2m16s
A buttons widget: a grid of presses on one message
Several stateless instructions where a single button is one — six presets are
otherwise six tiles to place, six titles to read, and the message they share
repeated six times. Nothing is read back, as for a single button: what these
send is an instruction, and the last one sent is not a state to draw.

Auto-fit columns rather than a configured count: a tile is resized in the
editor and scaled again to whatever panel it hangs on, so how many fit is not
something the document can know.

The editor reads `cfg.buttons` raw rather than through `buttonsOf`, which
drops the blanks — a row being typed into is blank until the first keystroke.

Also carried along by the hooks: the generated SDK was stale (it had no
`RunsReadMetricNames`) and one pre-existing block in `test_panels.py` was
unformatted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bm2HwnRsTDpeWjSPNJxQd
2026-09-04 08:44:16 +02:00

740 lines
24 KiB
TypeScript

import { expect, type Page, test } from "@playwright/test"
import { api, apiPage, deleteAll } from "./utils/api"
/**
* The wall-panel widgets, on the page a panel actually opens.
*
* Each one is a picture rather than a number, so what is worth asserting is
* the picture: a nested bar sits inside its outer fill, a condition picks the
* glyph the mapping names, a forecast fades outwards, and a control that
* latches reads back what it published. The two screenshots are the other
* half — a colour that collapses in one theme is invisible to every assertion
* above.
*/
const flowName = `test_widgets_${Date.now().toString(36)}`
const dashboardName = `${flowName}_panel`
/** A panel of its own: a bar of three rows needs a tile to itself. */
const stackName = `${flowName}_stack`
/** A read-only dashboard, on its own so the lock guard below has a lamp the
* interaction test above hasn't already flipped. */
const lockedName = `${flowName}_locked`
/** A message of the flow under test, qualified the way the engine names it. */
const w = (name: string) => `${flowName}.${name}`
/** Every tile the dashboard carries, including the deliberately unbound one. */
const TILES = 9
test.use({ storageState: "playwright/.auth/user.json" })
test.describe.configure({ mode: "serial" })
/** Put a value into the graph, as a flow answering would. */
const publish = (page: Page, name: string, value: unknown) =>
api(page, `/messages/${name}`, { method: "POST", data: { value } })
test.beforeAll(async ({ browser }) => {
const page = await apiPage(browser)
// One node that only declares: nothing consumes these, so publishing a value
// fills the panel without waking an engine run.
await api(page, `/flows/${flowName}`, {
method: "PUT",
data: {
name: flowName,
title: "Widgets",
version: 1,
nodes: [
{
id: "emit",
type: "python",
provides: [
{ name: "level", dtype: "float" },
{ name: "pv", dtype: "float" },
{ name: "grid", dtype: "float" },
{ name: "condition", dtype: "str" },
{ name: "days", dtype: "list", item: "record" },
{ name: "mode", dtype: "str" },
{ name: "lamp", dtype: "bool" },
{ name: "setpoint", dtype: "float" },
],
},
],
},
})
// Saving writes a draft and moves the version on, so publish what is
// actually there rather than what it was a moment ago.
const flow = await (await api(page, `/flows/${flowName}?draft=true`)).json()
await api(page, `/flows/${flowName}/publish`, {
method: "POST",
data: { version: flow.definition.version },
})
await publish(page, w("level"), 80)
await publish(page, w("pv"), 30)
await publish(page, w("grid"), 20)
await publish(page, w("condition"), "sun")
await publish(page, w("setpoint"), 21.5)
await publish(page, w("days"), [
{ label: "Mon", icon: "sun", value: "21°" },
{ label: "Tue", icon: "cloudy", value: "18°" },
{ label: "Wed", icon: "cloud-rain", value: "15°", color: "primary" },
{ label: "Thu", icon: "wind", value: "16°" },
{ label: "Fri", icon: "snowflake", value: "2°" },
// A sixth, so "Items shown" is what decides there are five columns.
{ label: "Sat", icon: "sun", value: "9°" },
])
const dashboard = await (
await api(page, `/dashboards/${dashboardName}`, { method: "POST" })
).json()
// Two rows taller than the default panel: the tiles below already fill it,
// and a widget past the last row is clipped rather than drawn.
dashboard.canvas_height = 1400
dashboard.widgets = [
{
id: "load",
type: "bar",
title: "Load",
layout: { lg: { x: 0, y: 0, w: 4, h: 2 } },
config: {
message: w("level"),
dtype: "float",
inner: w("pv"),
inner_dtype: "float",
min: 0,
max: 100,
unit: " kW",
},
},
{
id: "sky",
type: "icon",
title: "Sky",
layout: { lg: { x: 4, y: 0, w: 2, h: 2 } },
config: {
message: w("condition"),
dtype: "str",
rules: [
{ at: "rain", icon: "cloud-rain", color: "primary" },
{ at: "sun", icon: "sun", color: "default" },
],
},
},
{
id: "wall",
type: "clock",
title: "Now",
layout: { lg: { x: 6, y: 0, w: 3, h: 2 } },
config: {},
},
{
id: "week",
type: "forecast",
title: "Week",
layout: { lg: { x: 0, y: 2, w: 6, h: 2 } },
config: { message: w("days"), dtype: "list", count: 5 },
},
{
id: "mode",
type: "dropdown",
title: "Mode",
layout: { lg: { x: 6, y: 2, w: 4, h: 2 } },
config: {
target: w("mode"),
dtype: "str",
style: "segmented",
options: [
{ label: "Eco", value: "eco" },
{ label: "Boost", value: "boost" },
],
},
},
{
id: "scenes",
type: "buttons",
title: "Scenes",
layout: { lg: { x: 9, y: 0, w: 3, h: 2 } },
config: {
target: w("mode"),
dtype: "str",
buttons: [
{ label: "Night", value: "night" },
{ label: "Away", value: "away" },
],
},
},
{
id: "lamp",
type: "switch",
title: "Lamp",
layout: { lg: { x: 0, y: 4, w: 3, h: 2 } },
config: { target: w("lamp"), dtype: "bool", style: "button" },
},
{
// Bound to nothing on purpose: a half-configured tile has to say so
// rather than take the page down with it.
id: "spare",
type: "bar",
title: "Spare",
layout: { lg: { x: 3, y: 4, w: 3, h: 2 } },
config: {},
},
{
// No precision configured, so what it is worth is what decides how it is
// written — including on the way there.
id: "aim",
type: "slider",
title: "Setpoint",
layout: { lg: { x: 0, y: 6, w: 4, h: 2 } },
config: {
target: w("setpoint"),
dtype: "float",
min: 16,
max: 24,
step: 0.5,
unit: "°C",
},
},
{
id: "trend",
type: "chart",
title: "Trend",
layout: { lg: { x: 6, y: 4, w: 6, h: 3 } },
config: { series: [{ message: w("level"), dtype: "float" }] },
},
]
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 stack = await (
await api(page, `/dashboards/${stackName}`, { method: "POST" })
).json()
stack.widgets = [
{
id: "split",
type: "bar",
title: "Split",
layout: { lg: { x: 0, y: 0, w: 6, h: 2 } },
config: {
message: w("level"),
dtype: "float",
// The list shape. The panel above keeps the single binding a bar was
// written with, which is what proves both are still read.
inner: [
{ message: w("pv"), dtype: "float" },
{ message: w("grid"), dtype: "float" },
],
min: 0,
max: 100,
unit: " kW",
},
},
]
const stacked = await (
await api(page, `/dashboards/${stackName}`, { method: "PUT", data: stack })
).json()
await api(page, `/dashboards/${stackName}/publish`, {
method: "POST",
data: { version: stacked.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: w("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/${stackName}`,
`/dashboards/${lockedName}`,
`/flows/${flowName}`,
])
})
/** The panel as a wall panel opens it, once the tiles are drawn.
*
* Widgets arrive as a page rather than all at once, so "drawn" means settled:
* the first frame exists a moment before the last one has faded in, and a
* screenshot taken between the two shows half a dashboard.
*/
async function openPanel(page: Page, name = dashboardName) {
await page.goto(`/view/${name}`)
await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 })
await page.waitForFunction(
() =>
[...document.querySelectorAll(".widget-cell")].every(
(cell) => Number(getComputedStyle(cell).opacity) > 0.99,
),
undefined,
{ timeout: 15000 },
)
}
test("a bar draws a row per reading", async ({ page }) => {
await openPanel(page)
// The panel's bar is stored the way a bar was written before it had rows —
// one reading with a second nested in it — so drawing two rows is also what
// proves that shape is still read.
const rows = page.getByTestId("bar-row")
await expect(rows).toHaveCount(2)
const fills = page.getByTestId("bar-fill")
const outer = await fills.nth(0).boundingBox()
const inner = await fills.nth(1).boundingBox()
expect(outer).not.toBeNull()
expect(inner).not.toBeNull()
expect(
inner!.width,
"the smaller reading is not drawn as the shorter bar",
).toBeLessThan(outer!.width)
// Told apart by colour rather than by nesting, which is what lets a row
// carry a scale of its own.
const paint = (index: number) =>
fills.nth(index).evaluate((el) => getComputedStyle(el).backgroundColor)
expect(await paint(0), "two rows are drawn in the same colour").not.toEqual(
await paint(1),
)
})
test("a reading over its scale fills the track and no more", async ({
page,
}) => {
await openPanel(page)
await publish(page, w("pv"), 120)
await expect(page.getByText(/120\.00? kW/)).toBeVisible()
const fill = await page.getByTestId("bar-fill").nth(1).boundingBox()
const track = await page
.getByTestId("bar-row")
.nth(1)
.locator(".dui-bar-track")
.boundingBox()
expect(
fill!.width,
"a value over the scale runs off the end of its track",
).toBeLessThanOrEqual(track!.width + 1)
await publish(page, w("pv"), 30)
})
test("rows are drawn in the order they were configured", async ({ page }) => {
await openPanel(page, stackName)
const rows = page.getByTestId("bar-row")
await expect(rows).toHaveCount(3)
const first = await rows.nth(0).boundingBox()
const second = await rows.nth(1).boundingBox()
expect(second!.y, "the second row is not below the first").toBeGreaterThan(
first!.y,
)
// Each row names what it reads, so a stack of three is legible without the
// widget's title having to list them.
await expect(rows.nth(1)).toContainText(/pv/i)
await expect(rows.nth(2)).toContainText(/grid/i)
})
test("a reading is written to its own precision while it is moving", async ({
page,
}) => {
await openPanel(page)
const readout = page
.getByTestId("widget-frame")
.filter({ hasText: "Setpoint" })
.getByTestId("readout")
await expect(readout).toContainText("21.5")
// A value tweening toward 24 must pass through 22.0 and 23.5, not
// 22.37460937: a reading nobody asked for, a different width every frame.
// Started before the publish, not awaited: the frames worth looking at are
// the ones between the old reading and the new one.
const seen = page.evaluate(async () => {
const cell = [
...document.querySelectorAll("[data-testid=widget-frame]"),
].find((frame) => frame.textContent?.includes("Setpoint"))
const el = cell?.querySelector("[data-testid=readout]")
const samples: string[] = []
const until = performance.now() + 600
return new Promise<string[]>((resolve) => {
const step = () => {
samples.push(el?.textContent ?? "")
if (performance.now() < until) requestAnimationFrame(step)
else resolve(samples)
}
requestAnimationFrame(step)
})
})
await publish(page, w("setpoint"), 24)
const frames = await seen
for (const frame of frames) {
expect(frame, "a reading grew decimals on its way").toMatch(
/^-?\d+(\.\d)?\s*°C$/,
)
}
// Otherwise the frames above are all the reading standing still, and every
// one of them would pass whatever the tween was doing.
expect(
new Set(frames).size,
"the reading never moved, so nothing was watched",
).toBeGreaterThan(1)
await publish(page, w("setpoint"), 21.5)
})
test("the arrangement is held off the panel's edges", async ({ page }) => {
await openPanel(page)
const canvas = (await page.getByTestId("canvas-surface").boundingBox())!
const tiles = await page.getByTestId("widget-frame").all()
const boxes = await Promise.all(tiles.map((tile) => tile.boundingBox()))
// A tile sits as far from the edge of the screen as it does from its
// neighbour. The scale is whatever fits the viewport, so the margin is
// asserted as "some room" rather than a pixel count.
for (const box of boxes) {
expect(box!.x, "a tile is flush against the left edge").toBeGreaterThan(
canvas.x + 1,
)
expect(box!.y, "a tile is flush against the top edge").toBeGreaterThan(
canvas.y + 1,
)
expect(
box!.x + box!.width,
"a tile is flush against the right edge",
).toBeLessThan(canvas.x + canvas.width - 1)
}
})
test("a widget is drawn inside its tile rather than scrolled", async ({
page,
}) => {
await openPanel(page)
// A picture that overflows its tile is a picture nobody can see the rest of.
// Only the widgets that are text or a list may scroll, and this panel holds
// none of them — so nothing here should.
const overflowing = await page.evaluate(() =>
[...document.querySelectorAll(".dui-frame-body")]
.filter((body) => body.scrollHeight > body.clientHeight + 1)
.map((body) => body.closest("[data-testid=widget-frame]")?.textContent),
)
expect(overflowing, "these widgets overflow their tile").toEqual([])
})
test("the icon follows what the message says", async ({ page }) => {
await openPanel(page)
const glyph = page.getByTestId("icon-glyph")
await expect(glyph).toBeVisible()
await publish(page, w("condition"), "rain")
// Colour is never the only signal: the glyph names itself.
await expect(glyph).toHaveAttribute("aria-label", /rain/i)
})
test("a forecast shows the days asked for, fading outwards", async ({
page,
}) => {
await openPanel(page)
const columns = page.getByTestId("forecast-column")
await expect(columns).toHaveCount(5)
const opacity = (index: number) =>
columns.nth(index).evaluate((el) => Number(getComputedStyle(el).opacity))
expect(
await opacity(4),
"the far end of the forecast reads as certain as the near end",
).toBeLessThan(await opacity(0))
})
test("the clock reads the wall and is not mis-wired", async ({ page }) => {
await openPanel(page)
await expect(page.getByTestId("clock-time")).toHaveText(/\d{1,2}:\d{2}/)
// Bound to nothing by design, so it must not be flagged as unbound.
const tile = page
.getByTestId("widget-frame")
.filter({ has: page.getByTestId("clock-time") })
await expect(tile.getByTestId("widget-issue")).toHaveCount(0)
})
test("a control reads back what it published", async ({ page }) => {
await openPanel(page)
const boost = page.getByRole("button", { name: "Boost" })
await boost.click()
await expect(boost).toHaveAttribute("aria-pressed", "true")
// A grid draws one press per entry and publishes the value of the one
// pressed — nothing is read back, so the segmented control beside it, bound
// to the same message, is what says the publish landed.
await page.getByRole("button", { name: "Night" }).click()
await expect(boost).toHaveAttribute("aria-pressed", "false")
// The latching button names its state; two presses are a round trip.
const lamp = page.getByRole("button", { name: "Lamp" })
await expect(lamp).toHaveText("Off")
await lamp.click()
await expect(lamp).toHaveText("On")
await lamp.click()
await expect(lamp).toHaveText("Off")
})
/**
* A locked dashboard, read and arranged on the desktop grid.
*
* mobile.spec.ts guards this same setting through the stacked editor, which a
* phone's width routes through `DashboardView` on both the read and the
* arrange side. Desktop takes a second path once editing starts — `?edit=true`
* swaps in `<GridLayout>` directly, which never mounts `LockedProvider` at
* all — so this is the same guard through the branch that path never reaches.
*/
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("an unbound widget says so and takes nothing down", async ({ page }) => {
const crashes: string[] = []
page.on("pageerror", (error) => crashes.push(error.message))
await openPanel(page)
await expect(page.getByText("Pick a message.")).toBeVisible()
await expect(page.getByTestId("widget-frame")).toHaveCount(TILES)
expect(crashes, "the panel threw").toEqual([])
})
/**
* uPlot draws the axis title into the canvas, so there is nothing in the DOM
* to assert. What can be checked is that the setting survives: the panel is
* where it is written, and a reload is what proves it was stored.
*/
test("a chart's axis title is kept", async ({ page }) => {
await page.goto(`/dashboards/${dashboardName}?edit=true`)
const chart = page
.getByTestId("widget-frame")
.filter({ hasText: "Trend" })
.first()
await chart.waitFor({ timeout: 15000 })
await chart.click()
const field = page.locator('div:has(> label:text-is("Y axis title")) > input')
await expect(field).toBeVisible()
await field.fill("kW")
// Longer than the editor sits on an edit before saving it.
await page.waitForTimeout(2000)
await page.reload()
await chart.waitFor({ timeout: 15000 })
await chart.click()
await expect(field).toHaveValue("kW")
})
/**
* The cursor has to land under the pointer.
*
* A panel is drawn at its own pixel size and CSS-scaled to fit the screen it
* landed on, while uPlot maps the pointer against its own unscaled plot width
* — so without a correction the cursor lags further behind the further into
* the chart it is. The cursor line's box is in screen pixels, which is the
* same space the mouse was moved in, so the two are directly comparable.
*
* The readings are published with the panel already open: nothing keeps a ring
* for this tile, so the live tail is what puts a line on it.
*/
test("a chart's cursor follows the pointer", async ({ page }) => {
await openPanel(page)
// The socket carries the tail, so it has to be listening first.
await page.waitForTimeout(1000)
for (const value of [40, 60, 50, 70]) {
await publish(page, w("level"), value)
await page.waitForTimeout(250)
}
const chart = page.getByTestId("widget-frame").filter({ hasText: "Trend" })
const over = chart.locator(".u-over")
await over.waitFor({ timeout: 15000 })
const box = (await over.boundingBox()) as {
x: number
y: number
width: number
height: number
}
// Worth asserting only while the panel really is scaled, which is what the
// correction is for.
const drawnAt = await over.evaluate(
(el) => el.getBoundingClientRect().width / el.clientWidth,
)
expect(drawnAt, "a panel is scaled to fit the screen").toBeLessThan(0.95)
// Well inside: uPlot snaps the last pixel at either edge to the edge itself.
const x = box.x + box.width * 0.6
await page.mouse.move(x, box.y + box.height / 2)
const cursor = chart.locator(".u-cursor-x")
await expect(cursor).toBeVisible()
const at = async () => ((await cursor.boundingBox()) as { x: number }).x
const line = await at()
expect(
Math.abs(line - x),
`the cursor is drawn at ${line.toFixed(1)}, the pointer is at ${x.toFixed(1)}`,
).toBeLessThan(3)
// And it stays there. The chart sets its data on every render, which makes
// uPlot recompute the cursor from the position it already holds — so a
// correction applied twice would walk the line left while nothing moved.
await publish(page, w("level"), 55)
await page.waitForTimeout(1500)
const settled = await at()
expect(
Math.abs(settled - x),
`after a redraw the cursor is at ${settled.toFixed(1)}, the pointer at ${x.toFixed(1)}`,
).toBeLessThan(3)
})
/**
* What a dashboard was told to wear, as a wall panel would be told.
*
* The page has to be on the app first: the token these calls carry is read out
* of its local storage, and `about:blank` has none to read.
*/
async function setLook(
page: Page,
name: string,
settings: Record<string, unknown>,
) {
const current = await (
await api(page, `/dashboards/${name}?draft=true`)
).json()
const next = await (
await api(page, `/dashboards/${name}`, {
method: "PUT",
data: { ...current, settings: { ...current.settings, ...settings } },
})
).json()
await api(page, `/dashboards/${name}/publish`, {
method: "POST",
data: { version: next.version },
})
}
// Every look, in both themes. A look is mostly colour and depth, so the
// screenshots are the assertion that none of them collapses — and the checks
// around them are that a look changes how the panel is drawn and nothing else.
for (const scheme of ["light", "dark"] as const) {
test.describe(`${scheme} theme`, () => {
test.use({ colorScheme: scheme })
for (const look of ["fluksio", "material", "glass"] as const) {
test(`the panel reads in ${scheme} ${look}`, async ({ page }) => {
await openPanel(page)
await setLook(page, dashboardName, { look: { value: look } })
await openPanel(page)
await expect(page.getByTestId("canvas-surface")).toHaveAttribute(
"data-look",
look,
)
// Every widget still draws, whichever set is asked to draw it.
await expect(page.getByTestId("widget-frame")).toHaveCount(TILES)
await expect(page.getByTestId("bar-row")).toHaveCount(2)
await page.screenshot({
path: `screenshots/widgets/${scheme}-${look}.png`,
})
})
}
test(`a palette recolours the panel in ${scheme}`, async ({ page }) => {
await openPanel(page)
await setLook(page, dashboardName, {
look: { value: "glass" },
// Ground, surface, primary, accent — the roles, in order.
palette: {
value: ["#264653", "#2a9d8f", "#e9c46a", "#f4a261"],
},
})
await openPanel(page)
const surface = page.getByTestId("canvas-surface")
// The ground decides light or dark, so a dark palette reads dark
// whatever the device asked for.
await expect(surface).toHaveClass(/dark/)
await expect(surface).toHaveAttribute("data-palette", "")
await expect(page.getByTestId("canvas-ground")).toBeVisible()
// The margin holds the arrangement off the edges; it does not hold the
// ground off with it, because a background covers the whole panel.
const canvas = (await surface.boundingBox())!
const ground = (await page.getByTestId("canvas-ground").boundingBox())!
expect(Math.round(ground.width)).toBe(Math.round(canvas.width))
expect(Math.round(ground.height)).toBe(Math.round(canvas.height))
const primary = await page
.getByTestId("bar-fill")
.first()
.evaluate((el) => getComputedStyle(el).backgroundColor)
expect(
primary,
"the first row is not drawn in the palette's primary",
).toBe("rgb(233, 196, 106)")
await page.screenshot({
path: `screenshots/widgets/${scheme}-palette.png`,
})
await setLook(page, dashboardName, {
look: { value: "fluksio" },
palette: { value: [] },
})
})
})
}