/** * 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 context.close() console.log( ` wrote ${dir}/{website-hero,app-login,app-dashboard,app-flows,app-flow-panel,app-panel}.png`, ) } } await browser.close() /** * 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` }) } /** * 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` }) }