Say what caused a value, and draw what is not a node
Moving a dashboard slider lit up an edge between two nodes that had done nothing. The canvas pulsed on the message's timestamp alone, and a message has no idea who published it — so it credited whichever node happened to be drawn as a producer. That was never only about dashboards. Two nodes producing one message pulsed both their edges whichever fired, and a message produced in another flow changed with nothing on screen to account for it at all. Values now carry their cause: a node, a dashboard widget, another flow, an agent or an API caller. An edge pulses only for the producer that actually published, and the edge inspector says where a value came from when it did not come from a node. What is not a node in this flow is now drawn as one — a label rather than a card, because a dashboard with twenty tiles would otherwise bury the logic the canvas exists to show. That covers cross-flow wiring too, which is the link in/out affordance that has been missing. They are never part of the document. They join at render, after everything that reads or writes the canvas nodes, so an autosave, an undo or a delete cannot reach them — with a Playwright test that drags a node and asserts the stored flow still holds exactly what it did. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import { expect, type Page, test } from "@playwright/test"
|
||||
|
||||
/**
|
||||
* 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" })
|
||||
|
||||
const apiUrl = process.env.VITE_API_URL || "http://api.localhost"
|
||||
|
||||
async function api(
|
||||
page: Page,
|
||||
path: string,
|
||||
init: Record<string, unknown> = {},
|
||||
) {
|
||||
const token = await page.evaluate(() => localStorage.getItem("access_token"))
|
||||
return page.request.fetch(`${apiUrl}/api/v1${path}`, {
|
||||
...init,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const page = await browser.newPage({
|
||||
storageState: "playwright/.auth/user.json",
|
||||
})
|
||||
await page.goto("/")
|
||||
|
||||
// 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" }],
|
||||
provides: [],
|
||||
},
|
||||
],
|
||||
inputs: [
|
||||
{ spec: { name: "level", port: "level", dtype: "float" }, initial: 0 },
|
||||
],
|
||||
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` },
|
||||
},
|
||||
]
|
||||
await api(page, `/dashboards/${dashboardName}`, {
|
||||
method: "PUT",
|
||||
data: dashboard,
|
||||
})
|
||||
await page.close()
|
||||
})
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
const page = await browser.newPage({
|
||||
storageState: "playwright/.auth/user.json",
|
||||
})
|
||||
await page.goto("/")
|
||||
await api(page, `/dashboards/${dashboardName}`, { method: "DELETE" })
|
||||
await api(page, `/flows/${flowName}`, { method: "DELETE" })
|
||||
await page.close()
|
||||
})
|
||||
|
||||
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("moving a node does not save the dashboard into the flow", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/flows/${flowName}`)
|
||||
const node = page.locator(".react-flow__node-flow").first()
|
||||
await node.waitFor()
|
||||
|
||||
const box = await node.boundingBox()
|
||||
if (!box) throw new Error("the node has no position to drag from")
|
||||
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()
|
||||
// The autosave is debounced; give it room to land.
|
||||
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",
|
||||
])
|
||||
})
|
||||
Reference in New Issue
Block a user