#!/usr/bin/env node
import { createWriteStream, readFileSync } from "node:fs";
import { mkdir, readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const BACKENDS = ["DirectGLES", "DirectVulkan"];
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_FIXTURE_ROOT = path.join(SCRIPT_DIR, "fixtures");
const TRACE_CASES = JSON.parse(readFileSync(path.join(SCRIPT_DIR, "trace_cases.json"), "utf8"));
const CASES = TRACE_CASES.cases.map((traceCase) => traceCase.name);
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]`);
}
function parseArgs(argv) {
const args = {
inputs: [],
outputDir: "",
title: "MobileGL retrace overview",
groupLabel: "retrace",
htmlName: "mobilegl-retrace-overview.html",
};
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 === "--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 segment of text.split("/")) {
const match = segment.match(/^android-retrace-(?:result|fixture|work)-(.+)$/);
if (!match) continue;
const parts = match[1].split("-");
const serial = parts[parts.length - 1];
if (serial) return serial;
}
return defaultGroup;
}
function parseGpuLabel(logText) {
const renderer = logText.match(/^\[[^\n]*\]\s*\[[^\n]*\]:\s*GL_RENDERER:\s*(.+?)\s*$/m)
?? logText.match(/^GL_RENDERER:\s*(.+?)\s*$/m);
if (renderer?.[1]) return renderer[1].trim();
const vulkanDevice = logText.match(/^\[[^\n]*\]\s*\[[^\n]*\]:\s+(.+?)\s+\(Vulkan\s+[^,\n]+,\s*[^)\n]*GPU\)\s*$/m)
?? logText.match(/^\s+(.+?)\s+\(Vulkan\s+[^,\n]+,\s*[^)\n]*GPU\)\s*$/m);
if (vulkanDevice?.[1]) return vulkanDevice[1].trim();
return "";
}
async function collectGroupLabels(files, defaultGroup) {
const labels = new Map();
const logFiles = files.filter((file) => {
const name = path.basename(file).toLowerCase();
return name === "mobilegl.log" || name === "logcat.txt";
});
for (const logFile of logFiles) {
const group = inferGroup(logFile, defaultGroup);
try {
const gpuLabel = parseGpuLabel(await readFile(logFile, "utf8"));
if (gpuLabel) labels.set(group, gpuLabel);
} catch {
// Diagnostics should not make summary rendering fail.
}
}
return labels;
}
function scoreImage(filePath, caseName, backend, kind) {
const text = normalizeSlashes(filePath);
const name = path.basename(filePath).toLowerCase();
const fixtureGoldenPrefix = `${caseName}.`.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`);
const matchesFixtureGolden =
(lowerKind === "golden" || lowerKind === "alternate-golden") &&
name.startsWith(fixtureGoldenPrefix) &&
name.endsWith(".png");
if (!matchesKind && !matchesFixtureGolden) return 0;
let score = 0;
if (text.includes(caseName) || text.includes(safeCase(caseName))) score += 4;
if (text.includes(backend)) score += 3;
score += 3;
if (matchesFixtureGolden) {
score += 4;
if (lowerKind === "alternate-golden" && name.includes("-mali.")) score += 3;
if (lowerKind === "golden" && !name.includes("-mali.")) score += 2;
}
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 = [];
const nearGroup = nearFile ? inferGroup(nearFile, "") : "";
for (const png of pngs) {
const text = normalizeSlashes(png);
if ((kind === "actual" || kind === "diff") && backend && !text.includes(backend)) continue;
const pngGroup = inferGroup(png, "");
if (nearGroup && pngGroup && pngGroup !== nearGroup) continue;
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 groupLabels = await collectGroupLabels(files, defaultGroup);
const fixtureFiles = await walk(DEFAULT_FIXTURE_ROOT);
const pngs = [];
for (const file of [...files, ...fixtureFiles]) {
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: [...rows.values()], groupLabels };
}
function htmlEscape(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
function jsonScriptText(value) {
return JSON.stringify(value).replaceAll("<", "\\u003c");
}
async function embedPng(source) {
if (!source) return "";
if (!(await isUsablePng(source))) return "";
const data = await readFile(source);
return `data:image/png;base64,${data.toString("base64")}`;
}
function statusText(row) {
if (!row) return "NO RESULT";
const effective = effectiveStatus(row);
if (effective === "FAIL" && row.status === "NO_RESULT") return `FAIL 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;
}
function effectiveStatus(row) {
if (!row) return "NO_RESULT";
if (row.status === "PASS" || row.status === "FAIL") return row.status;
return !row.actualSrc && !row.goldenSrc ? "NO_RESULT" : "FAIL";
}
async function renderHtml(rows, groupLabels, 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) {
cells.set(`${row.group}\0${row.backend}\0${row.caseName}`, {
...row,
actualSrc: await embedPng(row.actual),
goldenSrc: await embedPng(row.golden),
diffSrc: await embedPng(row.diff),
});
}
const imageTemplates = [];
const imageTemplatesByGroup = new Map();
const imageTemplateForGroup = (group) => {
let template = imageTemplatesByGroup.get(group);
if (!template) {
template = { id: `image-data-${imageTemplates.length}`, images: {}, nextImage: 0 };
imageTemplatesByGroup.set(group, template);
imageTemplates.push(template);
}
return template;
};
const registerImage = (group, src) => {
if (!src) return "";
const template = imageTemplateForGroup(group);
const id = `image-${template.nextImage}`;
template.nextImage += 1;
template.images[id] = src;
return id;
};
const thumb = (group, src, label) => {
const imageId = registerImage(group, src);
return `
${htmlEscape(label)}
${imageId ? `` : `NO IMAGE
`}
`;
};
const missingImages = (row) => !row.actualSrc || !row.goldenSrc || !row.diffSrc;
const cellFor = (group, backend, caseName) => cells.get(`${group}\0${backend}\0${caseName}`) ?? {
status: "NO_RESULT",
actualSrc: "",
goldenSrc: "",
diffSrc: "",
};
const backendCell = (group, backend, caseName) => {
const row = cellFor(group, backend, caseName);
const effective = effectiveStatus(row);
const statusClass = effective === "PASS" ? "pass" : effective === "NO_RESULT" ? "missing" : "fail";
const imageClass = missingImages(row) ? "image-missing" : "";
return `
${backend} ${statusText(row)}
${thumb(group, row.actualSrc, "actual")}
${thumb(group, row.goldenSrc, "golden")}
${thumb(group, row.diffSrc, "diff")}
`;
};
const output = path.join(outputDir, htmlName);
const stream = createWriteStream(output, { encoding: "utf8" });
const write = (chunk) => new Promise((resolve, reject) => {
stream.write(chunk, (error) => error ? reject(error) : resolve());
});
const finish = () => new Promise((resolve, reject) => {
stream.end((error) => error ? reject(error) : resolve());
});
await write(`
${htmlEscape(title)}
${htmlEscape(title)}
Each backend cell shows actual / golden / diff. Failed cases are red; absent results are orange; incomplete image sets get an orange notch.
`);
const caseTemplates = [];
for (const group of groups) {
const displayGroup = groupLabels.get(group) ?? group;
const templateId = `case-template-${caseTemplates.length}`;
const groupCells = CASES.flatMap((caseName) => BACKENDS.map((backend) => cellFor(group, backend, caseName)));
const passed = groupCells.filter((row) => effectiveStatus(row) === "PASS").length;
const failed = groupCells.filter((row) => effectiveStatus(row) === "FAIL").length;
const noResult = groupCells.filter((row) => effectiveStatus(row) === "NO_RESULT").length;
const passRateDenominator = passed + failed;
const passRate = passRateDenominator === 0 ? "0.0%" : `${(passed * 100 / passRateDenominator).toFixed(1)}%`;
const statusTotal = passed + noResult + failed;
const passPercent = statusTotal === 0 ? "0" : (passed * 100 / statusTotal).toFixed(3);
const noResultPercent = statusTotal === 0 ? "0" : (noResult * 100 / statusTotal).toFixed(3);
const failPercent = statusTotal === 0 ? "0" : (failed * 100 / statusTotal).toFixed(3);
let caseHtml = `
`;
for (const [index, caseName] of CASES.entries()) {
caseHtml += `
${backendCell(group, "DirectGLES", caseName)}
${backendCell(group, "DirectVulkan", caseName)}
`;
}
caseTemplates.push({ id: templateId, html: caseHtml });
const imageTemplate = imageTemplatesByGroup.get(group);
await write(`
${htmlEscape(displayGroup)}
PASS RATE ${passRate}
PASS ${passed}
NO RESULT ${noResult}
FAIL ${failed}
${statusTotal === 0
? ``
: ``}
`);
}
for (const template of caseTemplates) {
await write(`
`);
}
await write(`
`);
for (const template of imageTemplates) {
await write(`
`);
}
await write(`
`);
await finish();
return output;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
await mkdir(args.outputDir, { recursive: true });
const { rows, groupLabels } = await collectRows(args.inputs, args.groupLabel);
const htmlPath = await renderHtml(rows, groupLabels, args.outputDir, args.title, args.groupLabel, args.htmlName);
console.log(htmlPath);
}
main().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});