107 lines
6.1 KiB
JavaScript
107 lines
6.1 KiB
JavaScript
import { chromium } from "@playwright/test";
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
|
|
import { WP4_07_VISUAL_THRESHOLDS } from "../tests/visual-performance/wp4-07-fixture.mjs";
|
|
|
|
const evidenceIndex = process.argv.indexOf("--evidence");
|
|
const phaseIndex = process.argv.indexOf("--phase");
|
|
const evidenceDirectory = resolve(evidenceIndex >= 0 ? process.argv[evidenceIndex + 1] : "");
|
|
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
|
|
if (evidenceIndex < 0 || !process.argv[evidenceIndex + 1] || !["green", "red"].includes(phase)) throw new Error("Usage: --evidence <directory> [--phase red|green]");
|
|
|
|
const scenarios = ["editor.png", "canvas.png", "export-dialog.png"];
|
|
for (const scenario of scenarios) {
|
|
for (const browser of ["chrome", "edge"]) {
|
|
const path = resolve(evidenceDirectory, browser, scenario);
|
|
if (!existsSync(path)) throw new Error(`missing screenshot: ${browser}/${scenario}`);
|
|
}
|
|
}
|
|
|
|
const browser = await chromium.launch({ channel: "msedge", headless: true });
|
|
const page = await browser.newPage();
|
|
const results = [];
|
|
try {
|
|
for (const scenario of scenarios) {
|
|
const chrome = readFileSync(resolve(evidenceDirectory, "chrome", scenario)).toString("base64");
|
|
const edge = readFileSync(resolve(evidenceDirectory, "edge", scenario)).toString("base64");
|
|
const comparison = await page.evaluate(async ({ chromeBase64, edgeBase64, threshold }) => {
|
|
const decode = async (base64) => {
|
|
const binary = atob(base64);
|
|
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
return createImageBitmap(new Blob([bytes], { type: "image/png" }));
|
|
};
|
|
const [chromeImage, edgeImage] = await Promise.all([decode(chromeBase64), decode(edgeBase64)]);
|
|
if (chromeImage.width !== edgeImage.width || chromeImage.height !== edgeImage.height) {
|
|
return { dimensions_match: false, chrome: { height: chromeImage.height, width: chromeImage.width }, edge: { height: edgeImage.height, width: edgeImage.width } };
|
|
}
|
|
const surface = new OffscreenCanvas(chromeImage.width, chromeImage.height);
|
|
const context = surface.getContext("2d", { willReadFrequently: true });
|
|
context.drawImage(chromeImage, 0, 0);
|
|
const chromePixels = context.getImageData(0, 0, chromeImage.width, chromeImage.height).data;
|
|
context.clearRect(0, 0, chromeImage.width, chromeImage.height);
|
|
context.drawImage(edgeImage, 0, 0);
|
|
const edgePixels = context.getImageData(0, 0, edgeImage.width, edgeImage.height).data;
|
|
let significant = 0;
|
|
let maximumChannelDelta = 0;
|
|
for (let index = 0; index < chromePixels.length; index += 4) {
|
|
const deltas = [0, 1, 2, 3].map((channel) => Math.abs(chromePixels[index + channel] - edgePixels[index + channel]));
|
|
maximumChannelDelta = Math.max(maximumChannelDelta, ...deltas);
|
|
if (deltas.some((delta) => delta > threshold)) significant += 1;
|
|
}
|
|
const total = chromeImage.width * chromeImage.height;
|
|
chromeImage.close();
|
|
edgeImage.close();
|
|
return {
|
|
dimensions_match: true,
|
|
height: surface.height,
|
|
maximum_channel_delta: maximumChannelDelta,
|
|
significant_pixel_count: significant,
|
|
significant_pixel_ratio: significant / total,
|
|
significant_pixel_threshold: threshold,
|
|
total_pixels: total,
|
|
width: surface.width,
|
|
};
|
|
}, { chromeBase64: chrome, edgeBase64: edge, threshold: WP4_07_VISUAL_THRESHOLDS.channel_delta_significant });
|
|
results.push({ scenario, ...comparison });
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
|
|
const chromeLayout = JSON.parse(readFileSync(resolve(evidenceDirectory, "chrome", "layout-boxes.json"), "utf8"));
|
|
const edgeLayout = JSON.parse(readFileSync(resolve(evidenceDirectory, "edge", "layout-boxes.json"), "utf8"));
|
|
const greenInputsEligible = [chromeLayout, edgeLayout].every((input) => input.eligible_for_green === true && input.harness_mode === "real_archive");
|
|
const layoutComparisons = Object.keys(chromeLayout.layout_boxes).map((name) => {
|
|
const chromeBox = chromeLayout.layout_boxes[name];
|
|
const edgeBox = edgeLayout.layout_boxes[name];
|
|
const deltas = Object.fromEntries(["height", "width", "x", "y"].map((field) => [field, Math.abs(chromeBox[field] - edgeBox[field])]));
|
|
return { deltas, maximum_delta_px: Math.max(...Object.values(deltas)), name };
|
|
});
|
|
const visualPassed = results.every((item) => item.dimensions_match && item.significant_pixel_ratio <= WP4_07_VISUAL_THRESHOLDS.significant_pixel_ratio_max);
|
|
const layoutPassed = layoutComparisons.every((item) => item.maximum_delta_px <= WP4_07_VISUAL_THRESHOLDS.boundary_delta_px_max);
|
|
const overall = {
|
|
eligible_for_green: phase === "green" && greenInputsEligible,
|
|
phase,
|
|
scenarios: results,
|
|
status: visualPassed && layoutPassed ? "within_threshold" : "threshold_exceeded_manual_review_required",
|
|
thresholds: WP4_07_VISUAL_THRESHOLDS,
|
|
};
|
|
const layout = {
|
|
comparisons: layoutComparisons,
|
|
eligible_for_green: phase === "green" && greenInputsEligible,
|
|
status: layoutPassed ? "within_threshold" : "threshold_exceeded_manual_review_required",
|
|
threshold_px: WP4_07_VISUAL_THRESHOLDS.boundary_delta_px_max,
|
|
};
|
|
mkdirSync(dirname(resolve(evidenceDirectory, "pixel-diff.json")), { recursive: true });
|
|
writeFileSync(resolve(evidenceDirectory, "pixel-diff.json"), `${JSON.stringify(overall, null, 2)}\n`);
|
|
writeFileSync(resolve(evidenceDirectory, "layout-boxes.json"), `${JSON.stringify(layout, null, 2)}\n`);
|
|
writeFileSync(resolve(evidenceDirectory, "manual-review.json"), `${JSON.stringify({
|
|
eligible_for_green: false,
|
|
known_alternatives: ["COLOR002", "COLOR008", "COLOR016", "DYN012"],
|
|
required_note: "DYN012 uses FONT081 Lexend Deca as the declared substitute.",
|
|
status: phase === "green" ? "pending_project_owner_review" : "pending_wp5_final_renderer_and_project_owner_review",
|
|
}, null, 2)}\n`);
|
|
console.log(JSON.stringify({ layout_status: layout.status, phase, visual_status: overall.status }, null, 2));
|
|
if (phase === "green" && (!greenInputsEligible || !visualPassed || !layoutPassed)) process.exit(1);
|