381 lines
13 KiB
TypeScript
381 lines
13 KiB
TypeScript
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<string, unknown>) {
|
|
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<Request>((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)
|
|
})
|