Docs / docs (push) Successful in 30s
Playwright Tests / test-playwright (1, 2) (push) Successful in 3m7s
Playwright Tests / test-playwright (2, 2) (push) Successful in 1m54s
pre-commit / pre-commit (push) Failing after 4m24s
Test Backend / test-backend (push) Successful in 3m8s
Compose Smoke Test / test-compose (push) Successful in 40s
Playwright Tests / merge-reports (push) Successful in 1m33s
A port may now declare `image`, `audio` or `video`. Each is the artifact
reference the engine already had, narrowed by the `media_type` on it, so a
speech recogniser declares what it eats rather than taking any bytes at all and
finding out. Bytes still never travel as a message and nothing on the wire
stops being JSON: a camera publishes one reference per frame, a microphone one
per chunk, and a reference may carry a `meta` dict nothing here interprets.
Streaming media is therefore an ordinary streaming port — with one change to
what that means. An emission used to journal an item with no payload, so
downstream read whatever was current when the item was claimed; a consumer
slower than its producer saw only the newest chunk and the ones between were
lost. That is right for a training curve and wrong for a second of speech, so
an emission now journals a `kind="emission"` item carrying its values, and the
executor hands them to the nodes reading that message instead of writing them
to state again. The value in state stays the latest, which is what everything
else reads, and the wave is filtered by what actually changed rather than
walking everything reachable. No queue serialization change — the existing
`outputs` field carries it.
Continuous media makes the store's missing GC a real problem, so this closes
it: `sweep_artifacts` runs hourly, keeps every digest a `run_artifact` row
records or a live message holds, spares anything written in the last hour, and
stands aside entirely while a run is in flight, since a node may store a
checkpoint long before it returns the reference to it. That also collects the
orphans a deleted flow has always left behind. `ARTIFACT_GC_INTERVAL_S=0` turns
it off.
Around the edges: `GET /artifacts/{digest}` serves the media type the caller
passes and answers ranged requests, so a browser plays a clip rather than
downloading it; `PUT` spools to disk instead of holding the whole body in
memory, as does `save_artifact` given a path; a Media widget draws whatever its
message points at, and a wall panel may fetch the bytes its own tiles are
showing and nothing else; and a connector gets `save_artifact`, for a device
whose readings are bytes.
What this cannot do is live video: a frame every second or two is a glance, and
the honest answer above that is the camera's own stream, which the widget takes
as a URL and the browser plays from source.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
276 lines
10 KiB
JavaScript
276 lines
10 KiB
JavaScript
/**
|
|
* Visual verification for the integrated local stack (root `make verify`).
|
|
*
|
|
* Logs into the dashboard with the bootstrap superuser and captures the app
|
|
* shell plus the website hero in both themes. This is the standard "did the
|
|
* change actually work" gate — look at the PNGs, do not just trust the build.
|
|
*
|
|
* Env (all set by the root Makefile):
|
|
* APP_URL, WEBSITE_URL, FIRST_SUPERUSER, FIRST_SUPERUSER_PASSWORD
|
|
* SCREENSHOT_DIR (default: ./screenshots)
|
|
*/
|
|
import { mkdir } from "node:fs/promises"
|
|
import { chromium } from "@playwright/test"
|
|
|
|
const APP_URL = process.env.APP_URL || "http://app.localhost"
|
|
const WEBSITE_URL = process.env.WEBSITE_URL || "http://localhost"
|
|
const EMAIL = process.env.FIRST_SUPERUSER
|
|
const PASSWORD = process.env.FIRST_SUPERUSER_PASSWORD
|
|
const OUT = process.env.SCREENSHOT_DIR || "screenshots"
|
|
|
|
if (!EMAIL || !PASSWORD) {
|
|
console.error(
|
|
"FIRST_SUPERUSER / FIRST_SUPERUSER_PASSWORD are unset — run from the root `make verify`.",
|
|
)
|
|
process.exit(1)
|
|
}
|
|
|
|
/**
|
|
* The two shapes the app is drawn for: a desktop, and a phone. Below `md` it
|
|
* is a different layout rather than a narrower one — see the Responsive
|
|
* section of the root DESIGN-GUIDELINES.md — so it wants its own shots.
|
|
*/
|
|
const VIEWPORTS = [
|
|
{ name: "", viewport: { width: 1440, height: 900 } },
|
|
{ name: "mobile", viewport: { width: 390, height: 844 } },
|
|
]
|
|
|
|
/** Force the theme through the same storage key the pre-paint script reads. */
|
|
async function withTheme(browser, theme, viewport) {
|
|
const context = await browser.newContext({
|
|
viewport,
|
|
colorScheme: theme,
|
|
isMobile: viewport.width < 768,
|
|
hasTouch: viewport.width < 768,
|
|
})
|
|
await context.addInitScript((t) => {
|
|
localStorage.setItem("fluksio-ui-theme", t)
|
|
}, theme)
|
|
return context
|
|
}
|
|
|
|
// Chromium pins *.localhost to loopback (RFC 6761), so /etc/hosts cannot point
|
|
// it at Traefik. The containerised run (root `make verify-docker`) passes the
|
|
// mapping through here instead; empty on a host run.
|
|
const RESOLVER = process.env.HOST_RESOLVER_RULES
|
|
const browser = await chromium.launch(
|
|
RESOLVER ? { args: [`--host-resolver-rules=${RESOLVER}`] } : {},
|
|
)
|
|
|
|
for (const theme of ["light", "dark"]) {
|
|
for (const { name, viewport } of VIEWPORTS) {
|
|
const dir = name ? `${OUT}/${theme}/${name}` : `${OUT}/${theme}`
|
|
await mkdir(dir, { recursive: true })
|
|
const context = await withTheme(browser, theme, viewport)
|
|
const page = await context.newPage()
|
|
|
|
await page.goto(`${WEBSITE_URL}/`, { waitUntil: "networkidle" })
|
|
// networkidle fires before the staggered entrance animations settle, which
|
|
// would capture buttons mid-fade and make contrast look broken.
|
|
await page.waitForTimeout(1500)
|
|
await page.screenshot({ path: `${dir}/website-hero.png` })
|
|
|
|
await page.goto(`${APP_URL}/login`, { waitUntil: "networkidle" })
|
|
await page.screenshot({ path: `${dir}/app-login.png` })
|
|
|
|
await page.getByTestId("email-input").fill(EMAIL)
|
|
await page.getByTestId("password-input").fill(PASSWORD)
|
|
await page.getByRole("button", { name: /log in/i }).click()
|
|
await page.waitForURL(`${APP_URL}/`, { timeout: 15000 })
|
|
await page.waitForLoadState("networkidle")
|
|
// Home's sections fetch independently, so networkidle can fall between them
|
|
// and photograph the skeletons. The flow table is the last of them to land.
|
|
await page
|
|
.getByText(/Flow activity/i)
|
|
.first()
|
|
.waitFor({ timeout: 15000 })
|
|
await page.waitForTimeout(1500)
|
|
await page.screenshot({ path: `${dir}/app-dashboard.png` })
|
|
|
|
await captureFlows(page, dir)
|
|
await captureDashboards(page, dir)
|
|
await captureMedia(page, dir)
|
|
await captureRuns(page, dir)
|
|
|
|
await context.close()
|
|
console.log(
|
|
` wrote ${dir}/{website-hero,app-login,app-dashboard,app-flows,app-flow-panel,app-panel,app-runs,app-run,app-run-context}.png`,
|
|
)
|
|
}
|
|
}
|
|
|
|
await browser.close()
|
|
|
|
/**
|
|
* The experiment log: the table, one run in full, and a dashboard read against
|
|
* the runs someone picked. Skipped on an instance that has never run anything,
|
|
* where all three would photograph the same empty state.
|
|
*/
|
|
async function captureRuns(page, dir) {
|
|
await page.goto(`${APP_URL}/runs`, { waitUntil: "networkidle" })
|
|
await page.waitForTimeout(1000)
|
|
await page.screenshot({ path: `${dir}/app-runs.png` })
|
|
|
|
const rows = page.getByTestId("run-row")
|
|
if (!(await rows.count())) return
|
|
|
|
// Two runs compared, which is the whole point of the screen.
|
|
const boxes = page.getByTestId("run-select")
|
|
await boxes.nth(0).click()
|
|
if ((await boxes.count()) > 1) await boxes.nth(1).click()
|
|
await page.waitForTimeout(1500)
|
|
await page.screenshot({ path: `${dir}/app-runs-compare.png`, fullPage: true })
|
|
|
|
// A dashboard read against those two runs: the same page a live run is
|
|
// watched on, showing finished ones. This is the seam the feature exists for.
|
|
const picked = await page
|
|
.getByTestId("run-link")
|
|
.evaluateAll((links) =>
|
|
links.slice(0, 2).map((a) => a.getAttribute("href")),
|
|
)
|
|
const ids = picked
|
|
.map((href) => (href || "").split("/").pop())
|
|
.filter(Boolean)
|
|
// The API is its own host here; the SPA's origin does not proxy /api.
|
|
const apiUrl = APP_URL.replace("//app.", "//api.")
|
|
const results = await page.evaluate(async (base) => {
|
|
const answer = await fetch(`${base}/api/v1/dashboards/`, {
|
|
headers: {
|
|
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
|
},
|
|
})
|
|
if (!answer.ok) return null
|
|
const body = await answer.json()
|
|
return (body.data || [])
|
|
.map((one) => one.name)
|
|
.find((n) => n.endsWith("_results"))
|
|
}, apiUrl)
|
|
if (results && ids.length) {
|
|
await page.goto(`${APP_URL}/view/${results}?runs=${ids.join(",")}`, {
|
|
waitUntil: "networkidle",
|
|
})
|
|
await page.waitForTimeout(2000)
|
|
await page.screenshot({ path: `${dir}/app-run-context.png` })
|
|
}
|
|
|
|
// The dashboard that pins the last few runs, drawn live rather than in a
|
|
// context: the other half of the same idea.
|
|
await page.goto(`${APP_URL}/view/demo_training`, { waitUntil: "networkidle" })
|
|
await page.waitForTimeout(2500)
|
|
await page.screenshot({ path: `${dir}/app-runs-pinned.png` })
|
|
|
|
await page.goto(`${APP_URL}/runs`, { waitUntil: "networkidle" })
|
|
await page.getByTestId("run-link").first().click()
|
|
await page.waitForURL(/\/runs\/.+/, { timeout: 15000 })
|
|
await page.waitForLoadState("networkidle")
|
|
await page.waitForTimeout(1500)
|
|
await page.screenshot({ path: `${dir}/app-run.png`, fullPage: true })
|
|
}
|
|
|
|
/**
|
|
* A dashboard as a wall panel sees it. Seeds one if the instance has none, so
|
|
* the shot shows the grid rather than an empty-state message.
|
|
*/
|
|
async function captureDashboards(page, dir) {
|
|
await page.goto(`${APP_URL}/dashboards`, { waitUntil: "networkidle" })
|
|
|
|
if (!(await page.getByTestId("dashboard-card").count())) {
|
|
await page.getByTestId("new-dashboard").click()
|
|
await page.getByTestId("new-dashboard-name").fill("panel")
|
|
await page.getByTestId("create-dashboard").click()
|
|
await page.waitForURL(/\/dashboards\/.+/, { timeout: 15000 })
|
|
// A widget, so the grid has something in it worth photographing.
|
|
await page.getByTestId("add-widget").click()
|
|
await page.getByTestId("add-widget-stat").click()
|
|
await page.waitForSelector("[data-testid=widget-settings]")
|
|
await page.getByTestId("toggle-edit").click()
|
|
} else {
|
|
await page.getByTestId("dashboard-card").first().click()
|
|
await page.waitForURL(/\/dashboards\/.+/, { timeout: 15000 })
|
|
}
|
|
|
|
await page.waitForTimeout(1500)
|
|
await page.screenshot({ path: `${dir}/app-panel.png` })
|
|
}
|
|
|
|
/**
|
|
* A media tile drawing what a camera published, where there is one.
|
|
*
|
|
* Skipped unless the media example is seeded (root `make seed-example-media`),
|
|
* since it is the one shot that needs a source of frames. The bytes arrive as
|
|
* a blob — the tile fetches them with the session's credential, which no `img`
|
|
* could carry on its own — so a `blob:` source is the proof the whole path ran
|
|
* rather than that a picture is merely present.
|
|
*/
|
|
async function captureMedia(page, dir) {
|
|
const answer = await page.goto(`${APP_URL}/view/camera`, {
|
|
waitUntil: "networkidle",
|
|
})
|
|
if (!answer?.ok()) return
|
|
|
|
const picture = page.locator("img[alt='Test camera']")
|
|
try {
|
|
await picture.waitFor({ timeout: 15000 })
|
|
await page.waitForFunction(
|
|
() =>
|
|
document
|
|
.querySelector("img[alt='Test camera']")
|
|
?.src?.startsWith("blob:") ?? false,
|
|
{ timeout: 15000 },
|
|
)
|
|
} catch {
|
|
console.warn(
|
|
" media tile drew nothing — is `make seed-example-media` run?",
|
|
)
|
|
return
|
|
}
|
|
await page.waitForTimeout(500)
|
|
await page.screenshot({ path: `${dir}/app-media.png` })
|
|
}
|
|
|
|
/**
|
|
* The flow editor, empty-handed if the instance has no flows yet: seeds one
|
|
* with a node so the canvas and the node panel are both worth looking at.
|
|
*/
|
|
async function captureFlows(page, dir) {
|
|
await page.goto(`${APP_URL}/flows`, { waitUntil: "networkidle" })
|
|
|
|
if (await page.getByTestId("flow-card").count()) {
|
|
await page.getByTestId("flow-card").first().click()
|
|
} else {
|
|
await page.getByTestId("new-flow").click()
|
|
await page.getByTestId("new-flow-name").fill("first_flow")
|
|
await page.getByTestId("create-flow").click()
|
|
}
|
|
await page.waitForURL(/\/flows\/.+/, { timeout: 15000 })
|
|
// The canvas paints before the flow detail arrives, so counting nodes right
|
|
// away reads 0 for a populated flow — and the seeding below would then add a
|
|
// node to somebody's real flow. Wait until it says which of the two it is.
|
|
await page
|
|
.locator(".react-flow__node, [data-testid=flow-empty]")
|
|
.first()
|
|
.waitFor({ timeout: 15000 })
|
|
|
|
if (!(await page.locator(".react-flow__node").count())) {
|
|
await page.getByTestId("add-node").click()
|
|
await page
|
|
.getByRole("option", { name: /function/i })
|
|
.first()
|
|
.click()
|
|
await page.waitForSelector(".react-flow__node")
|
|
}
|
|
|
|
// Adding a node opens its panel; the canvas shot wants it out of the way.
|
|
await page.keyboard.press("Escape")
|
|
// The dock and the title bar slide in; let them land before the shutter.
|
|
await page.waitForTimeout(1200)
|
|
await page.screenshot({ path: `${dir}/app-flows.png` })
|
|
|
|
await page.locator(".react-flow__node").first().click()
|
|
await page.waitForSelector("[data-testid=node-panel], .monaco-editor", {
|
|
timeout: 15000,
|
|
})
|
|
await page.waitForTimeout(1500)
|
|
await page.screenshot({ path: `${dir}/app-flow-panel.png` })
|
|
}
|