diff --git a/.github/workflows/visual-review.yml b/.github/workflows/visual-review.yml new file mode 100644 index 0000000..3fd9cdd --- /dev/null +++ b/.github/workflows/visual-review.yml @@ -0,0 +1,119 @@ +name: Visual review + +on: + pull_request: + paths: + - ".github/workflows/visual-review.yml" + - "apps/specimens/**" + - "packages/ui/**" + - "scripts/visual-review.mjs" + - "scripts/mobile-quality-review.mjs" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: visual-review-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + specimens: + name: Specimen browser + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Set up pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 10.17.1 + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build package and specimen app + run: pnpm build + + - name: Set up Chrome for Testing + id: chrome + uses: browser-actions/setup-chrome@2e1d749697dd1612b833dba4a722266286fbefcd # v2.1.2 + with: + chrome-version: stable + + - name: Capture visual and mobile-quality receipts + env: + CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }} + run: | + set -euo pipefail + + mkdir -p artifacts/visual-review artifacts/mobile-quality + preview_log="$RUNNER_TEMP/specimens-preview.log" + + pnpm --filter @opencoven/specimens preview \ + --host 127.0.0.1 \ + --port 4173 >"$preview_log" 2>&1 & + preview_pid=$! + + cleanup() { + kill "$preview_pid" 2>/dev/null || true + wait "$preview_pid" 2>/dev/null || true + } + trap cleanup EXIT + + ready=false + for _ in $(seq 1 100); do + if curl --fail --silent --show-error http://127.0.0.1:4173/ >/dev/null; then + ready=true + break + fi + sleep 0.1 + done + + if [[ "$ready" != "true" ]]; then + cp "$preview_log" artifacts/visual-review/preview.log + echo "Specimen preview did not become ready." >&2 + exit 1 + fi + + set +e + node scripts/visual-review.mjs + visual_status=$? + node scripts/mobile-quality-review.mjs + mobile_status=$? + set -e + + cp "$preview_log" artifacts/visual-review/preview.log + + if [[ "$visual_status" -ne 0 || "$mobile_status" -ne 0 ]]; then + exit 1 + fi + + - name: Upload visual-review artifact + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: specimen-visual-review-${{ github.event.pull_request.number || github.run_number }} + path: artifacts/visual-review + if-no-files-found: error + retention-days: 14 + + - name: Upload mobile-quality artifact + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: specimen-mobile-quality-${{ github.event.pull_request.number || github.run_number }} + path: artifacts/mobile-quality + if-no-files-found: error + retention-days: 14 diff --git a/handoffs/visual-review.md b/handoffs/visual-review.md new file mode 100644 index 0000000..42a31b0 --- /dev/null +++ b/handoffs/visual-review.md @@ -0,0 +1,77 @@ +# Specimen visual review + +The specimen browser produces reviewable viewport receipts for changes that can +alter its presentation. These checks are render and responsive-contract smoke +tests, not a pixel-perfect golden-image suite. + +## What the workflow proves + +For each run, `scripts/visual-review.mjs` drives Chrome through the DevTools +Protocol without adding a browser-testing dependency to the package graph. It +checks: + +- the top bar, responsive rail, and main landmark are visible; +- the page has no horizontal overflow and key assembled surfaces have no hidden internal clipping; +- light/dark scheme and cozy/compact density persist through reload; +- the library renders all 16 specimens in its three task groups; +- the assembled lab renders five tabs; +- no uncaught exception or `console.error` is emitted. + +The visual runner captures these viewport receipts: + +| Surface | Viewport | Scheme | Density | +|---|---:|---|---| +| Library | 1440×1000 | dark | cozy | +| Library | 390×844 | dark | cozy | +| Library | 1440×1000 | light | compact | +| Assembled lab | 1440×1000 | dark | cozy | +| Assembled lab | 390×844 | dark | compact | + +`scripts/mobile-quality-review.mjs` adds a stricter library-surface matrix. It +checks the 16-card catalog at 320, 375, 390, and 430 px; light and dark schemes; +cozy and compact density; RTL direction; reduced-motion behavior; and a 200% +root-text-size simulation. It also verifies that every card retains its tab +root, tab list, active panel, full-width stacked layout, and at least a 44 px tab +target. Session and transcript blocks must remain unellipsized and free of +internal overflow. + +The 200% case is a deterministic text-resizing stress case, not a claim that it +emulates every browser zoom or operating-system accessibility implementation. +Its purpose is to catch rem-scaled viewport floors, fixed-size controls, and +other layout assumptions that make enlarged text force page-level horizontal +scrolling. + +Every run uploads both receipt sets as PNGs with `summary.json` and Markdown +summaries. The visual artifact also includes the Vite preview log, and each +runner writes a bounded Chrome log when its capture process fails. Artifacts are +retained for 14 days. + +## Local use + +Build and start the specimen preview first: + +```bash +pnpm build +pnpm --filter @opencoven/specimens preview --host 127.0.0.1 --port 4173 +``` + +Then, from another shell: + +```bash +CHROME_PATH=/path/to/chrome node scripts/visual-review.mjs +CHROME_PATH=/path/to/chrome node scripts/mobile-quality-review.mjs +``` + +Set `BASE_URL` when the preview is not on `http://127.0.0.1:4173`. Set +`VISUAL_OUTPUT_DIR` or `MOBILE_OUTPUT_DIR` to change the corresponding receipt +directory. `CHROME_DEBUGGING_PORT` and `MOBILE_CHROME_PORT` may be overridden +when the default local ports are occupied. + +## Review policy + +A green result proves the shell rendered, stayed within the requested viewport, +kept key surfaces free of hidden internal clipping, preserved the named +structural contracts, and emitted no observed runtime error. It does not prove +subjective visual quality. Reviewers should still open the PNG receipts when +hierarchy, spacing, typography, responsive behavior, or component composition +changed. diff --git a/scripts/mobile-quality-review.mjs b/scripts/mobile-quality-review.mjs new file mode 100644 index 0000000..72692f9 --- /dev/null +++ b/scripts/mobile-quality-review.mjs @@ -0,0 +1,402 @@ +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const chromePath = process.env.CHROME_PATH; +const baseUrl = process.env.BASE_URL ?? "http://127.0.0.1:4173"; +const outputDir = path.resolve( + process.env.MOBILE_OUTPUT_DIR ?? "artifacts/mobile-quality", +); +const port = Number(process.env.MOBILE_CHROME_PORT ?? 9233); +const cases = [ + { + name: "mobile-320-dark-cozy", + width: 320, + scheme: "dark", + density: "default", + }, + { + name: "mobile-375-light-compact", + width: 375, + scheme: "light", + density: "compact", + }, + { + name: "mobile-390-dark-cozy", + width: 390, + scheme: "dark", + density: "default", + }, + { + name: "mobile-430-light-cozy", + width: 430, + scheme: "light", + density: "default", + }, + { + name: "mobile-390-dark-rtl", + width: 390, + scheme: "dark", + density: "compact", + rtl: true, + }, + { + name: "mobile-390-dark-text-200", + width: 390, + scheme: "dark", + density: "default", + textScale: 2, + }, +]; + +if (!chromePath) throw new Error("CHROME_PATH is required"); + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const profile = await mkdtemp(path.join(tmpdir(), "opencoven-mobile-quality-")); +await mkdir(outputDir, { recursive: true }); + +const chrome = spawn(chromePath, [ + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--hide-scrollbars", + `--remote-debugging-port=${port}`, + `--user-data-dir=${profile}`, + "about:blank", +]); + +let socket; +try { + let target; + for (let attempt = 0; attempt < 100; attempt += 1) { + try { + const targets = await fetch(`http://127.0.0.1:${port}/json/list`).then( + (response) => response.json(), + ); + target = targets.find((entry) => entry.type === "page"); + if (target?.webSocketDebuggerUrl) break; + } catch { + // Chrome may not have exposed the debugging endpoint yet. + } + await sleep(100); + } + + if (!target?.webSocketDebuggerUrl) { + throw new Error("Chrome debugging target unavailable"); + } + + socket = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", reject, { once: true }); + }); + + let id = 0; + const pending = new Map(); + socket.addEventListener("message", (event) => { + const message = JSON.parse(String(event.data)); + if (!message.id) return; + const waiter = pending.get(message.id); + if (!waiter) return; + pending.delete(message.id); + if (message.error) waiter.reject(new Error(message.error.message)); + else waiter.resolve(message.result ?? {}); + }); + + const send = (method, params = {}) => + new Promise((resolve, reject) => { + const requestId = ++id; + pending.set(requestId, { resolve, reject }); + socket.send(JSON.stringify({ id: requestId, method, params })); + }); + + const evaluate = async (expression) => { + const result = await send("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true, + }); + + if (result.exceptionDetails) { + throw new Error( + result.exceptionDetails.exception?.description ?? + result.exceptionDetails.text, + ); + } + + return result.result?.value; + }; + + const navigate = async (url) => { + await send("Page.navigate", { url }); + await sleep(500); + }; + + await send("Page.enable"); + await send("Runtime.enable"); + await send("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-reduced-motion", value: "reduce" }], + }); + + const results = []; + + for (const scenario of cases) { + await send("Emulation.setDeviceMetricsOverride", { + width: scenario.width, + height: 900, + deviceScaleFactor: 1, + mobile: true, + screenWidth: scenario.width, + screenHeight: 900, + }); + + await navigate(new URL("/", baseUrl).href); + await evaluate(`(() => { + localStorage.setItem("coven-ui:scheme", ${JSON.stringify(scenario.scheme)}); + localStorage.setItem("coven-ui:density", ${JSON.stringify(scenario.density)}); + })()`); + await navigate(new URL("/", baseUrl).href); + + await evaluate(`(() => { + document.documentElement.dir = ${JSON.stringify( + scenario.rtl ? "rtl" : "ltr", + )}; + document.documentElement.style.fontSize = ${JSON.stringify( + scenario.textScale ? `${scenario.textScale * 100}%` : "", + )}; + })()`); + + const measurement = await evaluate(`(async () => { + await document.fonts.ready; + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + + const root = document.documentElement; + const cards = [...document.querySelectorAll(".specimen-card")]; + const stages = [...document.querySelectorAll(".specimen-stage")]; + const cardTabRoots = cards + .map((card) => card.querySelector(':scope > [data-slot="tabs"]')) + .filter(Boolean); + const cardLists = cardTabRoots + .map((tabs) => tabs.querySelector(':scope > [data-slot="tabs-list"]')) + .filter(Boolean); + const activePanels = cardTabRoots + .map((tabs) => tabs.querySelector(':scope > [data-slot="tabs-content"]')) + .filter(Boolean); + const transcript = document.querySelector( + '#transcript-turn [data-slot="transcript-turn"]', + ); + const session = document.querySelector( + '#session-header [data-slot="session-header"]', + ); + const sessionTitle = session?.querySelector("strong"); + const clipped = (element) => + element ? Math.max(0, element.scrollWidth - element.clientWidth) : 0; + const rect = (element) => element?.getBoundingClientRect(); + const tabHeights = cardLists.flatMap((list) => + [...list.querySelectorAll('[role="tab"]')].map( + (tab) => rect(tab)?.height ?? 0, + ), + ); + const overflowingElements = [...document.querySelectorAll("body *")] + .map((element) => { + const bounds = rect(element); + const overflow = bounds + ? Math.max(0, -bounds.left, bounds.right - root.clientWidth) + : 0; + const label = [ + element.tagName.toLowerCase(), + element.id ? "#" + element.id : "", + ...[...element.classList] + .slice(0, 3) + .map((name) => "." + name), + ].join(""); + return { label, overflow }; + }) + .filter(({ overflow }) => overflow > 1) + .sort((left, right) => right.overflow - left.overflow) + .slice(0, 5); + + return { + viewport: root.clientWidth, + documentOverflow: Math.max(0, root.scrollWidth - root.clientWidth), + cardCount: cards.length, + tabRootCount: cardTabRoots.length, + tabListCount: cardLists.length, + activePanelCount: activePanels.length, + tabTargetCount: tabHeights.length, + maxCardOverflow: Math.max(0, ...cards.map(clipped)), + maxStageOverflow: Math.max(0, ...stages.map(clipped)), + maxTabRootOverflow: Math.max(0, ...cardTabRoots.map(clipped)), + minTabHeight: tabHeights.length > 0 ? Math.min(...tabHeights) : null, + stackedTabs: cardLists.every((list, index) => { + const listRect = rect(list); + const panelRect = rect(activePanels[index]); + return listRect && panelRect && panelRect.top >= listRect.bottom - 1; + }), + fullWidthTabs: cardLists.every((list, index) => { + const listRect = rect(list); + const rootRect = rect(cardTabRoots[index]); + return ( + listRect && + rootRect && + Math.abs(listRect.width - rootRect.width) <= 1 + ); + }), + transcriptOverflow: clipped(transcript), + sessionOverflow: clipped(session), + sessionTitleEllipsized: sessionTitle + ? getComputedStyle(sessionTitle).textOverflow === "ellipsis" + : null, + direction: root.dir, + reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches, + overflowingElements, + }; + })()`); + + const failures = []; + if (measurement.cardCount !== 16) { + failures.push(`expected 16 cards, got ${measurement.cardCount}`); + } + if (measurement.tabRootCount !== 16) { + failures.push( + `expected 16 card tab roots, got ${measurement.tabRootCount}`, + ); + } + if (measurement.tabListCount !== 16) { + failures.push( + `expected 16 card tab lists, got ${measurement.tabListCount}`, + ); + } + if (measurement.activePanelCount !== 16) { + failures.push( + `expected 16 active card panels, got ${measurement.activePanelCount}`, + ); + } + if (measurement.tabTargetCount !== 48) { + failures.push( + `expected 48 card tab targets, got ${measurement.tabTargetCount}`, + ); + } + if (measurement.documentOverflow > 1) { + failures.push( + `document overflow ${measurement.documentOverflow}px: ${measurement.overflowingElements + .map(({ label, overflow }) => `${label} (${overflow}px)`) + .join(", ")}`, + ); + } + if (measurement.maxCardOverflow > 1) { + failures.push(`card overflow ${measurement.maxCardOverflow}px`); + } + if (measurement.maxStageOverflow > 1) { + failures.push(`stage overflow ${measurement.maxStageOverflow}px`); + } + if (measurement.maxTabRootOverflow > 1) { + failures.push(`tab-root overflow ${measurement.maxTabRootOverflow}px`); + } + if (measurement.minTabHeight === null || measurement.minTabHeight < 44) { + failures.push( + `tab target ${measurement.minTabHeight ?? "missing"}px < 44px`, + ); + } + if (!measurement.stackedTabs) { + failures.push("card tabs are not stacked above their active panels"); + } + if (!measurement.fullWidthTabs) { + failures.push("card tab lists do not consume the mobile content width"); + } + if (measurement.transcriptOverflow > 1) { + failures.push(`transcript overflow ${measurement.transcriptOverflow}px`); + } + if (measurement.sessionOverflow > 1) { + failures.push(`session header overflow ${measurement.sessionOverflow}px`); + } + if (measurement.sessionTitleEllipsized) { + failures.push("session title is ellipsized on mobile"); + } + if (!measurement.reducedMotion) { + failures.push("reduced-motion media query was not active"); + } + if (scenario.rtl && measurement.direction !== "rtl") { + failures.push("RTL direction was not applied"); + } + + const image = await send("Page.captureScreenshot", { + format: "png", + fromSurface: true, + captureBeyondViewport: false, + }); + const screenshot = `${scenario.name}.png`; + await writeFile( + path.join(outputDir, screenshot), + Buffer.from(image.data, "base64"), + ); + results.push({ ...scenario, measurement, failures, screenshot }); + } + + const summary = { + generatedAt: new Date().toISOString(), + passed: results.every((entry) => entry.failures.length === 0), + results, + }; + await writeFile( + path.join(outputDir, "summary.json"), + `${JSON.stringify(summary, null, 2)}\n`, + ); + await writeFile( + path.join(outputDir, "README.md"), + [ + "# OpenCoven UI mobile quality review", + "", + `Result: **${summary.passed ? "PASS" : "FAIL"}**`, + "", + "| Scenario | Width | Document overflow | Stage overflow | Min tab target | Result |", + "|---|---:|---:|---:|---:|---|", + ...results.map( + (entry) => + `| ${entry.name} | ${entry.width}px | ${entry.measurement.documentOverflow}px | ${entry.measurement.maxStageOverflow}px | ${entry.measurement.minTabHeight}px | ${entry.failures.length ? entry.failures.join("; ") : "PASS"} |`, + ), + "", + ].join("\n"), + ); + + if (!summary.passed) { + throw new Error( + results + .flatMap((entry) => + entry.failures.map((failure) => `${entry.name}: ${failure}`), + ) + .join("\n"), + ); + } +} finally { + socket?.close(); + chrome.kill(); + await new Promise((resolve) => { + if (chrome.exitCode !== null) { + resolve(); + return; + } + const timeout = setTimeout(resolve, 2000); + chrome.once("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + let profileRemoved = false; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await rm(profile, { recursive: true, force: true }); + profileRemoved = true; + break; + } catch { + await sleep(100 * (attempt + 1)); + } + } + if (!profileRemoved) { + console.warn(`Unable to remove temporary Chrome profile: ${profile}`); + } +} diff --git a/scripts/visual-review.mjs b/scripts/visual-review.mjs new file mode 100644 index 0000000..366e127 --- /dev/null +++ b/scripts/visual-review.mjs @@ -0,0 +1,531 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const chromePath = process.env.CHROME_PATH; +const baseUrl = process.env.BASE_URL ?? "http://127.0.0.1:4173"; +const outputDir = path.resolve( + process.env.VISUAL_OUTPUT_DIR ?? "artifacts/visual-review", +); +const debuggingPort = Number(process.env.CHROME_DEBUGGING_PORT ?? 9222); + +if (!chromePath) { + throw new Error("CHROME_PATH is required"); +} + +class CdpClient { + constructor(url) { + this.url = url; + this.socket = null; + this.nextId = 1; + this.pending = new Map(); + this.listeners = new Map(); + } + + async connect() { + await new Promise((resolve, reject) => { + const socket = new globalThis.WebSocket(this.url); + this.socket = socket; + + socket.addEventListener("open", resolve, { once: true }); + socket.addEventListener("error", reject, { once: true }); + socket.addEventListener("message", (event) => { + const message = JSON.parse(String(event.data)); + + if (message.id) { + const pending = this.pending.get(message.id); + if (!pending) { + return; + } + + this.pending.delete(message.id); + if (message.error) { + pending.reject( + new Error( + `${pending.method}: ${message.error.message ?? "CDP error"}`, + ), + ); + } else { + pending.resolve(message.result ?? {}); + } + return; + } + + const handlers = this.listeners.get(message.method) ?? []; + for (const handler of handlers) { + handler(message.params ?? {}); + } + }); + socket.addEventListener( + "close", + () => { + for (const pending of this.pending.values()) { + pending.reject(new Error("Chrome DevTools connection closed")); + } + this.pending.clear(); + }, + { once: true }, + ); + }); + } + + on(method, handler) { + const handlers = this.listeners.get(method) ?? []; + handlers.push(handler); + this.listeners.set(method, handlers); + + return () => { + this.listeners.set( + method, + (this.listeners.get(method) ?? []).filter( + (candidate) => candidate !== handler, + ), + ); + }; + } + + send(method, params = {}) { + if (!this.socket || this.socket.readyState !== globalThis.WebSocket.OPEN) { + throw new Error("Chrome DevTools connection is not open"); + } + + const id = this.nextId; + this.nextId += 1; + + return new Promise((resolve, reject) => { + this.pending.set(id, { method, resolve, reject }); + this.socket.send(JSON.stringify({ id, method, params })); + }); + } + + waitForEvent(method, timeoutMs = 15_000) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + removeListener(); + reject(new Error(`Timed out waiting for ${method}`)); + }, timeoutMs); + const removeListener = this.on(method, (params) => { + clearTimeout(timeout); + removeListener(); + resolve(params); + }); + }); + } + + close() { + this.socket?.close(); + } +} + +async function waitForJson(url, timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs; + let lastError; + + while (Date.now() < deadline) { + try { + const response = await globalThis.fetch(url); + if (response.ok) { + return await response.json(); + } + lastError = new Error(`${response.status} ${response.statusText}`); + } catch (error) { + lastError = error; + } + + await new Promise((resolve) => setTimeout(resolve, 150)); + } + + throw new Error( + `Chrome debugging endpoint did not become ready: ${lastError}`, + ); +} + +async function waitForRender(client, selector, timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const result = await client.send("Runtime.evaluate", { + expression: `Boolean(document.querySelector(${JSON.stringify(selector)}))`, + returnByValue: true, + }); + + if (result.result?.value === true) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + throw new Error(`Timed out waiting for ${selector}`); +} + +async function navigate(client, url) { + const loaded = client.waitForEvent("Page.loadEventFired"); + const response = await client.send("Page.navigate", { url }); + + if (response.errorText) { + throw new Error(`Navigation failed: ${response.errorText}`); + } + + await loaded; +} + +async function evaluateValue(client, expression, awaitPromise = false) { + const result = await client.send("Runtime.evaluate", { + expression, + awaitPromise, + returnByValue: true, + }); + + if (result.exceptionDetails) { + throw new Error( + result.exceptionDetails.exception?.description ?? + result.exceptionDetails.text ?? + "Runtime evaluation failed", + ); + } + + return result.result?.value; +} + +const scenarios = [ + { + name: "library-dark-desktop", + pathname: "/", + width: 1440, + height: 1000, + scheme: "dark", + density: "default", + mobile: false, + expected: "library", + }, + { + name: "library-dark-mobile", + pathname: "/", + width: 390, + height: 844, + scheme: "dark", + density: "default", + mobile: true, + expected: "library", + }, + { + name: "library-light-desktop", + pathname: "/", + width: 1440, + height: 1000, + scheme: "light", + density: "compact", + mobile: false, + expected: "library", + }, + { + name: "assembled-dark-desktop", + pathname: "/lab", + width: 1440, + height: 1000, + scheme: "dark", + density: "default", + mobile: false, + expected: "lab", + }, + { + name: "assembled-dark-mobile", + pathname: "/lab", + width: 390, + height: 844, + scheme: "dark", + density: "compact", + mobile: true, + expected: "lab", + }, +]; + +await mkdir(outputDir, { recursive: true }); +const profileDir = await mkdtemp( + path.join(tmpdir(), "opencoven-ui-visual-review-"), +); +const chromeOutput = []; +const chrome = spawn( + chromePath, + [ + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--hide-scrollbars", + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + "--disable-component-update", + `--remote-debugging-port=${debuggingPort}`, + `--user-data-dir=${profileDir}`, + "about:blank", + ], + { stdio: ["ignore", "pipe", "pipe"] }, +); + +chrome.stdout.on("data", (chunk) => chromeOutput.push(String(chunk))); +chrome.stderr.on("data", (chunk) => chromeOutput.push(String(chunk))); + +let client; +const results = []; + +try { + const targets = await waitForJson( + `http://127.0.0.1:${debuggingPort}/json/list`, + ); + const page = targets.find((target) => target.type === "page"); + + if (!page?.webSocketDebuggerUrl) { + throw new Error("Chrome did not expose a page debugging target"); + } + + client = new CdpClient(page.webSocketDebuggerUrl); + await client.connect(); + await client.send("Page.enable"); + await client.send("Runtime.enable"); + await client.send("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-reduced-motion", value: "reduce" }], + }); + + for (const scenario of scenarios) { + const runtimeErrors = []; + const removeExceptionListener = client.on( + "Runtime.exceptionThrown", + ({ exceptionDetails }) => { + runtimeErrors.push( + exceptionDetails.exception?.description ?? + exceptionDetails.text ?? + "Uncaught runtime exception", + ); + }, + ); + const removeConsoleListener = client.on( + "Runtime.consoleAPICalled", + ({ type, args = [] }) => { + if (type === "error") { + runtimeErrors.push( + args + .map((argument) => argument.value ?? argument.description ?? "") + .join(" "), + ); + } + }, + ); + + await client.send("Emulation.setDeviceMetricsOverride", { + width: scenario.width, + height: scenario.height, + deviceScaleFactor: 1, + mobile: scenario.mobile, + screenWidth: scenario.width, + screenHeight: scenario.height, + }); + + await navigate(client, new URL("/", baseUrl).href); + await evaluateValue( + client, + `localStorage.setItem("coven-ui:scheme", ${JSON.stringify( + scenario.scheme, + )}); localStorage.setItem("coven-ui:density", ${JSON.stringify( + scenario.density, + )});`, + ); + await navigate(client, new URL(scenario.pathname, baseUrl).href); + await waitForRender(client, "#specimen-main"); + await evaluateValue( + client, + `(async () => { + await document.fonts.ready; + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)), + ); + return true; + })()`, + true, + ); + + const layout = await evaluateValue( + client, + `(() => { + const root = document.documentElement; + const topbar = document.querySelector(".specimen-topbar"); + const rail = document.querySelector(".specimen-rail"); + const main = document.querySelector("#specimen-main"); + const cards = [...document.querySelectorAll(".specimen-card")]; + const groups = [...document.querySelectorAll(".catalog-group")]; + const lab = document.querySelector(".assembled-lab"); + const tabs = lab + ? [...lab.querySelectorAll('[role="tab"]')] + : []; + const clippedSurfaceSelectors = [ + '[data-slot="session-header"]', + '.assembled-lab [data-slot="tabs"]', + '.assembled-lab [data-slot="transcript-turn"]', + '.assembled-lab [data-slot="composer"]', + ]; + const internallyClipped = clippedSurfaceSelectors.flatMap((selector) => + [...document.querySelectorAll(selector)] + .map((element) => ({ + selector, + overflow: Math.max(0, element.scrollWidth - element.clientWidth), + })) + .filter(({ overflow }) => overflow > 1), + ); + + const isVisible = (element) => { + if (!element) return false; + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return ( + style.display !== "none" && + style.visibility !== "hidden" && + rect.width > 0 && + rect.height > 0 + ); + }; + + return { + pathname: location.pathname, + title: document.title, + viewportWidth: root.clientWidth, + scrollWidth: root.scrollWidth, + horizontalOverflow: Math.max(0, root.scrollWidth - root.clientWidth), + topbarVisible: isVisible(topbar), + railVisible: isVisible(rail), + mainVisible: isVisible(main), + cardCount: cards.length, + groupCount: groups.length, + labVisible: isVisible(lab), + tabCount: tabs.length, + scheme: root.classList.contains("dark") ? "dark" : "light", + density: root.dataset.density, + internallyClipped, + }; + })()`, + ); + + const failures = []; + if (!layout.topbarVisible || !layout.railVisible || !layout.mainVisible) { + failures.push("required shell landmarks are not visible"); + } + if (layout.horizontalOverflow > 1) { + failures.push( + `horizontal overflow is ${layout.horizontalOverflow}px at ${scenario.width}px`, + ); + } + if (layout.internallyClipped.length > 0) { + failures.push( + `internally clipped surfaces: ${layout.internallyClipped + .map(({ selector, overflow }) => `${selector} (${overflow}px)`) + .join(", ")}`, + ); + } + if (layout.scheme !== scenario.scheme) { + failures.push( + `expected ${scenario.scheme} scheme, received ${layout.scheme}`, + ); + } + if (layout.density !== scenario.density) { + failures.push( + `expected ${scenario.density} density, received ${layout.density}`, + ); + } + if ( + scenario.expected === "library" && + (layout.cardCount !== 16 || layout.groupCount !== 3) + ) { + failures.push( + `library rendered ${layout.cardCount} cards across ${layout.groupCount} groups`, + ); + } + if ( + scenario.expected === "lab" && + (!layout.labVisible || layout.tabCount !== 5) + ) { + failures.push( + `assembled lab rendered visible=${layout.labVisible} tabs=${layout.tabCount}`, + ); + } + if (runtimeErrors.length > 0) { + failures.push(`runtime errors: ${runtimeErrors.join(" | ")}`); + } + + const screenshot = await client.send("Page.captureScreenshot", { + format: "png", + fromSurface: true, + captureBeyondViewport: false, + }); + const screenshotPath = path.join(outputDir, `${scenario.name}.png`); + await writeFile(screenshotPath, Buffer.from(screenshot.data, "base64")); + + results.push({ + ...scenario, + layout, + failures, + screenshot: path.basename(screenshotPath), + }); + + removeExceptionListener(); + removeConsoleListener(); + } + + const summary = { + generatedAt: new Date().toISOString(), + baseUrl, + chromePath, + passed: results.every((result) => result.failures.length === 0), + scenarios: results, + }; + + await writeFile( + path.join(outputDir, "summary.json"), + `${JSON.stringify(summary, null, 2)}\n`, + ); + await writeFile( + path.join(outputDir, "README.md"), + [ + "# OpenCoven UI visual review", + "", + `Result: **${summary.passed ? "PASS" : "FAIL"}**`, + "", + "| Scenario | Viewport | Scheme | Density | Overflow | Result |", + "|---|---:|---|---|---:|---|", + ...results.map( + (result) => + `| ${result.name} | ${result.width}×${result.height} | ${result.scheme} | ${result.density} | ${result.layout.horizontalOverflow}px | ${ + result.failures.length === 0 ? "PASS" : result.failures.join("; ") + } |`, + ), + "", + "The PNG files in this artifact are viewport receipts, not golden snapshots.", + "", + ].join("\n"), + ); + + const failures = results.flatMap((result) => + result.failures.map((failure) => `${result.name}: ${failure}`), + ); + + if (failures.length > 0) { + throw new Error(`Visual review failed:\n- ${failures.join("\n- ")}`); + } + + console.log(`Captured ${results.length} passing visual-review scenarios.`); +} catch (error) { + await writeFile( + path.join(outputDir, "chrome.log"), + `${chromeOutput.join("")}\n`, + ); + throw error; +} finally { + client?.close(); + chrome.kill("SIGTERM"); + await Promise.race([ + new Promise((resolve) => chrome.once("exit", resolve)), + new Promise((resolve) => setTimeout(resolve, 2_000)), + ]); + await rm(profileDir, { recursive: true, force: true }).catch(() => undefined); +}