#!/usr/bin/env node import { copyFile, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { pathToFileURL } from "node:url"; const BACKENDS = ["DirectGLES", "DirectVulkan"]; const CASES = [ "OpenRA", "minecraft-1.21.4-startup", "minecraft-1.21.4-main-menu", "minecraft-1.21.4-in-world", "minecraft-1.21.4-fabric-sodium-in-world", "minecraft-1.21.4-fabric-iris-bsl-in-world", "minecraft-1.21.4-fabric-iris-makeup-ultrafast-in-world", "minecraft-1.21.4-fabric-iris-super-duper-vanilla-in-world", "minecraft-1.21.4-fabric-iris-sundial-lite-in-world", "minecraft-1.21.4-fabric-iris-complementary-reimagined-in-world", "minecraft-1.21.4-fabric-iris-complementary-unbound-in-world", "minecraft-1.21.4-fabric-iris-mellow-in-world", "minecraft-1.21.4-fabric-iris-nostalgia-in-world", "minecraft-1.21.4-fabric-iris-bliss-in-world", "minecraft-1.21.4-fabric-iris-chocapic-v6-lite-in-world", "minecraft-1.21.4-fabric-iris-iterationt-in-world", "minecraft-1.21.4-fabric-iris-iterationt-nodsa-in-world", "minecraft-1.21.4-fabric-iris-photon-v1.1-in-world", "minecraft-1.21.4-fabric-iris-photon-v1.3b-in-world", "minecraft-1.21.4-fabric-iris-derivative-main-d24.4.14-in-world", ]; function usage() { console.error(`Usage: node tools/trace_replay/render_retrace_summary.mjs \\ --input DIR [--input DIR ...] \\ --output-dir DIR \\ [--title TITLE] [--group-label LABEL] [--html NAME] [--pdf NAME] When --pdf is provided, the script requires Playwright to be installed in the current Node environment.`); } function parseArgs(argv) { const args = { inputs: [], outputDir: "", title: "MobileGL retrace overview", groupLabel: "retrace", htmlName: "mobilegl-retrace-overview.html", pdfName: "", }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; const value = argv[i + 1]; if (arg === "--input" && value) { args.inputs.push(path.resolve(value)); i += 1; } else if (arg === "--output-dir" && value) { args.outputDir = path.resolve(value); i += 1; } else if (arg === "--title" && value) { args.title = value; i += 1; } else if (arg === "--group-label" && value) { args.groupLabel = value; i += 1; } else if (arg === "--html" && value) { args.htmlName = value; i += 1; } else if (arg === "--pdf" && value) { args.pdfName = value; i += 1; } else if (arg === "--help" || arg === "-h") { usage(); process.exit(0); } else { throw new Error(`Unknown or incomplete argument: ${arg}`); } } if (args.inputs.length === 0 || !args.outputDir) { usage(); process.exit(2); } return args; } async function exists(filePath) { try { await stat(filePath); return true; } catch { return false; } } async function isUsablePng(filePath) { try { const info = await stat(filePath); return info.isFile() && filePath.toLowerCase().endsWith(".png") && info.size > 1024; } catch { return false; } } async function walk(root) { const results = []; if (!(await exists(root))) return results; const entries = await readdir(root, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(root, entry.name); if (entry.isDirectory()) { results.push(...await walk(fullPath)); } else if (entry.isFile()) { results.push(fullPath); } } return results; } function safeCase(caseName) { return caseName.replace(/[^A-Za-z0-9._-]/g, "_"); } function shortCase(caseName) { return caseName .replace(/^minecraft-1\.21\.4-/, "mc-") .replaceAll("fabric-iris-", "") .replaceAll("fabric-", "") .replace(/-in-world$/, ""); } function normalizeSlashes(value) { return value.split(path.sep).join("/"); } function inferCaseBackend(filePath) { const text = normalizeSlashes(filePath); const backend = BACKENDS.find((candidate) => text.includes(candidate)) ?? ""; const sortedCases = [...CASES].sort((a, b) => b.length - a.length); const caseName = sortedCases.find((candidate) => text.includes(candidate) || text.includes(safeCase(candidate)) ) ?? ""; return { caseName, backend }; } function inferGroup(filePath, defaultGroup) { const text = normalizeSlashes(filePath); for (const token of ["HA27Q3LQ", "JNHUVK4XL7ZLWK7L"]) { if (text.includes(token)) return token; } return defaultGroup; } function scoreImage(filePath, caseName, backend, kind) { const text = normalizeSlashes(filePath); const name = path.basename(filePath).toLowerCase(); const lowerKind = kind.toLowerCase(); if (lowerKind === "golden" && name.includes("alternate-golden")) return 0; const matchesKind = name === `${lowerKind}.png` || name.endsWith(`-${lowerKind}.png`) || name.endsWith(`_${lowerKind}.png`); if (!matchesKind) return 0; let score = 0; if (text.includes(caseName) || text.includes(safeCase(caseName))) score += 4; if (text.includes(backend)) score += 3; score += 3; if (name === `${caseName}-${backend}-${kind}.png`.toLowerCase()) score += 4; if (name === `${safeCase(caseName)}-${backend}-${kind}.png`.toLowerCase()) score += 4; if (name === `${kind}.png`) score += 4; return score; } async function findImage(pngs, caseName, backend, kind, nearFile) { const exactNames = [ `${caseName}-${backend}-${kind}.png`, `${safeCase(caseName)}-${backend}-${kind}.png`, `${caseName}-${kind}.png`, `${safeCase(caseName)}-${kind}.png`, `${kind}.png`, ]; if (nearFile) { let current = path.dirname(nearFile); for (let depth = 0; depth < 5; depth += 1) { for (const name of exactNames) { const candidate = path.join(current, name); if (await isUsablePng(candidate)) return candidate; } const parent = path.dirname(current); if (parent === current) break; current = parent; } } const scored = []; for (const png of pngs) { const score = scoreImage(png, caseName, backend, kind); if (score >= 7) scored.push({ score, png }); } scored.sort((a, b) => b.score - a.score || a.png.length - b.png.length); return scored[0]?.png ?? ""; } async function collectRows(inputs, defaultGroup) { const files = []; for (const input of inputs) files.push(...await walk(input)); const pngs = []; for (const file of files) { if (await isUsablePng(file)) pngs.push(file); } const rows = new Map(); for (const resultJson of files.filter((file) => path.basename(file) === "result.json" || file.endsWith("-result.json"))) { let { caseName, backend } = inferCaseBackend(resultJson); let result = {}; try { result = JSON.parse(await readFile(resultJson, "utf8")); backend = backend || result.backend || ""; } catch (error) { result = { passed: false, message: `failed to parse result.json: ${error.message}` }; } if (!caseName || !BACKENDS.includes(backend)) continue; const group = inferGroup(resultJson, defaultGroup); const key = `${group}\0${backend}\0${caseName}`; const matchedGolden = result.matchedGoldenPath || ""; const alternateGoldens = Array.isArray(result.alternateGoldenPaths) ? result.alternateGoldenPaths : []; const useAlternateGolden = alternateGoldens.some((alternate) => path.basename(String(alternate)) === path.basename(String(matchedGolden)) ) || String(matchedGolden).includes("alternate-golden"); rows.set(key, { group, backend, caseName, status: result.passed ? "PASS" : "FAIL", ssim: result.ssim === undefined || result.ssim === null ? "" : String(result.ssim), message: result.message || "", actual: await findImage(pngs, caseName, backend, "actual", resultJson), golden: await findImage(pngs, caseName, backend, useAlternateGolden ? "alternate-golden" : "golden", resultJson), diff: await findImage(pngs, caseName, backend, "diff", resultJson), }); } // Preserve visibility for jobs that uploaded logs/diagnostics but never wrote result.json. const directories = new Set(files.map((file) => path.dirname(file))); for (const directory of directories) { const { caseName, backend } = inferCaseBackend(directory); if (!caseName || !BACKENDS.includes(backend)) continue; const group = inferGroup(directory, defaultGroup); const key = `${group}\0${backend}\0${caseName}`; if (rows.has(key)) continue; rows.set(key, { group, backend, caseName, status: "NO_RESULT", ssim: "", message: "result.json was not found", actual: await findImage(pngs, caseName, backend, "actual", directory), golden: await findImage(pngs, caseName, backend, "golden", directory), diff: await findImage(pngs, caseName, backend, "diff", directory), }); } return [...rows.values()]; } function htmlEscape(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } async function copyAsset(source, outputDir, assetName) { if (!source) return ""; if (!(await isUsablePng(source))) return ""; const assetsDir = path.join(outputDir, "assets"); await mkdir(assetsDir, { recursive: true }); const destination = path.join(assetsDir, assetName); await copyFile(source, destination); return normalizeSlashes(path.relative(outputDir, destination)); } function statusText(row) { if (!row) return "NO RESULT"; if (row.status === "NO_RESULT") return "NO RESULT"; if (row.status === "FIXTURE_MISSING") return "MISSING FIXTURE"; if (row.ssim) { const value = Number(row.ssim); const text = Number.isFinite(value) ? value.toFixed(4) : row.ssim; return `${row.status} SSIM ${htmlEscape(text)}`; } return row.status; } async function renderHtml(rows, outputDir, title, groupLabel, htmlName) { await mkdir(outputDir, { recursive: true }); const groups = [...new Set(rows.map((row) => row.group))].sort(); if (groups.length === 0) groups.push(groupLabel); const rowMap = new Map(rows.map((row) => [`${row.group}\0${row.backend}\0${row.caseName}`, row])); const cells = new Map(); for (const row of rows) { const base = `${safeCase(row.group)}-${row.backend}-${safeCase(row.caseName)}`; cells.set(`${row.group}\0${row.backend}\0${row.caseName}`, { ...row, actualRel: await copyAsset(row.actual, outputDir, `${base}-actual.png`), goldenRel: await copyAsset(row.golden, outputDir, `${base}-golden.png`), diffRel: await copyAsset(row.diff, outputDir, `${base}-diff.png`), }); } const thumb = (src, label) => `
${label}
${src ? `${label}` : `
NO IMAGE
`}
`; const missingImages = (row) => !row.actualRel || !row.goldenRel || !row.diffRel; const cellFor = (group, backend, caseName) => cells.get(`${group}\0${backend}\0${caseName}`) ?? { status: "NO_RESULT", actualRel: "", goldenRel: "", diffRel: "", }; const backendCell = (group, backend, caseName) => { const row = cellFor(group, backend, caseName); const statusClass = row.status === "PASS" ? "pass" : row.status === "NO_RESULT" || row.status === "FIXTURE_MISSING" ? "missing" : "fail"; const imageClass = missingImages(row) ? "image-missing" : ""; return `
${backend} ${statusText(row)}
${thumb(row.actualRel, "actual")} ${thumb(row.goldenRel, "golden")} ${thumb(row.diffRel, "diff")}
`; }; const groupSections = groups.map((group) => { const groupCells = CASES.flatMap((caseName) => BACKENDS.map((backend) => cellFor(group, backend, caseName))); const passed = groupCells.filter((row) => row.status === "PASS").length; const failed = groupCells.filter((row) => row.status === "FAIL").length; const noResult = groupCells.filter((row) => row.status !== "PASS" && row.status !== "FAIL").length; return `

${htmlEscape(title)} - ${htmlEscape(group)}

Each backend cell shows actual / golden / diff. Failed cases are red; absent results are orange; incomplete image sets get an orange notch.
PASS ${passed}, FAIL ${failed}, NO RESULT ${noResult}.

case
DirectGLES
DirectVulkan
${CASES.map((caseName, index) => `
${backendCell(group, "DirectGLES", caseName)} ${backendCell(group, "DirectVulkan", caseName)}
`).join("")}
`; }).join(""); const html = ` ${htmlEscape(title)}
${groupSections}
`; const output = path.join(outputDir, htmlName); await writeFile(output, html, "utf8"); return output; } function csvEscape(value) { const text = String(value ?? ""); return /[",\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; } async function writeCsv(rows, outputDir) { const header = ["Group", "Backend", "Case", "Status", "SSIM", "Message", "Actual", "Golden", "Diff"]; const sorted = [...rows].sort((a, b) => a.group.localeCompare(b.group) || BACKENDS.indexOf(a.backend) - BACKENDS.indexOf(b.backend) || CASES.indexOf(a.caseName) - CASES.indexOf(b.caseName) ); const serialize = (row) => [ row.group, row.backend, row.caseName, row.status, row.ssim, row.message, row.actual, row.golden, row.diff, ].map(csvEscape).join(","); await writeFile(path.join(outputDir, "summary.csv"), [header.join(","), ...sorted.map(serialize)].join("\n"), "utf8"); await writeFile(path.join(outputDir, "failures.csv"), [header.join(","), ...sorted.filter((row) => row.status !== "PASS").map(serialize)].join("\n"), "utf8"); } async function renderPdf(htmlPath, pdfPath) { let chromium; try { ({ chromium } = await import("playwright")); } catch (error) { throw new Error("Playwright is required for --pdf. Install it with: npm install playwright && npx playwright install chromium"); } const browser = await chromium.launch(); try { const page = await browser.newPage({ viewport: { width: 1170, height: 2380 }, deviceScaleFactor: 1 }); await page.goto(pathToFileURL(htmlPath).href, { waitUntil: "networkidle" }); await page.pdf({ path: pdfPath, printBackground: true, preferCSSPageSize: true, }); } finally { await browser.close(); } } async function main() { const args = parseArgs(process.argv.slice(2)); await mkdir(args.outputDir, { recursive: true }); const rows = await collectRows(args.inputs, args.groupLabel); const htmlPath = await renderHtml(rows, args.outputDir, args.title, args.groupLabel, args.htmlName); await writeCsv(rows, args.outputDir); console.log(htmlPath); if (args.pdfName) { const pdfPath = path.join(args.outputDir, args.pdfName); await renderPdf(htmlPath, pdfPath); console.log(pdfPath); } } main().catch((error) => { console.error(error.stack || error.message); process.exit(1); });