Files
app/frontend/tests/endpoints.spec.ts
T
stroblmeandClaude Opus 5 e8a818a50b Hold a new dashboard back until someone publishes it
A dashboard went live the moment it was created — an empty document straight to
the panels — while a new flow starts as a draft. It now works the way flows do:
published means `dashboard.json` exists, so every dashboard on every running
installation is already published and nothing needs migrating. Only the ones
created from here on start as drafts.

Mirroring FlowStore turned up a latent 500: discarding the draft of a dashboard
that had never been published unlinked its only file, and the read that followed
raised out of a 200 handler. It answers 400 now, the way a flow does.

Publishing all of them was 2N requests, because a publish has to name the
version it expects and the summaries did not carry one. They do now — and so do
the flow summaries, which had the same defect nobody had written down.

A panel had no way to hear about any of this. A publish, or a change to which
dashboards a panel carries, now puts one event on the bus and the screen
refetches what changed: no reload, so a wall display never blanks or asks for
its credential again. The subtle half is that a socket's message allowlist was
computed once at handshake — a reassigned panel would have fetched its new
document and then shown tiles that never updated.

The panels dialog logged non-superusers out. Every write in it needs a
superuser, not only the checkboxes the report mentioned, so the dialog is
read-only for everyone else. The logout itself was `main.tsx` treating 403 as a
dead session, against the contract deps.py spells out: only a 401 ends a
session, and a 403 now says so rather than silently signing someone out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
2026-08-21 14:32:57 +02:00

150 lines
4.7 KiB
TypeScript

import { expect, test } from "@playwright/test"
import { api, apiPage, deleteAll } from "./utils/api"
/**
* Dashboards and other flows appear on the canvas but are not part of the flow.
*
* The risk this covers: they are React Flow nodes like any other, so anything
* that writes the document — autosave, undo, delete, drag — could pick one up
* and store it. A dashboard widget written into flow.json would become a node
* the engine then tries to build.
*/
const flowName = `test_endpoint_${Date.now().toString(36)}`
const dashboardName = `${flowName}_panel`
test.use({ storageState: "playwright/.auth/user.json" })
test.describe.configure({ mode: "serial" })
test.beforeAll(async ({ browser }) => {
const page = await apiPage(browser)
// A flow reading a message, and a dashboard control that sets it.
await api(page, `/flows/${flowName}`, {
method: "PUT",
data: {
name: flowName,
nodes: [
{
id: "sink",
type: "python",
requires: [
{ name: "level", port: "level", dtype: "float" },
{ name: "ceiling", port: "ceiling", dtype: "float" },
],
provides: [],
},
],
inputs: [
{ spec: { name: "level", port: "level", dtype: "float" }, initial: 0 },
{
spec: { name: "ceiling", port: "ceiling", dtype: "float" },
initial: 30,
},
],
version: 1,
},
})
const saved = await (await api(page, `/flows/${flowName}`)).json()
await api(page, `/flows/${flowName}/publish`, {
method: "POST",
data: { version: saved.definition.version },
})
const dashboard = await (
await api(page, `/dashboards/${dashboardName}`, { method: "POST" })
).json()
dashboard.pages[0].sections[0].widgets = [
{
id: "lever",
type: "slider",
title: "Lever",
layout: {},
config: { target: `${flowName}.level` },
},
]
const draft = await (
await api(page, `/dashboards/${dashboardName}`, {
method: "PUT",
data: dashboard,
})
).json()
// A dashboard edit is a draft, and the flow canvas draws the controls the
// panels actually carry — so the endpoint only exists once this is published,
// the same way the flow above had to be.
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}`,
])
})
test("a dashboard control is drawn on the flow it feeds", async ({ page }) => {
await page.goto(`/flows/${flowName}`)
await page.waitForSelector(".react-flow__node")
await expect(page.getByText("Lever")).toBeVisible()
})
test("an input nothing else writes is drawn, and the wired one is not", async ({
page,
}) => {
await page.goto(`/flows/${flowName}`)
await page.waitForSelector(".react-flow__node")
// The flow declares it and no node computes it, so it needs a visible
// origin. `level` already has one — the control below — and a second label
// saying the same thing would only be in the way.
await expect(page.getByText("ceiling")).toBeVisible()
await expect(page.getByText("level")).toHaveCount(0)
})
test("the dashboard is never stored as a node of the flow", async ({
page,
}) => {
await page.goto(`/flows/${flowName}`)
await page.waitForSelector(".react-flow__node")
// Long enough for an autosave to have landed if the canvas had queued one.
await page.waitForTimeout(2000)
const detail = await (await api(page, `/flows/${flowName}`)).json()
expect(detail.definition.nodes.map((n: { id: string }) => n.id)).toEqual([
"sink",
])
// Still reported as an endpoint, just never as a node.
expect(detail.endpoints.map((e: { label: string }) => e.label)).toEqual([
"Lever",
])
})
test("a node cannot be dragged: the graph places itself", async ({ page }) => {
await page.goto(`/flows/${flowName}`)
const node = page.locator(".react-flow__node-flow").first()
await node.waitFor()
// React Flow writes a node's place in the graph as the transform on its
// wrapper. The screen position is no good here: dragging the canvas pans
// the viewport, which moves every node on screen without moving any of them
// in the graph — which is the whole point.
const at = () => node.evaluate((el) => (el as HTMLElement).style.transform)
const before = await at()
const box = await node.boundingBox()
if (!box) throw new Error("the node was not rendered")
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
await page.mouse.move(box.x + box.width / 2 + 80, box.y + box.height / 2 + 40)
await page.mouse.up()
expect(await at()).toBe(before)
})