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
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
feat: add ability to disable animations in assertView
BREAKING CHANGE: assertView now disables animations by default
  • Loading branch information
KuznetsovRoman committed Oct 19, 2023
commit fc4dbe7e40d19c978c1510f71eb2ac7fb02bbd67
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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).

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

10 changes: 10 additions & 0 deletions src/browser/commands/types.ts
Original file line number Diff line number Diff line change
@@ -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>;
57 changes: 57 additions & 0 deletions src/browser/existing-browser.js
Original file line number Diff line number Diff line change
@@ -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);
}
@@ -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") {
2 changes: 2 additions & 0 deletions src/config/browser-options.js
Original file line number Diff line number Diff line change
@@ -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"),
1 change: 1 addition & 0 deletions src/config/defaults.js
Original file line number Diff line number Diff line change
@@ -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,
103 changes: 95 additions & 8 deletions test/src/browser/existing-browser.js
Original file line number Diff line number Diff line change
@@ -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", () => {
Loading
Loading