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. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013TTfoK82awm8wvxXhHz3XF
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)
|
|
})
|