/** * Playwright web view crawler — drives a headless Chromium against the local dev * server (UI :2138) through every route, capturing the same per-view signals as * the CDP crawler: console errors, page errors, network 4xx/5xx, render-state * (error boundary / no-agent / 404 / empty), interactive-element inventory, shot. * * Usage: bun web-crawler.mjs --url http://127.0.0.1:2138 --out */ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; import { chromium } from "@playwright/test"; import { ROUTES } from "./routes.mjs"; const args = Object.fromEntries( process.argv.slice(2).reduce((a, v, i, arr) => { if (v.startsWith("--")) a.push([v.slice(2), arr[i + 1]]); return a; }, []), ); const BASE = args.url || "http://127.0.0.1:2138"; const OUT = args.out || "/tmp/view-audit/web"; mkdirSync(OUT, { recursive: true }); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const SNAP = `(()=>{ const t=document.body?document.body.innerText:""; const low=t.toLowerCase(); const sig={ errorBoundary: /something went wrong|failed to load|is not a function|cannot read prop|unexpected token|chunkloaderror/i.test(t), noAgent: /no agent (is )?(configured|available)|agent is not|no agent connected|not configured/i.test(t), notFound: /\\b404\\b|not found|couldn'?t reach|unavailable|markets unavailable|reads blocked/i.test(low), offline: /\\boffline\\b|disconnected|not connected/i.test(low), emptyish: t.replace(/\\s+/g," ").trim().length < 40, }; const q=(sel)=>[...document.querySelectorAll(sel)]; const label=(el)=>((el.getAttribute&&(el.getAttribute("aria-label")||el.getAttribute("title")))||el.textContent||el.value||el.placeholder||"").replace(/\\s+/g," ").trim().slice(0,40); const buttons=q('button,[role="button"]').map(e=>({l:label(e),d:!!e.disabled||e.getAttribute("aria-disabled")==="true"})); const inputs=q('input,textarea,select').map(e=>({l:label(e),type:e.type||e.tagName.toLowerCase(),d:!!e.disabled})); const links=q('a[href]').length; return {len:t.replace(/\\s+/g," ").trim().length, sig, snip:t.replace(/\\s+/g," ").trim().slice(0,140), buttons, inputs, links, path:location.pathname+location.hash}; })()`; async function main() { const browser = await chromium.launch({ headless: true }); const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, ignoreHTTPSErrors: true, }); const page = await ctx.newPage(); // Prime auth: force local dev agent active-server so the renderer skips // onboarding and reaches the dashboard (mirrors the dev renderer default). await page.addInitScript(() => { try { const local = JSON.stringify({ id: "local:dev", kind: "remote", label: "Local dev agent", apiBase: "http://127.0.0.1:31337", }); localStorage.setItem("elizaos:active-server", local); localStorage.setItem("eliza:first-run-complete", "1"); localStorage.setItem("eliza:mobile-runtime-mode", "local"); } catch {} }); await page .goto(BASE, { waitUntil: "domcontentloaded", timeout: 30000 }) .catch(() => {}); await sleep(4000); const findings = []; for (const [route, slug] of ROUTES) { const consoleErrors = []; const pageErrors = []; const netFailures = []; const onConsole = (m) => { if (["error", "warning"].includes(m.type())) { const txt = m.text().slice(0, 300); if (txt.trim() && !/favicon|DevTools/.test(txt)) consoleErrors.push(`[${m.type()}] ${txt}`); } }; const onPageErr = (e) => pageErrors.push(String(e.message || e).slice(0, 300)); const onResp = (r) => { const s = r.status(); if (s >= 400) netFailures.push( `${s} ${r .url() .replace(/^https?:\/\/[^/]+/, "") .slice(0, 120)}`, ); }; page.on("console", onConsole); page.on("pageerror", onPageErr); page.on("response", onResp); await page .evaluate((u) => { history.pushState(null, "", u); window.dispatchEvent(new PopStateEvent("popstate")); if (u.includes("#")) window.dispatchEvent(new HashChangeEvent("hashchange")); }, route) .catch(() => {}); await sleep(2600); let parsed = {}; try { parsed = await page.evaluate(SNAP); } catch (e) { parsed = { __err: String(e.message).slice(0, 80) }; } let shot = null; try { shot = `${slug}.png`; await page.screenshot({ path: `${OUT}/${shot}` }); } catch { shot = null; } page.off("console", onConsole); page.off("pageerror", onPageErr); page.off("response", onResp); const f = { route, slug, landedPath: parsed.path, bodyLen: parsed.len, snippet: parsed.snip, signals: parsed.sig || {}, pageErrors: [...new Set(pageErrors)], consoleErrors: [...new Set(consoleErrors)].slice(0, 12), netFailures: [...new Set(netFailures)].slice(0, 12), buttons: parsed.buttons || [], inputs: parsed.inputs || [], links: parsed.links || 0, screenshot: shot, }; findings.push(f); const flags = []; if (f.signals.errorBoundary) flags.push("ERR-BOUNDARY"); if (f.pageErrors.length) flags.push(`${f.pageErrors.length}pageerr`); if (f.signals.noAgent) flags.push("NO-AGENT"); if (f.signals.notFound) flags.push("404/unavail"); if (f.netFailures.length) flags.push(`${f.netFailures.length}net4xx`); if (f.signals.emptyish) flags.push("EMPTY"); const line = `${slug.padEnd(24)} land=${(f.landedPath || "?").padEnd(20)} len=${String(f.bodyLen).padStart(5)} btn=${String(f.buttons.length).padStart(2)} inp=${String(f.inputs.length).padStart(2)} ${flags.join(" ") || "ok"}`; console.error(line); appendFileSync(`${OUT}/progress.log`, line + "\n"); writeFileSync( `${OUT}/report-web.json`, JSON.stringify( { surface: "web", count: findings.length, findings }, null, 2, ), ); } await browser.close(); console.error(`\nWROTE ${OUT}/report-web.json (${findings.length})`); } main().then( () => process.exit(0), (e) => { console.error("WEB CRAWLER ERR:", e.message); process.exit(1); }, );