A flow's inputs are the messages it takes from outside — a dashboard control, a run, the API — and its outputs are what a batch run reports. Both existed in the document and in the engine, and neither had any UI: the values looked hard-coded on the canvas and the Run button always used the declared defaults. The canvas now draws each as a labelled endpoint, the way it already draws a dashboard tile or another flow, skipping an input something else already accounts for. The flow panel edits them — mode, name, type, starting value, and for a live flow the value it currently holds with a way to put a new one in. Pressing Run on a batch flow asks for its parameters first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUb8YpL2s3gmN9WTACTt4q
151 lines
4.8 KiB
TypeScript
151 lines
4.8 KiB
TypeScript
import { expect, test } from "@playwright/test"
|
|
import { api, apiPage, deleteAll } from "./utils/api"
|
|
|
|
/**
|
|
* Dashboards and other flows appear on the canvas but are not part of the flow.
|
|
*
|
|
* The risk this covers: they are React Flow nodes like any other, so anything
|
|
* that writes the document — autosave, undo, delete, drag — could pick one up
|
|
* and store it. A dashboard widget written into flow.json would become a node
|
|
* the engine then tries to build.
|
|
*/
|
|
|
|
const flowName = `test_endpoint_${Date.now().toString(36)}`
|
|
const dashboardName = `${flowName}_panel`
|
|
|
|
test.use({ storageState: "playwright/.auth/user.json" })
|
|
|
|
test.describe.configure({ mode: "serial" })
|
|
|
|
test.beforeAll(async ({ browser }) => {
|
|
const page = await apiPage(browser)
|
|
|
|
// A flow reading a message, and a dashboard control that sets it.
|
|
await api(page, `/flows/${flowName}`, {
|
|
method: "PUT",
|
|
data: {
|
|
name: flowName,
|
|
nodes: [
|
|
{
|
|
id: "sink",
|
|
type: "python",
|
|
requires: [
|
|
{ name: "level", port: "level", dtype: "float" },
|
|
{ name: "ceiling", port: "ceiling", dtype: "float" },
|
|
],
|
|
provides: [],
|
|
},
|
|
],
|
|
inputs: [
|
|
{ spec: { name: "level", port: "level", dtype: "float" }, initial: 0 },
|
|
{
|
|
spec: { name: "ceiling", port: "ceiling", dtype: "float" },
|
|
initial: 30,
|
|
},
|
|
],
|
|
version: 1,
|
|
},
|
|
})
|
|
const saved = await (await api(page, `/flows/${flowName}`)).json()
|
|
await api(page, `/flows/${flowName}/publish`, {
|
|
method: "POST",
|
|
data: { version: saved.definition.version },
|
|
})
|
|
|
|
await api(page, `/dashboards/${dashboardName}`, { method: "POST" })
|
|
const dashboard = await (
|
|
await api(page, `/dashboards/${dashboardName}`)
|
|
).json()
|
|
dashboard.pages[0].sections[0].widgets = [
|
|
{
|
|
id: "lever",
|
|
type: "slider",
|
|
title: "Lever",
|
|
layout: {},
|
|
config: { target: `${flowName}.level` },
|
|
},
|
|
]
|
|
const draft = await (
|
|
await api(page, `/dashboards/${dashboardName}`, {
|
|
method: "PUT",
|
|
data: dashboard,
|
|
})
|
|
).json()
|
|
// A dashboard edit is a draft, and the flow canvas draws the controls the
|
|
// panels actually carry — so the endpoint only exists once this is published,
|
|
// the same way the flow above had to be.
|
|
await api(page, `/dashboards/${dashboardName}/publish`, {
|
|
method: "POST",
|
|
data: { version: draft.version },
|
|
})
|
|
await page.close()
|
|
})
|
|
|
|
test.afterAll(async ({ browser }) => {
|
|
await deleteAll(browser, [
|
|
`/dashboards/${dashboardName}`,
|
|
`/flows/${flowName}`,
|
|
])
|
|
})
|
|
|
|
test("a dashboard control is drawn on the flow it feeds", async ({ page }) => {
|
|
await page.goto(`/flows/${flowName}`)
|
|
await page.waitForSelector(".react-flow__node")
|
|
|
|
await expect(page.getByText("Lever")).toBeVisible()
|
|
})
|
|
|
|
test("an input nothing else writes is drawn, and the wired one is not", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto(`/flows/${flowName}`)
|
|
await page.waitForSelector(".react-flow__node")
|
|
|
|
// The flow declares it and no node computes it, so it needs a visible
|
|
// origin. `level` already has one — the control below — and a second label
|
|
// saying the same thing would only be in the way.
|
|
await expect(page.getByText("ceiling")).toBeVisible()
|
|
await expect(page.getByText("level")).toHaveCount(0)
|
|
})
|
|
|
|
test("the dashboard is never stored as a node of the flow", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto(`/flows/${flowName}`)
|
|
await page.waitForSelector(".react-flow__node")
|
|
// Long enough for an autosave to have landed if the canvas had queued one.
|
|
await page.waitForTimeout(2000)
|
|
|
|
const detail = await (await api(page, `/flows/${flowName}`)).json()
|
|
|
|
expect(detail.definition.nodes.map((n: { id: string }) => n.id)).toEqual([
|
|
"sink",
|
|
])
|
|
// Still reported as an endpoint, just never as a node.
|
|
expect(detail.endpoints.map((e: { label: string }) => e.label)).toEqual([
|
|
"Lever",
|
|
])
|
|
})
|
|
|
|
test("a node cannot be dragged: the graph places itself", async ({ page }) => {
|
|
await page.goto(`/flows/${flowName}`)
|
|
const node = page.locator(".react-flow__node-flow").first()
|
|
await node.waitFor()
|
|
|
|
// React Flow writes a node's place in the graph as the transform on its
|
|
// wrapper. The screen position is no good here: dragging the canvas pans
|
|
// the viewport, which moves every node on screen without moving any of them
|
|
// in the graph — which is the whole point.
|
|
const at = () => node.evaluate((el) => (el as HTMLElement).style.transform)
|
|
const before = await at()
|
|
|
|
const box = await node.boundingBox()
|
|
if (!box) throw new Error("the node was not rendered")
|
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
|
await page.mouse.down()
|
|
await page.mouse.move(box.x + box.width / 2 + 80, box.y + box.height / 2 + 40)
|
|
await page.mouse.up()
|
|
|
|
expect(await at()).toBe(before)
|
|
})
|