/** * 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` }) }