diff --git a/.github/workflows/browser-env.yml b/.github/workflows/browser-env.yml index 5c14bbda9..81e15e2ae 100644 --- a/.github/workflows/browser-env.yml +++ b/.github/workflows/browser-env.yml @@ -43,15 +43,15 @@ jobs: - name: "Prepare browser-env tests: Pull browser docker image" run: | mkdir -p ~/.docker/cache - if [ -f ~/.docker/cache/image.tar ]; then - docker load -i ~/.docker/cache/image.tar + if [ -f ~/.docker/cache/image-ghcr-chromium-126.tar ]; then + docker load -i ~/.docker/cache/image-ghcr-chromium-126.tar else - docker pull yinfra/html-reporter-browsers - docker save yinfra/html-reporter-browsers -o ~/.docker/cache/image.tar + docker pull ghcr.io/gemini-testing/browsers/chromium:126.0 + docker save ghcr.io/gemini-testing/browsers/chromium:126.0 -o ~/.docker/cache/image-ghcr-chromium-126.tar fi - name: "Prepare browser-env tests: Run browser docker image" - run: docker run -d --name ${{ env.DOCKER_IMAGE_NAME }} -it --rm --network=host $(which colima >/dev/null || echo --add-host=host.docker.internal:0.0.0.0) yinfra/html-reporter-browsers + run: docker run -d --name ${{ env.DOCKER_IMAGE_NAME }} -it --rm --network=host $(which colima >/dev/null || echo --add-host=host.docker.internal:0.0.0.0) ghcr.io/gemini-testing/browsers/chromium:126.0 - name: "browser-env: Run Testplane" id: "testplane" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 2d6fbeaa8..9c5e2a928 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -43,15 +43,15 @@ jobs: - name: "Prepare e2e tests: Pull browser docker image" run: | mkdir -p ~/.docker/cache - if [ -f ~/.docker/cache/image.tar ]; then - docker load -i ~/.docker/cache/image.tar + if [ -f ~/.docker/cache/image-ghcr-chromium-126.tar ]; then + docker load -i ~/.docker/cache/image-ghcr-chromium-126.tar else - docker pull yinfra/html-reporter-browsers - docker save yinfra/html-reporter-browsers -o ~/.docker/cache/image.tar + docker pull ghcr.io/gemini-testing/browsers/chromium:126.0 + docker save ghcr.io/gemini-testing/browsers/chromium:126.0 -o ~/.docker/cache/image-ghcr-chromium-126.tar fi - name: "Prepare e2e tests: Run browser docker image" - run: docker run -d --name ${{ env.DOCKER_IMAGE_NAME }} -it --rm --network=host $(which colima >/dev/null || echo --add-host=host.docker.internal:0.0.0.0) yinfra/html-reporter-browsers + run: docker run -d --name ${{ env.DOCKER_IMAGE_NAME }} -it --rm --network=host $(which colima >/dev/null || echo --add-host=host.docker.internal:0.0.0.0) ghcr.io/gemini-testing/browsers/chromium:126.0 # - name: 'Prepare e2e tests: Setup env' # run: | diff --git a/.github/workflows/standalone-e2e.yml b/.github/workflows/standalone-e2e.yml index 376ccc6e4..4ea9d2cea 100644 --- a/.github/workflows/standalone-e2e.yml +++ b/.github/workflows/standalone-e2e.yml @@ -55,16 +55,16 @@ jobs: if: ${{ matrix.browser == 'chrome' }} run: | mkdir -p ~/.docker/cache - if [ -f ~/.docker/cache/image.tar ]; then - docker load -i ~/.docker/cache/image.tar + if [ -f ~/.docker/cache/image-ghcr-chromium-126.tar ]; then + docker load -i ~/.docker/cache/image-ghcr-chromium-126.tar else - docker pull yinfra/html-reporter-browsers - docker save yinfra/html-reporter-browsers -o ~/.docker/cache/image.tar + docker pull ghcr.io/gemini-testing/browsers/chromium:126.0 + docker save ghcr.io/gemini-testing/browsers/chromium:126.0 -o ~/.docker/cache/image-ghcr-chromium-126.tar fi - name: "Prepare screenshot tests: Run browser docker image" if: ${{ matrix.browser == 'chrome' }} - run: docker run -d --name ${{ env.DOCKER_IMAGE_NAME }} -it --rm --network=host $(which colima >/dev/null || echo --add-host=host.docker.internal:0.0.0.0) yinfra/html-reporter-browsers + run: docker run -d --name ${{ env.DOCKER_IMAGE_NAME }} -it --rm --network=host $(which colima >/dev/null || echo --add-host=host.docker.internal:0.0.0.0) ghcr.io/gemini-testing/browsers/chromium:126.0 - name: Run integration tests for ${{ matrix.browser }} env: diff --git a/package.json b/package.json index 862e37118..4d42cef70 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ } }, "scripts": { - "browsers:launch": "docker run -it --rm --network=host $(which colima >/dev/null || echo --add-host=host.docker.internal:0.0.0.0) yinfra/html-reporter-browsers", + "browsers:launch": "docker run -it --rm --network=host --shm-size=2g $(which colima >/dev/null || echo --add-host=host.docker.internal:0.0.0.0) ghcr.io/gemini-testing/browsers/chromium:126.0", "build": "tsc --build && npm run build-bundles && npm run copy-static", "copy-static": "copyfiles 'src/browser/client-scripts/**/*.js' 'src/**/[!cache]*/autogenerated/**/*.json' build", "build-node-bundle": "esbuild ./src/bundle/cjs/index.ts --outdir=./build/src/bundle/cjs --bundle --format=cjs --platform=node --target=ES2021", diff --git a/src/browser/client-scripts/screen-shooter/implementation.ts b/src/browser/client-scripts/screen-shooter/implementation.ts index bde497f96..57aaed8d0 100644 --- a/src/browser/client-scripts/screen-shooter/implementation.ts +++ b/src/browser/client-scripts/screen-shooter/implementation.ts @@ -27,7 +27,9 @@ import { ScrollResult, GetCaptureStateResult, TrackedElementData, - ElementPositionsProbe + ElementPositionsProbe, + CaptureState, + ViewportState } from "./types"; import { createDebugLogger } from "../shared/logger"; import { @@ -181,15 +183,21 @@ export function prepareElementsScreenshot( return safeCall(prepareElementsScreenshotUnsafe, targetsToCapture, opts); } +export function getCurrentPixelRatio(): number { + return computePixelRatio(); +} + export function scrollBy( targetsToCapture: ElementTarget[], scrollDelta: Length<"device", "y"> | Coord<"page", "device", "y">, targetToScroll?: ElementTarget | null, + usePixelRatio?: boolean, + pixelRatioOverride?: number, debug?: string[] ): ScrollResult { return safeCall((): ScrollResult => { const logger = createDebugLogger({ debug }, "scrollBy"); - const pixelRatio = computePixelRatio(); + const pixelRatio = computePixelRatio(usePixelRatio, pixelRatioOverride); const scrollTarget = targetToScroll ? lib.queryFirst(targetToScroll) : null; const scrollElement = scrollTarget ?? getCommonScrollParent(targetsToCapture); @@ -214,6 +222,8 @@ export function scrollTo( targetsToCapture: ElementTarget[], scrollOffset: Length<"device", "y"> | Coord<"page", "device", "y">, targetToScroll?: ElementTarget | null, + usePixelRatio?: boolean, + pixelRatioOverride?: number, debug?: string[] ): ScrollResult { return safeCall((): ScrollResult => { @@ -226,7 +236,7 @@ export function scrollTo( "targetToScroll:", targetToScroll ); - const pixelRatio = computePixelRatio(); + const pixelRatio = computePixelRatio(usePixelRatio, pixelRatioOverride); const scrollTarget = targetToScroll ? lib.queryFirst(targetToScroll) : null; const scrollElement = scrollTarget ?? getCommonScrollParent(targetsToCapture); @@ -250,48 +260,79 @@ export function scrollTo( }); } +function getViewportState(usePixelRatio?: boolean, pixelRatioOverride?: number): ViewportState { + const pixelRatio = computePixelRatio(usePixelRatio, pixelRatioOverride); + + return { + viewportSize: fromCssToDevice(computeViewportSize(), pixelRatio), + viewportOffset: fromCssToDevice(floorCoords(computeViewportOffset()), pixelRatio), + documentSize: ceilCoords(fromCssToDevice(computeDocumentSize(), pixelRatio)), + pixelRatio + }; +} + +function getCaptureStateUnsafe( + targetsToCapture: ElementTarget[], + targetsToIgnore: ElementTarget[], + targetToScroll: ElementTarget | null | undefined, + usePixelRatio: boolean | undefined, + pixelRatioOverride: number | undefined, + logger: ReturnType +): CaptureState & { readableSelectorToScrollDescr: string } { + const viewportState = getViewportState(usePixelRatio, pixelRatioOverride); + const scrollTarget = targetToScroll ? lib.queryFirst(targetToScroll) : null; + const scrollElement = scrollTarget ?? getCommonScrollParent(targetsToCapture); + const readableAutoScrollElementDescr = getReadableElementDescriptor(scrollElement); + const readableSelectorToScrollDescr = targetToScroll + ? scrollTarget + ? `${targetToScroll} (${readableAutoScrollElementDescr})` + : `${targetToScroll} (not found, auto-detected ${readableAutoScrollElementDescr})` + : `auto-detected ${readableAutoScrollElementDescr}`; + const ignoreAreas = computeIgnoreAreas(targetsToIgnore); + const safeArea = computeSafeArea(targetsToCapture, scrollElement, logger); + const captureSpecs = computeCaptureSpecs(targetsToCapture, logger); + const scrollOffset = computeScrollOffset(scrollElement); + const anchorShift = computeActualShift(); + + logger("scrollOffset:", scrollOffset); + + return { + ...viewportState, + captureSpecs: captureSpecs.map(spec => ({ + full: fromCssToDevice(roundCoords(spec.full), viewportState.pixelRatio), + clip: fromCssToDevice(roundCoords(spec.clip), viewportState.pixelRatio), + visible: fromCssToDevice(roundCoords(spec.visible), viewportState.pixelRatio) + })), + ignoreAreas: ignoreAreas.map(area => fromCssToDevice(roundCoords(area), viewportState.pixelRatio)), + safeArea: fromCssToDevice(roundCoords(safeArea), viewportState.pixelRatio), + scrollOffset: fromCssToDeviceNumber(scrollOffset, viewportState.pixelRatio), + anchorShift: anchorShift === null ? null : fromCssToDeviceNumber(anchorShift, viewportState.pixelRatio), + readableSelectorToScrollDescr + }; +} + /** Returns current state: positions of elements to capture, ignore areas, safe area, scroll offset */ export function getCaptureState( targetsToCapture: ElementTarget[], targetsToIgnore: ElementTarget[], targetToScroll?: ElementTarget | null, + usePixelRatio?: boolean, + pixelRatioOverride?: number, debug?: string[] ): GetCaptureStateResult { return safeCall((): GetCaptureStateResult => { const logger = createDebugLogger({ debug }, "getCaptureState"); - const pixelRatio = computePixelRatio(); - const scrollTarget = targetToScroll ? lib.queryFirst(targetToScroll) : null; - const scrollElement = scrollTarget ?? getCommonScrollParent(targetsToCapture); - const readableAutoScrollElementDescr = getReadableElementDescriptor(scrollElement); - const readableSelectorToScrollDescr = targetToScroll - ? scrollTarget - ? `${targetToScroll} (${readableAutoScrollElementDescr})` - : `${targetToScroll} (not found, auto-detected ${readableAutoScrollElementDescr})` - : `auto-detected ${readableAutoScrollElementDescr}`; - const ignoreAreas = computeIgnoreAreas(targetsToIgnore); - const safeArea = computeSafeArea(targetsToCapture, scrollElement, logger); - const captureSpecsAfterCss = computeCaptureSpecs(targetsToCapture, logger); - const captureSpecs = captureSpecsAfterCss.map(spec => ({ - full: fromCssToDevice(roundCoords(spec.full), pixelRatio), - clip: fromCssToDevice(roundCoords(spec.clip), pixelRatio), - visible: fromCssToDevice(roundCoords(spec.visible), pixelRatio) - })); - const scrollOffset = computeScrollOffset(scrollElement); - const viewportOffset = computeViewportOffset(); - - const anchorShift = computeActualShift(); - const anchorShiftDevice = anchorShift === null ? null : fromCssToDeviceNumber(anchorShift, pixelRatio); - - logger("scrollOffset:", scrollOffset); + const state = getCaptureStateUnsafe( + targetsToCapture, + targetsToIgnore, + targetToScroll, + usePixelRatio, + pixelRatioOverride, + logger + ); return { - captureSpecs, - ignoreAreas: ignoreAreas.map(area => fromCssToDevice(roundCoords(area), pixelRatio)), - safeArea: fromCssToDevice(roundCoords(safeArea), pixelRatio), - scrollOffset: fromCssToDeviceNumber(scrollOffset, pixelRatio), - viewportOffset: fromCssToDevice(floorCoords(viewportOffset), pixelRatio), - anchorShift: anchorShiftDevice, - readableSelectorToScrollDescr, + ...state, debugLog: logger() }; }); @@ -376,19 +417,16 @@ export function prepareViewportScreenshot( disableAnimation?: boolean; disableHover?: DisableHoverMode; ignoreSelectors?: ElementTarget[]; + pixelRatioOverride?: number; } = {} ): PrepareViewportScreenshotResult { return safeCall((): PrepareViewportScreenshotResult => { - const pixelRatio = computePixelRatio(opts.usePixelRatio); - const viewportSize = computeViewportSize(); - const viewportOffset = computeViewportOffset(); - const documentSize = computeDocumentSize(); - const canHaveCaret = computeCanHaveCaret(); - if (opts.disableAnimation) { disableAnimations(); } + const viewportState = getViewportState(opts.usePixelRatio, opts.pixelRatioOverride); + const canHaveCaret = computeCanHaveCaret(); const ignoreAreas = computeIgnoreAreas(opts.ignoreSelectors); let pointerEventsDisabled = false; @@ -398,12 +436,9 @@ export function prepareViewportScreenshot( } return { - viewportSize: fromCssToDevice(viewportSize, pixelRatio), - viewportOffset: fromCssToDevice(floorCoords(viewportOffset), pixelRatio), - documentSize: ceilCoords(fromCssToDevice(documentSize, pixelRatio)), - ignoreAreas: ignoreAreas.map(area => fromCssToDevice(roundCoords(area), pixelRatio)), + ...viewportState, + ignoreAreas: ignoreAreas.map(area => fromCssToDevice(roundCoords(area), viewportState.pixelRatio)), canHaveCaret, - pixelRatio, pointerEventsDisabled }; }); @@ -482,7 +517,7 @@ export function captureAnchorBaseline(targetsToCapture: ElementTarget[]): void | function prepareElementsScreenshotUnsafe( targetsToCapture: ElementTarget[], opts: PrepareScreenshotOptions -): PrepareScreenshotResult { +): PrepareScreenshotSuccess { const logger = createDebugLogger(opts, "prepareElementsScreenshot"); saveScrollPositions(targetsToCapture, opts.selectorToScroll); @@ -499,18 +534,14 @@ function prepareElementsScreenshotUnsafe( disableAnimations(); } - const pixelRatio = computePixelRatio(opts.usePixelRatio); - const scrollTarget = opts.selectorToScroll ? lib.queryFirst(opts.selectorToScroll) : null; - const scrollElement = scrollTarget ?? getCommonScrollParent(targetsToCapture); - - const ignoreAreas = computeIgnoreAreas(opts.ignoreSelectors); - const captureSpecs = computeCaptureSpecs(targetsToCapture, logger); - const viewportSize = computeViewportSize(); - const viewportOffset = computeViewportOffset(); - const safeArea = computeSafeArea(targetsToCapture, scrollElement, logger); - const scrollOffset = computeScrollOffset(scrollElement); - - const documentSize = computeDocumentSize(); + const state = getCaptureStateUnsafe( + targetsToCapture, + opts.ignoreSelectors ?? [], + opts.selectorToScroll, + opts.usePixelRatio, + opts.pixelRatioOverride, + logger + ); const canHaveCaret = computeCanHaveCaret(); let pointerEventsDisabled = false; @@ -518,39 +549,26 @@ function prepareElementsScreenshotUnsafe( disablePointerEventsUnsafe(); pointerEventsDisabled = true; } else if (opts.disableHover === DisableHoverMode.WhenScrollingNeeded && opts.compositeImage) { - const captureArea = getCoveringRect(captureSpecs.map(s => s.full)); - const needsScrolling = getBottom(captureArea) > getBottom(safeArea); + const captureArea = getCoveringRect(state.captureSpecs.map(s => s.full)); + const needsScrolling = getBottom(captureArea) > getBottom(state.safeArea); if (needsScrolling) { logger( "adding stylesheet with pointer-events: none on all elements (composite capture needs scrolling). captureArea:", captureArea, "safeArea:", - safeArea + state.safeArea ); disablePointerEventsUnsafe(); pointerEventsDisabled = true; } } - logger("scrollOffset:", scrollOffset); - return { - ignoreAreas: ignoreAreas.map(area => fromCssToDevice(roundCoords(area), pixelRatio)), - captureSpecs: captureSpecs.map(s => ({ - full: fromCssToDevice(roundCoords(s.full), pixelRatio), - clip: fromCssToDevice(roundCoords(s.clip), pixelRatio), - visible: fromCssToDevice(roundCoords(s.visible), pixelRatio) - })), - viewportSize: fromCssToDevice(viewportSize, pixelRatio), - viewportOffset: fromCssToDevice(floorCoords(viewportOffset), pixelRatio), - safeArea: fromCssToDevice(roundCoords(safeArea), pixelRatio), - documentSize: ceilCoords(fromCssToDevice(documentSize, pixelRatio)), + ...state, canHaveCaret, - pixelRatio: pixelRatio, pointerEventsDisabled: pointerEventsDisabled, - debugLog: logger(), readableSelectorToScrollDescr, - scrollOffset: fromCssToDeviceNumber(scrollOffset, pixelRatio) + debugLog: logger() } satisfies PrepareScreenshotSuccess; } diff --git a/src/browser/client-scripts/screen-shooter/operations.ts b/src/browser/client-scripts/screen-shooter/operations.ts index f91055c7e..a25aa8b83 100644 --- a/src/browser/client-scripts/screen-shooter/operations.ts +++ b/src/browser/client-scripts/screen-shooter/operations.ts @@ -466,11 +466,15 @@ export function computeCanHaveCaret(): boolean { return canHaveCaret; } -export function computePixelRatio(usePixelRatio: boolean = true): number { +export function computePixelRatio(usePixelRatio: boolean = true, pixelRatioOverride?: number): number { if (usePixelRatio === false) { return 1; } + if (pixelRatioOverride) { + return pixelRatioOverride; + } + if (window.devicePixelRatio) { return window.devicePixelRatio; } diff --git a/src/browser/client-scripts/screen-shooter/types.ts b/src/browser/client-scripts/screen-shooter/types.ts index 0e81cc2e1..c2fceb0cc 100644 --- a/src/browser/client-scripts/screen-shooter/types.ts +++ b/src/browser/client-scripts/screen-shooter/types.ts @@ -16,9 +16,15 @@ export interface TrackedElementData { rect: Rect<"viewport", "css">; } -export interface CaptureState { - scrollOffset: Coord<"page", "device", "y">; +export interface ViewportState { + viewportSize: Size<"device">; viewportOffset: Point<"page", "device">; + documentSize: Size<"device">; + pixelRatio: number; +} + +export interface CaptureState extends ViewportState { + scrollOffset: Coord<"page", "device", "y">; captureSpecs: CaptureSpec<"viewport", "device">[]; ignoreAreas: Rect<"viewport", "device">[]; safeArea: YBand<"viewport", "device">; @@ -48,34 +54,18 @@ export interface PrepareScreenshotOptions { compositeImage?: boolean; debug?: string[]; usePixelRatio?: boolean; + pixelRatioOverride?: number; } -export interface PrepareScreenshotSuccess { - // Area free of sticky elements, inside which it's safe to capture element that's interesting to us - // Measured relative to browser viewport (not the whole page!) - safeArea: YBand<"viewport", "device">; - // Boundaries of elements that we should ignore when comparing screenshots (these areas will be painted in black) - ignoreAreas: Rect<"viewport", "device">[]; - // Element capture areas with full (unconstrained) and visible (clipped by ancestor overflow) rects - captureSpecs: CaptureSpec<"viewport", "device">[]; - // Viewport size - viewportSize: Size<"device">; - // Viewport scroll offsets, window.pageXOffset / window.pageYOffset respectively - viewportOffset: Point<"page", "device">; - // Total height of the document, may be larger than viewport - documentSize: Size<"device">; +export interface PrepareScreenshotSuccess extends CaptureState { // Whether the document.activeElement is likely editable (e.g. input, textarea, etc.) 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; // Description of the element that is being scrolled, used for human-readable errors readableSelectorToScrollDescr?: string; - // Current vertical scroll offset of the resolved scroll element (or window/document root) - scrollOffset: Coord<"page", "device", "y">; } export type PrepareScreenshotResult = PrepareScreenshotSuccess | BrowserSideError; @@ -86,13 +76,9 @@ export interface ScrollToCaptureSpecResult { export type ElementPositionsProbe = (Rect<"viewport", U> & { elementDescr?: string }) | null; -export interface PrepareFullPageScreenshotSuccess { - documentSize: Size<"device">; - viewportSize: Size<"device">; - viewportOffset: Point<"page", "device">; +export interface PrepareFullPageScreenshotSuccess extends ViewportState { safeArea: YBand<"viewport", "device">; elementPositionsProbe: ElementPositionsProbe<"device">[]; - pixelRatio: number; pointerEventsDisabled?: boolean; } @@ -103,13 +89,9 @@ export interface ScrollFullPageSuccess { elementPositionsProbe: ElementPositionsProbe<"device">[]; } -export interface PrepareViewportScreenshotSuccess { - viewportSize: Size<"device">; - viewportOffset: Point<"page", "device">; - documentSize: Size<"device">; +export interface PrepareViewportScreenshotSuccess extends ViewportState { ignoreAreas: Rect<"viewport", "device">[]; canHaveCaret: boolean; - pixelRatio: number; pointerEventsDisabled?: boolean; } diff --git a/src/browser/commands/assert-view/index.js b/src/browser/commands/assert-view/index.js index 5e3eaf05d..21aca40b4 100644 --- a/src/browser/commands/assert-view/index.js +++ b/src/browser/commands/assert-view/index.js @@ -19,8 +19,20 @@ const { AssertViewError } = require("./errors/assert-view-error"); const makeDebug = require("debug"); const debug = makeDebug("testplane:screenshots:assert-view"); +const HEADLESS_CHROME_ARG_RE = /^-{0,2}headless(?:=|$)/; + const getShortDebugId = debugId => crypto.createHash("sha1").update(debugId).digest("hex").slice(0, 7); +const isHeadlessBrowser = chromeOptions => (chromeOptions?.args || []).some(arg => HEADLESS_CHROME_ARG_RE.test(arg)); + +const getEstimatedPixelRatioFromCapabilities = chromeOptions => { + const pixelRatio = _.get(chromeOptions, "mobileEmulation.deviceMetrics.pixelRatio"); + + return _.isFinite(pixelRatio) && pixelRatio > 0 ? pixelRatio : undefined; +}; + +const isPixelRatioEmulated = chromeOptions => Boolean(chromeOptions?.mobileEmulation); + const getIgnoreDiffPixelCountRatio = value => { const percent = _.isString(value) && value.endsWith("%") ? parseFloat(value.slice(0, -1)) : false; @@ -184,10 +196,18 @@ module.exports.default = browser => { if (!elementsScreenShooterPromise) { const { isWebdriverProtocol, shouldUsePixelRatio, needsCompatLib } = browser; + const chromeOptions = session.requestedCapabilities?.["goog:chromeOptions"]; elementsScreenShooterPromise = ElementsScreenShooter.create({ camera: browser.camera, browser: browser.publicAPI, - browserProperties: { isWebdriverProtocol, shouldUsePixelRatio, needsCompatLib }, + browserProperties: { + isWebdriverProtocol, + shouldUsePixelRatio, + needsCompatLib, + isHeadless: isHeadlessBrowser(chromeOptions), + isPixelRatioEmulated: isPixelRatioEmulated(chromeOptions), + estimatedPixelRatioFromCapabilities: getEstimatedPixelRatioFromCapabilities(chromeOptions), + }, }); } @@ -239,10 +259,18 @@ module.exports.default = browser => { if (!viewportScreenShooterPromise) { const { isWebdriverProtocol, shouldUsePixelRatio, needsCompatLib } = browser; + const chromeOptions = session.requestedCapabilities?.["goog:chromeOptions"]; viewportScreenShooterPromise = ViewportScreenShooter.create({ camera: browser.camera, browser: browser.publicAPI, - browserProperties: { isWebdriverProtocol, shouldUsePixelRatio, needsCompatLib }, + browserProperties: { + isWebdriverProtocol, + shouldUsePixelRatio, + needsCompatLib, + isHeadless: isHeadlessBrowser(chromeOptions), + isPixelRatioEmulated: isPixelRatioEmulated(chromeOptions), + estimatedPixelRatioFromCapabilities: getEstimatedPixelRatioFromCapabilities(chromeOptions), + }, }); } diff --git a/src/browser/screen-shooter/elements-screen-shooter.ts b/src/browser/screen-shooter/elements-screen-shooter.ts index 0a1d0b728..a73ca8d4c 100644 --- a/src/browser/screen-shooter/elements-screen-shooter.ts +++ b/src/browser/screen-shooter/elements-screen-shooter.ts @@ -34,12 +34,20 @@ class CaptureAreaSizeChangeError extends Error { } } +class PixelRatioChangeError extends Error { + constructor() { + super("Estimated pixel ratio did not match actual pixel ratio during capture"); + this.name = "PixelRatioChangeError"; + } +} + const debug = makeVerboseScreenshotsDebug("testplane:screenshots:elements-screen-shooter"); const SCROLL_OVERLAP_PX = 1; const formatDuration = (duration: number): string => `${duration.toFixed(1)}ms`; interface ScreenShooterOpts extends AssertViewOpts { debugId?: string; + pixelRatioOverride?: number; } type PrepareScreenshotOptions = ClientBridgeArgument; @@ -50,10 +58,22 @@ interface CaptureImageResult { meta: PrepareScreenshotSuccess; } +interface CaptureAttemptParams { + targetsToCapture: ElementTarget[]; + targetsToIgnore: ElementTarget[]; + page: PrepareScreenshotSuccess; + opts: ScreenShooterOpts; + isStrictAttempt: boolean; + shouldCheckPixelRatio: boolean; +} + interface ScreenShooterBrowserProperties { isWebdriverProtocol: boolean; shouldUsePixelRatio: boolean; needsCompatLib: boolean; + isHeadless: boolean; + isPixelRatioEmulated: boolean; + estimatedPixelRatioFromCapabilities?: number; } interface ScreenShooterInputParams { @@ -230,6 +250,21 @@ export class ElementsScreenShooter { throw new Error("No targets to capture passed to ElementsScreenShooter.capture"); } + // Important to fix a bug with DPR, see test in dpr-off.testplane.js, which reproduces the bug with DPR + const shouldValidatePixelRatio = + this._browserProperties.shouldUsePixelRatio && + !this._browserProperties.isHeadless && + this._browserProperties.isPixelRatioEmulated; + + if (shouldValidatePixelRatio && this._browserProperties.estimatedPixelRatioFromCapabilities) { + debug( + "using pixel ratio %d estimated from capabilities", + this._browserProperties.estimatedPixelRatioFromCapabilities, + ); + + opts.pixelRatioOverride = this._browserProperties.estimatedPixelRatioFromCapabilities; + } + try { perfDebug("capture: begin"); @@ -241,6 +276,7 @@ export class ElementsScreenShooter { disableAnimation: opts.disableAnimation, disableHover: opts.disableHover, compositeImage: opts.compositeImage, + pixelRatioOverride: opts.pixelRatioOverride, }); assertCorrectCaptureAreaBounds( @@ -258,21 +294,38 @@ export class ElementsScreenShooter { let compositeImage: CompositeImage; try { - compositeImage = await this._performCaptureAttempt(targetsToCapture, targetsToIgnore, page, opts, true); + compositeImage = await this._performCaptureAttempt({ + targetsToCapture, + targetsToIgnore, + page, + opts, + isStrictAttempt: true, + shouldCheckPixelRatio: shouldValidatePixelRatio, + }); } catch (error) { - if (!(error instanceof CaptureAreaSizeChangeError)) { + if (!(error instanceof CaptureAreaSizeChangeError) && !(error instanceof PixelRatioChangeError)) { throw error; } - perfDebug("capture: retrying in best-effort mode"); - await this._preloadCaptureArea(targetsToCapture, targetsToIgnore, page, opts); - compositeImage = await this._performCaptureAttempt( + perfDebug(`capture: retrying in best-effort mode (${error.message})`); + + if (opts.pixelRatioOverride !== undefined || error instanceof PixelRatioChangeError) { + delete opts.pixelRatioOverride; + Object.assign(page, await this._getCaptureState(targetsToCapture, targetsToIgnore, opts)); + } + + if (error instanceof CaptureAreaSizeChangeError) { + await this._preloadCaptureArea(targetsToCapture, targetsToIgnore, page, opts); + } + + compositeImage = await this._performCaptureAttempt({ targetsToCapture, targetsToIgnore, page, opts, - false, - ); + isStrictAttempt: false, + shouldCheckPixelRatio: false, + }); } const renderedImage = await compositeImage.render(); @@ -365,6 +418,42 @@ export class ElementsScreenShooter { }); } + private async _getCaptureState( + targetsToCapture: ElementTarget[], + targetsToIgnore: ElementTarget[], + opts: ScreenShooterOpts, + ): Promise { + const enabledDebugTopics: string[] = []; + const browserScrollDebug = makeVerboseScreenshotsDebug("testplane:screenshots:browser:getCaptureState"); + if (browserScrollDebug.enabled) { + enabledDebugTopics.push("getCaptureState"); + } + + const state = await this._browserSideScreenshooter.call("getCaptureState", [ + targetsToCapture, + targetsToIgnore, + opts.selectorToScroll, + this._browserProperties.shouldUsePixelRatio, + opts.pixelRatioOverride, + enabledDebugTopics, + ]); + const stateDebugLog = state.debugLog; + delete state.debugLog; + browserScrollDebug(stateDebugLog); + + debug("currentState: %O", state); + + if (isBrowserSideError(state)) { + throw new Error( + `Failed to recompute areas while compositing image of elements: ${getTargetDescriptions( + targetsToCapture, + ).join(", ")}, error type '${state.errorCode}' and error message: ${state.message}`, + ); + } + + return state; + } + /** Scrolls through the entire capture area to trigger lazy loading, then restores scroll and records anchor baselines. */ private async _preloadCaptureArea( targetsToCapture: ElementTarget[], @@ -382,6 +471,8 @@ export class ElementsScreenShooter { targetsToCapture, page.scrollOffset, opts.selectorToScroll ?? null, + this._browserProperties.shouldUsePixelRatio, + opts.pixelRatioOverride, ]); await this._browserSideScreenshooter.call("captureAnchorBaseline", [targetsToCapture]); @@ -399,14 +490,7 @@ export class ElementsScreenShooter { ): Promise { const perfDebug = makeDebug("testplane:screenshots:perf:" + opts.debugId); let iterations = 0; - let lastState: CaptureState = { - captureSpecs: page.captureSpecs, - viewportOffset: page.viewportOffset, - scrollOffset: page.scrollOffset, - safeArea: page.safeArea, - ignoreAreas: page.ignoreAreas, - anchorShift: null, - }; + let lastState: CaptureState = { ...page, anchorShift: null }; let hasReachedScrollLimit = false; let hasCapturedTheWholeArea = false; @@ -428,36 +512,8 @@ export class ElementsScreenShooter { const recomputeStartTime = performance.now(); - const enabledScrollDebugTopics: string[] = []; - const browserScrollDebug = makeVerboseScreenshotsDebug("testplane:screenshots:browser:getCaptureState"); - if (browserScrollDebug.enabled) { - enabledScrollDebugTopics.push("getCaptureState"); - } - - const currentStateOrError = await this._browserSideScreenshooter.call("getCaptureState", [ - targetsToCapture, - targetsToIgnore, - opts.selectorToScroll, - enabledScrollDebugTopics, - ]); + let currentState = await this._getCaptureState(targetsToCapture, targetsToIgnore, opts); recomputeTime += performance.now() - recomputeStartTime; - const recomputeDebugLog = currentStateOrError.debugLog; - delete currentStateOrError.debugLog; - browserScrollDebug(recomputeDebugLog); - - debug("currentState: %O", currentStateOrError); - - if (isBrowserSideError(currentStateOrError)) { - throw new Error( - `Failed to recompute areas while compositing image of elements: ${getTargetDescriptions( - targetsToCapture, - ).join(", ")}, error type '${currentStateOrError.errorCode}' and error message: ${ - currentStateOrError.message - }`, - ); - } - - let currentState = currentStateOrError; const safeAreaShrink = (lastState.safeArea.height - currentState.safeArea.height) as Length< "device", @@ -484,22 +540,10 @@ export class ElementsScreenShooter { targetsToCapture, -rollbackDistance as Coord<"page", "device", "y">, opts.selectorToScroll, + this._browserProperties.shouldUsePixelRatio, + opts.pixelRatioOverride, ]); - const afterRollbackState = await this._browserSideScreenshooter.call("getCaptureState", [ - targetsToCapture, - targetsToIgnore, - opts.selectorToScroll, - ]); - - if (isBrowserSideError(afterRollbackState)) { - throw new Error( - `Failed to rollback and recompute areas while compositing image of elements: ${getTargetDescriptions( - targetsToCapture, - ).join(", ")}, error type '${afterRollbackState.errorCode}' and error message: ${ - afterRollbackState.message - }`, - ); - } + const afterRollbackState = await this._getCaptureState(targetsToCapture, targetsToIgnore, opts); if (!afterRollbackState.safeArea || !afterRollbackState.ignoreAreas) { throw new Error( @@ -554,10 +598,17 @@ export class ElementsScreenShooter { ); const scrollStartTime = performance.now(); + const enabledScrollDebugTopics: string[] = []; + const browserScrollDebug = makeVerboseScreenshotsDebug("testplane:screenshots:browser:getCaptureState"); + if (browserScrollDebug.enabled) { + enabledScrollDebugTopics.push("getCaptureState"); + } const scrollResult = await this._browserSideScreenshooter.call("scrollBy", [ targetsToCapture, scrollDelta, opts.selectorToScroll, + this._browserProperties.shouldUsePixelRatio, + opts.pixelRatioOverride, enabledScrollDebugTopics, ]); scrollTime += performance.now() - scrollStartTime; @@ -598,15 +649,16 @@ export class ElementsScreenShooter { } } - private async _performCaptureAttempt( - targetsToCapture: ElementTarget[], - targetsToIgnore: ElementTarget[], - page: PrepareScreenshotSuccess, - opts: ScreenShooterOpts, - shouldThrowOnCaptureAreaSizeChange: boolean, - ): Promise { + private async _performCaptureAttempt({ + targetsToCapture, + targetsToIgnore, + page, + opts, + isStrictAttempt, + shouldCheckPixelRatio, + }: CaptureAttemptParams): Promise { const perfDebug = makeDebug("testplane:screenshots:perf:" + opts.debugId); - const attemptMode = shouldThrowOnCaptureAreaSizeChange ? "strict" : "best-effort"; + const attemptMode = isStrictAttempt ? "strict" : "best-effort"; const image = CompositeImage.create(); let timeSpentOnCapture = 0; @@ -617,20 +669,17 @@ export class ElementsScreenShooter { let hasCapturedTheWholeArea = false; let restoreScrollPositionError: Error | null = null; - let lastState: CaptureState = { - viewportOffset: page.viewportOffset, - captureSpecs: page.captureSpecs, - scrollOffset: page.scrollOffset, - safeArea: page.safeArea, - ignoreAreas: page.ignoreAreas, - anchorShift: null, - }; + let lastState: CaptureState = { ...page, anchorShift: null }; let shouldRestoreScrollPosition = false; perfDebug(`capture attempt (${attemptMode}): begin`); try { await this._scrollThroughCaptureArea(targetsToCapture, targetsToIgnore, page, opts, async currentState => { + if (currentState.scrollOffset !== page.scrollOffset) { + shouldRestoreScrollPosition = true; + } + if (currentState.captureSpecs.length === 0) { if (iterations > 0) { debug( @@ -653,7 +702,13 @@ export class ElementsScreenShooter { spec.full.height !== currentState.captureSpecs[index]?.full.height, ); - if (hasCaptureAreaSizeChanged && shouldThrowOnCaptureAreaSizeChange) { + if (hasCaptureAreaSizeChanged && isStrictAttempt && !shouldCheckPixelRatio) { + debug( + "capture area size changed, will retry capture attempt. Last state: %O, current state: %O", + lastState.captureSpecs, + currentState.captureSpecs, + ); + throw new CaptureAreaSizeChangeError(); } @@ -674,11 +729,37 @@ export class ElementsScreenShooter { timeSpentOnCapture += performance.now() - captureStartTime; + if (shouldCheckPixelRatio) { + const currentPixelRatio = await this._browserSideScreenshooter.call("getCurrentPixelRatio", []); + + if (currentPixelRatio !== currentState.pixelRatio) { + debug( + "expected pixel ratio %d did not match actual %d, retrying capture attempt", + currentState.pixelRatio, + currentPixelRatio, + ); + + throw new PixelRatioChangeError(); + } else { + debug("pixel ratio %d matched expected %d", currentPixelRatio, currentState.pixelRatio); + } + } + + if (hasCaptureAreaSizeChanged && isStrictAttempt) { + debug( + "capture area size changed, will retry capture attempt. Last state: %O, current state: %O", + lastState.captureSpecs, + currentState.captureSpecs, + ); + + throw new CaptureAreaSizeChangeError(); + } + const expectedTotalMove = getExpectedTotalMoveFromBaseline(page.captureSpecs, newCaptureSpecs); const observedTotalMove = currentState.anchorShift; let correctionDelta = 0; - if (!shouldThrowOnCaptureAreaSizeChange && observedTotalMove !== null) { + if (!isStrictAttempt && observedTotalMove !== null) { correctionDelta = expectedTotalMove - observedTotalMove; } @@ -702,10 +783,6 @@ export class ElementsScreenShooter { hasCapturedTheWholeArea = movingCaptureSpecs.every(s => getBottom(s.full) <= getBottom(newSafeArea)); isOverflowingViewport = newCaptureSpecs.some(s => getBottom(s.full) > page.viewportSize.height); - if (currentState.scrollOffset !== page.scrollOffset) { - shouldRestoreScrollPosition = true; - } - debug("newCaptureSpecs: %O", newCaptureSpecs); debug("newSafeArea: %O", newSafeArea); debug("lastState.captureSpecs: %O", lastState.captureSpecs); @@ -726,6 +803,8 @@ export class ElementsScreenShooter { targetsToCapture, page.scrollOffset, opts.selectorToScroll, + this._browserProperties.shouldUsePixelRatio, + opts.pixelRatioOverride, enabledScrollDebugTopics, ]); const restoreScrollDebugLog = restoreScrollResult.debugLog; diff --git a/src/browser/screen-shooter/viewport-screen-shooter.ts b/src/browser/screen-shooter/viewport-screen-shooter.ts index c0deb6ce9..8faddddfb 100644 --- a/src/browser/screen-shooter/viewport-screen-shooter.ts +++ b/src/browser/screen-shooter/viewport-screen-shooter.ts @@ -21,6 +21,9 @@ interface ScreenShooterBrowserProperties { isWebdriverProtocol: boolean; shouldUsePixelRatio: boolean; needsCompatLib: boolean; + isHeadless: boolean; + isPixelRatioEmulated: boolean; + estimatedPixelRatioFromCapabilities?: number; } interface ViewportScreenShooterInputParams { @@ -39,6 +42,7 @@ interface ViewportCaptureOpts { disableAnimation?: boolean; disableHover?: DisableHoverMode; cropMargins?: CropMargins; + pixelRatioOverride?: number; } interface ViewportCaptureResult { @@ -70,6 +74,15 @@ export class ViewportScreenShooter { } async capture(opts: ViewportCaptureOpts = {}): Promise { + const shouldValidatePixelRatio = + this._browserProperties.shouldUsePixelRatio && + !this._browserProperties.isHeadless && + this._browserProperties.isPixelRatioEmulated; + + if (shouldValidatePixelRatio && this._browserProperties.estimatedPixelRatioFromCapabilities !== undefined) { + opts.pixelRatioOverride = this._browserProperties.estimatedPixelRatioFromCapabilities; + } + try { return await this._captureImpl(opts); } finally { @@ -82,25 +95,30 @@ export class ViewportScreenShooter { } } - private async _captureImpl(opts: ViewportCaptureOpts): Promise { + private async _captureImpl(opts: ViewportCaptureOpts, isRetry = false): Promise { const selectorsToIgnore = ([] as string[]).concat(opts.ignoreElements ?? []); + // Keep the initial preparation side effects and their cleanup callbacks intact during the retry. + const disableAnimation = isRetry ? false : opts.disableAnimation; + const disableHover = isRetry ? undefined : opts.disableHover; const prepareResult = await runWithoutHistory({}, () => this._browserSideScreenshooter.call("prepareViewportScreenshot", [ { usePixelRatio: this._browserProperties.shouldUsePixelRatio, - disableAnimation: opts.disableAnimation, - disableHover: opts.disableHover, + disableAnimation, + disableHover, ignoreSelectors: selectorsToIgnore, + pixelRatioOverride: opts.pixelRatioOverride, }, ]), ); debug("prepareViewportScreenshot opts: %O", { usePixelRatio: this._browserProperties.shouldUsePixelRatio, - disableAnimation: opts.disableAnimation, - disableHover: opts.disableHover, + disableAnimation, + disableHover, ignoreSelectors: selectorsToIgnore, + pixelRatioOverride: opts.pixelRatioOverride, }); debug("prepareViewportScreenshot result: %O", prepareResult); @@ -111,12 +129,12 @@ export class ViewportScreenShooter { } // https://github.com/webdriverio/webdriverio/issues/11396 - if (this._browserProperties.isWebdriverProtocol && opts.disableAnimation) { + if (this._browserProperties.isWebdriverProtocol && disableAnimation) { await disableIframeAnimations(this._browser, this._browserSideScreenshooter); } await preparePointerForScreenshot(this._browser, { - disableHover: opts.disableHover, + disableHover, pointerEventsDisabled: prepareResult.pointerEventsDisabled, }); @@ -131,6 +149,22 @@ export class ViewportScreenShooter { cropMargins: opts.cropMargins, }); + const shouldCheckPixelRatio = + !isRetry && + this._browserProperties.shouldUsePixelRatio && + !this._browserProperties.isHeadless && + this._browserProperties.isPixelRatioEmulated; + + if (shouldCheckPixelRatio) { + const currentPixelRatio = await this._browserSideScreenshooter.call("getCurrentPixelRatio", []); + + if (currentPixelRatio !== prepareResult.pixelRatio) { + delete opts.pixelRatioOverride; + + return this._captureImpl(opts, true); + } + } + if (prepareResult.ignoreAreas.length > 0) { const cropOffset = { left: opts.cropMargins?.left ?? 0, diff --git a/test/browser-env/screens/0467357/chrome/compute-safe-area-header-and-footer.png b/test/browser-env/screens/0467357/chrome/compute-safe-area-header-and-footer.png index ec852b087..a0376bb0b 100644 Binary files a/test/browser-env/screens/0467357/chrome/compute-safe-area-header-and-footer.png and b/test/browser-env/screens/0467357/chrome/compute-safe-area-header-and-footer.png differ diff --git a/test/browser-env/screens/122ded8/chrome/compute-safe-area-nested-stacking-overlay-behind.png b/test/browser-env/screens/122ded8/chrome/compute-safe-area-nested-stacking-overlay-behind.png index 4293ffd97..fcf29d7dd 100644 Binary files a/test/browser-env/screens/122ded8/chrome/compute-safe-area-nested-stacking-overlay-behind.png and b/test/browser-env/screens/122ded8/chrome/compute-safe-area-nested-stacking-overlay-behind.png differ diff --git a/test/browser-env/screens/34ca123/chrome/compute-safe-area-stacking-context-filter-in-front.png b/test/browser-env/screens/34ca123/chrome/compute-safe-area-stacking-context-filter-in-front.png index 8bdc94b9c..951d16575 100644 Binary files a/test/browser-env/screens/34ca123/chrome/compute-safe-area-stacking-context-filter-in-front.png and b/test/browser-env/screens/34ca123/chrome/compute-safe-area-stacking-context-filter-in-front.png differ diff --git a/test/browser-env/screens/3d5257a/chrome/compute-safe-area-fixed-app-header.png b/test/browser-env/screens/3d5257a/chrome/compute-safe-area-fixed-app-header.png index af8214c02..6e1a9bebe 100644 Binary files a/test/browser-env/screens/3d5257a/chrome/compute-safe-area-fixed-app-header.png and b/test/browser-env/screens/3d5257a/chrome/compute-safe-area-fixed-app-header.png differ diff --git a/test/browser-env/screens/3d7960d/chrome/compute-safe-area-stacking-context-opacity-behind.png b/test/browser-env/screens/3d7960d/chrome/compute-safe-area-stacking-context-opacity-behind.png index 520125f74..78550fffd 100644 Binary files a/test/browser-env/screens/3d7960d/chrome/compute-safe-area-stacking-context-opacity-behind.png and b/test/browser-env/screens/3d7960d/chrome/compute-safe-area-stacking-context-opacity-behind.png differ diff --git a/test/browser-env/screens/40bec12/chrome/compute-safe-area-root-sticky-behind-absolute-popup.png b/test/browser-env/screens/40bec12/chrome/compute-safe-area-root-sticky-behind-absolute-popup.png index e43299c71..fa5072041 100644 Binary files a/test/browser-env/screens/40bec12/chrome/compute-safe-area-root-sticky-behind-absolute-popup.png and b/test/browser-env/screens/40bec12/chrome/compute-safe-area-root-sticky-behind-absolute-popup.png differ diff --git a/test/browser-env/screens/aaf8f86/chrome/compute-safe-area-nested-stacking-overlay-in-front.png b/test/browser-env/screens/aaf8f86/chrome/compute-safe-area-nested-stacking-overlay-in-front.png index eb7dd07fb..ec57d0a25 100644 Binary files a/test/browser-env/screens/aaf8f86/chrome/compute-safe-area-nested-stacking-overlay-in-front.png and b/test/browser-env/screens/aaf8f86/chrome/compute-safe-area-nested-stacking-overlay-in-front.png differ diff --git a/test/e2e/screens/4a08211/chrome/test-block.png b/test/e2e/screens/4a08211/chrome/test-block.png index 58529407d..c15c7bea8 100644 Binary files a/test/e2e/screens/4a08211/chrome/test-block.png and b/test/e2e/screens/4a08211/chrome/test-block.png differ diff --git a/test/e2e/screens/5e90508/chrome/fixed-overlay-content-and-scrollable-block.png b/test/e2e/screens/5e90508/chrome/fixed-overlay-content-and-scrollable-block.png index da569c894..333c4e464 100644 Binary files a/test/e2e/screens/5e90508/chrome/fixed-overlay-content-and-scrollable-block.png and b/test/e2e/screens/5e90508/chrome/fixed-overlay-content-and-scrollable-block.png differ diff --git a/test/e2e/screens/64eaa81/chrome/test-block.png b/test/e2e/screens/64eaa81/chrome/test-block.png index 5f72c2e71..f73b74d1d 100644 Binary files a/test/e2e/screens/64eaa81/chrome/test-block.png and b/test/e2e/screens/64eaa81/chrome/test-block.png differ diff --git a/test/e2e/screens/6f1347f/chrome/test-block.png b/test/e2e/screens/6f1347f/chrome/test-block.png index dee22b9f5..338320f7e 100644 Binary files a/test/e2e/screens/6f1347f/chrome/test-block.png and b/test/e2e/screens/6f1347f/chrome/test-block.png differ diff --git a/test/e2e/screens/75147d9/chrome/test-block.png b/test/e2e/screens/75147d9/chrome/test-block.png index dee22b9f5..338320f7e 100644 Binary files a/test/e2e/screens/75147d9/chrome/test-block.png and b/test/e2e/screens/75147d9/chrome/test-block.png differ diff --git a/test/e2e/screens/7e93836/chrome/test-block.png b/test/e2e/screens/7e93836/chrome/test-block.png index c5fbd3388..9d19a61ef 100644 Binary files a/test/e2e/screens/7e93836/chrome/test-block.png and b/test/e2e/screens/7e93836/chrome/test-block.png differ diff --git a/test/e2e/screens/7ef2d08/chrome-dpr-3/bordered-block.png b/test/e2e/screens/7ef2d08/chrome-dpr-3/bordered-block.png new file mode 100644 index 000000000..7e21df4bd Binary files /dev/null and b/test/e2e/screens/7ef2d08/chrome-dpr-3/bordered-block.png differ diff --git a/test/e2e/screens/89919a4/chrome/viewport.png b/test/e2e/screens/89919a4/chrome/viewport.png index 3a3681829..3ddf15e88 100644 Binary files a/test/e2e/screens/89919a4/chrome/viewport.png and b/test/e2e/screens/89919a4/chrome/viewport.png differ diff --git a/test/e2e/screens/96ac145/chrome/sticky-interference.png b/test/e2e/screens/96ac145/chrome/sticky-interference.png index 8b5c1f935..fe2a3223c 100644 Binary files a/test/e2e/screens/96ac145/chrome/sticky-interference.png and b/test/e2e/screens/96ac145/chrome/sticky-interference.png differ diff --git a/test/e2e/screens/a2b6a7e/chrome/test-block.png b/test/e2e/screens/a2b6a7e/chrome/test-block.png index a94826482..1a6141a3f 100644 Binary files a/test/e2e/screens/a2b6a7e/chrome/test-block.png and b/test/e2e/screens/a2b6a7e/chrome/test-block.png differ diff --git a/test/e2e/screens/bfd0070/chrome/mixed-fixed-and-scrollable-blocks.png b/test/e2e/screens/bfd0070/chrome/mixed-fixed-and-scrollable-blocks.png index b71c1d161..4b0baf92f 100644 Binary files a/test/e2e/screens/bfd0070/chrome/mixed-fixed-and-scrollable-blocks.png and b/test/e2e/screens/bfd0070/chrome/mixed-fixed-and-scrollable-blocks.png differ diff --git a/test/e2e/screens/c1dabd5/chrome/text-block.png b/test/e2e/screens/c1dabd5/chrome/text-block.png index 0add39e36..e39c993be 100644 Binary files a/test/e2e/screens/c1dabd5/chrome/text-block.png and b/test/e2e/screens/c1dabd5/chrome/text-block.png differ diff --git a/test/e2e/screens/ca575b5/chrome/test-block.png b/test/e2e/screens/ca575b5/chrome/test-block.png index efa7cb7af..c634e7e9f 100644 Binary files a/test/e2e/screens/ca575b5/chrome/test-block.png and b/test/e2e/screens/ca575b5/chrome/test-block.png differ diff --git a/test/e2e/screens/dfee2f2/chrome/test-block.png b/test/e2e/screens/dfee2f2/chrome/test-block.png index a23fc54de..eb62cf614 100644 Binary files a/test/e2e/screens/dfee2f2/chrome/test-block.png and b/test/e2e/screens/dfee2f2/chrome/test-block.png differ diff --git a/test/e2e/static/dpr-oopif.html b/test/e2e/static/dpr-oopif.html new file mode 100644 index 000000000..dc4f02b98 --- /dev/null +++ b/test/e2e/static/dpr-oopif.html @@ -0,0 +1,70 @@ + + + + + + DPR OOPIF reproduction + + + +
+ + + + + diff --git a/test/e2e/testplane.config.ts b/test/e2e/testplane.config.ts index c7ce8558f..bca2ec29e 100644 --- a/test/e2e/testplane.config.ts +++ b/test/e2e/testplane.config.ts @@ -25,6 +25,10 @@ export default { files: path.join(__dirname, "tests/calibration-resize.testplane.js"), browsers: ["calibrated-chrome"], }, + dprOopif: { + files: path.join(__dirname, "tests/dpr-oopif.testplane.js"), + browsers: ["chrome-dpr-3"], + }, }, takeScreenshotOnFails: { @@ -42,7 +46,6 @@ export default { browserName: "chrome", "goog:chromeOptions": { args: ["headless", "no-sandbox", "hide-scrollbars", "disable-dev-shm-usage"], - binary: "/usr/bin/chromium", }, }, waitTimeout: 3000, @@ -57,7 +60,38 @@ export default { browserName: "chrome", "goog:chromeOptions": { args: ["headless", "no-sandbox", "hide-scrollbars", "disable-dev-shm-usage"], - binary: "/usr/bin/chromium", + }, + }, + waitTimeout: 3000, + }, + "chrome-dpr-3": { + headless: false, + assertViewOpts: { + ignoreDiffPixelCount: 4, + }, + isolation: false, + saveHistoryMode: "none", + timeTravel: "off", + desiredCapabilities: { + browserName: "chrome", + "goog:chromeOptions": { + args: [ + // "headless", + "no-sandbox", + "hide-scrollbars", + "disable-gpu", + "disable-dev-shm-usage", + "--host-resolver-rules=MAP localhost host.docker.internal,MAP 127.0.0.1 host.docker.internal", + ], + mobileEmulation: { + deviceMetrics: { + width: 390, + height: 844, + pixelRatio: 3, + mobile: true, + touch: true, + }, + }, }, }, waitTimeout: 3000, diff --git a/test/e2e/tests/dpr-oopif.testplane.js b/test/e2e/tests/dpr-oopif.testplane.js new file mode 100644 index 000000000..3c9567d14 --- /dev/null +++ b/test/e2e/tests/dpr-oopif.testplane.js @@ -0,0 +1,18 @@ +/* global window */ + +describe("assertView with DPR and OOPIFs", () => { + it("should capture the whole bordered element after nested cross-site iframes attach", async ({ browser }) => { + await browser.url("http://localhost:3000/dpr-oopif.html"); + + expect(await browser.execute(() => window.devicePixelRatio)).toBe(3); + + await browser.$("#attach").click(); + await browser.pause(3000); + + // Here, we expect DPR to be 1 to verify that the bug with pixel ratio actually reproduced, but assertView + // should still work correctly. Without this check, it's easy to have evergreen test. + expect(await browser.execute(() => window.devicePixelRatio)).toBe(1); + + await browser.assertView("bordered-block", "[data-testid=capture-target]"); + }); +}); diff --git a/test/integration/screen-shooter/screen-shooter.test.ts b/test/integration/screen-shooter/screen-shooter.test.ts index 287857a6b..b38bdea4a 100644 --- a/test/integration/screen-shooter/screen-shooter.test.ts +++ b/test/integration/screen-shooter/screen-shooter.test.ts @@ -33,6 +33,8 @@ const createScreenShooter = async (browser: WdioBrowser): Promise { }); it("should lazily create viewport screen shooter with current browser properties", async () => { - const browser = await initBrowser_(); + const session = mkSessionStub_(); + session.requestedCapabilities = { + "goog:chromeOptions": { + mobileEmulation: { deviceMetrics: { pixelRatio: 3 } }, + }, + }; + const browser = await initBrowser_({ session }); sandbox.stub(browser, "needsCompatLib").get(() => true); @@ -199,6 +205,28 @@ describe("assertView command", () => { isWebdriverProtocol: true, shouldUsePixelRatio: true, needsCompatLib: true, + isHeadless: false, + isPixelRatioEmulated: true, + estimatedPixelRatioFromCapabilities: 3, + }), + }); + }); + + it("should recognize pixel ratio emulation without an explicit ratio", async () => { + const session = mkSessionStub_(); + session.requestedCapabilities = { + "goog:chromeOptions": { + mobileEmulation: { deviceName: "Pixel 7" }, + }, + }; + const browser = await initBrowser_({ session }); + + await browser.publicAPI.assertView("plain"); + + assert.calledOnceWithMatch(ViewportScreenShooter.create, { + browserProperties: sinon.match({ + isPixelRatioEmulated: true, + estimatedPixelRatioFromCapabilities: undefined, }), }); }); diff --git a/test/src/browser/screen-shooter/index.js b/test/src/browser/screen-shooter/index.js index 23055a841..35b343304 100644 --- a/test/src/browser/screen-shooter/index.js +++ b/test/src/browser/screen-shooter/index.js @@ -75,6 +75,7 @@ describe("ElementsScreenShooter", () => { documentSize: size(100, 1000), canHaveCaret: false, pixelRatio: 1, + anchorShift: null, pointerEventsDisabled: false, readableSelectorToScrollDescr: "html", scrollOffset: 0, @@ -87,6 +88,10 @@ describe("ElementsScreenShooter", () => { Object.assign( { scrollOffset: 0, + viewportSize: size(100, 100), + viewportOffset: { left: 0, top: 0 }, + documentSize: size(100, 1000), + pixelRatio: 1, captureSpecs: [captureSpec(rect(0, 0, 100, 80))], ignoreAreas: [], safeArea: band(0, 100), @@ -141,6 +146,8 @@ describe("ElementsScreenShooter", () => { isWebdriverProtocol: true, shouldUsePixelRatio: true, needsCompatLib: false, + isHeadless: false, + isPixelRatioEmulated: false, }; browserSideScreenshooter = { call: sandbox.stub(), @@ -244,6 +251,7 @@ describe("ElementsScreenShooter", () => { compositeImage: true, debug: [], usePixelRatio: true, + pixelRatioOverride: undefined, }); }); @@ -307,10 +315,29 @@ describe("ElementsScreenShooter", () => { }); it("should preload and do best-effort capture when capture area size changes mid-capture", async () => { - const page = createMockPage({ captureSpecs: [captureSpec(rect(0, 0, 100, 80))] }); - const changedState = createCaptureState({ captureSpecs: [captureSpec(rect(0, 0, 100, 120))] }); - const preloadState = createCaptureState({ captureSpecs: [captureSpec(rect(0, 0, 100, 120))] }); - const settledState = createCaptureState({ captureSpecs: page.captureSpecs, safeArea: page.safeArea }); + browserProperties.isPixelRatioEmulated = true; + browserProperties.estimatedPixelRatioFromCapabilities = 3; + const page = createMockPage({ + captureSpecs: [captureSpec(rect(0, 0, 100, 80))], + pixelRatio: 3, + }); + const changedState = createCaptureState({ + captureSpecs: [captureSpec(rect(0, 0, 100, 120))], + pixelRatio: 3, + }); + const refreshedState = createCaptureState({ + captureSpecs: [captureSpec(rect(0, 0, 100, 120))], + pixelRatio: 3, + }); + const preloadState = createCaptureState({ + captureSpecs: [captureSpec(rect(0, 0, 100, 120))], + pixelRatio: 3, + }); + const settledState = createCaptureState({ + captureSpecs: refreshedState.captureSpecs, + safeArea: refreshedState.safeArea, + pixelRatio: 3, + }); browserSideScreenshooter.call .onCall(0) @@ -318,15 +345,22 @@ describe("ElementsScreenShooter", () => { .onCall(1) .resolves(changedState) // getCaptureState phase 1 → size change .onCall(2) - .resolves(preloadState) // getCaptureState in preload + .resolves(3) // getCurrentPixelRatio .onCall(3) - .resolves({}) // scrollTo restore after preload + .resolves(refreshedState) // refresh capture state without override .onCall(4) - .resolves(undefined) // captureAnchorBaseline + .resolves(preloadState) // getCaptureState in preload .onCall(5) + .resolves({}) // scrollTo restore after preload + .onCall(6) + .resolves(undefined) // captureAnchorBaseline + .onCall(7) .resolves(settledState); // getCaptureState phase 2 - const result = await screenShooter.capture(".element", { compositeImage: false }); + const result = await screenShooter.capture(".element", { + compositeImage: false, + allowViewportOverflow: true, + }); assert.deepEqual( browserSideScreenshooter.call @@ -342,10 +376,61 @@ describe("ElementsScreenShooter", () => { .filter(m => m === "captureAnchorBaseline"), ["captureAnchorBaseline"], ); - assert.calledOnce(camera.captureViewportImage); + assert.deepEqual(browserSideScreenshooter.call.getCall(1).args, [ + "getCaptureState", + [[".element"], [], undefined, true, 3, []], + ]); + assert.deepEqual(browserSideScreenshooter.call.getCall(3).args, [ + "getCaptureState", + [[".element"], [], undefined, true, undefined, []], + ]); + assert.deepEqual(browserSideScreenshooter.call.getCall(4).args, [ + "getCaptureState", + [[".element"], [], undefined, true, undefined, []], + ]); + assert.deepEqual(browserSideScreenshooter.call.getCall(7).args, [ + "getCaptureState", + [[".element"], [], undefined, true, undefined, []], + ]); + assert.calledTwice(camera.captureViewportImage); assert.deepEqual(result, { image: renderedImage, meta: page }); }); + it("should refresh capture state when an emulated pixel ratio changes after a screenshot", async () => { + browserProperties.isPixelRatioEmulated = true; + const page = createMockPage(); + const initialState = createCaptureState(); + const refreshedState = createCaptureState({ + viewportSize: size(300, 300), + documentSize: size(300, 3000), + pixelRatio: 3, + }); + const retryState = createCaptureState(refreshedState); + + browserSideScreenshooter.call + .onCall(0) + .resolves(page) + .onCall(1) + .resolves(initialState) + .onCall(2) + .resolves(3) + .onCall(3) + .resolves(refreshedState) + .onCall(4) + .resolves(retryState); + + const result = await screenShooter.capture(".element", { compositeImage: false }); + + assert.calledTwice(camera.captureViewportImage); + assert.equal(result.meta.pixelRatio, 3); + assert.deepEqual(result.meta.viewportSize, size(300, 300)); + assert.deepEqual(result.meta.documentSize, size(300, 3000)); + assert.deepEqual(browserSideScreenshooter.call.getCall(3).args, [ + "getCaptureState", + [[".element"], [], undefined, true, undefined, []], + ]); + }); + it("should return rendered image and page meta", async () => { const page = createMockPage(); browserSideScreenshooter.call.resolves(page); @@ -443,7 +528,7 @@ describe("ElementsScreenShooter", () => { assert.calledOnce(camera.captureViewportImage); assert.deepEqual(browserSideScreenshooter.call.getCall(1).args, [ "getCaptureState", - [[".element"], [], undefined, []], + [[".element"], [], undefined, true, undefined, []], ]); }); @@ -483,11 +568,11 @@ describe("ElementsScreenShooter", () => { assert.calledTwice(compositeImage.registerViewportImageAtOffset); assert.deepEqual(browserSideScreenshooter.call.getCall(2).args, [ "scrollBy", - [[".element"], 50, undefined, []], + [[".element"], 50, undefined, true, undefined, []], ]); assert.deepEqual(browserSideScreenshooter.call.getCall(4).args, [ "scrollTo", - [[".element"], 0, undefined, []], + [[".element"], 0, undefined, true, undefined, []], ]); });