diff --git a/.agents/skills/README.md b/.agents/skills/README.md index e80b3e107..2d05304bc 100644 --- a/.agents/skills/README.md +++ b/.agents/skills/README.md @@ -4,8 +4,9 @@ Skills live here as real directories. `.claude/skills/` holds symlinks to them (`.claude/skills/ -> ../../.agents/skills/`), which is the only path Claude Code reads; that directory stays untracked. Most entries are personal installs via `npx skills add` and stay gitignored; the ones re-included in -`.gitignore` ship with the repo. A fresh clone (a teammate, a cloud session) -needs the symlinks once: +`.gitignore` ship with the repo. A fresh clone needs the symlinks once; the +session-start hook in `.claude/hooks/session-start.sh` creates them in cloud +sessions, a teammate runs: ```sh mkdir -p .claude/skills && for d in .agents/skills/*/; do n=$(basename "$d"); ln -sfn "../../.agents/skills/$n" ".claude/skills/$n"; done diff --git a/.agents/skills/screenshot/SKILL.md b/.agents/skills/screenshot/SKILL.md index 823af0400..d49c2350b 100644 --- a/.agents/skills/screenshot/SKILL.md +++ b/.agents/skills/screenshot/SKILL.md @@ -5,9 +5,14 @@ description: Screenshot pages of the local dev server of any Next.js app here (d # Screenshots -One script does the loop: find Playwright, use system Chrome (or the bundled -Chromium on a machine without it), log in if the app needs it, set the theme, -shoot an element or the page. +The engine is [agent-browser](https://github.com/vercel-labs/agent-browser), +a CLI that drives Chrome over CDP. Two ways in: + +- `scripts/screenshot.mjs` for the standard evidence shots. It logs in with + a seeded session, shoots light and dark at 2×, hides dev-only chrome, + takes the element or the page, and writes a gallery. +- `agent-browser` directly for a state the script cannot reach: a dropdown + open, a dialog, a filled composer. See **Interactive shots**. ```sh node scripts/screenshot.mjs --app dashboard /incidents/1 --selector "[data-slot=timeline]" @@ -15,10 +20,36 @@ node scripts/screenshot.mjs --app web /pricing --full-page node scripts/screenshot.mjs --app status-page /acme ``` -It prints the PNG paths. Read them with the Read tool and judge against -`DESIGN.md`. Both themes by default; `--theme light` for one. +It prints the PNG paths and writes `.screenshots/index.html`, a gallery of +every shot in the output directory. Read the PNGs with the Read tool and +judge against `DESIGN.md`. Both themes by default; `--theme light` for one. + +## Cloud session (nothing installed, nothing running) + +One command brings up everything the dashboard needs, then shoot: + +```sh +node scripts/dev-stack.mjs up --warm /incidents/1 +node scripts/screenshot.mjs --app dashboard /incidents/1 --user max@openstatus.dev +node scripts/dev-stack.mjs down # when finished; stops only what `up` started +``` + +`up` fetches pinned deno, sqld and agent-browser from npm when they are not +on PATH (the official download hosts are blocked in the sandbox; npm is +not), creates the two `.env` files from their examples, starts sqld on +:8080, migrates, seeds once (`--reseed` wipes and reseeds), starts the +dashboard on :3001 and warms the given routes. Servers already answering +are reused and left alone by `down`. The session-start hook has usually +already fetched the tools and compiled the dashboard, so `up` takes about +15 seconds. `status` shows what is up; logs are in `.screenshots/stack/`. +`--port` and `--db-port` move the two servers. + +`pnpm -w dev:dashboard` does not work there: it runs `turso dev`, which the +sandbox lacks. agent-browser uses the sandbox's Chromium through +`AGENT_BROWSER_EXECUTABLE_PATH=/opt/pw-browsers/chromium`; the hook exports +it and the script sets it itself when unset. No `agent-browser install`. -## Before running +## Before running (local machine) 1. Find the dev server: `lsof -nP -iTCP:3000 -iTCP:3001 -iTCP:3002 -sTCP:LISTEN`. Defaults are dashboard `:3001`, web and status page `:3000`, but `apps/web` @@ -44,11 +75,14 @@ It prints the PNG paths. Read them with the Read tool and judge against Pass `--user max@openstatus.dev` and the script finds the row, or skip the DB with `--token seed-session-max --workspace love-openstatus`. Without `--user` it takes any live session, and the two seeded ones tie, so name - the user when identity matters. No session at all: - `--login --log ` submits the email form, follows the - `>>> Magic Link:` line the dev server prints and saves a storage state next - to the output. A database that predates the seed change has no `max@` - session: re-seed or `--login`. + the user when identity matters. No seeded session: log in once with + agent-browser (`open http://localhost:3001/login`, submit the email, follow + the `>>> Magic Link:` line from the dev server log with `open`), run + `agent-browser state save .screenshots/auth.json`, and pass + `--state .screenshots/auth.json`. +4. agent-browser: `npm i -g agent-browser` (it wants node 24, the devbox + pin), or let `node scripts/dev-stack.mjs tools` fetch the pinned copy + into `node_modules/.cache`. It finds Chrome on its own locally. ## Flags @@ -56,7 +90,7 @@ It prints the PNG paths. Read them with the Read tool and judge against |---|---|---| | `--app` | `dashboard` | `dashboard`, `web` or `status-page`; sets base and whether to log in | | ` …` | required | one or more paths; on the status page the slug is the first segment in dev | -| `--selector` | page | CSS selector; every visible match is shot | +| `--selector` | page | CSS selector; every visible match is shot on its own | | `--theme` | `both` | `light` or `dark` | | `--out` | `.screenshots` | gitignored | | `--width` | `1400` | viewport width, 2× device scale | @@ -66,7 +100,35 @@ It prints the PNG paths. Read them with the Read tool and judge against | `--user` | | pick the session of this email | | `--workspace` | first membership | slug for the `workspace-slug` cookie | | `--token` | | explicit session token, skips the lookup | -| `--login`, `--log` | | magic-link login fallback | +| `--state` | | agent-browser state file (cookies + storage) instead of a seeded session | + +## Interactive shots + +For a state the script cannot reach, drive agent-browser yourself. The +sequence below is what the script does, minus the gallery; `agent-browser +skills get core` has the full command set. + +```sh +agent-browser set viewport 1400 1000 2 # 2× like the script +agent-browser set media dark # or light; next-themes follows it +agent-browser cookies set authjs.session-token seed-session-max --url http://localhost:3001 +agent-browser cookies set workspace-slug love-openstatus --url http://localhost:3001 +agent-browser open http://localhost:3001/incidents/1 +agent-browser wait --fn "document.fonts.status === 'loaded'" +agent-browser eval "document.head.insertAdjacentHTML('beforeend','')" +agent-browser snapshot -i # refs for the interactive elements +agent-browser click @e12 # e.g. the severity select +agent-browser screenshot .screenshots/incidents-1-severity-dark.png +agent-browser close # sessions outlive the shell +``` + +- `wait --load networkidle` times out against the dev server (the HMR + socket never goes idle). Wait for fonts, an element (`wait @ref`) or text. +- `--session ` isolates a browser; the script uses one per theme and + closes it. Close the ones you open. +- agent-browser shoots viewports, not elements. The script's `--selector` + pins the match to the viewport's top-left at its own size and hides the + rest; for an ad-hoc element shot, `scrollintoview` and shoot the viewport. ## Selectors worth knowing @@ -86,6 +148,15 @@ plain note and a Slack unbind), status reports #1 (resolved) and #2 (linked), ma `/status` (page 1, with components) and `/acme` (page 2). Safe to mutate; re-seed to reset. +## What the script already handles + +- Each route is fetched once with the session cookies before the browser + opens it, so a cold Turbopack compile (10s+) never trips a timeout. +- The dev-only Tailwind breakpoint badge (`[data-slot=tailwind-indicator]`) + and the Next.js dev indicator are hidden before the capture. +- Not handled: avatar images from blocked hosts render as initials or alt + text in the sandbox. Say so when it shows; do not chase it. + ## Rules - Shoot the element, not the viewport, unless layout is the question. @@ -94,8 +165,8 @@ re-seed to reset. that state; say so when you do. - A redirect to `/login` means auth failed. Fix auth; do not screenshot the login page and call it done. -- Stop any dev server you started. Match by port, never by process name, - so the user's own servers survive. +- Stop any dev server you started (`dev-stack down`, or by port, never by + process name) and close any agent-browser session you opened. ## Sharing @@ -105,10 +176,12 @@ session runs. - **Local machine (terminal or desktop app):** leave them in `.screenshots/` and name the files in the final message. The user opens them directly. - **Cloud session:** nothing leaves the sandbox except git, so the files - vanish with the VM. Publish them with the Artifact tool: one page, the - shots side by side with "before" and "after" captions, uploaded as assets, - and put the link in the final message. Never commit screenshots to make - them visible. + vanish with the VM. Publish the generated gallery with the Artifact tool: + `file_path` `.screenshots/index.html`, `root` `.screenshots`, and every PNG + it shows passed in `files` under its own name (`{"incidents-1-light.png": + "incidents-1-light.png", ...}`). The gallery is written as a page fragment + (title, style, body) for exactly that. Put the link in the final message. + Never commit screenshots to make them visible. - **Review evidence for a PR:** when a reviewer needs the image, publish it as an artifact and link it from the PR description; the PR text is the one place a screenshot link belongs in the repo. diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh new file mode 100755 index 000000000..d3bd6fc69 --- /dev/null +++ b/.claude/hooks/session-start.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Cloud sessions start from a bare clone: no node_modules, no deno, no sqld, +# and no .claude/skills symlinks. Prepare all of it once; the container state +# is cached afterwards, so later sessions start with it in place. +set -euo pipefail + +if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + exit 0 +fi + +cd "$CLAUDE_PROJECT_DIR" + +# Claude Code reads skills from .claude/skills only; see .agents/skills/README.md. +mkdir -p .claude/skills +for d in .agents/skills/*/; do + n=$(basename "$d") + ln -sfn "../../.agents/skills/$n" ".claude/skills/$n" +done + +pnpm install + +# deno (pnpm verify, migrations, seed), sqld (local libSQL) and agent-browser +# (screenshots) are not on the image; dev-stack fetches pinned versions from +# npm into node_modules/.cache and prints their paths. +node scripts/dev-stack.mjs tools + +# Seed the database and compile the dashboard once, so the cached container +# carries a warm .next and the first screenshot starts in seconds instead of +# a minute. Skipped on resume (cache present); a failure must not block the +# session. +if [ ! -d apps/dashboard/.next/dev ]; then + node scripts/dev-stack.mjs up --warm /overview /incidents/1 || true + node scripts/dev-stack.mjs down || true +fi + +tools="$CLAUDE_PROJECT_DIR/node_modules/.cache/openstatus-tools" +if [ -n "${CLAUDE_ENV_FILE:-}" ]; then + echo "export PATH=\"$tools/node_modules/.bin:$tools/sqld:\$PATH\"" >> "$CLAUDE_ENV_FILE" + # The sandbox's Chromium; agent-browser does not look there on its own. + if [ -x /opt/pw-browsers/chromium ]; then + echo 'export AGENT_BROWSER_EXECUTABLE_PATH=/opt/pw-browsers/chromium' >> "$CLAUDE_ENV_FILE" + fi +fi diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..d0d6eb355 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-start.sh" + } + ] + } + ] + } +} diff --git a/apps/dashboard/AGENTS.md b/apps/dashboard/AGENTS.md index 1e0aca814..8d55adcae 100644 --- a/apps/dashboard/AGENTS.md +++ b/apps/dashboard/AGENTS.md @@ -6,15 +6,16 @@ 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/dev-stack.mjs up --warm /incidents/1 # only when nothing is running node scripts/screenshot.mjs --app dashboard /incidents/1 --selector "[data-slot=timeline]" ``` -It runs system Chrome (or Playwright's bundled Chromium where there is none) -against the local dev server, logs in +It drives agent-browser 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, +Look at them. `dev-stack.mjs` provisions the database and dashboard from a +bare clone, which a cloud session is. 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. diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index 9d0c3757c..15c7e40e5 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -82,6 +82,7 @@ To log in: - **Port 8080 already in use** — a leftover libSQL server from step 2. Kill that terminal and re-run `pnpm -w dev:dashboard`. - **Magic link doesn't arrive** — it isn't emailed in dev. Look in the dashboard terminal stdout for `>>> Magic Link: ...`. - **Reset the dev DB** — stop the libSQL server, delete `openstatus-dev.db` at the repo root, restart libSQL, re-run `pnpm -w dx`. +- **No Turso CLI or Deno on the machine** (a cloud sandbox) — `node scripts/dev-stack.mjs up` does steps 2 to 5 with sqld and deno fetched from npm; `down` stops them. Its database is `.screenshots/stack/openstatus-dev.db`: `up --reseed` resets the fixtures, or delete the directory while the stack is down. ## Related services (optional for dashboard development) diff --git a/apps/dashboard/src/components/tailwind-indicator.tsx b/apps/dashboard/src/components/tailwind-indicator.tsx index a9967cb30..28c6eed30 100644 --- a/apps/dashboard/src/components/tailwind-indicator.tsx +++ b/apps/dashboard/src/components/tailwind-indicator.tsx @@ -2,7 +2,10 @@ export function TailwindIndicator() { if (process.env.NODE_ENV === "production") return null; return ( -
+
xs
sm
md
diff --git a/apps/status-page/src/components/tailwind-indicator.tsx b/apps/status-page/src/components/tailwind-indicator.tsx index a9967cb30..28c6eed30 100644 --- a/apps/status-page/src/components/tailwind-indicator.tsx +++ b/apps/status-page/src/components/tailwind-indicator.tsx @@ -2,7 +2,10 @@ export function TailwindIndicator() { if (process.env.NODE_ENV === "production") return null; return ( -
+
xs
sm
md
diff --git a/apps/web/src/components/tailwind-indicator.tsx b/apps/web/src/components/tailwind-indicator.tsx index b2ada3c52..3e67729b0 100644 --- a/apps/web/src/components/tailwind-indicator.tsx +++ b/apps/web/src/components/tailwind-indicator.tsx @@ -2,7 +2,10 @@ export function TailwindIndicator() { if (process.env.NODE_ENV === "production") return null; return ( -
+
xs
sm
md
diff --git a/scripts/dev-stack.mjs b/scripts/dev-stack.mjs new file mode 100644 index 000000000..56153235b --- /dev/null +++ b/scripts/dev-stack.mjs @@ -0,0 +1,388 @@ +#!/usr/bin/env node +// Bring up (or tear down) what a dashboard screenshot needs: deno and sqld, +// the env files, a seeded libSQL on :8080 and the dashboard on :3001. +// +// node scripts/dev-stack.mjs up [--reseed] [--warm /incidents/1 ...] +// node scripts/dev-stack.mjs down +// node scripts/dev-stack.mjs status +// node scripts/dev-stack.mjs tools # only fetch deno, sqld, agent-browser +// +// Servers already answering on their port are reused and left alone by `down`; +// only processes this script started are stopped. Flags, ports and fixtures: +// .agents/skills/screenshot/SKILL.md. + +import { execFileSync, spawn, spawnSync } from "node:child_process"; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repo = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const stackDir = join(repo, ".screenshots", "stack"); +const stateFile = join(stackDir, "state.json"); +const toolsDir = join(repo, "node_modules", ".cache", "openstatus-tools"); + +// The sqld npm wrapper's postinstall is broken, so the platform tarball is +// unpacked by hand. Its binary reports the devbox-pinned sqld version. +const SQLD_NPM_VERSION = "0.24.1-pre.42"; +// Capture engine of scripts/screenshot.mjs; not in devbox.json, pinned here. +const AGENT_BROWSER_VERSION = "0.39.1"; + +const args = process.argv.slice(2); +const command = args.shift(); +const flags = { warm: [] }; +for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "--warm") { + while (args[i + 1] && !args[i + 1].startsWith("--")) + flags.warm.push(args[++i]); + } else if (a.startsWith("--")) { + const next = args[i + 1]; + flags[a.slice(2)] = + next === undefined || next.startsWith("--") ? true : args[++i]; + } +} +if (!["up", "down", "status", "tools"].includes(command)) { + console.error( + "usage: dev-stack.mjs up [--reseed] [--warm ...] [--port 3001] [--db-port 8080] | down | status | tools", + ); + process.exit(2); +} + +const port = Number(flags.port ?? 3001); +const dbPort = Number(flags["db-port"] ?? 8080); +const base = `http://localhost:${port}`; +const dbUrl = `http://127.0.0.1:${dbPort}`; + +const log = (msg) => console.error(`dev-stack: ${msg}`); +const since = (t) => `${((Date.now() - t) / 1000).toFixed(1)}s`; + +function pinnedVersion(name) { + const devbox = JSON.parse(readFileSync(join(repo, "devbox.json"), "utf8")); + const entry = devbox.packages.find((p) => p.startsWith(`${name}@`)); + if (!entry) throw new Error(`${name} is not pinned in devbox.json`); + return entry.slice(name.length + 1); +} + +function onPath(bin) { + const r = spawnSync(process.platform === "win32" ? "where" : "which", [bin], { + encoding: "utf8", + }); + return r.status === 0 ? r.stdout.trim().split("\n")[0] : null; +} + +// The cache has one package.json for every npm-installed tool, so a later +// install never prunes an earlier one. +function ensureNpmTool(name, version) { + const found = onPath(name); + if (found) return found; + const bin = join(toolsDir, "node_modules", ".bin", name); + if (existsSync(bin)) return bin; + mkdirSync(toolsDir, { recursive: true }); + const manifest = join(toolsDir, "package.json"); + const pkg = existsSync(manifest) + ? JSON.parse(readFileSync(manifest, "utf8")) + : { name: "openstatus-tools", private: true, dependencies: {} }; + pkg.dependencies[name] = version; + writeFileSync(manifest, JSON.stringify(pkg, null, 2)); + log(`fetching ${name} ${version} from npm into ${toolsDir}`); + execFileSync( + "npm", + ["install", "--prefix", toolsDir, "--no-audit", "--no-fund"], + { stdio: "inherit" }, + ); + return bin; +} + +const ensureDeno = () => ensureNpmTool("deno", pinnedVersion("deno")); +const ensureAgentBrowser = () => + ensureNpmTool("agent-browser", AGENT_BROWSER_VERSION); + +function ensureSqld() { + const found = onPath("sqld"); + if (found) return found; + const bin = join(toolsDir, "sqld", "sqld"); + if (existsSync(bin)) return bin; + const os = { linux: "linux", darwin: "darwin" }[process.platform]; + const arch = { x64: "x64", arm64: "arm64" }[process.arch]; + if (!os || !arch) + throw new Error( + `no sqld npm build for ${process.platform}-${process.arch}; install sqld ${pinnedVersion("sqld")} yourself`, + ); + log( + `fetching sqld ${pinnedVersion("sqld")} (npm @sqld/${os}-${arch}@${SQLD_NPM_VERSION})`, + ); + const tmp = mkdtempSync(join(tmpdir(), "sqld-")); + const tarball = execFileSync( + "npm", + [ + "pack", + `@sqld/${os}-${arch}@${SQLD_NPM_VERSION}`, + "--pack-destination", + tmp, + "--silent", + ], + { encoding: "utf8" }, + ).trim(); + execFileSync("tar", ["xzf", join(tmp, tarball), "-C", tmp, "package/sqld"]); + mkdirSync(dirname(bin), { recursive: true }); + renameSync(join(tmp, "package", "sqld"), bin); + rmSync(tmp, { recursive: true, force: true }); + return bin; +} + +function ensureEnvFiles() { + for (const dir of ["apps/dashboard", "packages/db"]) { + const target = join(repo, dir, ".env"); + if (!existsSync(target)) { + copyFileSync(join(repo, dir, ".env.example"), target); + log(`created ${dir}/.env from .env.example`); + } + } +} + +async function sql(statements) { + const res = await fetch(dbUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ statements }), + signal: AbortSignal.timeout(5000), + }).catch(() => null); + if (!res?.ok) return null; + const body = await res.json(); + return body.map((r) => { + if (r.error) throw new Error(r.error.message); + return r.results.rows; + }); +} + +async function dashboardUp() { + const res = await fetch(`${base}/login`, { + redirect: "manual", + signal: AbortSignal.timeout(5000), + }).catch(() => null); + return res?.status === 200; +} + +async function waitFor(check, what, ms) { + const until = Date.now() + ms; + while (Date.now() < until) { + if (await check()) return; + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`${what} did not come up within ${ms / 1000}s`); +} + +function readState() { + return existsSync(stateFile) + ? JSON.parse(readFileSync(stateFile, "utf8")) + : {}; +} + +function writeState(state) { + mkdirSync(stackDir, { recursive: true }); + writeFileSync(stateFile, JSON.stringify(state, null, 2)); +} + +// Detached with its own process group, so `down` can stop pnpm, next and its +// workers together via the negative pid. +function launch(name, cmd, cmdArgs, opts) { + const out = openSync(join(stackDir, `${name}.log`), "a"); + const child = spawn(cmd, cmdArgs, { + ...opts, + detached: true, + stdio: ["ignore", out, out], + }); + child.unref(); + log(`started ${name} (pid ${child.pid}, log .screenshots/stack/${name}.log)`); + return child.pid; +} + +function alive(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +// The recorded pid leads a process group. The launcher (pnpm) can exit before +// next does, and after a reboot the pid can belong to something else, so the +// group's members decide, not the leader. +function groupMembers(pgid) { + const r = spawnSync("pgrep", ["-l", "-g", String(pgid)], { + encoding: "utf8", + }); + // pgrep exits 1 for "no match"; anything else means it could not answer, + // and guessing "empty" would make `down` drop the state while the stack runs. + if (r.error || (r.status !== 0 && r.status !== 1)) + throw new Error( + `pgrep is needed to stop the stack: ${r.error?.message ?? r.stderr.trim()}`, + ); + return r.status === 0 ? r.stdout.trim().split("\n").filter(Boolean) : []; +} + +const SQLD_GROUP = /\bsqld\b/; +const DASHBOARD_GROUP = /\b(pnpm|node|next-server)\b/; +const ours = (pid, expected) => + Boolean(pid) && groupMembers(pid).some((m) => expected.test(m)); + +function stop(name, pid, expected) { + if (!pid) return; + const members = groupMembers(pid); + if (members.length === 0) return; + if (!members.some((m) => expected.test(m))) { + log(`not stopping ${name}: pid ${pid} now belongs to "${members[0]}"`); + return; + } + for (const signal of ["SIGTERM", "SIGKILL"]) { + try { + process.kill(-pid, signal); + } catch { + return; + } + const until = Date.now() + 5000; + while (groupMembers(pid).length && Date.now() < until) + spawnSync("sleep", ["0.2"]); + if (groupMembers(pid).length === 0) break; + } + log(`stopped ${name} (pid ${pid})`); +} + +async function up() { + const started = Date.now(); + mkdirSync(stackDir, { recursive: true }); + const deno = ensureDeno(); + const sqld = ensureSqld(); + ensureAgentBrowser(); + ensureEnvFiles(); + const toolPath = [dirname(deno), dirname(sqld), process.env.PATH].join( + delimiter, + ); + const state = readState(); + + // The seed wipes every table, so only a database this run created is seeded + // without being asked; a reused server may hold someone's real data. + let fresh = false; + if (await sql(["select 1"])) { + log(`reusing sqld at ${dbUrl}`); + if (!ours(state.sqld, SQLD_GROUP)) delete state.sqld; + } else { + const dbPath = join(stackDir, "openstatus-dev.db"); + fresh = !existsSync(dbPath); + state.sqld = launch("sqld", sqld, [ + "--db-path", + dbPath, + "--http-listen-addr", + `127.0.0.1:${dbPort}`, + ]); + writeState(state); + await waitFor(() => sql(["select 1"]), "sqld", 20000); + log(`sqld answering after ${since(started)}`); + } + + const env = { + ...process.env, + PATH: toolPath, + DATABASE_URL: dbUrl, + DATABASE_AUTH_TOKEN: process.env.DATABASE_AUTH_TOKEN ?? "any-token", + }; + const run = (script) => { + const t = Date.now(); + const r = spawnSync("pnpm", ["--filter", "@openstatus/db", script], { + cwd: repo, + env, + stdio: "inherit", + }); + if (r.status !== 0) + throw new Error(`pnpm --filter @openstatus/db ${script} failed`); + log(`${script}: ${since(t)}`); + }; + run("migrate"); + const [sessions] = (await sql([ + "select count(*) from session where session_token = 'seed-session-max'", + ])) ?? [[[0]]]; + const seeded = sessions[0][0] > 0; + if (flags.reseed || (fresh && !seeded)) run("seed"); + else if (!seeded) + log( + `no seeded session in the existing database at ${dbUrl}; pass --reseed to wipe and seed it`, + ); + else + log("seed present (seed-session-max); pass --reseed to reset the fixtures"); + + if (await dashboardUp()) { + log(`reusing dashboard at ${base}`); + if (!ours(state.dashboard, DASHBOARD_GROUP)) delete state.dashboard; + } else { + state.dashboard = launch( + "dashboard", + "pnpm", + ["--filter", "@openstatus/dashboard", "dev"], + { cwd: repo, env: { ...env, PORT: String(port) } }, + ); + writeState(state); + const t = Date.now(); + await waitFor(dashboardUp, "dashboard", 180000); + log(`dashboard answering after ${since(t)}`); + } + + // The first request to a route pays the Turbopack compile (10s+); do it here + // so the screenshot's navigation never races a timeout. + for (const path of flags.warm) { + const t = Date.now(); + const res = await fetch(base + path, { + headers: { + cookie: + "authjs.session-token=seed-session-max; workspace-slug=love-openstatus", + }, + redirect: "manual", + }).catch(() => null); + log(`warmed ${path}: ${res?.status ?? "unreachable"} in ${since(t)}`); + } + writeState(state); + log(`ready in ${since(started)}: dashboard ${base}, sqld ${dbUrl}`); +} + +async function down() { + const state = readState(); + stop("dashboard", state.dashboard, DASHBOARD_GROUP); + stop("sqld", state.sqld, SQLD_GROUP); + if (!state.dashboard && !state.sqld) + log("nothing started by this script is running"); + rmSync(stateFile, { force: true }); +} + +async function status() { + const state = readState(); + console.log( + `dashboard ${base}: ${(await dashboardUp()) ? "up" : "down"}${state.dashboard ? ` (pid ${state.dashboard}${alive(state.dashboard) ? "" : ", gone"})` : ""}`, + ); + console.log( + `sqld ${dbUrl}: ${(await sql(["select 1"])) ? "up" : "down"}${state.sqld ? ` (pid ${state.sqld}${alive(state.sqld) ? "" : ", gone"})` : ""}`, + ); +} + +function tools() { + console.log(`deno: ${ensureDeno()}`); + console.log(`sqld: ${ensureSqld()}`); + console.log(`agent-browser: ${ensureAgentBrowser()}`); +} + +try { + await { up, down, status, tools }[command](); +} catch (e) { + console.error(`dev-stack: ${e.message}`); + process.exit(1); +} diff --git a/scripts/screenshot.mjs b/scripts/screenshot.mjs index eb97fa41b..8e6a5812d 100644 --- a/scripts/screenshot.mjs +++ b/scripts/screenshot.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node -// Screenshot pages of a local Next.js dev server, in light and dark. +// Screenshot pages of a local Next.js dev server, in light and dark, through +// agent-browser (https://github.com/vercel-labs/agent-browser). // // node scripts/screenshot.mjs --app dashboard /incidents/1 --selector "[data-slot=timeline]" // node scripts/screenshot.mjs --app web /pricing --full-page @@ -7,15 +8,18 @@ // // Flags, auth order and fixtures: .agents/skills/screenshot/SKILL.md. -import { existsSync, mkdirSync, readFileSync } from "node:fs"; -import { createRequire } from "node:module"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + readdirSync, + statSync, + writeFileSync, +} from "node:fs"; 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" }, @@ -58,10 +62,48 @@ if (!Number.isFinite(width) || width <= 0) { console.error(`--width must be a positive number, got ${flags.width}`); process.exit(2); } -const stateFile = resolve(flags.state ?? join(out, "state.json")); -const wantsAuth = app.auth || Boolean(flags.token || flags.login); +const wantsAuth = app.auth || Boolean(flags.token || flags.state); mkdirSync(out, { recursive: true }); +// agent-browser from PATH, else the copy `dev-stack.mjs tools` fetched. +const bin = (() => { + const which = spawnSync("which", ["agent-browser"], { encoding: "utf8" }); + if (which.status === 0) return which.stdout.trim(); + const cached = join( + repo, + "node_modules/.cache/openstatus-tools/node_modules/.bin/agent-browser", + ); + if (existsSync(cached)) return cached; + console.error( + "agent-browser not found: node scripts/dev-stack.mjs tools, or npm i -g agent-browser", + ); + process.exit(1); +})(); +const env = { ...process.env, AGENT_BROWSER_DEFAULT_TIMEOUT: "120000" }; +// The cloud sandbox ships a Chromium that agent-browser does not find by itself. +if ( + !env.AGENT_BROWSER_EXECUTABLE_PATH && + existsSync("/opt/pw-browsers/chromium") +) + env.AGENT_BROWSER_EXECUTABLE_PATH = "/opt/pw-browsers/chromium"; + +function ab(session, ...cmd) { + const r = spawnSync(bin, ["--session", session, ...cmd], { + encoding: "utf8", + env, + }); + if (r.status !== 0) + throw new Error( + `agent-browser ${cmd.join(" ")}: ${(r.stderr || r.stdout).trim()}`, + ); + return r.stdout.trim(); +} +// `eval` prints the result as JSON; page code returns JSON strings for objects. +function evalJs(session, js) { + const v = JSON.parse(ab(session, "eval", js)); + return typeof v === "string" && v.startsWith("{") ? JSON.parse(v) : v; +} + async function sql(statements) { const res = await fetch(db, { method: "POST", @@ -111,42 +153,11 @@ async function sessionCookies() { } 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: "/" }); + const list = [{ name: "authjs.session-token", value: token }]; + if (slug) list.push({ name: "workspace-slug", value: slug }); 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`); - // The form opens closed; the email input appears after the mode button. - const input = page.locator('input[name="email"]'); - if (!(await input.isVisible())) - await page.getByRole("button", { name: "Continue with email" }).click(); - 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}`, - ); -} - // Three apps, two default ports, and on this machine any of them may sit on // :3001. Only the dashboard serves a form at /login; use that to tell them apart. const probe = await fetch(base + (app.probe ?? paths[0])).catch(() => null); @@ -168,102 +179,227 @@ if (!app.auth) { } } -// 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); console.error("auth: --token"); -} else if (wantsAuth && !flags.login) { +} else if (flags.state) { + console.error(`auth: agent-browser state ${flags.state}`); +} else if (wantsAuth) { auth = await sessionCookies().catch( (e) => (console.error(`auth: ${e.message}`), null), ); + if (!auth) + console.error( + "auth: no session found; seed the database, pass --token, or --state ", + ); } -// One login for all themes: the dashboard rate-limits magic-link emails. -if (flags.login && !auth) { - const context = await browser.newContext(); - await magicLinkLogin(context); - await context.close(); -} -// A saved state is only a fallback; an explicit identity must not silently -// become whoever logged in last. -const useState = - wantsAuth && !auth && !flags.user && !flags.token && existsSync(stateFile); -if (useState) console.error(`auth: saved state ${stateFile}`); -if (wantsAuth && !auth && !useState) { - console.error( - "auth: no session found; seed the database, pass --token, or --login --log ", - ); + +// The first request to a route pays the dev server's compile (10s+ on a cold +// Turbopack cache); take it here so no browser command waits on it. +if (auth) { + const headers = { + cookie: auth.map((c) => `${c.name}=${c.value}`).join("; "), + }; + for (const path of paths) { + const t = Date.now(); + const res = await fetch(base + path, { headers, redirect: "manual" }).catch( + () => null, + ); + console.error( + `warm ${path}: ${res?.status ?? "unreachable"} in ${((Date.now() - t) / 1000).toFixed(1)}s`, + ); + } } +// Dev-only chrome floats over the bottom-left corner: the apps' Tailwind +// breakpoint badge and the Next.js dev indicator. +const HIDE_DEV_CHROME = `(() => { + const st = document.createElement("style"); + st.textContent = "[data-slot=tailwind-indicator],nextjs-portal{display:none!important}"; + document.head.appendChild(st); + return "ok"; +})()`; + +// agent-browser shoots viewports, not elements. Pin the k-th match to the +// viewport's top-left at its own size, hide everything else, and let the +// caller size the viewport to it. +const isolate = (selector, k) => `(() => { + const els = [...document.querySelectorAll(${JSON.stringify(selector)})].filter((e) => e.getClientRects().length && getComputedStyle(e).visibility !== "hidden"); + const el = els[${k}]; + if (!el) return JSON.stringify({ count: els.length }); + const r = el.getBoundingClientRect(); + const w = Math.ceil(r.width), h = Math.ceil(r.height); + const st = document.createElement("style"); + st.id = "__shot"; + st.textContent = "html{background:" + getComputedStyle(document.body).backgroundColor + "!important}" + + "body{visibility:hidden!important;overflow:hidden!important}" + + "[data-shot-target]{visibility:visible!important;position:fixed!important;top:0!important;left:0!important;margin:0!important;width:" + w + "px!important;height:" + h + "px!important;z-index:2147483647!important}"; + el.setAttribute("data-shot-target", ""); + document.head.appendChild(st); + return JSON.stringify({ count: els.length, w, h }); +})()`; +const RESTORE = `(() => { + document.getElementById("__shot")?.remove(); + document.querySelector("[data-shot-target]")?.removeAttribute("data-shot-target"); + return "ok"; +})()`; + +// next-themes follows prefers-color-scheme until the user picks a theme; a +// saved choice (from --state) would win, so pin the storage key too. +const pinTheme = (theme) => `(() => { + localStorage.setItem("theme", ${JSON.stringify(theme)}); + return document.documentElement.classList.contains(${JSON.stringify(theme)}) ? "ok" : "reload"; +})()`; + +const LOADED = `document.readyState === "complete" && document.fonts.status === "loaded"`; + const written = []; -process.on("uncaughtException", async (e) => { - console.error(e.message); - await browser.close().catch(() => {}); - process.exit(1); -}); for (const theme of themes) { - const context = await browser.newContext({ - viewport: { width, height: 1000 }, - deviceScaleFactor: 2, - colorScheme: theme, - ...(useState ? { storageState: stateFile } : {}), - }); - if (auth) await context.addCookies(auth); - // 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 && - new URL(page.url()).pathname === "/login" && - !path.startsWith("/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++; + // One isolated browser per theme; sessions survive this process, so close it. + const session = `openstatus-shot-${theme}-${process.pid}`; + try { + ab(session, "set", "viewport", String(width), "1000", "2"); + ab(session, "set", "media", theme); + for (const c of auth ?? []) + ab(session, "cookies", "set", c.name, c.value, "--url", base); + for (const path of paths) { + const open = flags.state ? ["--state", flags.state] : []; + ab(session, ...open, "open", base + path); + ab(session, "wait", "--fn", LOADED); + const url = ab(session, "get", "url"); + if ( + app.auth && + new URL(url).pathname === "/login" && + !path.startsWith("/login") + ) + throw new Error(`redirected to login for ${path}; auth failed`); + if (evalJs(session, pinTheme(theme)) === "reload") { + ab(session, "reload"); + ab(session, "wait", "--fn", LOADED); } - if (k === 0) { - console.error(`no visible match for ${flags.selector} on ${path}`); - process.exitCode = 1; + evalJs(session, HIDE_DEV_CHROME); + const name = + path.replace(/^\/+/, "").replace(/[^a-z0-9]+/gi, "-") || "root"; + if (flags.selector) { + let k = 0; + for (;;) { + const box = evalJs(session, isolate(flags.selector, k)); + if (!box.w) break; + ab(session, "set", "viewport", String(box.w), String(box.h), "2"); + const file = join( + out, + `${name}-${theme}${box.count > 1 ? `-${k}` : ""}.png`, + ); + ab(session, "screenshot", file); + written.push(file); + evalJs(session, RESTORE); + ab(session, "set", "viewport", String(width), "1000", "2"); + if (++k >= box.count) break; + } + if (k === 0) { + console.error(`no visible match for ${flags.selector} on ${path}`); + process.exitCode = 1; + } + } else { + const file = join(out, `${name}-${theme}.png`); + ab( + session, + "screenshot", + file, + ...(flags["full-page"] ? ["--full"] : []), + ); + written.push(file); } - } else { - const file = join(out, `${name}-${theme}.png`); - await page.screenshot({ - path: file, - fullPage: Boolean(flags["full-page"]), - }); - written.push(file); } + } catch (e) { + console.error(e.message); + process.exitCode = 1; + break; + } finally { + spawnSync(bin, ["--session", session, "close"], { env }); } - await context.close(); } -await browser.close(); +writeGallery(); for (const f of written) console.log(f); + +// A gallery of every PNG in the output directory, written as a page fragment +// (title + style + body) so the Artifact tool can publish it as is, with the +// PNGs passed as `files`. Browsers render the fragment fine locally too. +function writeGallery() { + const shots = readdirSync(out) + .filter((f) => f.endsWith(".png")) + .map((f) => ({ file: f, mtime: statSync(join(out, f)).mtimeMs })); + if (shots.length === 0) return; + const groups = new Map(); + for (const s of shots) { + const m = s.file.match(/^(.*?)-(light|dark)(-\d+)?\.png$/); + const key = m ? `${m[1]}${m[3] ?? ""}` : s.file; + const g = groups.get(key) ?? { key, shots: [], mtime: 0 }; + g.shots.push({ ...s, theme: m?.[2] ?? "" }); + g.mtime = Math.max(g.mtime, s.mtime); + groups.set(key, g); + } + const git = (...a) => + execFileSync("git", a, { cwd: repo, encoding: "utf8" }).trim(); + const commit = git("rev-parse", "--short", "HEAD"); + const facts = [ + ["App", flags.app ?? "dashboard"], + ["Branch", git("rev-parse", "--abbrev-ref", "HEAD")], + ["Commit", commit], + ["Taken", new Date().toISOString().slice(0, 16).replace("T", " ") + " UTC"], + [ + "Viewport", + `${width} px wide, 2× scale${flags["full-page"] ? ", full page" : ""}`, + ], + ]; + const esc = (s) => + s.replace( + /[&<>"]/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], + ); + const html = `Screenshots ${esc(commit)} + +
+

Screenshots

+
${facts.map(([k, v]) => `
${esc(k)}
${esc(v)}
`).join("")}
+${[...groups.values()] + .sort((a, b) => b.mtime - a.mtime) + .map( + (g) => `
+

${esc(g.key)}

+${g.shots + .sort((a, b) => a.theme.localeCompare(b.theme) * -1) + .map( + (s) => `
+
${esc(s.theme || s.file)}
+
${esc(g.key)} ${esc(s.theme)}
+
`, + ) + .join("\n")} +
`, + ) + .join("\n")} +
+`; + const index = join(out, "index.html"); + writeFileSync(index, html); + console.error(`gallery: ${index}`); +}