Files
app/frontend/tests/panel.spec.ts
T
stroblmeandClaude Opus 5 d01a8dad37 Rename Installation to Instance
Follows the portal: the noun is "instance" everywhere the app says it —
UI strings, CLI output, error details, docs and comments. The wire keys
(`instance_id`, `instance_token`) and the hub route this calls move with it.

An existing cloud.json is adopted rather than refused: without the key
alias the dataclass fails to parse, which the caller swallows and reads as
"never enrolled" instead of "reconnect".

`instance_key` on a node type becomes `target_key`. It means the outside
thing a node points at, which is a different sense of the word, and keeping
both would put two meanings of "instance" in one codebase.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
2026-08-31 10:12:01 +02:00

167 lines
5.4 KiB
TypeScript

import { expect, test } from "@playwright/test"
import type { PanelDef } from "../src/client"
import { api, apiPage, deleteAll } from "./utils/api"
/**
* A panel carrying more than one dashboard draws the way between them.
*
* The rail is part of what the screen shows rather than chrome beside it: it
* is inside the canvas, scaled with it, and wearing the dashboard's own look.
* Drawn outside, it was a strip of the app's design bolted to the edge of
* somebody's wall panel — and on a scaled canvas it did not even line up.
*
* It is also as tall as what it carries. Stretched end to end, a panel with
* two dashboards showed a pill nine tenths empty.
*/
const flowName = `test_panel_${Date.now().toString(36)}`
const first = `${flowName}_a`
const second = `${flowName}_b`
/** The instance's own panels, put back by the teardown. */
let panels: PanelDef[] | null = null
test.use({ storageState: "playwright/.auth/user.json" })
test.describe.configure({ mode: "serial" })
test.beforeAll(async ({ browser }) => {
const page = await apiPage(browser)
await api(page, `/flows/${flowName}`, {
method: "PUT",
data: {
name: flowName,
title: "Panel",
version: 1,
nodes: [
{
id: "emit",
type: "python",
provides: [{ name: "level", dtype: "float" }],
},
],
},
})
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 api(page, `/messages/${flowName}.level`, {
method: "POST",
data: { value: 4 },
})
for (const name of [first, second]) {
await api(page, `/dashboards/${name}`, { method: "POST" })
const doc = await (await api(page, `/dashboards/${name}?draft=true`)).json()
doc.settings = { look: { value: "glass" } }
doc.widgets = [
{
id: "stat",
type: "stat",
title: "Level",
layout: { lg: { x: 0, y: 0, w: 3, h: 2 } },
config: { message: `${flowName}.level`, dtype: "float" },
},
]
const put = await (
await api(page, `/dashboards/${name}`, { method: "PUT", data: doc })
).json()
await api(page, `/dashboards/${name}/publish`, {
method: "POST",
data: { version: put.version },
})
}
// Saving is a whole-list replace, and a panel that disappears takes the
// credential of the screen hanging on it. So append to what is there, and
// keep the list for the teardown to put back.
const config = await (await api(page, "/panels/")).json()
const existing: PanelDef[] = (config.panels ?? []).filter(
(p: PanelDef) => p.id !== flowName,
)
panels = existing
await api(page, "/panels/", {
method: "PUT",
data: {
panels: [
...existing,
{ id: flowName, title: "Hall", dashboards: [first, second] },
],
},
})
await page.close()
})
test.afterAll(async ({ browser }) => {
// Restoring the list is also what removes this spec's own panel.
if (panels) {
const page = await apiPage(browser)
await api(page, "/panels/", { method: "PUT", data: { panels } })
await page.close()
}
await deleteAll(browser, [
`/dashboards/${first}`,
`/dashboards/${second}`,
`/flows/${flowName}`,
])
})
test("the rail is drawn on the panel, in the panel's own look", async ({
page,
}) => {
await page.goto(`/panel/${flowName}?d=${first}`)
const rail = page.getByTestId("panel-rail")
await rail.waitFor({ timeout: 20000 })
const canvas = (await page.getByTestId("canvas-surface").boundingBox())!
const box = (await rail.boundingBox())!
expect(box.x, "the rail starts left of the panel").toBeGreaterThanOrEqual(
canvas.x - 1,
)
expect(box.y, "the rail starts above the panel").toBeGreaterThanOrEqual(
canvas.y - 1,
)
expect(
box.x + box.width,
"the rail runs off the right of the panel",
).toBeLessThanOrEqual(canvas.x + canvas.width + 1)
expect(
box.y + box.height,
"the rail runs off the bottom of the panel",
).toBeLessThanOrEqual(canvas.y + canvas.height + 1)
// Inside the canvas is also what makes it wear the look: the attributes and
// the palette are stated on the canvas, and inheritance does the rest.
const inside = await rail.evaluate(
(el) => el.closest("[data-testid=canvas-surface]") !== null,
)
expect(inside, "the rail is drawn outside the panel it belongs to").toBe(true)
// As tall as what it carries rather than as tall as the panel: a rail of
// two stretched end to end is mostly empty pill.
expect(
box.height,
"the rail is stretched to the height of the panel",
).toBeLessThan(canvas.height / 2)
const centres = Math.abs(
box.y + box.height / 2 - (canvas.y + canvas.height / 2),
)
expect(centres, "the rail is not centred in its column").toBeLessThan(2)
// And the arrangement keeps clear of it rather than sitting under it.
const tile = (await page.getByTestId("widget-frame").first().boundingBox())!
expect(tile.x, "a widget is drawn under the rail").toBeGreaterThanOrEqual(
box.x + box.width,
)
})
test("the rail switches the panel between its dashboards", async ({ page }) => {
await page.goto(`/panel/${flowName}?d=${first}`)
await page.getByTestId(`panel-rail-${second}`).click()
await expect(page).toHaveURL(new RegExp(`d=${second}`))
await expect(page.getByTestId(`panel-rail-${second}`)).toHaveAttribute(
"aria-current",
"page",
)
})