The specs run against a development stack, so every run left a test_flow_* in someone's flow list. Each spec now tears down what it made in an afterAll, which runs whether or not the tests passed, and the three copies of the API helper move into tests/utils/api.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
145 lines
4.9 KiB
TypeScript
145 lines
4.9 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.getByRole("button", { name: "New flow" }).click()
|
|
await page.getByTestId("flow-name-input").fill(flowName)
|
|
await page.getByRole("button", { name: "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")
|
|
})
|