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() 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") })