mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-08 12:18:30 +09:00
[CI] add retrace summary artifacts
This commit is contained in:
@@ -445,6 +445,19 @@ jobs:
|
||||
--crop-height "${{ matrix.case.crop_height }}" \
|
||||
--timeout-seconds "${{ matrix.case.timeout_seconds }}"
|
||||
|
||||
- name: Collect retrace summary inputs
|
||||
if: always()
|
||||
run: |
|
||||
safe_case="$(printf '%s' '${{ matrix.case.name }}' | sed 's/[^A-Za-z0-9._-]/_/g')"
|
||||
result_dir="android-retrace-result/${safe_case}-${{ matrix.backend.name }}"
|
||||
mkdir -p "${result_dir}"
|
||||
if [ -s "${{ matrix.case.golden }}" ]; then
|
||||
cp "${{ matrix.case.golden }}" "${result_dir}/${safe_case}-${{ matrix.backend.name }}-golden.png"
|
||||
fi
|
||||
if [ -n "${{ matrix.case.alternate_golden || '' }}" ] && [ -s "${{ matrix.case.alternate_golden || '' }}" ]; then
|
||||
cp "${{ matrix.case.alternate_golden || '' }}" "${result_dir}/${safe_case}-${{ matrix.backend.name }}-alternate-golden.png"
|
||||
fi
|
||||
|
||||
- name: Collect emulator diagnostics
|
||||
if: always()
|
||||
run: |
|
||||
@@ -470,3 +483,53 @@ jobs:
|
||||
name: MobileGL-android-retrace-result-${{ env.date_today }}-${{ env.short_sha }}-${{ matrix.backend.name }}-${{ matrix.case.name }}
|
||||
path: android-retrace-result/**
|
||||
if-no-files-found: warn
|
||||
|
||||
retrace-summary:
|
||||
name: retrace summary
|
||||
runs-on: ubuntu-latest
|
||||
needs: retrace
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set artifact metadata
|
||||
run: |
|
||||
echo "date_today=$(date +'%Y-%m-%d')" >> "$GITHUB_ENV"
|
||||
echo "short_sha=${GITHUB_SHA::7}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Download Android retrace results
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: MobileGL-android-retrace-result-*
|
||||
path: retrace-artifacts
|
||||
|
||||
- name: Install PDF renderer
|
||||
run: |
|
||||
npm install --no-save --no-package-lock playwright
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
- name: Render retrace summary
|
||||
run: |
|
||||
node tools/trace_replay/render_retrace_summary.mjs \
|
||||
--input retrace-artifacts \
|
||||
--output-dir android-retrace-summary \
|
||||
--title "MobileGL Android retrace overview" \
|
||||
--group-label "Android Emulator" \
|
||||
--html mobilegl-android-retrace-overview.html \
|
||||
--pdf mobilegl-android-retrace-overview.pdf
|
||||
|
||||
- name: Upload Android retrace summary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: MobileGL-android-retrace-summary-${{ env.date_today }}-${{ env.short_sha }}
|
||||
path: android-retrace-summary/**
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -418,3 +418,53 @@ jobs:
|
||||
build-retrace/tools/trace_replay/${{ matrix.case }}/${{ matrix.backend }}/output/**
|
||||
if-no-files-found: warn
|
||||
|
||||
retrace-summary:
|
||||
name: retrace summary
|
||||
runs-on: ubuntu-latest
|
||||
needs: retrace
|
||||
if: always()
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set artifact metadata
|
||||
run: |
|
||||
echo "date_today=$(date +'%Y-%m-%d')" >> "$GITHUB_ENV"
|
||||
echo "short_sha=${GITHUB_SHA::7}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Download retrace results
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: retrace-result-*
|
||||
path: retrace-artifacts
|
||||
|
||||
- name: Install PDF renderer
|
||||
run: |
|
||||
npm install --no-save --no-package-lock playwright
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
- name: Render retrace summary
|
||||
run: |
|
||||
node tools/trace_replay/render_retrace_summary.mjs \
|
||||
--input retrace-artifacts \
|
||||
--output-dir retrace-summary \
|
||||
--title "MobileGL Linux retrace overview" \
|
||||
--group-label "Linux" \
|
||||
--html mobilegl-linux-retrace-overview.html \
|
||||
--pdf mobilegl-linux-retrace-overview.pdf
|
||||
|
||||
- name: Upload retrace summary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: retrace-summary-${{ env.date_today }}-${{ env.short_sha }}
|
||||
path: retrace-summary/**
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
#!/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",
|
||||
];
|
||||
|
||||
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: "FAIL",
|
||||
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 === "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} <span>SSIM ${htmlEscape(text)}</span>`;
|
||||
}
|
||||
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) => `
|
||||
<figure class="thumb">
|
||||
<figcaption>${label}</figcaption>
|
||||
${src ? `<img src="${htmlEscape(src)}" alt="${label}">` : `<div class="no-image">NO IMAGE</div>`}
|
||||
</figure>`;
|
||||
|
||||
const backendCell = (group, backend, caseName) => {
|
||||
const row = cells.get(`${group}\0${backend}\0${caseName}`) ?? {
|
||||
status: "NO_RESULT",
|
||||
actualRel: "",
|
||||
goldenRel: "",
|
||||
diffRel: "",
|
||||
};
|
||||
const statusClass = row.status === "PASS" ? "pass" : row.status === "NO_RESULT" || row.status === "FIXTURE_MISSING" ? "missing" : "fail";
|
||||
return `
|
||||
<section class="backend-cell ${statusClass}">
|
||||
<header>${backend} <strong>${statusText(row)}</strong></header>
|
||||
<div class="thumbs">
|
||||
${thumb(row.actualRel, "actual")}
|
||||
${thumb(row.goldenRel, "golden")}
|
||||
${thumb(row.diffRel, "diff")}
|
||||
</div>
|
||||
</section>`;
|
||||
};
|
||||
|
||||
const groupSections = groups.map((group) => {
|
||||
const groupRows = rows.filter((row) => row.group === group);
|
||||
const passed = groupRows.filter((row) => row.status === "PASS").length;
|
||||
const failed = groupRows.filter((row) => row.status === "FAIL").length;
|
||||
const missing = groupRows.filter((row) => row.status !== "PASS" && row.status !== "FAIL").length;
|
||||
return `
|
||||
<section class="group">
|
||||
<h1>${htmlEscape(title)} - ${htmlEscape(group)}</h1>
|
||||
<p>Each backend cell shows actual / golden / diff. Failed cases are red; missing or absent results are orange. PASS ${passed}, FAIL ${failed}, MISSING ${missing}.</p>
|
||||
<div class="grid header-row">
|
||||
<div>case</div>
|
||||
<div>DirectGLES</div>
|
||||
<div>DirectVulkan</div>
|
||||
</div>
|
||||
${CASES.map((caseName, index) => `
|
||||
<div class="grid case-row ${index % 2 === 0 ? "even" : "odd"}">
|
||||
<aside>
|
||||
<strong>${htmlEscape(shortCase(caseName))}</strong>
|
||||
${caseName.includes("iterationt") && !rowMap.has(`${group}\0DirectGLES\0${caseName}`) && !rowMap.has(`${group}\0DirectVulkan\0${caseName}`)
|
||||
? `<small>LFS fixture may be unavailable</small>` : ""}
|
||||
</aside>
|
||||
${backendCell(group, "DirectGLES", caseName)}
|
||||
${backendCell(group, "DirectVulkan", caseName)}
|
||||
</div>`).join("")}
|
||||
</section>`;
|
||||
}).join("");
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${htmlEscape(title)}</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #141414;
|
||||
--row-a: #1e1e1e;
|
||||
--row-b: #242424;
|
||||
--panel: #181818;
|
||||
--image-bg: #242424;
|
||||
--line: #3a3a3a;
|
||||
--text: #e8e8e8;
|
||||
--muted: #aaa;
|
||||
--green: #1a9453;
|
||||
--red: #d22d2d;
|
||||
--orange: #cd841c;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
}
|
||||
main { width: 1170px; margin: 0 auto; padding: 14px 10px 24px; }
|
||||
.group { break-after: page; margin-bottom: 28px; }
|
||||
.group:last-child { break-after: auto; }
|
||||
h1 { margin: 0; font-size: 24px; line-height: 1.2; }
|
||||
p { margin: 4px 0 12px; color: var(--muted); }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 430px 430px;
|
||||
column-gap: 10px;
|
||||
}
|
||||
.header-row {
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.case-row {
|
||||
min-height: 112px;
|
||||
padding: 5px 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
.case-row.even { background: var(--row-a); }
|
||||
.case-row.odd { background: var(--row-b); }
|
||||
aside {
|
||||
padding: 22px 8px 0 2px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
aside small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--orange);
|
||||
}
|
||||
.backend-cell {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
min-height: 102px;
|
||||
}
|
||||
.backend-cell.fail { border: 3px solid var(--red); }
|
||||
.backend-cell.missing { border: 2px solid var(--orange); }
|
||||
.backend-cell > header {
|
||||
height: 22px;
|
||||
padding: 4px 7px;
|
||||
font-weight: 700;
|
||||
background: var(--green);
|
||||
}
|
||||
.backend-cell.fail > header { background: var(--red); }
|
||||
.backend-cell.missing > header { background: var(--orange); }
|
||||
.backend-cell header span { margin-left: 4px; }
|
||||
.thumbs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
.thumb {
|
||||
height: 74px;
|
||||
margin: 0;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--image-bg);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.thumb figcaption {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 2px;
|
||||
z-index: 1;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
padding-top: 12px;
|
||||
display: block;
|
||||
}
|
||||
.no-image {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding-top: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
@page { size: 1170px 2380px; margin: 0; }
|
||||
@media print {
|
||||
main { padding: 10px; }
|
||||
.group { margin-bottom: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
${groupSections}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
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);
|
||||
});
|
||||
@@ -87,6 +87,12 @@ if(DEFINED TRACE_ARTIFACT_DIR AND NOT "${TRACE_ARTIFACT_DIR}" STREQUAL "")
|
||||
if(EXISTS "${actual_png}")
|
||||
file(COPY_FILE "${actual_png}" "${TRACE_ARTIFACT_DIR}/${TRACE_CASE_NAME}-${TRACE_BACKEND}-actual.png")
|
||||
endif()
|
||||
if(EXISTS "${TRACE_GOLDEN}")
|
||||
file(COPY_FILE "${TRACE_GOLDEN}" "${TRACE_ARTIFACT_DIR}/${TRACE_CASE_NAME}-${TRACE_BACKEND}-golden.png")
|
||||
endif()
|
||||
if(DEFINED TRACE_ALTERNATE_GOLDEN AND NOT "${TRACE_ALTERNATE_GOLDEN}" STREQUAL "" AND EXISTS "${TRACE_ALTERNATE_GOLDEN}")
|
||||
file(COPY_FILE "${TRACE_ALTERNATE_GOLDEN}" "${TRACE_ARTIFACT_DIR}/${TRACE_CASE_NAME}-${TRACE_BACKEND}-alternate-golden.png")
|
||||
endif()
|
||||
if(EXISTS "${result_json}")
|
||||
file(COPY_FILE "${result_json}" "${TRACE_ARTIFACT_DIR}/${TRACE_CASE_NAME}-${TRACE_BACKEND}-result.json")
|
||||
endif()
|
||||
|
||||
Reference in New Issue
Block a user