One shell for both editors. Flows and dashboards each get a searchable
overview under the padded shell, their editors move to the full-bleed
canvas, and the floating chrome is shared: a title bar that only says
what you are looking at, and a bottom dock carrying everything else —
the flow bar's status, settings and Publish moved down there, the
add-flow button moved to the overview.
Dashboards gain the rest of M4's visualization work:
- widgets are picked by clicking them, with the header as the drag
handle so a slider still slides and a switch still flips while
editing; settings moved into the flows' SidePanel
- react-grid-layout for drag and edge-resize, so the stored x/y finally
mean something; a dashboard nobody arranged is shelf-packed once
- a per-dashboard grid size, so a panel can be matched to its screen
- the chart widget, drawn with uPlot: several messages on one axis, fed
from the stored history plus the live socket tail, coloured from the
new --chart-1..5 ramp
- /view/{name}: the URL a wall panel is pointed at — no sidebar, no
footer, no editing, and no editor code, since routes are split
- a widget wired to a payload type it cannot carry, or wired to nothing
at all, carries the same red dot a failing node does; the picker
records the type it bound and WidgetDef refuses a mismatch on save
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
143 lines
5.2 KiB
JavaScript
143 lines
5.2 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)
|
|
}
|
|
|
|
/** Force the theme through the same storage key the pre-paint script reads. */
|
|
async function withTheme(browser, theme) {
|
|
const context = await browser.newContext({
|
|
viewport: { width: 1440, height: 900 },
|
|
colorScheme: theme,
|
|
})
|
|
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"]) {
|
|
const dir = `${OUT}/${theme}`
|
|
await mkdir(dir, { recursive: true })
|
|
const context = await withTheme(browser, theme)
|
|
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")
|
|
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-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-name").fill("first_flow")
|
|
await page.getByTestId("create-flow").click()
|
|
}
|
|
await page.waitForURL(/\/flows\/.+/, { 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` })
|
|
}
|