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 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: "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 }, }) await page.close() }) test.afterAll(async ({ browser }) => { await deleteAll(browser, [ `/dashboards/${dashboardName}`, `/dashboards/${stackName}`, `/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((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") // 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") }) 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) }) test("touch makes the controls bigger without changing what they do", async ({ page, }) => { await openPanel(page) const control = page.getByTestId("widget-frame").filter({ hasText: "Mode" }) const pointer = (await control.getByRole("button").first().boundingBox())! .height await setLook(page, dashboardName, { touch: { value: true } }) await openPanel(page) await expect(page.getByTestId("canvas-surface")).toHaveAttribute( "data-touch", "", ) const touched = (await control.getByRole("button").first().boundingBox())! .height expect( touched, `a segment is ${touched.toFixed(1)} touched and ${pointer.toFixed(1)} pointed at`, ).toBeGreaterThan(pointer) // The control is the same control: it still publishes what it always did. await control.getByRole("button", { name: "Boost" }).click() await expect(control.getByRole("button", { name: "Boost" })).toHaveAttribute( "aria-pressed", "true", ) await setLook(page, dashboardName, { touch: { value: false } }) }) /** * 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, ) { 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: [] }, }) }) }) }