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" }) } }) /** * The code editor is a contenteditable the canvas must leave alone, and what is * typed into it reaches the node's source through the panel's own debounce. * Typed with no key delay at all, which is the pace that used to drop letters. */ test("code typed into a node's editor is saved", async ({ page }) => { const name = `test_code_${Date.now().toString(36)}` const text = "# level, other = 1, 2" await page.goto("/") const saved = await api(page, `/flows/${name}`, { method: "PUT", data: { name, nodes: [{ id: "writer", type: "python" }], inputs: [] }, }) expect(saved.ok()).toBeTruthy() try { await page.goto(`/flows/${name}?node=writer`) const editor = page.getByTestId("code-editor").locator(".cm-content") await editor.click() await page.keyboard.press("ControlOrMeta+End") await page.keyboard.type(`\n${text}`) await expect(editor).toContainText(text) await expect .poll( async () => (await (await api(page, `/flows/${name}/nodes/writer/source`)).json()) .code, ) .toContain(text) } finally { await api(page, `/flows/${name}`, { method: "DELETE" }) } }) /** * A JSON setting opens in a dialog that edits a draft: the node only changes * once the text parses and Save is pressed. */ test("a JSON setting saves only text that parses", async ({ page }) => { const name = `test_json_${Date.now().toString(36)}` await page.goto("/") const saved = await api(page, `/flows/${name}`, { method: "PUT", data: { name, nodes: [ { id: "gate", type: "switch", params: { rules: [], otherwise: "" }, requires: [{ name: "level", dtype: "float" }], provides: [{ name: "high", dtype: "float" }], }, ], inputs: [], }, }) expect(saved.ok()).toBeTruthy() try { await page.goto(`/flows/${name}?node=gate`) await page.getByTestId("json-field").first().click() const dialog = page.getByTestId("json-dialog") const editor = dialog.locator(".cm-content") const save = dialog.getByTestId("json-save") await expect(editor).toBeFocused() await expect(save).toBeEnabled() // Brackets close themselves, so this leaves `[1,]`: a trailing comma. await page.keyboard.press("ControlOrMeta+a") await page.keyboard.press("Backspace") await page.keyboard.type("[1,") await expect(save).toBeDisabled() await expect(dialog.getByRole("alert")).toBeVisible() await expect(dialog.locator(".cm-lint-marker-error")).toBeVisible() await page.keyboard.press("Backspace") await expect(save).toBeEnabled() await save.click() await expect(dialog).toHaveCount(0) await expect(page.getByTestId("json-field").first()).toContainText("[1]") } finally { await api(page, `/flows/${name}`, { method: "DELETE" }) } })