[CI] upload self-contained retrace summary

This commit is contained in:
2026-06-27 20:05:37 +08:00
parent 59c9b94d76
commit 8fd25acbb6
3 changed files with 83 additions and 118 deletions
+2 -17
View File
@@ -537,11 +537,6 @@ jobs:
pattern: MobileGL-android-retrace-result-* pattern: MobileGL-android-retrace-result-*
path: retrace-artifacts 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 - name: Render retrace summary
run: | run: |
node tools/trace_replay/render_retrace_summary.mjs \ node tools/trace_replay/render_retrace_summary.mjs \
@@ -549,21 +544,11 @@ jobs:
--output-dir android-retrace-summary \ --output-dir android-retrace-summary \
--title "MobileGL Android retrace overview" \ --title "MobileGL Android retrace overview" \
--group-label "Android Emulator" \ --group-label "Android Emulator" \
--html mobilegl-android-retrace-overview.html \ --html mobilegl-android-retrace-overview.html
--pdf mobilegl-android-retrace-overview.pdf
- name: Upload Android retrace summary - 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/**
!android-retrace-summary/mobilegl-android-retrace-overview.pdf
if-no-files-found: error
- name: Upload Android retrace summary PDF
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
path: android-retrace-summary/mobilegl-android-retrace-overview.pdf path: android-retrace-summary/mobilegl-android-retrace-overview.html
archive: false archive: false
if-no-files-found: error if-no-files-found: error
+2 -17
View File
@@ -448,11 +448,6 @@ jobs:
pattern: retrace-result-* pattern: retrace-result-*
path: retrace-artifacts 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 - name: Render retrace summary
run: | run: |
node tools/trace_replay/render_retrace_summary.mjs \ node tools/trace_replay/render_retrace_summary.mjs \
@@ -460,22 +455,12 @@ jobs:
--output-dir retrace-summary \ --output-dir retrace-summary \
--title "MobileGL Linux retrace overview" \ --title "MobileGL Linux retrace overview" \
--group-label "Linux" \ --group-label "Linux" \
--html mobilegl-linux-retrace-overview.html \ --html mobilegl-linux-retrace-overview.html
--pdf mobilegl-linux-retrace-overview.pdf
- name: Upload retrace summary - name: Upload retrace summary
uses: actions/upload-artifact@v4
with:
name: retrace-summary-${{ env.date_today }}-${{ env.short_sha }}
path: |
retrace-summary/**
!retrace-summary/mobilegl-linux-retrace-overview.pdf
if-no-files-found: error
- name: Upload retrace summary PDF
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
path: retrace-summary/mobilegl-linux-retrace-overview.pdf path: retrace-summary/mobilegl-linux-retrace-overview.html
archive: false archive: false
if-no-files-found: error if-no-files-found: error
+79 -84
View File
@@ -1,8 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
import { copyFile, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { pathToFileURL } from "node:url";
const BACKENDS = ["DirectGLES", "DirectVulkan"]; const BACKENDS = ["DirectGLES", "DirectVulkan"];
const CASES = [ const CASES = [
@@ -33,10 +32,7 @@ function usage() {
node tools/trace_replay/render_retrace_summary.mjs \\ node tools/trace_replay/render_retrace_summary.mjs \\
--input DIR [--input DIR ...] \\ --input DIR [--input DIR ...] \\
--output-dir DIR \\ --output-dir DIR \\
[--title TITLE] [--group-label LABEL] [--html NAME] [--pdf NAME] [--title TITLE] [--group-label LABEL] [--html NAME]`);
When --pdf is provided, the script requires Playwright to be installed in the
current Node environment.`);
} }
function parseArgs(argv) { function parseArgs(argv) {
@@ -46,7 +42,6 @@ function parseArgs(argv) {
title: "MobileGL retrace overview", title: "MobileGL retrace overview",
groupLabel: "retrace", groupLabel: "retrace",
htmlName: "mobilegl-retrace-overview.html", htmlName: "mobilegl-retrace-overview.html",
pdfName: "",
}; };
for (let i = 0; i < argv.length; i += 1) { for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]; const arg = argv[i];
@@ -66,9 +61,6 @@ function parseArgs(argv) {
} else if (arg === "--html" && value) { } else if (arg === "--html" && value) {
args.htmlName = value; args.htmlName = value;
i += 1; i += 1;
} else if (arg === "--pdf" && value) {
args.pdfName = value;
i += 1;
} else if (arg === "--help" || arg === "-h") { } else if (arg === "--help" || arg === "-h") {
usage(); usage();
process.exit(0); process.exit(0);
@@ -274,14 +266,11 @@ function htmlEscape(value) {
.replaceAll('"', "&quot;"); .replaceAll('"', "&quot;");
} }
async function copyAsset(source, outputDir, assetName) { async function embedPng(source) {
if (!source) return ""; if (!source) return "";
if (!(await isUsablePng(source))) return ""; if (!(await isUsablePng(source))) return "";
const assetsDir = path.join(outputDir, "assets"); const data = await readFile(source);
await mkdir(assetsDir, { recursive: true }); return `data:image/png;base64,${data.toString("base64")}`;
const destination = path.join(assetsDir, assetName);
await copyFile(source, destination);
return normalizeSlashes(path.relative(outputDir, destination));
} }
function statusText(row) { function statusText(row) {
@@ -304,27 +293,26 @@ async function renderHtml(rows, outputDir, title, groupLabel, htmlName) {
const cells = new Map(); const cells = new Map();
for (const row of rows) { 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}`, { cells.set(`${row.group}\0${row.backend}\0${row.caseName}`, {
...row, ...row,
actualRel: await copyAsset(row.actual, outputDir, `${base}-actual.png`), actualSrc: await embedPng(row.actual),
goldenRel: await copyAsset(row.golden, outputDir, `${base}-golden.png`), goldenSrc: await embedPng(row.golden),
diffRel: await copyAsset(row.diff, outputDir, `${base}-diff.png`), diffSrc: await embedPng(row.diff),
}); });
} }
const thumb = (src, label) => ` const thumb = (src, label) => `
<figure class="thumb"> <figure class="thumb">
<figcaption>${label}</figcaption> <figcaption>${htmlEscape(label)}</figcaption>
${src ? `<img src="${htmlEscape(src)}" alt="${label}">` : `<div class="no-image">NO IMAGE</div>`} ${src ? `<button class="image-button" type="button" aria-label="Open ${htmlEscape(label)} image"><img src="${htmlEscape(src)}" alt="${htmlEscape(label)}"></button>` : `<div class="no-image">NO IMAGE</div>`}
</figure>`; </figure>`;
const missingImages = (row) => !row.actualRel || !row.goldenRel || !row.diffRel; const missingImages = (row) => !row.actualSrc || !row.goldenSrc || !row.diffSrc;
const cellFor = (group, backend, caseName) => cells.get(`${group}\0${backend}\0${caseName}`) ?? { const cellFor = (group, backend, caseName) => cells.get(`${group}\0${backend}\0${caseName}`) ?? {
status: "NO_RESULT", status: "NO_RESULT",
actualRel: "", actualSrc: "",
goldenRel: "", goldenSrc: "",
diffRel: "", diffSrc: "",
}; };
const backendCell = (group, backend, caseName) => { const backendCell = (group, backend, caseName) => {
@@ -335,9 +323,9 @@ async function renderHtml(rows, outputDir, title, groupLabel, htmlName) {
<section class="backend-cell ${statusClass} ${imageClass}"> <section class="backend-cell ${statusClass} ${imageClass}">
<header>${backend} <strong>${statusText(row)}</strong></header> <header>${backend} <strong>${statusText(row)}</strong></header>
<div class="thumbs"> <div class="thumbs">
${thumb(row.actualRel, "actual")} ${thumb(row.actualSrc, "actual")}
${thumb(row.goldenRel, "golden")} ${thumb(row.goldenSrc, "golden")}
${thumb(row.diffRel, "diff")} ${thumb(row.diffSrc, "diff")}
</div> </div>
</section>`; </section>`;
}; };
@@ -347,10 +335,12 @@ async function renderHtml(rows, outputDir, title, groupLabel, htmlName) {
const passed = groupCells.filter((row) => row.status === "PASS").length; const passed = groupCells.filter((row) => row.status === "PASS").length;
const failed = groupCells.filter((row) => row.status === "FAIL").length; const failed = groupCells.filter((row) => row.status === "FAIL").length;
const noResult = groupCells.filter((row) => row.status !== "PASS" && row.status !== "FAIL").length; const noResult = groupCells.filter((row) => row.status !== "PASS" && row.status !== "FAIL").length;
const passRateDenominator = passed + failed;
const passRate = passRateDenominator === 0 ? "0.0%" : `${(passed * 100 / passRateDenominator).toFixed(1)}%`;
return ` return `
<section class="group"> <section class="group">
<h1>${htmlEscape(title)} - ${htmlEscape(group)}</h1> <h1>${htmlEscape(title)} - ${htmlEscape(group)}</h1>
<p>Each backend cell shows actual / golden / diff. Failed cases are red; absent results are orange; incomplete image sets get an orange notch.<br>PASS ${passed}, FAIL ${failed}, NO RESULT ${noResult}.</p> <p>Each backend cell shows actual / golden / diff. Failed cases are red; absent results are orange; incomplete image sets get an orange notch.<br>PASS ${passed}, FAIL ${failed}, NO RESULT ${noResult}, PASS RATE ${passRate}.</p>
<div class="grid header-row"> <div class="grid header-row">
<div>case</div> <div>case</div>
<div>DirectGLES</div> <div>DirectGLES</div>
@@ -478,6 +468,16 @@ async function renderHtml(rows, outputDir, title, groupLabel, htmlName) {
font-size: 9px; font-size: 9px;
font-weight: 700; font-weight: 700;
} }
.image-button {
width: 100%;
height: 100%;
display: block;
margin: 0;
padding: 0;
border: 0;
background: transparent;
cursor: zoom-in;
}
.thumb img { .thumb img {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -493,10 +493,30 @@ async function renderHtml(rows, outputDir, title, groupLabel, htmlName) {
font-weight: 700; font-weight: 700;
color: var(--text); color: var(--text);
} }
.lightbox {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
padding: 28px;
background: rgb(0 0 0 / 86%);
cursor: zoom-out;
}
.lightbox[hidden] {
display: none;
}
.lightbox img {
max-width: min(96vw, 1600px);
max-height: 94vh;
object-fit: contain;
box-shadow: 0 18px 70px rgb(0 0 0 / 70%);
}
@page { size: 1170px 2380px; margin: 0; } @page { size: 1170px 2380px; margin: 0; }
@media print { @media print {
main { padding: 10px; } main { padding: 10px; }
.group { margin-bottom: 0; } .group { margin-bottom: 0; }
.lightbox { display: none; }
} }
</style> </style>
</head> </head>
@@ -504,6 +524,35 @@ async function renderHtml(rows, outputDir, title, groupLabel, htmlName) {
<main> <main>
${groupSections} ${groupSections}
</main> </main>
<div class="lightbox" id="lightbox" hidden>
<img id="lightbox-image" alt="">
</div>
<script>
(() => {
const lightbox = document.getElementById("lightbox");
const lightboxImage = document.getElementById("lightbox-image");
const closeLightbox = () => {
lightbox.hidden = true;
lightboxImage.removeAttribute("src");
lightboxImage.alt = "";
};
document.querySelectorAll(".image-button").forEach((button) => {
button.addEventListener("click", () => {
const image = button.querySelector("img");
if (!image) return;
lightboxImage.src = image.src;
lightboxImage.alt = image.alt;
lightbox.hidden = false;
});
});
lightbox.addEventListener("click", closeLightbox);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && !lightbox.hidden) closeLightbox();
});
})();
</script>
</body> </body>
</html> </html>
`; `;
@@ -512,66 +561,12 @@ ${groupSections}
return output; 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() { async function main() {
const args = parseArgs(process.argv.slice(2)); const args = parseArgs(process.argv.slice(2));
await mkdir(args.outputDir, { recursive: true }); await mkdir(args.outputDir, { recursive: true });
const rows = await collectRows(args.inputs, args.groupLabel); const rows = await collectRows(args.inputs, args.groupLabel);
const htmlPath = await renderHtml(rows, args.outputDir, args.title, args.groupLabel, args.htmlName); const htmlPath = await renderHtml(rows, args.outputDir, args.title, args.groupLabel, args.htmlName);
await writeCsv(rows, args.outputDir);
console.log(htmlPath); console.log(htmlPath);
if (args.pdfName) {
const pdfPath = path.join(args.outputDir, args.pdfName);
await renderPdf(htmlPath, pdfPath);
console.log(pdfPath);
}
} }
main().catch((error) => { main().catch((error) => {