One shell for both editors. Flows and dashboards each get a searchable
overview under the padded shell, their editors move to the full-bleed
canvas, and the floating chrome is shared: a title bar that only says
what you are looking at, and a bottom dock carrying everything else —
the flow bar's status, settings and Publish moved down there, the
add-flow button moved to the overview.
Dashboards gain the rest of M4's visualization work:
- widgets are picked by clicking them, with the header as the drag
handle so a slider still slides and a switch still flips while
editing; settings moved into the flows' SidePanel
- react-grid-layout for drag and edge-resize, so the stored x/y finally
mean something; a dashboard nobody arranged is shelf-packed once
- a per-dashboard grid size, so a panel can be matched to its screen
- the chart widget, drawn with uPlot: several messages on one axis, fed
from the stored history plus the live socket tail, coloured from the
new --chart-1..5 ramp
- /view/{name}: the URL a wall panel is pointed at — no sidebar, no
footer, no editing, and no editor code, since routes are split
- a widget wired to a payload type it cannot carry, or wired to nothing
at all, carries the same red dot a failing node does; the picker
records the type it bound and WidgetDef refuses a mismatch on save
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
144 lines
4.8 KiB
TypeScript
144 lines
4.8 KiB
TypeScript
import { expect, type Page, test } from "@playwright/test"
|
|
import { deleteAll } from "./utils/api"
|
|
|
|
/**
|
|
* The flow editor's load-bearing behaviour: nodes are placed, connections come
|
|
* from matching message names, and everything survives a reload because the
|
|
* canvas autosaves.
|
|
*/
|
|
|
|
const flowName = `test_flow_${Date.now().toString(36)}`
|
|
|
|
test.use({ storageState: "playwright/.auth/user.json" })
|
|
|
|
test.describe.configure({ mode: "serial" })
|
|
|
|
test.afterAll(async ({ browser }) => {
|
|
await deleteAll(browser, [`/flows/${flowName}`])
|
|
})
|
|
|
|
const apiUrl = process.env.VITE_API_URL || "http://api.localhost"
|
|
|
|
/** Write a node's source through the API; typing code is not what we test. */
|
|
async function setNodeSource(page: Page, nodeId: string, code: string) {
|
|
const token = await page.evaluate(() => localStorage.getItem("access_token"))
|
|
const response = await page.request.put(
|
|
`${apiUrl}/api/v1/flows/${flowName}/nodes/${nodeId}/source`,
|
|
{ headers: { Authorization: `Bearer ${token}` }, data: { code } },
|
|
)
|
|
expect(response.ok()).toBeTruthy()
|
|
}
|
|
|
|
/** Read a node's source back, to check what a delete and an undo did to it. */
|
|
async function nodeSource(page: Page, nodeId: string) {
|
|
const token = await page.evaluate(() => localStorage.getItem("access_token"))
|
|
const response = await page.request.get(
|
|
`${apiUrl}/api/v1/flows/${flowName}/nodes/${nodeId}/source`,
|
|
{ headers: { Authorization: `Bearer ${token}` } },
|
|
)
|
|
expect(response.ok()).toBeTruthy()
|
|
return (await response.json()).code as string
|
|
}
|
|
|
|
async function addFunctionNode(page: Page, expected: number) {
|
|
await page.getByTestId("add-node").click()
|
|
await page
|
|
.getByRole("option", { name: /function/i })
|
|
.first()
|
|
.click()
|
|
await expect(page.locator(".react-flow__node")).toHaveCount(expected)
|
|
await page.keyboard.press("Escape")
|
|
}
|
|
|
|
/** Name the message on a node's first input or output port. */
|
|
async function bindPort(
|
|
page: Page,
|
|
index: number,
|
|
side: "in" | "out",
|
|
message: string,
|
|
) {
|
|
await page.locator(".react-flow__node").nth(index).click()
|
|
const panel = page.getByTestId("node-panel")
|
|
await panel
|
|
.getByRole("button", { name: "Add" })
|
|
.nth(side === "in" ? 0 : 1)
|
|
.click()
|
|
await panel.getByLabel("Message name").fill(message)
|
|
await page.keyboard.press("Escape")
|
|
}
|
|
|
|
test("a flow can be created, wired up, and comes back after a reload", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/flows")
|
|
|
|
// Create a flow of our own so the test does not lean on existing data.
|
|
await page.getByTestId("new-flow-name").fill(flowName)
|
|
await page.getByTestId("create-flow").click()
|
|
await page.waitForURL(`/flows/${flowName}`)
|
|
|
|
await addFunctionNode(page, 1)
|
|
await addFunctionNode(page, 2)
|
|
|
|
// The first node publishes "reading", the second consumes it. That match is
|
|
// the whole connection; no edge was drawn by hand.
|
|
await bindPort(page, 0, "out", "reading")
|
|
await bindPort(page, 1, "in", "reading")
|
|
await expect(page.locator(".react-flow__edge")).toHaveCount(1)
|
|
|
|
// Autosave, then prove it by coming back fresh.
|
|
await page.waitForTimeout(1500)
|
|
await page.reload()
|
|
await expect(page.locator(".react-flow__node")).toHaveCount(2)
|
|
await expect(page.locator(".react-flow__edge")).toHaveCount(1)
|
|
})
|
|
|
|
test("running a flow puts values on its edges", async ({ page }) => {
|
|
await page.goto(`/flows/${flowName}`)
|
|
await page.waitForSelector(".react-flow__node")
|
|
|
|
await setNodeSource(
|
|
page,
|
|
"python",
|
|
'def process(params):\n return {"reading": 42.0}\n',
|
|
)
|
|
await setNodeSource(
|
|
page,
|
|
"python_2",
|
|
"def process(reading, params):\n return {}\n",
|
|
)
|
|
|
|
await page.reload()
|
|
await page.waitForSelector(".react-flow__edge")
|
|
|
|
await page.getByTestId("run-flow").click()
|
|
await expect(page.locator(".react-flow__edgelabel-renderer")).toContainText(
|
|
"42",
|
|
{ timeout: 15000 },
|
|
)
|
|
})
|
|
|
|
/**
|
|
* The mis-click case: a node's source lives beside the flow document, so undoing
|
|
* a delete has to bring the code back with the node, not just the box.
|
|
*/
|
|
test("undo brings a deleted node back with its source", async ({ page }) => {
|
|
await page.goto(`/flows/${flowName}`)
|
|
await page.waitForSelector(".react-flow__node")
|
|
|
|
await page.locator(".react-flow__node").first().click()
|
|
await page.getByRole("button", { name: "Delete node" }).click()
|
|
await expect(page.locator(".react-flow__node")).toHaveCount(1)
|
|
|
|
// Deleting closes the panel, so the shortcut reaches the canvas rather than
|
|
// a field, which keeps its own undo.
|
|
await page.keyboard.press("ControlOrMeta+z")
|
|
await expect(page.locator(".react-flow__node")).toHaveCount(2)
|
|
|
|
// Let the autosave land, then come back fresh and read the source.
|
|
await page.waitForTimeout(1500)
|
|
await page.reload()
|
|
await expect(page.locator(".react-flow__node")).toHaveCount(2)
|
|
expect(await nodeSource(page, "python")).toContain("42.0")
|
|
})
|