UNPKG

@wdio/image-comparison-core

Version:

Image comparison core module for @wdio/visual-service - WebdriverIO visual testing framework

569 lines (568 loc) 24.7 kB
import logger from '@wdio/logger'; import { join } from 'node:path'; import { DESKTOP, NOT_KNOWN } from './constants.js'; import { mkdirSync } from 'node:fs'; import { checkMetaTag } from '../clientSideScripts/checkMetaTag.js'; import { injectWebviewOverlay } from '../clientSideScripts/injectWebviewOverlay.js'; import { getMobileWebviewClickAndDimensions } from '../clientSideScripts/getMobileWebviewClickAndDimensions.js'; const log = logger('@wdio/visual-service:@wdio/image-comparison-core:utils'); /** * Get and create a folder */ export function getAndCreatePath(folder, options) { const { browserName = NOT_KNOWN, deviceName = NOT_KNOWN, isMobile, savePerInstance, } = options; const instanceName = (isMobile ? deviceName : `${DESKTOP}_${browserName}`).replace(/ /g, '_'); const subFolder = savePerInstance ? instanceName : ''; const folderName = join(folder, subFolder); mkdirSync(folderName, { recursive: true }); return folderName; } /** * Format the filename */ export function formatFileName(options) { const { browserName = NOT_KNOWN, browserVersion = NOT_KNOWN, deviceName = NOT_KNOWN, devicePixelRatio, isMobile, screenHeight, screenWidth, outerHeight = screenHeight, outerWidth = screenWidth, isTestInBrowser, name, platformName, platformVersion, tag, } = options; const defaults = { browserName, browserVersion, deviceName, dpr: devicePixelRatio, height: isMobile ? screenHeight : outerHeight, logName: options.logName, mobile: isMobile && isTestInBrowser ? browserName : isMobile ? 'app' : NOT_KNOWN, name: name, platformName, platformVersion, tag, width: isMobile ? screenWidth : outerWidth, }; let fileName = options.formatImageName; Object.keys(defaults).forEach((value) => { // @ts-ignore // @TODO: Fix this in a proper way fileName = fileName.replace(`{${value}}`, defaults[value]); }); return `${fileName.replace(/ /g, '_')}.png`; } /** * Checks if the test is executed in a browser * checking for app is not sufficient because different vendors have different * custom names and or solutions for the app */ export function checkTestInBrowser(browserName) { return browserName !== ''; } /** * Checks if the test is executed in a browser on a mobile phone */ export function checkTestInMobileBrowser(isMobile, browserName) { return isMobile && checkTestInBrowser(browserName); } /** * Checks if this is a native webscreenshot on android */ export function checkAndroidNativeWebScreenshot(isAndroid, nativeWebscreenshot) { return (isAndroid && nativeWebscreenshot) || false; } /** * Checks if this is an Android chromedriver screenshot */ export function checkAndroidChromeDriverScreenshot(isAndroid, nativeWebScreenshot) { return isAndroid && !checkAndroidNativeWebScreenshot(isAndroid, nativeWebScreenshot); } /** * Get the address bar shadow padding. This is only needed for Android native webscreenshot and iOS */ export function getAddressBarShadowPadding(options) { const { browserName, isAndroid, isIOS, isMobile, nativeWebScreenshot, addressBarShadowPadding, addShadowPadding } = options; const isTestInMobileBrowser = checkTestInMobileBrowser(isMobile, browserName); const isAndroidNativeWebScreenshot = checkAndroidNativeWebScreenshot(isAndroid, nativeWebScreenshot); return isTestInMobileBrowser && ((isAndroidNativeWebScreenshot && isAndroid) || isIOS) && addShadowPadding ? addressBarShadowPadding : 0; } /** * Get the tool bar shadow padding. Add some extra padding for iOS when we have a home bar */ export function getToolBarShadowPadding(options) { const { isMobile, browserName, isIOS, toolBarShadowPadding, addShadowPadding } = options; return checkTestInMobileBrowser(isMobile, browserName) && addShadowPadding ? isIOS ? // The 9 extra are for iOS home bar for iPhones with a notch or iPads with a home bar toolBarShadowPadding + 9 : toolBarShadowPadding : 0; } /** * Calculate the data based on the device pixel ratio */ export function calculateDprData(data, devicePixelRatio) { // @ts-ignore // @TODO: need to figure this one out Object.keys(data).map((key) => (data[key] = typeof data[key] === 'number' ? Math.round(data[key] * devicePixelRatio) : data[key])); return data; } /** * Wait for an amount of milliseconds */ export async function waitFor(milliseconds) { /* istanbul ignore next */ return new Promise((resolve) => setTimeout(() => resolve(), milliseconds)); } /** * Get the size of a screenshot in pixels without the device pixel ratio */ export function getBase64ScreenshotSize(screenshot, devicePixelRation = 1) { return { height: Math.round(Buffer.from(screenshot, 'base64').readUInt32BE(20) / devicePixelRation), width: Math.round(Buffer.from(screenshot, 'base64').readUInt32BE(16) / devicePixelRation), }; } /** * Get the device pixel ratio */ export function getDevicePixelRatio(screenshot, deviceScreenSize) { const screenshotSize = getBase64ScreenshotSize(screenshot); const devicePixelRatio = screenshotSize.width / deviceScreenSize.width; return Math.round(devicePixelRatio); } /** * Get the iOS bezel image names */ export function getIosBezelImageNames(normalizedDeviceName) { let topImageName, bottomImageName; switch (normalizedDeviceName) { case 'iphonex': topImageName = 'iphonex.iphonexs.iphone11pro-top'; bottomImageName = 'iphonex.iphonexs.iphone11pro-bottom'; break; case 'iphonexs': topImageName = 'iphonex.iphonexs.iphone11pro-top'; bottomImageName = 'iphonex.iphonexs.iphone11pro-bottom'; break; case 'iphonexsmax': topImageName = 'iphonexsmax-top'; bottomImageName = 'iphonexsmax-bottom'; break; case 'iphonexr': topImageName = 'iphonexr.iphone11-top'; bottomImageName = 'iphonexr.iphone11-bottom'; break; case 'iphone11': topImageName = 'iphonexr.iphone11-top'; bottomImageName = 'iphonexr.iphone11-bottom'; break; case 'iphone11pro': topImageName = 'iphonex.iphonexs.iphone11pro-top'; bottomImageName = 'iphonex.iphonexs.iphone11pro-bottom'; break; case 'iphone11promax': topImageName = 'iphone11promax-top'; bottomImageName = 'iphone11promax-bottom'; break; case 'iphone12': topImageName = 'iphone12.iphone12pro-top'; bottomImageName = 'iphone12.iphone12pro.iphone13.iphone13pro.iphone14-bottom'; break; case 'iphone12mini': topImageName = 'iphone12mini-top'; bottomImageName = 'iphone12mini.iphone13mini-bottom'; break; case 'iphone12pro': topImageName = 'iphone12.iphone12pro-top'; bottomImageName = 'iphone12.iphone12pro.iphone13.iphone13pro.iphone14-bottom'; break; case 'iphone12promax': topImageName = 'iphone12promax-top'; bottomImageName = 'iphone12promax.iphone13promax.iphone14plus-bottom'; break; case 'iphone13': topImageName = 'iphone13.iphone13pro.iphone14-top'; bottomImageName = 'iphone12.iphone12pro.iphone13.iphone13pro.iphone14-bottom'; break; case 'iphone13mini': topImageName = 'iphone13mini-top'; bottomImageName = 'iphone12mini.iphone13mini-bottom'; break; case 'iphone13pro': topImageName = 'iphone13.iphone13pro.iphone14-top'; bottomImageName = 'iphone12.iphone12pro.iphone13.iphone13pro.iphone14-bottom'; break; case 'iphone13promax': topImageName = 'iphone13promax.iphone14plus-top'; bottomImageName = 'iphone12promax.iphone13promax.iphone14plus-bottom'; break; case 'iphone14': topImageName = 'iphone13.iphone13pro.iphone14-top'; bottomImageName = 'iphone12.iphone12pro.iphone13.iphone13pro.iphone14-bottom'; break; case 'iphone14plus': topImageName = 'iphone13promax.iphone14plus-top'; bottomImageName = 'iphone12promax.iphone13promax.iphone14plus-bottom'; break; case 'iphone14pro': topImageName = 'iphone14pro-top'; bottomImageName = 'iphone14pro-bottom'; break; case 'iphone14promax': topImageName = 'iphone14promax-top'; bottomImageName = 'iphone14promax-bottom'; break; case 'iphone15': topImageName = 'iphone15-top'; bottomImageName = 'iphone15-bottom'; break; // iPad case 'ipadmini': topImageName = 'ipadmini6th-top'; bottomImageName = 'ipadmini6th-bottom'; break; case 'ipadair': topImageName = 'ipadair4th.ipadair5th-top'; bottomImageName = 'ipadair4th.ipadair5th-bottom'; break; case 'ipadpro11': topImageName = 'ipadpro11-top'; bottomImageName = 'ipadpro11-bottom'; break; case 'ipadpro129': topImageName = 'ipadpro129-top'; bottomImageName = 'ipadpro129-bottom'; break; } if (!topImageName || !bottomImageName) { throw new Error(`Could not find iOS bezel images for device ${normalizedDeviceName}`); } return { topImageName, bottomImageName }; } /** * Validate that the item is an object */ export function isObject(item) { return (typeof item === 'object' && item !== null) || typeof item === 'function'; } /** * Validate if it's storybook */ export function isStorybook() { return process.argv.includes('--storybook'); } /** * Check if we want to update baseline images */ export function updateVisualBaseline() { return process.argv.includes('--update-visual-baseline'); } /** * Log the deprecated root compareOptions (at `ClassOptions` level) * and returns non-undefined ones to be added back to the config */ export function logAllDeprecatedCompareOptions(options) { const deprecatedKeys = [ 'blockOutSideBar', 'blockOutStatusBar', 'blockOutToolBar', 'createJsonReportFiles', 'diffPixelBoundingBoxProximity', 'ignoreAlpha', 'ignoreAntialiasing', 'ignoreColors', 'ignoreLess', 'ignoreNothing', 'rawMisMatchPercentage', 'returnAllCompareData', 'saveAboveTolerance', 'scaleImagesToSameSize', ]; const foundDeprecatedKeys = deprecatedKeys.filter((key) => key in options); if (foundDeprecatedKeys.length > 0) { log.warn('The following root-level compare options are deprecated and should be moved under \'compareOptions\':\n' + foundDeprecatedKeys.map((k) => ` - ${k}`).join('\n') + '\nIn the next major version, these options will be removed from the root level and only be available under \'compareOptions\''); } return foundDeprecatedKeys.reduce((acc, key) => { if (options[key] !== undefined) { acc[key] = options[key]; } return acc; }, {}); } /** * Get the mobile screen size, this is different for native and webview */ export async function getMobileScreenSize({ browserInstance, isIOS, isNativeContext, }) { let height = 0, width = 0; const isLandscapeByOrientation = (await browserInstance.getOrientation()).toUpperCase() === 'LANDSCAPE'; try { if (isIOS) { ({ screenSize: { height, width } } = (await browserInstance.execute('mobile: deviceScreenInfo'))); // It's Android } else { const { realDisplaySize } = (await browserInstance.execute('mobile: deviceInfo')); if (!realDisplaySize || !/^\d+x\d+$/.test(realDisplaySize)) { throw new Error(`Invalid realDisplaySize format. Expected 'widthxheight', got "${realDisplaySize}"`); } [width, height] = realDisplaySize.split('x').map(Number); } } catch (error) { log.warn('Error getting mobile screen size:\n', error, `\nFalling back to ${isNativeContext ? '`getWindowSize()` which might not be as accurate' : 'window.screen.height and window.screen.width'}`); if (isNativeContext) { ({ height, width } = await browserInstance.getWindowSize()); } else { // This is a fallback and not 100% accurate, but we need to have something =) ({ height, width } = await browserInstance.execute(() => { const { height, width } = window.screen; return { height, width }; })); } } // There are issues where the landscape mode by orientation is not the same as the landscape mode by value // So we need to check and fix this const isLandscapeByValue = width > height; if (isLandscapeByOrientation !== isLandscapeByValue) { [height, width] = [width, height]; } return { height, width }; } /** * Load a base64 HTML page in the browser */ export async function loadBase64Html({ browserInstance, isIOS }) { const htmlContent = ` <html> <head> <title>Base64 Page</title> <meta name="viewport" content="width=device-width, initial-scale=1"> <script> document.addEventListener("DOMContentLoaded", function() { // Force correct viewport settings const meta = document.querySelector("meta[name='viewport']"); if (!meta) { const newMeta = document.createElement("meta"); newMeta.name = "viewport"; newMeta.content = "width=device-width, initial-scale=1"; document.head.appendChild(newMeta); } }); </script> </head> <body> <h1>Hello from Base64!</h1> <p>This page was loaded without visiting a URL.</p> </body> </html>`; await browserInstance.execute((htmlContent) => { const blob = new Blob([htmlContent], { type: 'text/html' }); const blobUrl = URL.createObjectURL(blob); window.location.href = blobUrl; }, htmlContent); if (isIOS) { await browserInstance.execute(checkMetaTag); } } /** * Execute a native click */ export async function executeNativeClick({ browserInstance, isIOS, x, y }) { if (isIOS) { return browserInstance.execute('mobile: tap', { x, y }); } try { // The `clickGesture` is not working on Appium 1, only on Appium 2 await browserInstance.execute('mobile: clickGesture', { x, y }); } catch (error) { if (error instanceof Error && /WebDriverError: Unknown mobile command.*?(clickGesture|tap)/i.test(error.message)) { log.warn('Error executing `clickGesture`, falling back to `doubleClickGesture`. This likely means you are using Appium 1. Is this intentional?'); await browserInstance.execute('mobile: doubleClickGesture', { x, y }); } else { throw error; } } } /** * Get the mobile viewport position, we determine this by: * 1. Loading a base64 HTML page * 2. Injecting an overlay on top of the webview with an event listener that stores the click position in the webview * 3. Clicking on the overlay in the center of the screen with a native click * 4. Getting the data from the overlay and removing it * 5. Calculating the position of the viewport based on the click position of the native click vs the overlay * 6. Returning the calculated values */ export async function getMobileViewPortPosition({ browserInstance, initialDeviceRectangles, isAndroid, isIOS, isNativeContext, nativeWebScreenshot, screenHeight, screenWidth, }) { if (!isNativeContext && (isIOS || (isAndroid && nativeWebScreenshot))) { const currentUrl = await browserInstance.getUrl(); // 1. Load a base64 HTML page await loadBase64Html({ browserInstance, isIOS }); // 2. Inject an overlay on top of the webview with an event listener that stores the click position in the webview await browserInstance.execute(injectWebviewOverlay, isAndroid); // 3. Click on the overlay in the center of the screen with a native click const nativeClickX = screenWidth / 2; const nativeClickY = screenHeight / 2; await executeNativeClick({ browserInstance, isIOS, x: nativeClickX, y: nativeClickY }); // We need to wait a bit here, otherwise the click is not registered await waitFor(100); // 4a. Get the data from the overlay and remove it const { y, x, width, height } = await browserInstance.execute(getMobileWebviewClickAndDimensions, '[data-test="ics-overlay"]'); // 4.b reset the url await browserInstance.url(currentUrl); // 5. Calculate the position of the viewport based on the click position of the native click vs the overlay const viewportTop = Math.max(0, Math.round(nativeClickY - y)); const viewportLeft = Math.max(0, Math.round(nativeClickX - x)); const statusBarAndAddressBarHeight = Math.max(0, Math.round(viewportTop)); const bottomBarHeight = Math.max(0, Math.round(screenHeight - (viewportTop + height))); const leftSidePaddingWidth = Math.max(0, Math.round(viewportLeft)); const rightSidePaddingWidth = Math.max(0, Math.round(screenWidth - (viewportLeft + width))); const deviceRectangles = { ...initialDeviceRectangles, bottomBar: { y: viewportTop + height, x: 0, width: screenWidth, height: bottomBarHeight }, leftSidePadding: { y: viewportTop, x: 0, width: leftSidePaddingWidth, height: height }, rightSidePadding: { y: viewportTop, x: viewportLeft + width, width: rightSidePaddingWidth, height: height }, screenSize: { height: screenHeight, width: screenWidth }, statusBarAndAddressBar: { y: 0, x: 0, width: screenWidth, height: statusBarAndAddressBarHeight }, viewport: { y: viewportTop, x: viewportLeft, width: width, height: height }, }; return deviceRectangles; } // No WebView detected, return empty values return initialDeviceRectangles; } /** * Get the value of a method or the default value */ export function getMethodOrWicOption(method, wic, key) { return method?.[key] ?? wic[key]; } /** * Determine if the Bidi screenshot can be used */ export function canUseBidiScreenshot(browserInstance) { const { isBidi } = browserInstance; const hasBrowsingContextCaptureScreenshot = typeof browserInstance.browsingContextCaptureScreenshot === 'function'; const hasGetWindowHandle = typeof browserInstance.getWindowHandle === 'function'; return isBidi && hasBrowsingContextCaptureScreenshot && hasGetWindowHandle; } /** * Helper function to safely check boolean properties with proper defaults */ export function getBooleanOption(options, key, defaultValue) { return Object.prototype.hasOwnProperty.call(options, key) && options[key] !== undefined ? Boolean(options[key]) : defaultValue; } /** * Helper function to create conditional property objects for cleaner spread operations */ export function createConditionalProperty(condition, key, value) { return condition ? { [key]: value } : {}; } /** * Check if resizeDimensions has any non-zero values (indicating it's been changed from default) */ export function hasResizeDimensions(resizeDimensions) { return resizeDimensions && Object.values(resizeDimensions).some(value => value !== 0); } /** * Extracts common variables used across all check methods to reduce duplication */ export function extractCommonCheckVariables(options) { const { folders, instanceData, wicOptions } = options; return { // Folders actualFolder: folders.actualFolder, baselineFolder: folders.baselineFolder, diffFolder: folders.diffFolder, // Instance data browserName: instanceData.browserName, deviceName: instanceData.deviceName, deviceRectangles: instanceData.deviceRectangles, isAndroid: instanceData.isAndroid, isMobile: instanceData.isMobile, isAndroidNativeWebScreenshot: instanceData.nativeWebScreenshot, // Optional instance data ...(instanceData.platformName && { platformName: instanceData.platformName }), ...(instanceData.isIOS !== undefined && { isIOS: instanceData.isIOS }), // WIC options autoSaveBaseline: wicOptions.autoSaveBaseline, savePerInstance: wicOptions.savePerInstance, ...(wicOptions.alwaysSaveActualImage !== undefined && { alwaysSaveActualImage: wicOptions.alwaysSaveActualImage }), // Optional WIC options ...(wicOptions.isHybridApp !== undefined && { isHybridApp: wicOptions.isHybridApp }), }; } /** * Builds folder options object used across all check methods to reduce duplication */ export function buildFolderOptions(options) { const { commonCheckVariables } = options; return { autoSaveBaseline: commonCheckVariables.autoSaveBaseline, ...(commonCheckVariables.alwaysSaveActualImage !== undefined && { alwaysSaveActualImage: commonCheckVariables.alwaysSaveActualImage }), actualFolder: commonCheckVariables.actualFolder, baselineFolder: commonCheckVariables.baselineFolder, diffFolder: commonCheckVariables.diffFolder, browserName: commonCheckVariables.browserName, deviceName: commonCheckVariables.deviceName, isMobile: commonCheckVariables.isMobile, savePerInstance: commonCheckVariables.savePerInstance, }; } /** * Builds base execute compare options object used across all check methods to reduce duplication */ export function buildBaseExecuteCompareOptions(options) { const { commonCheckVariables, wicCompareOptions, methodCompareOptions, devicePixelRatio, fileName, isElementScreenshot = false, additionalProperties = {} } = options; // For element screenshots, override blockOut options to false const processedWicOptions = isElementScreenshot ? { ...wicCompareOptions, blockOutSideBar: false, blockOutStatusBar: false, blockOutToolBar: false, } : wicCompareOptions; const baseOptions = { compareOptions: { wic: processedWicOptions, method: methodCompareOptions, }, devicePixelRatio, deviceRectangles: commonCheckVariables.deviceRectangles, fileName, folderOptions: buildFolderOptions({ commonCheckVariables }), isAndroid: commonCheckVariables.isAndroid, isAndroidNativeWebScreenshot: commonCheckVariables.isAndroidNativeWebScreenshot, // Add optional properties from commonCheckVariables if they exist ...(commonCheckVariables.platformName && { platformName: commonCheckVariables.platformName }), ...(commonCheckVariables.isIOS !== undefined && { isIOS: commonCheckVariables.isIOS }), ...(commonCheckVariables.isHybridApp !== undefined && { isHybridApp: commonCheckVariables.isHybridApp }), }; // Add any additional properties return { ...baseOptions, ...additionalProperties, }; } /** * Prepare all file paths needed for image comparison */ export function prepareComparisonFilePaths(options) { const { actualFolder, baselineFolder, diffFolder, browserName, deviceName, isMobile, savePerInstance, fileName } = options; const createFolderOptions = { browserName, deviceName, isMobile, savePerInstance }; const actualFolderPath = getAndCreatePath(actualFolder, createFolderOptions); const baselineFolderPath = getAndCreatePath(baselineFolder, createFolderOptions); const diffFolderPath = getAndCreatePath(diffFolder, createFolderOptions); const actualFilePath = join(actualFolderPath, fileName); const baselineFilePath = join(baselineFolderPath, fileName); const diffFilePath = join(diffFolderPath, fileName); return { actualFolderPath, baselineFolderPath, diffFolderPath, actualFilePath, baselineFilePath, diffFilePath }; }