From 48c022db8863ecf6c9dbc0f4745df4efd9db8025 Mon Sep 17 00:00:00 2001 From: shadowusr Date: Fri, 18 Sep 2026 01:18:51 +0300 Subject: [PATCH 1/2] fix: calculate pixel ratio based on actual screenshot data in case of oopif --- src/browser/camera/index.ts | 11 +- .../screen-shooter/implementation.ts | 9 +- .../client-scripts/screen-shooter/types.ts | 1 + .../screen-shooter/elements-screen-shooter.ts | 48 ++++----- .../errors/pixel-ratio-change-error.ts | 6 ++ .../screen-shooter/validation/index.ts | 28 +++++- .../screen-shooter/viewport-screen-shooter.ts | 34 ++++--- .../prepareViewportScreenshot.testplane.ts | 1 + .../ce9d8a3/chrome-dpr-3/bordered-block.png | Bin 0 -> 5619 bytes test/e2e/tests/dpr-oopif.testplane.js | 36 ++++++- test/src/browser/camera/index.js | 22 +++- test/src/browser/screen-shooter/index.js | 50 ++++++---- test/src/browser/screen-shooter/validation.js | 94 ++++++++++++++++++ 13 files changed, 268 insertions(+), 72 deletions(-) create mode 100644 src/browser/screen-shooter/errors/pixel-ratio-change-error.ts create mode 100644 test/e2e/screens/ce9d8a3/chrome-dpr-3/bordered-block.png diff --git a/src/browser/camera/index.ts b/src/browser/camera/index.ts index 72ed9f5a8..c71a2d18c 100644 --- a/src/browser/camera/index.ts +++ b/src/browser/camera/index.ts @@ -23,6 +23,11 @@ const debug = makeDebug("testplane:screenshots:camera"); export type ScreenshotMode = "fullpage" | "viewport" | "auto"; export type { CropMargins } from "./utils"; +export interface ViewportImage extends Image { + /** Calibrated screenshot dimensions before applying user margins or cropping to the viewport. */ + readonly uncroppedSize: Size<"device">; +} + export interface CaptureViewportImageOpts { viewportOffset: Point<"page", "device">; viewportSize: Size<"device">; @@ -64,7 +69,7 @@ export class Camera { this._calibrationScreenshotSize = screenshotSize; } - async captureViewportImage(opts?: CaptureViewportImageOpts): Promise { + async captureViewportImage(opts?: CaptureViewportImageOpts): Promise { if (opts?.screenshotDelay) { await new Promise(resolve => setTimeout(resolve, opts.screenshotDelay)); } @@ -108,7 +113,9 @@ export class Camera { await image.crop(viewportCroppedArea); } - return image; + return Object.assign(image, { + uncroppedSize: { width: calibratedImageArea.width, height: calibratedImageArea.height }, + }); } private _cropAreaToIntersection( diff --git a/src/browser/client-scripts/screen-shooter/implementation.ts b/src/browser/client-scripts/screen-shooter/implementation.ts index 57aaed8d0..e027e2945 100644 --- a/src/browser/client-scripts/screen-shooter/implementation.ts +++ b/src/browser/client-scripts/screen-shooter/implementation.ts @@ -183,10 +183,6 @@ 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">, @@ -262,9 +258,11 @@ export function scrollTo( function getViewportState(usePixelRatio?: boolean, pixelRatioOverride?: number): ViewportState { const pixelRatio = computePixelRatio(usePixelRatio, pixelRatioOverride); + const viewportSizeInCss = computeViewportSize(); return { - viewportSize: fromCssToDevice(computeViewportSize(), pixelRatio), + viewportSize: fromCssToDevice(viewportSizeInCss, pixelRatio), + viewportSizeInCss, viewportOffset: fromCssToDevice(floorCoords(computeViewportOffset()), pixelRatio), documentSize: ceilCoords(fromCssToDevice(computeDocumentSize(), pixelRatio)), pixelRatio @@ -378,6 +376,7 @@ export function prepareFullPageScreenshot( return { documentSize: ceilCoords(fromCssToDevice(documentSize, pixelRatio)), viewportSize: fromCssToDevice(viewportSize, pixelRatio), + viewportSizeInCss: viewportSize, viewportOffset: fromCssToDevice(floorCoords(viewportOffset), pixelRatio), safeArea: fromCssToDevice(roundCoords(safeArea), pixelRatio), elementPositionsProbe, diff --git a/src/browser/client-scripts/screen-shooter/types.ts b/src/browser/client-scripts/screen-shooter/types.ts index c2fceb0cc..66e71ae52 100644 --- a/src/browser/client-scripts/screen-shooter/types.ts +++ b/src/browser/client-scripts/screen-shooter/types.ts @@ -18,6 +18,7 @@ export interface TrackedElementData { export interface ViewportState { viewportSize: Size<"device">; + viewportSizeInCss: Size<"css">; viewportOffset: Point<"page", "device">; documentSize: Size<"device">; pixelRatio: number; diff --git a/src/browser/screen-shooter/elements-screen-shooter.ts b/src/browser/screen-shooter/elements-screen-shooter.ts index a73ca8d4c..f8b906bff 100644 --- a/src/browser/screen-shooter/elements-screen-shooter.ts +++ b/src/browser/screen-shooter/elements-screen-shooter.ts @@ -1,7 +1,8 @@ import makeDebug from "debug"; import { CompositeImage } from "./composite-image"; import { Image } from "../../image"; -import { assertCorrectCaptureAreaBounds } from "./validation"; +import { assertCorrectCaptureAreaBounds, assertPixelRatio } from "./validation"; +import { PixelRatioChangeError } from "./errors/pixel-ratio-change-error"; import type { AssertViewOpts } from "../../config/types"; import { runWithoutHistory } from "../history"; import { @@ -34,13 +35,6 @@ 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`; @@ -64,7 +58,7 @@ interface CaptureAttemptParams { page: PrepareScreenshotSuccess; opts: ScreenShooterOpts; isStrictAttempt: boolean; - shouldCheckPixelRatio: boolean; + shouldValidatePixelRatio: boolean; } interface ScreenShooterBrowserProperties { @@ -250,7 +244,7 @@ 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 + // OOPIFs can corrupt window.devicePixelRatio; validate against the screenshot instead. const shouldValidatePixelRatio = this._browserProperties.shouldUsePixelRatio && !this._browserProperties.isHeadless && @@ -300,7 +294,7 @@ export class ElementsScreenShooter { page, opts, isStrictAttempt: true, - shouldCheckPixelRatio: shouldValidatePixelRatio, + shouldValidatePixelRatio, }); } catch (error) { if (!(error instanceof CaptureAreaSizeChangeError) && !(error instanceof PixelRatioChangeError)) { @@ -310,7 +304,9 @@ export class ElementsScreenShooter { perfDebug(`capture: retrying in best-effort mode (${error.message})`); if (opts.pixelRatioOverride !== undefined || error instanceof PixelRatioChangeError) { - delete opts.pixelRatioOverride; + if (error instanceof PixelRatioChangeError) { + opts.pixelRatioOverride = error.pixelRatio; + } Object.assign(page, await this._getCaptureState(targetsToCapture, targetsToIgnore, opts)); } @@ -324,7 +320,7 @@ export class ElementsScreenShooter { page, opts, isStrictAttempt: false, - shouldCheckPixelRatio: false, + shouldValidatePixelRatio: false, }); } @@ -655,7 +651,7 @@ export class ElementsScreenShooter { page, opts, isStrictAttempt, - shouldCheckPixelRatio, + shouldValidatePixelRatio, }: CaptureAttemptParams): Promise { const perfDebug = makeDebug("testplane:screenshots:perf:" + opts.debugId); const attemptMode = isStrictAttempt ? "strict" : "best-effort"; @@ -702,7 +698,7 @@ export class ElementsScreenShooter { spec.full.height !== currentState.captureSpecs[index]?.full.height, ); - if (hasCaptureAreaSizeChanged && isStrictAttempt && !shouldCheckPixelRatio) { + if (hasCaptureAreaSizeChanged && isStrictAttempt && !shouldValidatePixelRatio) { debug( "capture area size changed, will retry capture attempt. Last state: %O, current state: %O", lastState.captureSpecs, @@ -721,7 +717,7 @@ export class ElementsScreenShooter { const captureStartTime = performance.now(); const viewportImage = await this._camera.captureViewportImage({ - viewportSize: page.viewportSize, + viewportSize: currentState.viewportSize, viewportOffset: currentState.viewportOffset, screenshotDelay: opts.screenshotDelay, cropMargins: opts.cropMargins, @@ -729,20 +725,12 @@ 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 (shouldValidatePixelRatio) { + assertPixelRatio( + viewportImage.uncroppedSize, + currentState.viewportSize, + currentState.viewportSizeInCss, + ); } if (hasCaptureAreaSizeChanged && isStrictAttempt) { diff --git a/src/browser/screen-shooter/errors/pixel-ratio-change-error.ts b/src/browser/screen-shooter/errors/pixel-ratio-change-error.ts new file mode 100644 index 000000000..428ed4462 --- /dev/null +++ b/src/browser/screen-shooter/errors/pixel-ratio-change-error.ts @@ -0,0 +1,6 @@ +export class PixelRatioChangeError extends Error { + constructor(readonly pixelRatio: number) { + super("Estimated pixel ratio did not match actual pixel ratio during capture"); + this.name = "PixelRatioChangeError"; + } +} diff --git a/src/browser/screen-shooter/validation/index.ts b/src/browser/screen-shooter/validation/index.ts index d654dbe9a..11ee67889 100644 --- a/src/browser/screen-shooter/validation/index.ts +++ b/src/browser/screen-shooter/validation/index.ts @@ -1,8 +1,34 @@ import { AssertViewOpts } from "../../../config/types"; import { getVerticalOverflowErrorMessage } from "../errors/vertical-overflow-error"; import { getHorizontalOverflowErrorMessage } from "../errors/horizontal-overflow-error"; +import { PixelRatioChangeError } from "../errors/pixel-ratio-change-error"; import * as logger from "../../../utils/logger"; -import { Point, Rect, Size, getCoveringRect } from "../../isomorphic"; +import { Point, Rect, Size, getCoveringRect, prettySize } from "../../isomorphic"; + +export const assertPixelRatio = ( + imageSize: Size<"device">, + viewportSize: Size<"device">, + viewportSizeInCss: Size<"css">, +): void => { + // Allow rounding differences between CSS geometry and the captured bitmap. + if (Math.abs(imageSize.width - viewportSize.width) <= 1 && Math.abs(imageSize.height - viewportSize.height) <= 1) { + return; + } + + const pixelRatio = imageSize.width / viewportSizeInCss.width; + if (Math.abs(imageSize.height - viewportSizeInCss.height * pixelRatio) > 1) { + throw new Error( + `Screenshot dimensions do not match the viewport at a consistent pixel ratio. ` + + `imageSize: ${prettySize(imageSize)}, viewportSize: ${prettySize(viewportSize)}`, + ); + } + + const roundedPixelRatio = Math.round(pixelRatio); + const PIXEL_RATIO_EPSILON = 0.001; + const finalPixelRatio = pixelRatio - roundedPixelRatio <= PIXEL_RATIO_EPSILON ? roundedPixelRatio : pixelRatio; + + throw new PixelRatioChangeError(finalPixelRatio); +}; const isOutsideOfViewportHorizontally = (viewport: Size<"device">, cropArea: Rect<"viewport", "device">): boolean => cropArea.left < 0 || cropArea.left + cropArea.width > viewport.width; diff --git a/src/browser/screen-shooter/viewport-screen-shooter.ts b/src/browser/screen-shooter/viewport-screen-shooter.ts index 8faddddfb..3f7708a88 100644 --- a/src/browser/screen-shooter/viewport-screen-shooter.ts +++ b/src/browser/screen-shooter/viewport-screen-shooter.ts @@ -1,7 +1,9 @@ import { Image } from "../../image"; import type { DisableHoverMode } from "../isomorphic/types"; import type { WdioBrowser } from "../../types"; -import { Camera, type CropMargins } from "../camera"; +import { Camera, type CropMargins, type ViewportImage } from "../camera"; +import { assertPixelRatio } from "./validation"; +import { PixelRatioChangeError } from "./errors/pixel-ratio-change-error"; import type * as browserSideScreenshooterImplementation from "../client-scripts/screen-shooter/implementation"; import { ClientBridge } from "../client-bridge"; import { isBrowserSideError } from "../isomorphic/types"; @@ -142,27 +144,31 @@ export class ViewportScreenShooter { debug("Capturing viewport screenshot.\n viewportSize: %O\n viewportOffset: %O", viewportSize, viewportOffset); - const image = await this._camera.captureViewportImage({ - viewportSize, - viewportOffset, - screenshotDelay: opts.screenshotDelay, - 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; + let image: ViewportImage; + try { + image = await this._camera.captureViewportImage({ + viewportSize, + viewportOffset, + screenshotDelay: opts.screenshotDelay, + cropMargins: opts.cropMargins, + }); + + if (shouldCheckPixelRatio) { + assertPixelRatio(image.uncroppedSize, viewportSize, prepareResult.viewportSizeInCss); + } + } catch (error) { + if (error instanceof PixelRatioChangeError && !isRetry) { + opts.pixelRatioOverride = error.pixelRatio; return this._captureImpl(opts, true); } + + throw error; } if (prepareResult.ignoreAreas.length > 0) { diff --git a/test/browser-env/tests/high-pixel-ratio/prepareViewportScreenshot.testplane.ts b/test/browser-env/tests/high-pixel-ratio/prepareViewportScreenshot.testplane.ts index 0c0593abf..15a2384f1 100644 --- a/test/browser-env/tests/high-pixel-ratio/prepareViewportScreenshot.testplane.ts +++ b/test/browser-env/tests/high-pixel-ratio/prepareViewportScreenshot.testplane.ts @@ -35,6 +35,7 @@ describe("prepareViewportScreenshot in high pixel ratio mode", () => { const result = getPrepareResult({ usePixelRatio: true }); expect(result.pixelRatio).toBe(3); + expect(result.viewportSizeInCss).toEqual(cssViewportSize); expect(result.viewportSize.width).toBe((cssViewportSize.width as number) * 3); expect(result.viewportSize.height).toBe((cssViewportSize.height as number) * 3); expect(result.viewportOffset.left).toBe(Math.floor(cssViewportOffset.left as number) * 3); diff --git a/test/e2e/screens/ce9d8a3/chrome-dpr-3/bordered-block.png b/test/e2e/screens/ce9d8a3/chrome-dpr-3/bordered-block.png new file mode 100644 index 0000000000000000000000000000000000000000..7e21df4bd8f83f902a3b720fe0270a856d53dd00 GIT binary patch literal 5619 zcmeAS@N?(olHy`uVBq!ia0y~yVCG?9V3Ofr1Byi7(+pu?5Z&eJ;uum9_x75iA8UY! z^TGX&VP_91C{`}xU3a}#qBthje*XFA>{>og6&M&2)c=2P { it("should capture the whole bordered element after nested cross-site iframes attach", async ({ browser }) => { @@ -15,4 +15,38 @@ describe("assertView with DPR and OOPIFs", () => { await browser.assertView("bordered-block", "[data-testid=capture-target]"); }); + + it("should capture the whole bordered element when DPR changes after taking the screenshot", async ({ + browser, + }) => { + await browser.url("http://localhost:3000/dpr-oopif.html"); + + expect(await browser.execute(() => window.devicePixelRatio)).toBe(3); + + const originalTakeScreenshot = browser.takeScreenshot.bind(browser); + let shouldAttachIframes = true; + + browser.overwriteCommand("takeScreenshot", async () => { + const screenshot = await originalTakeScreenshot(); + + if (shouldAttachIframes) { + shouldAttachIframes = false; + // Reproduce the race between taking the screenshot and validating its pixel ratio. + await browser.execute(() => document.querySelector("#attach").click()); + await browser.waitUntil(async () => (await browser.execute(() => window.devicePixelRatio)) === 1, { + timeout: 10000, + interval: 50, + timeoutMsg: "Nested cross-site iframes did not change devicePixelRatio to 1", + }); + } + + return screenshot; + }); + + try { + await browser.assertView("bordered-block", "[data-testid=capture-target]"); + } finally { + browser.overwriteCommand("takeScreenshot", async () => originalTakeScreenshot()); + } + }); }); diff --git a/test/src/browser/camera/index.js b/test/src/browser/camera/index.js index 11906f90b..c1d07dc66 100644 --- a/test/src/browser/camera/index.js +++ b/test/src/browser/camera/index.js @@ -36,7 +36,27 @@ describe("browser/camera", () => { Image.fromBase64.withArgs({ foo: "bar" }).returns(image); - return assert.becomes(camera.captureViewportImage(), image); + return camera.captureViewportImage().then(result => { + assert.strictEqual(result, image); + assert.deepEqual(result.uncroppedSize, { width: 100500, height: 500100 }); + }); + }); + + it("should expose calibrated dimensions before user margins and viewport cropping", async () => { + image.getSize.returns({ width: 390, height: 884 }); + const camera = Camera.create("fullpage", sinon.stub().resolves()); + camera.calibrate({ left: 0, top: 20, width: 390, height: 844 }, { width: 390, height: 884 }); + isFullPageStub.returns(true); + + const result = await camera.captureViewportImage({ + viewportOffset: { left: 0, top: 20 }, + viewportSize: { width: 130, height: 200 }, + cropMargins: { left: 10 }, + }); + + assert.strictEqual(result, image); + assert.deepEqual(result.uncroppedSize, { width: 390, height: 844 }); + assert.calledOnceWithExactly(image.crop, { left: 10, top: 20, width: 120, height: 200 }); }); describe("crop", () => { diff --git a/test/src/browser/screen-shooter/index.js b/test/src/browser/screen-shooter/index.js index 35b343304..8ee759230 100644 --- a/test/src/browser/screen-shooter/index.js +++ b/test/src/browser/screen-shooter/index.js @@ -1,9 +1,11 @@ "use strict"; const proxyquire = require("proxyquire").noCallThru(); +const { assertPixelRatio } = require("src/browser/screen-shooter/validation"); const validationStubs = { assertCorrectCaptureAreaBounds: sinon.stub(), + assertPixelRatio, }; const historyStubs = { @@ -71,6 +73,7 @@ describe("ElementsScreenShooter", () => { ignoreAreas: [], captureSpecs: [captureSpec(rect(0, 0, 100, 80))], viewportSize: size(100, 100), + viewportSizeInCss: size(100, 100), viewportOffset: { left: 0, top: 0 }, documentSize: size(100, 1000), canHaveCaret: false, @@ -89,6 +92,7 @@ describe("ElementsScreenShooter", () => { { scrollOffset: 0, viewportSize: size(100, 100), + viewportSizeInCss: size(100, 100), viewportOffset: { left: 0, top: 0 }, documentSize: size(100, 1000), pixelRatio: 1, @@ -152,7 +156,7 @@ describe("ElementsScreenShooter", () => { browserSideScreenshooter = { call: sandbox.stub(), }; - viewportImage = { id: "viewport-image" }; + viewportImage = { id: "viewport-image", uncroppedSize: size(100, 100) }; renderedImage = { id: "rendered-image" }; camera = { captureViewportImage: sandbox.stub().resolves(viewportImage), @@ -345,16 +349,14 @@ describe("ElementsScreenShooter", () => { .onCall(1) .resolves(changedState) // getCaptureState phase 1 → size change .onCall(2) - .resolves(3) // getCurrentPixelRatio + .resolves(refreshedState) // refresh capture state while retaining the override .onCall(3) - .resolves(refreshedState) // refresh capture state without override - .onCall(4) .resolves(preloadState) // getCaptureState in preload - .onCall(5) + .onCall(4) .resolves({}) // scrollTo restore after preload - .onCall(6) + .onCall(5) .resolves(undefined) // captureAnchorBaseline - .onCall(7) + .onCall(6) .resolves(settledState); // getCaptureState phase 2 const result = await screenShooter.capture(".element", { @@ -380,17 +382,17 @@ describe("ElementsScreenShooter", () => { "getCaptureState", [[".element"], [], undefined, true, 3, []], ]); - assert.deepEqual(browserSideScreenshooter.call.getCall(3).args, [ + assert.deepEqual(browserSideScreenshooter.call.getCall(2).args, [ "getCaptureState", - [[".element"], [], undefined, true, undefined, []], + [[".element"], [], undefined, true, 3, []], ]); - assert.deepEqual(browserSideScreenshooter.call.getCall(4).args, [ + assert.deepEqual(browserSideScreenshooter.call.getCall(3).args, [ "getCaptureState", - [[".element"], [], undefined, true, undefined, []], + [[".element"], [], undefined, true, 3, []], ]); - assert.deepEqual(browserSideScreenshooter.call.getCall(7).args, [ + assert.deepEqual(browserSideScreenshooter.call.getCall(6).args, [ "getCaptureState", - [[".element"], [], undefined, true, undefined, []], + [[".element"], [], undefined, true, 3, []], ]); assert.calledTwice(camera.captureViewportImage); assert.deepEqual(result, { image: renderedImage, meta: page }); @@ -413,24 +415,36 @@ describe("ElementsScreenShooter", () => { .onCall(1) .resolves(initialState) .onCall(2) - .resolves(3) - .onCall(3) .resolves(refreshedState) - .onCall(4) + .onCall(3) .resolves(retryState); + viewportImage.uncroppedSize = size(300, 300); + 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, [ + assert.deepEqual(browserSideScreenshooter.call.getCall(2).args, [ "getCaptureState", - [[".element"], [], undefined, true, undefined, []], + [[".element"], [], undefined, true, 3, []], ]); }); + it("should return a best-effort screenshot when the pixel ratio still differs on retry", async () => { + browserProperties.isPixelRatioEmulated = true; + browserSideScreenshooter.call.resolves(createMockPage()); + viewportImage.uncroppedSize = size(300, 300); + + const result = await screenShooter.capture(".element", { compositeImage: false }); + + assert.calledTwice(camera.captureViewportImage); + assert.calledOnce(compositeImage.render); + assert.strictEqual(result.image, renderedImage); + }); + it("should return rendered image and page meta", async () => { const page = createMockPage(); browserSideScreenshooter.call.resolves(page); diff --git a/test/src/browser/screen-shooter/validation.js b/test/src/browser/screen-shooter/validation.js index 1ea22fa82..a05fa5eca 100644 --- a/test/src/browser/screen-shooter/validation.js +++ b/test/src/browser/screen-shooter/validation.js @@ -2,6 +2,8 @@ const _ = require("lodash"); const proxyquire = require("proxyquire"); +const { assertPixelRatio } = require("src/browser/screen-shooter/validation"); +const { PixelRatioChangeError } = require("src/browser/screen-shooter/errors/pixel-ratio-change-error"); describe("assertCorrectCaptureAreaBounds", () => { const loggerWarnStub = sinon.stub(); @@ -158,3 +160,95 @@ describe("assertCorrectCaptureAreaBounds", () => { }); }); }); + +describe("assertPixelRatio", () => { + it("should retain the pixel ratio when screenshot dimensions match within rounding", () => { + assert.doesNotThrow(() => + assertPixelRatio({ width: 391, height: 843 }, { width: 390, height: 844 }, { width: 390, height: 844 }), + ); + }); + + for (const estimatedRatio of [1, 4]) { + it(`should correct a pixel ratio of ${estimatedRatio} using screenshot dimensions`, () => { + const error = assert.throws( + () => + assertPixelRatio( + { width: 1170, height: 2532 }, + { width: 390 * estimatedRatio, height: 844 * estimatedRatio }, + { width: 390, height: 844 }, + ), + PixelRatioChangeError, + ); + + assert.equal(error.pixelRatio, 3); + }); + } + + it("should infer the ratio from CSS dimensions when the estimated viewport was rounded", () => { + const error = assert.throws( + () => + assertPixelRatio( + { width: 780, height: 1688 }, + { width: 488, height: 1055 }, + { width: 390, height: 844 }, + ), + PixelRatioChangeError, + ); + + assert.equal(error.pixelRatio, 2); + }); + + it("should preserve fractional CSS viewport dimensions when inferring the ratio", () => { + const error = assert.throws( + () => + assertPixelRatio( + { width: 781, height: 1689 }, + { width: 1171.5, height: 2533.5 }, + { width: 390.5, height: 844.5 }, + ), + PixelRatioChangeError, + ); + + assert.equal(error.pixelRatio, 2); + }); + + it("should reject dimensions that do not indicate a uniform scale change", () => { + assert.throws( + () => + assertPixelRatio( + { width: 1170, height: 844 }, + { width: 390, height: 844 }, + { width: 390, height: 844 }, + ), + /consistent pixel ratio/, + ); + }); + + for (const [measuredRatio, expectedRatio] of [ + [1.999999, 2], + [2.000001, 2], + [1.999, 2], + [2.001, 2], + [4.999, 5], + [5.001, 5], + [1.9989, 1.9989], + [2.0011, 2.0011], + [1.25, 1.25], + [0.0005, 0.0005], + ]) { + it(`should correct an inferred ratio of ${measuredRatio} to ${expectedRatio}`, () => { + const viewportSizeInCss = { width: 1000 / measuredRatio, height: 2000 / measuredRatio }; + const error = assert.throws( + () => + assertPixelRatio( + { width: 1000, height: 2000 }, + { width: viewportSizeInCss.width * 3, height: viewportSizeInCss.height * 3 }, + viewportSizeInCss, + ), + PixelRatioChangeError, + ); + + assert.equal(error.pixelRatio, expectedRatio); + }); + } +}); From e79f7ce393d98524ae13a048d264dee0ab642354 Mon Sep 17 00:00:00 2001 From: shadowusr Date: Fri, 18 Sep 2026 17:05:43 +0300 Subject: [PATCH 2/2] fix: fix subtraction rounding --- src/browser/screen-shooter/validation/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/browser/screen-shooter/validation/index.ts b/src/browser/screen-shooter/validation/index.ts index 11ee67889..77dbada2d 100644 --- a/src/browser/screen-shooter/validation/index.ts +++ b/src/browser/screen-shooter/validation/index.ts @@ -25,7 +25,12 @@ export const assertPixelRatio = ( const roundedPixelRatio = Math.round(pixelRatio); const PIXEL_RATIO_EPSILON = 0.001; - const finalPixelRatio = pixelRatio - roundedPixelRatio <= PIXEL_RATIO_EPSILON ? roundedPixelRatio : pixelRatio; + // Compare bounds directly to avoid subtraction rounding at the tolerance boundary. + const shouldSnap = + roundedPixelRatio > 0 && + pixelRatio >= roundedPixelRatio - PIXEL_RATIO_EPSILON && + pixelRatio <= roundedPixelRatio + PIXEL_RATIO_EPSILON; + const finalPixelRatio = shouldSnap ? roundedPixelRatio : pixelRatio; throw new PixelRatioChangeError(finalPixelRatio); };