import { type Browser, type BrowserContext, expect, type Page, type Request, type Route, test, type WebSocketRoute, } from "@playwright/test" import { api, apiPage, deleteAll } from "./utils/api" /** * Renaming a key while the things bound to it are open. * * Every object here has an id the server issued, so nothing that addresses by * id can pass by accident of the id being the key. The flow is renamed with a * second editor holding a flow that reads it, and with a paired panel showing * a dashboard bound to it, one of which misses the news while it happens. */ const stamp = Date.now().toString(36) const key = `test_rn_${stamp}` const renamed = `${key}_new` const readerKey = `${key}_reader` const boardKey = `${key}_board` const slowKey = `${key}_slow` const deviceId = `${key}_panel` const ids = { flow: "", reader: "", board: "", slow: "" } let deviceToken = "" test.use({ storageState: "playwright/.auth/user.json" }) test.describe.configure({ mode: "serial" }) async function createFlow(page: Page, doc: Record) { const made = await api(page, "/flows/", { method: "POST", data: doc }) expect(made.status()).toBe(201) return (await made.json()).definition.id as string } async function publishFlow(page: Page, id: string) { const detail = await (await api(page, `/flows/${id}`)).json() await api(page, `/flows/${id}/publish`, { method: "POST", data: { version: detail.definition.version }, }) } async function flowDoc(page: Page, id: string) { return (await (await api(page, `/flows/${id}`)).json()).definition } /** A panel signed in with a device's own credential, as a wall screen is. */ async function openPanel(browser: Browser): Promise<[BrowserContext, Page]> { const context = await browser.newContext({ storageState: { cookies: [], origins: [] }, }) await context.addInitScript((token) => { // Also run on the blank page a context opens with, which has no storage. if (location.protocol.startsWith("http")) localStorage.setItem("access_token", token) }, deviceToken) return [context, await context.newPage()] } /** Send a reading in through the flow's webhook, addressed by its id. */ async function send(page: Page, level: number) { await expect .poll(async () => ( await page.request.post(`/hooks/${ids.flow}/ping`, { data: { level }, }) ).status(), ) .toBe(200) } /** Plan a rename in the open dialog and confirm it. */ async function renameTo(page: Page, current: string, next: string) { const dialog = page.getByTestId("rename-dialog") await dialog.getByTestId("rename-key-input").fill(next) await expect(dialog.getByTestId("rename-plan")).toBeVisible() await dialog.getByTestId("rename-key-confirmation").fill(current) await dialog.getByTestId("rename-key-submit").click() } test.beforeAll(async ({ browser }) => { const page = await apiPage(browser) ids.flow = await createFlow(page, { name: key, nodes: [ { id: "hook", type: "http", params: { url: "/ping", method: "POST" }, requires: [], provides: [{ name: "level", dtype: "float" }], }, ], }) await publishFlow(page, ids.flow) // Another flow reading it by its qualified name, which the rename rewrites. ids.reader = await createFlow(page, { name: readerKey, nodes: [ { id: "double", type: "python", requires: [{ name: `${key}.level`, dtype: "float" }], provides: [{ name: "twice", dtype: "float" }], }, ], }) await api(page, `/flows/${ids.reader}/nodes/double/source`, { method: "PUT", data: { code: 'def process(level):\n return {"twice": level * 2}\n' }, }) await publishFlow(page, ids.reader) // A dashboard bound to it, hanging on a panel of its own. const board = await api(page, "/dashboards/", { method: "POST", data: { name: boardKey, title: "Rename board" }, }) const made = await board.json() ids.board = made.id const saved = await ( await api(page, `/dashboards/${ids.board}`, { method: "PUT", data: { ...made, widgets: [ { id: "level", type: "stat", title: "Renamed level", layout: { lg: { x: 0, y: 0, w: 3, h: 2 } }, config: { message: `${key}.level`, dtype: "float" }, }, ], }, }) ).json() await api(page, `/dashboards/${ids.board}/publish`, { method: "POST", data: { version: saved.version }, }) const device = await ( await api(page, `/devices/${deviceId}`, { method: "POST" }) ).json() await api(page, `/devices/${deviceId}`, { method: "PUT", data: { ...device, title: "Rename panel", dashboards: [ids.board] }, }) // Paired as a screen is: a code, approved, then collected by the device. const pairing = await ( await page.request.post("/api/v1/devices/pair", { data: {} }) ).json() await api(page, `/devices/${deviceId}/pair`, { method: "POST", data: { code: pairing.code }, }) deviceToken = ( await ( await page.request.get( `/api/v1/devices/pair/${pairing.code}?secret=${pairing.secret}`, ) ).json() ).access_token expect(deviceToken).toBeTruthy() // A node that keeps a rename waiting while it runs. ids.slow = await createFlow(page, { name: slowKey, nodes: [ { id: "slow", type: "python", provides: [{ name: "done", dtype: "float" }], }, ], }) await api(page, `/flows/${ids.slow}/nodes/slow/source`, { method: "PUT", data: { code: 'import time\n\n\ndef process():\n time.sleep(12)\n return {"done": 1.0}\n', }, }) await publishFlow(page, ids.slow) await page.close() }) test.afterAll(async ({ browser }) => { await deleteAll( browser, [ `/devices/${deviceId}`, ids.board && `/dashboards/${ids.board}`, ids.reader && `/flows/${ids.reader}`, ids.flow && `/flows/${ids.flow}`, ids.slow && `/flows/${ids.slow}`, ].filter(Boolean), ) }) test("a flow rename moves editors, panels and values at the same addresses", async ({ page, browser, }) => { test.setTimeout(120_000) // The panels first, one hearing everything and one that will miss it. const [heard, panel] = await openPanel(browser) const [deafContext, deaf] = await openPanel(browser) const sockets: WebSocketRoute[] = [] let muted = false await deaf.routeWebSocket(/\/api\/v1\/flows\/ws/, (ws) => { const server = ws.connectToServer() sockets.push(ws) server.onMessage((message) => { if (!muted) ws.send(message) }) }) for (const screen of [panel, deaf]) { await screen.goto(`/panel/${deviceId}`) await expect(screen.getByText("Renamed level")).toBeVisible() } await send(page, 7) for (const screen of [panel, deaf]) { await expect(screen.getByTestId("canvas-surface")).toContainText("7") } // A second editor on the flow that reads it, with an edit on its way that // the rename will overtake. const reader = await page.context().newPage() await reader.goto(`/flows/${ids.reader}`) await reader.waitForSelector(".react-flow__node") let held: Route | null = null const heldRequest = new Promise((resolve) => { reader.route(`**/api/v1/flows/${ids.reader}`, (route) => { if (route.request().method() !== "PUT") return route.continue() held = route resolve(route.request()) }) }) await reader.getByTestId("edit-flow").click() await reader.getByLabel("Flow title").fill("Edited before") await reader.getByTestId("confirm-rename").click() const stale = await heldRequest // The rename, from the flow's own editor. await page.goto(`/flows/${ids.flow}`) await page.waitForSelector(".react-flow__node") await page.getByTestId("edit-flow").click() await page.getByTestId("flow-panel").getByTestId("rename-key").click() muted = true const dialog = page.getByTestId("rename-dialog") await dialog.getByTestId("rename-key-input").fill(renamed) const documents = dialog.getByTestId("rename-documents") await expect(documents).toContainText(key) await expect(documents).toContainText(readerKey) await expect(documents).toContainText(boardKey) await expect(dialog.getByTestId("rename-warnings")).toContainText( `/messages/${key}.*`, ) await dialog.getByTestId("rename-key-confirmation").fill(key) await dialog.getByTestId("rename-key-submit").click() await expect(dialog).toBeHidden({ timeout: 30_000 }) // Same address, new key. expect(new URL(page.url()).pathname).toBe(`/flows/${ids.flow}`) await page.getByTestId("edit-flow").click() await expect( page.getByTestId("flow-panel").getByTestId("identity-key"), ).toHaveText(renamed) const reading = (await flowDoc(page, ids.reader)).nodes[0].requires[0].name expect(reading).toBe(`${renamed}.level`) // The other editor took the renamed document at its own address, and the // edit it was still sending is refused rather than written over it. expect(new URL(reader.url()).pathname).toBe(`/flows/${ids.reader}`) const refused = reader.waitForResponse( (response) => response.request() === stale, ) await (held as Route | null)?.continue() expect((await refused).status()).toBe(409) await reader.unroute(`**/api/v1/flows/${ids.reader}`) await expect(reader.getByTestId("save-conflict")).toBeHidden() // Nor does undoing bring the old binding back: that history went too. await reader.locator(".react-flow__pane").click() await reader.keyboard.press("ControlOrMeta+z") await reader.waitForTimeout(1500) const after = await flowDoc(page, ids.reader) expect(after.nodes[0].requires[0].name).toBe(`${renamed}.level`) expect(after.title ?? "").not.toBe("Edited before") // A reading under the new key reaches the panel that heard the rename. await send(page, 9) await expect(panel.getByTestId("canvas-surface")).toContainText("9") // The one that missed it catches up once its socket is back. await expect(deaf.getByTestId("canvas-surface")).not.toContainText("9") muted = false for (const ws of sockets) await ws.close() await expect(deaf.getByTestId("canvas-surface")).toContainText("9", { timeout: 20_000, }) expect(new URL(deaf.url()).pathname).toBe(`/panel/${deviceId}`) await heard.close() await deafContext.close() await reader.close() }) test("a dashboard rename keeps both editors at its address", async ({ page, }) => { const other = await page.context().newPage() await other.goto(`/dashboards/${ids.board}?edit=true`) await expect(other.getByText("Renamed level")).toBeVisible() await page.goto(`/dashboards/${ids.board}?edit=true`) await page.getByTestId("edit-dashboard").click() await page.getByTestId("dashboard-panel").getByTestId("rename-key").click() await renameTo(page, boardKey, `${boardKey}_new`) await expect(page.getByTestId("rename-dialog")).toBeHidden() expect(new URL(page.url()).pathname).toBe(`/dashboards/${ids.board}`) await page.getByTestId("edit-dashboard").click() await expect( page.getByTestId("dashboard-panel").getByTestId("identity-key"), ).toHaveText(`${boardKey}_new`) // The other editor reloaded on the renamed draft, so its next save is // based on it rather than refused or written over it. await other.getByTestId("edit-dashboard").click() const panel = other.getByTestId("dashboard-panel") await expect(panel.getByTestId("identity-key")).toHaveText(`${boardKey}_new`) await panel.getByLabel("Dashboard title").fill("Saved after") await panel.getByTestId("confirm-rename").click() await expect .poll( async () => (await (await api(page, `/dashboards/${ids.board}?draft=true`)).json()) .title, ) .toBe("Saved after") const board = await ( await api(page, `/dashboards/${ids.board}?draft=true`) ).json() expect(board.name).toBe(`${boardKey}_new`) await other.close() }) test("a rename waiting on running work can be called off", async ({ page }) => { test.setTimeout(60_000) await page.goto(`/flows/${ids.slow}`) await page.waitForSelector(".react-flow__node") // Running, and so in the way, before the rename is asked for. const running = api(page, `/flows/${ids.slow}/nodes/slow/trigger`, { method: "POST", data: { values: {} }, timeout: 60_000, }) await page.getByTestId("edit-flow").click() await page.getByTestId("flow-panel").getByTestId("rename-key").click() await renameTo(page, slowKey, `${slowKey}_new`) const dialog = page.getByTestId("rename-dialog") await expect(dialog.getByTestId("rename-busy")).toContainText( `/flows/${ids.slow}/nodes/slow/trigger`, ) await dialog.getByTestId("rename-cancel").click() await expect(dialog.getByTestId("rename-outcome")).toContainText( "Nothing changed", ) expect((await flowDoc(page, ids.slow)).name).toBe(slowKey) expect((await running).status()).toBe(200) })