@wdio/image-comparison-core
Version:
Image comparison core module for @wdio/visual-service - WebdriverIO visual testing framework
299 lines (298 loc) • 13.4 kB
JavaScript
import { Jimp } from 'jimp';
import { calculateDprData, getBase64ScreenshotSize, isObject } from '../helpers/utils.js';
import { getElementPositionAndroid, getElementPositionDesktop, getElementWebviewPosition } from './elementPosition.js';
/**
* Determine the element rectangles on the page / screenshot
*/
export async function determineElementRectangles({ browserInstance, base64Image, options, element, }) {
// Determine screenshot data
const { devicePixelRatio, deviceRectangles, initialDevicePixelRatio, innerHeight, isAndroid, isAndroidNativeWebScreenshot, isEmulated, isIOS, } = options;
const internalDpr = isEmulated ? initialDevicePixelRatio : devicePixelRatio;
const { height } = getBase64ScreenshotSize(base64Image, internalDpr);
let elementPosition;
// Determine the element position on the screenshot
if (isIOS) {
elementPosition = await getElementWebviewPosition(browserInstance, element, { deviceRectangles });
}
else if (isAndroid) {
elementPosition = await getElementPositionAndroid(browserInstance, element, { deviceRectangles, isAndroidNativeWebScreenshot });
}
else {
elementPosition = await getElementPositionDesktop(browserInstance, element, { innerHeight, screenshotHeight: height });
}
// Validate if the element is visible
if (elementPosition.height === 0 || elementPosition.width === 0) {
let selectorMessage = ' ';
if (element.selector) {
selectorMessage = `, with selector "$(${element.selector})",`;
}
const message = `The element${selectorMessage}is not visible. The dimensions are ${elementPosition.width}x${elementPosition.height}`;
throw new Error(message);
}
// Determine the rectangles based on the device pixel ratio
return calculateDprData({
height: elementPosition.height,
width: elementPosition.width,
x: elementPosition.x,
y: elementPosition.y,
}, internalDpr);
}
/**
* Determine the rectangles of the screen for the screenshot
*/
export function determineScreenRectangles(base64Image, options) {
// Determine screenshot data
const { devicePixelRatio, enableLegacyScreenshotMethod, initialDevicePixelRatio, innerHeight, innerWidth, isEmulated, isIOS, isAndroidChromeDriverScreenshot, isAndroidNativeWebScreenshot, isLandscape, } = options;
// For #967: When a screenshot of an emulated device is taken, but the browser was initially
// started as a "desktop" session, so not with emulated caps, we need to store the initial
// devicePixelRatio when we take a screenshot and enableLegacyScreenshotMethod is enabled
const internalDpr = isEmulated && enableLegacyScreenshotMethod ? initialDevicePixelRatio : devicePixelRatio;
const { height, width } = getBase64ScreenshotSize(base64Image, internalDpr);
// Determine the width
const screenshotWidth = isIOS || isAndroidChromeDriverScreenshot ? width : innerWidth;
const screenshotHeight = isIOS || isAndroidNativeWebScreenshot ? height : innerHeight;
const isRotated = isLandscape && height > width;
// Determine the rectangles
return calculateDprData({
height: isRotated ? screenshotWidth : screenshotHeight,
width: isRotated ? screenshotHeight : screenshotWidth,
x: 0,
y: 0,
}, internalDpr);
}
/**
* Determine the rectangles for the mobile devices
*/
export function determineStatusAddressToolBarRectangles({ deviceRectangles, options }) {
const { blockOutSideBar, blockOutStatusBar, blockOutToolBar, isAndroid, isAndroidNativeWebScreenshot, isMobile, isViewPortScreenshot, } = options;
const rectangles = [];
if (isViewPortScreenshot &&
isMobile &&
(isAndroid && isAndroidNativeWebScreenshot || !isAndroid)) {
const statusAddressBar = {
x: deviceRectangles.statusBarAndAddressBar.x, y: deviceRectangles.statusBarAndAddressBar.y,
width: deviceRectangles.statusBarAndAddressBar.width, height: deviceRectangles.statusBarAndAddressBar.height,
};
const toolBar = {
x: deviceRectangles.bottomBar.x, y: deviceRectangles.bottomBar.y,
width: deviceRectangles.bottomBar.width, height: deviceRectangles.bottomBar.height,
};
const leftSidePadding = {
x: deviceRectangles.leftSidePadding.x, y: deviceRectangles.leftSidePadding.y,
width: deviceRectangles.leftSidePadding.width, height: deviceRectangles.leftSidePadding.height,
};
const rightSidePadding = {
x: deviceRectangles.rightSidePadding.x, y: deviceRectangles.rightSidePadding.y,
width: deviceRectangles.rightSidePadding.width, height: deviceRectangles.rightSidePadding.height,
};
if (blockOutStatusBar) {
rectangles.push(statusAddressBar);
}
if (blockOutToolBar) {
rectangles.push(toolBar);
}
if (blockOutSideBar) {
rectangles.push(leftSidePadding, rightSidePadding);
}
}
return rectangles;
}
/**
* Validate that the element is a WebdriverIO element
*/
export function isWdioElement(x) {
if (!isObject(x)) {
return false;
}
const region = x;
const keys = ['selector', 'elementId'];
return keys.every(key => typeof region[key] === 'string');
}
/**
* Validate that the object is a valid ignore region
*/
export function validateIgnoreRegion(x) {
if (!isObject(x)) {
return false;
}
const region = x;
const keys = ['height', 'width', 'x', 'y'];
return keys.every(key => typeof region[key] === 'number');
}
/**
* Format the error message
*/
export function formatErrorMessage(item, message) {
const formattedItem = isObject(item) ? JSON.stringify(item) : item;
return `${formattedItem} ${message}`;
}
/**
* Split the ignores into elements and regions and throw an error if
* an element is not a valid WebdriverIO element/region
*/
export function splitIgnores(items) {
const elements = [];
const regions = [];
const errorMessages = [];
for (const item of items) {
if (Array.isArray(item)) {
for (const nestedItem of item) {
if (!isWdioElement(nestedItem)) {
errorMessages.push(formatErrorMessage(nestedItem, 'is not a valid WebdriverIO element'));
}
else {
elements.push(nestedItem);
}
}
}
else if (isWdioElement(item)) {
elements.push(item);
}
else if (validateIgnoreRegion(item)) {
regions.push(item);
}
else {
errorMessages.push(formatErrorMessage(item, 'is not a valid WebdriverIO element or region'));
}
}
if (errorMessages.length > 0) {
throw new Error('Invalid elements or regions: ' + errorMessages.join(', '));
}
return { elements, regions };
}
/**
* Get the regions from the elements
*/
export async function getRegionsFromElements(browserInstance, elements) {
const regions = [];
for (const element of elements) {
const region = await browserInstance.getElementRect(element.elementId);
regions.push(region);
}
return regions;
}
/**
* Translate ignores to regions
*/
export async function determineIgnoreRegions(browserInstance, ignores) {
const awaitedIgnores = await Promise.all(ignores);
const { elements, regions } = splitIgnores(awaitedIgnores);
const regionsFromElements = await getRegionsFromElements(browserInstance, elements);
return [...regions, ...regionsFromElements]
.map((region) => ({
x: Math.round(region.x),
y: Math.round(region.y),
width: Math.round(region.width),
height: Math.round(region.height),
}));
}
/**
* Determine the device block outs
*/
export async function determineDeviceBlockOuts({ isAndroid, screenCompareOptions, instanceData }) {
const rectangles = [];
const { blockOutStatusBar, blockOutToolBar } = screenCompareOptions;
const { deviceRectangles: { homeBar, statusBar } } = instanceData;
if (blockOutStatusBar) {
rectangles.push(statusBar);
}
if (isAndroid) {
//
}
else if (blockOutToolBar) {
rectangles.push(homeBar);
}
// @TODO: This is from the native-app-compare module, I can't really find the diffs between the two
// if (options.blockOutStatusBar) {
// rectangles.push(deviceInfo.rectangles.statusBar)
// }
// if (driver.isAndroid && options.blockOutNavigationBar) {
// rectangles.push(deviceInfo.rectangles.androidNavigationBar)
// }
return rectangles;
}
/**
* Prepare all ignore rectangles for image comparison
*/
export async function prepareIgnoreRectangles(options) {
const { blockOut, ignoreRegions, deviceRectangles, devicePixelRatio, isMobile, isNativeContext, isAndroid, isAndroidNativeWebScreenshot, isViewPortScreenshot, imageCompareOptions, actualFilePath } = options;
// Get blockOut rectangles
let webStatusAddressToolBarOptions = [];
// Handle mobile web status/address/toolbar rectangles
if (isMobile && !isNativeContext) {
const statusAddressToolBarOptions = {
blockOutSideBar: imageCompareOptions.blockOutSideBar,
blockOutStatusBar: imageCompareOptions.blockOutStatusBar,
blockOutToolBar: imageCompareOptions.blockOutToolBar,
isAndroid,
isAndroidNativeWebScreenshot,
isMobile,
isViewPortScreenshot,
};
webStatusAddressToolBarOptions.push(...(determineStatusAddressToolBarRectangles({ deviceRectangles, options: statusAddressToolBarOptions })) || []);
if (webStatusAddressToolBarOptions.length > 0) {
// There's an issue with the resemble lib when all the rectangles are 0,0,0,0, it will see this as a full
// blockout of the image and the comparison will succeed with 0 % difference.
// Additionally, rectangles with either width or height equal to 0 will result in an entire axis being ignored
// due to how resemble handles falsy values. Filter those out up front.
webStatusAddressToolBarOptions = webStatusAddressToolBarOptions
.filter((rectangle) => !(rectangle.x === 0 && rectangle.y === 0 && rectangle.width === 0 && rectangle.height === 0))
.filter((rectangle) => rectangle.width > 0 && rectangle.height > 0);
}
// Handle home bar (iOS) blockOut for full page screenshots
// The toolbar should be blocked out by default (when blockOutToolBar is true or undefined)
if (!isViewPortScreenshot && imageCompareOptions.blockOutToolBar !== false && actualFilePath) {
try {
// For iOS: block out home bar
if (!isAndroid && deviceRectangles.homeBar.height > 0) {
const image = await Jimp.read(actualFilePath);
const imageHeightDevicePixels = image.bitmap.height;
const imageHeightCssPixels = imageHeightDevicePixels / devicePixelRatio;
// Adjust home bar X position relative to the viewport (full page image only contains viewport)
const viewportXCssPixels = deviceRectangles.viewport.x;
const homeBarXRelativeToViewport = deviceRectangles.homeBar.x - viewportXCssPixels;
// Position the home bar at the bottom of the full page image
const homeBarYFullPageCssPixels = imageHeightCssPixels - deviceRectangles.homeBar.height;
const homeBarRectangle = {
x: homeBarXRelativeToViewport,
y: homeBarYFullPageCssPixels,
width: deviceRectangles.homeBar.width,
height: deviceRectangles.homeBar.height,
};
webStatusAddressToolBarOptions.push(homeBarRectangle);
}
}
catch (_error) {
// If we can't read the image, skip adding the toolbar blockOut
// This shouldn't happen in normal operation, but we don't want to fail the comparison
}
}
}
// Combine all ignore regions
const ignoredBoxes = [
// These come from the method
...blockOut,
// @TODO: I'm defaulting ignore regions for devices
// Need to check if this is the right thing to do for web and mobile browser tests
...ignoreRegions,
// Only get info about the status bars when we are in the web context
...webStatusAddressToolBarOptions
]
.map(
// Make sure all the rectangles are equal to the dpr for the screenshot
(rectangles) => {
return calculateDprData({
// Adjust for the ResembleJS API
bottom: rectangles.y + rectangles.height,
right: rectangles.x + rectangles.width,
left: rectangles.x,
top: rectangles.y,
},
// For Android we don't need to do it times the pixel ratio, for all others we need to
isAndroid ? 1 : devicePixelRatio);
});
return {
ignoredBoxes,
hasIgnoreRectangles: ignoredBoxes.length > 0
};
}