Files
app/frontend/tests/flows.spec.ts
T
stroblmeandClaude Opus 5 39ee0e0aa5 Computed flow layout, and mobile written into the design
The canvas lays itself out: a layered graph, left to right on a desktop and
top to bottom on a phone, with room reserved for the value each edge carries.
Nodes cannot be dragged and `NodeDef.position` is gone from the document —
a graph nobody can arrange is one worth keeping small, which is what keeps
flows atomic. Endpoints join the same layout, so their lanes and the
localStorage that remembered where they were dragged go too.

Mobile, per the new Responsive section of DESIGN-GUIDELINES.md: the dock caps
its width and wraps instead of running off the screen, the dashboard stacks
into one column rather than shrinking a wall panel to a fifth of its size, and
Home stops widening its grid track past the viewport. A Playwright project at
a phone's width fails the build when a screen no longer fits.

Along the way: publish is the checkmark that was already there rather than a
button that appears and disappears, with discard beside it on both the flow
and the dashboard; the brain reveals a neuron's name on the first tap; and the
port sparklines get room to breathe.

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

148 lines
5.0 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").click()
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()
// Attached, not visible: the layout puts a two-node chain on one rank, so
// the edge between them is a straight horizontal line — correct, and a
// zero-height box as far as a visibility check is concerned.
await page.waitForSelector(".react-flow__edge", { state: "attached" })
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")
})