@wdio/image-comparison-core
Version:
Image comparison core module for @wdio/visual-service - WebdriverIO visual testing framework
539 lines (538 loc) • 27.4 kB
JavaScript
import logger from '@wdio/logger';
import scrollToPosition from '../clientSideScripts/scrollToPosition.js';
import getDocumentScrollHeight from '../clientSideScripts/getDocumentScrollHeight.js';
import { calculateDprData, getBase64ScreenshotSize, waitFor } from '../helpers/utils.js';
import hideRemoveElements from '../clientSideScripts/hideRemoveElements.js';
import hideScrollBars from '../clientSideScripts/hideScrollbars.js';
import { determineElementRectangles } from './rectangles.js';
const log = logger('@wdio/visual-service:@wdio/image-comparison-core:screenshots');
/**
* Take a full page screenshots for desktop / iOS / Android
*/
export async function getMobileFullPageNativeWebScreenshotsData(browserInstance, options) {
const viewportScreenshots = [];
// The addressBarShadowPadding and toolBarShadowPadding is used because the viewport might have a shadow on the address and the tool bar
// so the cutout of the viewport needs to be a little bit smaller
const { addressBarShadowPadding, devicePixelRatio, deviceRectangles: { viewport, bottomBar, homeBar }, fullPageScrollTimeout, hideAfterFirstScroll, isAndroid, isLandscape, toolBarShadowPadding, } = options;
// The returned data from the deviceRectangles is in real pixels, not CSS pixels, so we need to divide it by the devicePixelRatio
// but only for Android, because the deviceRectangles are already in CSS pixels for iOS
const viewportHeight = Math.round(viewport.height / (isAndroid ? devicePixelRatio : 1)) - addressBarShadowPadding - toolBarShadowPadding;
const hasNoBottomBar = bottomBar.height === 0;
const hasHomeBar = homeBar.height > 0;
const effectiveViewportHeight = hasNoBottomBar && hasHomeBar
? viewportHeight - Math.round(homeBar.height / (isAndroid ? devicePixelRatio : 1))
: viewportHeight;
const viewportWidth = Math.round(viewport.width / (isAndroid ? devicePixelRatio : 1));
const viewportX = Math.round(viewport.x / (isAndroid ? devicePixelRatio : 1));
const viewportY = Math.round(viewport.y / (isAndroid ? devicePixelRatio : 1));
// Start with an empty array, during the scroll it will be filled because a page could also have a lazy loading
const amountOfScrollsArray = [];
let scrollHeight;
let isRotated = false;
let actualFullPageWidth;
for (let i = 0; i <= amountOfScrollsArray.length; i++) {
// Determine and start scrolling
const scrollY = effectiveViewportHeight * i;
if (scrollY < 0) {
const currentBrowserScrollPosition = await browserInstance.execute(() => window.pageYOffset || document.documentElement.scrollTop);
log.error('Negative scrollY detected during full page screenshot', {
iteration: i,
scrollY,
effectiveViewportHeight,
originalViewportHeight: viewportHeight,
calculatedScrollY: effectiveViewportHeight * i,
deviceInfo: {
isAndroid,
isLandscape,
devicePixelRatio,
addressBarShadowPadding,
toolBarShadowPadding
},
deviceRectangles: {
viewport: options.deviceRectangles.viewport,
bottomBar: options.deviceRectangles.bottomBar,
homeBar: options.deviceRectangles.homeBar,
screenSize: options.deviceRectangles.screenSize
},
homeBarAdjustment: {
hasNoBottomBar,
hasHomeBar,
homeBarHeightAdjustment: hasNoBottomBar && hasHomeBar ? Math.round(homeBar.height / (isAndroid ? devicePixelRatio : 1)) : 0
},
scrollHeight,
currentBrowserScrollPosition
});
throw new Error(`Negative scroll position detected (scrollY: ${scrollY}) during full page screenshot at iteration ${i}. This indicates an issue with viewport calculations or browser scroll state. Check logs for detailed debug information.`);
}
await browserInstance.execute(scrollToPosition, scrollY);
// Hide scrollbars before taking a screenshot, we don't want them, on the screenshot
await browserInstance.execute(hideScrollBars, true);
// Simply wait the amount of time specified for lazy-loading
await waitFor(fullPageScrollTimeout);
// Elements that need to be hidden after the first scroll for a fullpage scroll
if (i === 1 && hideAfterFirstScroll.length > 0) {
try {
await browserInstance.execute(hideRemoveElements, { hide: hideAfterFirstScroll, remove: [] }, true);
}
catch (e) {
logHiddenRemovedError(e);
}
}
// Take the screenshot and determine if it's rotated
const screenshot = await takeBase64Screenshot(browserInstance);
isRotated = Boolean(isLandscape && effectiveViewportHeight > viewportWidth);
// Determine scroll height and check if we need to scroll again
scrollHeight = await browserInstance.execute(getDocumentScrollHeight);
// Get actual scroll position after scrolling to verify it matches scrollY
const actualScrollInfo = await browserInstance.execute(() => ({
scrollTop: window.pageYOffset || document.documentElement.scrollTop,
}));
if (scrollHeight && (scrollY + effectiveViewportHeight < scrollHeight)) {
amountOfScrollsArray.push(amountOfScrollsArray.length);
}
// For the last image, use the actual scroll position instead of the intended scrollY
// because the browser may not be able to scroll to the exact position we requested
const isLastImage = amountOfScrollsArray.length === i;
const scrollPositionForCalculation = isLastImage && actualScrollInfo
? actualScrollInfo.scrollTop
: scrollY;
const remainingContent = scrollHeight ? scrollHeight - scrollPositionForCalculation : 0;
const imageHeight = isLastImage && scrollHeight && remainingContent > 0
? remainingContent
: effectiveViewportHeight;
if (amountOfScrollsArray.length === i && remainingContent <= 0) {
break;
}
// The starting position for cropping could be different for the last image
// The cropping always needs to start at status and address bar height and the address bar shadow padding
// For the last image, if imageHeight > effectiveViewportHeight, we crop from the top (y=0)
// Otherwise, we crop from the bottom to align with previous images
const imageYPositionBase = isLastImage && imageHeight <= effectiveViewportHeight
? effectiveViewportHeight - imageHeight
: 0;
const imageYPosition = imageYPositionBase + viewportY + addressBarShadowPadding;
// For the last image, use the actual scroll position for canvasYPosition
// because the browser may not be able to scroll to the exact position we requested
const canvasYPositionForStorage = isLastImage && actualScrollInfo
? actualScrollInfo.scrollTop
: scrollY;
// Store all the screenshot data in the screenshot object
const screenshotData = {
...calculateDprData({
canvasWidth: isRotated ? effectiveViewportHeight : viewportWidth,
canvasYPosition: canvasYPositionForStorage,
imageHeight: imageHeight,
imageWidth: isRotated ? effectiveViewportHeight : viewportWidth,
imageXPosition: viewportX,
imageYPosition: imageYPosition,
}, devicePixelRatio),
screenshot,
};
viewportScreenshots.push(screenshotData);
// Calculate the actual cropped width from the first screenshot to handle rounding differences
if (i === 0 && !actualFullPageWidth) {
const { height: screenshotHeightDevicePixels, width: screenshotWidthDevicePixels } = getBase64ScreenshotSize(screenshot);
const screenshotIsRotated = Boolean(isLandscape && screenshotHeightDevicePixels > screenshotWidthDevicePixels);
const actualScreenshotWidthDevicePixels = screenshotIsRotated ? screenshotHeightDevicePixels : screenshotWidthDevicePixels;
const maxAvailableWidthDevicePixels = actualScreenshotWidthDevicePixels - screenshotData.imageXPosition;
const actualCroppedWidthDevicePixels = Math.min(screenshotData.imageWidth, maxAvailableWidthDevicePixels);
actualFullPageWidth = actualCroppedWidthDevicePixels / devicePixelRatio;
}
// Show scrollbars again
await browserInstance.execute(hideScrollBars, false);
}
// Put back the hidden elements to visible
if (hideAfterFirstScroll.length > 0) {
try {
await browserInstance.execute(hideRemoveElements, { hide: hideAfterFirstScroll, remove: [] }, false);
}
catch (e) {
logHiddenRemovedError(e);
}
}
if (!scrollHeight) {
throw new Error('Couldn\'t determine scroll height or screenshot size');
}
const fullPageHeight = scrollHeight - addressBarShadowPadding - toolBarShadowPadding;
const fullPageWidth = actualFullPageWidth ?? (isRotated ? effectiveViewportHeight : viewportWidth);
return {
...calculateDprData({
fullPageHeight,
fullPageWidth,
}, devicePixelRatio),
data: viewportScreenshots,
};
}
/**
* Take a full page screenshot for Android with Chromedriver
*/
export async function getAndroidChromeDriverFullPageScreenshotsData(browserInstance, options) {
const viewportScreenshots = [];
const { devicePixelRatio, fullPageScrollTimeout, hideAfterFirstScroll, innerHeight } = options;
// Start with an empty array, during the scroll it will be filled because a page could also have a lazy loading
const amountOfScrollsArray = [];
let scrollHeight;
let screenshotSize;
for (let i = 0; i <= amountOfScrollsArray.length; i++) {
// Determine and start scrolling
const scrollY = innerHeight * i;
await browserInstance.execute(scrollToPosition, scrollY);
// Hide scrollbars before taking a screenshot, we don't want them, on the screenshot
await browserInstance.execute(hideScrollBars, true);
// Simply wait the amount of time specified for lazy-loading
await waitFor(fullPageScrollTimeout);
// Elements that need to be hidden after the first scroll for a fullpage scroll
if (i === 1 && hideAfterFirstScroll.length > 0) {
try {
await browserInstance.execute(hideRemoveElements, { hide: hideAfterFirstScroll, remove: [] }, true);
}
catch (e) {
logHiddenRemovedError(e);
}
}
// Take the screenshot
const screenshot = await takeBase64Screenshot(browserInstance);
screenshotSize = getBase64ScreenshotSize(screenshot, devicePixelRatio);
// Determine scroll height and check if we need to scroll again
scrollHeight = await browserInstance.execute(getDocumentScrollHeight);
if (scrollHeight && (scrollY + innerHeight < scrollHeight)) {
amountOfScrollsArray.push(amountOfScrollsArray.length);
}
// There is no else
// The height of the image of the last 1 could be different
const imageHeight = amountOfScrollsArray.length === i && scrollHeight
? scrollHeight - innerHeight * viewportScreenshots.length
: innerHeight;
// The starting position for cropping could be different for the last image (0 means no cropping)
const imageYPosition = amountOfScrollsArray.length === i && amountOfScrollsArray.length !== 0 ? innerHeight - imageHeight : 0;
// Store all the screenshot data in the screenshot object
viewportScreenshots.push({
...calculateDprData({
canvasWidth: screenshotSize.width,
canvasYPosition: scrollY,
imageHeight: imageHeight,
imageWidth: screenshotSize.width,
imageXPosition: 0,
imageYPosition: imageYPosition,
}, devicePixelRatio),
screenshot,
});
// Show the scrollbars again
await browserInstance.execute(hideScrollBars, false);
}
// Put back the hidden elements to visible
if (hideAfterFirstScroll.length > 0) {
try {
await browserInstance.execute(hideRemoveElements, { hide: hideAfterFirstScroll, remove: [] }, false);
}
catch (e) {
logHiddenRemovedError(e);
}
}
if (!scrollHeight || !screenshotSize) {
throw new Error('Couldn\'t determine scroll height or screenshot size');
}
return {
...calculateDprData({
fullPageHeight: scrollHeight,
fullPageWidth: screenshotSize.width,
}, devicePixelRatio),
data: viewportScreenshots,
};
}
/**
* Take a full page screenshots
*/
export async function getDesktopFullPageScreenshotsData(browserInstance, options) {
const viewportScreenshots = [];
const { devicePixelRatio, fullPageScrollTimeout, hideAfterFirstScroll, innerHeight } = options;
let actualInnerHeight = innerHeight;
const { capabilities } = browserInstance;
const browserName = (capabilities?.browserName || '').toLowerCase();
// Safari desktop returns the browser mask with rounded corners and a drop shadow, so we need to fix this
const isSafariDesktop = browserName.includes('safari') && !browserInstance.isMobile;
const safariTopDropShadowCssPixels = isSafariDesktop ? Math.round(1 * devicePixelRatio) : 0;
const safariBottomCropOffsetCssPixels = isSafariDesktop ? Math.round(10 * devicePixelRatio) : 0;
// For Safari desktop, calculate effective scroll increment
// First image: scroll by 0, use full height (e.g.716px), crop 10px from bottom
// Subsequent images: scroll by (actualInnerHeight - dropShadowOffset - bottomCropOffset) = 705px, crop 1px from top and 10px from bottom
const effectiveScrollIncrement = isSafariDesktop
? actualInnerHeight - safariTopDropShadowCssPixels - safariBottomCropOffsetCssPixels
: actualInnerHeight;
// Start with an empty array, during the scroll it will be filled because a page could also have a lazy loading
const amountOfScrollsArray = [];
let scrollHeight;
let screenshotSize;
for (let i = 0; i <= amountOfScrollsArray.length; i++) {
// Determine and start scrolling
// For Safari desktop: first image scrolls to 0, subsequent images scroll by effectiveScrollIncrement (715px)
// Image 0: scrollY = 0
// Image 1: scrollY = 715 (effectiveScrollIncrement)
// Image 2: scrollY = 1430 (2 * effectiveScrollIncrement)
// etc.
const scrollY = isSafariDesktop
? (i === 0 ? 0 : i * effectiveScrollIncrement)
: actualInnerHeight * i;
await browserInstance.execute(scrollToPosition, scrollY);
// Simply wait the amount of time specified for lazy-loading
await waitFor(fullPageScrollTimeout);
// Elements that need to be hidden after the first scroll for a fullpage scroll
if (i === 1 && hideAfterFirstScroll.length > 0) {
try {
await browserInstance.execute(hideRemoveElements, { hide: hideAfterFirstScroll, remove: [] }, true);
}
catch (e) {
logHiddenRemovedError(e);
}
}
// Take the screenshot
const screenshot = await takeBase64Screenshot(browserInstance);
screenshotSize = getBase64ScreenshotSize(screenshot, devicePixelRatio);
// The actual screenshot size might be slightly different than the inner height
// In that case, use the screenshot size instead of the innerHeight
if (i === 0 && screenshotSize.height !== actualInnerHeight) {
if (Math.round(screenshotSize.height) === actualInnerHeight) {
actualInnerHeight = screenshotSize.height;
}
// No else, because some drivers take a full page screenshot, e.g. some versions of FireFox,
// and SafariDriver for Safari 11
}
scrollHeight = await browserInstance.execute(getDocumentScrollHeight);
// For Safari desktop, use effectiveScrollIncrement for the scroll check
const scrollCheckHeight = isSafariDesktop ? effectiveScrollIncrement : actualInnerHeight;
if (scrollHeight && (scrollY + scrollCheckHeight < scrollHeight) && screenshotSize.height === actualInnerHeight) {
amountOfScrollsArray.push(amountOfScrollsArray.length);
}
// The height of the image of the last 1 could be different
// For Safari desktop, account for first image being full height and subsequent images being cropped
const isFirstImage = i === 0;
const isLastImage = amountOfScrollsArray.length === i;
let imageHeight;
if (scrollHeight && isLastImage) {
if (isSafariDesktop) {
// Calculate remaining content: scrollHeight - (firstImageHeight + (numberOfPreviousImages - 1) * effectiveScrollIncrement)
const numberOfPreviousImages = viewportScreenshots.length;
const totalPreviousHeight = numberOfPreviousImages === 0
? 0
: actualInnerHeight + (numberOfPreviousImages - 1) * effectiveScrollIncrement;
const remainingContent = scrollHeight - totalPreviousHeight;
// For the last image, we need to be smart:
// - If remainingContent >= actualInnerHeight: it's a full screenshot, treat it like a regular non-first image
// (crop 1px from top, visible height = 705px, but last image doesn't crop bottom, so add 10px)
// - If remainingContent < actualInnerHeight: it's a partial screenshot
// For partial screenshots, we're cropping from a position that doesn't include the drop shadow at pixel 0
// Last image doesn't crop bottom, so we need to add 10px to account for that
imageHeight = remainingContent >= actualInnerHeight
? effectiveScrollIncrement + safariBottomCropOffsetCssPixels
: remainingContent + safariBottomCropOffsetCssPixels;
}
else {
imageHeight = scrollHeight - actualInnerHeight * viewportScreenshots.length;
}
}
else {
// Non-last images: use full height for first, effectiveScrollIncrement for subsequent
// For non-first images, effectiveScrollIncrement already accounts for top and bottom crops
imageHeight = isSafariDesktop && !isFirstImage
? effectiveScrollIncrement
: screenshotSize.height;
}
// The starting position for cropping could be different for the last image (0 means no cropping)
// For Safari desktop, crop 1px from top for all images except first
if (isSafariDesktop && isFirstImage && safariBottomCropOffsetCssPixels > 0) {
imageHeight -= safariBottomCropOffsetCssPixels;
}
// The starting position for cropping could be different for the last image (0 means no cropping)
// For Safari desktop, crop 1px from top for all images except first
let imageYPosition;
if (isSafariDesktop) {
if (isLastImage && !isFirstImage) {
// Last image: need to handle two cases
const numberOfPreviousImages = viewportScreenshots.length;
const totalPreviousHeight = numberOfPreviousImages === 0
? 0
: actualInnerHeight + (numberOfPreviousImages - 1) * effectiveScrollIncrement;
const remainingContent = scrollHeight ? scrollHeight - totalPreviousHeight : 0;
// Full screenshot: treat like regular non-first image (crop 1px from top)
// Partial screenshot: we want to show the last remainingContent pixels
// But we need to include the bottom 10px that we're not cropping, so start 10px higher
// imageHeight = remainingContent, so we start at: 716 - remainingContent - 10px
// This way we crop 10px higher to include the bottom corners
imageYPosition = remainingContent >= actualInnerHeight
? safariTopDropShadowCssPixels
: actualInnerHeight - remainingContent - safariBottomCropOffsetCssPixels;
// If remainingContent is too small, we might get negative imageYPosition or invalid dimensions
if (imageYPosition < 0) {
imageYPosition = actualInnerHeight - remainingContent;
imageHeight = remainingContent;
}
else if (imageYPosition + imageHeight > screenshotSize.height) {
imageHeight = screenshotSize.height - imageYPosition;
}
}
else if (!isFirstImage) {
// Non-last, non-first images: crop 1px from top
imageYPosition = safariTopDropShadowCssPixels;
}
else {
// First image: no crop
imageYPosition = 0;
}
}
else {
imageYPosition = isLastImage && !isFirstImage
? actualInnerHeight - imageHeight
: 0;
}
// Ensure imageYPosition and imageHeight are valid for all cases
if (imageYPosition < 0) {
imageHeight += imageYPosition;
imageYPosition = 0;
}
if (imageYPosition + imageHeight > screenshotSize.height) {
imageHeight = screenshotSize.height - imageYPosition;
}
// Calculate based on where the previous image ends
// Previous image's canvasYPosition + previous image's height
let canvasYPosition;
if (isSafariDesktop && !isFirstImage) {
const previousImage = viewportScreenshots[viewportScreenshots.length - 1];
canvasYPosition = previousImage
? previousImage.canvasYPosition + previousImage.imageHeight
: actualInnerHeight + (i - 1) * effectiveScrollIncrement;
}
else {
canvasYPosition = isSafariDesktop ? 0 : scrollY;
}
// Store all the screenshot data in the screenshot object
viewportScreenshots.push({
...calculateDprData({
canvasWidth: screenshotSize.width,
canvasYPosition: canvasYPosition,
imageHeight: imageHeight,
imageWidth: screenshotSize.width,
imageXPosition: 0,
imageYPosition: imageYPosition,
}, devicePixelRatio),
screenshot,
});
}
// Put back the hidden elements to visible
if (hideAfterFirstScroll.length > 0) {
try {
await browserInstance.execute(hideRemoveElements, { hide: hideAfterFirstScroll, remove: [] }, false);
}
catch (e) {
logHiddenRemovedError(e);
}
}
if (!scrollHeight || !screenshotSize) {
throw new Error('Couldn\'t determine scroll height or screenshot size');
}
return {
...calculateDprData({
fullPageHeight: scrollHeight,
fullPageWidth: screenshotSize.width,
}, devicePixelRatio),
data: viewportScreenshots,
};
}
/**
* Take a screenshot
*/
export async function takeBase64Screenshot(browserInstance) {
return browserInstance.takeScreenshot();
}
/**
* Take a bidi screenshot
*/
export async function takeBase64BiDiScreenshot({ browserInstance, origin = 'viewport', clip, }) {
log.info('Taking a BiDi screenshot');
const contextID = await browserInstance.getWindowHandle();
return (await browserInstance.browsingContextCaptureScreenshot({
context: contextID,
origin,
...(clip ? { clip: { ...clip, type: 'box' } } : {})
})).data;
}
/**
* Log an error for not being able to hide remove elements
*
* @TODO: remove the any
*/
export function logHiddenRemovedError(error) {
log.warn('\x1b[33m%s\x1b[0m', `
#####################################################################################
WARNING:
(One of) the elements that needed to be hidden or removed could not be found on the
page and caused this error
Error: ${error}
We made sure the test didn't break.
#####################################################################################
`);
}
/**
* Take an element screenshot on the web
*/
export async function takeWebElementScreenshot({ addressBarShadowPadding, browserInstance, devicePixelRatio, deviceRectangles, element, fallback = false, initialDevicePixelRatio, isEmulated, innerHeight, isAndroid, isAndroidChromeDriverScreenshot, isAndroidNativeWebScreenshot, isIOS, isLandscape, toolBarShadowPadding, }) {
if (fallback) {
const base64Image = await takeBase64Screenshot(browserInstance);
const elementRectangleOptions = {
/**
* ToDo: handle NaA case
*/
devicePixelRatio: devicePixelRatio,
deviceRectangles,
initialDevicePixelRatio: initialDevicePixelRatio || 1,
innerHeight: innerHeight || NaN,
isEmulated,
isAndroidNativeWebScreenshot,
isAndroid,
isIOS,
};
const rectangles = await determineElementRectangles({
browserInstance,
base64Image,
element,
options: elementRectangleOptions,
});
return {
base64Image,
isWebDriverElementScreenshot: false,
rectangles,
};
}
try {
const base64Image = await browserInstance.takeElementScreenshot((await element).elementId);
const { height, width } = getBase64ScreenshotSize(base64Image);
const rectangles = { x: 0, y: 0, width, height };
if (rectangles.width === 0 || rectangles.height === 0) {
throw new Error('The element has no width or height.');
}
return {
base64Image,
isWebDriverElementScreenshot: true,
rectangles,
};
}
catch (_e) {
log.warn('The element screenshot failed, falling back to cutting the full device/viewport screenshot:', _e);
return takeWebElementScreenshot({
addressBarShadowPadding,
browserInstance,
devicePixelRatio,
deviceRectangles,
element,
fallback: true,
initialDevicePixelRatio,
isEmulated,
innerHeight,
isAndroid,
isAndroidChromeDriverScreenshot,
isAndroidNativeWebScreenshot,
isIOS,
isLandscape,
toolBarShadowPadding,
});
}
}