Start, stop and pause flows, and show what their nodes print
Flows can now be taken off the engine and put back. Stopped state lives in a runtime.json beside the flow, not in the flow document: the canvas autosaves that document, so a stopped flow would otherwise start itself again on the next edit. A stopped flow gets no subscriptions, schedules or webhooks, its nodes are skipped by the scheduler, and running it answers 409. Pausing holds a flow's nodes while its values keep arriving, so the canvas still shows what is coming in. Node code is user code and print is how it says things, so stdout is teed through a contextvar sink active only during a node execution — one event per execution, capped, so a chatty node cannot outrun the stream. A node that fails sends its traceback the same way, trimmed to the author's own frames. The dock gains a logs panel and a pause control; the dashboard replaces its placeholder with what is running, stopped or failing; the edge inspector can send the last message again. Single-stepping is deferred and noted: the scheduler keeps no progress between calls, so a step button would re-run the same node rather than advance. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
606ab3c423
commit
7344eac262
@@ -0,0 +1,128 @@
|
||||
import { expect, type Page, test } from "@playwright/test"
|
||||
|
||||
/**
|
||||
* 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 apiUrl = process.env.VITE_API_URL || "http://api.localhost"
|
||||
|
||||
const PRINTING_NODE = `def process(params):
|
||||
print("sensor read 21.5 degrees")
|
||||
return {"reading": 21.5}
|
||||
`
|
||||
const BROKEN_NODE = `def process(reading, params):
|
||||
raise RuntimeError("downstream blew up")
|
||||
`
|
||||
|
||||
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("/")
|
||||
await api(page, `/flows/${flowName}`, {
|
||||
method: "PUT",
|
||||
data: {
|
||||
name: flowName,
|
||||
title: "Runtime",
|
||||
nodes: [
|
||||
{
|
||||
id: "sensor",
|
||||
type: "python",
|
||||
position: { x: 0, y: 0 },
|
||||
provides: [{ name: "reading", dtype: "float" }],
|
||||
},
|
||||
{
|
||||
id: "logger",
|
||||
type: "python",
|
||||
position: { x: 260, y: 0 },
|
||||
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 dashboard lists flows and can stop one", async ({ page }) => {
|
||||
await page.goto("/")
|
||||
const row = page
|
||||
.getByTestId("dashboard-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.locator('[data-slot="popover-content"]')
|
||||
await expect(panel).toContainText("sensor read 21.5 degrees")
|
||||
await expect(panel).toContainText("RuntimeError")
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
await api(page, `/flows/${flowName}`, { method: "DELETE" })
|
||||
})
|
||||
Reference in New Issue
Block a user