evOo-local/packages/testkit/src/cli/generate-playwright-report-dashboard.ts
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

249 lines
8.7 KiB
TypeScript

import { createHash } from "node:crypto";
import type { Dirent } from "node:fs";
import { copyFile, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import {
classifyPlaywrightScreenshot,
compareScreenshotsWithBaseline,
createScreenshotManifest,
type PlaywrightScreenshot,
renderPlaywrightDashboard,
renderScreenshotGallery,
screenshotTitleFromFileName,
shouldPublishStableMainBaseline,
updatePlaywrightHistory,
validatePlaywrightScreenshotBudget,
} from "../playwright-report-dashboard.js";
import { MAX_PNG_SCREENSHOT_BYTES, validatePngScreenshot } from "../png-validation.js";
const [historyPath, dashboardPath, testResultsPath, galleryPath, baselineManifestPath] =
process.argv.slice(2);
const MAX_ARTIFACT_ENTRIES = 2_000;
const MAX_ARTIFACT_DEPTH = 12;
if (!historyPath || !dashboardPath || !testResultsPath || !galleryPath) {
throw new Error(
"Usage: generate-playwright-report-dashboard <history-path> <dashboard-path> <test-results-path> <gallery-path>",
);
}
const collectedScreenshots = await collectScreenshots(testResultsPath, galleryPath);
const comparison = compareScreenshotsWithBaseline(
collectedScreenshots,
await readOptionalJson(baselineManifestPath),
);
const screenshots = comparison.screenshots;
const createdAt = new Date().toISOString();
const dashboardUrl = getRequiredEnvironmentVariable("PLAYWRIGHT_DASHBOARD_URL");
const reportUrl = getOptionalHttpsEnvironmentVariable("PLAYWRIGHT_REPORT_URL");
const screenshotsUrl = getRequiredEnvironmentVariable("PLAYWRIGHT_SCREENSHOTS_URL");
const result = getRequiredEnvironmentVariable("PLAYWRIGHT_RESULT");
const runUrl = getRequiredEnvironmentVariable("PLAYWRIGHT_RUN_URL");
const sha = getRequiredEnvironmentVariable("PLAYWRIGHT_SHA");
const pullRequestNumber = getOptionalNumber("PLAYWRIGHT_PR_NUMBER");
const pullRequestUrl = getOptionalHttpsEnvironmentVariable("PLAYWRIGHT_PR_URL");
const existingHistory = await readHistory(historyPath);
const runIdentity = {
attempt: getRequiredNumber("PLAYWRIGHT_RUN_ATTEMPT"),
id: getRequiredEnvironmentVariable("PLAYWRIGHT_RUN_ID"),
};
const history = updatePlaywrightHistory(existingHistory, {
...runIdentity,
branch: getRequiredEnvironmentVariable("PLAYWRIGHT_BRANCH"),
createdAt,
event: getRequiredEnvironmentVariable("PLAYWRIGHT_EVENT"),
pullRequestNumber,
pullRequestUrl,
reportUrl,
result,
runNumber: getRequiredNumber("PLAYWRIGHT_RUN_NUMBER"),
runUrl,
screenshotCount: screenshots.length,
screenshotsUrl,
sha,
});
await mkdir(path.dirname(historyPath), { recursive: true });
await mkdir(path.dirname(dashboardPath), { recursive: true });
await mkdir(galleryPath, { recursive: true });
await writeFile(historyPath, `${JSON.stringify(history, null, 2)}\n`);
await writeFile(dashboardPath, renderPlaywrightDashboard(history));
await writeFile(
path.join(galleryPath, "manifest.json"),
`${JSON.stringify(createScreenshotManifest(screenshots, runIdentity), null, 2)}\n`,
);
await writeFile(
path.join(galleryPath, "publish-stable-baseline"),
`${shouldPublishStableMainBaseline({
candidate: runIdentity,
existingBaseline: await readOptionalJson(process.env.PLAYWRIGHT_EXISTING_STABLE_BASELINE_PATH),
history,
})}\n`,
);
await writeFile(
path.join(galleryPath, "index.html"),
renderScreenshotGallery({
baselineAvailable: comparison.baselineAvailable,
createdAt,
dashboardUrl,
pullRequestNumber,
pullRequestUrl,
reportUrl,
result,
runUrl,
screenshots,
screenshotsUrl,
sha,
}),
);
await writeFile(
path.join(galleryPath, "review.json"),
`${JSON.stringify({ screenshots, screenshotsUrl }, null, 2)}\n`,
);
console.log(
`Playwright dashboard generated with ${history.length} runs and ${screenshots.length} screenshots.`,
);
async function collectScreenshots(
resultsPath: string,
outputPath: string,
): Promise<Array<Omit<PlaywrightScreenshot, "comparison">>> {
const files = (await findPngFiles(resultsPath))
.filter((file) => classifyPlaywrightScreenshot(file) !== undefined)
.sort((left, right) => path.basename(left).localeCompare(path.basename(right)));
validatePlaywrightScreenshotBudget(files.length, 0);
const imagePath = path.join(outputPath, "images");
await mkdir(imagePath, { recursive: true });
const screenshots: Array<Omit<PlaywrightScreenshot, "comparison">> = [];
let totalScreenshotBytes = 0;
for (const [index, file] of files.entries()) {
const info = await stat(file);
if (!info.isFile() || info.size <= 0 || info.size > MAX_PNG_SCREENSHOT_BYTES) {
throw new Error(`Invalid screenshot size for ${file}`);
}
totalScreenshotBytes += info.size;
validatePlaywrightScreenshotBudget(files.length, totalScreenshotBytes);
const screenshot = await readFile(file);
validatePngScreenshot(screenshot, file);
const captureType = classifyPlaywrightScreenshot(file);
if (captureType === undefined) continue;
const source = path.relative(resultsPath, file);
const baseName = sanitizeFileName(path.basename(file));
const fileName = `${String(index + 1).padStart(3, "0")}-${baseName}`;
await copyFile(file, path.join(imagePath, fileName));
screenshots.push({
captureType,
fileName: `images/${fileName}`,
hash: createHash("sha256").update(screenshot).digest("hex"),
source,
testId: testIdFromSource(source),
title: screenshotTitleFromFileName(baseName),
});
}
return screenshots;
}
async function findPngFiles(
directory: string,
depth = 0,
budget = { entries: 0 },
): Promise<string[]> {
if (depth > MAX_ARTIFACT_DEPTH) {
throw new Error(`Playwright artifact exceeds maximum directory depth of ${MAX_ARTIFACT_DEPTH}`);
}
let entries: Dirent[];
try {
entries = await readdir(directory, { withFileTypes: true });
} catch (error) {
if (isNodeError(error) && error.code === "ENOENT") return [];
throw error;
}
budget.entries += entries.length;
if (budget.entries > MAX_ARTIFACT_ENTRIES) {
throw new Error(`Playwright artifact exceeds maximum entry count of ${MAX_ARTIFACT_ENTRIES}`);
}
const files = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory() && entry.name === "attachments") return [];
if (entry.isDirectory()) return findPngFiles(entryPath, depth + 1, budget);
return entry.isFile() && entry.name.toLowerCase().endsWith(".png") ? [entryPath] : [];
}),
);
return files.flat();
}
function sanitizeFileName(fileName: string): string {
return fileName.replaceAll(/[^a-zA-Z0-9._-]/g, "-");
}
function testIdFromSource(source: string): string {
const directory = path.dirname(source);
return directory === "." ? "Unknown test" : directory.split(path.sep).join(" / ");
}
function getRequiredEnvironmentVariable(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return value;
}
function getOptionalHttpsEnvironmentVariable(name: string): string | undefined {
const value = process.env[name]?.trim();
if (!value) return undefined;
if (!URL.canParse(value) || new URL(value).protocol !== "https:") {
throw new Error(`${name} must use HTTPS`);
}
return value;
}
function getRequiredNumber(name: string): number {
const value = Number(getRequiredEnvironmentVariable(name));
if (!Number.isFinite(value)) throw new Error(`${name} must be a number`);
return value;
}
function getOptionalNumber(name: string): number | undefined {
const value = process.env[name]?.trim();
if (!value) return undefined;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0)
throw new Error(`${name} must be a positive integer`);
return parsed;
}
async function readHistory(filePath: string): Promise<unknown> {
try {
return JSON.parse(await readFile(filePath, "utf8"));
} catch (error) {
if (isNodeError(error) && error.code === "ENOENT") return [];
throw error;
}
}
async function readOptionalJson(filePath: string | undefined): Promise<unknown> {
if (!filePath) return undefined;
try {
const info = await stat(filePath);
if (!info.isFile() || info.size > 1_000_000) {
console.warn("Ignoring an invalid or oversized Playwright baseline manifest.");
return undefined;
}
return JSON.parse(await readFile(filePath, "utf8"));
} catch (error) {
if (isNodeError(error) && error.code === "ENOENT") return undefined;
if (error instanceof SyntaxError) {
console.warn("Ignoring an invalid Playwright baseline manifest.");
return null;
}
throw error;
}
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error;
}