The site promises simulated inputs and mocked sensor values, and nothing in the app was that. A run already is: the values are the caller's, the state is the run's own namespace, and nothing it computes reaches the live flow. What was missing was a screen to do it from, and the draft flag being honoured. `/runs/new` is a flow, a field per declared input, a seed and Run; `/runs` stays the log. A comma-separated list in a number field expands into the grid `fluksio sweep --param` builds and goes to the sweep route, so launching one no longer needs a terminal. Only numbers split: a comma in a string is content, and one in JSON is syntax. `RunCreate.draft` was validated at submit and dropped before the run executed, so "try the working copy" ran the published one. `Run.draft` is a column now, the driver reads the same copy the submit checked, and a retry carries it. `FlowSummary.mode` came with it so the rail can say which flows are batch before one is picked. Also here: a Retry button on a finished run, which the route has always had and the UI never did, and parameter cells truncated to their column with the full value on hover.
177 lines
5.7 KiB
TypeScript
177 lines
5.7 KiB
TypeScript
import { expect, test } from "@playwright/test"
|
|
import { api, apiPage, deleteAll } from "./utils/api"
|
|
|
|
/**
|
|
* What the runs screen can do to a run, rather than what it can show about one.
|
|
*
|
|
* The reading half is covered by the run itself being there; these are the two
|
|
* things that change something — a delete, and a download — plus the two links
|
|
* a run makes to what produced it.
|
|
*/
|
|
|
|
const flowName = `test_runs_${Date.now().toString(36)}`
|
|
|
|
test.use({ storageState: "playwright/.auth/user.json" })
|
|
test.describe.configure({ mode: "serial" })
|
|
|
|
// Yields a curve so the run has something to draw, which is what the zoom
|
|
// below needs; the streaming port is what makes those yields a series.
|
|
const TRAIN = `def process(epochs):
|
|
for step in range(epochs):
|
|
yield {"loss": 1.0 / (step + 1)}
|
|
return {"score": epochs * 0.5}
|
|
`
|
|
|
|
test.afterAll(async ({ browser }) => {
|
|
await deleteAll(browser, [`/flows/${flowName}`])
|
|
})
|
|
|
|
test.beforeAll(async ({ browser }) => {
|
|
const page = await apiPage(browser)
|
|
await api(page, `/flows/${flowName}`, {
|
|
method: "PUT",
|
|
data: {
|
|
name: flowName,
|
|
title: "Runs under test",
|
|
// `rate` is declared and never passed, which is what puts a default in
|
|
// the Inputs panel below.
|
|
inputs: [
|
|
{ spec: { name: "epochs", dtype: "int" }, initial: 2 },
|
|
{ spec: { name: "rate", dtype: "float" }, initial: 0.5 },
|
|
],
|
|
nodes: [
|
|
{
|
|
id: "train",
|
|
type: "python",
|
|
requires: [{ name: "epochs", dtype: "int" }],
|
|
provides: [
|
|
{ name: "loss", dtype: "float", stream: true },
|
|
{ name: "score", dtype: "float" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
await api(page, `/flows/${flowName}/nodes/train/source`, {
|
|
method: "PUT",
|
|
data: { code: TRAIN },
|
|
})
|
|
const detail = await (await api(page, `/flows/${flowName}`)).json()
|
|
await api(page, `/flows/${flowName}/publish`, {
|
|
method: "POST",
|
|
data: { version: detail.definition.version },
|
|
})
|
|
|
|
for (const epochs of [4, 6]) {
|
|
const answer = await api(page, `/runs/flows/${flowName}`, {
|
|
method: "POST",
|
|
data: { params: { epochs } },
|
|
})
|
|
expect(answer.ok()).toBeTruthy()
|
|
}
|
|
await page.close()
|
|
})
|
|
|
|
/** The screen filtered to this flow, once both runs have stopped moving. */
|
|
async function openRuns(page: import("@playwright/test").Page) {
|
|
await page.goto(`/runs?flow=${flowName}`)
|
|
await expect(page.getByTestId("run-row")).toHaveCount(2)
|
|
await expect(page.getByText(/queued|running/)).toHaveCount(0, {
|
|
timeout: 30_000,
|
|
})
|
|
}
|
|
|
|
test("a run names the flow it came from, and links to it", async ({ page }) => {
|
|
await page.goto("/runs")
|
|
const link = page
|
|
.getByTestId("run-row")
|
|
.filter({ hasText: flowName })
|
|
.first()
|
|
.getByRole("link", { name: flowName })
|
|
await link.click()
|
|
await page.waitForURL(`**/flows/${flowName}`)
|
|
})
|
|
|
|
test("an input the run never passed reads as the flow's own", async ({
|
|
page,
|
|
}) => {
|
|
await openRuns(page)
|
|
await page.getByTestId("run-link").first().click()
|
|
|
|
const inputs = page.locator("section", { hasText: "Inputs" }).last()
|
|
// Passed, so no marker.
|
|
await expect(inputs).toContainText("epochs")
|
|
// Declared and left alone: the value shows, and says where it came from.
|
|
await expect(inputs).toContainText("rate")
|
|
await expect(inputs).toContainText("0.5")
|
|
await expect(inputs.getByText("default").first()).toBeVisible()
|
|
})
|
|
|
|
test("a chart can be dragged into and double-clicked back out of", async ({
|
|
page,
|
|
}) => {
|
|
await openRuns(page)
|
|
await page.getByTestId("run-link").first().click()
|
|
|
|
const plot = page.locator(".u-over").first()
|
|
await expect(plot).toBeVisible()
|
|
const box = await plot.boundingBox()
|
|
if (!box) throw new Error("the chart has no box to drag across")
|
|
|
|
const y = box.y + box.height / 2
|
|
await page.mouse.move(box.x + box.width * 0.3, y)
|
|
await page.mouse.down()
|
|
await page.mouse.move(box.x + box.width * 0.7, y, { steps: 8 })
|
|
await page.mouse.up()
|
|
|
|
const reset = page.getByTestId("chart-reset-zoom")
|
|
await expect(reset).toBeVisible()
|
|
await plot.dblclick()
|
|
await expect(reset).toBeHidden()
|
|
})
|
|
|
|
test("the export button downloads the selection", async ({ page }) => {
|
|
await openRuns(page)
|
|
await page.getByTestId("export-runs").click()
|
|
|
|
const download = page.waitForEvent("download")
|
|
await page.getByRole("menuitem", { name: /runs table/i }).click()
|
|
expect((await download).suggestedFilename()).toBe("runs.csv")
|
|
})
|
|
|
|
test("picked runs can be deleted", async ({ page }) => {
|
|
await openRuns(page)
|
|
for (const box of await page.getByTestId("run-select").all())
|
|
await box.click()
|
|
|
|
await page.getByTestId("delete-selected").click()
|
|
await page.getByTestId("confirm-delete").click()
|
|
|
|
await expect(page.getByTestId("run-row")).toHaveCount(0, { timeout: 30_000 })
|
|
})
|
|
|
|
test("a run is started from the form", async ({ page }) => {
|
|
await page.goto(`/runs/new?flow=${flowName}`)
|
|
await page.getByLabel("epochs", { exact: true }).fill("3")
|
|
await page.getByTestId("submit-new-run").click()
|
|
|
|
// The result card is the run just started, and it fills in as it goes.
|
|
const result = page.getByTestId("run-result")
|
|
await expect(result).toBeVisible()
|
|
await expect(result.getByText("score")).toBeVisible({ timeout: 30_000 })
|
|
await expect(page.getByTestId("recent-run").first()).toBeVisible()
|
|
})
|
|
|
|
test("a list of values is a sweep", async ({ page }) => {
|
|
await page.goto(`/runs/new?flow=${flowName}`)
|
|
await page.getByLabel("epochs", { exact: true }).fill("2,3")
|
|
|
|
const run = page.getByTestId("submit-new-run")
|
|
await expect(run).toHaveText("Run 2")
|
|
await run.click()
|
|
|
|
// Submitting a sweep lands on the history, filtered to that group.
|
|
await page.waitForURL(/\/runs\?.*group=/)
|
|
await expect(page.getByTestId("run-row")).toHaveCount(2)
|
|
})
|