- flow/devices.py: DeviceDef in FLOWS_DIR/_devices/<id>.json, committed under
the flow store's write lock, 409 on a stale version; nonces in DEVICES_FILE
outside git; in-memory presence announced as device_status
- api/routes/devices.py: CRUD, pairing with an optional {name, board,
firmware}, a pending list adopted without a code from the LAN only, unpair;
/panels/pair, /panels/pair/{code} and GET /panels/{id} kept as aliases,
PUT /panels/ gone
- _device_may replaces _panel_may, JWT claims unchanged
- panels.json moves into devices once at startup, then .migrated
- MCP list_devices, get_device; search category device
- Devices tab (list, detail with presence, pairing, ordered dashboards,
touch, I/O), PanelsDialog removed, serialised optimistic saves
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
339 lines
13 KiB
JavaScript
339 lines
13 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 captureDevices(page, dir)
|
|
await captureMedia(page, dir)
|
|
await captureRound(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-devices,app-device,app-round,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 })
|
|
|
|
// The API is a path on the SPA's own origin.
|
|
const results = await page.evaluate(async () => {
|
|
const answer = await fetch("/api/v1/dashboards/", {
|
|
headers: {
|
|
Authorization: `Bearer ${localStorage.getItem("access_token")}`,
|
|
},
|
|
}).catch(() => null)
|
|
if (!answer?.ok) return null
|
|
const body = await answer.json()
|
|
return (body.data || [])
|
|
.map((one) => one.name)
|
|
.find((n) => n.endsWith("_results"))
|
|
})
|
|
|
|
// A dashboard read against two runs: the same page a live run is watched
|
|
// on, showing finished ones. This is the seam the feature exists for — and
|
|
// it draws nothing unless the runs are the ones that dashboard reads, so
|
|
// the table is filtered to its flow rather than read from the top. Another
|
|
// flow's run finishing later would otherwise photograph an empty grid.
|
|
if (results) {
|
|
const flow = results.replace(/_results$/, "")
|
|
await page.goto(`${APP_URL}/runs?flow=${flow}`, {
|
|
waitUntil: "networkidle",
|
|
})
|
|
const ids = await page.getByTestId("run-link").evaluateAll((links) =>
|
|
links
|
|
.slice(0, 2)
|
|
.map((a) => (a.getAttribute("href") || "").split("/").pop())
|
|
.filter(Boolean),
|
|
)
|
|
if (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` })
|
|
}
|
|
|
|
/** The Devices tab, and one device: the Demo's when it is seeded. */
|
|
async function captureDevices(page, dir) {
|
|
await page.goto(`${APP_URL}/devices`, { waitUntil: "networkidle" })
|
|
await page.waitForTimeout(1000)
|
|
await page.screenshot({ path: `${dir}/app-devices.png` })
|
|
|
|
const demo = page.locator("a[href$='/devices/demo']")
|
|
const card = (await demo.count()) ? demo : page.getByTestId("device-card")
|
|
if (!(await card.count())) return
|
|
await card.first().click()
|
|
await page.getByTestId("device-presence").waitFor({ timeout: 15000 })
|
|
await page.waitForLoadState("networkidle")
|
|
await page.waitForTimeout(1000)
|
|
await page.screenshot({ path: `${dir}/app-device.png`, fullPage: true })
|
|
}
|
|
|
|
/**
|
|
* A media tile drawing what a camera published: the Demo's Media screen,
|
|
* whose house is drawn by a node as heat.
|
|
*
|
|
* Skipped unless the Demo is seeded (root `make seed-hosted-demo`), 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/media`, {
|
|
waitUntil: "domcontentloaded",
|
|
})
|
|
if (!answer?.ok()) return
|
|
|
|
const picture = page.locator("img[alt='The house, as heat']")
|
|
try {
|
|
await picture.waitFor({ timeout: 15000 })
|
|
await page.waitForFunction(
|
|
() =>
|
|
document
|
|
.querySelector("img[alt='The house, as heat']")
|
|
?.src?.startsWith("blob:") ?? false,
|
|
{ timeout: 15000 },
|
|
)
|
|
} catch {
|
|
console.warn(" media tile drew nothing — is the Demo seeded?")
|
|
return
|
|
}
|
|
await page.waitForTimeout(500)
|
|
await page.screenshot({ path: `${dir}/app-media.png` })
|
|
}
|
|
|
|
/**
|
|
* A round dashboard, on the panel that carries it: the Demo's thermostat on
|
|
* `/panel/knob`, ring and all. Skipped where the Demo is not seeded.
|
|
*/
|
|
async function captureRound(page, dir) {
|
|
const answer = await page.goto(`${APP_URL}/panel/knob`, {
|
|
waitUntil: "networkidle",
|
|
})
|
|
if (!answer?.ok()) return
|
|
try {
|
|
await page.getByTestId("ring").waitFor({ timeout: 10000 })
|
|
} catch {
|
|
console.warn(" no round dashboard drew — is the Demo seeded?")
|
|
return
|
|
}
|
|
await page.waitForTimeout(1000)
|
|
await page.screenshot({ path: `${dir}/app-round.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" })
|
|
|
|
// The Demo's music flow, where it is seeded: two nodes, live values on the
|
|
// edges and the tiles at both ends, all still legible once fitted. The house
|
|
// has fifteen nodes, which fitted to the screen read as dots.
|
|
const demo = page.locator("[data-testid=flow-card][href$='/flows/music']")
|
|
if (await demo.count()) {
|
|
await demo.click()
|
|
} else 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` })
|
|
}
|