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>
99 lines
3.3 KiB
TypeScript
99 lines
3.3 KiB
TypeScript
import { expect, type Page, test } from "@playwright/test"
|
|
|
|
/**
|
|
* Editing writes a draft; only publishing hands it to the engine. The two
|
|
* things worth proving here are that the engine ignores a draft until it is
|
|
* published, and that a second client cannot quietly overwrite the first.
|
|
*/
|
|
|
|
const flowName = `test_draft_${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"
|
|
|
|
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("a draft stays off the engine until it is published", async ({ page }) => {
|
|
await page.goto("/flows")
|
|
await page.getByRole("button", { name: "New flow" }).click()
|
|
await page.getByTestId("flow-name-input").fill(flowName)
|
|
await page.getByRole("button", { name: "Create flow" }).click()
|
|
await page.waitForURL(`/flows/${flowName}`)
|
|
|
|
await page.getByTestId("add-node").click()
|
|
await page
|
|
.getByRole("option", { name: /function/i })
|
|
.first()
|
|
.click()
|
|
await expect(page.locator(".react-flow__node")).toHaveCount(1)
|
|
// Adding a node opens its panel, and the floating chrome steps aside for it.
|
|
await page.keyboard.press("Escape")
|
|
|
|
await expect(page.getByTestId("publish-flow")).toBeVisible()
|
|
|
|
// Nothing of this flow is loaded while it is only a draft.
|
|
await page.waitForTimeout(1500)
|
|
const before = await (await api(page, `/flows/${flowName}/state`)).json()
|
|
expect(before.nodes).toHaveLength(0)
|
|
|
|
await page.getByTestId("publish-flow").click()
|
|
await expect(page.getByTestId("publish-flow")).toBeHidden()
|
|
|
|
const after = await (await api(page, `/flows/${flowName}/state`)).json()
|
|
expect(after.nodes.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
test("a save against a version someone else moved on from is refused", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto(`/flows/${flowName}`)
|
|
await page.waitForSelector(".react-flow__node")
|
|
|
|
const detail = await (await api(page, `/flows/${flowName}`)).json()
|
|
|
|
// Stand in for a second client that saved first.
|
|
const first = await api(page, `/flows/${flowName}`, {
|
|
method: "PUT",
|
|
data: { ...detail.definition, title: "Theirs" },
|
|
})
|
|
expect(first.ok()).toBeTruthy()
|
|
|
|
const stale = await api(page, `/flows/${flowName}`, {
|
|
method: "PUT",
|
|
data: { ...detail.definition, title: "Mine" },
|
|
})
|
|
expect(stale.status()).toBe(409)
|
|
expect((await stale.json()).detail.current_version).toBe(
|
|
detail.definition.version + 1,
|
|
)
|
|
})
|
|
|
|
test("discarding a draft goes back to the published flow", async ({ page }) => {
|
|
await page.goto(`/flows/${flowName}`)
|
|
await page.waitForSelector(".react-flow__node")
|
|
|
|
await page.getByTestId("edit-flow").click()
|
|
await page.getByTestId("discard-draft").click()
|
|
await page.getByTestId("confirm-discard-draft").click()
|
|
|
|
await expect(page.getByTestId("publish-flow")).toBeHidden()
|
|
const detail = await (await api(page, `/flows/${flowName}`)).json()
|
|
expect(detail.has_draft).toBe(false)
|
|
expect(detail.definition.title).not.toBe("Theirs")
|
|
|
|
await api(page, `/flows/${flowName}`, { method: "DELETE" })
|
|
})
|