From d4c7a3a3a96bfe0cb58c9ebe8343e9c585be20e8 Mon Sep 17 00:00:00 2001 From: Roman Kuznetsov Date: Tue, 10 Oct 2023 18:33:04 +0300 Subject: [PATCH] feat: add ability to disable animations in assertView --- README.md | 4 +- src/browser/client-scripts/index.js | 59 +++++++++++++ src/browser/client-scripts/util.js | 16 ++++ src/browser/commands/assert-view/index.js | 16 +++- src/browser/existing-browser.js | 57 ++++++++++++ src/config/browser-options.js | 2 + src/config/defaults.js | 1 + test/src/browser/existing-browser.js | 103 ++++++++++++++++++++-- test/src/browser/utils.js | 14 ++- typings/index.d.ts | 10 +++ 10 files changed, 267 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 5c997f3c8..70aa8397e 100644 --- a/README.md +++ b/README.md @@ -646,6 +646,7 @@ Parameters: - compositeImage (optional) `Boolean` - overrides config [browsers](#browsers).[compositeImage](#compositeImage) value - screenshotDelay (optional) `Number` - overrides config [browsers](#browsers).[screenshotDelay](#screenshotDelay) value - selectorToScroll (optional) `String` - DOM-node selector which should be scroll when the captured element does not completely fit on the screen. Useful when you capture the modal (popup). In this case a duplicate of the modal appears on the screenshot. That happens because we scroll the page using `window` selector, which scroll only the background of the modal, and the modal itself remains in place. Works only when `compositeImage` is `true`. + - disableAnimation (optional): `Boolean` - ability to disable animations and transitions while capturing a screenshot. All options inside `assertView` command override the same options in the [browsers](#browsers).[assertViewOpts](#assertViewOpts). @@ -1020,7 +1021,8 @@ Default options used when calling [assertView](https://github.com/gemini-testing ```javascript ignoreElements: [], captureElementFromTop: true, - allowViewportOverflow: false + allowViewportOverflow: false, + disableAnimation: false ``` #### screenshotsDir diff --git a/src/browser/client-scripts/index.js b/src/browser/client-scripts/index.js index 677f3840a..a5b7f3e32 100644 --- a/src/browser/client-scripts/index.js +++ b/src/browser/client-scripts/index.js @@ -33,9 +33,27 @@ exports.prepareScreenshot = function prepareScreenshot(areas, opts) { } }; +exports.disableFrameAnimations = function disableFrameAnimations() { + try { + return disableFrameAnimationsUnsafe(); + } catch (e) { + return { + error: "JS", + message: e.stack || e.message + }; + } +}; + +exports.cleanupFrameAnimations = function cleanupFrameAnimations() { + if (window.__cleanupAnimation) { + window.__cleanupAnimation(); + } +}; + function prepareScreenshotUnsafe(areas, opts) { var allowViewportOverflow = opts.allowViewportOverflow; var captureElementFromTop = opts.captureElementFromTop; + var disableAnimation = opts.disableAnimation; var scrollElem = window; if (opts.selectorToScroll) { @@ -102,6 +120,10 @@ function prepareScreenshotUnsafe(areas, opts) { }; } + if (disableAnimation) { + disableFrameAnimationsUnsafe(); + } + return { captureArea: rect.scale(pixelRatio).serialize(), ignoreAreas: findIgnoreAreas(opts.ignoreSelectors, { @@ -125,6 +147,43 @@ function prepareScreenshotUnsafe(areas, opts) { }; } +function disableFrameAnimationsUnsafe() { + var everyElementSelector = "*:not(#hermione-q.hermione-w.hermione-e.hermione-r.hermione-t.hermione-y)"; + var everythingSelector = ["", "::before", "::after"] + .map(function (pseudo) { + return everyElementSelector + pseudo; + }) + .join(", "); + + var styleElements = []; + + util.forEachRoot(function (root) { + var styleElement = document.createElement("style"); + styleElement.innerHTML = + everythingSelector + + [ + "{", + " animation-delay: 0ms !important;", + " animation-duration: 0ms !important;", + " animation-timing-function: step-start !important;", + " transition-timing-function: step-start !important;", + " scroll-behavior: auto !important;", + "}" + ].join("\n"); + + root.appendChild(styleElement); + styleElements.push(styleElement); + }); + + window.__cleanupAnimation = function () { + for (var i = 0; i < styleElements.length; i++) { + styleElements[i].remove(); + } + + delete window.__cleanupAnimation; + }; +} + exports.resetZoom = function () { var meta = lib.queryFirst('meta[name="viewport"]'); if (!meta) { diff --git a/src/browser/client-scripts/util.js b/src/browser/client-scripts/util.js index 6f7f54265..337cc8760 100644 --- a/src/browser/client-scripts/util.js +++ b/src/browser/client-scripts/util.js @@ -88,3 +88,19 @@ exports.isSafariMobile = function () { exports.isInteger = function (num) { return num % 1 === 0; }; + +exports.forEachRoot = function (cb) { + function traverseRoots(root) { + cb(root); + + var treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + + for (var node = treeWalker.currentNode; node !== null; node = treeWalker.nextNode()) { + if (node instanceof Element && node.shadowRoot) { + traverseRoots(node.shadowRoot); + } + } + } + + traverseRoots(document.documentElement); +}; diff --git a/src/browser/commands/assert-view/index.js b/src/browser/commands/assert-view/index.js index 7a068492e..62458a9ae 100644 --- a/src/browser/commands/assert-view/index.js +++ b/src/browser/commands/assert-view/index.js @@ -17,7 +17,15 @@ const InvalidPngError = require("./errors/invalid-png-error"); module.exports = browser => { const screenShooter = ScreenShooter.create(browser); const { publicAPI: session, config } = browser; - const { assertViewOpts, compareOpts, compositeImage, screenshotDelay, tolerance, antialiasingTolerance } = config; + const { + assertViewOpts, + compareOpts, + compositeImage, + screenshotDelay, + tolerance, + antialiasingTolerance, + disableAnimation, + } = config; const { handleNoRefImage, handleImageDiff } = getCaptureProcessors(); @@ -27,6 +35,7 @@ module.exports = browser => { screenshotDelay, tolerance, antialiasingTolerance, + disableAnimation, }); const { hermioneCtx } = session.executionContext; @@ -44,6 +53,7 @@ module.exports = browser => { allowViewportOverflow: opts.allowViewportOverflow, captureElementFromTop: opts.captureElementFromTop, selectorToScroll: opts.selectorToScroll, + disableAnimation: opts.disableAnimation, }); const { tempOpts } = RuntimeConfig.getInstance(); @@ -55,7 +65,9 @@ module.exports = browser => { "screenshotDelay", "selectorToScroll", ]); - const currImgInst = await screenShooter.capture(page, screenshoterOpts); + const currImgInst = await screenShooter + .capture(page, screenshoterOpts) + .finally(() => browser.cleanupScreenshot(opts)); const currSize = await currImgInst.getSize(); const currImg = { path: temp.path(Object.assign(tempOpts, { suffix: ".png" })), size: currSize }; diff --git a/src/browser/existing-browser.js b/src/browser/existing-browser.js index c1d11da21..f29ce63d6 100644 --- a/src/browser/existing-browser.js +++ b/src/browser/existing-browser.js @@ -79,9 +79,21 @@ module.exports = class ExistingBrowser extends Browser { `Prepare screenshot failed with error type '${result.error}' and error message: ${result.message}`, ); } + + // https://github.com/webdriverio/webdriverio/issues/11396 + if (this._config.automationProtocol === "webdriver" && opts.disableAnimation) { + await this._disableIframeAnimations(); + } + return result; } + async cleanupScreenshot(opts = {}) { + if (opts.disableAnimation) { + await this._cleanupPageAnimations(); + } + } + open(url) { return this._session.url(url); } @@ -281,6 +293,51 @@ module.exports = class ExistingBrowser extends Browser { .then(clientBridge => (this._clientBridge = clientBridge)); } + async _runInEachIframe(cb) { + const iframes = await this._session.findElements("css selector", "iframe"); + + try { + for (const iframe of iframes) { + await this._session.switchToFrame(iframe); + await cb(); + } + } finally { + await this._session.switchToParentFrame(); + } + } + + async _disableFrameAnimations() { + const result = await this._clientBridge.call("disableFrameAnimations"); + + if (result && result.error) { + throw new Error( + `Disable animations failed with error type '${result.error}' and error message: ${result.message}`, + ); + } + + return result; + } + + async _disableIframeAnimations() { + await this._runInEachIframe(() => this._disableFrameAnimations()); + } + + async _cleanupFrameAnimations() { + return this._clientBridge.call("cleanupFrameAnimations"); + } + + async _cleanupIframeAnimations() { + await this._runInEachIframe(() => this._cleanupFrameAnimations()); + } + + async _cleanupPageAnimations() { + await this._cleanupFrameAnimations(); + + if (this._config.automationProtocol === "webdriver") { + await this._cleanupIframeAnimations(); + } + } + _stubCommands() { for (let commandName of this._session.commandList) { if (commandName === "deleteSession") { diff --git a/src/config/browser-options.js b/src/config/browser-options.js index 8aa659d9f..8905721b5 100644 --- a/src/config/browser-options.js +++ b/src/config/browser-options.js @@ -226,6 +226,8 @@ function buildBrowserOptions(defaultFactory, extra) { validate: value => utils.assertNonNegativeNumber(value, "antialiasingTolerance"), }), + disableAnimation: options.boolean("disableAnimation"), + compareOpts: options.optionalObject("compareOpts"), buildDiffOpts: options.optionalObject("buildDiffOpts"), diff --git a/src/config/defaults.js b/src/config/defaults.js index 53266298c..ee9dae149 100644 --- a/src/config/defaults.js +++ b/src/config/defaults.js @@ -13,6 +13,7 @@ module.exports = { diffColor: "#ff00ff", tolerance: 2.3, antialiasingTolerance: 4, + disableAnimation: false, compareOpts: { shouldCluster: false, clustersSize: 10, diff --git a/test/src/browser/existing-browser.js b/test/src/browser/existing-browser.js index 92bb3950c..2e214753c 100644 --- a/test/src/browser/existing-browser.js +++ b/test/src/browser/existing-browser.js @@ -35,6 +35,14 @@ describe("ExistingBrowser", () => { return browser.init(sessionData, calibrator); }; + const stubClientBridge_ = () => { + const bridge = { call: sandbox.stub().resolves({}) }; + + clientBridge.build.resolves(bridge); + + return bridge; + }; + beforeEach(() => { session = mkSessionStub_(); sandbox.stub(webdriverio, "attach").resolves(session); @@ -648,14 +656,6 @@ describe("ExistingBrowser", () => { }); describe("prepareScreenshot", () => { - const stubClientBridge_ = () => { - const bridge = { call: sandbox.stub().resolves({}) }; - - clientBridge.build.resolves(bridge); - - return bridge; - }; - it("should prepare screenshot", async () => { const clientBridge = stubClientBridge_(); clientBridge.call.withArgs("prepareScreenshot").resolves({ foo: "bar" }); @@ -721,6 +721,93 @@ describe("ExistingBrowser", () => { "Prepare screenshot failed with error type 'JS' and error message: stub error", ); }); + + it("should disable animations if 'disableAnimation: true' and 'automationProtocol: webdriver'", async () => { + const clientBridge = stubClientBridge_(); + const browser = await initBrowser_(mkBrowser_({ automationProtocol: "webdriver" })); + const [wdElement] = await browser.publicAPI.findElements("css selector", ".some-selector"); + + await browser.prepareScreenshot(".selector", { disableAnimation: true }); + + assert.calledWith(clientBridge.call, "prepareScreenshot", [ + ".selector", + sinon.match({ disableAnimation: true }), + ]); + assert.calledOnceWith(browser.publicAPI.switchToFrame, wdElement); + assert.calledWith(clientBridge.call, "disableFrameAnimations"); + }); + + it("should not disable iframe animations if 'disableAnimation: true' and 'automationProtocol: devtools'", async () => { + const clientBridge = stubClientBridge_(); + const browser = await initBrowser_(mkBrowser_({ automationProtocol: "devtools" })); + + await browser.prepareScreenshot(".selector", { disableAnimation: true }); + + assert.calledWith(clientBridge.call, "prepareScreenshot", [ + ".selector", + sinon.match({ disableAnimation: true }), + ]); + assert.notCalled(browser.publicAPI.switchToFrame); + assert.neverCalledWith(clientBridge.call, "disableFrameAnimations"); + }); + + it("should not disable animations if 'disableAnimation: false'", async () => { + const clientBridge = stubClientBridge_(); + const browser = await initBrowser_(mkBrowser_({ automationProtocol: "webdriver" })); + const [wdElement] = await browser.publicAPI.findElements("css selector", ".some-selector"); + + await browser.prepareScreenshot(".selector", { disableAnimation: false }); + + assert.neverCalledWith(clientBridge.call, "prepareScreenshot", [ + ".selector", + sinon.match({ disableAnimation: true }), + ]); + assert.neverCalledWith(browser.publicAPI.switchToFrame, wdElement); + assert.neverCalledWith(clientBridge.call, "disableFrameAnimations"); + }); + }); + + describe("cleanupScreenshot", () => { + it("should cleanup parent frame if 'disableAnimation: true'", async () => { + const clientBridge = stubClientBridge_(); + const browser = await initBrowser_(mkBrowser_({ automationProtocol: "webdriver" })); + + await browser.cleanupScreenshot({ disableAnimation: true }); + + assert.calledWith(clientBridge.call, "cleanupFrameAnimations"); + }); + + it("should not cleanup frames if 'disableAnimation: false'", async () => { + const clientBridge = stubClientBridge_(); + const browser = await initBrowser_(mkBrowser_({ automationProtocol: "webdriver" })); + + await browser.cleanupScreenshot({ disableAnimation: false }); + + assert.neverCalledWith(clientBridge.call, "cleanupFrameAnimations"); + }); + + it("should cleanup animations in iframe if 'automationProtocol: webdriver'", async () => { + const clientBridge = stubClientBridge_(); + const browser = await initBrowser_(mkBrowser_({ automationProtocol: "webdriver" })); + const [wdElement] = await browser.publicAPI.findElements("css selector", ".some-selector"); + + await browser.cleanupScreenshot({ disableAnimation: true }); + + assert.calledOnceWith(browser.publicAPI.switchToFrame, wdElement); + assert.calledWith(clientBridge.call, "cleanupFrameAnimations"); + assert.callOrder(browser.publicAPI.switchToFrame, clientBridge.call); + }); + + it("should not cleanup animations in iframe if 'automationProtocol: devtools'", async () => { + const clientBridge = stubClientBridge_(); + const browser = await initBrowser_(mkBrowser_({ automationProtocol: "devtools" })); + const [wdElement] = await browser.publicAPI.findElements("css selector", ".some-selector"); + + await browser.cleanupScreenshot({ disableAnimation: true }); + + assert.notCalled(browser.publicAPI.switchToFrame); + assert.neverCalledWith(clientBridge.call, "cleanupFrameAnimations", wdElement); + }); }); describe("open", () => { diff --git a/test/src/browser/utils.js b/test/src/browser/utils.js index 4788987f4..e66c184c0 100644 --- a/test/src/browser/utils.js +++ b/test/src/browser/utils.js @@ -58,11 +58,14 @@ exports.mkExistingBrowser_ = (opts, browser = "browser", browserVersion, emitter exports.mkSessionStub_ = () => { const session = {}; - const element = { + const wdioElement = { selector: ".selector", click: sinon.stub().named("click").resolves(), waitForExist: sinon.stub().named("waitForExist").resolves(), }; + const wdElement = { + "element-6066-11e4-a52e-4f735466cecf": "95777D6590AF653A2FD8EB0ADD20B333_element_1", + }; session.sessionId = "1234567890"; session.isW3C = false; @@ -83,19 +86,22 @@ exports.mkSessionStub_ = () => { session.setTimeout = sinon.stub().named("setTimeout").resolves(); session.setTimeouts = sinon.stub().named("setTimeouts").resolves(); session.getPuppeteer = sinon.stub().named("getPuppeteer").resolves(exports.mkCDPStub_()); - session.$ = sinon.stub().named("$").resolves(element); + session.$ = sinon.stub().named("$").resolves(wdioElement); session.getWindowHandles = sinon.stub().named("getWindowHandles").resolves([]); session.switchToWindow = sinon.stub().named("switchToWindow").resolves(); + session.findElements = sinon.stub().named("findElements").resolves([wdElement]); + session.switchToFrame = sinon.stub().named("switchToFrame").resolves(); + session.switchToParentFrame = sinon.stub().named("switchToParentFrame").resolves(); session.addCommand = sinon.stub().callsFake((name, command, isElement) => { - const target = isElement ? element : session; + const target = isElement ? wdioElement : session; target[name] = command.bind(target); sinon.spy(target, name); }); session.overwriteCommand = sinon.stub().callsFake((name, command, isElement) => { - const target = isElement ? element : session; + const target = isElement ? wdioElement : session; target[name] = command.bind(target, target[name]); sinon.spy(target, name); diff --git a/typings/index.d.ts b/typings/index.d.ts index 6d5fed5f2..ca18b642a 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -496,6 +496,16 @@ declare namespace Hermione { * @defaultValue `false` */ allowViewportOverflow: boolean; + /** + * Ability to disable animations and transitions while making a screenshot + * + * @remarks + * Usefull when you capture screenshot of a page, having animations and transitions. + * Iframe animations are only disabled when using webdriver protocol. + * + * @defaultValue `false` + */ + disableAnimation?: boolean; } export interface ExpectOptsConfig {