Files
c0py/apps/api/src/capture/capture.ts
T
admin f6ff1396d8
CI (SIAX Cloud) / contracts (pull_request) Successful in 17s
CI (SIAX Cloud) / contracts (push) Successful in 17s
CI (SIAX Cloud) / quality (pull_request) Successful in 54s
CI (SIAX Cloud) / quality (push) Successful in 56s
CI (SIAX Cloud) / security (pull_request) Successful in 1m7s
CI (SIAX Cloud) / security (push) Successful in 1m5s
feat(api): CL0UD capability probe + a11y/HAR evidence + worker scale toggle
- cl0ud client: contract-first probe of documented GET /capabilities/:id,
  status connected/denied/unavailable/disabled surfaced in /health;
  CL0UD_REQUIRED=true = fail-closed 503; full policy-decisions gated on
  CL0UD B-4/ADR-0031 (deliberately not simulated)
- capture depth: accessibility tree + condensed HAR as evidence kinds
  (null = explicit evidence gap)
- CAPTURE_WORKER=on/off for separate-scale deployments
- 34/34 tests
2026-09-16 23:21:54 +02:00

282 lines
9.5 KiB
TypeScript

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<string, number>;
stylesheets: string[];
images: string[];
computedStyle: Record<string, Record<string, string | null>>;
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<T>(
targetId: string,
route: string,
kind: EvidenceRecord["kind"],
payload: T,
tool: string,
): EvidenceRecord<T> {
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<CapturedPage> {
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<string, number>;
stylesheets: string[];
images: string[];
computedStyle: Record<string, Record<string, string | null>>;
};
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<unknown> } })
.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(() => {});
}
}