@cappa/core
Version:
Core Playwright screenshot functionality for Cappa
627 lines (621 loc) • 21.1 kB
JavaScript
//#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