110 lines
2.8 KiB
TypeScript
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),
|
|
};
|
|
}
|