UNPKG

@wdio/image-comparison-core

Version:

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

395 lines (394 loc) 18.5 kB
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; 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); if (scrollHeight && (scrollY + effectiveViewportHeight < scrollHeight)) { amountOfScrollsArray.push(amountOfScrollsArray.length); } const remainingContent = scrollHeight ? scrollHeight - scrollY : 0; const imageHeight = amountOfScrollsArray.length === i && 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 const imageYPosition = (amountOfScrollsArray.length === i ? effectiveViewportHeight - imageHeight : 0) + viewportY + addressBarShadowPadding; // Store all the screenshot data in the screenshot object viewportScreenshots.push({ ...calculateDprData({ canvasWidth: isRotated ? effectiveViewportHeight : viewportWidth, canvasYPosition: scrollY, imageHeight: imageHeight, imageWidth: isRotated ? effectiveViewportHeight : viewportWidth, imageXPosition: viewportX, imageYPosition: imageYPosition, }, devicePixelRatio), screenshot, }); // 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'); } return { ...calculateDprData({ fullPageHeight: scrollHeight - addressBarShadowPadding - toolBarShadowPadding, fullPageWidth: isRotated ? effectiveViewportHeight : viewportWidth, }, 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; // 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 = 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 } // Determine scroll height and check if we need to scroll again scrollHeight = await browserInstance.execute(getDocumentScrollHeight); if (scrollHeight && (scrollY + actualInnerHeight < scrollHeight) && screenshotSize.height === actualInnerHeight) { amountOfScrollsArray.push(amountOfScrollsArray.length); } // There is no else, Lazy load and large screenshots, // like with older drivers such as FF <= 47 and IE11, will not work // The height of the image of the last 1 could be different const imageHeight = scrollHeight && amountOfScrollsArray.length === i ? scrollHeight - actualInnerHeight * viewportScreenshots.length : screenshotSize.height; // The starting position for cropping could be different for the last image (0 means no cropping) const imageYPosition = amountOfScrollsArray.length === i && amountOfScrollsArray.length !== 0 ? actualInnerHeight - 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, }); } // 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, }); } }