Files
app/frontend/tests/flows.spec.ts
T
stroblme 2e0ba32124 Node panel: ports read message first on inputs, code name is text until it differs
An input row now starts from its message and ends as the argument the code
takes it as ("as prompt"); an output keeps its key on top and the message
below. The name the code uses is muted text with a pencil while it follows the
message's last segment, and a field only when it differs. Device Follows ports
get the same order (message, then the port driven). A list port's item type
moves to the second row so it no longer squeezes the name, two ports deriving
one name get a warning, and the rows rebuilt use 44px touch targets on phones.
The stored node spec is unchanged.
2026-10-28 11:04:15 +01:00

224 lines
7.7 KiB
TypeScript

import { expect, type Page, test } from "@playwright/test"
import { apiUrl } from "./config.ts"
import { api, 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}`])
})
/** 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():\n return {"reading": 42.0}\n',
)
await setNodeSource(
page,
"python_2",
"def process(reading):\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")
})
/**
* A port reads top to bottom, source over destination: an input is its message
* and then the argument the code takes it as, an output the key the code
* returns and then its message. A name the code uses is only a field when it
* differs from the message's last segment, muted text while it follows.
*/
test("a port's code name is a field only when it differs from its message", async ({
page,
}) => {
const name = `test_ports_${Date.now().toString(36)}`
await page.goto("/")
const saved = await api(page, `/flows/${name}`, {
method: "PUT",
data: {
name,
nodes: [
{
id: "reader",
type: "python",
requires: [
{ name: "command", port: "prompt", dtype: "str" },
{ name: "outdoor", dtype: "float" },
{ name: "a.level", dtype: "float" },
{ name: "b.level", dtype: "float" },
],
provides: [
{ name: "setpoint", port: "living_room_setpoint", dtype: "float" },
{ name: "pv_kw", dtype: "float" },
],
},
],
inputs: [],
},
})
expect(saved.ok()).toBeTruthy()
try {
await page.goto(`/flows/${name}?node=reader`)
const panel = page.getByTestId("node-panel")
// Differs: the argument is a field, under its message.
const argument = panel.getByLabel("Argument name")
await expect(argument).toHaveCount(1)
await expect(argument).toHaveValue("prompt")
const message = panel.getByLabel("Message name").first()
await expect(message).toHaveValue("command")
const [above, below] = await Promise.all([
message.boundingBox(),
argument.boundingBox(),
])
expect(above?.y).toBeLessThan(below?.y ?? 0)
// An output keeps its key on top, a field because it differs.
await expect(panel.getByLabel("Port name")).toHaveValue(
"living_room_setpoint",
)
// Matches: muted text with a pencil, no field. `level` is derived twice.
const following = panel.getByTestId("port-follows")
await expect(following).toHaveCount(4)
await expect(following.nth(0)).toHaveText("outdoor")
await expect(following.nth(3)).toHaveText("pv_kw")
await expect(panel.getByTestId("port-clash")).toHaveCount(2)
// The pencil opens the field on the derived name.
await panel
.getByRole("button", { name: "Name the argument differently" })
.first()
.click()
await expect(argument).toHaveCount(2)
await expect(argument.nth(1)).toBeFocused()
await expect(argument.nth(1)).toHaveValue("")
await expect(argument.nth(1)).toHaveAttribute("placeholder", "outdoor")
} finally {
await api(page, `/flows/${name}`, { method: "DELETE" })
}
})