diff --git a/src/browser/client-scripts/index.js b/src/browser/client-scripts/index.js index 8b0ae80a6..7a1ac6c67 100644 --- a/src/browser/client-scripts/index.js +++ b/src/browser/client-scripts/index.js @@ -81,7 +81,7 @@ function prepareScreenshotUnsafe(areas, opts) { width: viewportWidth, height: viewportHeight }), - pixelRatio = configurePixelRatio(opts.usePixelRatio), + pixelRatio = configurePixelRatio(opts.usePixelRatio, opts.preferredPixelRatio), rect, selectors = []; @@ -252,11 +252,15 @@ function getCaptureRect(selectors, opts) { }; } -function configurePixelRatio(usePixelRatio) { +function configurePixelRatio(usePixelRatio, preferredPixelRatio) { if (usePixelRatio === false) { return 1; } + if (preferredPixelRatio) { + return preferredPixelRatio; + } + if (window.devicePixelRatio) { return window.devicePixelRatio; } diff --git a/src/browser/commands/assert-view/index.js b/src/browser/commands/assert-view/index.js index 1ce8c78c2..b249808ec 100644 --- a/src/browser/commands/assert-view/index.js +++ b/src/browser/commands/assert-view/index.js @@ -12,6 +12,18 @@ const AssertViewResults = require("./assert-view-results"); const { BaseStateError } = require("./errors/base-state-error"); const { addTestplaneSelectivityPngDependency } = require("../../cdp/selectivity/testplane-selectivity"); +const HEADLESS_CHROME_ARG_RE = /^-{0,2}headless(?:=|$)/; + +const isHeadlessBrowser = chromeOptions => (chromeOptions?.args || []).some(arg => HEADLESS_CHROME_ARG_RE.test(arg)); + +const isPixelRatioEmulated = chromeOptions => Boolean(chromeOptions?.mobileEmulation); + +const getEmulatedPixelRatio = chromeOptions => { + const pixelRatio = _.get(chromeOptions, "mobileEmulation.deviceMetrics.pixelRatio"); + + return _.isFinite(pixelRatio) && pixelRatio > 0 ? pixelRatio : undefined; +}; + const getIgnoreDiffPixelCountRatio = value => { const percent = _.isString(value) && value.endsWith("%") ? parseFloat(value.slice(0, -1)) : false; @@ -29,6 +41,8 @@ const getIgnoreDiffPixelCountRatio = value => { module.exports.default = browser => { const screenShooter = ScreenShooter.create(browser); const { publicAPI: session, config } = browser; + const chromeOptions = session.requestedCapabilities?.["goog:chromeOptions"]; + const emulatedPixelRatio = getEmulatedPixelRatio(chromeOptions); const { assertViewOpts, compareOpts, @@ -68,13 +82,20 @@ module.exports.default = browser => { const handleCaptureProcessorError = e => e instanceof BaseStateError ? testplaneCtx.assertViewResults.add(e) : Promise.reject(e); - const page = await browser.prepareScreenshot([].concat(selectors), { + const shouldValidatePixelRatio = + browser.shouldUsePixelRatio && !isHeadlessBrowser(chromeOptions) && isPixelRatioEmulated(chromeOptions); + const preferredPixelRatio = shouldValidatePixelRatio ? emulatedPixelRatio : undefined; + + const screenshotSelectors = [].concat(selectors); + const prepareScreenshotOpts = { ignoreSelectors: [].concat(opts.ignoreElements), allowViewportOverflow: opts.allowViewportOverflow, captureElementFromTop: opts.captureElementFromTop, selectorToScroll: opts.selectorToScroll, disableAnimation: opts.disableAnimation, - }); + preferredPixelRatio, + }; + const page = await browser.prepareScreenshot(screenshotSelectors, prepareScreenshotOpts); const { tempOpts, updateRefs: isUpdatingRefs } = RuntimeConfig.getInstance(); temp.attach(tempOpts); @@ -85,6 +106,18 @@ module.exports.default = browser => { "screenshotDelay", "selectorToScroll", ]); + if (shouldValidatePixelRatio) { + if (preferredPixelRatio) { + screenshoterOpts.preferredPixelRatio = preferredPixelRatio; + } + + screenshoterOpts.reprepareScreenshot = currentPixelRatio => + browser.prepareScreenshot(screenshotSelectors, { + ...prepareScreenshotOpts, + disableAnimation: false, + preferredPixelRatio: currentPixelRatio, + }); + } const currImgInst = await screenShooter .capture(page, screenshoterOpts) .finally(() => browser.cleanupScreenshot(opts)); diff --git a/src/browser/existing-browser.ts b/src/browser/existing-browser.ts index 77651c972..d4a6d87d6 100644 --- a/src/browser/existing-browser.ts +++ b/src/browser/existing-browser.ts @@ -172,7 +172,7 @@ export class ExistingBrowser extends Browser { // Running this fragment with history causes rrweb snapshots to break on pages with iframes return runWithoutHistory({ callstack: this._callstackHistory! }, async () => { opts = _.extend(opts, { - usePixelRatio: this._calibration ? this._calibration.usePixelRatio : true, + usePixelRatio: this.shouldUsePixelRatio, }); ensure(this._clientBridge, CLIENT_BRIDGE_HINT); @@ -212,6 +212,10 @@ export class ExistingBrowser extends Browser { return this._session.execute(`return ${script}`); } + get shouldUsePixelRatio(): boolean { + return this._calibration ? this._calibration.usePixelRatio : true; + } + injectScript(script: string): Promise { ensure(this._session, BROWSER_SESSION_HINT); diff --git a/src/browser/screen-shooter/index.js b/src/browser/screen-shooter/index.js index 3434e805a..90c016922 100644 --- a/src/browser/screen-shooter/index.js +++ b/src/browser/screen-shooter/index.js @@ -12,11 +12,30 @@ module.exports = class ScreenShooter { } async capture(page, opts = {}) { - const { allowViewportOverflow, compositeImage, screenshotDelay, selectorToScroll } = opts; + const { + allowViewportOverflow, + compositeImage, + screenshotDelay, + selectorToScroll, + preferredPixelRatio, + 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)) { + Object.assign(page, await reprepareScreenshot(currentPixelRatio)); + delete opts.preferredPixelRatio; + delete opts.reprepareScreenshot; + + return this.capture(page, opts); + } + } + const viewport = Viewport.create(page, capturedImage, viewportOpts); await viewport.handleImage(capturedImage); diff --git a/test/src/browser/commands/assert-view/index.js b/test/src/browser/commands/assert-view/index.js index bf8d37141..91f77d2a1 100644 --- a/test/src/browser/commands/assert-view/index.js +++ b/test/src/browser/commands/assert-view/index.js @@ -161,6 +161,66 @@ describe("assertView command", () => { assert.calledOnceWith(browser.prepareScreenshot, [".selector1", ".selector2"]); }); + it("should use emulated pixel ratio from requested capabilities in headful browser", async () => { + const session = mkSessionStub_(); + session.requestedCapabilities = { + "goog:chromeOptions": { + mobileEmulation: { deviceMetrics: { pixelRatio: 3 } }, + }, + }; + const browser = await initBrowser_({ session }); + sandbox.stub(browser, "cleanupScreenshot").resolves(); + + await browser.publicAPI.assertView("plain", ".selector", { disableAnimation: true }); + + assert.calledOnceWith( + browser.prepareScreenshot, + [".selector"], + sinon.match({ disableAnimation: true, preferredPixelRatio: 3 }), + ); + assert.calledOnceWith( + ScreenShooter.prototype.capture, + sinon.match.any, + sinon.match({ preferredPixelRatio: 3 }), + ); + + const reprepareScreenshot = ScreenShooter.prototype.capture.lastCall.args[1].reprepareScreenshot; + + await reprepareScreenshot(1); + + assert.calledWith( + browser.prepareScreenshot, + [".selector"], + sinon.match({ disableAnimation: false, preferredPixelRatio: 1 }), + ); + }); + + it("should validate pixel ratio when mobile emulation uses a device name", async () => { + const session = mkSessionStub_(); + session.requestedCapabilities = { + "goog:chromeOptions": { + mobileEmulation: { deviceName: "Pixel 7" }, + }, + }; + const browser = await initBrowser_({ session }); + sandbox.stub(browser, "cleanupScreenshot").resolves(); + + await browser.publicAPI.assertView("plain", ".selector"); + + const screenShooterOpts = ScreenShooter.prototype.capture.lastCall.args[1]; + + assert.notProperty(screenShooterOpts, "preferredPixelRatio"); + assert.isFunction(screenShooterOpts.reprepareScreenshot); + + await screenShooterOpts.reprepareScreenshot(3); + + assert.calledWith( + browser.prepareScreenshot, + [".selector"], + sinon.match({ disableAnimation: false, preferredPixelRatio: 3 }), + ); + }); + 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 facdaa754..be61fc01d 100644 --- a/test/src/browser/screen-shooter/index.js +++ b/test/src/browser/screen-shooter/index.js @@ -29,6 +29,7 @@ describe("screen-shooter", () => { browser = { config: {}, captureViewportImage: sandbox.stub().resolves(imageStub), + evalScript: sandbox.stub().resolves(1), scrollBy: sandbox.stub().resolves(), }; }); @@ -94,6 +95,67 @@ 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 () => { + 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, + 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, + pixelRatio: 1, + }, + opts, + ); + + assert.calledTwice(browser.captureViewportImage); + assert.calledOnceWith(browser.evalScript, "window.devicePixelRatio"); + assert.calledOnceWith(reprepareScreenshot, 3); + assert.calledOnceWith(Viewport.create, preparedPage, imageStub, sinon.match.any); + assert.notProperty(opts, "preferredPixelRatio"); + assert.notProperty(opts, "reprepareScreenshot"); + }); + + it("should recompute fractional-DPR geometry instead of rescaling rounded bounds", async () => { + const preparedPage = { + captureArea: { left: 1, top: 2, width: 10, height: 20 }, + viewport: { left: 50, top: 0, width: 101, height: 201 }, + ignoreAreas: [{ left: 3, top: 4, width: 6, height: 7 }], + documentHeight: 201, + documentWidth: 101, + pixelRatio: 1, + }; + const reprepareScreenshot = sandbox.stub().resolves(preparedPage); + const opts = { preferredPixelRatio: 2.625, reprepareScreenshot }; + + await capture( + { + captureArea: { left: 3, top: 6, width: 28, height: 54 }, + viewport: { left: 131, top: 0, width: 266, height: 528 }, + ignoreAreas: [{ left: 9, top: 12, width: 15, height: 18 }], + documentHeight: 528, + documentWidth: 266, + pixelRatio: 2.625, + }, + opts, + ); + + assert.calledOnceWith(reprepareScreenshot, 1); + assert.calledOnceWith(Viewport.create, preparedPage, imageStub, sinon.match.any); + }); + it("should extract image of passed size", async () => { await capture({ captureArea: { foo: "bar" } });