muddle/modules/analysis/index.ts
2026-10-23 09:12:49 -04:00

110 lines
2.8 KiB
TypeScript

import { chromium } from "playwright";
import type { Integration } from "muddle/contracts";
import { inspectDocument } from "./inspect.ts";
export interface AuditFinding {
rule: string;
selector: string;
severity: "error" | "warning" | "review";
message: string;
evidence: Record<string, unknown>;
}
export interface AuditReport {
viewport: { width: number; height: number };
findings: AuditFinding[];
score: number;
checked: number;
method: "static-rendered-heuristic";
}
export interface AuditOptions {
viewports?: { width: number; height: number }[];
ignore?: string[];
failBelow?: number;
failOnError?: boolean;
includeDisclosures?: boolean;
}
export async function analyzeHtml(
html: string,
kind: "accessible" | "overflow",
options: AuditOptions = {},
): Promise<AuditReport[]> {
const browser = await chromium.launch();
try {
const reports: AuditReport[] = [];
for (const viewport of options.viewports ?? [
{ width: 1440, height: 900 },
{ width: 390, height: 844 },
]) {
const context = await browser.newContext({
viewport,
javaScriptEnabled: false,
});
try {
const page = await context.newPage();
await page.route("**/*", (route) => route.abort());
await page.setContent(html, { waitUntil: "load" });
const result = await page.evaluate(inspectDocument, {
kind,
ignore: options.ignore ?? [],
disclosures: options.includeDisclosures ?? false,
});
const findings = result.findings as AuditFinding[];
const score = Math.max(
0,
Math.round(
100 -
findings.reduce(
(sum, finding) =>
sum +
(finding.severity === "error"
? 10
: finding.severity === "warning"
? 4
: 2),
0,
),
),
);
const report: AuditReport = {
viewport,
findings,
score,
checked: result.checked,
method: "static-rendered-heuristic",
};
reports.push(report);
if (options.failBelow !== undefined && score < options.failBelow) {
throw new Error(
`${kind} score ${score} below ${options.failBelow} at ${viewport.width}px`,
);
}
if (
options.failOnError &&
findings.some((finding) => finding.severity === "error")
) {
throw new Error(`${kind} audit found errors at ${viewport.width}px`);
}
} finally {
await context.close();
}
}
return reports;
} finally {
await browser.close();
}
}
export function accessibleIntegration(options: AuditOptions = {}): Integration {
return {
name: "accessible",
analyze: (page) => analyzeHtml(page.html, "accessible", options),
};
}
export function overflowIntegration(options: AuditOptions = {}): Integration {
return {
name: "overflow",
analyze: (page) => analyzeHtml(page.html, "overflow", options),
};
}