Files
app/frontend/tests/flows.spec.ts
T
Melvin StroblandClaude Opus 5 9cede8dbb5 Add undo on the canvas, and sharpen the flow chrome
Deleting a node cost its source with no way back. Every mutation already
funnels through one commit, so undo/redo is a bounded stack of node
snapshots replayed through the same debounced save. Deleting a node only
drops it from flow.json — the source file survives — so restoring the id
restores the code. Renaming a message now offers to follow the rename
across every node still bound to the old name, as one undoable step.

Autosave never fired: flush depended on the whole mutation object, which
react-query rebuilds every render, so the effect re-ran and its cleanup
cancelled the pending timer. Unmounting now flushes rather than drops.

The canvas looked blurry zoomed out because Background scales the dot
radius by zoom, leaving quarter-pixel dots on a drifting tile; radius
and spacing now divide the zoom back out, spacing in octaves so the grid
halves. Edge value labels are opaque, the edge popover fits its summary
on one row with a trash icon and scrolls names that overflow, and flow
settings moved to the flowbar. The flowbar was sized against the
viewport rather than the canvas, so its buttons left the screen once
enough flows were open.

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

140 lines
4.7 KiB
TypeScript

import { expect, type Page, test } from "@playwright/test"
/**
* 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" })
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.getByRole("button", { name: "New flow" }).click()
await page.getByTestId("flow-name-input").fill(flowName)
await page.getByRole("button", { name: "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")
})