UNPKG

creevey

Version:

Cross-browser screenshot testing tool for Storybook with fancy UI Runner

265 lines (223 loc) 8.76 kB
import path from 'path'; import { Stats } from 'fs'; import assert from 'assert'; import { PNG } from 'pngjs'; import type { ODiffOptions } from 'odiff-bin'; import type { PixelmatchOptions } from 'pixelmatch'; import { mkdir, readdir, readFile, stat, writeFile } from 'fs/promises'; import { Images } from '../types'; export interface ImageContext { attachments: string[]; testFullPath: string[]; images: Partial<Record<string, Images>>; } interface ImagePaths { imageName: string; actualImageName: string; expectImageName: string; diffImageName: string; expectImageDir: string; reportImageDir: string; } async function getStat(filePath: string): Promise<Stats | null> { try { return await stat(filePath); } catch (error) { if (typeof error == 'object' && error && (error as { code?: unknown }).code === 'ENOENT') { return null; } throw error; } } async function getLastImageNumber(imageDir: string, imageName: string): Promise<number> { const actualImagesRegexp = new RegExp(`${imageName}-actual-(\\d+)\\.png`); try { return ( (await readdir(imageDir)) .map((filename) => filename.replace(actualImagesRegexp, '$1')) .map(Number) .filter((x) => !isNaN(x)) .sort((a, b) => b - a)[0] ?? 0 ); } catch { return 0; } } async function readExpected(expectImageDir: string, imageName: string): Promise<Buffer> { const expected = await readFile(path.join(expectImageDir, `${imageName}.png`)); return expected; } async function saveImages(imageDir: string, images: { name: string; data: Buffer }[]): Promise<string[]> { const files: string[] = []; await mkdir(imageDir, { recursive: true }); for (const { name, data } of images) { const filePath = path.join(imageDir, name); await writeFile(filePath, data); files.push(filePath); } return files; } async function getImagePaths( config: { screenDir: string; reportDir: string }, testFullPath: string[], assertImageName?: string, ): Promise<ImagePaths> { const testPath = [...testFullPath]; const imageName = assertImageName ?? testPath.pop(); assert(typeof imageName === 'string', `Can't get image name from empty test scope`); const expectImageDir = path.join(config.screenDir, ...testPath); const reportImageDir = path.join(config.reportDir, ...testPath); const imageNumber = (await getLastImageNumber(reportImageDir, imageName)) + 1; const actualImageName = `${imageName}-actual-${imageNumber}.png`; const expectImageName = `${imageName}-expect-${imageNumber}.png`; const diffImageName = `${imageName}-diff-${imageNumber}.png`; return { imageName, actualImageName, expectImageName, diffImageName, expectImageDir, reportImageDir }; } async function getExpected( ctx: ImageContext, { imageName, actualImageName, expectImageName, diffImageName, expectImageDir, reportImageDir }: ImagePaths, ): Promise<{ expected: Buffer | null; onCompare: (actual: Buffer, expect?: Buffer, diff?: Buffer) => Promise<void>; }> { const onCompare = async (actual: Buffer, expect?: Buffer, diff?: Buffer): Promise<void> => { const imagesMeta: { name: string; data: Buffer }[] = []; const image = (ctx.images[imageName] = ctx.images[imageName] ?? { actual: actualImageName }); imagesMeta.push({ name: image.actual, data: actual }); if (diff && expect) { image.expect = expectImageName; image.diff = diffImageName; imagesMeta.push({ name: image.expect, data: expect }); imagesMeta.push({ name: image.diff, data: diff }); } ctx.attachments = await saveImages(reportImageDir, imagesMeta); }; const expectImageStat = await getStat(path.join(expectImageDir, `${imageName}.png`)); if (!expectImageStat) return { expected: null, onCompare }; const expected = await readExpected(expectImageDir, imageName); return { expected, onCompare }; } async function getOdiffExpected( ctx: ImageContext, actual: Buffer, { imageName, actualImageName, expectImageName, diffImageName, expectImageDir, reportImageDir }: ImagePaths, ): Promise<{ actual: string; expect: string; diff: string }> { const expected = await readExpected(expectImageDir, imageName); const image = (ctx.images[imageName] = ctx.images[imageName] ?? { actual: actualImageName }); image.expect = expectImageName; image.diff = diffImageName; const imagesMeta = [ { name: image.actual, data: actual }, { name: expectImageName, data: expected }, ]; ctx.attachments = await saveImages(reportImageDir, imagesMeta); return { actual: path.join(reportImageDir, actualImageName), expect: path.join(reportImageDir, expectImageName), diff: path.join(reportImageDir, diffImageName), }; } function normalizeImageSize(image: PNG, width: number, height: number): Buffer { const normalizedImage = Buffer.alloc(4 * width * height); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const i = (y * width + x) * 4; if (x < image.width && y < image.height) { const j = (y * image.width + x) * 4; normalizedImage[i + 0] = image.data[j + 0]; normalizedImage[i + 1] = image.data[j + 1]; normalizedImage[i + 2] = image.data[j + 2]; normalizedImage[i + 3] = image.data[j + 3]; } else { normalizedImage[i + 0] = 0; normalizedImage[i + 1] = 0; normalizedImage[i + 2] = 0; normalizedImage[i + 3] = 0; } } } return normalizedImage; } function hasDiffPixels(diff: Buffer): boolean { for (let i = 0; i < diff.length; i += 4) { if (diff[i + 0] == 255 && diff[i + 1] == 0 && diff[i + 2] == 0 && diff[i + 3] == 255) return true; } return false; } function compareImages( expect: Buffer, actual: Buffer, pixelmatch: typeof import('pixelmatch'), diffOptions: PixelmatchOptions, ): { isEqual: boolean; diff: Buffer } { const expectImage = PNG.sync.read(expect); const actualImage = PNG.sync.read(actual); const width = Math.max(actualImage.width, expectImage.width); const height = Math.max(actualImage.height, expectImage.height); const diffImage = new PNG({ width, height }); let actualImageData = actualImage.data; if (actualImage.width < width || actualImage.height < height) { actualImageData = normalizeImageSize(actualImage, width, height); } let expectImageData = expectImage.data; if (expectImage.width < width || expectImage.height < height) { expectImageData = normalizeImageSize(expectImage, width, height); } pixelmatch(expectImageData, actualImageData, diffImage.data, width, height, diffOptions); return { isEqual: !hasDiffPixels(diffImage.data), diff: PNG.sync.write(diffImage), }; } export function getPixelmatchAssert( pixelmatch: typeof import('pixelmatch'), ctx: ImageContext, config: { screenDir: string; reportDir: string; diffOptions: PixelmatchOptions; reportOnlyFailedTests?: boolean }, ) { return async function assertImagePixelmatch(actual: Buffer, imageName?: string): Promise<string | undefined> { const { expected, onCompare } = await getExpected(ctx, await getImagePaths(config, ctx.testFullPath, imageName)); if (expected == null) { await onCompare(actual); return imageName ? `Expected image '${imageName}' does not exists` : 'Expected image does not exists'; } if (actual.equals(expected)) { if (!config.reportOnlyFailedTests) { await onCompare(actual); } return; } const { isEqual, diff } = compareImages(expected, actual, pixelmatch, config.diffOptions); if (isEqual) { if (!config.reportOnlyFailedTests) { await onCompare(actual); } return; } await onCompare(actual, expected, diff); return imageName ? `Expected image '${imageName}' to match` : 'Expected image to match'; }; } export function getOdiffAssert( compare: (typeof import('odiff-bin'))['compare'], ctx: ImageContext, config: { screenDir: string; reportDir: string; odiffOptions?: ODiffOptions }, ) { const diffOptions = { ...config.odiffOptions, noFailOnFsErrors: true, }; return async function assertImage(image: Buffer, imageName?: string): Promise<string | undefined> { const { actual, expect, diff } = await getOdiffExpected( ctx, image, await getImagePaths(config, ctx.testFullPath, imageName), ); const result = await compare(actual, expect, diff, diffOptions); if (!result.match) { if (result.reason == 'file-not-exists') { return imageName ? `Expected image '${imageName}' does not exists` : 'Expected image does not exists'; } return imageName ? `Expected image '${imageName}' to match` : 'Expected image to match'; } }; }