Files
app/frontend/tests/flows.spec.ts
T

305 lines
10 KiB
TypeScript

import { expect, type Page, test } from "@playwright/test"
import { apiUrl } from "./config.ts"
import { api, createFlow, deleteAll, openedFlowId } 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)}`
/** Issued by the server when the first test creates it in the app. */
let flowId = ""
test.use({ storageState: "playwright/.auth/user.json" })
test.describe.configure({ mode: "serial" })
test.afterAll(async ({ browser }) => {
await deleteAll(browser, [`/flows/${flowId}`])
})
/** 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/${flowId}/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/${flowId}/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()
flowId = await openedFlowId(page)
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/${flowId}`)
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/${flowId}`)
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 createFlow(page, {
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 createFlow(page, {
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 createFlow(page, {
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" })
}
})