236 lines
7.3 KiB
TypeScript
236 lines
7.3 KiB
TypeScript
import { expect, type Page, test } from "@playwright/test"
|
|
import { api, apiPage, deleteAll } from "./utils/api"
|
|
|
|
/**
|
|
* Objects made the ordinary way, with an id the server issues.
|
|
*
|
|
* Every other spec pins its id to its key, and with the two equal a key
|
|
* passed where an id belongs still works. Here they differ, so each place the
|
|
* app has to tell them apart is exercised: routes and API calls by id, live
|
|
* values, hooks and message names by key.
|
|
*/
|
|
|
|
const stamp = Date.now().toString(36)
|
|
const key = `test_ids_${stamp}`
|
|
const boardKey = `${key}_board`
|
|
const batchKey = `${key}_batch`
|
|
const FLOW_ID = /^f_[a-z2-7]{8}$/
|
|
const BOARD_ID = /^d_[a-z2-7]{8}$/
|
|
|
|
let flowId = ""
|
|
let boardId = ""
|
|
let batchId = ""
|
|
let runId = ""
|
|
let resultsId = ""
|
|
|
|
test.use({
|
|
storageState: "playwright/.auth/user.json",
|
|
permissions: ["clipboard-read", "clipboard-write"],
|
|
})
|
|
test.describe.configure({ mode: "serial" })
|
|
|
|
/** Publish a flow's working copy, whatever version it has reached. */
|
|
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 },
|
|
})
|
|
}
|
|
|
|
test.beforeAll(async ({ browser }) => {
|
|
const page = await apiPage(browser)
|
|
|
|
// A webhook feeding `level`, created without an id.
|
|
const flow = await api(page, "/flows/", {
|
|
method: "POST",
|
|
data: {
|
|
name: key,
|
|
nodes: [
|
|
{
|
|
id: "hook",
|
|
type: "http",
|
|
params: { url: "/ping", method: "POST" },
|
|
requires: [],
|
|
provides: [{ name: "level", dtype: "float" }],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
expect(flow.status()).toBe(201)
|
|
flowId = (await flow.json()).definition.id
|
|
expect(flowId).toMatch(FLOW_ID)
|
|
|
|
// A dashboard reading it, bound by the flow's key.
|
|
const board = await api(page, "/dashboards/", {
|
|
method: "POST",
|
|
data: { name: boardKey, title: "Issued board" },
|
|
})
|
|
expect(board.status()).toBe(201)
|
|
const made = await board.json()
|
|
boardId = made.id
|
|
expect(boardId).toMatch(BOARD_ID)
|
|
const saved = await (
|
|
await api(page, `/dashboards/${boardId}`, {
|
|
method: "PUT",
|
|
data: {
|
|
...made,
|
|
widgets: [
|
|
{
|
|
id: "level",
|
|
type: "stat",
|
|
title: "Issued level",
|
|
layout: { lg: { x: 0, y: 0, w: 3, h: 2 } },
|
|
config: { message: `${key}.level`, dtype: "float" },
|
|
},
|
|
],
|
|
},
|
|
})
|
|
).json()
|
|
await api(page, `/dashboards/${boardId}/publish`, {
|
|
method: "POST",
|
|
data: { version: saved.version },
|
|
})
|
|
|
|
// A batch flow with one finished run, for its results dashboard.
|
|
const batch = await api(page, "/flows/", {
|
|
method: "POST",
|
|
data: {
|
|
name: batchKey,
|
|
mode: "batch",
|
|
outputs: ["score"],
|
|
nodes: [
|
|
{
|
|
id: "score",
|
|
type: "python",
|
|
provides: [{ name: "score", dtype: "float" }],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
batchId = (await batch.json()).definition.id
|
|
expect(batchId).toMatch(FLOW_ID)
|
|
await api(page, `/flows/${batchId}/nodes/score/source`, {
|
|
method: "PUT",
|
|
data: { code: 'def process():\n return {"score": 1.5}\n' },
|
|
})
|
|
await publishFlow(page, batchId)
|
|
const run = await api(page, `/runs/flows/${batchId}`, {
|
|
method: "POST",
|
|
data: { params: {} },
|
|
})
|
|
expect(run.ok()).toBeTruthy()
|
|
runId = (await run.json()).id
|
|
await page.close()
|
|
})
|
|
|
|
test.afterAll(async ({ browser }) => {
|
|
await deleteAll(
|
|
browser,
|
|
[
|
|
resultsId && `/dashboards/${resultsId}`,
|
|
boardId && `/dashboards/${boardId}`,
|
|
flowId && `/flows/${flowId}`,
|
|
batchId && `/flows/${batchId}`,
|
|
// After the flow, as the editor does it, and only by its id.
|
|
batchId && `/runs?flow=${batchId}&history=true`,
|
|
].filter(Boolean),
|
|
)
|
|
})
|
|
|
|
test("a flow opens, saves and publishes by its issued id", async ({ page }) => {
|
|
await page.goto(`/flows/${flowId}`)
|
|
await page.waitForSelector(".react-flow__node")
|
|
// The tab is named from the document, never from the opaque address.
|
|
await expect(page).toHaveTitle(`${key} - Fluksio`)
|
|
|
|
await page.getByTestId("edit-flow").click()
|
|
const panel = page.getByTestId("flow-panel")
|
|
await expect(panel.getByTestId("identity-key")).toHaveText(key)
|
|
await expect(panel.getByTestId("identity-id")).toHaveText(flowId)
|
|
|
|
await panel.getByLabel("Flow title").fill("Issued id")
|
|
await panel.getByTestId("confirm-rename").click()
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(await (await api(page, `/flows/${flowId}`)).json()).definition.title,
|
|
)
|
|
.toBe("Issued id")
|
|
await expect(page).toHaveTitle("Issued id - Fluksio")
|
|
|
|
await page.getByTestId("publish-flow").click()
|
|
await expect(page.getByTestId("discard-draft")).toBeHidden()
|
|
const detail = await (await api(page, `/flows/${flowId}`)).json()
|
|
expect(detail.has_draft).toBe(false)
|
|
expect(detail.definition.name).toBe(key)
|
|
})
|
|
|
|
test("its hook URL carries the id, and a call through it shows up live", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto(`/flows/${flowId}?node=hook`)
|
|
const url = page.getByTestId("hook-url")
|
|
await expect(url).toHaveText(new RegExp(`/hooks/${flowId}/ping$`))
|
|
const hook = (await url.textContent()) ?? ""
|
|
|
|
await page.getByRole("button", { name: "Copy the webhook URL" }).click()
|
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(hook)
|
|
|
|
// The route is mounted once the published flow is rebuilt.
|
|
await expect
|
|
.poll(async () =>
|
|
(await page.request.post(hook, { data: { level: 7 } })).status(),
|
|
)
|
|
.toBe(200)
|
|
|
|
// The value is named by the key, which is what the canvas matches on.
|
|
await expect(page.locator(".react-flow__edgelabel-renderer")).toContainText(
|
|
"7",
|
|
{ timeout: 15000 },
|
|
)
|
|
|
|
// The dashboard drawn on the canvas opens by its own id.
|
|
await page.getByText("Issued level").first().click()
|
|
await page.waitForURL(`**/dashboards/${boardId}`)
|
|
await expect(page).toHaveTitle("Issued board - Fluksio")
|
|
await expect(page.getByTestId("canvas-surface")).toContainText("7")
|
|
})
|
|
|
|
test("a dashboard shows its key and id, and its view opens by the id", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto(`/dashboards/${boardId}?edit=true`)
|
|
await page.getByTestId("edit-dashboard").click()
|
|
const panel = page.getByTestId("dashboard-panel")
|
|
await expect(panel.getByTestId("identity-key")).toHaveText(boardKey)
|
|
await expect(panel.getByTestId("identity-id")).toHaveText(boardId)
|
|
|
|
await page.goto(`/view/${boardId}`)
|
|
await expect(page).toHaveTitle("Issued board - Fluksio")
|
|
await expect(page.getByText("Issued level")).toBeVisible()
|
|
})
|
|
|
|
test("a run's results dashboard is built and opened by its issued id", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/")
|
|
await expect
|
|
.poll(
|
|
async () => (await (await api(page, `/runs/${runId}`)).json()).status,
|
|
{ timeout: 30_000 },
|
|
)
|
|
.toBe("ok")
|
|
|
|
await page.goto(`/runs/${runId}`)
|
|
// Labelled by the flow's key, linked by its id.
|
|
await expect(page.getByRole("link", { name: batchKey })).toBeVisible()
|
|
await page.getByRole("button", { name: "Open in dashboard" }).click()
|
|
await page.getByText(`Build a results dashboard for ${batchKey}`).click()
|
|
|
|
await page.waitForURL(/\/view\/d_[a-z2-7]{8}\?runs=/)
|
|
resultsId = new URL(page.url()).pathname.split("/").pop() ?? ""
|
|
const results = await (await api(page, `/dashboards/${resultsId}`)).json()
|
|
expect(results.name).toBe(`${batchKey}_results`)
|
|
})
|