Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add ability to disable animations in assertView #794

Merged
merged 1 commit into from
Oct 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,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).

Expand Down Expand Up @@ -1031,7 +1032,8 @@ Default options used when calling [assertView](https://github.com/gemini-testing
```javascript
ignoreElements: [],
captureElementFromTop: true,
allowViewportOverflow: false
allowViewportOverflow: false,
disableAnimation: true
```

#### screenshotsDir
Expand Down
59 changes: 59 additions & 0 deletions src/browser/client-scripts/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

В процессе отключения анимации, в руты вставляются style-элементы.
Эта функция __cleanupAnimation убирает из DOM-дерева эти элементы

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) {
Expand Down Expand Up @@ -102,6 +120,10 @@ function prepareScreenshotUnsafe(areas, opts) {
};
}

if (disableAnimation) {
disableFrameAnimationsUnsafe();
}

return {
captureArea: rect.scale(pixelRatio).serialize(),
ignoreAreas: findIgnoreAreas(opts.ignoreSelectors, {
Expand All @@ -125,6 +147,43 @@ function prepareScreenshotUnsafe(areas, opts) {
};
}

function disableFrameAnimationsUnsafe() {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

frame - потому что это происходит только в текущем фрейме (iframe тут не трогаются)

var everyElementSelector = "*:not(#hermione-q.hermione-w.hermione-e.hermione-r.hermione-t.hermione-y)";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

А почему hermione-q - это id, а остальные hermione-* - это классы? Или так и задумано?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Это не имеет значения. Главное, чтоб у селектора был и айдишник, и несколько классов, тогда он будет специфичнее даже селектора по айдишнику

var everythingSelector = ["", "::before", "::after"]
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

По итогу тут получаем сильный селектор, на который матчатся все элементы, ::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;",
"}"
Comment on lines +165 to +171
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

отключаем анимации, транзишены и делаем "грубый" скролл

].join("\n");

root.appendChild(styleElement);
styleElements.push(styleElement);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

В этом массиве хранятся style-элементы, которые удаляются из дерева при последующем вызове window.__cleanupAnimation

});

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) {
Expand Down
16 changes: 16 additions & 0 deletions src/browser/client-scripts/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
16 changes: 14 additions & 2 deletions src/browser/commands/assert-view/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -27,6 +35,7 @@ module.exports = browser => {
screenshotDelay,
tolerance,
antialiasingTolerance,
disableAnimation,
});

const { hermioneCtx } = session.executionContext;
Expand All @@ -44,6 +53,7 @@ module.exports = browser => {
allowViewportOverflow: opts.allowViewportOverflow,
captureElementFromTop: opts.captureElementFromTop,
selectorToScroll: opts.selectorToScroll,
disableAnimation: opts.disableAnimation,
});

const { tempOpts } = RuntimeConfig.getInstance();
Expand All @@ -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));
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prepareScreenshot имеет сайд-эффекты в виде добавленных в DOM-дерево элементов. Тут эти сайд-эффекты чистим

const currSize = await currImgInst.getSize();
const currImg = { path: temp.path(Object.assign(tempOpts, { suffix: ".png" })), size: currSize };

Expand Down
10 changes: 10 additions & 0 deletions src/browser/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@ export interface AssertViewOpts extends Partial<AssertViewOptsConfig> {
* @defaultValue `undefined`
*/
selectorToScroll?: string;
/**
* 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 `true`
*/
disableAnimation?: boolean;
}

export type AssertViewCommand = (state: string, selectors: string | string[], opts?: AssertViewOpts) => Promise<void>;
Expand Down
57 changes: 57 additions & 0 deletions src/browser/existing-browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,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();
}
Comment on lines +87 to +90
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

В webdriver можем отключить анимацию во всех айфреймах. В devtools они отключаются только в текущем фрейме
Отключение анимации для родительского фрейма происходит в самом prepareScreenshot


return result;
}

async cleanupScreenshot(opts = {}) {
if (opts.disableAnimation) {
await this._cleanupPageAnimations();
}
}

open(url) {
return this._session.url(url);
}
Expand Down Expand Up @@ -285,6 +297,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() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Я бы для этого метода и _disableIframeAnimations написал хелпер. Они же отличаются только вызовом одной функции, а все остальное в них одинаковое.

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") {
Expand Down
2 changes: 2 additions & 0 deletions src/config/browser-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ function buildBrowserOptions(defaultFactory, extra) {
validate: value => utils.assertNonNegativeNumber(value, "antialiasingTolerance"),
}),

disableAnimation: options.boolean("disableAnimation"),

compareOpts: options.optionalObject("compareOpts"),

buildDiffOpts: options.optionalObject("buildDiffOpts"),
Expand Down
1 change: 1 addition & 0 deletions src/config/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ module.exports = {
diffColor: "#ff00ff",
tolerance: 2.3,
antialiasingTolerance: 4,
disableAnimation: true,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

breaking change

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Для hermione@7 нужно выставить в false

compareOpts: {
shouldCluster: false,
clustersSize: 10,
Expand Down
103 changes: 95 additions & 8 deletions test/src/browser/existing-browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading
Loading