UNPKG

@cappa/core

Version:

Core Playwright screenshot functionality for Cappa

627 lines (621 loc) 21.1 kB
//#region rolldown:runtime var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) { key = keys[i]; if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: ((k) => from[k]).bind(null, key), enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod)); //#endregion let node_fs = require("node:fs"); node_fs = __toESM(node_fs); let node_path = require("node:path"); node_path = __toESM(node_path); let __cappa_logger = require("@cappa/logger"); __cappa_logger = __toESM(__cappa_logger); let playwright_core = require("playwright-core"); playwright_core = __toESM(playwright_core); let __blazediff_core = require("@blazediff/core"); __blazediff_core = __toESM(__blazediff_core); let pngjs = require("pngjs"); pngjs = __toESM(pngjs); //#region src/config.ts /** * Type helper to make it easier to use cappa.config.ts, or a function that returns it. The function receives a ConfigEnv object. */ function defineConfig(options) { return options; } //#endregion //#region src/filesystem.ts /** * Class for interacting with the local file system to store the screenshots. */ var ScreenshotFileSystem = class { actualDir; expectedDir; diffDir; constructor(outputDir) { this.actualDir = node_path.default.resolve(outputDir, "actual"); this.expectedDir = node_path.default.resolve(outputDir, "expected"); this.diffDir = node_path.default.resolve(outputDir, "diff"); this.ensureParentDir(this.actualDir); this.ensureParentDir(this.expectedDir); this.ensureParentDir(this.diffDir); } clearActual() { this.removeDir(this.actualDir); } clearDiff() { this.removeDir(this.diffDir); } clearActualAndDiff() { this.clearActual(); this.clearDiff(); } /** * Approve a screenshot based on its actual file path. * @param actualFilePath - The path to the actual screenshot file. * @returns The paths to the actual, expected, and diff screenshot files. */ approveFromActualPath(actualFilePath) { const actualAbsolute = this.resolveActualPath(actualFilePath); const relativePath = node_path.default.relative(this.actualDir, actualAbsolute); if (relativePath.startsWith("..")) throw new Error(`Cannot approve screenshot outside of actual directory: ${actualFilePath}`); return this.approveRelative(relativePath); } /** * Approve a screenshot based on its name. * @param name - The name of the screenshot. Can include '.png' extension. * @returns The paths to the actual, expected, and diff screenshot files. */ approveByName(name) { const relativeWithExtension = name.endsWith(".png") ? name : `${name}.png`; return this.approveRelative(relativeWithExtension); } /** * Approve a screenshot based on a relative path. * @param relativePath - The relative path to the screenshot. * @returns The paths to the actual, expected, and diff screenshot files. */ approveRelative(relativePath) { const actualPath = node_path.default.resolve(this.actualDir, relativePath); const expectedPath = node_path.default.resolve(this.expectedDir, relativePath); const diffPath = node_path.default.resolve(this.diffDir, relativePath); this.ensureParentDir(expectedPath); node_fs.default.copyFileSync(actualPath, expectedPath); if (node_fs.default.existsSync(diffPath)) node_fs.default.unlinkSync(diffPath); return { actualPath, expectedPath, diffPath }; } /** * Ensure the parent directory of a file exists. If not, it creates it. * @param filePath - The path to the file. */ ensureParentDir(filePath) { const dir = node_path.default.dirname(filePath); if (!node_fs.default.existsSync(dir)) node_fs.default.mkdirSync(dir, { recursive: true }); } /** * Remove a directory and all its contents. * @param dir - The path to the directory. */ removeDir(dir) { if (node_fs.default.existsSync(dir)) node_fs.default.rmSync(dir, { recursive: true, force: true }); } /** * Get the actual directory path */ getActualDir() { return this.actualDir; } /** * Get the expected directory path */ getExpectedDir() { return this.expectedDir; } /** * Get the diff directory path */ getDiffDir() { return this.diffDir; } /** * Get the path to an actual screenshot file */ getActualFilePath(filename) { return node_path.default.resolve(this.actualDir, filename); } /** * Get the path to an expected screenshot file */ getExpectedFilePath(filename) { return node_path.default.resolve(this.expectedDir, filename); } /** * Get the path to a diff screenshot file */ getDiffFilePath(filename) { return node_path.default.resolve(this.diffDir, filename); } /** * Write a file to the actual directory */ writeActualFile(filename, data) { const filepath = this.getActualFilePath(filename); this.ensureParentDir(filepath); node_fs.default.writeFileSync(filepath, data); } /** * Write a file to the diff directory */ writeDiffFile(filename, data) { const filepath = this.getDiffFilePath(filename); this.ensureParentDir(filepath); node_fs.default.writeFileSync(filepath, data); } /** * Check if an expected file exists */ hasExpectedFile(filename) { const expectedPath = this.getExpectedFilePath(filename); return node_fs.default.existsSync(expectedPath); } /** * Read an expected file */ readExpectedFile(filename) { const expectedPath = this.getExpectedFilePath(filename); if (!node_fs.default.existsSync(expectedPath)) throw new Error(`Expected image not found: ${expectedPath}`); return node_fs.default.readFileSync(expectedPath); } /** * Resolve the actual path of a screenshot file. * @param filePath - The path to the screenshot file. * @returns The actual path of the screenshot file. */ resolveActualPath(filePath) { if (node_path.default.isAbsolute(filePath)) return filePath; return node_path.default.resolve(this.actualDir, filePath); } }; //#endregion //#region src/compare.ts const compare = (image1, image2, diff, width, height, options) => { return (0, __blazediff_core.default)(image1, image2, diff, width, height, options); }; /** * Compare two PNG images and return the difference * @param image1 - First image (file path or Buffer) * @param image2 - Second image (file path or Buffer) * @param withDiff - Whether to create a diff image * @param options - Comparison options * @returns Comparison result */ async function compareImages(image1, image2, withDiff = false, options = {}) { const png1 = await loadPNG(image1); const png2 = await loadPNG(image2); const { width, height } = png1; if (width !== png2.width || height !== png2.height) return { numDiffPixels: 0, totalPixels: 0, percentDifference: 0, passed: false, differentSizes: true }; const diff = withDiff ? new pngjs.PNG({ width, height }) : void 0; try { const numDiffPixels = compare(png1.data, png2.data, diff?.data, width, height, options); const totalPixels = width * height; const percentDifference = numDiffPixels / totalPixels * 100; return { numDiffPixels, totalPixels, percentDifference, diffBuffer: diff ? pngjs.PNG.sync.write(diff) : void 0, passed: isPassed(percentDifference, numDiffPixels, options), differentSizes: false }; } catch (error) { (0, __cappa_logger.getLogger)().error(error.message || "Unknown error"); return { numDiffPixels: 0, totalPixels: 0, percentDifference: 0, passed: false, differentSizes: false, error: error.message || "Unknown error" }; } } /** * Load a PNG image from file path or Buffer */ async function loadPNG(source) { if (Buffer.isBuffer(source)) return pngjs.PNG.sync.read(source); else { const buffer = node_fs.default.readFileSync(source); return pngjs.PNG.sync.read(buffer); } } /** * Check if the comparison passed based on the options * @param percentDifference - Percentage difference between the images * @param numDiffPixels - Number of different pixels * @param options - Comparison options * @returns Whether the comparison passed */ const isPassed = (percentDifference, numDiffPixels, options) => { if (options.maxDiffPercentage && options.maxDiffPixels) return percentDifference <= options.maxDiffPercentage && numDiffPixels <= options.maxDiffPixels; if (options.maxDiffPercentage) return percentDifference <= options.maxDiffPercentage; if (options.maxDiffPixels) return numDiffPixels <= options.maxDiffPixels; return numDiffPixels === 0; }; /** * Create different size png image with all pixels red * @returns */ function createDiffSizePngImage(width, height) { const png = new pngjs.PNG({ width, height }); for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { const idx = width * y + x << 2; png.data[idx] = 255; png.data[idx + 1] = 0; png.data[idx + 2] = 0; png.data[idx + 3] = 255; } return pngjs.PNG.sync.write(png); } //#endregion //#region src/screenshot.ts const defaultDiffConfig = { threshold: .1, includeAA: false, fastBufferCheck: true, maxDiffPixels: 0, maxDiffPercentage: 0 }; var ScreenshotTool = class { browserType; headless; viewport; outputDir; browser = null; context = null; page = null; concurrency; contexts = []; pages = []; diff; logger; retries; filesystem; constructor(options) { this.browserType = options.browserType || "chromium"; this.headless = options.headless !== false; this.viewport = options.viewport || { width: 1920, height: 1080 }; this.outputDir = options.outputDir || "./screenshots"; this.diff = { ...defaultDiffConfig, ...options.diff }; this.concurrency = Math.max(1, options.concurrency || 1); this.logger = (0, __cappa_logger.getLogger)(); this.retries = options.retries || 2; this.filesystem = new ScreenshotFileSystem(this.outputDir); } /** * Initialize playwright and page */ async init() { const browserClass = { chromium: playwright_core.chromium, firefox: playwright_core.firefox, webkit: playwright_core.webkit }[this.browserType]; if (!browserClass) throw new Error(`Unsupported browser type: ${this.browserType}`); this.browser = await browserClass.launch({ headless: this.headless }); const defaultUserAgent = (await this.browser.newContext()).newPage().then((page) => page.evaluate(() => navigator.userAgent)); this.contexts = []; this.pages = []; for (let i = 0; i < this.concurrency; i++) { const context = await this.browser.newContext({ reducedMotion: "reduce", deviceScaleFactor: 2, userAgent: `${await defaultUserAgent} CappaStorybook`, viewport: this.viewport }); const page = await context.newPage(); this.contexts.push(context); this.pages.push(page); } this.context = this.contexts[0] ?? null; this.page = this.pages[0] ?? null; } /** * Closes browser * If not closed, the process will not exit */ async close() { for (const context of this.contexts) await context.close(); if (this.browser) await this.browser.close(); } /** * Navigates to a URL with "domcontentloaded" waitUntil */ async goTo(page, url) { if (!this.context) throw new Error("Browser not initialized"); await page.goto(url, { waitUntil: "domcontentloaded" }); } /** * Takes a screenshot of the page */ async takeScreenshot(page, filename, options) { if (!this.browser) throw new Error("Browser not initialized"); if (options.skip) return; try { const filepath = this.filesystem.getActualFilePath(filename); const screenshotOptions = { path: filepath, fullPage: options.fullPage, type: "png", timeout: 6e4, mask: options.mask, omitBackground: options.omitBackground, scale: "css", animations: "disabled", caret: "hide" }; await this.withViewport(page, options.viewport, async () => { if (options.delay) await page.waitForTimeout(options.delay); await page.screenshot(screenshotOptions); }); return filepath; } catch (error) { this.logger.error(`Error taking screenshot of ${page.url()}:`, error.message); throw error; } } /** * Sets the viewport size and executes the action, ensuring the original viewport is restored * even if the action throws an error. */ async withViewport(page, viewport, action) { if (!viewport) return action(); const previousViewport = page.viewportSize(); if (previousViewport && previousViewport.width === viewport.width && previousViewport.height === viewport.height) return action(); await page.setViewportSize(viewport); try { return await action(); } finally { if (previousViewport) await page.setViewportSize(previousViewport); } } /** * Takes a screenshot and returns the buffer (for comparison purposes) */ async takeScreenshotBuffer(page, options) { if (!this.browser) throw new Error("Browser not initialized"); if (options.skip) throw new Error("Screenshot skipped"); try { const screenshotOptions = { fullPage: options.fullPage, type: "png", timeout: 6e4, mask: options.mask, omitBackground: options.omitBackground, scale: "css", animations: "disabled", caret: "hide" }; return await this.withViewport(page, options.viewport, async () => { if (options.delay) await page.waitForTimeout(options.delay); return page.screenshot(screenshotOptions); }); } catch (error) { this.logger.error(`Error taking screenshot of ${page.url()}:`, error.message); throw error; } } async takeScreenshotWithComparison(page, filename, referenceImage, options) { if (!this.browser) throw new Error("Browser not initialized"); try { const filepath = this.filesystem.getActualFilePath(filename); const screenshotSettings = { fullPage: options.fullPage, mask: options.mask, omitBackground: options.omitBackground, delay: options.delay, viewport: options.viewport }; const retryScreenshot = await this.retryScreenshot(page, referenceImage, screenshotSettings); if (!retryScreenshot.screenshotPath) throw new Error("Screenshot buffer is undefined"); this.filesystem.writeActualFile(filename, retryScreenshot.screenshotPath); this.logger.success(`Screenshot saved: ${filepath}`); if (retryScreenshot.passed) this.logger.success(`Screenshot passed visual comparison`); else this.logger.error(`Screenshot failed visual comparison`); let diffImagePath; if (options.saveDiffImage && retryScreenshot.comparisonResult.diffBuffer && !retryScreenshot.passed) { const diffFilename = options.diffImageFilename || filename; diffImagePath = this.filesystem.getDiffFilePath(diffFilename); this.filesystem.writeDiffFile(diffFilename, retryScreenshot.comparisonResult.diffBuffer); this.logger.debug(`Diff image saved: ${diffImagePath}`); } if (retryScreenshot.comparisonResult.differentSizes) { this.logger.warn(`Screenshot has different sizes than reference image`); const diffFilename = options.diffImageFilename || filename; diffImagePath = this.filesystem.getDiffFilePath(diffFilename); const diffBuffer = createDiffSizePngImage(200, 200); this.filesystem.writeDiffFile(diffFilename, diffBuffer); this.logger.debug(`Diff Size image saved: ${diffImagePath}`); } return { screenshotPath: filepath, comparisonResult: retryScreenshot.comparisonResult, diffImagePath }; } catch (error) { this.logger.error(`Error taking screenshot with comparison of ${page.url()}:`, error.message); throw error; } } async retryScreenshot(page, referenceImage, options) { for (let i = 0; i < this.retries; i++) try { const screenshotBuffer = await this.takeScreenshotBuffer(page, { ...options, delay: this.getIncBackoffDelay(i, options.delay || 0) }); if (!screenshotBuffer) throw new Error("Screenshot buffer is undefined"); const comparisonResult = await compareImages(screenshotBuffer, referenceImage, true, this.diff); if (comparisonResult.differentSizes) return { screenshotPath: screenshotBuffer, comparisonResult, passed: false }; if (i === this.retries - 1 && !comparisonResult.passed) { this.logger.error(`Failed to match screenshot after ${this.retries} retries`); return { screenshotPath: screenshotBuffer, comparisonResult, passed: false }; } if (comparisonResult.passed) return { screenshotPath: screenshotBuffer, comparisonResult, passed: true }; else this.logger.warn(`Comparison did not match. Retrying in ${this.getIncBackoffDelay(i + 1, options.delay || 0)}ms... \nComparison result: ${comparisonResult.numDiffPixels} pixels different (${comparisonResult.percentDifference.toFixed(2)}%)`); } catch (error) { this.logger.error(`Error taking screenshot after ${i + 1} retries:`, error.message); } return { screenshotPath: null, comparisonResult: null, passed: false }; } getVariantFilename(filename, variant) { if (variant.filename) return variant.filename; const parsed = node_path.default.parse(filename); const suffix = variant.id.trim().replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "variant"; const ext = parsed.ext || ".png"; const variantName = `${parsed.name}--${suffix}${ext}`; return parsed.dir ? node_path.default.join(parsed.dir, variantName) : variantName; } async capture(page, filename, options, extras = {}) { if (!this.browser) throw new Error("Browser not initialized"); const { variants = [],...baseOptions } = options; const baseResult = { filename }; const variantResults = []; const baseSaveDiff = extras.saveDiffImage ?? false; const baseDiffFilename = extras.diffImageFilename ?? filename; if (baseOptions.skip) return { base: { ...baseResult, skipped: true }, variants: variants.map((variant) => ({ id: variant.id, label: variant.label, filename: this.getVariantFilename(filename, variant), skipped: true })) }; if (this.filesystem.hasExpectedFile(filename)) { const { screenshotPath, comparisonResult, diffImagePath } = await this.takeScreenshotWithComparison(page, filename, this.filesystem.readExpectedFile(filename), { ...baseOptions, saveDiffImage: baseSaveDiff, diffImageFilename: baseDiffFilename }); baseResult.filepath = screenshotPath; baseResult.comparisonResult = comparisonResult; baseResult.diffImagePath = diffImagePath; } else baseResult.filepath = await this.takeScreenshot(page, filename, baseOptions); for (const variant of variants) { const variantFilename = this.getVariantFilename(filename, variant); const variantOptions = { ...baseOptions, ...variant.options }; const variantResult = { id: variant.id, label: variant.label, filename: variantFilename }; if (variantOptions.skip) { variantResult.skipped = true; variantResults.push(variantResult); continue; } const variantExtra = extras.variants?.[variant.id]; const variantSaveDiff = variantExtra?.saveDiffImage ?? baseSaveDiff; const variantDiffFilename = variantExtra?.diffImageFilename ?? variantFilename; if (this.filesystem.hasExpectedFile(variantFilename)) { const { screenshotPath, comparisonResult, diffImagePath } = await this.takeScreenshotWithComparison(page, variantFilename, this.filesystem.readExpectedFile(variantFilename), { ...variantOptions, saveDiffImage: variantSaveDiff, diffImageFilename: variantDiffFilename }); variantResult.filepath = screenshotPath; variantResult.comparisonResult = comparisonResult; variantResult.diffImagePath = diffImagePath; } else variantResult.filepath = await this.takeScreenshot(page, variantFilename, variantOptions); variantResults.push(variantResult); } return { base: baseResult, variants: variantResults }; } /** * Gets the exponential backoff delay */ getIncBackoffDelay(i, delay) { return i > 0 ? 500 * 2 ** (i - 1) + (delay || 0) : 0; } /** * Get a page from the pool by index */ getPageFromPool(index) { if (index < 0) throw new Error(`Page index ${index} must be non-negative`); if (index >= this.pages.length) throw new Error(`Page index ${index} exceeds pool size ${this.pages.length}`); if (!this.pages[index]) throw new Error(`Page index ${index} is undefined`); return this.pages[index]; } }; var screenshot_default = ScreenshotTool; //#endregion exports.ScreenshotFileSystem = ScreenshotFileSystem; exports.ScreenshotTool = screenshot_default; exports.defineConfig = defineConfig; //# sourceMappingURL=index.js.map