UNPKG

@augment-vir/test

Version:

A universal testing suite that works with Mocha style test runners _and_ Node.js's built-in test runner.

160 lines (159 loc) 6.86 kB
import { assert } from '@augment-vir/assert'; import { addSuffix, log } from '@augment-vir/common'; import { writeFileAndDir } from '@augment-vir/node'; import { expect } from '@playwright/test'; import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { relative } from 'node:path'; import pixelmatch from 'pixelmatch'; import { PNG } from 'pngjs'; import sharp from 'sharp'; import { assertTestContext, assertWrapTestContext, TestEnv, } from '../augments/universal-testing-suite/universal-test-context.js'; /** This is used for type extraction because Playwright does not export the types we need. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars function extractScreenshotMethod() { assert.never('this function should not be executed, it is only used for types'); // eslint-disable-next-line @typescript-eslint/unbound-method return expect({}).toHaveScreenshot; } /** * Default internal options for {@link LocatorScreenshotOptions}, used in {@link expectScreenshot} * * @category Internal */ export const defaultScreenshotOptions = { animations: 'disabled', caret: 'hide', timeout: 10_000, scale: 'css', threshold: 0.1, maxDiffPixelRatio: 0.08, }; async function padImage(image, { height, width }) { return await sharp({ create: { width, height, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, }) /** Top-left align. */ .composite([{ input: image, left: 0, top: 0 }]) .png() .toBuffer(); } /** Pads both images to the same canvas (max width/height) without scaling. */ async function padToSameCanvas(aBuf, bBuf) { const [aMeta, bMeta,] = await Promise.all([ sharp(aBuf).metadata(), sharp(bBuf).metadata(), ]); if (!aMeta.width || !aMeta.height || !bMeta.width || !bMeta.height) { throw new Error('Unable to read image dimensions.'); } const dimensions = { width: Math.max(aMeta.width, bMeta.width), height: Math.max(aMeta.height, bMeta.height), }; const [aPadded, bPadded,] = await Promise.all([ padImage(aBuf, dimensions), padImage(bBuf, dimensions), ]); const aPng = PNG.sync.read(aPadded); const bPng = PNG.sync.read(bPadded); return { aPng, bPng, dimensions, }; } async function takeScreenshotBuffer(testContext, options = {}) { if (options.locator) { /** The locator expectation has different options than the page expectation. */ return await options.locator.screenshot({ ...defaultScreenshotOptions, ...options, }); } else { assertTestContext(testContext, TestEnv.Playwright); return await testContext.page.screenshot({ ...defaultScreenshotOptions, ...options, }); } } /** @returns The path that the screenshot was saved to. */ async function saveScreenshotBuffer(testContext, screenshotBuffer, screenshotBaseName) { assertTestContext(testContext, TestEnv.Playwright); const screenshotPath = getScreenshotPath(testContext, screenshotBaseName); await writeFileAndDir(screenshotPath, screenshotBuffer); return screenshotPath; } /** * Get the path to save the given screenshot file name to. * * @category Internal */ export function getScreenshotPath(testContext, screenshotBaseName) { assertTestContext(testContext, TestEnv.Playwright); const screenshotFileName = addSuffix({ value: screenshotBaseName, suffix: '.png' }); return testContext.testInfo.snapshotPath(screenshotFileName); } /** * Take and immediately save a screenshot. * * @category Internal * @returns The path that the screenshot was saved to. */ export async function takeScreenshot(testContext, options) { return await saveScreenshotBuffer(testContext, await takeScreenshotBuffer(testContext, options), options.screenshotBaseName); } /** * Similar to Playwright's `expect().toHaveScreenshot` but allows images to have different sizes and * has default comparison threshold options that are wide enough to allow testing between different * operating systems without failure (usually). * * @category Internal */ export async function expectScreenshot(testContext, options) { assertTestContext(testContext, TestEnv.Playwright); const currentScreenshotBuffer = await takeScreenshotBuffer(testContext, options); const screenshotFilePath = getScreenshotPath(testContext, options.screenshotBaseName); async function writeNewScreenshot() { log.mutate(`Updated screenshot: ${relative(process.cwd(), screenshotFilePath)}`); await saveScreenshotBuffer(testContext, currentScreenshotBuffer, options.screenshotBaseName); } async function writeExpectationScreenshot(contents, fileName) { const filePath = assertWrapTestContext(testContext, TestEnv.Playwright).testInfo.outputPath(addSuffix({ value: fileName, suffix: '.png' })); await writeFileAndDir(filePath, contents); } if (existsSync(screenshotFilePath)) { if (testContext.testInfo.config.updateSnapshots === 'changed') { await writeNewScreenshot(); } } else { if (testContext.testInfo.config.updateSnapshots !== 'none') { await writeNewScreenshot(); } await writeExpectationScreenshot(currentScreenshotBuffer, 'actual'); throw new Error(`Baseline screenshot not found: ${screenshotFilePath}. Re-run with --update-snapshots to create it.`); } const baseScreenshotBuffer = await readFile(screenshotFilePath); const { aPng: baseScreenshotPng, bPng: currentScreenshotPng, dimensions, } = await padToSameCanvas(baseScreenshotBuffer, currentScreenshotBuffer); const diffPng = new PNG(dimensions); const diffPixelCount = pixelmatch(baseScreenshotPng.data, currentScreenshotPng.data, diffPng.data, dimensions.width, dimensions.height, { threshold: defaultScreenshotOptions.threshold, }); const totalPixels = dimensions.width * dimensions.height; const diffRatio = diffPixelCount / totalPixels; const ratioOk = diffRatio <= defaultScreenshotOptions.maxDiffPixelRatio; if (!ratioOk) { if (process.env.CI) { await writeNewScreenshot(); } else { await writeExpectationScreenshot(PNG.sync.write(baseScreenshotPng), 'expected'); await writeExpectationScreenshot(PNG.sync.write(currentScreenshotPng), 'actual'); await writeExpectationScreenshot(PNG.sync.write(diffPng), 'diff'); throw new Error(`Screenshot mismatch: ${screenshotFilePath}\n diff=${diffPixelCount}px (${(diffRatio * 100).toFixed(3)}%) (limit: ${(defaultScreenshotOptions.maxDiffPixelRatio * 100).toFixed(3)}%). Run with --update-snapshots to update screenshot.`); } } }