Files
app/frontend/tests/flows.spec.ts
T
stroblme 87e750c776 Code editor: CodeMirror 6 replaces Monaco
Node source (Python) and the JSON dialog share one CodeMirror editor with
find and replace, autocomplete, folding, indent guides, comment toggle and a
JSON lint, themed from the CSS tokens so light and dark follow the .dark class
without a second palette. Programmatic value changes no longer fire onChange,
blur ignores focus moving into the find bar, readOnly keeps selection and copy.

The editor chunk is 470 kB (157 kB gzipped) against 2.7 MB (704 kB) for Monaco,
which also pulled in a 1.2 MB JSON mode, two workers and a font. At 4x CPU on
slow 4G a node panel is ready in 5.7 s instead of 10.8 s, the JSON dialog in
1.5 s instead of 6.7 s. Typing without a key delay keeps every character.

monaco-editor, @monaco-editor/react and the dompurify override are removed.
Specs: typed code reaches the node source, a JSON setting saves only text that
parses. Docs name Tab and Ctrl-M.
2026-10-28 17:32:49 +01:00

308 lines
10 KiB
TypeScript

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