Files

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)
})