#!/usr/bin/env bun /** * Robomp Lifecycle Cards Verification Harness. * Drives headless Chrome to assert layout, contrast, interactions, and edge cases. */ import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { HTTPRequest } from "puppeteer-core"; import puppeteer from "puppeteer-core"; import type { StatusResponse } from "../src/types"; const VIEWPORT = { width: 1280, height: 720, deviceScaleFactor: 1 } as const; interface CheckResult { name: string; ok: boolean; detail: string; } const results: CheckResult[] = []; // Resolve a Chrome/Chromium binary. Walks the explicit path, PATH lookups via // Bun.which, and well-known absolute locations; each candidate is confirmed // with fs.stat so a directory or stale entry never wins. async function resolveChrome(explicit: string | undefined): Promise { const candidates: string[] = []; const fromEnv = explicit ?? process.env.PUPPETEER_EXECUTABLE_PATH ?? process.env.CHROME_PATH; if (fromEnv) candidates.push(fromEnv); for (const name of ["google-chrome-stable", "google-chrome", "chromium", "chromium-browser", "chrome"]) { const found = Bun.which(name); if (found) candidates.push(found); } candidates.push( "/usr/bin/google-chrome-stable", "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/snap/bin/chromium", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", ); for (const candidate of candidates) { try { const stat = await fs.stat(candidate); if (stat.isFile()) return candidate; } catch { // not present — try the next candidate } } throw new Error("No Chrome/Chromium found. Pass --chrome or set PUPPETEER_EXECUTABLE_PATH."); } const baseStatus: StatusResponse = { runtime: { bot_login: "robomp-bot", repo_allowlist: ["octo/widget"], max_concurrency: 1, model: "test-model", thinking_level: "low", uptime_seconds: 120, }, event_counts: { queued: 0, running: 0, done: 0, failed: 0, skipped: 0 }, issue_event_counts: { queued: 0, running: 0, done: 0, failed: 0, skipped: 0 }, running_events: [], inflight: [], issues: [], recent_events: [], }; const populatedStatus: StatusResponse = { runtime: { bot_login: "robomp-bot", repo_allowlist: ["octo/widget"], max_concurrency: 1, model: "test-model", thinking_level: "low", uptime_seconds: 120, }, event_counts: { queued: 1, running: 1, done: 0, failed: 2, skipped: 0 }, issue_event_counts: { queued: 1, running: 1, done: 0, failed: 2, skipped: 0 }, running_events: [ { delivery_id: "run-del-2", event_type: "issue_comment", repo: "octo/widget", issue_key: "octo/widget#2", received_at: "2026-06-18T00:00:00Z", started_at: "2026-06-18T00:01:00Z", attempts: 1, model: "claude-3-5", last_tool: "edit", last_tool_ts: "2026-06-18T00:01:10Z" }, { delivery_id: "run-del-9", event_type: "issue_comment", repo: "octo/widget", issue_key: "octo/widget#9", received_at: "2026-06-18T00:00:00Z", started_at: null, attempts: 1, model: null, last_tool: null, last_tool_ts: null }, { delivery_id: "run-del-5", event_type: "issue_comment", repo: "octo/widget", issue_key: null, received_at: "2026-06-18T00:00:00Z", started_at: "2026-06-18T00:01:00Z", attempts: 1, model: "claude-3-5", last_tool: "edit", last_tool_ts: null } ], inflight: ["octo/widget#2", "octo/widget#7"], issues: [ { key: "octo/widget#1", repo: "octo/widget", number: 1, branch: null, pr_number: null, state: "fixing", classification: "bug", updated_at: "2026-06-18T00:00:00Z", latest_event: { delivery_id: "fail-del-1", event_type: "issues", state: "failed", attempts: 1, received_at: "2026-06-18T00:00:00Z", last_error: "triage failed error" } }, { key: "octo/widget#2", repo: "octo/widget", number: 2, branch: "fix-branch", pr_number: 42, state: "reproducing", classification: "bug", updated_at: "2026-06-18T00:00:00Z", latest_event: { delivery_id: "run-del-2", event_type: "issue_comment", state: "running", attempts: 1, received_at: "2026-06-18T00:00:00Z", last_error: null } }, { key: "octo/widget#3", repo: "octo/widget", number: 3, branch: null, pr_number: null, state: "new", classification: "bug", updated_at: "2026-06-18T00:00:00Z", latest_event: { delivery_id: "queued-del-3", event_type: "issues", state: "queued", attempts: 1, received_at: "2026-06-18T00:00:00Z", last_error: null } }, { key: "octo/widget#4", repo: "octo/widget", number: 4, branch: null, pr_number: null, state: "new", classification: "bug", updated_at: "2026-06-18T00:00:00Z", latest_event: null }, { key: "octo/widget#8", repo: "octo/widget", number: 8, branch: null, pr_number: null, state: "fixing", classification: "bug", updated_at: "2026-06-18T00:00:00Z", latest_event: { delivery_id: "fail-del-8", event_type: "issues", state: "failed", attempts: 1, received_at: "2026-06-18T00:00:00Z", last_error: null } }, { key: "octo/widget#9", repo: "octo/widget", number: 9, branch: null, pr_number: null, state: "reproducing", classification: "bug", updated_at: "2026-06-18T00:00:00Z", latest_event: { delivery_id: "run-del-9", event_type: "issue_comment", state: "running", attempts: 1, received_at: "2026-06-18T00:00:00Z", last_error: null } }, { key: "octo/widget#10", repo: "octo/widget", number: 10, branch: null, pr_number: null, state: "merged", classification: "bug", updated_at: "2026-06-18T00:02:00Z", latest_event: { delivery_id: "done-del", event_type: "issues", state: "done", attempts: 1, received_at: "2026-06-18T00:02:00Z", last_error: null } }, { key: "octo/widget#11", repo: "octo/widget", number: 11, branch: null, pr_number: null, state: "reproducing", classification: "bug", updated_at: "2026-06-18T00:00:00Z", latest_event: { delivery_id: "latest-run-del-11", event_type: "issue_comment", state: "running", attempts: 1, received_at: "2026-06-18T00:00:00Z", last_error: null } } ], recent_events: [ { delivery_id: "superseded-del", event_type: "issues", repo: "octo/widget", issue_key: "octo/widget#10", state: "failed", attempts: 1, received_at: "2026-06-18T00:00:00Z", last_error: "old error", issue_state: "merged" }, { delivery_id: "fail-del-6", event_type: "issues", repo: "octo/widget", issue_key: null, state: "failed", attempts: 1, received_at: "2026-06-18T00:00:00Z", last_error: "orphan failed error", issue_state: null } ] }; async function main(): Promise { const outDir = path.resolve(import.meta.dir, "../verify-out/robomp-lifecycle-cards"); await fs.mkdir(path.join(outDir, "shots"), { recursive: true }); const chrome = await resolveChrome(undefined); console.log(`Chrome resolved: ${chrome}`); const browser = await puppeteer.launch({ headless: true, executablePath: chrome, args: [ "--no-sandbox", "--disable-setuid-sandbox", `--window-size=${VIEWPORT.width},${VIEWPORT.height}`, ], defaultViewport: { ...VIEWPORT }, }); const page = await browser.newPage(); await page.setRequestInterception(true); let statusMock: StatusResponse = baseStatus; let statusDelayMs = 0; let customIndexHtml: string | null = null; // When set, /api/trigger answers POSTs with an error status + detail body so // the retry-failure surface (e.g. the Activity status line) can be asserted. let triggerErrorDetail: string | null = null; // Intercept trigger / cancel requests for asserting later. Bodies are // narrowed to a record (or null) so property checks below stay type-safe // instead of reaching into `unknown`. const triggeredRequests: { url: string; method: string; headers: Record; body: Record | null }[] = []; page.on("request", async (req: HTTPRequest) => { const url = new URL(req.url()); if (req.resourceType() === "document" && customIndexHtml !== null) { req.respond({ status: 200, contentType: "text/html", body: customIndexHtml, }); return; } if (url.pathname === "/api/status") { if (statusDelayMs > 0) { await Bun.sleep(statusDelayMs); } req.respond({ status: 200, contentType: "application/json", body: JSON.stringify(statusMock), }); } else if (url.pathname === "/api/logs") { req.respond({ status: 200, contentType: "application/json", body: JSON.stringify({ entries: [], count: 0, limit: 400 }), }); } else if (url.pathname === "/api/github/issues") { req.respond({ status: 200, contentType: "application/json", body: JSON.stringify({ issues: [], errors: [], repos: ["octo/widget"], cache: { hit: false, fetched_at: 0 } }), }); } else if (url.pathname === "/api/trigger" && req.method() === "POST") { const parsedBody: Record | null = req.postData() ? (JSON.parse(req.postData()!) as Record) : null; const deliveryId = parsedBody?.delivery_id; const mode = parsedBody?.mode; triggeredRequests.push({ url: req.url(), method: req.method(), headers: req.headers() as Record, body: parsedBody, }); if (triggerErrorDetail !== null) { req.respond({ status: 500, contentType: "application/json", body: JSON.stringify({ detail: triggerErrorDetail }), }); } else { req.respond({ status: 202, contentType: "application/json", body: JSON.stringify({ delivery: (typeof deliveryId === "string" && deliveryId) ? deliveryId : "manual-trigger", state: "queued", mode: (typeof mode === "string" && mode) ? mode : "triage", }), }); } } else if (url.pathname === "/api/cancel" && req.method() === "POST") { const parsedBody: Record | null = req.postData() ? (JSON.parse(req.postData()!) as Record) : null; const deliveryId = parsedBody?.delivery_id; triggeredRequests.push({ url: req.url(), method: req.method(), headers: req.headers() as Record, body: parsedBody, }); req.respond({ status: 202, contentType: "application/json", body: JSON.stringify({ delivery: (typeof deliveryId === "string" && deliveryId) ? deliveryId : "cancel-trigger", fired: false, previous_state: "running", }), }); } else { req.continue(); } }); const BASE_URL = "http://127.0.0.1:8099/"; // 1. Skeleton state statusMock = baseStatus; statusDelayMs = 600; await page.goto(BASE_URL, { waitUntil: "domcontentloaded" }); // Wait a small frame but not full delay await Bun.sleep(100); const skeletonsCount = await page.evaluate(() => document.querySelectorAll(".rmp-card-skeleton").length); const cardsCount = await page.evaluate(() => document.querySelectorAll(".rmp-card").length); results.push({ name: "skeleton-state", ok: skeletonsCount >= 2 && cardsCount === 0, detail: `Found ${skeletonsCount} skeletons and ${cardsCount} cards.`, }); await page.screenshot({ path: path.join(outDir, "shots/skeleton.png") }); statusDelayMs = 0; // reset delay // 2. Empty state + allowlist statusMock = { ...baseStatus, runtime: { ...baseStatus.runtime, repo_allowlist: ["octo/widget", "octo/robomp"], }, }; await page.goto(BASE_URL, { waitUntil: "networkidle0" }); const emptyText = await page.evaluate(() => document.querySelector(".rmp-pipeline-empty")?.textContent ?? ""); results.push({ name: "empty-state-allowlist", ok: emptyText.includes("No active work") && emptyText.includes("watching octo/widget, octo/robomp"), detail: `Empty text: "${emptyText}"`, }); await page.screenshot({ path: path.join(outDir, "shots/empty-dark.png") }); // Helper for applying theme const applyTheme = async (themeName: "dark" | "light") => { await page.evaluate((theme) => { localStorage.setItem("omp-robomp-theme", theme); document.documentElement.dataset.theme = theme; document.documentElement.style.colorScheme = theme; }, themeName); await page.reload({ waitUntil: "networkidle0" }); }; // 3. Populated state - Dark Theme statusMock = populatedStatus; await page.goto(BASE_URL, { waitUntil: "networkidle0" }); await applyTheme("dark"); const cards = await page.evaluate(() => { const elements = document.querySelectorAll(".rmp-card"); return Array.from(elements).map((el) => { const key = el.querySelector(".rmp-card-id")?.textContent?.trim() ?? ""; const bucket = el.getAttribute("data-bucket") ?? ""; const errorText = el.querySelector(".rmp-card-error")?.textContent?.trim() ?? ""; const actions = Array.from(el.querySelectorAll(".rmp-card-actions button")).map(b => b.textContent?.trim()); const metaText = el.querySelector(".rmp-card-meta")?.textContent?.trim() ?? ""; const text = el.textContent ?? ""; const stepper = el.querySelector(".rmp-step") !== null; const currentStep = el.querySelector(".rmp-step-node[data-state='current']") !== null; const liveStep = el.querySelector(".rmp-step-node[data-live='true']") !== null; const failedStep = el.querySelector(".rmp-step-node[data-state='failed']") !== null; return { key, bucket, errorText, actions, metaText, text, stepper, currentStep, liveStep, failedStep }; }); }); console.log("All resolved cards:", JSON.stringify(cards)); const fullFailedCard = cards.find(c => c.key === "bugocto/widget#1"); const runningCard = cards.find(c => c.key === "bugocto/widget#2"); const inflightOnlyCard = cards.find(c => c.key === "issueocto/widget#7"); const compactOrphanRunning = cards.find(c => c.key === "run-del-"); const compactOrphanFailed = cards.find(c => c.key === "fail-del"); const failedWithoutErr = cards.find(c => c.key === "bugocto/widget#8"); const runningWithInvalidTs = cards.find(c => c.key === "bugocto/widget#9"); const queuedCard = cards.find(c => c.key === "bugocto/widget#3"); const activeCard = cards.find(c => c.key === "bugocto/widget#4"); // Latest-event-only running: issue.latest_event is running but no matching // running_events row and not inflight (live === null, inflightOnly === false). const latestEventOnlyRunning = cards.find(c => c.key === "bugocto/widget#11"); // Check failed full card results.push({ name: "failed-full-card", ok: !!fullFailedCard && fullFailedCard.bucket === "failed" && fullFailedCard.errorText === "triage failed error" && fullFailedCard.actions.includes("retry"), detail: `Failed full card check: ${JSON.stringify(fullFailedCard)}`, }); // Check running full card results.push({ name: "running-full-card", ok: !!runningCard && runningCard.bucket === "running" && runningCard.metaText.includes("claude-3-5") && runningCard.metaText.includes("last action edit") && runningCard.actions.includes("cancel"), detail: `Running full card check: ${JSON.stringify(runningCard)}`, }); // Check inflight-only card results.push({ name: "inflight-only-card", ok: !!inflightOnlyCard && inflightOnlyCard.bucket === "running" && inflightOnlyCard.metaText.includes("held by pool") && inflightOnlyCard.actions.length === 0 && inflightOnlyCard.stepper && inflightOnlyCard.liveStep, detail: `Inflight-only card check: ${JSON.stringify(inflightOnlyCard)}`, }); // Check compact orphan running results.push({ name: "compact-orphan-running", ok: !!compactOrphanRunning && compactOrphanRunning.bucket === "running" && !compactOrphanRunning.stepper && compactOrphanRunning.actions.includes("cancel"), detail: `Compact orphan running check: ${JSON.stringify(compactOrphanRunning)}`, }); // Check compact orphan failed results.push({ name: "compact-orphan-failed", ok: !!compactOrphanFailed && compactOrphanFailed.bucket === "failed" && !compactOrphanFailed.stepper && compactOrphanFailed.actions.includes("retry"), detail: `Compact orphan failed check: ${JSON.stringify(compactOrphanFailed)}`, }); // Check failed without error text results.push({ name: "failed-without-error-text", ok: !!failedWithoutErr && failedWithoutErr.bucket === "failed" && failedWithoutErr.errorText === "" && failedWithoutErr.actions.includes("retry"), detail: `Failed without error text check: ${JSON.stringify(failedWithoutErr)}`, }); // Check invalid/missing started_at on running item elapsed renders "—" results.push({ name: "invalid-missing-started-at-elapsed", ok: !!runningWithInvalidTs && runningWithInvalidTs.metaText.includes("elapsed —"), detail: `Running with invalid ts check: ${JSON.stringify(runningWithInvalidTs)}`, }); // Check queued full card — non-terminal issue whose latest event is queued. results.push({ name: "queued-full-card", ok: !!queuedCard && queuedCard.bucket === "queued" && queuedCard.stepper && queuedCard.currentStep && queuedCard.actions.length === 0, detail: `Queued full card check: ${JSON.stringify(queuedCard)}`, }); // Check active full card — non-terminal issue with no latest event yet. results.push({ name: "active-full-card", ok: !!activeCard && activeCard.bucket === "active" && activeCard.stepper && activeCard.currentStep && activeCard.actions.length === 0, detail: `Active full card check: ${JSON.stringify(activeCard)}`, }); // Latest-event-only running card — renders as running (latest_event state), // but with NO live row it must not expose cancel (nothing to kill) and must // not mark a lifecycle step live (no real subprocess heartbeat). results.push({ name: "latest-event-only-running-no-cancel-no-live-step", ok: !!latestEventOnlyRunning && latestEventOnlyRunning.bucket === "running" && !latestEventOnlyRunning.actions.includes("cancel") && latestEventOnlyRunning.stepper && !latestEventOnlyRunning.liveStep, detail: `Latest-event-only running card check: ${JSON.stringify(latestEventOnlyRunning)}`, }); // Failed cards must sort ahead of every other bucket: the first rendered // card is a failed card (DOM order mirrors buildWorkItems sort order). results.push({ name: "failed-sorts-first", ok: cards.length > 0 && cards[0].bucket === "failed", detail: `First rendered card: ${JSON.stringify(cards[0] ?? null)}`, }); // Stepper state indicators (existing data attributes, not invented): // the running card's current node is marked live; the failed card's // current node carries the failed state. results.push({ name: "stepper-running-live-node", ok: !!runningCard && runningCard.currentStep && runningCard.liveStep, detail: `Running card stepper currentStep=${runningCard?.currentStep} liveStep=${runningCard?.liveStep}`, }); results.push({ name: "stepper-failed-node", ok: !!fullFailedCard && fullFailedCard.failedStep, detail: `Failed card stepper failedStep=${fullFailedCard?.failedStep}`, }); // Check superseded + terminal exclusion against the rendered lifecycle-card // model above, not document.body. Activity stays mounted while hidden and may // legitimately contain historical terminal events. const terminalLeakKeys = cards .filter( (card) => card.text.includes("superseded-del") || card.text.includes("done-del") || card.text.includes("octo/widget#10"), ) .map((card) => card.key); results.push({ name: "superseded-terminal-exclusion-visual", ok: terminalLeakKeys.length === 0, detail: `Terminal/superseded card keys: ${terminalLeakKeys.join(", ") || "none"}`, }); const debugTextContrast = await page.evaluate(() => { const getLuminance = (str: string): number => { const s = str.trim().toLowerCase(); if (s.startsWith("lab")) { const m = s.match(/lab\(([\d.]+)\s+([\d.-]+)\s+([\d.-]+)/); if (m) { const L = parseFloat(m[1]); return L > 8 ? Math.pow((L + 16) / 116, 3) : L / 903.3; } } const mRgb = s.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (mRgb) { const r = parseInt(mRgb[1]), g = parseInt(mRgb[2]), b = parseInt(mRgb[3]); const a = [r, g, b].map(v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; } return 0; }; const getContrast = (c1: string, c2: string): number => { const lum1 = getLuminance(c1); const lum2 = getLuminance(c2); return (Math.max(lum1, lum2) + 0.05) / (Math.min(lum1, lum2) + 0.05); }; const getBgColor = (el: Element, fallback = "lab(15 0 0)"): string => { let cur: Element | null = el; while (cur) { const bg = window.getComputedStyle(cur).backgroundColor; if (bg && bg !== "transparent" && !bg.includes("rgba(0, 0, 0, 0)")) { return bg; } cur = cur.parentElement; } return fallback; }; const meta = document.querySelector(".rmp-card-meta"); const label = document.querySelector(".rmp-step-label"); const error = document.querySelector(".rmp-card-error"); const pill = document.querySelector(".pill"); const getElDetails = (el: Element | null) => { if (!el) return null; const fg = window.getComputedStyle(el).color; const bg = getBgColor(el); const ratio = getContrast(fg, bg); return { tag: el.tagName, class: el.className, text: el.textContent?.trim()?.slice(0, 30), fgStr: fg, bgStr: bg, ratio }; }; return { meta: getElDetails(meta), label: getElDetails(label), error: getElDetails(error), pill: getElDetails(pill), }; }); console.log("Contrast diagnostics (dark theme):", JSON.stringify(debugTextContrast, null, 2)); // Contrast & structural checks (dark theme) const structuralCheck = await page.evaluate(() => { const cards = Array.from(document.querySelectorAll(".rmp-card")); // borders and shadows const borderShadowOk = cards.every(el => { const style = window.getComputedStyle(el); const radius = style.borderRadius; const shadow = style.boxShadow; const bl = parseFloat(style.borderLeftWidth); const br = parseFloat(style.borderRightWidth); return radius === "8px" && (shadow === "none" || shadow.includes("0px 0px 0px") || shadow === "") && bl <= 1.05 && br <= 1.05; }); const getLuminance = (str: string): number => { const s = str.trim().toLowerCase(); if (s.startsWith("lab")) { const m = s.match(/lab\(([\d.]+)\s+([\d.-]+)\s+([\d.-]+)/); if (m) { const L = parseFloat(m[1]); return L > 8 ? Math.pow((L + 16) / 116, 3) : L / 903.3; } } const mRgb = s.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (mRgb) { const r = parseInt(mRgb[1]), g = parseInt(mRgb[2]), b = parseInt(mRgb[3]); const a = [r, g, b].map(v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; } return 0; }; const getContrast = (c1: string, c2: string): number => { const lum1 = getLuminance(c1); const lum2 = getLuminance(c2); return (Math.max(lum1, lum2) + 0.05) / (Math.min(lum1, lum2) + 0.05); }; const getBgColor = (el: Element): string => { let cur: Element | null = el; while (cur) { const bg = window.getComputedStyle(cur).backgroundColor; if (bg && bg !== "transparent" && !bg.includes("rgba(0, 0, 0, 0)")) { return bg; } cur = cur.parentElement; } return "lab(15 0 0)"; }; const metaElements = Array.from(document.querySelectorAll(".rmp-card-meta")); const labelElements = Array.from(document.querySelectorAll(".rmp-step-label")); const errorElements = Array.from(document.querySelectorAll(".rmp-card-error")); const pillElements = Array.from(document.querySelectorAll(".pill")); const textElements = [...metaElements, ...labelElements, ...errorElements]; const textContrastOk = textElements.every(el => { const fg = window.getComputedStyle(el).color; const bg = getBgColor(el); const ratio = getContrast(fg, bg); return ratio >= 4.5; }); const pillContrastOk = pillElements.every(el => { const fg = window.getComputedStyle(el).color; const bg = getBgColor(el); const ratio = getContrast(fg, bg); return ratio >= 3.0; }); return { borderShadowOk, textContrastOk, pillContrastOk }; }); results.push({ name: "dark-theme-structural", ok: structuralCheck.borderShadowOk, detail: "Computed borderRadius, boxShadow, and border widths match constraints.", }); results.push({ name: "dark-theme-text-contrast", ok: structuralCheck.textContrastOk, detail: "Text elements meta/label/error contrast ratio >= 4.5 against card backgrounds.", }); results.push({ name: "dark-theme-pill-contrast", ok: structuralCheck.pillContrastOk, detail: "Pill elements contrast ratio >= 3.0 against card backgrounds.", }); await page.screenshot({ path: path.join(outDir, "shots/populated-dark-replay.png") }); // 4. Populated state - Light Theme await applyTheme("light"); const lightThemeChecks = await page.evaluate(() => { const getLuminance = (str: string): number => { const s = str.trim().toLowerCase(); if (s.startsWith("lab")) { const m = s.match(/lab\(([\d.]+)\s+([\d.-]+)\s+([\d.-]+)/); if (m) { const L = parseFloat(m[1]); return L > 8 ? Math.pow((L + 16) / 116, 3) : L / 903.3; } } const mRgb = s.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (mRgb) { const r = parseInt(mRgb[1]), g = parseInt(mRgb[2]), b = parseInt(mRgb[3]); const a = [r, g, b].map(v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; } return 1.0; }; const getContrast = (c1: string, c2: string): number => { const lum1 = getLuminance(c1); const lum2 = getLuminance(c2); return (Math.max(lum1, lum2) + 0.05) / (Math.min(lum1, lum2) + 0.05); }; const getBgColor = (el: Element): string => { let cur: Element | null = el; while (cur) { const bg = window.getComputedStyle(cur).backgroundColor; if (bg && bg !== "transparent" && !bg.includes("rgba(0, 0, 0, 0)")) { return bg; } cur = cur.parentElement; } return "lab(100 0 0)"; }; const metaElements = Array.from(document.querySelectorAll(".rmp-card-meta")); const labelElements = Array.from(document.querySelectorAll(".rmp-step-label")); const errorElements = Array.from(document.querySelectorAll(".rmp-card-error")); const pillElements = Array.from(document.querySelectorAll(".pill")); const textElements = [...metaElements, ...labelElements, ...errorElements]; const textContrastOk = textElements.every(el => { const fg = window.getComputedStyle(el).color; const bg = getBgColor(el); const ratio = getContrast(fg, bg); return ratio >= 4.5; }); const pillContrastOk = pillElements.every(el => { const fg = window.getComputedStyle(el).color; const bg = getBgColor(el); const ratio = getContrast(fg, bg); return ratio >= 3.0; }); return { textContrastOk, pillContrastOk }; }); results.push({ name: "light-theme-text-contrast", ok: lightThemeChecks.textContrastOk, detail: "Light theme: text elements meta/label/error contrast ratio >= 4.5.", }); results.push({ name: "light-theme-pill-contrast", ok: lightThemeChecks.pillContrastOk, detail: "Light theme: pill elements contrast ratio >= 3.0.", }); await page.screenshot({ path: path.join(outDir, "shots/populated-light-replay.png") }); // 5. Read-only pass // Fetch the real index.html from server, and replace the robomp-config script tag const resp = await fetch(BASE_URL); let html = await resp.text(); html = html.replace( /' ); customIndexHtml = html; // Reload page to parse with read-only config await page.goto(BASE_URL, { waitUntil: "networkidle0" }); await applyTheme("dark"); const readonly = await page.evaluate(() => { const bar = document.querySelector(".rmp-trigger-bar"); return { chipText: bar?.querySelector(".rmp-readonly-chip")?.textContent?.trim() ?? "", barInputs: document.querySelectorAll(".rmp-trigger-bar input").length, barButtons: document.querySelectorAll(".rmp-trigger-bar button").length, cardActionButtons: document.querySelectorAll(".rmp-card-actions button").length, cardActionWrappers: document.querySelectorAll(".rmp-card-actions").length, }; }); results.push({ name: "readonly-actions-disabled", ok: readonly.chipText.includes("trigger disabled · read-only") && readonly.barInputs === 0 && readonly.barButtons === 0 && readonly.cardActionButtons === 0 && readonly.cardActionWrappers === 0, detail: `Trigger-bar chip: "${readonly.chipText}". Trigger-bar inputs: ${readonly.barInputs}, buttons: ${readonly.barButtons}. Card action buttons: ${readonly.cardActionButtons}, wrappers: ${readonly.cardActionWrappers}.`, }); await page.screenshot({ path: path.join(outDir, "shots/populated-dark-readonly.png") }); customIndexHtml = null; // restore index intercept // 6. Responsive check await page.setViewport({ width: 380, height: 720 }); await page.goto(BASE_URL, { waitUntil: "networkidle0" }); await applyTheme("dark"); const responsiveOk = await page.evaluate(() => { const grid = document.querySelector(".rmp-card-grid"); if (!grid) return false; const cards = Array.from(document.querySelectorAll(".rmp-card")); // Single column: every card fits within the grid's content width. A card // wider than the grid (allowing 1px sub-pixel rounding) means it broke out // of the column and must fail. const gridWidth = grid.getBoundingClientRect().width; const allCardsWithinGrid = cards.every(c => { const cardWidth = c.getBoundingClientRect().width; return cardWidth <= gridWidth + 1; }); // Content doesn't overflow card container. const noCardOverflow = cards.every(c => { return c.scrollWidth <= c.clientWidth + 1; }); // The page itself must not scroll horizontally: document and body content // width stay within their client width (1px tolerance for rounding). const docEl = document.documentElement; const noPageOverflow = docEl.scrollWidth <= docEl.clientWidth + 1 && document.body.scrollWidth <= document.body.clientWidth + 1; return allCardsWithinGrid && noCardOverflow && noPageOverflow; }); const debugResponsive = await page.evaluate(() => { const grid = document.querySelector(".rmp-card-grid"); const cards = Array.from(document.querySelectorAll(".rmp-card")); const docEl = document.documentElement; return { gridWidth: grid ? grid.getBoundingClientRect().width : 0, maxCardWidth: cards.reduce((m, c) => Math.max(m, c.getBoundingClientRect().width), 0), docScrollWidth: docEl.scrollWidth, docClientWidth: docEl.clientWidth, bodyScrollWidth: document.body.scrollWidth, bodyClientWidth: document.body.clientWidth, }; }); console.log("Debug responsive widths:", JSON.stringify(debugResponsive)); results.push({ name: "responsive-single-column-no-overflow", ok: responsiveOk, detail: `At width 380: every card fits within the grid and neither cards nor the page scroll horizontally. Grid: ${debugResponsive.gridWidth}, maxCard: ${debugResponsive.maxCardWidth}, doc: ${debugResponsive.docScrollWidth}/${debugResponsive.docClientWidth}, body: ${debugResponsive.bodyScrollWidth}/${debugResponsive.bodyClientWidth}`, }); await page.screenshot({ path: path.join(outDir, "shots/responsive-dark.png") }); // Restore viewport await page.setViewport({ ...VIEWPORT }); // 7. Reduced motion check. await page.emulateMediaFeatures([{ name: "prefers-reduced-motion", value: "reduce" }]); // 7a. Skeleton phase — delay the status response so the loading skeleton is // actually on screen, then assert its pulse animation is suppressed // WHILE skeleton elements exist (the old check ran after data loaded, so // no skeleton was present and the assertion was vacuous). statusMock = baseStatus; statusDelayMs = 600; await page.goto(BASE_URL, { waitUntil: "domcontentloaded" }); await Bun.sleep(150); // inside the delay window: skeleton still rendered const reducedSkeleton = await page.evaluate(() => { const skeletons = Array.from(document.querySelectorAll(".rmp-card-skeleton")); return { count: skeletons.length, allDisabled: skeletons.every((el) => window.getComputedStyle(el).animationName === "none"), }; }); statusDelayMs = 0; results.push({ name: "reduced-motion-skeleton-animation-disabled", ok: reducedSkeleton.count >= 2 && reducedSkeleton.allDisabled, detail: `Skeletons present: ${reducedSkeleton.count}, all animations disabled: ${reducedSkeleton.allDisabled}`, }); // 7b. Populated phase — once cards render, the live stepper node and card // element exist; assert their animation/transition are suppressed too. statusMock = populatedStatus; await page.goto(BASE_URL, { waitUntil: "networkidle0" }); const reducedPopulated = await page.evaluate(() => { const liveStepNode = document.querySelector(".rmp-step-node[data-live='true']"); const nodeStyle = liveStepNode ? window.getComputedStyle(liveStepNode).animationName : "none"; const card = document.querySelector(".rmp-card"); const cardTransition = card ? window.getComputedStyle(card).transition : "none"; return { liveNodePresent: liveStepNode !== null, nodeAnimationOff: nodeStyle === "none" || nodeStyle === "", cardTransitionOff: cardTransition.includes("none") || cardTransition === "all 0s" || cardTransition === "", }; }); results.push({ name: "reduced-motion-node-and-card-animations-disabled", ok: reducedPopulated.liveNodePresent && reducedPopulated.nodeAnimationOff && reducedPopulated.cardTransitionOff, detail: `Live node present: ${reducedPopulated.liveNodePresent}, node animation off: ${reducedPopulated.nodeAnimationOff}, card transition off: ${reducedPopulated.cardTransitionOff}`, }); // Restore media features await page.emulateMediaFeatures([]); // 8. Interactions (clicks). // Serve a replay-enabled index with a KNOWN token so every privileged // request carries an auditable `X-Robomp-Replay-Token: trigger-secret` // header, independent of whatever token the live server was started with. const REPLAY_TOKEN = "trigger-secret"; const replayResp = await fetch(BASE_URL); const replayHtml = (await replayResp.text()).replace( /`, ); customIndexHtml = replayHtml; statusMock = populatedStatus; await page.goto(BASE_URL, { waitUntil: "networkidle0" }); await applyTheme("dark"); // A. Retry click — exactly one POST /api/trigger {mode:"retry", delivery_id} // carrying the replay token. triggeredRequests.length = 0; await page.evaluate(() => { const btn = document.querySelector(".rmp-card[data-bucket='failed'] button") as HTMLElement | null; btn?.click(); }); await Bun.sleep(100); // wait for request and state update const retryReqs = triggeredRequests.filter( (r) => r.url.endsWith("/api/trigger") && r.body?.mode === "retry" && r.body?.delivery_id === "fail-del-1", ); const statusTextA = await page.evaluate(() => document.querySelector(".rmp-trigger-bar-status")?.textContent ?? ""); results.push({ name: "interaction-retry-click", ok: retryReqs.length === 1 && retryReqs[0].headers["x-robomp-replay-token"] === REPLAY_TOKEN && statusTextA.includes("queued retry: fail-del-1"), detail: `Matching retry requests: ${retryReqs.length}, token: "${retryReqs[0]?.headers["x-robomp-replay-token"] ?? ""}". Status text: "${statusTextA}"`, }); // B. Cancel click (confirm accepted) — exactly one POST /api/cancel // {delivery_id} carrying the replay token. triggeredRequests.length = 0; await page.evaluate(() => { window.confirm = () => true; const card = Array.from(document.querySelectorAll(".rmp-card")).find(c => c.querySelector(".rmp-card-id")?.textContent?.includes("octo/widget#2")); const btn = card?.querySelector("button.danger") as HTMLElement | null; btn?.click(); }); await Bun.sleep(100); const cancelReqs = triggeredRequests.filter( (r) => r.url.endsWith("/api/cancel") && r.body?.delivery_id === "run-del-2", ); const statusTextB = await page.evaluate(() => document.querySelector(".rmp-trigger-bar-status")?.textContent ?? ""); results.push({ name: "interaction-cancel-click-accepted", ok: cancelReqs.length === 1 && cancelReqs[0].headers["x-robomp-replay-token"] === REPLAY_TOKEN && statusTextB.includes("cancel signaled: run-del- (fired=false)"), detail: `Matching cancel requests: ${cancelReqs.length}, token: "${cancelReqs[0]?.headers["x-robomp-replay-token"] ?? ""}". Status text: "${statusTextB}"`, }); // C. Cancel click (confirm dismissed) — verbatim confirm gate suppresses the // request entirely; assert zero /api/cancel requests fire. triggeredRequests.length = 0; await page.evaluate(() => { window.confirm = () => false; const card = Array.from(document.querySelectorAll(".rmp-card")).find(c => c.querySelector(".rmp-card-id")?.textContent?.includes("octo/widget#2")); const btn = card?.querySelector("button.danger") as HTMLElement | null; btn?.click(); }); await Bun.sleep(100); const dismissedCancelReqs = triggeredRequests.filter((r) => r.url.endsWith("/api/cancel")); results.push({ name: "interaction-cancel-click-dismissed", ok: dismissedCancelReqs.length === 0, detail: `Cancel requests fired after dismissal: ${dismissedCancelReqs.length}`, }); // D. Trigger bar triage click — exactly one POST /api/trigger // {mode:"triage", issue} carrying the replay token. triggeredRequests.length = 0; await page.type(".rmp-trigger-bar input", "octo/widget#5"); await page.evaluate(() => { const btns = Array.from(document.querySelectorAll(".rmp-trigger-bar button")); const triageBtn = btns.find(b => b.textContent?.trim() === "triage"); triageBtn?.click(); }); await Bun.sleep(100); const triageReqs = triggeredRequests.filter( (r) => r.url.endsWith("/api/trigger") && r.body?.mode === "triage" && r.body?.issue === "octo/widget#5", ); results.push({ name: "trigger-bar-triage-click", ok: triageReqs.length === 1 && triageReqs[0].headers["x-robomp-replay-token"] === REPLAY_TOKEN, detail: `Matching triage requests: ${triageReqs.length}, token: "${triageReqs[0]?.headers["x-robomp-replay-token"] ?? ""}"`, }); // E. Trigger bar retry latest click — exactly one POST /api/trigger // {mode:"retry", issue} carrying the replay token. triggeredRequests.length = 0; await page.evaluate(() => { const btns = Array.from(document.querySelectorAll(".rmp-trigger-bar button")); const retryBtn = btns.find(b => b.textContent?.trim() === "retry latest"); retryBtn?.click(); }); await Bun.sleep(100); const retryLatestReqs = triggeredRequests.filter( (r) => r.url.endsWith("/api/trigger") && r.body?.mode === "retry" && r.body?.issue === "octo/widget#5", ); results.push({ name: "trigger-bar-retry-latest-click", ok: retryLatestReqs.length === 1 && retryLatestReqs[0].headers["x-robomp-replay-token"] === REPLAY_TOKEN, detail: `Matching retry-latest requests: ${retryLatestReqs.length}, token: "${retryLatestReqs[0]?.headers["x-robomp-replay-token"] ?? ""}"`, }); // F. Trigger bar Enter key — Enter in the input triages; exactly one POST // /api/trigger {mode:"triage", issue} carrying the replay token. triggeredRequests.length = 0; await page.focus(".rmp-trigger-bar input"); await page.keyboard.press("Enter"); await Bun.sleep(100); const enterReqs = triggeredRequests.filter( (r) => r.url.endsWith("/api/trigger") && r.body?.mode === "triage" && r.body?.issue === "octo/widget#5", ); results.push({ name: "trigger-bar-enter-press", ok: enterReqs.length === 1 && enterReqs[0].headers["x-robomp-replay-token"] === REPLAY_TOKEN, detail: `Matching enter requests: ${enterReqs.length}, token: "${enterReqs[0]?.headers["x-robomp-replay-token"] ?? ""}"`, }); // G. Activity-view retry failure — switching to Activity and retrying a // failed event must surface the trigger error WHERE the button was // clicked (the Trigger bar lives only on Operations/Triage). Force // /api/trigger to error, click the Activity events-table retry button, // and assert .rmp-activity-status shows the error with role="alert" and // that exactly one trigger request fired carrying the replay token. triggeredRequests.length = 0; const ACTIVITY_ERROR_DETAIL = "forced retry failure"; triggerErrorDetail = ACTIVITY_ERROR_DETAIL; // Switch to the Activity view via the nav rail. await page.evaluate(() => { const navBtn = Array.from(document.querySelectorAll(".rmp-nav-item")).find( (b) => b.querySelector(".rmp-nav-item-label")?.textContent?.trim() === "Activity", ) as HTMLElement | null; navBtn?.click(); }); await Bun.sleep(100); // let the view flip to display:flex // Click the first retry button in the Activity events table (Events is the // only `table.t` consumer, so this is unambiguous). await page.evaluate(() => { const btn = Array.from(document.querySelectorAll("table.t button")).find( (b) => b.textContent?.trim() === "retry", ) as HTMLElement | null; btn?.click(); }); await Bun.sleep(100); // wait for the failed trigger round-trip + state update const activityStatus = await page.evaluate(() => { const el = document.querySelector(".rmp-activity-status"); if (!el) return null; return { text: el.textContent?.trim() ?? "", role: el.getAttribute("role") ?? "", // offsetParent is null when an ancestor is display:none — proves the // status is actually visible inside the active Activity view. visible: (el as HTMLElement).offsetParent !== null, }; }); const activityRetryReqs = triggeredRequests.filter( (r) => r.url.endsWith("/api/trigger") && r.body?.mode === "retry", ); triggerErrorDetail = null; // reset so later passes see the normal 202 results.push({ name: "activity-retry-error-visible", ok: !!activityStatus && activityStatus.visible && activityStatus.role === "alert" && activityStatus.text.includes(ACTIVITY_ERROR_DETAIL) && activityRetryReqs.length === 1 && activityRetryReqs[0].headers["x-robomp-replay-token"] === REPLAY_TOKEN, detail: `Activity status: ${JSON.stringify(activityStatus)}. Retry requests: ${activityRetryReqs.length}, token: "${activityRetryReqs[0]?.headers["x-robomp-replay-token"] ?? ""}"`, }); await page.screenshot({ path: path.join(outDir, "shots/activity-retry-error.png") }); customIndexHtml = null; // restore real-index interception // Cleanup browser await browser.close(); // Print results console.log("\n--- Verification checks summary ---"); let allPass = true; for (const r of results) { const status = r.ok ? "PASS" : "FAIL"; console.log(`[${status}] ${r.name}: ${r.detail}`); if (!r.ok) allPass = false; } // Write report const summaryJson = { generatedAt: new Date().toISOString(), pass: allPass, checks: results.map(r => ({ name: r.name, ok: r.ok, detail: r.detail })), }; await fs.writeFile(path.join(outDir, "report.json"), JSON.stringify(summaryJson, null, 2), "utf-8"); console.log(`\nReport written to ${path.join(outDir, "report.json")}`); process.exitCode = allPass ? 0 : 1; } main().catch(err => { console.error("Verification harness crashed:", err); process.exit(1); });