Files
app/frontend/scripts/capture-screenshots.mjs
T
stroblmeandClaude Opus 5 d471614e6a Push a frame instead of storing and fetching it
The rate the media dtypes could carry was one frame every second or two: each
was a file on the data volume, an event on the socket, and a request back for
the bytes. This closes both halves of that, and they are one feature.

`save_artifact(..., volatile=True)` writes to a `VolatileStore` — the same
content-addressed store, in `/dev/shm`, bounded by size with the oldest falling
out (`ARTIFACT_VOLATILE_BYTES`, 48 MB under the container's raised `shm_size`).
Nothing sweeps it: a frame nobody kept is not worth walking the store to find.
`ArtifactStore.path` falls through to it, which is what lets a volatile frame be
an ordinary reference everywhere else — the dtype check, a panel's digest scope,
`load_artifact` in a node, and the widget's own fetch all work on one unchanged.
`adopt` copies one into the store when a run records it, so "returned media is
kept, emitted media is not" stays true.

The bytes then go down the flows websocket as a length-prefixed binary frame,
sent just ahead of the `message_value` naming them, so a tile has the frame when
it hears the value moved. Nothing is pushed unasked: a client names the messages
it is drawing (`{"type":"media","names":[…]}`), a panel's list is intersected
with the scope it already had, and only the newest frame per name in a batch is
sent — a client that fell behind is not handed frames it would draw over. The
tunnel relays text only, so a screen reached through a portal falls back to
fetching, which is why the rate table now has two rows.

Around the edges: the remote worker's fetch cache is bounded at last
(`FLUKSIO_ARTIFACT_CACHE_BYTES`), since content addressing means nothing in it
ever expires and a media stream fills it with chunks nothing asks for twice; a
port carrying an image draws the frame in the node panel rather than only
saying `image/png · frame.png · 1.79kB`; and an edge chip says that much instead
of a line of hash. The media screenshot stops waiting for `networkidle` — a
camera is a socket that never goes quiet, which is the point of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YC4u66vjzW54fnHu5Juhh9
2026-09-02 10:15:14 +02:00

290 lines
11 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(/activity over the last/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 captureWorkers(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,app-workers}.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 — pushed down the socket, or fetched 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.
*
* The one page that cannot wait for `networkidle`: a camera publishing several
* frames a second is a socket that never goes quiet, which is the point of it.
* The blob source below is a stronger wait anyway — it says a frame arrived,
* where idleness only ever said the page stopped asking.
*/
async function captureMedia(page, dir) {
const answer = await page.goto(`${APP_URL}/view/camera`, {
waitUntil: "domcontentloaded",
})
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.
*/
/** Every machine a node can run on, and the sizes it can ask for. */
async function captureWorkers(page, dir) {
await page.goto(`${APP_URL}/workers`, { waitUntil: "networkidle" })
await page.getByText(/^Sizes$/).waitFor({ timeout: 15000 })
await page.waitForTimeout(500)
await page.screenshot({ path: `${dir}/app-workers.png`, fullPage: true })
}
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` })
}