diff --git a/src/browser/camera/index.ts b/src/browser/camera/index.ts index 7d4d7ae39..0c9586266 100644 --- a/src/browser/camera/index.ts +++ b/src/browser/camera/index.ts @@ -17,6 +17,12 @@ export interface PageMeta { documentWidth: number; } +export interface ViewportImage extends Image { + /** Calibrated screenshot dimensions before cropping to the viewport. */ + readonly uncroppedSize: Pick; + readonly isFullPage: boolean; +} + interface Calibration { left: number; top: number; @@ -41,7 +47,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); @@ -49,13 +55,17 @@ export class Camera { const imageArea: ImageArea = { left: 0, top: 0, width, height }; const calibratedArea = this._calibrateArea(imageArea); - const viewportCroppedArea = this._cropAreaToViewport(calibratedArea, page); + const isFullPage = page ? utils.isFullPage(calibratedArea, page, this._screenshotMode) : false; + const viewportCroppedArea = this._cropAreaToViewport(calibratedArea, isFullPage, page); if (viewportCroppedArea.width !== width || viewportCroppedArea.height !== height) { await image.crop(viewportCroppedArea); } - return image; + return Object.assign(image, { + uncroppedSize: { width: calibratedArea.width, height: calibratedArea.height }, + isFullPage, + }); } private _calibrateArea(imageArea: ImageArea): ImageArea { @@ -68,12 +78,11 @@ export class Camera { return { left, top, width: imageArea.width - left, height: imageArea.height - top }; } - private _cropAreaToViewport(imageArea: ImageArea, page?: PageMeta): ImageArea { + private _cropAreaToViewport(imageArea: ImageArea, isFullPage: boolean, page?: PageMeta): ImageArea { if (!page) { return imageArea; } - const isFullPage = utils.isFullPage(imageArea, page, this._screenshotMode); const cropArea = _.clone(page.viewport); if (!isFullPage) { diff --git a/src/browser/client-scripts/index.js b/src/browser/client-scripts/index.js index 7a1ac6c67..5c807e1bb 100644 --- a/src/browser/client-scripts/index.js +++ b/src/browser/client-scripts/index.js @@ -142,6 +142,9 @@ function prepareScreenshotUnsafe(areas, opts) { }) .scale(pixelRatio) .serialize(), + viewportSizeInCss: { width: viewportWidth, height: viewportHeight }, + isTopLevelViewport: util.getOwnerWindow(mainDocumentElem) === window.top, + documentSizeInCss: { width: documentWidth, height: documentHeight }, 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..dfe87128f 100644 --- a/src/browser/screen-shooter/index.js +++ b/src/browser/screen-shooter/index.js @@ -12,22 +12,19 @@ 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"); - - if (currentPixelRatio !== (preferredPixelRatio ?? page.pixelRatio)) { + if (reprepareScreenshot && page.isTopLevelViewport) { + const currentPixelRatio = getPixelRatioFromImage( + capturedImage, + page, + this._browser.config.screenshotMode === "auto", + ); + + if (currentPixelRatio !== undefined) { Object.assign(page, await reprepareScreenshot(currentPixelRatio)); delete opts.preferredPixelRatio; delete opts.reprepareScreenshot; @@ -67,3 +64,34 @@ module.exports = class ScreenShooter { await viewport.extendBy(physicalScrollHeight, newImage); } }; + +function getPixelRatioFromImage({ uncroppedSize: imageSize, isFullPage }, page, allowFallback = false) { + const expectedSize = isFullPage ? { width: page.documentWidth, height: page.documentHeight } : page.viewport; + const cssSize = isFullPage ? page.documentSizeInCss : page.viewportSizeInCss; + + // Allow rounding differences between CSS geometry and the captured bitmap. + if (Math.abs(imageSize.width - expectedSize.width) <= 1 && Math.abs(imageSize.height - expectedSize.height) <= 1) { + return; + } + + // Each bitmap axis can round independently by one pixel. + const minPixelRatio = Math.max((imageSize.width - 1) / cssSize.width, (imageSize.height - 1) / cssSize.height); + const maxPixelRatio = Math.min((imageSize.width + 1) / cssSize.width, (imageSize.height + 1) / cssSize.height); + if (minPixelRatio > maxPixelRatio) { + // Auto detection uses the estimated DPR and can misidentify the source bitmap. + if (allowFallback) { + return getPixelRatioFromImage({ uncroppedSize: imageSize, isFullPage: !isFullPage }, page); + } + throw new Error("Screenshot dimensions do not match the viewport at a consistent pixel ratio"); + } + + const pixelRatio = (minPixelRatio + maxPixelRatio) / 2; + 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..f6d02a633 100644 --- a/test/src/browser/camera/index.js +++ b/test/src/browser/camera/index.js @@ -36,6 +36,42 @@ 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 }); + }); + + ["fullpage", "auto", "viewport"].forEach(mode => { + it(`should expose the source bitmap type in ${mode} mode`, async () => { + const { Camera: RealCamera } = require("src/browser/camera"); + const camera = RealCamera.create(mode, sinon.stub().resolves()); + image.getSize.returns({ width: 1200, height: 3600 }); + + const result = await camera.captureViewportImage({ + viewport: { left: 0, top: 30, width: 300, height: 600 }, + documentWidth: 1200, + documentHeight: 3600, + }); + + assert.strictEqual(result.isFullPage, mode !== "viewport"); + assert.deepEqual(result.uncroppedSize, { width: 1200, height: 3600 }); + assert.calledOnceWith(image.crop, { + left: 0, + top: mode === "viewport" ? 0 : 30, + width: 300, + height: 600, + }); + }); + }); + 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..3d01fca03 100644 --- a/test/src/browser/screen-shooter/index.js +++ b/test/src/browser/screen-shooter/index.js @@ -22,10 +22,16 @@ describe("screen-shooter", () => { let browser; const imageStub = sinon.createStubInstance(Image); - const stubPage = page => Object.assign({ viewport: {}, captureArea: {}, ignoreAreas: [], pixelRatio: 1 }, page); + const stubPage = page => + Object.assign( + { viewport: {}, captureArea: {}, ignoreAreas: [], pixelRatio: 1, isTopLevelViewport: true }, + page, + ); const capture = (page, opts) => ScreenShooter.create(browser).capture(stubPage(page), opts); beforeEach(() => { + imageStub.uncroppedSize = { width: 300, height: 600 }; + imageStub.isFullPage = false; browser = { config: {}, captureViewportImage: sandbox.stub().resolves(imageStub), @@ -54,6 +60,7 @@ describe("screen-shooter", () => { captureArea: {}, ignoreAreas: [], pixelRatio: 1, + isTopLevelViewport: true, viewport: "foo", }); }); @@ -71,6 +78,7 @@ describe("screen-shooter", () => { captureArea: {}, ignoreAreas: [], pixelRatio: 100500, + isTopLevelViewport: true, viewport: {}, }); }); @@ -95,33 +103,35 @@ 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, + isTopLevelViewport: true, }; 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,18 +142,22 @@ 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, pixelRatio: 1, + isTopLevelViewport: true, }; 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 +170,200 @@ 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); + }); + + [2400, 1800].forEach(height => { + it(`should not infer DPR from a cross-origin frame and an outer-window image of height ${height}`, async () => { + imageStub.uncroppedSize = { width: 1200, height }; + const reprepareScreenshot = sandbox.stub(); + + await capture( + { + isTopLevelViewport: false, + viewport: { width: 720, height: 1080 }, + viewportSizeInCss: { width: 240, height: 360 }, + pixelRatio: 3, + }, + { reprepareScreenshot }, + ); + + assert.notCalled(reprepareScreenshot); + assert.calledOnce(browser.captureViewportImage); + assert.calledOnceWith(Viewport.prototype.handleImage, imageStub); + }); + }); + + it("should retain the estimate when a full-page image matches document dimensions", async () => { + imageStub.isFullPage = true; + imageStub.uncroppedSize = { width: 1200, height: 3600 }; + const reprepareScreenshot = sandbox.stub(); + + await capture( + { + viewport: { width: 300, height: 600 }, + viewportSizeInCss: { width: 100, height: 200 }, + documentWidth: 1200, + documentHeight: 3600, + documentSizeInCss: { width: 400, height: 1200 }, + pixelRatio: 3, + }, + { reprepareScreenshot }, + ); + + assert.notCalled(reprepareScreenshot); + assert.calledOnce(browser.captureViewportImage); + }); + + it("should infer DPR from full-page CSS dimensions without rescaling rounded document bounds", async () => { + imageStub.isFullPage = true; + imageStub.uncroppedSize = { width: 201, height: 401 }; + const reprepareScreenshot = sandbox.stub().resolves({ pixelRatio: 1 }); + + await capture( + { + viewport: { width: 266, height: 528 }, + viewportSizeInCss: { width: 101, height: 201 }, + documentWidth: 528, + documentHeight: 1053, + documentSizeInCss: { width: 201, height: 401 }, + pixelRatio: 2.625, + }, + { reprepareScreenshot }, + ); + + assert.calledOnceWith(reprepareScreenshot, 1); + assert.calledTwice(browser.captureViewportImage); + }); + + [true, false].forEach(isFullPage => { + it(`should recover when auto detection incorrectly sets isFullPage to ${isFullPage}`, async () => { + browser.config.screenshotMode = "auto"; + imageStub.isFullPage = isFullPage; + imageStub.uncroppedSize = isFullPage ? { width: 1200, height: 2400 } : { width: 400, height: 1600 }; + const preparedRatio = isFullPage ? 1 : 3; + const actualRatio = isFullPage ? 3 : 1; + const reprepareScreenshot = sandbox.stub().resolves({ pixelRatio: actualRatio }); + + await capture( + { + viewport: { width: 400 * preparedRatio, height: 800 * preparedRatio }, + viewportSizeInCss: { width: 400, height: 800 }, + documentWidth: 400 * preparedRatio, + documentHeight: 1600 * preparedRatio, + documentSizeInCss: { width: 400, height: 1600 }, + pixelRatio: preparedRatio, + }, + { reprepareScreenshot }, + ); + + assert.calledOnceWith(reprepareScreenshot, actualRatio); + assert.calledTwice(browser.captureViewportImage); + }); + }); + + [ + [101, 201], + [201, 101], + [101, 1001], + ].forEach(([width, height]) => { + it(`should accept independent fractional-DPR rounding for a ${width}x${height} viewport`, async () => { + imageStub.uncroppedSize = { width: Math.ceil(width * 2.625), height: Math.ceil(height * 2.625) }; + const reprepareScreenshot = sandbox.stub().resolves({}); + + await capture( + { viewport: { width, height }, viewportSizeInCss: { width, height } }, + { reprepareScreenshot }, + ); + + assert.calledOnce(reprepareScreenshot); + const pixelRatio = reprepareScreenshot.firstCall.args[0]; + assert.closeTo(width * pixelRatio, imageStub.uncroppedSize.width, 1); + assert.closeTo(height * pixelRatio, imageStub.uncroppedSize.height, 1); + assert.calledTwice(browser.captureViewportImage); + }); + }); + + 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" } }); @@ -249,6 +457,7 @@ describe("screen-shooter", () => { viewport: { top: 2, height: 5 }, ignoreAreas: [], pixelRatio: 1, + isTopLevelViewport: true, }; const captureViewportImage = browser.captureViewportImage .withArgs(scrolledPage)