305 lines
10 KiB
TypeScript
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" })
|
|
}
|
|
})
|