ant-design/scripts/visual-regression/build.ts
2023-12-14 17:02:31 +08:00

347 lines
11 KiB
TypeScript

/* eslint-disable compat/compat */
/* eslint-disable no-console, no-await-in-loop, import/no-extraneous-dependencies, lodash/import-scope, no-restricted-syntax */
import path from 'path';
import fs from 'fs';
import os from 'os';
import { Readable } from 'stream';
import { finished } from 'stream/promises';
import { remark } from 'remark';
import remarkHtml from 'remark-html';
import remarkGfm from 'remark-gfm';
import minimist from 'minimist';
import tar from 'tar';
import fse from 'fs-extra';
import chalk from 'chalk';
import _ from 'lodash';
import pixelmatch from 'pixelmatch';
import { PNG } from 'pngjs';
import sharp from 'sharp';
import { assert } from 'console';
const ALI_OSS_BUCKET = 'antd-visual-diff';
const compareScreenshots = async (
baseImgPath: string,
currentImgPath: string,
diffImagePath: string,
): Promise<number> => {
const baseImgBuf = await sharp(baseImgPath).toBuffer();
const currentImgBuf = await sharp(currentImgPath).toBuffer();
const basePng = PNG.sync.read(baseImgBuf);
const targetWidth = basePng.width;
const targetHeight = basePng.height;
const comparePng = PNG.sync.read(
await sharp(currentImgBuf)
.resize({
width: targetWidth,
height: targetHeight,
fit: sharp.fit.contain,
background: { r: 255, g: 255, b: 255, alpha: 0 },
})
.png()
.toBuffer(),
);
const diffPng = new PNG({ width: targetWidth, height: targetHeight });
const mismatchedPixels = pixelmatch(
basePng.data,
comparePng.data,
diffPng.data,
targetWidth,
targetHeight,
{ threshold: 0.1, diffMask: false },
);
// if mismatched then write diff image
if (mismatchedPixels) {
diffPng.pack().pipe(fs.createWriteStream(diffImagePath));
}
return (mismatchedPixels / (targetWidth * targetHeight)) * 100;
};
const readPngs = (dir: string) => fs.readdirSync(dir).filter((n) => n.endsWith('.png'));
const prettyList = (list: string[]) => list.map((i) => ` * ${i}`).join('\n');
const ossDomain = `https://${ALI_OSS_BUCKET}.oss-cn-shanghai.aliyuncs.com`;
async function downloadFile(url: string, destPath: string) {
const response = await fetch(url);
if (!response.ok || response.status !== 200) {
throw new Error(`Download file failed: ${new URL(url).pathname}`);
}
// @ts-ignore
const body = Readable.fromWeb(response.body);
await finished(body.pipe(fs.createWriteStream(destPath)));
}
async function getBranchLatestRef(branchName: string) {
const baseImageRefUrl = `${ossDomain}/${branchName}/visual-regression-ref.txt`;
// get content from baseImageRefText
const res = await fetch(baseImageRefUrl);
const text = await res.text();
const ref = text.trim();
return ref;
}
async function downloadBaseSnapshots(ref: string, targetDir: string) {
// download imageSnapshotsUrl
const imageSnapshotsUrl = `${ossDomain}/${ref}/imageSnapshots.tar.gz`;
const targzPath = path.resolve(os.tmpdir(), `./${path.basename(targetDir)}.tar.gz`);
await downloadFile(imageSnapshotsUrl, targzPath);
// untar
return tar.x({
// remove top-level dir
strip: 1,
C: targetDir,
file: targzPath,
});
}
interface IBadCase {
type: 'removed' | 'changed';
filename: string;
}
function md2Html(md: string) {
return remark().use(remarkGfm).use(remarkHtml).processSync(md).toString();
}
function parseArgs() {
// parse args from -- --pr-id=123 --base_ref=feature
const argv = minimist(process.argv.slice(2));
const prId = argv['pr-id'];
assert(prId, 'Missing --pr-id');
const baseRef = argv['base-ref'];
assert(baseRef, 'Missing --base-ref');
return {
prId,
baseRef,
};
}
function generateReport(
badCases: IBadCase[],
targetBranch: string,
targetRef: string,
prId: string,
): [string, string] {
const publicPath = `${ossDomain}/pr-${prId}`;
const commonHeader = `
## Visual Regression Report for PR #${prId}
> **Target branch:** ${targetBranch} (${targetRef})
`.trim();
if (badCases.length === 0) {
const mdStr = [
commonHeader,
'------------------------',
'Congrats! No visual-regression diff found',
].join('\n');
return [mdStr, md2Html(mdStr)];
}
const htmlReportLink = `${publicPath}/visualRegressionReport/report.html`;
const addonFullReportDesc = `\n\nToo many visual-regression diffs found, please check <a href="${htmlReportLink}" target="_blank">Full Report</a> for details`;
// github action pr comment has limit of 65536 4-byte unicode characters
const limit = 65536 - addonFullReportDesc.length;
let reportMdStr = `
${commonHeader}
> <a href="${htmlReportLink}" target="_blank">View Full Report</a> \n
------------------------
| image name | expected | actual | diff |
| --- | --- | --- | --- |
`.trim();
reportMdStr += '\n';
let fullVersionMd = reportMdStr;
let addonFullReportDescAdded = false;
for (const badCase of badCases) {
const { filename, type } = badCase;
let lineReportMdStr = '';
if (type === 'changed') {
lineReportMdStr += '| ';
lineReportMdStr += [
badCase.filename,
`![${targetBranch}: ${targetRef}](${publicPath}/visualRegressionReport/images/base/${filename})`,
`![current: pr-${prId}](${publicPath}/visualRegressionReport/images/current/${filename})`,
`![diff](${publicPath}/visualRegressionReport/images/diff/${filename})`,
].join(' | ');
lineReportMdStr += ' |\n';
} else if (type === 'removed') {
lineReportMdStr += '| ';
lineReportMdStr += [
badCase.filename,
`![${targetBranch}: ${targetRef}](${publicPath}/visualRegressionReport/images/base/${filename})`,
`⛔️⛔️⛔️ Missing ⛔️⛔️⛔️`,
`🚨🚨🚨 Removed 🚨🚨🚨`,
].join(' | ');
lineReportMdStr += ' |\n';
}
if (lineReportMdStr) {
if (reportMdStr.length + lineReportMdStr.length < limit) {
reportMdStr += lineReportMdStr;
} else if (!addonFullReportDescAdded) {
reportMdStr += addonFullReportDesc;
addonFullReportDescAdded = true;
}
fullVersionMd += lineReportMdStr;
}
}
// convert fullVersionMd to html
return [reportMdStr, md2Html(fullVersionMd)];
}
async function boot() {
const { prId, baseRef: targetBranch = 'master' } = parseArgs();
console.log(
chalk.green(
'Preparing image snapshots from latest `%s` branch for pr %s\n',
targetBranch,
prId,
),
);
const baseImgSourceDir = path.resolve(__dirname, `../../imageSnapshots-${targetBranch}`);
await fse.ensureDir(baseImgSourceDir);
const targetRef = await getBranchLatestRef(targetBranch);
assert(targetRef, `Missing ref from ${targetBranch}`);
await downloadBaseSnapshots(targetRef, baseImgSourceDir);
const currentImgSourceDir = path.resolve(__dirname, '../../imageSnapshots');
const reportDir = path.resolve(__dirname, '../../visualRegressionReport');
// save diff images(x3) to reportDir
const diffImgReportDir = path.resolve(reportDir, './images/diff');
const baseImgReportDir = path.resolve(reportDir, './images/base');
const currentImgReportDir = path.resolve(reportDir, './images/current');
await fse.ensureDir(diffImgReportDir);
await fse.ensureDir(baseImgReportDir);
await fse.ensureDir(currentImgReportDir);
console.log(chalk.blue('⛳ Checking image snapshots with branch %s'), targetBranch);
console.log('\n');
const baseImgFileList = readPngs(baseImgSourceDir);
const currentImgFileList = readPngs(currentImgSourceDir);
const deletedImgs = _.difference(baseImgFileList, currentImgFileList);
if (deletedImgs.length) {
console.log(
chalk.red('⛔️ Missing images compare to %s:\n%s'),
targetBranch,
prettyList(deletedImgs),
);
console.log('\n');
}
// ignore new images
const newImgs = _.difference(currentImgFileList, baseImgFileList);
if (newImgs.length) {
console.log(chalk.green('🆕 Added images:\n'), prettyList(newImgs));
console.log('\n');
}
const badCases: IBadCase[] = [];
// compare cssinjs and css-var png from pr
// to the same cssinjs png in `master` branch
const cssInJsImgNames = baseImgFileList
.filter((i) => !i.endsWith('.css-var.png'))
.map((n) => path.basename(n, path.extname(n)));
for (const basename of cssInJsImgNames) {
for (const extname of ['.png', '.css-var.png']) {
// baseImg always use cssinjs png
const baseImgName = `${basename}.png`;
const baseImgPath = path.join(baseImgSourceDir, baseImgName);
// currentImg use cssinjs png or css-var png
const compareImgName = basename + extname;
const currentImgPath = path.join(currentImgSourceDir, compareImgName);
const diffImgPath = path.join(diffImgReportDir, compareImgName);
const currentImgExists = await fse.exists(currentImgPath);
if (!currentImgExists) {
console.log(chalk.red(`⛔️ Missing image: ${compareImgName}\n`));
badCases.push({
type: 'removed',
filename: compareImgName,
});
await fse.copy(baseImgPath, path.join(baseImgReportDir, compareImgName));
continue;
}
const mismatchedPxPercent = await compareScreenshots(
baseImgPath,
currentImgPath,
diffImgPath,
);
if (mismatchedPxPercent > 0) {
console.log(
'Mismatched pixels for:',
chalk.yellow(compareImgName),
`${mismatchedPxPercent.toFixed(2)}%\n`,
);
// copy compare imgs(x2) to report dir
await fse.copy(baseImgPath, path.join(baseImgReportDir, compareImgName));
await fse.copy(currentImgPath, path.join(currentImgReportDir, compareImgName));
badCases.push({
type: 'changed',
filename: compareImgName,
});
} else {
console.log('Passed for: %s\n', chalk.green(compareImgName));
}
}
}
if (badCases.length) {
console.log(chalk.red('⛔️ Failed cases:\n'), prettyList(badCases.map((i) => i.filename)));
console.log('\n');
}
const jsonl = badCases.map((i) => JSON.stringify(i)).join('\n');
// write jsonl and markdown report to diffImgDir
await fse.writeFile(path.join(reportDir, './report.jsonl'), jsonl);
const [reportMdStr, reportHtmlStr] = generateReport(badCases, targetBranch, targetRef, prId);
await fse.writeFile(path.join(reportDir, './report.md'), reportMdStr);
const htmlTemplate = await fse.readFile(path.join(__dirname, './report-template.html'), 'utf8');
await fse.writeFile(
path.join(reportDir, './report.html'),
htmlTemplate.replace('{{reportContent}}', reportHtmlStr),
'utf-8',
);
await tar.c(
{
gzip: true,
// ignore top-level dir(e.g. visualRegressionReport) and zip all files in it
cwd: reportDir,
file: `${path.basename(reportDir)}.tar.gz`,
},
await fse.readdir(reportDir),
);
}
boot();