diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml
index f0405b89c..26e330d22 100644
--- a/.github/workflows/deploy-preview.yml
+++ b/.github/workflows/deploy-preview.yml
@@ -179,6 +179,9 @@ jobs:
- name: Run visual diff
run: yarn ts-node scripts/sitemap-visual-diff.ts --preview-url ${{ needs.deploy.outputs.preview_url }} --summary-file visual_diffs/results.json --concurrency 4 --paths "/tests/"
+ - name: Generate report and summary
+ run: yarn ts-node scripts/generate-visual-diff-report.ts visual_diffs/results.json visual_diffs/index.html
+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: always()
with:
diff --git a/scripts/generate-visual-diff-report.ts b/scripts/generate-visual-diff-report.ts
new file mode 100644
index 000000000..24829de9d
--- /dev/null
+++ b/scripts/generate-visual-diff-report.ts
@@ -0,0 +1,150 @@
+#!/usr/bin/env ts-node
+/* ============================================================================
+ * Copyright (c) Palo Alto Networks
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ * ========================================================================== */
+
+import fs from "fs";
+import path from "path";
+
+interface Summary {
+ total: number;
+ matches: number;
+ mismatches: number;
+ skipped: number;
+}
+
+interface Page {
+ path: string;
+ status: string;
+}
+
+function clean(p: string): string {
+ return p.replace(/^\//, "").replace(/\/$/, "") || "root";
+}
+
+function generateHTML(results: { summary: Summary; pages: Page[] }): string {
+ const pages = results.pages.map((p) => ({ ...p, clean: clean(p.path) }));
+ const listItems = pages
+ .map((p, i) => {
+ return `
${p.path}`;
+ })
+ .join("\n");
+ return `
+
+
+
+Visual Diff Report
+
+
+
+
+
+
Select a page
+
+
Prod
![]()
+
Preview
![]()
+
Diff
![]()
+
+
+
+
+`;
+}
+
+function encodeImage(filePath: string): string {
+ try {
+ const data = fs.readFileSync(filePath);
+ return `data:image/png;base64,${data.toString("base64")}`;
+ } catch {
+ return "";
+ }
+}
+
+function generateMarkdown(
+ results: { summary: Summary; pages: Page[] },
+ baseDir: string
+): string {
+ const lines: string[] = [];
+ lines.push("### Visual Diff Summary\n");
+ lines.push(
+ `Total: ${results.summary.total}, Matches: ${results.summary.matches}, Diffs: ${results.summary.mismatches}, Skipped: ${results.summary.skipped}\n`
+ );
+ if (results.pages.length) {
+ lines.push("| Page | Status | Prod | Preview | Diff |");
+ lines.push("| --- | --- | --- | --- | --- |");
+ for (const p of results.pages) {
+ const cleanPath = clean(p.path);
+ if (p.status === "diff") {
+ const prod = encodeImage(
+ path.join(baseDir, "prod", `${cleanPath}.png`)
+ );
+ const prev = encodeImage(
+ path.join(baseDir, "preview", `${cleanPath}.png`)
+ );
+ const diff = encodeImage(
+ path.join(baseDir, "diff", `${cleanPath}.png`)
+ );
+ lines.push(
+ `| ${p.path} | diff |  |  |  |`
+ );
+ } else {
+ const color = p.status === "match" ? "#090" : "#999";
+ lines.push(
+ `| ${p.path} | ${p.status} | | | |`
+ );
+ }
+ }
+ }
+ lines.push("");
+ lines.push("[Download full report](./visual_diffs/index.html)\n");
+ return lines.join("\n");
+}
+
+function main() {
+ const input = process.argv[2] || path.join("visual_diffs", "results.json");
+ const output = process.argv[3] || path.join("visual_diffs", "index.html");
+ const results = JSON.parse(fs.readFileSync(input, "utf8"));
+ const html = generateHTML(results);
+ fs.writeFileSync(output, html);
+
+ const summaryPath = process.env.GITHUB_STEP_SUMMARY;
+ if (summaryPath) {
+ const markdown = generateMarkdown(results, path.dirname(output));
+ fs.appendFileSync(summaryPath, `${markdown}\n`);
+ }
+}
+
+main();
diff --git a/scripts/sitemap-visual-diff.ts b/scripts/sitemap-visual-diff.ts
index 9a60ae153..67a403410 100644
--- a/scripts/sitemap-visual-diff.ts
+++ b/scripts/sitemap-visual-diff.ts
@@ -31,7 +31,7 @@ function parseArgs(): Options {
const opts: Options = {
previewUrl: "",
outputDir: "visual_diffs",
- tolerance: 0,
+ tolerance: 0.3,
width: 1280,
viewHeight: 1024,
concurrency: 4,
@@ -100,16 +100,31 @@ function parseUrlsFromSitemap(xml: string): string[] {
async function screenshotFullPage(page: any, url: string, outputPath: string) {
await page.goto(url, { waitUntil: "networkidle" });
await page.evaluate(() => {
- document.querySelectorAll("details").forEach((d) => {
- const summary = d.querySelector("summary");
- if (!d.open && summary) (summary as HTMLElement).click();
- (d as HTMLDetailsElement).open = true;
- d.setAttribute("data-collapsed", "false");
+ document.querySelectorAll("div.container details").forEach((el) => {
+ const detail = el as HTMLDetailsElement;
+ const summary = detail.querySelector("summary");
+ if (!detail.open && summary) (summary as HTMLElement).click();
+ detail.open = true;
+ detail.setAttribute("data-collapsed", "false");
});
});
await page.waitForTimeout(500);
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
- await page.screenshot({ path: outputPath, fullPage: true });
+ const container = await page.$("div.container");
+ if (container) {
+ await container.screenshot({ path: outputPath });
+ } else {
+ await page.screenshot({ path: outputPath, fullPage: true });
+ }
+}
+
+function padImage(img: PNG, width: number, height: number): PNG {
+ if (img.width === width && img.height === height) {
+ return img;
+ }
+ const out = new PNG({ width, height });
+ PNG.bitblt(img, out, 0, 0, img.width, img.height, 0, 0);
+ return out;
}
function compareImages(
@@ -119,11 +134,16 @@ function compareImages(
tolerance: number,
diffAlpha: number
): boolean {
- const prod = PNG.sync.read(fs.readFileSync(prodPath));
- const prev = PNG.sync.read(fs.readFileSync(prevPath));
+ let prod = PNG.sync.read(fs.readFileSync(prodPath));
+ let prev = PNG.sync.read(fs.readFileSync(prevPath));
if (prod.width !== prev.width || prod.height !== prev.height) {
- console.warn(`Size mismatch for ${prevPath}`);
- return false;
+ const width = Math.max(prod.width, prev.width);
+ const height = Math.max(prod.height, prev.height);
+ console.warn(
+ `Size mismatch for ${prevPath}, padding images to ${width}x${height}`
+ );
+ prod = padImage(prod, width, height);
+ prev = padImage(prev, width, height);
}
const diff = new PNG({ width: prod.width, height: prod.height });
const numDiff = pixelmatch(