Node source (Python) and the JSON dialog share one CodeMirror editor with find and replace, autocomplete, folding, indent guides, comment toggle and a JSON lint, themed from the CSS tokens so light and dark follow the .dark class without a second palette. Programmatic value changes no longer fire onChange, blur ignores focus moving into the find bar, readOnly keeps selection and copy. The editor chunk is 470 kB (157 kB gzipped) against 2.7 MB (704 kB) for Monaco, which also pulled in a 1.2 MB JSON mode, two workers and a font. At 4x CPU on slow 4G a node panel is ready in 5.7 s instead of 10.8 s, the JSON dialog in 1.5 s instead of 6.7 s. Typing without a key delay keeps every character. monaco-editor, @monaco-editor/react and the dompurify override are removed. Specs: typed code reaches the node source, a JSON setting saves only text that parses. Docs name Tab and Ctrl-M.
339 lines
13 KiB
JavaScript
339 lines
13 KiB
JavaScript
/**
|
|
* Capture the app at desktop and phone sizes in both themes.
|
|
*
|
|
* Logs into the dashboard with the bootstrap superuser and captures the app
|
|
* shell. Set WEBSITE_URL to also capture another site's landing page.
|
|
*
|
|
* Env:
|
|
* 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
|
|
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(
|
|
"Set FIRST_SUPERUSER and FIRST_SUPERUSER_PASSWORD before running this script.",
|
|
)
|
|
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. A containerised run can pass the mapping through
|
|
// HOST_RESOLVER_RULES instead.
|
|
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()
|
|
|
|
if (WEBSITE_URL) {
|
|
await page.goto(`${WEBSITE_URL}/`, { waitUntil: "networkidle" })
|
|
// Wait for entrance animations before capturing the page.
|
|
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 instance has the Demo's Media dashboard and frame source.
|
|
* 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], .cm-editor", {
|
|
timeout: 15000,
|
|
})
|
|
await page.waitForTimeout(1500)
|
|
await page.screenshot({ path: `${dir}/app-flow-panel.png` })
|
|
}
|