Files
app/frontend/tests/runtime.spec.ts
T
stroblmeandClaude Opus 5 b0efb4b0f1 Let a node's failure outlive the run that followed it
A node's error cleared the moment it ran again, so a failure that genuinely
fired an alert could leave no trace on the canvas by the time anyone looked.
The engine records it now — on the node's status, so it survives a reload and
every client agrees — and reading the traceback is what clears it. The seam is
the event bus, which is where every failing path already meets: a queued live
run, an explicit run, a preview, and a single triggered node all publish
`node_error`, while the controller's own observer would have seen only one of
them.

That was half the confusion. The other half: clicking a failed neuron on Home
often landed on a flow where everything looked fine. Nodes merge into one
neuron by instance key — every InfluxDB node pointing at the same bucket is one
neuron — and the click went to whichever flow contributed a member first, not
the one that failed. It now goes to the failing member and selects it, and the
canvas marks a failing node rather than leaving it to the dot alone.

The inject node emitted one payload to every port it declared, whatever their
types, so an inject on a bool port carrying the text "true" raised at publish
time. Each port gets its own field now, typed and parsed by that port's dtype,
and remembers what it last sent. A port that is renamed carries its value with
it; one that is removed takes its value with it. An inject written before this
keeps emitting exactly what it did.

The derived-cron chip also appeared on the delay node, where `interval` is a
rate limit and a schedule derived from it means nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uq8mtNb97A7praJLyeEYgs
2026-08-21 14:33:41 +02:00

161 lines
5.4 KiB
TypeScript

import { expect, test } from "@playwright/test"
import { api, apiPage, deleteAll } from "./utils/api"
/**
* Flows can be taken off the engine and put back, and what a node prints — or
* the traceback of one that fails — is readable without leaving the canvas.
*/
const flowName = `test_runtime_${Date.now().toString(36)}`
test.use({ storageState: "playwright/.auth/user.json" })
test.describe.configure({ mode: "serial" })
const PRINTING_NODE = `def process():
print("sensor read 21.5 degrees")
return {"reading": 21.5}
`
const BROKEN_NODE = `def process(reading):
raise RuntimeError("downstream blew up")
`
const FIXED_NODE = `def process(reading):
print(f"logged {reading}")
`
test.afterAll(async ({ browser }) => {
await deleteAll(browser, [`/flows/${flowName}`])
})
test.beforeAll(async ({ browser }) => {
const page = await apiPage(browser)
await api(page, `/flows/${flowName}`, {
method: "PUT",
data: {
name: flowName,
title: "Runtime",
nodes: [
{
id: "sensor",
type: "python",
provides: [{ name: "reading", dtype: "float" }],
},
{
id: "logger",
type: "python",
requires: [{ name: "reading", dtype: "float" }],
},
],
},
})
await api(page, `/flows/${flowName}/nodes/sensor/source`, {
method: "PUT",
data: { code: PRINTING_NODE },
})
await api(page, `/flows/${flowName}/nodes/logger/source`, {
method: "PUT",
data: { code: BROKEN_NODE },
})
const detail = await (await api(page, `/flows/${flowName}`)).json()
await api(page, `/flows/${flowName}/publish`, {
method: "POST",
data: { version: detail.definition.version },
})
await page.close()
})
test("the home page lists flows and can stop one", async ({ page }) => {
await page.goto("/")
const row = page.getByTestId("home-flow-row").filter({ hasText: "Runtime" })
await expect(row).toBeVisible()
await expect(row).toContainText("Running")
await row.getByTestId("flow-enabled-switch").click()
await expect(row).toContainText("Stopped")
// A stopped flow is not something the engine will run.
const refused = await api(page, `/flows/${flowName}/run`, {
method: "POST",
data: { inputs: {} },
})
expect(refused.status()).toBe(409)
await row.getByTestId("flow-enabled-switch").click()
await expect(row).toContainText("Running")
})
test("the logs panel shows what a node printed and why one failed", async ({
page,
}) => {
await page.goto(`/flows/${flowName}`)
await page.waitForSelector(".react-flow__node")
await page.getByTestId("run-flow").click()
await page.getByTestId("flow-logs").click()
const panel = page.getByTestId("logs-panel")
await expect(panel).toContainText("sensor read 21.5 degrees")
await expect(panel).toContainText("RuntimeError")
// Reading the logs is something you do *while* working on the flow, so a
// click on the canvas leaves them up — only the button or Escape closes it.
await page.locator(".react-flow__pane").click({ position: { x: 8, y: 8 } })
await expect(panel).toBeVisible()
// It sits above the dock rather than on it, and shares its centre. The dock
// has no box of its own to measure, so its span is its first and last
// buttons; its padding is symmetric, so their midpoint is its centre.
const logs = (await panel.boundingBox())!
const dock = (await page.getByTestId("run-flow").boundingBox())!
expect(logs.y + logs.height).toBeLessThan(dock.y)
const first = (await page.getByTestId("add-node").boundingBox())!
const last = (await page.getByTestId("publish-flow").boundingBox())!
const centre = (first.x + last.x + last.width) / 2
expect(Math.abs(logs.x + logs.width / 2 - centre)).toBeLessThan(2)
await page.getByTestId("flow-logs").click()
await expect(panel).toBeHidden()
})
test("a node's failure outlives its next good run", async ({ page }) => {
await page.goto(`/flows/${flowName}`)
await page.waitForSelector(".react-flow__node")
const logger = page.locator(".react-flow__node").filter({ hasText: "logger" })
await page.getByTestId("run-flow").click()
await expect(logger.getByTestId("node-traceback")).toBeVisible()
// Fix it the way the editor does, and put it on the engine: publishing
// rebuilds every node, which is exactly what must not wipe the record.
await api(page, `/flows/${flowName}/nodes/logger/source`, {
method: "PUT",
data: { code: FIXED_NODE },
})
const detail = await (await api(page, `/flows/${flowName}`)).json()
await api(page, `/flows/${flowName}/publish`, {
method: "POST",
data: { version: detail.definition.version },
})
await page.getByTestId("run-flow").click()
// The dot says how the *last* run went, and the traceback button says the
// node failed at some point since anyone looked. Both at once is the point.
await expect(logger.getByLabel("Last run succeeded")).toBeVisible()
await expect(logger.getByTestId("node-traceback")).toBeVisible()
})
test("a flow can be paused and let go again", async ({ page }) => {
await page.goto(`/flows/${flowName}`)
await page.waitForSelector(".react-flow__node")
await page.getByTestId("pause-flow").click()
await expect(page.getByTestId("resume-flow")).toBeVisible()
expect((await (await api(page, `/flows/${flowName}`)).json()).paused).toBe(
true,
)
await page.getByTestId("resume-flow").click()
await expect(page.getByTestId("pause-flow")).toBeVisible()
})