The canvas lays itself out: a layered graph, left to right on a desktop and top to bottom on a phone, with room reserved for the value each edge carries. Nodes cannot be dragged and `NodeDef.position` is gone from the document — a graph nobody can arrange is one worth keeping small, which is what keeps flows atomic. Endpoints join the same layout, so their lanes and the localStorage that remembered where they were dragged go too. Mobile, per the new Responsive section of DESIGN-GUIDELINES.md: the dock caps its width and wraps instead of running off the screen, the dashboard stacks into one column rather than shrinking a wall panel to a fifth of its size, and Home stops widening its grid track past the viewport. A Playwright project at a phone's width fails the build when a screen no longer fits. Along the way: publish is the checkmark that was already there rather than a button that appears and disappears, with discard beside it on both the flow and the dashboard; the brain reveals a neuron's name on the first tap; and the port sparklines get room to breathe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VDSXaRhvqHYNevgDGmNAto
173 lines
6.5 KiB
JavaScript
173 lines
6.5 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 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` })
|
|
}
|