Edits autosave to flow.draft.json and nodes.draft/ instead of the files the engine reads, so the pipeline keeps running the published version until someone publishes. Every save carries the version it was based on: a second client editing the same flow is refused with 409 and offered the choice between their version and its own, rather than silently overwriting. Draft saves no longer rebuild the pipeline; validation and node status for a draft come from a throwaway build that never touches live state. Also fixes a latent bug where an empty state backend is falsy, so Pipeline quietly built itself a second, private state and left message history empty. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
95 lines
3.3 KiB
TypeScript
95 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" })
|
|
})
|