diff --git a/.eslintignore b/.eslintignore index c7d3ca923..82855125d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -6,4 +6,5 @@ bundle.compat.js bundle.native.js wt test/e2e/report +test/e2e/static/basic-report tmp diff --git a/.prettierignore b/.prettierignore index e14314411..fc15f0618 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,3 +11,4 @@ bundle.native.js wt/** test/e2e/report tmp/** +.testplane/** diff --git a/package.json b/package.json index 8ffde789d..e8c102ccd 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,9 @@ "test-unit": "npm run create-client-scripts-symlinks && _mocha \"test/!(integration|e2e)/**/*.js\"", "test": "npm run test-unit && npm run check-types && npm run lint", "test-integration": "npm run create-client-scripts-symlinks && mocha -r ts-node/register -r test/integration/*/**", - "test-e2e": "node bin/testplane --config test/e2e/testplane.config.ts", + "test-e2e": "npm run test-e2e:generate-fixtures && npm run test-e2e:run-tests", + "test-e2e:run-tests": "node bin/testplane --config test/e2e/testplane.config.ts", + "test-e2e:generate-fixtures": "node bin/testplane --config test/e2e/fixtures/basic-report/testplane.config.ts", "test-e2e:gui": "node bin/testplane --config test/e2e/testplane.config.ts gui", "toc": "doctoc docs --title '### Contents'", "precommit": "npm run lint", diff --git a/src/browser/camera/index.ts b/src/browser/camera/index.ts index ccb494489..79f9a770b 100644 --- a/src/browser/camera/index.ts +++ b/src/browser/camera/index.ts @@ -1,6 +1,9 @@ import _ from "lodash"; import { Image } from "../../image"; import * as utils from "./utils"; +import makeDebug from "debug"; + +const debug = makeDebug("testplane:screenshots:camera"); export interface ImageArea { left: number; @@ -41,7 +44,8 @@ export class Camera { this._calibration = calibration; } - async captureViewportImage(page?: PageMeta): Promise { + /** @param viewport - Current state of the viewport. Top/left denote scroll offsets, width/height denote viewport size. */ + async captureViewportImage(viewport?: ImageArea): Promise { const base64 = await this._takeScreenshot(); const image = Image.fromBase64(base64); @@ -49,7 +53,7 @@ export class Camera { const imageArea: ImageArea = { left: 0, top: 0, width, height }; const calibratedArea = this._calibrateArea(imageArea); - const viewportCroppedArea = this._cropAreaToViewport(calibratedArea, page); + const viewportCroppedArea = this._cropAreaToViewport(calibratedArea, viewport); if (viewportCroppedArea.width !== width || viewportCroppedArea.height !== height) { await image.crop(viewportCroppedArea); @@ -68,23 +72,28 @@ export class Camera { return { left, top, width: imageArea.width - left, height: imageArea.height - top }; } - private _cropAreaToViewport(imageArea: ImageArea, page?: PageMeta): ImageArea { - if (!page) { + /* On some browsers, e.g. older firefox versions, the screenshot returned by the browser can be the whole page + (even beyond the viewport, potentially spanning thousands of pixels down). + This function is used to detect such cases and crop the image to the viewport, always. */ + private _cropAreaToViewport(imageArea: ImageArea, viewport?: ImageArea): ImageArea { + if (!viewport) { return imageArea; } - const isFullPage = utils.isFullPage(imageArea, page, this._screenshotMode); - const cropArea = _.clone(page.viewport); + const isFullPage = utils.isFullPage(imageArea, viewport, this._screenshotMode); + const cropArea = _.clone(viewport); if (!isFullPage) { _.extend(cropArea, { top: 0, left: 0 }); } - - return { - left: imageArea.left + cropArea.left, - top: imageArea.top + cropArea.top, - width: Math.min(imageArea.width - cropArea.left, cropArea.width), - height: Math.min(imageArea.height - cropArea.top, cropArea.height), - }; + debug( + "cropping area to viewport. imageArea: %O, viewport: %O, cropArea: %O, isFullPage: %s", + imageArea, + viewport, + cropArea, + isFullPage, + ); + + return utils.getIntersection(imageArea, cropArea); } } diff --git a/src/browser/camera/utils.ts b/src/browser/camera/utils.ts index 36fa31303..37a53ef5b 100644 --- a/src/browser/camera/utils.ts +++ b/src/browser/camera/utils.ts @@ -1,16 +1,26 @@ -import { ImageArea, PageMeta, ScreenshotMode } from "."; +import { ImageArea, ScreenshotMode } from "."; -export const isFullPage = (imageArea: ImageArea, page: PageMeta, screenshotMode: ScreenshotMode): boolean => { +export const isFullPage = (imageArea: ImageArea, viewport: ImageArea, screenshotMode: ScreenshotMode): boolean => { switch (screenshotMode) { case "fullpage": return true; case "viewport": return false; case "auto": - return compareDimensions(imageArea, page); + return imageArea.height > viewport.height || imageArea.width > viewport.width; } }; -function compareDimensions(imageArea: ImageArea, page: PageMeta): boolean { - return imageArea.height >= page.documentHeight && imageArea.width >= page.documentWidth; -} +export const getIntersection = (area1: ImageArea, area2: ImageArea): ImageArea => { + const left = Math.max(area1.left, area2.left); + const top = Math.max(area1.top, area2.top); + const right = Math.min(area1.left + area1.width, area2.left + area2.width); + const bottom = Math.min(area1.top + area1.height, area2.top + area2.height); + + return { + left, + top, + width: Math.max(0, right - left), + height: Math.max(0, bottom - top), + }; +}; diff --git a/src/browser/client-scripts/index.js b/src/browser/client-scripts/index.js index 43fcaeef2..6d5b3cd05 100644 --- a/src/browser/client-scripts/index.js +++ b/src/browser/client-scripts/index.js @@ -44,6 +44,19 @@ exports.cleanupFrameAnimations = function cleanupFrameAnimations() { } }; +exports.disablePointerEvents = function disablePointerEvents() { + try { + return disablePointerEventsUnsafe(); + } catch (e) { + return { + errorCode: "JS", + message: e.stack || e.message + }; + } +}; + +exports.cleanupPointerEvents = function cleanupPointerEvents() {}; + function prepareScreenshotUnsafe(areas, opts) { var logger = util.createDebugLogger(opts); @@ -77,6 +90,7 @@ function prepareScreenshotUnsafe(areas, opts) { } }); + var initialRect = rect; var captureElements = getCaptureElements(selectors); if (opts.selectorToScroll) { @@ -111,6 +125,7 @@ function prepareScreenshotUnsafe(areas, opts) { rect = getCaptureRect( captureElements, { + initialRect: initialRect, allowViewportOverflow: allowViewportOverflow, scrollElem: scrollElem, viewportWidth: viewportWidth, @@ -164,10 +179,13 @@ function prepareScreenshotUnsafe(areas, opts) { return top; }, 9999999); - if ( - captureElementFromTop && - (topmostCaptureElementTop < safeArea.top || topmostCaptureElementTop >= safeArea.top + safeArea.height) - ) { + var scrollOffsetTopForFit = scrollElem === window || !scrollElem.parentElement ? 0 : util.getScrollTop(scrollElem); + var rectTopInViewportForFit = rect.top - scrollOffsetTopForFit - window.scrollY; + var rectBottomInViewportForFit = rectTopInViewportForFit + rect.height; + var fitsInSafeArea = + rectTopInViewportForFit >= safeArea.top && rectBottomInViewportForFit <= safeArea.top + safeArea.height; + + if (captureElementFromTop && !fitsInSafeArea) { logger("captureElementFromTop=true and capture element is outside of viewport, going to perform scroll"); if (!util.isRootElement(scrollElem) && captureElementFromTop) { var scrollElemBoundingRect = getBoundingClientContentRect(scrollElem); @@ -184,6 +202,7 @@ function prepareScreenshotUnsafe(areas, opts) { rect = getCaptureRect( captureElements, { + initialRect: initialRect, allowViewportOverflow: allowViewportOverflow, scrollElem: scrollElem, viewportWidth: viewportWidth, @@ -242,6 +261,7 @@ function prepareScreenshotUnsafe(areas, opts) { rect = getCaptureRect( captureElements, { + initialRect: initialRect, allowViewportOverflow: allowViewportOverflow, scrollElem: scrollElem, viewportWidth: viewportWidth, @@ -346,6 +366,25 @@ function prepareScreenshotUnsafe(areas, opts) { disableFrameAnimationsUnsafe(); } + var disableHover = opts.disableHover; + var pointerEventsDisabled = false; + if (disableHover === "always") { + logger("adding stylesheet with pointer-events: none on all elements"); + disablePointerEventsUnsafe(); + pointerEventsDisabled = true; + } else if (disableHover === "when-scrolling-needed" && opts.compositeImage) { + var scrollOffsetTop = scrollElem === window || !scrollElem.parentElement ? 0 : util.getScrollTop(scrollElem); + var rectTopInViewport = rect.top - scrollOffsetTop - window.scrollY; + var needsScrolling = + rectTopInViewport < safeArea.top || rectTopInViewport + rect.height > safeArea.top + safeArea.height; + + if (needsScrolling) { + logger("adding stylesheet with pointer-events: none on all elements (composite capture needs scrolling)"); + disablePointerEventsUnsafe(); + pointerEventsDisabled = true; + } + } + logger("prepareScreenshotUnsafe, final capture rect:", rect); logger("prepareScreenshotUnsafe, pixelRatio:", pixelRatio); @@ -379,10 +418,21 @@ function prepareScreenshotUnsafe(areas, opts) { ? 0 : Math.floor(util.getScrollLeft(scrollElem) * pixelRatio) }, + pointerEventsDisabled: pointerEventsDisabled, debugLog: logger() }; } +function createDefaultTrustedTypesPolicy() { + if (window.trustedTypes && window.trustedTypes.createPolicy) { + window.trustedTypes.createPolicy("default", { + createHTML: function (string) { + return string; + } + }); + } +} + function disableFrameAnimationsUnsafe() { var everyElementSelector = "*:not(#testplane-q.testplane-w.testplane-e.testplane-r.testplane-t.testplane-y)"; var everythingSelector = ["", "::before", "::after"] @@ -412,16 +462,6 @@ function disableFrameAnimationsUnsafe() { styleElements.push(styleElement); } - function createDefaultTrustedTypesPolicy() { - if (window.trustedTypes && window.trustedTypes.createPolicy) { - window.trustedTypes.createPolicy("default", { - createHTML: function (string) { - return string; - } - }); - } - } - util.forEachRoot(function (root) { try { appendDisableAnimationStyleElement(root); @@ -446,6 +486,46 @@ function disableFrameAnimationsUnsafe() { }; } +function disablePointerEventsUnsafe() { + var everyElementSelector = "*:not(#testplane-q.testplane-w.testplane-e.testplane-r.testplane-t.testplane-y)"; + var everythingSelector = ["", "::before", "::after"] + .map(function (pseudo) { + return everyElementSelector + pseudo; + }) + .join(", "); + + var styleElements = []; + + function appendDisablePointerEventsStyleElement(root) { + var styleElement = document.createElement("style"); + styleElement.innerHTML = everythingSelector + ["{", " pointer-events: none !important;", "}"].join("\n"); + + root.appendChild(styleElement); + styleElements.push(styleElement); + } + + util.forEachRoot(function (root) { + try { + appendDisablePointerEventsStyleElement(root); + } catch (err) { + if (err && err.message && err.message.includes("This document requires 'TrustedHTML' assignment")) { + createDefaultTrustedTypesPolicy(); + + appendDisablePointerEventsStyleElement(root); + } else { + throw err; + } + } + }); + + exports.cleanupPointerEvents = function () { + for (var i = 0; i < styleElements.length; i++) { + styleElements[i].parentNode.removeChild(styleElements[i]); + } + exports.cleanupPointerEvents = function () {}; + }; +} + exports.resetZoom = function () { var meta = lib.queryFirst('meta[name="viewport"]'); if (!meta) { @@ -501,6 +581,7 @@ function getSafeAreaRect(captureArea, captureElements, opts, logger) { }); } + var captureElementsOrBody = captureElements.length > 0 ? captureElements : [document.body]; var scrollElem = opts.scrollElem; var viewportHeight = opts.viewportHeight; @@ -523,7 +604,7 @@ function getSafeAreaRect(captureArea, captureElements, opts, logger) { // 2. Build z-index chains for all capture elements // One z-chain is a list of objects: { stacking context, z-index } -> { stacking context, z-index } -> ... // It is used to determine which element is on top of the other. - var targetChains = captureElements.map(function (el) { + var targetChains = captureElementsOrBody.map(function (el) { return util.buildZChain(el); }); @@ -534,10 +615,9 @@ function getSafeAreaRect(captureArea, captureElements, opts, logger) { var interferingRects = []; allElements.forEach(function (el) { - logger("getSafeAreaRect(), processing potentially interfering element: " + el.classList.toString()); // Skip elements that contain capture elements if ( - util.some(captureElements, function (capEl) { + util.some(captureElementsOrBody, function (capEl) { return el.contains(capEl); }) ) { @@ -559,12 +639,13 @@ function getSafeAreaRect(captureArea, captureElements, opts, logger) { } var likelyInterferes = false; + var interferenceReason = ""; if (position === "fixed") { likelyInterferes = true; } else if (position === "absolute") { // Skip absolutely positioned elements that are inside capture elements if ( - captureElements.some(function (captureEl) { + captureElementsOrBody.some(function (captureEl) { return captureEl.contains(el); }) ) { @@ -579,6 +660,7 @@ function getSafeAreaRect(captureArea, captureElements, opts, logger) { typeof scrollElem.contains === "function" && !scrollElem.contains(containingBlock) ) { + interferenceReason = "absolute element is positioned relative to ancestor outside scroll container"; likelyInterferes = true; } } else if (position === "sticky") { @@ -593,33 +675,43 @@ function getSafeAreaRect(captureArea, captureElements, opts, logger) { } if (!isNaN(topValue)) { - br = { + br = new Rect({ left: br.left, top: topValue, width: br.width, height: br.height - }; + }); likelyInterferes = true; - logger(" it is sticky to top! topValue: " + topValue + " bounding rect: " + JSON.stringify(br)); + interferenceReason = + "sticky element is positioned to top, topValue: " + + topValue + + " bounding rect: " + + JSON.stringify(br); } else if (!isNaN(bottomValue)) { var viewportBottom = util.isRootElement(scrollElem) ? viewportHeight : safeArea.top + safeArea.height; - br = { + br = new Rect({ left: br.left, top: viewportBottom - bottomValue - br.height, width: br.width, height: br.height - }; + }); likelyInterferes = true; - logger( - " it is sticky to bottom! bottomValue: " + bottomValue + " bounding rect: " + JSON.stringify(br) - ); + interferenceReason = + "sticky element is positioned to bottom, bottomValue: " + + bottomValue + + " bounding rect: " + + JSON.stringify(br); } } - logger(" likely interferes: " + likelyInterferes); - if (likelyInterferes) { - var candChain = util.buildZChain(el); + logger( + "getSafeAreaRect(), this element likely interferes: " + + el.classList.toString() + + " interference reason: " + + interferenceReason + ); + var candChain = util.buildZChain(el, { includeReasons: false }); var behindAll = targetChains.every(function (tChain) { return util.isChainBehind(candChain, tChain); @@ -628,7 +720,19 @@ function getSafeAreaRect(captureArea, captureElements, opts, logger) { logger(" is candidate z chain behind all target chains? : " + behindAll); if (!behindAll) { - interferingRects.push({ x: br.left, y: br.top, width: br.width, height: br.height }); + var extRect = getExtRect(computedStyle, br, true); + if ( + extRect.right <= captureAreaInViewportCoords.left || + extRect.left >= captureAreaInViewportCoords.right + ) { + return; + } + interferingRects.push({ + x: extRect.left, + y: extRect.top, + width: extRect.width, + height: extRect.height + }); } } }); diff --git a/src/browser/client-scripts/util.js b/src/browser/client-scripts/util.js index c7e78de1f..ff86e448b 100644 --- a/src/browser/client-scripts/util.js +++ b/src/browser/client-scripts/util.js @@ -228,35 +228,182 @@ exports.findContainingBlock = function findContainingBlock(element) { return document.documentElement; }; -function _isCreatingStackingContext(computedStyle) { - var position = computedStyle.position; - var zIndexStr = computedStyle.zIndex; +function _matchesProp(style, propName, defaultValue) { + if (typeof style[propName] === "undefined") { + return false; + } + + return style[propName] !== (typeof defaultValue === "undefined" ? "none" : defaultValue); +} + +function _isFlexContainer(style) { + return style.display === "flex" || style.display === "inline-flex"; +} + +function _isGridContainer(style) { + return (style.display || "").indexOf("grid") !== -1; +} + +function _hasContainStackingContext(contain) { + if (!contain) { + return false; + } return ( - (position !== "static" && zIndexStr !== "auto") || - parseFloat(computedStyle.opacity) < 1 || - computedStyle.transform !== "none" || - computedStyle.filter !== "none" || - computedStyle.perspective !== "none" || - position === "fixed" || - position === "sticky" + contain === "layout" || + contain === "paint" || + contain === "strict" || + contain === "content" || + contain.indexOf("paint") !== -1 || + contain.indexOf("layout") !== -1 ); } -function _getStackingContextRoot(element) { - var curr = element.parentElement; - while (curr && curr !== document.documentElement) { - var style = lib.getComputedStyle(curr); +// This method was inspired by https://github.com/gwwar/z-context/blob/dea7c1c220c77281ce6a02b910460b3a5d4744c8/content-script.js#L30 +function _stackingContextReason(node, computedStyle, includeReason) { + var position = computedStyle.position; + var zIndexStr = computedStyle.zIndex; + var reasonValue = includeReason + ? function (text) { + return text; + } + : function () { + return true; + }; + + if (position === "fixed" || position === "sticky") { + return reasonValue("position: " + position); + } - var createsStackingContext = _isCreatingStackingContext(style); + if (computedStyle.containerType === "size" || computedStyle.containerType === "inline-size") { + return reasonValue("container-type: " + computedStyle.containerType); + } - if (createsStackingContext) { - return curr; + if (zIndexStr !== "auto" && position !== "static") { + return reasonValue("position: " + position + "; z-index: " + zIndexStr); + } + + if (parseFloat(computedStyle.opacity) < 1) { + return reasonValue("opacity: " + computedStyle.opacity); + } + + if (computedStyle.transform !== "none") { + return reasonValue("transform: " + computedStyle.transform); + } + + if (_matchesProp(computedStyle, "scale")) { + return reasonValue("scale: " + computedStyle.scale); + } + + if (_matchesProp(computedStyle, "rotate")) { + return reasonValue("rotate: " + computedStyle.rotate); + } + + if (_matchesProp(computedStyle, "translate")) { + return reasonValue("translate: " + computedStyle.translate); + } + + if (computedStyle.mixBlendMode !== "normal") { + return reasonValue("mix-blend-mode: " + computedStyle.mixBlendMode); + } + + if (computedStyle.filter !== "none") { + return reasonValue("filter: " + computedStyle.filter); + } + + if (_matchesProp(computedStyle, "backdropFilter")) { + return reasonValue("backdrop-filter: " + computedStyle.backdropFilter); + } + + if (_matchesProp(computedStyle, "webkitBackdropFilter")) { + return reasonValue("-webkit-backdrop-filter: " + computedStyle.webkitBackdropFilter); + } + + if (computedStyle.perspective !== "none") { + return reasonValue("perspective: " + computedStyle.perspective); + } + + if (_matchesProp(computedStyle, "clipPath")) { + return reasonValue("clip-path: " + computedStyle.clipPath); + } + + var mask = computedStyle.mask || computedStyle.webkitMask; + if (typeof mask !== "undefined" && mask !== "none") { + return reasonValue("mask: " + mask); + } + + var maskImage = computedStyle.maskImage || computedStyle.webkitMaskImage; + if (typeof maskImage !== "undefined" && maskImage !== "none") { + return reasonValue("mask-image: " + maskImage); + } + + var maskBorder = computedStyle.maskBorder || computedStyle.webkitMaskBorder; + if (typeof maskBorder !== "undefined" && maskBorder !== "none") { + return reasonValue("mask-border: " + maskBorder); + } + + if (computedStyle.isolation === "isolate") { + return reasonValue("isolation: isolate"); + } + + var willChange = computedStyle.willChange || ""; + if (willChange.indexOf("transform") !== -1 || willChange.indexOf("opacity") !== -1) { + return reasonValue("will-change: " + willChange); + } + + if (computedStyle.webkitOverflowScrolling === "touch") { + return reasonValue("-webkit-overflow-scrolling: touch"); + } + + if (zIndexStr !== "auto") { + var parentNode = getParentNode(node); + if (parentNode instanceof Element) { + var parentStyle = lib.getComputedStyle(parentNode); + if (_isFlexContainer(parentStyle)) { + return reasonValue("flex-item; z-index: " + zIndexStr); + } + if (_isGridContainer(parentStyle)) { + return reasonValue("grid-item; z-index: " + zIndexStr); + } } - curr = curr.parentElement; } - return document.documentElement; + if (_hasContainStackingContext(computedStyle.contain || "")) { + return reasonValue("contain: " + computedStyle.contain); + } + + return null; +} + +function _createsStackingContext(node, computedStyle) { + return Boolean(_stackingContextReason(node, computedStyle, false)); +} + +function _getClosestStackingContext(node, includeReason) { + if (!node || node.nodeName === "HTML") { + return includeReason ? { node: document.documentElement, reason: "root" } : document.documentElement; + } + + if (node.nodeName === "#document-fragment") { + return _getClosestStackingContext(node.host, includeReason); + } + + if (!(node instanceof Element)) { + return _getClosestStackingContext(getParentNode(node), includeReason); + } + + var computedStyle = lib.getComputedStyle(node); + var reason = _stackingContextReason(node, computedStyle, includeReason); + + if (reason) { + return includeReason ? { node: node, reason: reason } : node; + } + + return _getClosestStackingContext(getParentNode(node), includeReason); +} + +function _getStackingContextRoot(element, includeReason) { + return _getClosestStackingContext(getParentNode(element), includeReason); } function _getEffectiveZIndex(element) { @@ -264,7 +411,7 @@ function _getEffectiveZIndex(element) { while (curr && curr !== document.documentElement) { var style = lib.getComputedStyle(curr); var zIndexStr = style.zIndex; - var createsStackingContext = _isCreatingStackingContext(style); + var createsStackingContext = _createsStackingContext(curr, style); if (zIndexStr !== "auto") { var num = parseFloat(zIndexStr); @@ -283,15 +430,23 @@ function _getEffectiveZIndex(element) { return 0; } -exports.buildZChain = function buildZChain(element) { +exports.buildZChain = function buildZChain(element, opts) { + opts = opts || {}; + // includeReasons is useful for debugging only, but should not cause overhead in production + var includeReasons = Boolean(opts.includeReasons); var chain = []; var curr = element; while (curr && curr !== document.documentElement) { - var ctx = _getStackingContextRoot(curr); + var context = _getStackingContextRoot(curr, includeReasons); + var ctx = includeReasons ? context.node : context; var z = _getEffectiveZIndex(curr); - chain.unshift({ ctx: ctx, z: z }); + var chainItem = { ctx: ctx, z: z }; + if (includeReasons) { + chainItem.reason = context.reason; + } + chain.unshift(chainItem); if (ctx === document.documentElement) { break; diff --git a/src/browser/commands/assert-view/index.js b/src/browser/commands/assert-view/index.js index 7cb1c9db6..9c12ef770 100644 --- a/src/browser/commands/assert-view/index.js +++ b/src/browser/commands/assert-view/index.js @@ -77,9 +77,7 @@ module.exports.default = browser => { const { tempOpts } = RuntimeConfig.getInstance(); temp.attach(tempOpts); - const { image: currImgInst, meta: currImgMeta } = await screenShooter - .capture(selectors, opts) - .finally(() => browser.cleanupScreenshot(opts)); + const { image: currImgInst, meta: currImgMeta } = await screenShooter.capture(selectors, opts); const currSize = await currImgInst.getSize(); const currImg = { path: temp.path(Object.assign(tempOpts, { suffix: ".png" })), size: currSize }; diff --git a/src/browser/existing-browser.ts b/src/browser/existing-browser.ts index af6e99d0b..443bb1e16 100644 --- a/src/browser/existing-browser.ts +++ b/src/browser/existing-browser.ts @@ -4,7 +4,7 @@ import { attach, type AttachOptions, type ElementArray } from "@testplane/webdri import { sessionEnvironmentDetector } from "@testplane/wdio-utils"; import { Browser, BrowserOpts } from "./browser"; import { customCommandFileNames } from "./commands"; -import { Camera, PageMeta } from "./camera"; +import { Camera, ImageArea } from "./camera"; import { type ClientBridge, build as buildClientBridge } from "./client-bridge"; import * as history from "./history"; import * as logger from "../utils/logger"; @@ -13,34 +13,15 @@ import { MIN_CHROME_VERSION_SUPPORT_ISOLATION } from "../constants/browser"; import { isSupportIsolation } from "../utils/browser"; import { isRunInNodeJsEnv } from "../utils/config"; import { Config } from "../config"; -import { Image, Rect } from "../image"; +import { Image } from "../image"; import type { CalibrationResult, Calibrator } from "./calibrator"; import { NEW_ISSUE_LINK } from "../constants/help"; -import { runWithoutHistory } from "./history"; import type { SessionOptions } from "./types"; import { Page } from "puppeteer-core"; -import { PrepareScreenshotResult } from "./screen-shooter/types"; import { CDP } from "./cdp"; -import type { ElementReference } from "@testplane/wdio-protocols"; - -import makeDebug from "debug"; const OPTIONAL_SESSION_OPTS = ["transformRequest", "transformResponse"]; -interface PrepareScreenshotOpts { - ignoreSelectors?: string[]; - allowViewportOverflow?: boolean; - captureElementFromTop?: boolean; - selectorToScroll?: string; - disableAnimation?: boolean; - debug?: boolean; -} - -interface ClientBridgeErrorData { - errorCode: string; - message: string; -} - interface ScrollByParams { x: number; y: number; @@ -62,10 +43,6 @@ function ensure(value: T | undefined | null, hint?: string): asserts value is } } -const isClientBridgeErrorData = (data: unknown): data is ClientBridgeErrorData => { - return Boolean(data && (data as ClientBridgeErrorData).errorCode && (data as ClientBridgeErrorData).message); -}; - export const getActivePuppeteerPage = async (session: WebdriverIO.Browser): Promise => { const puppeteer = await session.getPuppeteer(); @@ -172,53 +149,6 @@ export class ExistingBrowser extends Browser { this._meta = this._initMeta(); } - async prepareScreenshot( - selectors: string[] | Rect[], - opts: PrepareScreenshotOpts = {}, - ): Promise { - // Running this fragment with history causes rrweb snapshots to break on pages with iframes - return runWithoutHistory({ callstack: this._callstackHistory! }, async () => { - opts = _.extend(opts, { - usePixelRatio: this._calibration ? this._calibration.usePixelRatio : true, - }); - - ensure(this._clientBridge, CLIENT_BRIDGE_HINT); - const result = await this._clientBridge.call( - "prepareScreenshot", - [selectors, opts], - ); - makeDebug("testplane:screenshots:browser:prepareScreenshot")((result as PrepareScreenshotResult).debugLog); - - if (isClientBridgeErrorData(result)) { - throw new Error( - `Failed to perform the visual check, because we couldn't compute screenshot area to capture.\n\n` + - `What happened:\n` + - `- You called assertView command with the following selectors: ${JSON.stringify(selectors)}\n` + - `- You passed the following options: ${JSON.stringify(opts)}\n` + - `- We tried to determine positions of these elements, but failed with the '${result.errorCode}' error: ${result.message}\n\n` + - `What you can do:\n` + - `- Check that passed selectors are valid and exist on the page\n` + - `- If you believe this is a bug on our side, re-run this test with DEBUG=testplane:screenshots* and file an issue with this log at ${NEW_ISSUE_LINK}\n`, - ); - } - - // https://github.com/webdriverio/webdriverio/issues/11396 - if (this._config.automationProtocol === WEBDRIVER_PROTOCOL && opts.disableAnimation) { - await this._disableIframeAnimations(); - } - - return result; - }); - } - - async cleanupScreenshot(opts: { disableAnimation?: boolean } = {}): Promise { - if (opts.disableAnimation) { - return runWithoutHistory({ callstack: this._callstackHistory! }, async () => { - await this._cleanupPageAnimations(); - }); - } - } - open(url: string): Promise { ensure(this._session, BROWSER_SESSION_HINT); @@ -237,12 +167,25 @@ export class ExistingBrowser extends Browser { return this._session.execute(script); } - async captureViewportImage(page?: PageMeta, screenshotDelay?: number): Promise { + callMethodOnBrowserSide(name: string, args: unknown[] = []): Promise { + ensure(this._clientBridge, CLIENT_BRIDGE_HINT); + return this._clientBridge.call(name, args); + } + + get shouldUsePixelRatio(): boolean { + return this._calibration ? this._calibration.usePixelRatio : true; + } + + get isWebdriverProtocol(): boolean { + return this._config.automationProtocol === WEBDRIVER_PROTOCOL; + } + + async captureViewportImage(viewport?: ImageArea, screenshotDelay?: number): Promise { if (screenshotDelay) { await new Promise(resolve => setTimeout(resolve, screenshotDelay)); } - return this._camera.captureViewportImage(page); + return this._camera.captureViewportImage(viewport); } scrollBy(params: ScrollByParams): Promise { @@ -536,70 +479,6 @@ export class ExistingBrowser extends Browser { ); } - protected async _runInEachDisplayedIframe(cb: (...args: unknown[]) => unknown): Promise { - ensure(this._session, BROWSER_SESSION_HINT); - const session = this._session; - const iframes = await session.findElements("css selector", "iframe[src]"); - const displayedIframes: ElementReference[] = []; - - await Promise.all( - iframes.map(async iframe => { - const isIframeDisplayed = await session.$(iframe).isDisplayed(); - - if (isIframeDisplayed) { - displayedIframes.push(iframe); - } - }), - ); - - try { - for (const iframe of displayedIframes) { - await session.switchToFrame(iframe); - await cb(); - // switchToParentFrame does not work in ios - https://github.com/appium/appium/issues/14882 - await session.switchToFrame(null); - } - } catch (e) { - await session.switchToFrame(null); - throw e; - } - } - - protected async _disableFrameAnimations(): Promise { - ensure(this._clientBridge, CLIENT_BRIDGE_HINT); - const result = await this._clientBridge.call("disableFrameAnimations"); - - if (isClientBridgeErrorData(result)) { - throw new Error( - `Disable animations failed with error type '${result.errorCode}' and error message: ${result.message}`, - ); - } - - return result; - } - - protected async _disableIframeAnimations(): Promise { - await this._runInEachDisplayedIframe(() => this._disableFrameAnimations()); - } - - protected async _cleanupFrameAnimations(): Promise { - ensure(this._clientBridge, CLIENT_BRIDGE_HINT); - - return this._clientBridge.call("cleanupFrameAnimations"); - } - - protected async _cleanupIframeAnimations(): Promise { - await this._runInEachDisplayedIframe(() => this._cleanupFrameAnimations()); - } - - protected async _cleanupPageAnimations(): Promise { - await this._cleanupFrameAnimations(); - - if (this._config.automationProtocol === WEBDRIVER_PROTOCOL) { - await this._cleanupIframeAnimations(); - } - } - _stubCommands(): void { if (!this._session) { return; diff --git a/src/browser/screen-shooter/composite-image/index.ts b/src/browser/screen-shooter/composite-image/index.ts index cb24a8426..ad5b12962 100644 --- a/src/browser/screen-shooter/composite-image/index.ts +++ b/src/browser/screen-shooter/composite-image/index.ts @@ -136,18 +136,20 @@ export class CompositeImage { this._lastContainerOffset = scrollElementOffset; this._lastViewportOffset = viewportOffset; + await viewportImage.crop(cropAreaInViewportCoords); + const imageSize = await viewportImage.getSize(); + debug( - "Captured the next chunk at offset %O.\n notCapturedArea before capture: %O\n notCapturedAreaInViewportCoords: %O\n cropArea: %O\n windowOffset: %O", + "Captured the next chunk at offset %O.\n notCapturedArea before capture: %O\n notCapturedAreaInViewportCoords: %O\n cropArea: %O\n windowOffset: %O\n image size: %O", scrollElementOffset, notCapturedArea, notCapturedAreaInViewportCoords, cropAreaInViewportCoords, viewportOffset, + imageSize, ); - await viewportImage.crop(cropAreaInViewportCoords); - - this._compositeChunks.push({ image: viewportImage, imageSize: await viewportImage.getSize() }); + this._compositeChunks.push({ image: viewportImage, imageSize }); } async render(): Promise { diff --git a/src/browser/screen-shooter/iframe-utils.ts b/src/browser/screen-shooter/iframe-utils.ts new file mode 100644 index 000000000..d6f96b172 --- /dev/null +++ b/src/browser/screen-shooter/iframe-utils.ts @@ -0,0 +1,31 @@ +import type { ElementReference } from "@testplane/wdio-protocols"; + +export async function runInEachDisplayedIframe( + session: WebdriverIO.Browser, + cb: () => Promise | unknown, +): Promise { + const iframes = await session.findElements("css selector", "iframe[src]"); + const displayedIframes: ElementReference[] = []; + + await Promise.all( + iframes.map(async iframe => { + const isIframeDisplayed = await session.$(iframe).isDisplayed(); + + if (isIframeDisplayed) { + displayedIframes.push(iframe); + } + }), + ); + + try { + for (const iframe of displayedIframes) { + await session.switchToFrame(iframe); + await cb(); + // switchToParentFrame does not work in ios - https://github.com/appium/appium/issues/14882 + await session.switchToFrame(null); + } + } catch (e) { + await session.switchToFrame(null); + throw e; + } +} diff --git a/src/browser/screen-shooter/index.ts b/src/browser/screen-shooter/index.ts index ed48851f1..e2517d5fc 100644 --- a/src/browser/screen-shooter/index.ts +++ b/src/browser/screen-shooter/index.ts @@ -4,15 +4,40 @@ import { Image, Rect } from "../../image"; import { PrepareScreenshotResult } from "./types"; import { ExistingBrowser } from "../existing-browser"; import { assertCorrectCaptureAreaBounds } from "./validation"; -import { AssertViewOpts } from "../../config/types"; +import type { AssertViewOpts } from "../../config/types"; import { findScrollParentAndScrollBy, getBoundingRects } from "./utils"; +import { runWithoutHistory } from "../history"; +import { runInEachDisplayedIframe } from "./iframe-utils"; +import { NEW_ISSUE_LINK } from "../../constants/help"; const debug = makeDebug("testplane:screenshots:screen-shooter"); +const pointerDebug = makeDebug("testplane:screenshots:browser:pointer"); interface ScreenShooterOpts extends AssertViewOpts { debugId?: string; } +interface PrepareScreenshotOpts { + ignoreSelectors?: string[]; + allowViewportOverflow?: boolean; + captureElementFromTop?: boolean; + selectorToScroll?: string; + disableAnimation?: boolean; + disableHover?: AssertViewOpts["disableHover"]; + compositeImage?: boolean; + debug?: boolean; + usePixelRatio?: boolean; +} + +interface ClientBridgeErrorData { + errorCode: string; + message: string; +} + +const isClientBridgeErrorData = (data: unknown): data is ClientBridgeErrorData => { + return Boolean(data && (data as ClientBridgeErrorData).errorCode && (data as ClientBridgeErrorData).message); +}; + interface ExtendImageResult { hasReachedScrollLimit: boolean; } @@ -26,6 +51,7 @@ export class ScreenShooter { private _browser: ExistingBrowser; private _lastVerticalScrollOffset: number = -1; private _selectorsToCapture: string[] = []; + private _relativeRestorePointerPosition: { x: number; y: number } | null = null; static create(browser: ExistingBrowser): ScreenShooter { return new this(browser); @@ -36,45 +62,123 @@ export class ScreenShooter { } async capture( - selectorOrSectorsArray: string | string[], + selectorsOrAreas: string | string[] | Rect | Rect[], opts: ScreenShooterOpts = {}, ): Promise { - const selectors = ([] as string[]).concat(selectorOrSectorsArray); + const selectorsOrAreasArray = ([] as Array).concat(selectorsOrAreas as Array); + const selectors = selectorsOrAreasArray.filter( + (areaOrSelector): areaOrSelector is string => typeof areaOrSelector === "string", + ); this._selectorsToCapture = selectors; const browserPrepareScreenshotDebug = makeDebug("testplane:screenshots:browser:prepareScreenshot"); + try { + const page = await this._prepareScreenshot(selectorsOrAreasArray, { + ignoreSelectors: ([] as string[]).concat(opts.ignoreElements ?? []), + allowViewportOverflow: opts.allowViewportOverflow, + captureElementFromTop: opts.captureElementFromTop, + selectorToScroll: opts.selectorToScroll, + disableAnimation: opts.disableAnimation, + disableHover: opts.disableHover, + compositeImage: opts.compositeImage, + debug: browserPrepareScreenshotDebug.enabled, + }); + + delete page.debugLog; + + assertCorrectCaptureAreaBounds( + JSON.stringify(selectorsOrAreasArray), + page.viewport, + page.viewportOffset, + page.captureArea, + opts, + ); + + debug(`[${opts.debugId}] prepareScreenshot result: %O`, page); + + await this._preparePointerForScreenshot(page, opts); + + const viewport = { + ...page.viewport, + ...page.viewportOffset, + }; + const viewportImage = await this._browser.captureViewportImage(viewport, opts.screenshotDelay); + const image = CompositeImage.create(page.captureArea, page.safeArea, page.ignoreAreas); + await image.registerViewportImageAtOffset(viewportImage, page.scrollElementOffset, page.viewportOffset); + + await this._captureOverflowingAreaIfNeeded(image, page, opts); + + debug(`[${opts.debugId}] All areas captured. Proceeding to render image`); + + return { + image: await image.render(), + meta: page, + }; + } catch (error) { + console.warn(`Failed to capture screenshot for selectors: ${JSON.stringify(selectorsOrAreasArray)}`); + throw error; + } finally { + try { + await this._cleanupScreenshot(opts); + } catch (cleanupError) { + const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError); + console.warn( + `Warning: failed to cleanup after screenshot for selectors: ${JSON.stringify( + selectorsOrAreasArray, + )}\n` + `Cleanup error: ${cleanupMessage}`, + ); + } + } + } - const page = await this._browser.prepareScreenshot(selectors, { - ignoreSelectors: ([] as string[]).concat(opts.ignoreElements ?? []), - allowViewportOverflow: opts.allowViewportOverflow, - captureElementFromTop: opts.captureElementFromTop, - selectorToScroll: opts.selectorToScroll, - disableAnimation: opts.disableAnimation, - debug: browserPrepareScreenshotDebug.enabled, - }); - - delete page.debugLog; - - assertCorrectCaptureAreaBounds( - JSON.stringify(selectors), - page.viewport, - page.viewportOffset, - page.captureArea, - opts, - ); - - debug(`[${opts.debugId}] prepareScreenshot result: %O`, page); + private async _prepareScreenshot( + areas: Array, + opts: PrepareScreenshotOpts = {}, + ): Promise { + return runWithoutHistory({}, async () => { + const optsWithPixelRatio = { + ...opts, + usePixelRatio: this._browser.shouldUsePixelRatio, + }; + + const result = await this._browser.callMethodOnBrowserSide( + "prepareScreenshot", + [areas, optsWithPixelRatio], + ); + + makeDebug("testplane:screenshots:browser:prepareScreenshot")((result as PrepareScreenshotResult).debugLog); + + if (isClientBridgeErrorData(result)) { + throw new Error( + `Failed to perform the visual check, because we couldn't compute screenshot area to capture.\n\n` + + `What happened:\n` + + `- You called assertView command with the following selectors: ${JSON.stringify(areas)}\n` + + `- You passed the following options: ${JSON.stringify(optsWithPixelRatio)}\n` + + `- We tried to determine positions of these elements, but failed with the '${result.errorCode}' error: ${result.message}\n\n` + + `What you can do:\n` + + `- Check that passed selectors are valid and exist on the page\n` + + `- If you believe this is a bug on our side, re-run this test with DEBUG=testplane:screenshots* and file an issue with this log at ${NEW_ISSUE_LINK}\n`, + ); + } - const viewportImage = await this._browser.captureViewportImage(page, opts.screenshotDelay); - const image = CompositeImage.create(page.captureArea, page.safeArea, page.ignoreAreas); - await image.registerViewportImageAtOffset(viewportImage, page.scrollElementOffset, page.viewportOffset); + if (this._browser.isWebdriverProtocol && opts.disableAnimation) { + await this._disableIframeAnimations(); + } - await this._captureOverflowingAreaIfNeeded(image, page, opts); + return result; + }); + } - return { - image: await image.render(), - meta: page, - }; + private async _cleanupScreenshot(opts: ScreenShooterOpts = {}): Promise { + return runWithoutHistory({}, async () => { + if (opts.disableAnimation) { + await this._cleanupPageAnimations(); + } + if (opts.disableHover && opts.disableHover !== "never") { + await this._cleanupPointerEvents(); + } + await this._restorePointerPosition(); + }); } private async _captureOverflowingAreaIfNeeded( @@ -99,6 +203,87 @@ export class ScreenShooter { } } + private async _preparePointerForScreenshot(page: PrepareScreenshotResult, opts: ScreenShooterOpts): Promise { + if (!opts.disableHover || opts.disableHover === "never") { + return; + } + + if (!page.pointerEventsDisabled) { + return; + } + + return runWithoutHistory({}, async () => { + let didMove = await this._movePointerBy(1, 0); + if (didMove) { + this._relativeRestorePointerPosition = { x: -1, y: 0 }; + } else if (!didMove) { + didMove = await this._movePointerBy(-1, 0); + if (didMove) { + this._relativeRestorePointerPosition = { x: 1, y: 0 }; + } + } + }); + } + + private async _disableIframeAnimations(): Promise { + await runInEachDisplayedIframe(this._browser.publicAPI, async () => { + const result = await this._browser.callMethodOnBrowserSide( + "disableFrameAnimations", + ); + + if (isClientBridgeErrorData(result)) { + throw new Error( + `Disable animations failed with error type '${result.errorCode}' and error message: ${result.message}`, + ); + } + }); + } + + private async _cleanupPageAnimations(): Promise { + await this._browser.callMethodOnBrowserSide("cleanupFrameAnimations"); + + if (this._browser.isWebdriverProtocol) { + await runInEachDisplayedIframe(this._browser.publicAPI, async () => { + await this._browser.callMethodOnBrowserSide("cleanupFrameAnimations"); + }); + } + } + + private async _cleanupPointerEvents(): Promise { + await this._browser.callMethodOnBrowserSide("cleanupPointerEvents"); + } + + private async _restorePointerPosition(): Promise { + if (!this._relativeRestorePointerPosition) { + return; + } + + await this._movePointerBy(this._relativeRestorePointerPosition.x, this._relativeRestorePointerPosition.y); + this._relativeRestorePointerPosition = null; + } + + private async _movePointerBy(x: number, y: number): Promise { + const session = this._browser.publicAPI; + + if (!session.isW3C) { + pointerDebug("Skipping relative pointer move because session is not W3C"); + return false; + } + + try { + pointerDebug("Trying to move pointer by %dpx, %dpx", x, y); + await session + .action("pointer", { parameters: { pointerType: "mouse" } }) + .move({ duration: 0, origin: "pointer", x, y }) + .perform(); + pointerDebug("Pointer moved by %dpx, %dpx", x, y); + return true; + } catch (error) { + pointerDebug("Failed to move pointer relatively: %O", error); + return false; + } + } + private async _scrollOnceAndExtendImage( image: CompositeImage, page: PrepareScreenshotResult, @@ -108,7 +293,7 @@ export class ScreenShooter { const boundingRectsBeforeScroll = await getBoundingRects( this._browser.publicAPI, - this._selectorsToCapture, + this._selectorsToCapture.length > 0 ? this._selectorsToCapture : ["body"], ).catch(() => null); debug("boundingRectBeforeScroll: %O", boundingRectsBeforeScroll); @@ -120,14 +305,18 @@ export class ScreenShooter { // Subtract 1px to avoid rounding artifacts. Without this, when top edge of scroll container is at fractional // position and if it gets rounded to the next integer, we'd get 1px lines of that edge. Scrolling 1px less means // that we'll have 1px reserve at the top and that fractional border won't be visible. - const logicalScrollHeight = Math.ceil(physicalScrollHeight / page.pixelRatio) - 1; + // But this rule should not apply to the last scroll iteration, because otherwise we'd always get useless 1px scroll in the end. + const logicalScrollHeight = + Math.ceil(physicalScrollHeight / page.pixelRatio) - + Number(nextNotCapturedArea.height >= page.safeArea.height); const browserScrollByDebug = makeDebug("testplane:screenshots:browser:scrollBy"); + const selectorsToCapture = this._selectorsToCapture.length > 0 ? this._selectorsToCapture : ["body"]; const scrollResult = await findScrollParentAndScrollBy(this._browser.publicAPI, { x: 0, y: logicalScrollHeight, selectorToScroll: opts.selectorToScroll, - selectorsToCapture: this._selectorsToCapture, + selectorsToCapture, debug: browserScrollByDebug.enabled, }); const { @@ -149,7 +338,7 @@ export class ScreenShooter { const boundingRectsAfterScroll = await getBoundingRects( this._browser.publicAPI, - this._selectorsToCapture, + this._selectorsToCapture.length > 0 ? this._selectorsToCapture : ["body"], ).catch(() => null); debug("boundingRectAfterScroll: %O", boundingRectsAfterScroll); @@ -195,7 +384,12 @@ export class ScreenShooter { windowOffset, ); - const newImage = await this._browser.captureViewportImage(page, opts.screenshotDelay); + const currentViewport = { + ...page.viewport, + top: windowOffset.top, + left: windowOffset.left, + }; + const newImage = await this._browser.captureViewportImage(currentViewport, opts.screenshotDelay); await image.registerViewportImageAtOffset(newImage, containerOffset, windowOffset); diff --git a/src/browser/screen-shooter/types.ts b/src/browser/screen-shooter/types.ts index 3bf12d594..e412dc2eb 100644 --- a/src/browser/screen-shooter/types.ts +++ b/src/browser/screen-shooter/types.ts @@ -25,6 +25,8 @@ export interface PrepareScreenshotResult { canHaveCaret: boolean; // Pixel ratio: window.devicePixelRatio or 1 if usePixelRatio was set to false pixelRatio: number; + // Whether pointer-events were disabled during prepareScreenshot. Useful for "when-scrolling-needed", because in that case it's determined on browser side + pointerEventsDisabled?: boolean; // Debug log, returned only if DEBUG env includes scope "testplane:screenshots:browser:prepareScreenshot" debugLog?: string; } diff --git a/src/browser/screen-shooter/utils.ts b/src/browser/screen-shooter/utils.ts index 07b85f2c4..aff065467 100644 --- a/src/browser/screen-shooter/utils.ts +++ b/src/browser/screen-shooter/utils.ts @@ -65,7 +65,7 @@ export async function findScrollParentAndScrollBy( try { log += JSON.stringify(arguments[i], null, 2) + "\n"; } catch (e) { - log += "\n"; } } else { log += arguments[i] + "\n"; diff --git a/src/config/defaults.js b/src/config/defaults.js index 3ecdf36e2..b54777f30 100644 --- a/src/config/defaults.js +++ b/src/config/defaults.js @@ -1,7 +1,7 @@ "use strict"; const { WEBDRIVER_PROTOCOL, SAVE_HISTORY_MODE, NODEJS_TEST_RUN_ENV } = require("../constants/config"); -const { TimeTravelMode } = require("./types"); +const { TimeTravelMode, DisableHoverMode } = require("./types"); module.exports = { baseUrl: "http://localhost", @@ -37,6 +37,7 @@ module.exports = { allowViewportOverflow: false, ignoreDiffPixelCount: 0, waitForStaticToLoadTimeout: 5000, + disableHover: DisableHoverMode.WhenScrollingNeeded, }, openAndWaitOpts: { waitNetworkIdle: true, diff --git a/src/config/types.ts b/src/config/types.ts index 0fcd03d08..c1765f1ed 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -17,6 +17,12 @@ export interface BuildDiffOptsConfig { ignoreCaret: boolean; } +export enum DisableHoverMode { + Always = "always", + WhenScrollingNeeded = "when-scrolling-needed", + Never = "never", +} + export interface AssertViewOpts { /** * DOM-node selectors which will be ignored (painted with a black rectangle) when comparing images. @@ -108,6 +114,23 @@ export interface AssertViewOpts { * @defaultValue `true` */ disableAnimation?: boolean; + /** + * Controls whether hover effects should be disabled while making a screenshot + * Works by injecting a style that sets pointer-events: none on all elements. + * + * @remarks + * When capturing long screenshots that require scrolling, the mouse cursor stays at its original position + * and can cause unwanted hover effects on captured chunks. + * + * - `"always"` — always injects the style, disabling hovers for every screenshot capture. + * - `"when-scrolling-needed"` — only injects the style when the captured area requires scrolling + * (long screenshots, needing compositing). Hovers are disabled for the entire capture duration, including + * the first chunk. Single-viewport screenshots are unaffected. + * - `"never"` — never injects the style. + * + * @defaultValue `"when-scrolling-needed"` + */ + disableHover?: DisableHoverMode; /** * Ability to ignore a small amount of different pixels to classify screenshots as being "identical" * diff --git a/src/image.ts b/src/image.ts index 1ff70e369..158eb81ac 100644 --- a/src/image.ts +++ b/src/image.ts @@ -197,7 +197,18 @@ export class Image { async _getPngBuffer(): Promise { const imageData = await this._getImgData(); - return convertRgbaToPng(imageData, this._width, this._height); + try { + return convertRgbaToPng(imageData, this._width, this._height); + } catch (e) { + const baseMessage = + `Failed to convert image buffer to PNG.\n` + + `Expected image size (formatted as height x width): ${this._height} x ${this._width}.\n` + + `Actual data present in buffer (formatted as height x width): ${this._height} x ${ + imageData.length / (this._height * RGBA_CHANNELS) + } or ${imageData.length / (this._width * RGBA_CHANNELS)} x ${this._width}.\n` + + `This means the data is malformed or image size doesn't match actual image dimensions.\n`; + throw new Error(baseMessage, { cause: e }); + } } async save(file: string): Promise { diff --git a/src/reporters/utils/helpers.js b/src/reporters/utils/helpers.js index 8496997d1..97a8d98cb 100644 --- a/src/reporters/utils/helpers.js +++ b/src/reporters/utils/helpers.js @@ -4,13 +4,25 @@ const path = require("path"); const chalk = require("chalk"); const stripAnsi = require("strip-ansi"); const _ = require("lodash"); +const util = require("util"); const getSkipReason = test => test && (getSkipReason(test.parent) || test.skipReason); const getFilePath = test => (test && test.file) || (test.parent && getFilePath(test.parent)); const getRelativeFilePath = file => (file ? path.relative(process.cwd(), file) : undefined); +function toPrintableError(err) { + if (!(err instanceof Error)) return err; + + const cause = err.cause instanceof Error ? toPrintableError(err.cause) : err.cause; + + const result = cause ? new Error(err.message, { cause }) : new Error(err.message); + result.stack = err.stack; + + return result; +} + const getTestError = test => { - let error = test.err ? test.err.stack || test.err.message || test.err : undefined; + let error = test.err ? util.inspect(toPrintableError(test.err)) : undefined; if (test.err && test.err.seleniumStack) { error = error.replace(/$/m, ` (${test.err.seleniumStack.orgStatusMessage})`); diff --git a/src/utils/processor.js b/src/utils/processor.js index 3f7866d35..5daf430de 100644 --- a/src/utils/processor.js +++ b/src/utils/processor.js @@ -8,6 +8,7 @@ const ipc = require("./ipc"); const { shouldIgnoreUnhandledRejection } = require("./errors"); const { utilInspectSafe } = require("./secret-replacer"); const { preloadWebdriverIO, preloadMochaReader } = require("./preload-utils.js"); +const { serializeWorkerError } = require("./worker-error-serialization"); process.on("uncaughtException", err => { if (err.code === "EPIPE" || err.code === "ERR_IPC_CHANNEL_CLOSED") { @@ -56,9 +57,9 @@ exports.execute = async (moduleName, methodName, args, cb) => { function sendError(err, cb) { try { - cb(err); + cb(serializeWorkerError(err)); } catch { - const shortenedErr = _.pick(err, [ + const shortenedErr = _.pick(err || {}, [ "message", "stack", "code", @@ -70,6 +71,6 @@ function sendError(err, cb) { "history", ]); - cb(shortenedErr); + cb(serializeWorkerError(shortenedErr)); } } diff --git a/src/utils/worker-error-serialization.ts b/src/utils/worker-error-serialization.ts new file mode 100644 index 000000000..7cb926678 --- /dev/null +++ b/src/utils/worker-error-serialization.ts @@ -0,0 +1,164 @@ +"use strict"; + +export const SERIALIZED_ERROR_MARKER = "__testplane_serialized_error__"; + +const CIRCULAR_REFERENCE_PLACEHOLDER = "[Circular]"; +const BASE_ERROR_FIELDS = new Set(["name", "message", "stack"]); +const ENVELOPE_FIELDS = new Set([SERIALIZED_ERROR_MARKER, "isThrownNonError", "value", "message", "stack", "name"]); + +type UnknownRecord = Record; + +type SerializedWorkerError = UnknownRecord & { + [SERIALIZED_ERROR_MARKER]: true; + isThrownNonError?: true; + value?: unknown; + message?: string; + stack?: string; + name?: string; +}; + +const isObject = (value: unknown): value is UnknownRecord => value !== null && typeof value === "object"; + +const isSerializedWorkerError = (value: unknown): value is SerializedWorkerError => + isObject(value) && value[SERIALIZED_ERROR_MARKER] === true; + +function serializeValue(value: unknown, traversed: WeakSet): unknown { + if (Buffer.isBuffer(value)) { + return { type: "Buffer", data: Array.from(value) }; + } + + if (value instanceof Error) { + return serializeError(value, traversed); + } + + if (!isObject(value)) { + return value; + } + + if (traversed.has(value)) { + return CIRCULAR_REFERENCE_PLACEHOLDER; + } + + traversed.add(value); + + try { + if (Array.isArray(value)) { + return value.map(item => serializeValue(item, traversed)); + } + + return Object.keys(value).reduce((result, key) => { + result[key] = serializeValue(value[key], traversed); + + return result; + }, {}); + } finally { + traversed.delete(value); + } +} + +function serializeError(error: Error, traversed: WeakSet): SerializedWorkerError { + if (traversed.has(error)) { + return { + [SERIALIZED_ERROR_MARKER]: true, + message: error.message, + stack: error.stack, + name: error.name, + }; + } + + traversed.add(error); + + try { + const serializedError: SerializedWorkerError = { + [SERIALIZED_ERROR_MARKER]: true, + message: error.message, + stack: error.stack, + name: error.name, + }; + + Object.getOwnPropertyNames(error).forEach(key => { + if (BASE_ERROR_FIELDS.has(key)) { + return; + } + + serializedError[key] = serializeValue((error as unknown as UnknownRecord)[key], traversed); + }); + + return serializedError; + } finally { + traversed.delete(error); + } +} + +function deserializeValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(deserializeValue); + } + + if (isSerializedWorkerError(value)) { + return deserializeWorkerError(value); + } + + if (!isObject(value)) { + return value; + } + + if (value.type === "Buffer" && Array.isArray(value.data)) { + return Buffer.from(value.data as number[]); + } + + return Object.keys(value).reduce((result, key) => { + result[key] = deserializeValue(value[key]); + + return result; + }, {}); +} + +export function deserializeWorkerError(value: unknown): unknown { + if (!isSerializedWorkerError(value)) { + return value; + } + + if (value.isThrownNonError) { + return deserializeValue(value.value); + } + + const message = typeof value.message === "string" ? value.message : ""; + const cause = Object.prototype.hasOwnProperty.call(value, "cause") ? deserializeValue(value.cause) : undefined; + const error = cause === undefined ? new Error(message) : new Error(message, { cause }); + + if (typeof value.stack === "string") { + error.stack = value.stack; + } + + if (typeof value.name === "string" && value.name !== "Error") { + Object.defineProperty(error, "name", { + value: value.name, + configurable: true, + writable: true, + enumerable: false, + }); + } + + Object.keys(value).forEach(key => { + if (ENVELOPE_FIELDS.has(key) || key === "cause") { + return; + } + + (error as unknown as UnknownRecord)[key] = deserializeValue(value[key]); + }); + + return error; +} + +export function serializeWorkerError(error: unknown): SerializedWorkerError { + if (error instanceof Error) { + return serializeError(error, new WeakSet()); + } + + return { + [SERIALIZED_ERROR_MARKER]: true, + isThrownNonError: true, + value: serializeValue(error, new WeakSet()), + }; +} diff --git a/src/utils/workers-registry.js b/src/utils/workers-registry.js index 80c7fd167..de1a98855 100644 --- a/src/utils/workers-registry.js +++ b/src/utils/workers-registry.js @@ -19,6 +19,7 @@ const { const { isRunInNodeJsEnv } = require("./config"); const { utilInspectSafe } = require("./secret-replacer"); const { NEW_ISSUE_LINK } = require("../constants/help"); +const { deserializeWorkerError } = require("./worker-error-serialization"); const extractErrorFromWorkerMessage = data => { if (data.error) { @@ -80,6 +81,8 @@ module.exports = class WorkersRegistry extends EventEmitter { } const stack = new Error().stack; return promisify(this._workerFarm.execute)(workerFilepath, methodName, args).catch(error => { + error = deserializeWorkerError(error); + if (error.name === "ProcessTerminatedError") { const workerCallError = new Error( `Testplane tried to run method '${methodName}' with args ${utilInspectSafe( diff --git a/src/worker/runner/test-runner/one-time-screenshooter.js b/src/worker/runner/test-runner/one-time-screenshooter.js index dc2bf07fa..53ad11a43 100644 --- a/src/worker/runner/test-runner/one-time-screenshooter.js +++ b/src/worker/runner/test-runner/one-time-screenshooter.js @@ -70,7 +70,7 @@ module.exports = class OneTimeScreenshooter { async _makeFullPageScreenshot() { const pageSize = await this._getPageSize(); - const page = await this._browser.prepareScreenshot( + const { image } = await this._screenshooter.capture( [ { left: 0, @@ -80,18 +80,16 @@ module.exports = class OneTimeScreenshooter { }, ], { - ignoreSelectors: [], + ignoreElements: [], captureElementFromTop: true, allowViewportOverflow: true, + compositeImage: true, }, ); - const image = await this._screenshooter.capture(page, { - compositeImage: true, - allowViewportOverflow: true, - }); + const imageBuffer = await image.toPngBuffer(); + const { data, size } = imageBuffer; - const { data, size } = await image.toPngBuffer(); const base64 = data.toString("base64"); return { base64, size }; diff --git a/test/e2e/.gitignore b/test/e2e/.gitignore index 3434025dc..f6d253d32 100644 --- a/test/e2e/.gitignore +++ b/test/e2e/.gitignore @@ -1 +1,2 @@ -report/** \ No newline at end of file +**/report/** +static/*-report/** diff --git a/test/e2e/test-pages/card-far-below-viewport.html b/test/e2e/fixtures/basic-report/static/card-far-below-viewport.html similarity index 100% rename from test/e2e/test-pages/card-far-below-viewport.html rename to test/e2e/fixtures/basic-report/static/card-far-below-viewport.html diff --git a/test/e2e/fixtures/basic-report/testplane.config.ts b/test/e2e/fixtures/basic-report/testplane.config.ts new file mode 100644 index 000000000..f65446601 --- /dev/null +++ b/test/e2e/fixtures/basic-report/testplane.config.ts @@ -0,0 +1,59 @@ +import path from "path"; + +const SERVER_PORT = 3700; + +export default { + gridUrl: "http://127.0.0.1:4444/", + + baseUrl: `http://host.docker.internal:${SERVER_PORT}/`, + + timeTravel: "off", + saveHistoryMode: "all", + + screenshotsDir: "test/e2e/screens", + + sets: { + assertView: { + files: path.join(__dirname, "tests/**/*.testplane.js"), + }, + }, + + takeScreenshotOnFails: { + testFail: true, + assertViewFail: true, + }, + + browsers: { + chrome: { + assertViewOpts: { + ignoreDiffPixelCount: 4, + }, + windowSize: "1280x1024", + desiredCapabilities: { + browserName: "chrome", + "goog:chromeOptions": { + args: ["headless", "no-sandbox", "hide-scrollbars", "disable-dev-shm-usage"], + binary: "/usr/bin/chromium", + }, + }, + waitTimeout: 3000, + }, + }, + + devServer: { + command: `npx --yes serve -p ${SERVER_PORT} --no-request-logging ${path.resolve(__dirname, "static")}`, + readinessProbe: { + url: `http://localhost:${SERVER_PORT}/`, + timeouts: { + waitServerTimeout: 60_000, + }, + }, + }, + + plugins: { + "html-reporter/testplane": { + enabled: true, + path: path.resolve(__dirname, "../../static/basic-report"), + }, + }, +}; diff --git a/test/e2e/fixtures/basic-report/tests/test.testplane.js b/test/e2e/fixtures/basic-report/tests/test.testplane.js new file mode 100644 index 000000000..206d37ab7 --- /dev/null +++ b/test/e2e/fixtures/basic-report/tests/test.testplane.js @@ -0,0 +1,7 @@ +describe("test", () => { + it("throws error and should have page screenshot", async ({ browser }) => { + await browser.url("card-far-below-viewport.html"); + + throw new Error("test error"); + }); +}); diff --git a/test/e2e/screens/026b36d/chrome/test-block.png b/test/e2e/screens/026b36d/chrome/test-block.png index e913916b6..f883c5c46 100644 Binary files a/test/e2e/screens/026b36d/chrome/test-block.png and b/test/e2e/screens/026b36d/chrome/test-block.png differ diff --git a/test/e2e/screens/0c9774e/chrome/test-block.png b/test/e2e/screens/0c9774e/chrome/test-block.png index 19877b991..083473c5e 100644 Binary files a/test/e2e/screens/0c9774e/chrome/test-block.png and b/test/e2e/screens/0c9774e/chrome/test-block.png differ diff --git a/test/e2e/screens/22e9725/chrome/animation-cleanup.png b/test/e2e/screens/22e9725/chrome/animation-cleanup.png new file mode 100644 index 000000000..b02e77131 Binary files /dev/null and b/test/e2e/screens/22e9725/chrome/animation-cleanup.png differ diff --git a/test/e2e/screens/2bcfbfd/chrome/short-block-final.png b/test/e2e/screens/2bcfbfd/chrome/short-block-final.png new file mode 100644 index 000000000..0bf2ad491 Binary files /dev/null and b/test/e2e/screens/2bcfbfd/chrome/short-block-final.png differ diff --git a/test/e2e/screens/2bcfbfd/chrome/short-block-suppress-on.png b/test/e2e/screens/2bcfbfd/chrome/short-block-suppress-on.png new file mode 100644 index 000000000..779af1bd9 Binary files /dev/null and b/test/e2e/screens/2bcfbfd/chrome/short-block-suppress-on.png differ diff --git a/test/e2e/screens/3ece52e/chrome/test-block.png b/test/e2e/screens/3ece52e/chrome/test-block.png index 78c68bcf5..fe7b812d2 100644 Binary files a/test/e2e/screens/3ece52e/chrome/test-block.png and b/test/e2e/screens/3ece52e/chrome/test-block.png differ diff --git a/test/e2e/screens/416a970/chrome/test-block.png b/test/e2e/screens/416a970/chrome/test-block.png index 19877b991..083473c5e 100644 Binary files a/test/e2e/screens/416a970/chrome/test-block.png and b/test/e2e/screens/416a970/chrome/test-block.png differ diff --git a/test/e2e/screens/589ead7/chrome/test-block.png b/test/e2e/screens/589ead7/chrome/test-block.png index 19877b991..083473c5e 100644 Binary files a/test/e2e/screens/589ead7/chrome/test-block.png and b/test/e2e/screens/589ead7/chrome/test-block.png differ diff --git a/test/e2e/screens/5abd350/chrome/test-block.png b/test/e2e/screens/5abd350/chrome/test-block.png new file mode 100644 index 000000000..f577f62a0 Binary files /dev/null and b/test/e2e/screens/5abd350/chrome/test-block.png differ diff --git a/test/e2e/screens/663ed98/chrome/short-block-final.png b/test/e2e/screens/663ed98/chrome/short-block-final.png new file mode 100644 index 000000000..0bf2ad491 Binary files /dev/null and b/test/e2e/screens/663ed98/chrome/short-block-final.png differ diff --git a/test/e2e/screens/663ed98/chrome/short-block-suppress-on.png b/test/e2e/screens/663ed98/chrome/short-block-suppress-on.png new file mode 100644 index 000000000..779af1bd9 Binary files /dev/null and b/test/e2e/screens/663ed98/chrome/short-block-suppress-on.png differ diff --git a/test/e2e/screens/80b193e/chrome/test-block.png b/test/e2e/screens/80b193e/chrome/test-block.png index 19877b991..083473c5e 100644 Binary files a/test/e2e/screens/80b193e/chrome/test-block.png and b/test/e2e/screens/80b193e/chrome/test-block.png differ diff --git a/test/e2e/screens/831bd54/chrome/long-block-suppress-off.png b/test/e2e/screens/831bd54/chrome/long-block-suppress-off.png new file mode 100644 index 000000000..d67174a35 Binary files /dev/null and b/test/e2e/screens/831bd54/chrome/long-block-suppress-off.png differ diff --git a/test/e2e/screens/9616746/chrome/basic-report-page-screenshot.png b/test/e2e/screens/9616746/chrome/basic-report-page-screenshot.png new file mode 100644 index 000000000..7dc3959ec Binary files /dev/null and b/test/e2e/screens/9616746/chrome/basic-report-page-screenshot.png differ diff --git a/test/e2e/screens/9aa7c13/chrome/long-block-suppress-default.png b/test/e2e/screens/9aa7c13/chrome/long-block-suppress-default.png new file mode 100644 index 000000000..54c89f720 Binary files /dev/null and b/test/e2e/screens/9aa7c13/chrome/long-block-suppress-default.png differ diff --git a/test/e2e/screens/a65b947/chrome/long-block-suppress-off.png b/test/e2e/screens/a65b947/chrome/long-block-suppress-off.png new file mode 100644 index 000000000..d67174a35 Binary files /dev/null and b/test/e2e/screens/a65b947/chrome/long-block-suppress-off.png differ diff --git a/test/e2e/screens/b9649bc/chrome/animation-cleanup-iframe.png b/test/e2e/screens/b9649bc/chrome/animation-cleanup-iframe.png new file mode 100644 index 000000000..c932d6db9 Binary files /dev/null and b/test/e2e/screens/b9649bc/chrome/animation-cleanup-iframe.png differ diff --git a/test/e2e/screens/d3ee0b2/chrome/long-block-suppress-default.png b/test/e2e/screens/d3ee0b2/chrome/long-block-suppress-default.png new file mode 100644 index 000000000..54c89f720 Binary files /dev/null and b/test/e2e/screens/d3ee0b2/chrome/long-block-suppress-default.png differ diff --git a/test/e2e/screens/e065453/chrome/test-block.png b/test/e2e/screens/e065453/chrome/test-block.png index 0efe26295..35c4d3ba2 100644 Binary files a/test/e2e/screens/e065453/chrome/test-block.png and b/test/e2e/screens/e065453/chrome/test-block.png differ diff --git a/test/e2e/static/animation-cleanup-iframe.html b/test/e2e/static/animation-cleanup-iframe.html new file mode 100644 index 000000000..75945e11e --- /dev/null +++ b/test/e2e/static/animation-cleanup-iframe.html @@ -0,0 +1,63 @@ + + + + + Iframe animation cleanup + + + +
+ + + diff --git a/test/e2e/static/animation-cleanup.html b/test/e2e/static/animation-cleanup.html new file mode 100644 index 000000000..b09fddd51 --- /dev/null +++ b/test/e2e/static/animation-cleanup.html @@ -0,0 +1,83 @@ + + + + + Animation cleanup + + + +
+
+
+ + + + diff --git a/test/e2e/test-pages/bottom-drawer-with-long-list.html b/test/e2e/static/bottom-drawer-with-long-list.html similarity index 100% rename from test/e2e/test-pages/bottom-drawer-with-long-list.html rename to test/e2e/static/bottom-drawer-with-long-list.html diff --git a/test/e2e/static/card-far-below-viewport.html b/test/e2e/static/card-far-below-viewport.html new file mode 100644 index 000000000..4ff94ba62 --- /dev/null +++ b/test/e2e/static/card-far-below-viewport.html @@ -0,0 +1,154 @@ + + + + + + Card Far Below Viewport + + + +
+ Test Page Info:
+ • Page height: 3500px
+ • Test card located at 2800px from top
+ • Requires scrolling to reach
+ • Simple text-only card
+
+ Use the button below to jump to the card quickly. +
+ +
↓ 1000px mark ↓
+ +
↓ 2000px mark ↓
+ +
+ ↓ 3000px mark ↓
+ Card is 200px below this point +
+ +
+

Test Card (Far Below)

+ +
+
This is a simple test card located far below the initial viewport.
+
+ You had to scroll down quite a bit to see this content.
+ Perfect for testing elements that are not immediately visible. +
+
+
+ + + + + + diff --git a/test/e2e/test-pages/fixed-element-out-of-viewport.html b/test/e2e/static/fixed-element-out-of-viewport.html similarity index 100% rename from test/e2e/test-pages/fixed-element-out-of-viewport.html rename to test/e2e/static/fixed-element-out-of-viewport.html diff --git a/test/e2e/test-pages/fractional-boxes-inside-test-block.html b/test/e2e/static/fractional-boxes-inside-test-block.html similarity index 100% rename from test/e2e/test-pages/fractional-boxes-inside-test-block.html rename to test/e2e/static/fractional-boxes-inside-test-block.html diff --git a/test/e2e/test-pages/impossible-ignore-elements.html b/test/e2e/static/impossible-ignore-elements.html similarity index 100% rename from test/e2e/test-pages/impossible-ignore-elements.html rename to test/e2e/static/impossible-ignore-elements.html diff --git a/test/e2e/test-pages/impossible-safe-area.html b/test/e2e/static/impossible-safe-area.html similarity index 100% rename from test/e2e/test-pages/impossible-safe-area.html rename to test/e2e/static/impossible-safe-area.html diff --git a/test/e2e/test-pages/long-block-inside-scrollable-container-not-in-view-ignore-areas.html b/test/e2e/static/long-block-inside-scrollable-container-not-in-view-ignore-areas.html similarity index 100% rename from test/e2e/test-pages/long-block-inside-scrollable-container-not-in-view-ignore-areas.html rename to test/e2e/static/long-block-inside-scrollable-container-not-in-view-ignore-areas.html diff --git a/test/e2e/test-pages/long-block-inside-scrollable-container-not-in-view.html b/test/e2e/static/long-block-inside-scrollable-container-not-in-view.html similarity index 100% rename from test/e2e/test-pages/long-block-inside-scrollable-container-not-in-view.html rename to test/e2e/static/long-block-inside-scrollable-container-not-in-view.html diff --git a/test/e2e/test-pages/long-block-inside-scrollable-container.html b/test/e2e/static/long-block-inside-scrollable-container.html similarity index 100% rename from test/e2e/test-pages/long-block-inside-scrollable-container.html rename to test/e2e/static/long-block-inside-scrollable-container.html diff --git a/test/e2e/static/long-block.html b/test/e2e/static/long-block.html new file mode 100644 index 000000000..977df8e03 --- /dev/null +++ b/test/e2e/static/long-block.html @@ -0,0 +1,46 @@ + + + + + + Long Block Inside Scrollable Container + + + +
+ + + + + + +
+ + diff --git a/test/e2e/test-pages/modal-window-playground.html b/test/e2e/static/modal-window-playground.html similarity index 100% rename from test/e2e/test-pages/modal-window-playground.html rename to test/e2e/static/modal-window-playground.html diff --git a/test/e2e/test-pages/nested-overflow-visible-diagonal-block.html b/test/e2e/static/nested-overflow-visible-diagonal-block.html similarity index 100% rename from test/e2e/test-pages/nested-overflow-visible-diagonal-block.html rename to test/e2e/static/nested-overflow-visible-diagonal-block.html diff --git a/test/e2e/static/overlapping-blocks-at-y2000.html b/test/e2e/static/overlapping-blocks-at-y2000.html new file mode 100644 index 000000000..aa34d3076 --- /dev/null +++ b/test/e2e/static/overlapping-blocks-at-y2000.html @@ -0,0 +1,113 @@ + + + + + + Overlapping Blocks at Y=2000px Test Page + + + + +
Y = 0px (Start)
+
Y = 1000px
+
Y = 2000px (Test blocks here)
+
Y = 3000px
+ + +
+ +
+

Test Block Content

+

+ This is a text block positioned at approximately Y=2000px. It contains some sample text content to + demonstrate the layout. +

+

+ This block has a white background with a solid border. The red semi-transparent overlay block is + positioned exactly on top of this block with the same dimensions (400x300px). +

+

Both blocks are absolutely positioned to ensure exact alignment.

+
+ + +
+
+ + + + diff --git a/test/e2e/test-pages/scrollable-modal-playground.html b/test/e2e/static/scrollable-modal-playground.html similarity index 100% rename from test/e2e/test-pages/scrollable-modal-playground.html rename to test/e2e/static/scrollable-modal-playground.html diff --git a/test/e2e/test-pages/simple-ignore-areas-test.html b/test/e2e/static/simple-ignore-areas-test.html similarity index 100% rename from test/e2e/test-pages/simple-ignore-areas-test.html rename to test/e2e/static/simple-ignore-areas-test.html diff --git a/test/e2e/test-pages/slightly-not-in-viewport.html b/test/e2e/static/slightly-not-in-viewport.html similarity index 100% rename from test/e2e/test-pages/slightly-not-in-viewport.html rename to test/e2e/static/slightly-not-in-viewport.html diff --git a/test/e2e/test-pages/slightly-overlapping-impossible-safe-area.html b/test/e2e/static/slightly-overlapping-impossible-safe-area.html similarity index 100% rename from test/e2e/test-pages/slightly-overlapping-impossible-safe-area.html rename to test/e2e/static/slightly-overlapping-impossible-safe-area.html diff --git a/test/e2e/test-pages/sticky-element-inside-capture-area.html b/test/e2e/static/sticky-element-inside-capture-area.html similarity index 100% rename from test/e2e/test-pages/sticky-element-inside-capture-area.html rename to test/e2e/static/sticky-element-inside-capture-area.html diff --git a/test/e2e/static/suppress-interactions-hover.html b/test/e2e/static/suppress-interactions-hover.html new file mode 100644 index 000000000..e7cd1f73a --- /dev/null +++ b/test/e2e/static/suppress-interactions-hover.html @@ -0,0 +1,44 @@ + + + + + + Suppress Interactions Hover + + + +
SHORT BLOCK
+
LONG BLOCK
+ + diff --git a/test/e2e/test-pages/very-long-block-with-sticky-elements.html b/test/e2e/static/very-long-block-with-sticky-elements.html similarity index 100% rename from test/e2e/test-pages/very-long-block-with-sticky-elements.html rename to test/e2e/static/very-long-block-with-sticky-elements.html diff --git a/test/e2e/test-pages/very-long-block.html b/test/e2e/static/very-long-block.html similarity index 100% rename from test/e2e/test-pages/very-long-block.html rename to test/e2e/static/very-long-block.html diff --git a/test/e2e/testplane.config.ts b/test/e2e/testplane.config.ts index eb9b17ad1..53002c7d8 100644 --- a/test/e2e/testplane.config.ts +++ b/test/e2e/testplane.config.ts @@ -7,7 +7,7 @@ export default { baseUrl: `http://host.docker.internal:${SERVER_PORT}/`, - timeTravel: "off", + timeTravel: "on", saveHistoryMode: "all", screenshotsDir: "test/e2e/screens", @@ -16,6 +16,9 @@ export default { assertView: { files: path.join(__dirname, "tests/assert-view.testplane.js"), }, + reportPageScreenshot: { + files: path.join(__dirname, "tests/report-page-screenshot.testplane.js"), + }, }, takeScreenshotOnFails: { @@ -41,7 +44,10 @@ export default { }, devServer: { - command: `npx --yes serve -p ${SERVER_PORT} --no-request-logging test/e2e/test-pages/`, + command: `npx --yes --prefer-offline serve -p ${SERVER_PORT} --no-request-logging ${path.resolve( + __dirname, + "static", + )}`, readinessProbe: { url: `http://localhost:${SERVER_PORT}/`, timeouts: { diff --git a/test/e2e/tests/assert-view.testplane.js b/test/e2e/tests/assert-view.testplane.js index ca1aad124..eddf18cf7 100644 --- a/test/e2e/tests/assert-view.testplane.js +++ b/test/e2e/tests/assert-view.testplane.js @@ -1,3 +1,5 @@ +/* global document, window */ + describe("assertView", () => { it("should take a screenshot of a block that is slightly not in viewport with captureElementFromTop", async ({ browser, @@ -189,6 +191,141 @@ describe("assertView", () => { await browser.assertView("test-block", "[data-testid=capture-element]"); }); + describe("allowViewportOverflow", () => { + it("should still try to scroll when allowViewportOverflow is true", async ({ browser }) => { + await browser.url("long-block.html"); + + await browser.assertView("test-block", "[data-testid=test-block]", { allowViewportOverflow: true }); + }); + }); + + describe("disableHover", () => { + it("should suppress hover on short blocks when disableHover=always", async ({ browser }) => { + await browser.url("suppress-interactions-hover.html"); + + await browser.$("[data-testid=short-block]").moveTo(); + + await browser.assertView("short-block-suppress-on", "[data-testid=short-block]", { + disableHover: "always", + }); + + // Previous assertView should not affect future behavior + await browser.$("[data-testid=short-block]").click(); + await browser.assertView("short-block-final", "[data-testid=short-block]"); + }); + + it("should suppress hover on long blocks by default during composite", async ({ browser }) => { + await browser.url("suppress-interactions-hover.html"); + + await browser.$("[data-testid=long-block]").moveTo(); + + await browser.assertView("long-block-suppress-default", "[data-testid=long-block]"); + }); + + it("should keep hover on long blocks when disableHover=never", async ({ browser }) => { + await browser.url("suppress-interactions-hover.html"); + + await browser.$("[data-testid=long-block]").moveTo(); + + await browser.assertView("long-block-suppress-off", "[data-testid=long-block]", { + disableHover: "never", + }); + }); + }); + + describe("disableAnimation", () => { + it("should stop and resume animations on a basic page", async ({ browser }) => { + await browser.url("animation-cleanup.html"); + + // This pause is to ensure the test fails if animations are not stopped + await browser.pause(Math.random() * 500); + + await browser.assertView("animation-cleanup", "[data-testid=animated-block]", { disableAnimation: true }); + + const state = await browser.execute(() => { + const targetElement = document.querySelector("[data-testid=animated-block]"); + const animationDuration = window.getComputedStyle(targetElement).animationDuration; + const hasStyle = Array.from(document.querySelectorAll("style")).some(style => + style.textContent.includes("animation-duration: 0ms"), + ); + + return { + animationDuration, + someElementHasAnimationStoppedStyle: hasStyle, + }; + }); + + expect(state.someElementHasAnimationStoppedStyle).toBe(false); + expect(state.animationDuration).toBe("0.2s"); + }); + + it("should stop and resume animations in iframe and restore frame context", async ({ browser }) => { + await browser.url("animation-cleanup.html"); + + await browser.execute(() => { + window.__thisIsOriginalFrame = true; + }); + + // This pause is to ensure the test fails if animations are not stopped + await browser.pause(Math.random() * 500); + + await browser.assertView("animation-cleanup-iframe", "[data-testid=animation-iframe]", { + disableAnimation: true, + }); + + const state = await browser.execute(() => { + const isOriginalFrame = window.__thisIsOriginalFrame; + + const iframe = document.querySelector("[data-testid=animation-iframe]"); + const iframeWindow = iframe.contentWindow; + const iframeDocument = iframe.contentDocument; + const iframeElement = iframeDocument.querySelector("[data-testid=animated-block]"); + const animationDuration = iframeWindow.getComputedStyle(iframeElement).animationDuration; + const hasStyle = Array.from(iframeDocument.querySelectorAll("style")).some(style => + style.textContent.includes("animation-duration: 0ms"), + ); + + return { + isOriginalFrame, + animationDuration, + someElementHasAnimationStoppedStyle: hasStyle, + }; + }); + + expect(state.isOriginalFrame).toBe(true); + expect(state.someElementHasAnimationStoppedStyle).toBe(false); + expect(state.animationDuration).toBe("0.3s"); + }); + + it("should resume animations after assertView failure", async ({ browser }) => { + await browser.url("animation-cleanup.html"); + + await expect(() => + browser.assertView("animation-cleanup-fail", "[data-testid=too-tall]", { + disableAnimation: true, + compositeImage: false, + captureElementFromTop: true, + }), + ).rejects.toThrow(); + + const state = await browser.execute(() => { + const targetElement = document.querySelector("[data-testid=animated-block]"); + const animationDuration = window.getComputedStyle(targetElement).animationDuration; + const hasStyle = Array.from(document.querySelectorAll("style")).some(style => + style.textContent.includes("animation-duration: 0ms"), + ); + + return { + animationDuration, + someElementHasAnimationStoppedStyle: hasStyle, + }; + }); + + expect(state.someElementHasAnimationStoppedStyle).toBe(false); + expect(state.animationDuration).toBe("0.2s"); + }); + }); + it("should work fine when capturing elements that are overlapping", async ({ browser }) => { await browser.url("overlapping-blocks-at-y2000.html"); diff --git a/test/e2e/tests/report-page-screenshot.testplane.js b/test/e2e/tests/report-page-screenshot.testplane.js new file mode 100644 index 000000000..a31d0881b --- /dev/null +++ b/test/e2e/tests/report-page-screenshot.testplane.js @@ -0,0 +1,21 @@ +describe("report page screenshot on fail", () => { + it("should show page screenshot for failed test", async ({ browser }) => { + await browser.url("basic-report/new-ui.html"); + + await browser.$("[data-qa=suites-tree-card]").waitForExist({ timeout: 15000 }); + const tree = await browser.$("[data-qa=tree-view-list]"); + const suiteNode = await tree.$("span=throws error and should have page screenshot"); + await suiteNode.waitForExist({ timeout: 15000 }); + + let browserNode = await tree.$(".error-tree-node"); + if (!(await browserNode.isExisting())) { + await suiteNode.click(); + browserNode = await tree.$(".error-tree-node"); + } + await browserNode.waitForExist({ timeout: 15000 }); + await browserNode.click(); + + await browser.$("img[alt='Screenshot']").waitForExist({ timeout: 15000 }); + await browser.assertView("basic-report-page-screenshot", "img[alt='Screenshot']", { tolerance: 10 }); + }); +}); diff --git a/test/src/browser/camera/index.js b/test/src/browser/camera/index.js index 61210fb65..450d9a159 100644 --- a/test/src/browser/camera/index.js +++ b/test/src/browser/camera/index.js @@ -55,7 +55,7 @@ describe("browser/camera", () => { }); describe("crop to viewport", () => { - let page; + let viewport; const mkCamera_ = browserOptions => { const screenshotMode = (browserOptions || {}).screenshotMode || "auto"; @@ -63,13 +63,11 @@ describe("browser/camera", () => { }; beforeEach(() => { - page = { - viewport: { - left: 1, - top: 1, - width: 100, - height: 100, - }, + viewport = { + left: 1, + top: 1, + width: 100, + height: 100, }; }); @@ -82,21 +80,36 @@ describe("browser/camera", () => { it("should crop fullPage image with viewport value if page disposition was set", async () => { isFullPageStub.returns(true); - await mkCamera_({ screenshotMode: "fullPage" }).captureViewportImage(page); + await mkCamera_({ screenshotMode: "fullPage" }).captureViewportImage(viewport); - assert.calledOnceWith(image.crop, page.viewport); + assert.calledOnceWith(image.crop, viewport); + }); + + it("should use viewportOffset for fullPage image crop if provided", async () => { + isFullPageStub.returns(true); + viewport.top = 10; + viewport.left = 20; + + await mkCamera_({ screenshotMode: "fullPage" }).captureViewportImage(viewport); + + assert.calledOnceWith(image.crop, { + top: 10, + left: 20, + width: viewport.width, + height: viewport.height, + }); }); it("should crop not fullPage image to the left and right", async () => { isFullPageStub.returns(false); - await mkCamera_({ screenshotMode: "viewport" }).captureViewportImage(page); + await mkCamera_({ screenshotMode: "viewport" }).captureViewportImage(viewport); assert.calledOnceWith(image.crop, { left: 0, top: 0, - height: page.viewport.height, - width: page.viewport.width, + height: viewport.height, + width: viewport.width, }); }); }); diff --git a/test/src/browser/commands/assert-view/index.js b/test/src/browser/commands/assert-view/index.js index 4cba38d15..416e44212 100644 --- a/test/src/browser/commands/assert-view/index.js +++ b/test/src/browser/commands/assert-view/index.js @@ -57,16 +57,6 @@ describe("assertView command", () => { const stubBrowser_ = config => { const browser = mkBrowser_(config, undefined, ExistingBrowser); - sandbox.stub(browser, "prepareScreenshot").resolves({ - viewport: { top: 0, left: 0, width: 1024, height: 768 }, - viewportOffset: { top: 0, left: 0 }, - captureArea: { top: 0, left: 0, width: 100, height: 100 }, - safeArea: { top: 0, left: 0, width: 100, height: 100 }, - ignoreAreas: [], - pixelRatio: 1, - scrollElementOffset: { top: 0, left: 0 }, - }); - sandbox.stub(browser, "captureViewportImage").resolves(stubImage_()); sandbox.stub(browser, "emitter").get(() => new EventEmitter()); return browser; @@ -639,8 +629,6 @@ describe("assertView command", () => { const config = mkConfig_({ [option]: 100 }); const browser = await initBrowser_({ browser: stubBrowser_(config) }); - browser.prepareScreenshot.resolves({}); - await fn(browser, null, null, { [option]: 500 }); assert.calledOnceWith( @@ -830,7 +818,6 @@ describe("assertView command", () => { it('should pass browser emitter to "handleImageDiff" handler', async () => { const browser = await initBrowser_(); - browser.prepareScreenshot.resolves({ canHaveCaret: true }); await fn(browser); diff --git a/test/src/browser/existing-browser.js b/test/src/browser/existing-browser.js index b1ee35478..b581138d8 100644 --- a/test/src/browser/existing-browser.js +++ b/test/src/browser/existing-browser.js @@ -37,14 +37,6 @@ describe("ExistingBrowser", () => { return browser.init(sessionData, calibrator); }; - const stubClientBridge_ = () => { - const bridge = { call: sandbox.stub().resolves({}) }; - - clientBridgeBuildStub.resolves(bridge); - - return bridge; - }; - beforeEach(() => { session = mkSessionStub_(); webdriverioAttachStub = sandbox.stub().resolves(session); @@ -772,212 +764,6 @@ describe("ExistingBrowser", () => { }); }); - describe("prepareScreenshot", () => { - it("should prepare screenshot", async () => { - const clientBridge = stubClientBridge_(); - clientBridge.call.withArgs("prepareScreenshot").resolves({ foo: "bar" }); - - const browser = await initBrowser_(); - - await assert.becomes(browser.prepareScreenshot(), { foo: "bar" }); - }); - - it("should prepare screenshot for passed selectors", async () => { - const clientBridge = stubClientBridge_(); - const browser = await initBrowser_(); - - await browser.prepareScreenshot([".foo", ".bar"]); - const selectors = clientBridge.call.lastCall.args[1][0]; - - assert.deepEqual(selectors, [".foo", ".bar"]); - }); - - it("should prepare screenshot using passed options", async () => { - const clientBridge = stubClientBridge_(); - const browser = await initBrowser_(); - - await browser.prepareScreenshot([], { foo: "bar" }); - const opts = clientBridge.call.lastCall.args[1][1]; - - assert.propertyVal(opts, "foo", "bar"); - }); - - it("should extend options by calibration results", async () => { - const clientBridge = stubClientBridge_(); - const calibrator = sinon.createStubInstance(Calibrator); - calibrator.calibrate.resolves({ usePixelRatio: false }); - - const browser = mkBrowser_({ calibrate: true }); - await initBrowser_(browser, {}, calibrator); - - await browser.prepareScreenshot(); - - const opts = clientBridge.call.lastCall.args[1][1]; - assert.propertyVal(opts, "usePixelRatio", false); - }); - - it("should use pixel ratio by default if calibration was not met", async () => { - const clientBridge = stubClientBridge_(); - const browser = mkBrowser_({ calibrate: false }); - await initBrowser_(browser); - - await browser.prepareScreenshot(); - const opts = clientBridge.call.lastCall.args[1][1]; - - assert.propertyVal(opts, "usePixelRatio", true); - }); - - it("should throw error from browser", async () => { - const clientBridge = stubClientBridge_(); - clientBridge.call.withArgs("prepareScreenshot").resolves({ error: "JS", message: "stub error" }); - - const browser = await initBrowser_(); - - await assert.isRejected( - browser.prepareScreenshot(), - "Prepare screenshot failed with error type 'JS' and error message: stub error", - ); - }); - - describe("'disableAnimation: true' and 'automationProtocol: webdriver'", () => { - it("should disable animations on displayed iframes", async () => { - const clientBridge = stubClientBridge_(); - const browser = await initBrowser_(mkBrowser_({ automationProtocol: "webdriver" })); - const iframeElement1 = { "element-12345": "67890_element_1" }; - const iframeElement2 = { "element-54321": "09876_element_2" }; - const elementStub = (browser.publicAPI.$ = sandbox.stub()); - elementStub.withArgs(iframeElement1).returns({ isDisplayed: () => Promise.resolve(false) }); - elementStub.withArgs(iframeElement2).returns({ isDisplayed: () => Promise.resolve(true) }); - browser.publicAPI.findElements - .withArgs("css selector", "iframe[src]") - .resolves([iframeElement1, iframeElement2]); - - await browser.prepareScreenshot(".selector", { disableAnimation: true }); - - assert.callOrder( - clientBridge.call.withArgs("prepareScreenshot", [ - ".selector", - sinon.match({ disableAnimation: true }), - ]), - browser.publicAPI.switchToFrame.withArgs(iframeElement2), - clientBridge.call.withArgs("disableFrameAnimations"), - browser.publicAPI.switchToFrame.withArgs(null), - ); - assert.neverCalledWithMatch(browser.publicAPI.switchToFrame, iframeElement1); - }); - }); - - it("should not disable iframe animations if 'disableAnimation: true' and 'automationProtocol: devtools'", async () => { - const clientBridge = stubClientBridge_(); - const browser = await initBrowser_(mkBrowser_({ automationProtocol: "devtools" })); - - await browser.prepareScreenshot(".selector", { disableAnimation: true }); - - assert.calledWith(clientBridge.call, "prepareScreenshot", [ - ".selector", - sinon.match({ disableAnimation: true }), - ]); - assert.notCalled(browser.publicAPI.switchToFrame); - assert.neverCalledWith(clientBridge.call, "disableFrameAnimations"); - }); - - it("should not disable animations if 'disableAnimation: false'", async () => { - const clientBridge = stubClientBridge_(); - const browser = await initBrowser_(mkBrowser_({ automationProtocol: "webdriver" })); - const iframeElement = { "element-12345": "67890_element_1" }; - browser.publicAPI.findElements.withArgs("css selector", "iframe[src]").resolves([iframeElement]); - browser.publicAPI.$ = sandbox - .stub() - .withArgs(iframeElement) - .returns({ isDisplayed: () => Promise.resolve(true) }); - - await browser.prepareScreenshot(".selector", { disableAnimation: false }); - - assert.neverCalledWith(clientBridge.call, "prepareScreenshot", [ - ".selector", - sinon.match({ disableAnimation: true }), - ]); - assert.neverCalledWith(browser.publicAPI.switchToFrame, iframeElement); - assert.neverCalledWith(clientBridge.call, "disableFrameAnimations"); - }); - }); - - describe("cleanupScreenshot", () => { - it("should cleanup parent frame if 'disableAnimation: true'", async () => { - const clientBridge = stubClientBridge_(); - const browser = await initBrowser_(mkBrowser_({ automationProtocol: "webdriver" })); - - await browser.cleanupScreenshot({ disableAnimation: true }); - - assert.calledWith(clientBridge.call, "cleanupFrameAnimations"); - }); - - it("should not cleanup frames if 'disableAnimation: false'", async () => { - const clientBridge = stubClientBridge_(); - const browser = await initBrowser_(mkBrowser_({ automationProtocol: "webdriver" })); - - await browser.cleanupScreenshot({ disableAnimation: false }); - - assert.neverCalledWith(clientBridge.call, "cleanupFrameAnimations"); - }); - - it("should not cleanup animations in iframe if 'automationProtocol: devtools'", async () => { - stubClientBridge_(); - const browser = await initBrowser_(mkBrowser_({ automationProtocol: "devtools" })); - - await browser.cleanupScreenshot({ disableAnimation: true }); - - assert.notCalled(browser.publicAPI.switchToFrame); - }); - - describe("'automationProtocol: webdriver'", () => { - it("should cleanup animations in iframe", async () => { - const clientBridge = stubClientBridge_(); - const browser = await initBrowser_(mkBrowser_({ automationProtocol: "webdriver" })); - const iframeElement = { "element-12345": "67890_element_1" }; - browser.publicAPI.findElements.withArgs("css selector", "iframe[src]").resolves([iframeElement]); - browser.publicAPI.$ = sandbox - .stub() - .withArgs(iframeElement) - .returns({ isDisplayed: () => Promise.resolve(true) }); - - await browser.cleanupScreenshot({ disableAnimation: true }); - - assert.calledWith(browser.publicAPI.switchToFrame, iframeElement); - assert.calledWith(clientBridge.call, "cleanupFrameAnimations"); - assert.callOrder(browser.publicAPI.switchToFrame, clientBridge.call); - }); - - it("should switch to parent frame after clean animations in iframe", async () => { - stubClientBridge_(); - const browser = await initBrowser_(mkBrowser_({ automationProtocol: "webdriver" })); - const iframeElement = { "element-12345": "67890_element_1" }; - browser.publicAPI.findElements.withArgs("css selector", "iframe[src]").resolves([iframeElement]); - browser.publicAPI.$ = sandbox - .stub() - .withArgs(iframeElement) - .returns({ isDisplayed: () => Promise.resolve(true) }); - - await browser.cleanupScreenshot({ disableAnimation: true }); - - assert.callOrder( - browser.publicAPI.switchToFrame.withArgs(iframeElement), - browser.publicAPI.switchToFrame.withArgs(null), - ); - }); - - it("should not switch to any frame if there are no iframes on the page ", async () => { - stubClientBridge_(); - const browser = await initBrowser_(mkBrowser_({ automationProtocol: "webdriver" })); - browser.publicAPI.findElements.withArgs("css selector", "iframe[src]").resolves([]); - - await browser.cleanupScreenshot({ disableAnimation: true }); - - assert.notCalled(browser.publicAPI.switchToFrame); - }); - }); - }); - describe("open", () => { it("should open URL", async () => { const browser = await initBrowser_(); diff --git a/test/src/browser/screen-shooter/index.js b/test/src/browser/screen-shooter/index.js index de570ee9d..f5a3fa9d8 100644 --- a/test/src/browser/screen-shooter/index.js +++ b/test/src/browser/screen-shooter/index.js @@ -84,10 +84,22 @@ describe("ScreenShooter", () => { config: {}, publicAPI: { execute: sandbox.stub(), + action: sandbox.stub().returns({ + move: sandbox.stub().returnsThis(), + perform: sandbox.stub().resolves(), + }), + findElements: sandbox.stub().resolves([]), + switchToFrame: sandbox.stub().resolves(), + $: sandbox.stub(), }, - prepareScreenshot: sandbox.stub().resolves(createMockPage()), + callMethodOnBrowserSide: sandbox.stub().resolves(createMockPage()), captureViewportImage: sandbox.stub().resolves(imageStub), - cleanupScreenshot: sandbox.stub().resolves(), + get shouldUsePixelRatio() { + return true; + }, + get isWebdriverProtocol() { + return true; + }, }; }); @@ -114,7 +126,9 @@ describe("ScreenShooter", () => { await screenShooter.capture(selector); - assert.calledOnceWith(browser.prepareScreenshot, [selector], sinon.match.object); + const [method, args] = browser.callMethodOnBrowserSide.firstCall.args; + assert.equal(method, "prepareScreenshot"); + assert.deepEqual(args[0], [selector]); }); it("should accept multiple selectors as array", async () => { @@ -122,7 +136,9 @@ describe("ScreenShooter", () => { await screenShooter.capture(selectors); - assert.calledOnceWith(browser.prepareScreenshot, selectors, sinon.match.object); + const [method, args] = browser.callMethodOnBrowserSide.firstCall.args; + assert.equal(method, "prepareScreenshot"); + assert.deepEqual(args[0], selectors); }); it("should pass options to prepareScreenshot", async () => { @@ -136,12 +152,18 @@ describe("ScreenShooter", () => { await screenShooter.capture(".element", opts); - assert.calledOnceWith(browser.prepareScreenshot, [".element"], { + const [method, args] = browser.callMethodOnBrowserSide.firstCall.args; + assert.equal(method, "prepareScreenshot"); + assert.deepEqual(args[0], [".element"]); + assert.deepEqual(args[1], { ignoreSelectors: [".ignore1", ".ignore2"], allowViewportOverflow: true, captureElementFromTop: false, selectorToScroll: ".scrollable", disableAnimation: true, + disableHover: undefined, + compositeImage: true, + usePixelRatio: true, debug: false, }); }); @@ -151,16 +173,15 @@ describe("ScreenShooter", () => { await screenShooter.capture(".element", opts); - assert.calledWith( - browser.prepareScreenshot, - [".element"], - sinon.match({ ignoreSelectors: [".single-ignore"] }), - ); + const [method, args] = browser.callMethodOnBrowserSide.firstCall.args; + assert.equal(method, "prepareScreenshot"); + assert.deepEqual(args[0], [".element"]); + assert.deepEqual(args[1].ignoreSelectors, [".single-ignore"]); }); it("should remove debugLog from page result", async () => { const pageWithDebugLog = createMockPage({ debugLog: "some debug info" }); - browser.prepareScreenshot.resolves(pageWithDebugLog); + browser.callMethodOnBrowserSide.resolves(pageWithDebugLog); await screenShooter.capture(".element"); @@ -173,7 +194,7 @@ describe("ScreenShooter", () => { const selectors = [".element1", ".element2"]; const page = createMockPage(); const opts = createDefaultOpts(); - browser.prepareScreenshot.resolves(page); + browser.callMethodOnBrowserSide.resolves(page); await screenShooter.capture(selectors, opts); @@ -192,11 +213,11 @@ describe("ScreenShooter", () => { it("should capture viewport image with page and screenshotDelay", async () => { const page = createMockPage(); const opts = createDefaultOpts({ screenshotDelay: 500 }); - browser.prepareScreenshot.resolves(page); + browser.callMethodOnBrowserSide.resolves(page); await screenShooter.capture(".element", opts); - assert.calledOnceWith(browser.captureViewportImage, page, 500); + assert.calledOnceWith(browser.captureViewportImage, page.viewport, 500); }); it("should create CompositeImage with page data", async () => { @@ -205,7 +226,7 @@ describe("ScreenShooter", () => { safeArea: { left: 0, top: 0, width: 100, height: 150 }, ignoreAreas: [{ left: 50, top: 50, width: 20, height: 20 }], }); - browser.prepareScreenshot.resolves(page); + browser.callMethodOnBrowserSide.resolves(page); await screenShooter.capture(".element"); @@ -217,7 +238,7 @@ describe("ScreenShooter", () => { scrollElementOffset: { left: 10, top: 20 }, viewportOffset: { left: 5, top: 15 }, }); - browser.prepareScreenshot.resolves(page); + browser.callMethodOnBrowserSide.resolves(page); await screenShooter.capture(".element"); @@ -231,7 +252,7 @@ describe("ScreenShooter", () => { it("should render composite image and return result", async () => { const page = createMockPage(); - browser.prepareScreenshot.resolves(page); + browser.callMethodOnBrowserSide.resolves(page); const result = await screenShooter.capture(".element"); @@ -296,7 +317,7 @@ describe("ScreenShooter", () => { it("should capture new image after scrolling", async () => { const page = createMockPage(); - browser.prepareScreenshot.resolves(page); + browser.callMethodOnBrowserSide.resolves(page); compositeImageStub.hasNotCapturedArea.onCall(0).returns(true).onCall(1).returns(false); @@ -309,7 +330,7 @@ describe("ScreenShooter", () => { it("should calculate correct scroll height based on pixelRatio", async () => { const page = createMockPage({ pixelRatio: 2 }); - browser.prepareScreenshot.resolves(page); + browser.callMethodOnBrowserSide.resolves(page); compositeImageStub.hasNotCapturedArea.onCall(0).returns(true).onCall(1).returns(false); compositeImageStub.getNextNotCapturedArea.returns({ @@ -479,23 +500,32 @@ describe("ScreenShooter", () => { it("should propagate prepareScreenshot errors", async () => { const error = new Error("Prepare screenshot failed"); - browser.prepareScreenshot.rejects(error); + browser.callMethodOnBrowserSide.rejects(error); - await assert.isRejected(screenShooter.capture(".element"), error); + const promise = screenShooter.capture(".element"); + + const thrownError = await promise.catch(e => e); + assert.include(thrownError.message, "Prepare screenshot failed"); }); it("should propagate captureViewportImage errors", async () => { const error = new Error("Capture failed"); browser.captureViewportImage.rejects(error); - await assert.isRejected(screenShooter.capture(".element"), error); + const promise = screenShooter.capture(".element"); + + const thrownError = await promise.catch(e => e); + assert.include(thrownError.message, "Capture failed"); }); it("should propagate CompositeImage errors", async () => { const error = new Error("Composite failed"); compositeImageStub.render.rejects(error); - await assert.isRejected(screenShooter.capture(".element"), error); + const promise = screenShooter.capture(".element"); + + const thrownError = await promise.catch(e => e); + assert.include(thrownError.message, "Composite failed"); }); }); }); diff --git a/test/src/image.js b/test/src/image.js index e8c4d21ba..7222ac814 100644 --- a/test/src/image.js +++ b/test/src/image.js @@ -5,301 +5,484 @@ const proxyquire = require("proxyquire"); describe("Image", () => { const sandbox = sinon.createSandbox(); let Image; - let image; let looksSameStub; - let sharpStub; - let mkSharpInstance; - - const mkSharpStub_ = () => { - const stub = {}; - - stub.metadata = sandbox.stub().resolves({ channels: 3, width: 100500, height: 500100 }); - stub.toBuffer = sandbox - .stub() - .resolves({ data: "buffer", info: { channels: 3, width: 100500, height: 500100 } }); - stub.extract = sandbox.stub().returns(stub); - stub.composite = sandbox.stub().returns(stub); - stub.resize = sandbox.stub().returns(stub); - stub.toFile = sandbox.stub().resolves(); - stub.raw = () => stub; - stub.png = () => stub; - - return stub; + let convertRgbaToPngStub; + let fsStub; + let loadEsmStub; + let jsquashDecodeStub; + + const createMockPngBuffer = (width = 100, height = 50) => { + const buffer = Buffer.alloc(100); + // Mock PNG header with width and height at correct offsets + buffer.writeUInt32BE(width, 16); // PNG_WIDTH_OFFSET + buffer.writeUInt32BE(height, 20); // PNG_HEIGHT_OFFSET + return buffer; }; - const transformAreaToClearData_ = ({ top, left, height, width, channels }) => ({ - top, - left, - input: { - create: { - background: { alpha: 1, b: 0, g: 0, r: 0 }, - channels, - height, - width, - }, - }, - }); + const createMockImageData = (width = 100, height = 50) => { + const dataSize = width * height * 4; // RGBA channels + return Buffer.alloc(dataSize); + }; beforeEach(() => { looksSameStub = sandbox.stub(); - sharpStub = mkSharpStub_(); - mkSharpInstance = sandbox.stub().callsFake(() => sharpStub); + looksSameStub.createDiff = sandbox.stub(); + convertRgbaToPngStub = sandbox.stub().returns(Buffer.alloc(0)); + fsStub = { + promises: { + readFile: sandbox.stub(), + writeFile: sandbox.stub().resolves(), + }, + }; + loadEsmStub = sandbox.stub(); + jsquashDecodeStub = sandbox.stub().resolves({ data: createMockImageData() }); + + // Mock the jsquash module loading + loadEsmStub.withArgs("@jsquash/png/decode.js").resolves({ + init: sandbox.stub().resolves(), + decode: jsquashDecodeStub, + }); + Image = proxyquire("src/image", { + fs: fsStub, "looks-same": looksSameStub, - sharp: mkSharpInstance, + "./utils/preload-utils": { loadEsm: loadEsmStub }, + "./utils/eight-bit-rgba-to-png": { convertRgbaToPng: convertRgbaToPngStub }, }).Image; - - image = Image.create("imgBuffer"); }); afterEach(() => sandbox.restore()); - describe("getSize", () => { - beforeEach(() => { - sharpStub.metadata.resolves({ width: 15, height: 12 }); - sharpStub.toBuffer.resolves({ data: "buffer", info: { width: 15, height: 12 } }); + describe("constructor", () => { + it("should read width and height from PNG buffer", () => { + const buffer = createMockPngBuffer(200, 150); + + const image = new Image(buffer); + + assert.equal(image._width, 200); + assert.equal(image._height, 150); }); - it("should return image size", async () => { - const size = await image.getSize(); + it("should initialize image data promise", () => { + const buffer = createMockPngBuffer(); - assert.deepEqual(size, { width: 15, height: 12 }); + const image = new Image(buffer); + + assert.exists(image._imgDataPromise); }); - it("should return updated image size after composite with another image", async () => { - image.addJoin(image); - await image.applyJoin(); - const size = await image.getSize(); + it("should initialize empty compose images array", () => { + const buffer = createMockPngBuffer(); - assert.deepEqual(size, { width: 15, height: 12 * 2 }); + const image = new Image(buffer); + + assert.deepEqual(image._composeImages, []); }); }); - describe("crop", () => { - it("should recreate image instance from buffer after crop", async () => { - sharpStub.toBuffer.resolves({ data: "croppedBuffer", info: { channels: 3, width: 10, height: 15 } }); - await image.crop({ left: 20, top: 10, width: 40, height: 30 }); - - assert.calledTwice(mkSharpInstance); - assert.calledWithMatch(mkSharpInstance.secondCall, "croppedBuffer", { - raw: { - width: 10, - height: 15, - channels: 3, - }, - }); + describe("create", () => { + it("should create new Image instance", () => { + const buffer = createMockPngBuffer(); + + const image = Image.create(buffer); + + assert.instanceOf(image, Image); + }); + }); + + describe("fromBase64", () => { + it("should create Image from base64 string", () => { + const base64 = Buffer.from("test").toString("base64"); + sandbox.stub(Buffer, "from").returns(createMockPngBuffer()); + + const image = Image.fromBase64(base64); + + assert.instanceOf(image, Image); + assert.calledWith(Buffer.from, base64, "base64"); }); + }); - it("should extract area from image", async () => { - const area = { left: 20, top: 10, width: 40, height: 30 }; + describe("_getImgData", () => { + it("should return cached image data if available", async () => { + const buffer = createMockPngBuffer(); + const image = new Image(buffer); + const mockData = Buffer.from("cached"); + image._imgData = mockData; - await image.crop(area); + const result = await image._getImgData(); - assert.calledOnceWith(sharpStub.extract, area); + assert.equal(result, mockData); }); - it("should consider image sizes", async () => { - sharpStub.metadata.resolves({ width: 10, height: 10 }); + it("should resolve image data promise and cache result", async () => { + const buffer = createMockPngBuffer(); + const image = new Image(buffer); + const mockImageData = createMockImageData(); + jsquashDecodeStub.resolves({ data: mockImageData }); - await image.crop({ left: 3, top: 3, width: 10, height: 10 }); + const result = await image._getImgData(); - assert.calledOnceWith(sharpStub.extract, { left: 3, top: 3, width: 7, height: 7 }); + assert.equal(image._imgData, result); + assert.instanceOf(result, Buffer); }); }); - describe("should clear", () => { - it("a region of an image", async () => { - sharpStub.metadata.resolves({ channels: 4 }); - const clearArea = { left: 20, top: 10, width: 40, height: 30 }; + describe("_ensureImagesHaveSameWidth", () => { + it("should not throw if all images have same width", () => { + const buffer = createMockPngBuffer(100, 50); + const image = new Image(buffer); + const attachedImage = new Image(createMockPngBuffer(100, 75)); + image._composeImages = [attachedImage]; - await image.addClear(clearArea); - await image.applyJoin(); + assert.doesNotThrow(() => image._ensureImagesHaveSameWidth()); + }); - assert.calledOnceWith(sharpStub.composite, [transformAreaToClearData_({ ...clearArea, channels: 4 })]); + it("should throw error if images have different widths", () => { + const buffer = createMockPngBuffer(100, 50); + const image = new Image(buffer); + const attachedImage = new Image(createMockPngBuffer(150, 75)); + image._composeImages = [attachedImage]; + + assert.throws( + () => image._ensureImagesHaveSameWidth(), + /It looks like viewport width changed while performing long page screenshot \(100px -> 150px\)/, + ); }); + }); - it("multiple regions of an image", async () => { - sharpStub.metadata.resolves({ channels: 3 }); - const firstArea = { left: 20, top: 10, width: 40, height: 30 }; - const secondArea = { left: 70, top: 50, width: 200, height: 100 }; + describe("getSize", () => { + it("should return size of single image", async () => { + const buffer = createMockPngBuffer(100, 50); + const image = new Image(buffer); - await image.addClear(firstArea); - await image.addClear(secondArea); - await image.applyJoin(); + const size = await image.getSize(); - assert.calledOnceWith(sharpStub.composite, [ - transformAreaToClearData_({ ...firstArea, channels: 3 }), - transformAreaToClearData_({ ...secondArea, channels: 3 }), - ]); + assert.deepEqual(size, { width: 100, height: 50 }); + }); + + it("should return combined height for composed images", async () => { + const buffer = createMockPngBuffer(100, 50); + const image = new Image(buffer); + const attachedImage1 = new Image(createMockPngBuffer(100, 30)); + const attachedImage2 = new Image(createMockPngBuffer(100, 20)); + image._composeImages = [attachedImage1, attachedImage2]; + + const size = await image.getSize(); + + assert.deepEqual(size, { width: 100, height: 100 }); // 50 + 30 + 20 + }); + + it("should ensure images have same width before calculating size", async () => { + const buffer = createMockPngBuffer(100, 50); + const image = new Image(buffer); + sandbox.spy(image, "_ensureImagesHaveSameWidth"); + + await image.getSize(); + + assert.calledOnce(image._ensureImagesHaveSameWidth); }); }); - describe("composite images", () => { - let image2; + describe("crop", () => { + it("should crop image data according to rect", async () => { + const buffer = createMockPngBuffer(100, 100); + const image = new Image(buffer); + const mockImageData = createMockImageData(100, 100); + image._imgData = mockImageData; + + const rect = { top: 10, left: 20, width: 50, height: 30 }; + await image.crop(rect); + + assert.equal(image._width, 50); + assert.equal(image._height, 30); + assert.instanceOf(image._imgData, Buffer); + }); - beforeEach(() => { - sharpStub.toBuffer.resolves({ data: "buf", info: { width: 12, height: 7, channels: 3 } }); - sharpStub.metadata.resolves({ width: 12, height: 7 }); + it("should get image data before cropping", async () => { + const buffer = createMockPngBuffer(100, 100); + const image = new Image(buffer); + sandbox.spy(image, "_getImgData"); - const sharpStub2 = mkSharpStub_(); - sharpStub2.toBuffer.resolves({ data: "buf2", info: { width: 12, height: 5, channels: 3 } }); - sharpStub2.metadata.resolves({ width: 12, height: 5 }); - mkSharpInstance.withArgs("buf2").returns(sharpStub2); + const rect = { top: 0, left: 0, width: 50, height: 50 }; + await image.crop(rect); - image2 = Image.create("buf2"); + assert.calledOnce(image._getImgData); }); + }); - it("should resize image before join", async () => { - image.addJoin(image2); - await image.applyJoin(); + describe("addJoin", () => { + it("should add images to compose array", () => { + const buffer = createMockPngBuffer(); + const image = new Image(buffer); + const attachedImages = [new Image(createMockPngBuffer()), new Image(createMockPngBuffer())]; - assert.callOrder(sharpStub.resize, sharpStub.composite); - assert.calledOnceWith(sharpStub.resize, { - width: 12, - height: 7 + 5, - fit: "contain", - position: "top", - }); + image.addJoin(attachedImages); + + assert.deepEqual(image._composeImages, attachedImages); }); - it("should composite images with incrementing top offset", async () => { - image.addJoin(image2); - image.addJoin(image2); - await image.applyJoin(); + it("should concatenate with existing compose images", () => { + const buffer = createMockPngBuffer(); + const image = new Image(buffer); + const existingImage = new Image(createMockPngBuffer()); + const newImages = [new Image(createMockPngBuffer()), new Image(createMockPngBuffer())]; + image._composeImages = [existingImage]; - assert.calledOnce(sharpStub.composite); - assert.calledWithMatch(sharpStub.composite, [ - { input: "buf2", left: 0, top: 7, raw: { width: 12, height: 5, channels: 3 } }, - { input: "buf2", left: 0, top: 12, raw: { width: 12, height: 5, channels: 3 } }, - ]); + image.addJoin(newImages); + + assert.equal(image._composeImages.length, 3); + assert.equal(image._composeImages[0], existingImage); }); + }); + + describe("applyJoin", () => { + it("should return early if no compose images", async () => { + const buffer = createMockPngBuffer(); + const image = new Image(buffer); + sandbox.spy(image, "_ensureImagesHaveSameWidth"); - it("should composite images after resize", async () => { - image.addJoin(image2); await image.applyJoin(); - assert.callOrder(sharpStub.resize, sharpStub.composite); + assert.notCalled(image._ensureImagesHaveSameWidth); }); - it("should update size after join", async () => { - image.addJoin(image2); + it("should ensure images have same width", async () => { + const buffer = createMockPngBuffer(100, 50); + const image = new Image(buffer); + const attachedImage = new Image(createMockPngBuffer(100, 30)); + image._composeImages = [attachedImage]; + sandbox.spy(image, "_ensureImagesHaveSameWidth"); + await image.applyJoin(); - assert.becomes(image.getSize(), { width: 12, height: 7 + 5 }); + assert.calledOnce(image._ensureImagesHaveSameWidth); }); - it("should not resize image if no images were added to join", async () => { - const oldSize = await image.getSize(); + it("should concatenate image buffers and update height", async () => { + const buffer = createMockPngBuffer(100, 50); + const image = new Image(buffer); + const attachedImage = new Image(createMockPngBuffer(100, 30)); + image._composeImages = [attachedImage]; + + const mockData1 = Buffer.from("data1"); + const mockData2 = Buffer.from("data2"); + image._imgData = mockData1; + attachedImage._imgData = mockData2; await image.applyJoin(); - const newSize = await image.getSize(); - assert.match(newSize, oldSize); - assert.notCalled(sharpStub.composite); - assert.notCalled(sharpStub.resize); + assert.equal(image._height, 80); // 50 + 30 + assert.instanceOf(image._imgData, Buffer); }); }); - describe("getRGBA", () => { - beforeEach(() => { - sharpStub.toBuffer.resolves({ - data: Buffer.from([1, 2, 3, 4, 5, 6]), - info: { - channels: 3, - width: 2, - }, - }); + describe("clearArea", () => { + it("should clear specified area with black pixels", async () => { + const buffer = createMockPngBuffer(100, 100); + const image = new Image(buffer); + const mockImageData = createMockImageData(100, 100); + image._imgData = mockImageData; + sandbox.spy(mockImageData, "fill"); + + const rect = { top: 10, left: 20, width: 50, height: 30 }; + await image.clearArea(rect); + + assert.called(mockImageData.fill); }); - it("should return RGBA pixel", async () => { - const pixel = await image.getRGBA(1, 0); + it("should get image data before clearing", async () => { + const buffer = createMockPngBuffer(100, 100); + const image = new Image(buffer); + sandbox.spy(image, "_getImgData"); + + const rect = { top: 0, left: 0, width: 50, height: 50 }; + await image.clearArea(rect); + + assert.calledOnce(image._getImgData); + }); + }); - assert.match(pixel, { r: 4, g: 5, b: 6, a: 1 }); + describe("getRGB", () => { + it("should return RGB values for specified coordinates", async () => { + const buffer = createMockPngBuffer(100, 100); + const image = new Image(buffer); + const mockImageData = Buffer.alloc(100 * 100 * 4); + // Set some test RGB values at position (10, 5) + const idx = (100 * 5 + 10) * 4; + mockImageData[idx] = 255; // R + mockImageData[idx + 1] = 128; // G + mockImageData[idx + 2] = 64; // B + image._imgData = mockImageData; + + const rgb = await image.getRGB(10, 5); + + assert.deepEqual(rgb, { R: 255, G: 128, B: 64 }); }); - it('should call "toBuffer" once', async () => { - await image.getRGBA(0, 1); - await image.getRGBA(0, 2); + it("should get image data before reading RGB", async () => { + const buffer = createMockPngBuffer(100, 100); + const image = new Image(buffer); + sandbox.spy(image, "_getImgData"); + + await image.getRGB(0, 0); - assert.calledOnce(sharpStub.toBuffer); + assert.calledOnce(image._getImgData); }); }); - it("should save image", async () => { - const fileName = "image.png"; + describe("_getPngBuffer", () => { + it("should convert image data to PNG buffer", async () => { + const buffer = createMockPngBuffer(100, 50); + const image = new Image(buffer); + const mockImageData = createMockImageData(100, 50); + image._imgData = mockImageData; + const expectedPngBuffer = Buffer.from("png-data"); + convertRgbaToPngStub.returns(expectedPngBuffer); - await image.save(fileName); + const result = await image._getPngBuffer(); - assert.calledOnceWith(sharpStub.toFile, fileName); + assert.calledWith(convertRgbaToPngStub, mockImageData, 100, 50); + assert.equal(result, expectedPngBuffer); + }); }); - it("should create image from base64", () => { - const base64 = "base64 image"; + describe("save", () => { + it("should save PNG buffer to file", async () => { + const buffer = createMockPngBuffer(); + const image = new Image(buffer); + const mockPngBuffer = Buffer.from("png-data"); + sandbox.stub(image, "_getPngBuffer").resolves(mockPngBuffer); - Image.fromBase64(base64); + await image.save("test.png"); - assert.calledWith(mkSharpInstance, Buffer.from(base64, "base64")); + assert.calledWith(fsStub.promises.writeFile, "test.png", mockPngBuffer); + }); }); describe("toPngBuffer", () => { - beforeEach(() => { - sharpStub.toBuffer.resolves({ data: "pngBuffer", info: { width: 15, height: 10, channels: 3 } }); - sharpStub.toBuffer.withArgs({ resolveWithObject: false }).resolves("pngBuffer"); + it("should return PNG buffer when resolveWithObject is false", async () => { + const buffer = createMockPngBuffer(100, 50); + const image = new Image(buffer); + const mockPngBuffer = Buffer.from("png-data"); + sandbox.stub(image, "_getPngBuffer").resolves(mockPngBuffer); + + const result = await image.toPngBuffer({ resolveWithObject: false }); + + assert.equal(result, mockPngBuffer); }); - it("should resolve png buffer with object", async () => { - const buffObj = await image.toPngBuffer(); + it("should return object with data and size when resolveWithObject is true", async () => { + const buffer = createMockPngBuffer(100, 50); + const image = new Image(buffer); + const mockPngBuffer = Buffer.from("png-data"); + sandbox.stub(image, "_getPngBuffer").resolves(mockPngBuffer); + + const result = await image.toPngBuffer({ resolveWithObject: true }); - assert.deepEqual(buffObj, { data: "pngBuffer", size: { width: 15, height: 10 } }); + assert.deepEqual(result, { + data: mockPngBuffer, + size: { width: 100, height: 50 }, + }); }); - it("should resolve png buffer without object", async () => { - const buffer = await image.toPngBuffer({ resolveWithObject: false }); + it("should default to resolveWithObject: true", async () => { + const buffer = createMockPngBuffer(100, 50); + const image = new Image(buffer); + const mockPngBuffer = Buffer.from("png-data"); + sandbox.stub(image, "_getPngBuffer").resolves(mockPngBuffer); + + const result = await image.toPngBuffer(); - assert.equal(buffer, "pngBuffer"); + assert.isObject(result); + assert.property(result, "data"); + assert.property(result, "size"); }); }); - it("should compare two images", async () => { - looksSameStub.resolves(); + describe("compare", () => { + it("should call looksSame with correct parameters", async () => { + const expectedResult = { equal: true }; + looksSameStub.resolves(expectedResult); + + const result = await Image.compare("path1.png", "path2.png", { + canHaveCaret: true, + pixelRatio: 4, + }); + + assert.calledWith(looksSameStub, "path1.png", "path2.png", { + createDiffImage: true, + ignoreCaret: true, + pixelRatio: 4, + }); + assert.equal(result, expectedResult); + }); + + it("should pass compare options to looksSame", async () => { + const opts = { + canHaveCaret: true, + pixelRatio: 2, + tolerance: 5, + antialiasingTolerance: 3, + compareOpts: { strict: true }, + }; + + await Image.compare("path1.png", "path2.png", opts); + + assert.calledWith(looksSameStub, "path1.png", "path2.png", { + ignoreCaret: true, + pixelRatio: 2, + tolerance: 5, + antialiasingTolerance: 3, + strict: true, + createDiffImage: true, + }); + }); - await Image.compare("some/path", "other/path", { - canHaveCaret: true, - pixelRatio: 11, - tolerance: 250, - antialiasingTolerance: 100500, - compareOpts: { stopOnFirstFail: true }, + it("should not set tolerance if not provided", async () => { + await Image.compare("path1.png", "path2.png", {}); + + const callArgs = looksSameStub.getCall(0).args[2]; + assert.notProperty(callArgs, "tolerance"); }); - assert.calledOnceWith(looksSameStub, "some/path", "other/path", { - ignoreCaret: true, - pixelRatio: 11, - tolerance: 250, - antialiasingTolerance: 100500, - stopOnFirstFail: true, - createDiffImage: true, + it("should not set antialiasingTolerance if not provided", async () => { + await Image.compare("path1.png", "path2.png", {}); + + const callArgs = looksSameStub.getCall(0).args[2]; + assert.notProperty(callArgs, "antialiasingTolerance"); }); }); - it("should build diff image", async () => { - const createDiffStub = sinon.stub(); - looksSameStub.createDiff = createDiffStub; - createDiffStub.resolves(); - - await Image.buildDiff({ - reference: 100, - current: 200, - diff: 500, - tolerance: 300, - diffColor: 400, - }); - - assert.calledOnceWith(createDiffStub, { - reference: 100, - current: 200, - diff: 500, - tolerance: 300, - highlightColor: 400, + describe("buildDiff", () => { + it("should call looksSame.createDiff with correct options", async () => { + const opts = { + diffColor: "#ff0000", + reference: "ref.png", + current: "current.png", + diff: "diff.png", + }; + looksSameStub.createDiff.resolves(null); + + const result = await Image.buildDiff(opts); + + assert.calledWith(looksSameStub.createDiff, { + highlightColor: "#ff0000", + reference: "ref.png", + current: "current.png", + diff: "diff.png", + }); + assert.isNull(result); + }); + + it("should rename diffColor to highlightColor", async () => { + const opts = { diffColor: "#00ff00" }; + looksSameStub.createDiff.resolves(null); + + await Image.buildDiff(opts); + + const callArgs = looksSameStub.createDiff.getCall(0).args[0]; + assert.equal(callArgs.highlightColor, "#00ff00"); + assert.notProperty(callArgs, "diffColor"); }); }); }); diff --git a/test/src/reporters/flat.js b/test/src/reporters/flat.js index 946eec32f..b5c989014 100644 --- a/test/src/reporters/flat.js +++ b/test/src/reporters/flat.js @@ -260,19 +260,19 @@ describe("Flat reporter", () => { }); it("should extend error with original selenium error if it exists", async () => { + const error = new Error("some error"); + error.stack = "some error\nsome stack"; + error.seleniumStack = { + orgStatusMessage: "some original message", + }; test = mkTestStub_({ - err: { - stack: "some stack", - seleniumStack: { - orgStatusMessage: "some original message", - }, - }, + err: error, }); await createFlatReporter(); emit(RunnerEvents[event], test); - assert.match(stdout, /some stack \(some original message\)/); + assert.match(stdout, /some error \(some original message\)/); }); it(`should log "undefined" if ${testStates[event]} test does not have "err" property`, async () => { diff --git a/test/src/reporters/jsonl.js b/test/src/reporters/jsonl.js index 82aa77dec..d312147db 100644 --- a/test/src/reporters/jsonl.js +++ b/test/src/reporters/jsonl.js @@ -130,30 +130,29 @@ describe("Jsonl reporter", () => { }); it(`should add "error" field with "stack" from ${testStates[event]} test`, async () => { + const error = new Error("o.O"); + error.stack = "o.O"; const test = mkTest_({ - err: { - stack: "o.O", - message: "O.o", - }, + err: error, }); await createJsonlReporter(); emit(RunnerEvents[event], test); - assert.equal(informer.log.firstCall.args[0].error, "o.O"); + assert.equal(informer.log.firstCall.args[0].error, "[o.O]"); }); it(`should add "error" field with "message" from ${testStates[event]} test if "stack" does not exist`, async () => { + const error = new Error("O.o"); + error.stack = undefined; const test = mkTest_({ - err: { - message: "O.o", - }, + err: error, }); await createJsonlReporter(); emit(RunnerEvents[event], test); - assert.equal(informer.log.firstCall.args[0].error, "O.o"); + assert.equal(informer.log.firstCall.args[0].error, "[Error: O.o]"); }); it(`should add "error" field if it's specified as string in ${testStates[event]} test`, async () => { @@ -164,23 +163,23 @@ describe("Jsonl reporter", () => { await createJsonlReporter(); emit(RunnerEvents[event], test); - assert.equal(informer.log.firstCall.args[0].error, "o.O"); + assert.equal(informer.log.firstCall.args[0].error, "'o.O'"); }); it(`should add "error" field with original selenium error if it exists in ${testStates[event]} test`, async () => { + const error = new Error("O.o"); + error.stack = "O.o"; + error.seleniumStack = { + orgStatusMessage: "some original message", + }; const test = mkTest_({ - err: { - message: "O.o", - seleniumStack: { - orgStatusMessage: "some original message", - }, - }, + err: error, }); await createJsonlReporter(); emit(RunnerEvents[event], test); - assert.equal(informer.log.firstCall.args[0].error, "O.o (some original message)"); + assert.equal(informer.log.firstCall.args[0].error, "[O.o] (some original message)"); }); }); }); diff --git a/test/src/reporters/plain.js b/test/src/reporters/plain.js index d23f79d24..338b7b5a2 100644 --- a/test/src/reporters/plain.js +++ b/test/src/reporters/plain.js @@ -97,19 +97,19 @@ describe("Plain reporter", () => { }); it("should extend error with original selenium error if it exists", async () => { + const error = new Error("some error"); + error.stack = "some error\nsome stack"; + error.seleniumStack = { + orgStatusMessage: "some original message", + }; test = mkTestStub_({ - err: { - stack: "some stack", - seleniumStack: { - orgStatusMessage: "some original message", - }, - }, + err: error, }); await createPlainReporter(); emit(RunnerEvents[event], test); - assert.match(stdout, /some stack \(some original message\)/); + assert.match(stdout, /some error \(some original message\)/); }); }); }); diff --git a/test/src/utils/worker-error-serialization.ts b/test/src/utils/worker-error-serialization.ts new file mode 100644 index 000000000..9c72dd382 --- /dev/null +++ b/test/src/utils/worker-error-serialization.ts @@ -0,0 +1,88 @@ +import { assert } from "chai"; +import { + SERIALIZED_ERROR_MARKER, + deserializeWorkerError, + serializeWorkerError, +} from "src/utils/worker-error-serialization"; + +describe("worker-error-serialization", () => { + it("should create serialized error envelope", () => { + const error = new Error("boom"); + + const serialized = serializeWorkerError(error) as Record; + + assert.equal(serialized[SERIALIZED_ERROR_MARKER], true); + assert.equal(serialized.message, "boom"); + assert.equal(serialized.name, "Error"); + }); + + it("should round-trip message and custom field", () => { + const error = Object.assign(new Error("boom"), { code: "E_BROKEN" }); + + const deserialized = deserializeWorkerError(serializeWorkerError(error)) as Error & { code: string }; + + assert.instanceOf(deserialized, Error); + assert.equal(deserialized.message, "boom"); + assert.equal(deserialized.code, "E_BROKEN"); + }); + + it("should restore nested causes", () => { + const error = new Error("outer", { cause: new Error("inner", { cause: new Error("root") }) }); + + const deserialized = deserializeWorkerError(serializeWorkerError(error)) as Error & { + cause: Error & { cause: Error }; + }; + + assert.equal(deserialized.cause.message, "inner"); + assert.equal(deserialized.cause.cause.message, "root"); + assert.isFalse(Object.getOwnPropertyDescriptor(deserialized, "cause")?.enumerable ?? true); + }); + + it("should keep original error name", () => { + const error = new TypeError("wrong"); + + const deserialized = deserializeWorkerError(serializeWorkerError(error)) as Error; + + assert.instanceOf(deserialized, Error); + assert.equal(deserialized.name, "TypeError"); + assert.isFalse(Object.keys(deserialized).includes("name")); + }); + + it("should round-trip non-error values", () => { + const value = { reason: "broken", nested: { retry: false } }; + + const deserialized = deserializeWorkerError(serializeWorkerError(value)); + + assert.deepEqual(deserialized, value); + }); + + it("should replace circular references", () => { + const details: Record = {}; + details.self = details; + const error = Object.assign(new Error("boom"), { details }); + + const serialized = serializeWorkerError(error) as Record>; + + assert.equal(serialized.details.self, "[Circular]"); + }); + + it("should return input as-is for plain objects on deserialize", () => { + const value = { foo: "bar" }; + + const deserialized = deserializeWorkerError(value); + + assert.strictEqual(deserialized, value); + }); + + it("should serialize and deserialize buffers", () => { + const buffer = Buffer.from([0x48, 0x65, 0x6c, 0x6c, 0x6f]); + const error = Object.assign(new Error("buffer error"), { data: buffer }); + + const deserialized = deserializeWorkerError(serializeWorkerError(error)) as Error & { data: Buffer }; + + assert.instanceOf(deserialized, Error); + assert.instanceOf(deserialized.data, Buffer); + assert.deepEqual(deserialized.data, buffer); + assert.equal(deserialized.data.toString(), "Hello"); + }); +}); diff --git a/test/src/utils/workers-registry.js b/test/src/utils/workers-registry.js index b1fa68450..ff1192714 100644 --- a/test/src/utils/workers-registry.js +++ b/test/src/utils/workers-registry.js @@ -6,6 +6,7 @@ const _ = require("lodash"); const RuntimeConfig = require("src/config/runtime-config"); const { MasterEvents: Events } = require("src/events"); const { WorkerProcess } = require("src/utils/worker-process"); +const { SERIALIZED_ERROR_MARKER } = require("src/utils/worker-error-serialization"); const { MASTER_INIT, MASTER_SYNC_CONFIG, @@ -211,6 +212,40 @@ describe("WorkersRegistry", () => { assert.calledOnceWith(workersImpl.execute, "worker.js", "runTest", ["foo", { bar: "baz" }]), ); }); + + it("should deserialize error with nested cause from worker", async () => { + const workersRegistry = mkWorkersRegistry_(); + const workers = workersRegistry.register("worker.js", ["runTest"]); + workersImpl.execute.yieldsRight({ + [SERIALIZED_ERROR_MARKER]: true, + name: "Error", + message: "outer", + stack: "Error: outer", + testplaneCtx: { foo: "bar" }, + cause: { + [SERIALIZED_ERROR_MARKER]: true, + name: "TypeError", + message: "inner", + stack: "TypeError: inner", + }, + }); + + let error; + try { + await workers.runTest("foo", { bar: "baz" }); + } catch (err) { + error = err; + } + + assert.instanceOf(error, Error); + assert.equal(error.message, "outer"); + assert.deepEqual(error.testplaneCtx, { foo: "bar" }); + assert.instanceOf(error.cause, Error); + assert.equal(error.cause.name, "TypeError"); + assert.equal(error.cause.message, "inner"); + assert.equal(error.cause.stack, "TypeError: inner"); + assert.deepEqual(Object.keys(error), ["testplaneCtx"]); + }); }); describe("end", () => { diff --git a/test/src/worker/runner/test-runner/one-time-screenshooter.js b/test/src/worker/runner/test-runner/one-time-screenshooter.js index f235f1525..711c61a1d 100644 --- a/test/src/worker/runner/test-runner/one-time-screenshooter.js +++ b/test/src/worker/runner/test-runner/one-time-screenshooter.js @@ -23,7 +23,6 @@ describe("worker/runner/test-runner/one-time-screenshooter", () => { opts, ), evalScript: sinon.stub().named("evalScript").resolves({ height: 0, width: 0 }), - prepareScreenshot: sinon.stub().named("prepareScreenshot").resolves(), setHttpTimeout: sinon.spy().named("setHttpTimeout"), restoreHttpTimeout: sinon.spy().named("restoreHttpTimeout"), }; @@ -61,7 +60,7 @@ describe("worker/runner/test-runner/one-time-screenshooter", () => { "../../../utils/logger": logger, }); - sandbox.stub(ScreenShooter.prototype, "capture").resolves(stubImage_()); + sandbox.stub(ScreenShooter.prototype, "capture").resolves({ image: stubImage_(), meta: {} }); sandbox.stub(Image, "fromBase64").returns(stubImage_()); }); @@ -102,20 +101,17 @@ describe("worker/runner/test-runner/one-time-screenshooter", () => { it('should capture full page screenshot if option "takeScreenshotOnFailsMode" is set to "fullpage"', async () => { const browser = mkBrowser_(); browser.evalScript.resolves({ width: 100, height: 500 }); - browser.prepareScreenshot - .withArgs([{ left: 0, top: 0, width: 100, height: 500 }], { - ignoreSelectors: [], - captureElementFromTop: true, - allowViewportOverflow: true, - }) - .resolves("page-data"); const imgStub = stubImage_({ width: 100, height: 500 }); ScreenShooter.prototype.capture - .withArgs("page-data", { - compositeImage: true, - allowViewportOverflow: true, - }) - .resolves(imgStub); + .withArgs( + sinon.match([{ left: 0, top: 0, width: 100, height: 500 }]), + sinon.match({ + compositeImage: true, + allowViewportOverflow: true, + captureElementFromTop: true, + }), + ) + .resolves({ image: imgStub, meta: {} }); const config = { takeScreenshotOnFailsMode: "fullpage" }; const screenshooter = mkScreenshooter_({ browser, config });