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
- 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
282 lines
9.5 KiB
TypeScript
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(() => {});
|
|
}
|
|
}
|