A python node's settings are constants of its own function, so they are passed the way its ports are: by name. The controller binds them to the compiled function, the `params` field is gone from the worker and remote protocols, and a setting sharing a port's name is reported as a node error rather than shadowing it. The panel's scaffold follows suit and keeps the header in step with both ports and settings. The demo's `pace` moves from a flow input to a setting of the training node, which is what it always was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUb8YpL2s3gmN9WTACTt4q
148 lines
5.0 KiB
TypeScript
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():\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")
|
|
})
|