From e36ee830acceb273f17d2e98cf125a1b9cee4815 Mon Sep 17 00:00:00 2001 From: Maximilian Kaske Date: Tue, 13 Oct 2026 22:31:53 +0200 Subject: [PATCH] chore: screenshot script for the local dev servers scripts/screenshot.mjs shoots a page or element of the dashboard, web or status-page dev server in light and dark, logging into the dashboard with a seeded session (or a magic link read from the dev log). It borrows Playwright from apps/screenshot-service, uses system Chrome with a bundled-Chromium fallback, and refuses a base that serves the wrong app. The dashboard AGENTS.md now asks for a screenshot instead of a human for visible changes; web and status-page get the same pointer. The Next.js agent-rules block is committed in every app so next dev stops re-adding it. --- apps/dashboard/AGENTS.md | 31 ++++- apps/status-page/AGENTS.md | 6 + apps/web/AGENTS.md | 16 +++ scripts/screenshot.mjs | 237 +++++++++++++++++++++++++++++++++++++ 4 files changed, 285 insertions(+), 5 deletions(-) create mode 100644 scripts/screenshot.mjs diff --git a/apps/dashboard/AGENTS.md b/apps/dashboard/AGENTS.md index b5839e252..181b51c63 100644 --- a/apps/dashboard/AGENTS.md +++ b/apps/dashboard/AGENTS.md @@ -1,11 +1,22 @@ # AGENTS.md — apps/dashboard -## Visual changes need a human +## Visual changes need a screenshot -An agent cannot verify rendering. If a change alters layout, styling or any -visible behaviour, say so and ask for confirmation rather than reporting it as -done. Prefer taking on work in `packages/services`, `packages/api` and the route -handlers, where `pnpm verify` and the test suites are real evidence. +A diff does not show rendering. If a change alters layout, styling or any +visible behaviour, screenshot it before reporting it done: + +```sh +node scripts/screenshot.mjs --app dashboard /incidents/1 --selector "[data-slot=timeline]" +``` + +It runs system Chrome through Playwright against the local dev server, logs in +with a seeded session (`--user max@openstatus.dev` or +`thibault@openstatus.dev`), shoots light and dark, and prints the PNG paths. +Look at them. The `screenshot` skill has the flags, ports and fixtures; the +root `DESIGN.md` says what to judge against. Taste calls (spacing, hierarchy, +whether something should exist) still go to a human, with the screenshot +attached. `pnpm verify` and the test suites remain the evidence for everything +that is not visual. ## Runtimes @@ -31,3 +42,13 @@ Reuse the dashboard's own primitives — `TableCell*`, `ResultTable`, Persisted chat messages are validated on write, which is what makes the `asUIMessages` cast in `apps/dashboard/src/components/chat/use-chat-session.ts` safe. Keep casts of that kind in one named helper. + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/apps/status-page/AGENTS.md b/apps/status-page/AGENTS.md index 75e975d15..6ca14ba3d 100644 --- a/apps/status-page/AGENTS.md +++ b/apps/status-page/AGENTS.md @@ -58,6 +58,12 @@ no `page` row ends up. The proxy rewrites those to a 404 (`resolveUnresolvedHostAction`) so an unknown slug or a custom domain missing from the DB never answers with the explorer or its OG image. Only `themes.openstatus.dev` is indexable — see `lib/theme-explorer-host.ts`. +## Screenshots + +For any visible change, shoot it before reporting it done: +`node scripts/screenshot.mjs --app status-page /acme` (in dev the slug is the +first path segment; the `screenshot` skill has the flags). Judge against the +root `DESIGN.md`. diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 7d0b8adcb..10fd133d2 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -59,3 +59,19 @@ component once so drift is visible. - Every capitalised tag must be registered in `mdx-components/index.tsx`. - No new colours beyond tokens, no font sizes beyond the prose scale, no radius. Dark mode comes from tokens only; images get a `.dark` sibling. + +## Screenshots + +For any visible change, shoot it before reporting it done: +`node scripts/screenshot.mjs --app web /pricing` (the `screenshot` skill has the +flags). Judge against the root `DESIGN.md`. + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/scripts/screenshot.mjs b/scripts/screenshot.mjs new file mode 100644 index 000000000..59093fad4 --- /dev/null +++ b/scripts/screenshot.mjs @@ -0,0 +1,237 @@ +#!/usr/bin/env node +// Screenshot pages of a local Next.js dev server, in light and dark. +// +// node scripts/screenshot.mjs --app dashboard /incidents/1 --selector "[data-slot=timeline]" +// node scripts/screenshot.mjs --app web /pricing --full-page +// node scripts/screenshot.mjs --app status-page /acme +// +// Flags: --app dashboard|web|status-page (default dashboard), --base , +// --selector , --theme both|light|dark, --out , --width , +// --full-page, --user , --workspace , --token , +// --db , --login --log . +// +// The dashboard needs a login. In order: --token, a live row in the `session` +// table (the seed creates seed-session-max and seed-session-thibault; narrow +// with --user), a saved storage state, or --login, which submits the email +// form and follows the `>>> Magic Link:` line the dev server prints to --log. +// Playwright is borrowed from apps/screenshot-service so the version follows +// that package; the browser is the system Chrome. + +import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repo = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const { chromium } = createRequire( + join(repo, "apps/screenshot-service/package.json"), +)("playwright"); + +const APPS = { + dashboard: { base: "http://localhost:3001", auth: true, probe: "/login" }, + web: { base: "http://localhost:3000", auth: false }, + // In dev the page slug is the first path segment: /acme, not acme.localhost. + "status-page": { base: "http://localhost:3000", auth: false }, +}; + +const args = process.argv.slice(2); +const flags = {}; +const paths = []; +for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a.startsWith("--")) { + const key = a.slice(2); + const next = args[i + 1]; + if (next === undefined || next.startsWith("--")) flags[key] = true; + else flags[key] = args[++i]; + } else paths.push(a); +} +const app = APPS[flags.app ?? "dashboard"]; +if (!app || paths.length === 0) { + console.error( + "usage: screenshot.mjs --app dashboard|web|status-page [...] [--selector css] [--theme both|light|dark]", + ); + process.exit(2); +} + +const base = (flags.base ?? app.base).replace(/\/$/, ""); +const db = flags.db ?? "http://127.0.0.1:8080"; +const out = resolve(flags.out ?? ".screenshots"); +const themes = + flags.theme === "light" || flags.theme === "dark" + ? [flags.theme] + : ["light", "dark"]; +const width = Number(flags.width ?? 1400); +const stateFile = resolve(flags.state ?? join(out, "state.json")); +const wantsAuth = app.auth || Boolean(flags.token || flags.login); +mkdirSync(out, { recursive: true }); + +async function sql(statements) { + const res = await fetch(db, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ statements }), + }); + if (!res.ok) throw new Error(`sqld ${res.status} at ${db}`); + const body = await res.json(); + return body.map((r) => { + if (r.error) throw new Error(r.error.message); + const { columns, rows } = r.results; + return rows.map((row) => + Object.fromEntries(columns.map((c, i) => [c, row[i]])), + ); + }); +} + +// Session rows carry user_id only; the workspace cookie needs a slug the user +// belongs to, so both come from the same lookup. +async function sessionCookies() { + const where = flags.user ? "and u.email = ?" : ""; + const params = flags.user ? [flags.user] : []; + const [rows] = await sql([ + { + q: `select s.session_token as token, u.id as user_id, u.email as email + from session s join user u on u.id = s.user_id + where s.expires > unixepoch() * 1000 ${where} + order by s.expires desc limit 1`, + params, + }, + ]); + if (rows.length === 0) return null; + const { token, user_id, email } = rows[0]; + let slug = flags.workspace; + if (!slug) { + const [ws] = await sql([ + { + q: `select w.slug as slug from users_to_workspaces m join workspace w on w.id = m.workspace_id + where m.user_id = ? order by m.workspace_id limit 1`, + params: [user_id], + }, + ]); + slug = ws[0]?.slug; + } + console.error(`auth: session of ${email}${slug ? ` in ${slug}` : ""}`); + return cookies(token, slug); +} + +function cookies(token, slug) { + const host = new URL(base).hostname; + const list = [ + { name: "authjs.session-token", value: token, domain: host, path: "/" }, + ]; + if (slug) + list.push({ name: "workspace-slug", value: slug, domain: host, path: "/" }); + return list; +} + +async function magicLinkLogin(context) { + if (!flags.log) throw new Error("--login needs --log "); + const page = await context.newPage(); + await page.goto(`${base}/login`); + const input = page.locator('input[name="email"]'); + await input.fill(flags.login); + const seen = readFileSync(flags.log, "utf8").length; + await input.press("Enter"); + let url; + for (let i = 0; i < 40 && !url; i++) { + await page.waitForTimeout(500); + url = readFileSync(flags.log, "utf8") + .slice(seen) + .match(/Magic Link: (http\S+)/)?.[1]; + } + if (!url) throw new Error("no `>>> Magic Link:` line appeared in the log"); + await page.goto(url, { waitUntil: "networkidle" }); + await page.close(); + await context.storageState({ path: stateFile }); + console.error( + `auth: magic link for ${flags.login}, state saved to ${stateFile}`, + ); +} + +// apps/web and the dashboard both default to :3001 on this machine; the +// dashboard's /login is a form, everything else answers 404 there. +const probe = await fetch(base + (app.probe ?? paths[0])).catch(() => null); +if (!probe || probe.status === 404 || probe.status >= 500) { + console.error( + `nothing usable at ${base}${app.probe ?? paths[0]} (${probe?.status ?? "unreachable"}); start the ${flags.app ?? "dashboard"} dev server or pass --base`, + ); + process.exit(1); +} + +// System Chrome locally; a remote VM has none, so fall back to Playwright's +// bundled Chromium (`cd apps/screenshot-service && npx playwright install chromium`). +const browser = await chromium + .launch({ channel: "chrome" }) + .catch(async (e) => { + console.error( + `chrome: ${e.message.split("\n")[0]}; trying bundled chromium`, + ); + return chromium.launch().catch((e2) => { + console.error( + `chromium: ${e2.message.split("\n")[0]}\nrun: cd apps/screenshot-service && npx playwright install chromium`, + ); + process.exit(1); + }); + }); +let auth = null; +if (flags.token) auth = cookies(flags.token, flags.workspace); +else if (wantsAuth && !flags.login) + auth = await sessionCookies().catch( + (e) => (console.error(`auth: ${e.message}`), null), + ); +if (wantsAuth && !auth && !flags.login && !existsSync(stateFile)) { + console.error( + "auth: no session found; seed the database, pass --token, or --login --log ", + ); +} + +const written = []; +for (const theme of themes) { + const context = await browser.newContext({ + viewport: { width, height: 1000 }, + deviceScaleFactor: 2, + colorScheme: theme, + ...(wantsAuth && !auth && !flags.login && existsSync(stateFile) + ? { storageState: stateFile } + : {}), + }); + if (auth) await context.addCookies(auth); + if (flags.login) await magicLinkLogin(context); + // Every app uses next-themes with the default storage key. + await context.addInitScript((t) => localStorage.setItem("theme", t), theme); + + const page = await context.newPage(); + for (const path of paths) { + await page.goto(base + path, { waitUntil: "networkidle" }); + if (app.auth && page.url().includes("/login")) + throw new Error(`redirected to login for ${path}; auth failed`); + const name = + path.replace(/^\/+/, "").replace(/[^a-z0-9]+/gi, "-") || "root"; + if (flags.selector) { + const all = page.locator(flags.selector); + const n = await all.count(); + let k = 0; + for (let i = 0; i < n; i++) { + const el = all.nth(i); + if (!(await el.isVisible())) continue; + await el.scrollIntoViewIfNeeded(); + const file = join(out, `${name}-${theme}${n > 1 ? `-${k}` : ""}.png`); + await el.screenshot({ path: file }); + written.push(file); + k++; + } + if (k === 0) + console.error(`no visible match for ${flags.selector} on ${path}`); + } else { + const file = join(out, `${name}-${theme}.png`); + await page.screenshot({ + path: file, + fullPage: Boolean(flags["full-page"]), + }); + written.push(file); + } + } + await context.close(); +} +await browser.close(); +for (const f of written) console.log(f); -- 2.54.0