175 lines
4.9 KiB
TypeScript
175 lines
4.9 KiB
TypeScript
import { expect, test } from 'playwright-test-coverage';
|
|
import { Locator, Page } from 'playwright';
|
|
import { promises as fs } from 'fs';
|
|
import path from 'path';
|
|
|
|
type CheckForScreenshotProps = {
|
|
page: Page;
|
|
locator?: Locator | Page;
|
|
screenshotPath: string;
|
|
attempts?: number;
|
|
delay?: number;
|
|
maxDiffPixelRatio?: number;
|
|
threshold?: number;
|
|
normalizedClip?: {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
fullPage?: boolean;
|
|
};
|
|
|
|
const _isIntermediateScreenshotArtifact = (filename: string, screenshotPath: string) => {
|
|
const { name } = path.parse(screenshotPath);
|
|
const lowerFilename = filename.toLowerCase();
|
|
|
|
if (!lowerFilename.endsWith('.png')) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
lowerFilename.startsWith(`${name.toLowerCase()}-`) &&
|
|
(lowerFilename.endsWith('-actual.png') ||
|
|
lowerFilename.endsWith('-diff.png') ||
|
|
lowerFilename.endsWith('-expected.png'))
|
|
);
|
|
};
|
|
|
|
const _cleanupIntermediateScreenshotArtifacts = async (
|
|
outputDir: string,
|
|
screenshotPath: string
|
|
) => {
|
|
const stack = [outputDir];
|
|
|
|
while (stack.length) {
|
|
const currentDir = stack.pop();
|
|
|
|
if (!currentDir) {
|
|
continue;
|
|
}
|
|
|
|
let entries;
|
|
try {
|
|
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(currentDir, entry.name);
|
|
|
|
if (entry.isDirectory()) {
|
|
stack.push(fullPath);
|
|
continue;
|
|
}
|
|
|
|
if (!_isIntermediateScreenshotArtifact(entry.name, screenshotPath)) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
await fs.unlink(fullPath);
|
|
} catch {
|
|
// Best-effort cleanup only.
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const _checkForScreenshot = async (props: CheckForScreenshotProps) => {
|
|
const {
|
|
page,
|
|
screenshotPath,
|
|
attempts = 10,
|
|
delay = 1250,
|
|
maxDiffPixelRatio = 0.02,
|
|
threshold = 0.05,
|
|
normalizedClip,
|
|
fullPage = false,
|
|
} = props;
|
|
|
|
let { locator = page } = props;
|
|
const testOutputDir = test.info().outputDir;
|
|
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
for (let i = 0; i < attempts; i++) {
|
|
try {
|
|
let clip;
|
|
if (normalizedClip) {
|
|
let boundingBox;
|
|
if (locator === page) {
|
|
boundingBox = { x: 0, y: 0, ...(await page.viewportSize()) };
|
|
} else {
|
|
boundingBox = await (locator as Locator).boundingBox();
|
|
|
|
// clip does not work for locator screen shots, so lets do some trickery
|
|
// set page as the locator here and below add the locator bounding box origin to the
|
|
// clip area
|
|
locator = page;
|
|
}
|
|
|
|
clip = {
|
|
x: boundingBox.x + normalizedClip.x * boundingBox.width,
|
|
y: boundingBox.y + normalizedClip.y * boundingBox.height,
|
|
width: normalizedClip.width * boundingBox.width,
|
|
height: normalizedClip.height * boundingBox.height,
|
|
};
|
|
}
|
|
|
|
await expect(locator).toHaveScreenshot(screenshotPath, {
|
|
maxDiffPixelRatio,
|
|
threshold,
|
|
clip,
|
|
fullPage,
|
|
});
|
|
return true;
|
|
} catch (error) {
|
|
if (i === attempts - 1) {
|
|
console.debug('Screenshot comparison failed after all attempts');
|
|
throw error; // Throw the original error with details instead of a generic message
|
|
}
|
|
await _cleanupIntermediateScreenshotArtifacts(testOutputDir, screenshotPath);
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
}
|
|
|
|
// This is a fallback in case the loop exits unexpectedly
|
|
throw new Error('Screenshot comparison failed: loop exited without match or proper error');
|
|
};
|
|
|
|
/**
|
|
* Checks if a screenshot of a specific element matches the expected screenshot.
|
|
* It retries the check for a specified number of attempts with a delay between each attempt.
|
|
* By default, the number of attempts is 10 and the delay is 500 milliseconds which results in a maximum wait time of 5 seconds.
|
|
* Instead of sleeping idle prior to calling this function, simply adjust the attempts and delay parameters to achieve the desired wait time.
|
|
* @param pageOrProps - The page to interact with or an object containing page and other properties
|
|
* @param locator - The element to check for screenshot
|
|
* @param screenshotPath - The path to save the screenshot
|
|
* @param attempts - The number of attempts to check for screenshot
|
|
* @param delay - The delay between attempts
|
|
* @returns True if the screenshot matches, otherwise throws an error
|
|
*/
|
|
const checkForScreenshot = async (
|
|
pageOrProps: Page | CheckForScreenshotProps,
|
|
locator?: Locator | Page,
|
|
screenshotPath?: string,
|
|
attempts?: number,
|
|
delay?: number
|
|
) => {
|
|
if (typeof pageOrProps === 'object' && 'page' in pageOrProps) {
|
|
return await _checkForScreenshot(pageOrProps as CheckForScreenshotProps);
|
|
} else {
|
|
return await _checkForScreenshot({
|
|
page: pageOrProps as Page,
|
|
locator,
|
|
screenshotPath,
|
|
attempts,
|
|
delay,
|
|
});
|
|
}
|
|
};
|
|
|
|
export { checkForScreenshot };
|