From 07e460f46113e2288913e61947f612cc32f2709e Mon Sep 17 00:00:00 2001 From: shadowusr Date: Fri, 18 Sep 2026 02:32:07 +0300 Subject: [PATCH] fix: compute pixel ratio based on image size in case of oopif --- src/browser/camera/index.ts | 11 +- src/browser/client-scripts/index.js | 1 + src/browser/screen-shooter/index.js | 37 ++++-- test/src/browser/camera/index.js | 13 ++ .../src/browser/commands/assert-view/index.js | 18 +++ test/src/browser/screen-shooter/index.js | 114 ++++++++++++++++-- 6 files changed, 169 insertions(+), 25 deletions(-) diff --git a/src/browser/camera/index.ts b/src/browser/camera/index.ts index 7d4d7ae39..a33ce9551 100644 --- a/src/browser/camera/index.ts +++ b/src/browser/camera/index.ts @@ -17,6 +17,11 @@ export interface PageMeta { documentWidth: number; } +export interface ViewportImage extends Image { + /** Calibrated screenshot dimensions before cropping to the viewport. */ + readonly uncroppedSize: Pick; +} + interface Calibration { left: number; top: number; @@ -41,7 +46,7 @@ export class Camera { this._calibration = calibration; } - async captureViewportImage(page?: PageMeta): Promise { + async captureViewportImage(page?: PageMeta): Promise { const base64 = await this._takeScreenshot(); const image = Image.fromBase64(base64); @@ -55,7 +60,9 @@ export class Camera { await image.crop(viewportCroppedArea); } - return image; + return Object.assign(image, { + uncroppedSize: { width: calibratedArea.width, height: calibratedArea.height }, + }); } private _calibrateArea(imageArea: ImageArea): ImageArea { diff --git a/src/browser/client-scripts/index.js b/src/browser/client-scripts/index.js index 7a1ac6c67..f242f31f1 100644 --- a/src/browser/client-scripts/index.js +++ b/src/browser/client-scripts/index.js @@ -142,6 +142,7 @@ function prepareScreenshotUnsafe(areas, opts) { }) .scale(pixelRatio) .serialize(), + viewportSizeInCss: { width: viewportWidth, height: viewportHeight }, documentHeight: Math.ceil(documentHeight * pixelRatio), documentWidth: Math.ceil(documentWidth * pixelRatio), canHaveCaret: isEditable(document.activeElement), diff --git a/src/browser/screen-shooter/index.js b/src/browser/screen-shooter/index.js index 90c016922..6a0734a69 100644 --- a/src/browser/screen-shooter/index.js +++ b/src/browser/screen-shooter/index.js @@ -12,22 +12,15 @@ module.exports = class ScreenShooter { } async capture(page, opts = {}) { - const { - allowViewportOverflow, - compositeImage, - screenshotDelay, - selectorToScroll, - preferredPixelRatio, - reprepareScreenshot, - } = opts; + const { allowViewportOverflow, compositeImage, screenshotDelay, selectorToScroll, reprepareScreenshot } = opts; const viewportOpts = { allowViewportOverflow, compositeImage }; const cropImageOpts = { screenshotDelay, compositeImage, selectorToScroll }; const capturedImage = await this._browser.captureViewportImage(page, screenshotDelay); if (reprepareScreenshot) { - const currentPixelRatio = await this._browser.evalScript("window.devicePixelRatio"); + const currentPixelRatio = getPixelRatioFromImage(capturedImage.uncroppedSize, page); - if (currentPixelRatio !== (preferredPixelRatio ?? page.pixelRatio)) { + if (currentPixelRatio !== undefined) { Object.assign(page, await reprepareScreenshot(currentPixelRatio)); delete opts.preferredPixelRatio; delete opts.reprepareScreenshot; @@ -67,3 +60,27 @@ module.exports = class ScreenShooter { await viewport.extendBy(physicalScrollHeight, newImage); } }; + +function getPixelRatioFromImage(imageSize, page) { + // Allow rounding differences between CSS geometry and the captured bitmap. + if ( + Math.abs(imageSize.width - page.viewport.width) <= 1 && + Math.abs(imageSize.height - page.viewport.height) <= 1 + ) { + return; + } + + const pixelRatio = imageSize.width / page.viewportSizeInCss.width; + if (Math.abs(imageSize.height - page.viewportSizeInCss.height * pixelRatio) > 1) { + throw new Error("Screenshot dimensions do not match the viewport at a consistent pixel ratio"); + } + + const roundedPixelRatio = Math.round(pixelRatio); + const epsilon = 0.001; + + return roundedPixelRatio > 0 && + pixelRatio >= roundedPixelRatio - epsilon && + pixelRatio <= roundedPixelRatio + epsilon + ? roundedPixelRatio + : pixelRatio; +} diff --git a/test/src/browser/camera/index.js b/test/src/browser/camera/index.js index 56024a13e..534a727bd 100644 --- a/test/src/browser/camera/index.js +++ b/test/src/browser/camera/index.js @@ -36,6 +36,19 @@ describe("browser/camera", () => { return assert.becomes(camera.captureViewportImage(), image); }); + it("should expose calibrated dimensions before cropping to the viewport", async () => { + const camera = Camera.create("viewport", sinon.stub().resolves()); + image.getSize.returns({ width: 1204, height: 2406 }); + camera.calibrate({ left: 4, top: 6 }); + + const result = await camera.captureViewportImage({ + viewport: { left: 0, top: 0, width: 400, height: 800 }, + }); + + assert.deepEqual(result.uncroppedSize, { width: 1200, height: 2400 }); + assert.calledOnceWith(image.crop, { left: 4, top: 6, width: 400, height: 800 }); + }); + describe("crop", () => { describe("calibration", () => { it("should apply calibration on taken screenshot", async () => { diff --git a/test/src/browser/commands/assert-view/index.js b/test/src/browser/commands/assert-view/index.js index 91f77d2a1..0f115ead3 100644 --- a/test/src/browser/commands/assert-view/index.js +++ b/test/src/browser/commands/assert-view/index.js @@ -221,6 +221,24 @@ describe("assertView command", () => { ); }); + [{ args: ["--headless=new"], mobileEmulation: { deviceMetrics: { pixelRatio: 3 } } }, { args: [] }].forEach( + chromeOptions => { + it(`should not validate pixel ratio without headful mobile emulation: ${JSON.stringify( + chromeOptions, + )}`, async () => { + const session = mkSessionStub_(); + session.requestedCapabilities = { "goog:chromeOptions": chromeOptions }; + const browser = await initBrowser_({ session }); + + await browser.publicAPI.assertView("plain", ".selector"); + + const screenShooterOpts = ScreenShooter.prototype.capture.lastCall.args[1]; + assert.notProperty(screenShooterOpts, "preferredPixelRatio"); + assert.notProperty(screenShooterOpts, "reprepareScreenshot"); + }); + }, + ); + it("should screenshot the viewport if selector is not provided", async () => { const browser = await initBrowser_(); diff --git a/test/src/browser/screen-shooter/index.js b/test/src/browser/screen-shooter/index.js index be61fc01d..bea367661 100644 --- a/test/src/browser/screen-shooter/index.js +++ b/test/src/browser/screen-shooter/index.js @@ -26,6 +26,7 @@ describe("screen-shooter", () => { const capture = (page, opts) => ScreenShooter.create(browser).capture(stubPage(page), opts); beforeEach(() => { + imageStub.uncroppedSize = { width: 300, height: 600 }; browser = { config: {}, captureViewportImage: sandbox.stub().resolves(imageStub), @@ -95,33 +96,34 @@ describe("screen-shooter", () => { assert.calledWithMatch(browser.captureViewportImage, sinon.match.any, 2000); }); - it("should retry capture using pixel ratio from browser if it differs from prepared", async () => { + it("should retry using the screenshot pixel ratio even if the browser reports another value", async () => { const preparedPage = { - captureArea: { left: 1, top: 2, width: 10, height: 20 }, - viewport: { left: 0, top: 0, width: 100, height: 200 }, - ignoreAreas: [{ left: 3, top: 4, width: 5, height: 6 }], - documentHeight: 300, - documentWidth: 200, + captureArea: { left: 3, top: 6, width: 30, height: 60 }, + viewport: { left: 0, top: 0, width: 300, height: 600 }, + viewportSizeInCss: { width: 100, height: 200 }, + ignoreAreas: [{ left: 9, top: 12, width: 15, height: 18 }], + documentHeight: 900, + documentWidth: 600, pixelRatio: 3, }; const reprepareScreenshot = sandbox.stub().resolves(preparedPage); const opts = { reprepareScreenshot }; - browser.evalScript.resolves(3); await capture( { - captureArea: { left: 3, top: 6, width: 30, height: 60 }, - viewport: { left: 0, top: 0, width: 300, height: 600 }, - ignoreAreas: [{ left: 9, top: 12, width: 15, height: 18 }], - documentHeight: 900, - documentWidth: 600, + captureArea: { left: 1, top: 2, width: 10, height: 20 }, + viewport: { left: 0, top: 0, width: 100, height: 200 }, + viewportSizeInCss: { width: 100, height: 200 }, + ignoreAreas: [{ left: 3, top: 4, width: 5, height: 6 }], + documentHeight: 300, + documentWidth: 200, pixelRatio: 1, }, opts, ); assert.calledTwice(browser.captureViewportImage); - assert.calledOnceWith(browser.evalScript, "window.devicePixelRatio"); + assert.notCalled(browser.evalScript); assert.calledOnceWith(reprepareScreenshot, 3); assert.calledOnceWith(Viewport.create, preparedPage, imageStub, sinon.match.any); assert.notProperty(opts, "preferredPixelRatio"); @@ -132,6 +134,7 @@ describe("screen-shooter", () => { const preparedPage = { captureArea: { left: 1, top: 2, width: 10, height: 20 }, viewport: { left: 50, top: 0, width: 101, height: 201 }, + viewportSizeInCss: { width: 101, height: 201 }, ignoreAreas: [{ left: 3, top: 4, width: 6, height: 7 }], documentHeight: 201, documentWidth: 101, @@ -139,11 +142,13 @@ describe("screen-shooter", () => { }; const reprepareScreenshot = sandbox.stub().resolves(preparedPage); const opts = { preferredPixelRatio: 2.625, reprepareScreenshot }; + imageStub.uncroppedSize = { width: 101, height: 201 }; await capture( { captureArea: { left: 3, top: 6, width: 28, height: 54 }, viewport: { left: 131, top: 0, width: 266, height: 528 }, + viewportSizeInCss: { width: 101, height: 201 }, ignoreAreas: [{ left: 9, top: 12, width: 15, height: 18 }], documentHeight: 528, documentWidth: 266, @@ -156,6 +161,89 @@ describe("screen-shooter", () => { assert.calledOnceWith(Viewport.create, preparedPage, imageStub, sinon.match.any); }); + it("should retain the capabilities estimate without a browser round trip when the image matches", async () => { + const reprepareScreenshot = sandbox.stub(); + + await capture( + { viewport: { width: 300, height: 600 }, pixelRatio: 3 }, + { preferredPixelRatio: 3, reprepareScreenshot }, + ); + + assert.calledOnce(browser.captureViewportImage); + assert.notCalled(browser.evalScript); + assert.notCalled(reprepareScreenshot); + }); + + it("should allow a one-pixel rounding difference in screenshot dimensions", async () => { + const reprepareScreenshot = sandbox.stub(); + + await capture({ viewport: { width: 301, height: 599 }, pixelRatio: 3 }, { reprepareScreenshot }); + + assert.notCalled(reprepareScreenshot); + }); + + it("should finish the retry in best-effort mode if the screenshot scale changes again", async () => { + const reprepareScreenshot = sandbox.stub().resolves({ + viewport: { width: 300, height: 600 }, + pixelRatio: 3, + }); + const secondImage = sinon.createStubInstance(Image); + secondImage.uncroppedSize = { width: 200, height: 400 }; + browser.captureViewportImage.onSecondCall().resolves(secondImage); + + await capture( + { viewport: { width: 100, height: 200 }, viewportSizeInCss: { width: 100, height: 200 } }, + { reprepareScreenshot }, + ); + + assert.calledOnceWith(reprepareScreenshot, 3); + assert.calledTwice(browser.captureViewportImage); + assert.calledOnceWith(Viewport.prototype.handleImage, secondImage); + }); + + it("should reject screenshot dimensions that do not indicate a uniform scale", async () => { + imageStub.uncroppedSize = { width: 300, height: 800 }; + const reprepareScreenshot = sandbox.stub(); + + await assert.isRejected( + capture( + { viewport: { width: 100, height: 200 }, viewportSizeInCss: { width: 100, height: 200 } }, + { reprepareScreenshot }, + ), + "Screenshot dimensions do not match the viewport at a consistent pixel ratio", + ); + + assert.notCalled(reprepareScreenshot); + }); + + [ + [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.75, 1.75], + [0.0005, 0.0005], + ].forEach(([actual, expected]) => { + it(`should infer screenshot pixel ratio ${actual} as ${expected}`, async () => { + imageStub.uncroppedSize = { width: actual * 1000000, height: actual * 2000000 }; + const reprepareScreenshot = sandbox.stub().resolves({ pixelRatio: expected }); + + await capture( + { + viewport: { width: 1000000, height: 2000000 }, + viewportSizeInCss: { width: 1000000, height: 2000000 }, + }, + { reprepareScreenshot }, + ); + + assert.calledOnceWith(reprepareScreenshot, expected); + }); + }); + it("should extract image of passed size", async () => { await capture({ captureArea: { foo: "bar" } });