import { randomUUID } from "node:crypto"; import type { EvidenceRecord } from "@siax/c0py-types"; export interface CapturedPage { finalUrl: string; status: number; contentType: string | null; title: string | null; description: string | null; lang: string | null; charset: string | null; viewportMeta: string | null; domCounts: Record; stylesheets: string[]; images: string[]; computedStyle: Record>; networkRequests: { url: string; method: string; resourceType: string; status: number | null }[]; accessibilityTree: { role: string | null; name: string | null; value: string | null; children?: unknown[]; } | null; har: { entries: { url: string; method: string; status: number; mimeType: string | null; size: number | null }[]; entryCount: number; } | null; screenshotBase64: string | null; } export interface CaptureEvidence { evidence: EvidenceRecord[]; summary: { url: string; status: number; title: string | null; domElementCount: number; stylesheetCount: number; imageCount: number; networkRequestCount: number; screenshotCaptured: boolean; }; } export const DEFAULT_PROFILE_ID = "c0py-default-public"; export const DEFAULT_VIEWPORT = { width: 1280, height: 800, deviceScaleFactor: 1 }; function record( targetId: string, route: string, kind: EvidenceRecord["kind"], payload: T, tool: string, ): EvidenceRecord { return { id: randomUUID(), targetId, kind, route, capturedAt: new Date().toISOString(), method: "deterministic-browser-capture", confidence: "measured", captureProfileId: DEFAULT_PROFILE_ID, viewport: DEFAULT_VIEWPORT, source: { tool, toolVersion: process.env.CAPTURE_TOOL_VERSION ?? "unknown" }, payload, }; } // Pure builder — unit-testable without a browser. export function buildEvidence(targetId: string, page: CapturedPage): CaptureEvidence { const tool = "playwright-core+chromium"; const evidence: EvidenceRecord[] = [ record(targetId, page.finalUrl, "runtime-html", { finalUrl: page.finalUrl, status: page.status, contentType: page.contentType, title: page.title, description: page.description, lang: page.lang, charset: page.charset, viewportMeta: page.viewportMeta, }, tool), record(targetId, page.finalUrl, "dom", { counts: page.domCounts, }, tool), record(targetId, page.finalUrl, "computed-style", page.computedStyle, tool), record(targetId, page.finalUrl, "stylesheet", { stylesheets: page.stylesheets, }, tool), record(targetId, page.finalUrl, "asset", { images: page.images, }, tool), record(targetId, page.finalUrl, "network-request", { requests: page.networkRequests, }, tool), ]; if (page.accessibilityTree) { evidence.push( record(targetId, page.finalUrl, "accessibility", { tree: page.accessibilityTree, }, tool), ); } if (page.har) { evidence.push( record(targetId, page.finalUrl, "har", { entries: page.har.entries, entryCount: page.har.entryCount, }, tool), ); } if (page.screenshotBase64) { evidence.push( record(targetId, page.finalUrl, "screenshot", { base64: page.screenshotBase64, contentType: "image/jpeg", }, tool), ); } return { evidence, summary: { url: page.finalUrl, status: page.status, title: page.title, domElementCount: Object.values(page.domCounts).reduce((a, b) => a + b, 0), stylesheetCount: page.stylesheets.length, imageCount: page.images.length, networkRequestCount: page.networkRequests.length, screenshotCaptured: page.screenshotBase64 !== null, }, }; } // Browser-backed capture. Playwright is the canonical runtime per AGENTS.md; // chromium comes from the system package (apk chromium) via executablePath. export async function capturePage( url: string, opts: { executablePath?: string; timeoutMs: number; maxRequests?: number }, ): Promise { const { chromium } = await import("playwright-core"); const executablePath = opts.executablePath ?? process.env.CHROMIUM_EXECUTABLE_PATH ?? "/usr/bin/chromium-browser"; const browser = await chromium.launch({ executablePath, args: ["--no-sandbox", "--disable-dev-shm-usage"], }); const maxRequests = opts.maxRequests ?? 100; const harPath = `/tmp/c0py-capture-${randomUUID()}.har`; try { const context = await browser.newContext({ viewport: DEFAULT_VIEWPORT, recordHar: { path: harPath, mode: "minimal" }, }); const page = await context.newPage(); const networkRequests: CapturedPage["networkRequests"] = []; page.on("response", (res) => { if (networkRequests.length < maxRequests) { networkRequests.push({ url: res.url(), method: res.request().method(), resourceType: res.request().resourceType(), status: res.status(), }); } }); const response = await page.goto(url, { timeout: opts.timeoutMs, waitUntil: "domcontentloaded" }); await page.waitForTimeout(500); // NOTE: evaluate code must be a plain string — tsx/esbuild would inject // __name helpers into a transpiled function, which break in the browser. const captured = await page.evaluate(`(() => { const count = (sel) => document.querySelectorAll(sel).length; const pick = (el, props) => { if (!el) return Object.fromEntries(props.map((p) => [p, null])); const cs = getComputedStyle(el); return Object.fromEntries(props.map((p) => [p, cs.getPropertyValue(p) || null])); }; const body = pick(document.body, ["font-family", "color", "background-color", "font-size"]); const h1 = pick(document.querySelector("h1"), ["font-family", "font-size", "font-weight", "color", "margin"]); return { title: document.title, description: document.querySelector('meta[name="description"]')?.getAttribute("content") ?? null, lang: document.documentElement.getAttribute("lang"), charset: document.characterSet, viewportMeta: document.querySelector('meta[name="viewport"]')?.getAttribute("content") ?? null, domCounts: { script: count("script"), link: count("link"), style: count("style"), img: count("img"), svg: count("svg"), form: count("form"), input: count("input"), button: count("button"), nav: count("nav"), header: count("header"), footer: count("footer"), main: count("main"), section: count("section"), h1: count("h1"), h2: count("h2"), h3: count("h3"), a: count("a"), table: count("table"), iframe: count("iframe"), video: count("video"), canvas: count("canvas"), }, stylesheets: Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map((l) => l.href), images: Array.from(document.querySelectorAll("img")).slice(0, 50).map((i) => i.src), computedStyle: { body, h1 }, }; })()`) as { title: string; description: string | null; lang: string | null; charset: string; viewportMeta: string | null; domCounts: Record; stylesheets: string[]; images: string[]; computedStyle: Record>; }; let screenshotBase64: string | null = null; try { screenshotBase64 = await page.screenshot({ fullPage: true, type: "jpeg", quality: 60, timeout: Math.min(opts.timeoutMs, 10_000), }).then((b) => b.toString("base64")); } catch { // Screenshot failure is an evidence gap, not a capture failure. screenshotBase64 = null; } // Accessibility tree (measured; null on failure = explicit evidence gap). let accessibilityTree: CapturedPage["accessibilityTree"] = null; try { const acc = (page as unknown as { accessibility?: { snapshot(): Promise } }) .accessibility; accessibilityTree = (await acc?.snapshot()) as CapturedPage["accessibilityTree"] ?? null; } catch { accessibilityTree = null; } // HAR (measured; condensed to entry-level metadata). let har: CapturedPage["har"] = null; try { await context.close(); const raw = await import("node:fs/promises").then((fs) => fs.readFile(harPath, "utf8")); const parsed = JSON.parse(raw) as { log: { entries: { request: { url: string; method: string }; response: { status: number; content: { mimeType?: string; size?: number } } }[] }; }; const entries = parsed.log.entries.slice(0, maxRequests).map((e) => ({ url: e.request.url, method: e.request.method, status: e.response.status, mimeType: e.response.content?.mimeType ?? null, size: e.response.content?.size ?? null, })); har = { entries, entryCount: parsed.log.entries.length }; } catch { har = null; } return { finalUrl: page.url(), status: response?.status() ?? 0, contentType: response?.headers()["content-type"] ?? null, ...captured, networkRequests, accessibilityTree, har, screenshotBase64, }; } finally { await browser.close().catch(() => {}); await import("node:fs/promises") .then((fs) => fs.unlink(harPath).catch(() => {})) .catch(() => {}); } }