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 [--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);