feat: introduce disableAnimations option for screenshots (#11870)

This option stops all kinds of CSS animations while doing screenshot:
- CSS animations
- CSS transitions
- Web Animations

Animations get different treatment depending on animation duration:
- finite animations are fast-forwarded to its end, issuing the
  `transitionend` event.
- Infinite animations are resetted to its beginning, and then
  resumed after the screenshot.

References #9938, fixes #11912
This commit is contained in:
Andrey Lushnikov 2022-02-09 13:52:11 -07:00 committed by GitHub
parent 48cc41f3e7
commit 6f87955243
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 533 additions and 29 deletions

View File

@ -642,6 +642,14 @@ The quality of the image, between 0-100. Not applicable to `png` images.
Hides default white background and allows capturing screenshots with transparency. Not applicable to `jpeg` images.
Defaults to `false`.
### option: ElementHandle.screenshot.disableAnimations
- `disableAnimations` <[boolean]>
When true, stops CSS animations, CSS transitions and Web Animations. Animations get different treatment depending on their duration:
- finite animations are fast-forwarded to completion, so they'll fire `transitionend` event.
- infinite animations are canceled to initial state, and then played over after the screenshot.
### option: ElementHandle.screenshot.timeout = %%-input-timeout-%%
## async method: ElementHandle.scrollIntoViewIfNeeded

View File

@ -620,6 +620,13 @@ The quality of the image, between 0-100. Not applicable to `png` images.
Hides default white background and allows capturing screenshots with transparency. Not applicable to `jpeg` images.
Defaults to `false`.
### option: Locator.screenshot.disableAnimations
- `disableAnimations` <[boolean]>
When true, stops CSS animations, CSS transitions and Web Animations. Animations get different treatment depending on their duration:
- finite animations are fast-forwarded to completion, so they'll fire `transitionend` event.
- infinite animations are canceled to initial state, and then played over after the screenshot.
### option: Locator.screenshot.timeout = %%-input-timeout-%%
## async method: Locator.scrollIntoViewIfNeeded

View File

@ -2668,6 +2668,13 @@ The quality of the image, between 0-100. Not applicable to `png` images.
When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Defaults to
`false`.
### option: Page.screenshot.disableAnimations
- `disableAnimations` <[boolean]>
When true, stops CSS animations, CSS transitions and Web Animations. Animations get different treatment depending on their duration:
- finite animations are fast-forwarded to completion, so they'll fire `transitionend` event.
- infinite animations are canceled to initial state, and then played over after the screenshot.
### option: Page.screenshot.clip
- `clip` <[Object]>
- `x` <[float]> x-coordinate of top-left corner of clip area

View File

@ -1475,6 +1475,7 @@ export type PageScreenshotParams = {
quality?: number,
omitBackground?: boolean,
fullPage?: boolean,
disableAnimations?: boolean,
clip?: Rect,
};
export type PageScreenshotOptions = {
@ -1483,6 +1484,7 @@ export type PageScreenshotOptions = {
quality?: number,
omitBackground?: boolean,
fullPage?: boolean,
disableAnimations?: boolean,
clip?: Rect,
};
export type PageScreenshotResult = {
@ -2778,12 +2780,14 @@ export type ElementHandleScreenshotParams = {
type?: 'png' | 'jpeg',
quality?: number,
omitBackground?: boolean,
disableAnimations?: boolean,
};
export type ElementHandleScreenshotOptions = {
timeout?: number,
type?: 'png' | 'jpeg',
quality?: number,
omitBackground?: boolean,
disableAnimations?: boolean,
};
export type ElementHandleScreenshotResult = {
binary: Binary,

View File

@ -996,6 +996,7 @@ Page:
quality: number?
omitBackground: boolean?
fullPage: boolean?
disableAnimations: boolean?
clip: Rect?
returns:
binary: binary
@ -2148,6 +2149,7 @@ ElementHandle:
- jpeg
quality: number?
omitBackground: boolean?
disableAnimations: boolean?
returns:
binary: binary

View File

@ -545,6 +545,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
quality: tOptional(tNumber),
omitBackground: tOptional(tBoolean),
fullPage: tOptional(tBoolean),
disableAnimations: tOptional(tBoolean),
clip: tOptional(tType('Rect')),
});
scheme.PageSetExtraHTTPHeadersParams = tObject({
@ -1035,6 +1036,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
type: tOptional(tEnum(['png', 'jpeg'])),
quality: tOptional(tNumber),
omitBackground: tOptional(tBoolean),
disableAnimations: tOptional(tBoolean),
});
scheme.ElementHandleScrollIntoViewIfNeededParams = tObject({
timeout: tOptional(tNumber),

View File

@ -69,6 +69,8 @@ export class Screenshotter {
const format = validateScreenshotOptions(options);
return this._queue.postTask(async () => {
const { viewportSize } = await this._originalViewportSize(progress);
await this._preparePageForScreenshot(progress, options.disableAnimations || false);
progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
if (options.fullPage) {
const fullPageSize = await this._fullPageSize(progress);
@ -78,11 +80,15 @@ export class Screenshotter {
documentRect = trimClipToSize(options.clip, documentRect);
const buffer = await this._screenshot(progress, format, documentRect, undefined, fitsViewport, options);
progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
await this._restorePageAfterScreenshot();
return buffer;
}
const viewportRect = options.clip ? trimClipToSize(options.clip, viewportSize) : { x: 0, y: 0, ...viewportSize };
return await this._screenshot(progress, format, undefined, viewportRect, true, options);
const buffer = await this._screenshot(progress, format, undefined, viewportRect, true, options);
progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
await this._restorePageAfterScreenshot();
return buffer;
});
}
@ -91,6 +97,9 @@ export class Screenshotter {
return this._queue.postTask(async () => {
const { viewportSize } = await this._originalViewportSize(progress);
await this._preparePageForScreenshot(progress, options.disableAnimations || false);
progress.throwIfAborted(); // Do not do extra work.
await handle._waitAndScrollIntoViewIfNeeded(progress);
progress.throwIfAborted(); // Do not do extra work.
@ -107,10 +116,100 @@ export class Screenshotter {
documentRect.y += scrollOffset.y;
const buffer = await this._screenshot(progress, format, helper.enclosingIntRect(documentRect), undefined, fitsViewport, options);
progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
await this._restorePageAfterScreenshot();
return buffer;
});
}
async _preparePageForScreenshot(progress: Progress, disableAnimations: boolean) {
await Promise.all(this._page.frames().map(async frame => {
await frame.nonStallingEvaluateInExistingContext('(' + (function(disableAnimations: boolean) {
const styleTag = document.createElement('style');
styleTag.textContent = `
*,
* > *,
* > * > *,
* > * > * > *,
* > * > * > * > * { caret-color: transparent !important; }
`;
document.documentElement.append(styleTag);
const infiniteAnimationsToResume: Set<Animation> = new Set();
const cleanupCallbacks: (() => void)[] = [];
if (disableAnimations) {
const collectRoots = (root: Document | ShadowRoot, roots: (Document|ShadowRoot)[] = []): (Document|ShadowRoot)[] => {
roots.push(root);
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
do {
const node = walker.currentNode;
const shadowRoot = node instanceof Element ? node.shadowRoot : null;
if (shadowRoot)
collectRoots(shadowRoot, roots);
} while (walker.nextNode());
return roots;
};
const handleAnimations = (root: Document|ShadowRoot): void => {
for (const animation of root.getAnimations()) {
if (!animation.effect || animation.playbackRate === 0 || infiniteAnimationsToResume.has(animation))
continue;
const endTime = animation.effect.getComputedTiming().endTime;
if (Number.isFinite(endTime)) {
try {
animation.finish();
} catch (e) {
// animation.finish() should not throw for
// finite animations, but we'd like to be on the
// safe side.
}
} else {
try {
animation.cancel();
infiniteAnimationsToResume.add(animation);
} catch (e) {
// animation.cancel() should not throw for
// infinite animations, but we'd like to be on the
// safe side.
}
}
}
};
for (const root of collectRoots(document)) {
const handleRootAnimations: (() => void) = handleAnimations.bind(null, root);
handleRootAnimations();
root.addEventListener('transitionrun', handleRootAnimations);
root.addEventListener('animationstart', handleRootAnimations);
cleanupCallbacks.push(() => {
root.removeEventListener('transitionrun', handleRootAnimations);
root.removeEventListener('animationstart', handleRootAnimations);
});
}
}
window.__cleanupScreenshot = () => {
styleTag.remove();
for (const animation of infiniteAnimationsToResume) {
try {
animation.play();
} catch (e) {
// animation.play() should never throw, but
// we'd like to be on the safe side.
}
}
for (const cleanupCallback of cleanupCallbacks)
cleanupCallback();
delete window.__cleanupScreenshot;
};
}).toString() + `)(${disableAnimations || false})`, false, 'utility').catch(() => {});
}));
progress.cleanupWhenAborted(() => this._restorePageAfterScreenshot());
}
async _restorePageAfterScreenshot() {
await Promise.all(this._page.frames().map(async frame => {
frame.nonStallingEvaluateInExistingContext('window.__cleanupScreenshot && window.__cleanupScreenshot()', false, 'utility').catch(() => {});
}));
}
private async _screenshot(progress: Progress, format: 'png' | 'jpeg', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, fitsViewport: boolean | undefined, options: types.ElementScreenshotOptions): Promise<Buffer> {
if ((options as any).__testHookBeforeScreenshot)
await (options as any).__testHookBeforeScreenshot();
@ -122,35 +221,8 @@ export class Screenshotter {
}
progress.throwIfAborted(); // Avoid extra work.
const restoreBlinkingCaret = async () => {
await Promise.all(this._page.frames().map(async frame => {
frame.nonStallingEvaluateInExistingContext('window.__cleanupScreenshot && window.__cleanupScreenshot()', false, 'utility').catch(() => {});
}));
};
await Promise.all(this._page.frames().map(async frame => {
await frame.nonStallingEvaluateInExistingContext((function() {
const styleTag = document.createElement('style');
styleTag.textContent = `
* { caret-color: transparent !important; }
* > * { caret-color: transparent !important; }
* > * > * { caret-color: transparent !important; }
* > * > * > * { caret-color: transparent !important; }
* > * > * > * > * { caret-color: transparent !important; }
`;
document.documentElement.append(styleTag);
window.__cleanupScreenshot = () => {
styleTag.remove();
delete window.__cleanupScreenshot;
};
}).toString(), true, 'utility').catch(() => {});
}));
progress.cleanupWhenAborted(() => restoreBlinkingCaret());
progress.throwIfAborted(); // Avoid extra work.
const buffer = await this._page._delegate.takeScreenshot(progress, format, documentRect, viewportRect, options.quality, fitsViewport);
progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
await restoreBlinkingCaret();
progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
if (shouldSetDefaultBackground)
await this._page._delegate.setBackgroundColor();
progress.throwIfAborted(); // Avoid side effects.

View File

@ -51,6 +51,7 @@ export type ElementScreenshotOptions = TimeoutOptions & {
type?: 'png' | 'jpeg',
quality?: number,
omitBackground?: boolean,
disableAnimations?: boolean,
};
export type ScreenshotOptions = ElementScreenshotOptions & {

View File

@ -8064,6 +8064,12 @@ export interface ElementHandle<T=Node> extends JSHandle<T> {
* @param options
*/
screenshot(options?: {
/**
* When true, stops CSS animations, CSS transitions and Web Animations. Animations get different treatment depending on
* their duration:
*/
disableAnimations?: boolean;
/**
* Hides default white background and allows capturing screenshots with transparency. Not applicable to `jpeg` images.
* Defaults to `false`.
@ -9361,6 +9367,12 @@ export interface Locator {
* @param options
*/
screenshot(options?: {
/**
* When true, stops CSS animations, CSS transitions and Web Animations. Animations get different treatment depending on
* their duration:
*/
disableAnimations?: boolean;
/**
* Hides default white background and allows capturing screenshots with transparency. Not applicable to `jpeg` images.
* Defaults to `false`.
@ -15698,6 +15710,12 @@ export interface PageScreenshotOptions {
height: number;
};
/**
* When true, stops CSS animations, CSS transitions and Web Animations. Animations get different treatment depending on
* their duration:
*/
disableAnimations?: boolean;
/**
* When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Defaults to
* `false`.

View File

@ -0,0 +1,21 @@
<!DOCTYPE HTML>
<style>
div {
position: absolute;
top: 0;
left: 0;
width: 200px;
height: 100px;
background-color: red;
transition: all 10s;
}
.transition {
transform: rotate(360deg);
}
</style>
<div></div>
<script>
window.addEventListener('load', () => {
document.querySelector('div').classList.add('transition');
}, false);
</script>

View File

@ -0,0 +1,32 @@
<html>
<head>
<style type="text/css">
div::after {
position: absolute;
content: " ";
top: 0;
left: 0;
width: 200px;
height: 100px;
background-color: red;
animation-name: z-spin;
animation-duration: 5s;
animation-iteration-count: infinite;
animation-timing-function: linear;
}
div {
position: absolute;
width: 10px;
height: 10px;
}
@keyframes z-spin {
0% { transform: rotateZ(0deg); }
100% { transform: rotateZ(360deg); }
}
</style>
</head>
<body >
<div class="square"></div>
</body>
</html>

View File

@ -0,0 +1,31 @@
<html>
<head></head>
<body></body>
<script>
window.addEventListener('DOMContentLoaded', () => {
const shadow = document.body.attachShadow({mode: 'open'});
shadow.append(document.createElement('div'));
const style = document.createElement('style');
style.textContent = `
div {
position: absolute;
top: 0;
left: 0;
width: 200px;
height: 100px;
background-color: red;
animation-name: z-spin;
animation-duration: 5s;
animation-iteration-count: infinite;
animation-timing-function: linear;
}
@keyframes z-spin {
0% { transform: rotateZ(0deg); }
100% { transform: rotateZ(360deg); }
}
`;
shadow.append(style);
}, false);
</script>
</html>

View File

@ -13,7 +13,7 @@
animation-iteration-count: infinite;
animation-timing-function: linear;
}
@keyframes z-spin {
0% { transform: rotateZ(0deg); }
100% { transform: rotateZ(360deg); }

View File

@ -0,0 +1,23 @@
<!DOCTYPE HTML>
<style>
div {
position: absolute;
top: 0;
left: 0;
width: 200px;
height: 100px;
background-color: red;
}
</style>
<div></div>
<script>
document.querySelector('div').animate(
[
{ transform: 'rotate(0deg)' },
{ transform: 'rotate(360deg)' }
], {
duration: 3000,
iterations: Infinity
}
);
</script>

View File

@ -339,3 +339,279 @@ it.describe('page screenshot', () => {
]);
});
});
async function rafraf(page) {
// Do a double raf since single raf does not
// actually guarantee a new animation frame.
await page.evaluate(() => new Promise(x => {
requestAnimationFrame(() => requestAnimationFrame(x));
}));
}
declare global {
interface Window {
animation?: Animation;
_EVENTS?: string[];
}
}
it.describe('page screenshot animations', () => {
it('should not capture infinite css animation', async ({ page, server }) => {
await page.goto(server.PREFIX + '/rotate-z.html');
const div = page.locator('div');
const screenshot = await div.screenshot({
disableAnimations: true,
});
for (let i = 0; i < 10; ++i) {
await rafraf(page);
const newScreenshot = await div.screenshot({
disableAnimations: true,
});
expect(newScreenshot.equals(screenshot)).toBe(true);
}
});
it('should not capture pseudo element css animation', async ({ page, server }) => {
await page.goto(server.PREFIX + '/rotate-pseudo.html');
const div = page.locator('div');
const screenshot = await div.screenshot({
disableAnimations: true,
});
for (let i = 0; i < 10; ++i) {
await rafraf(page);
const newScreenshot = await div.screenshot({
disableAnimations: true,
});
expect(newScreenshot.equals(screenshot)).toBe(true);
}
});
it('should not capture css animations in shadow DOM', async ({ page, server }) => {
await page.goto(server.PREFIX + '/rotate-z-shadow-dom.html');
const screenshot = await page.screenshot({
disableAnimations: true,
});
for (let i = 0; i < 4; ++i) {
await rafraf(page);
const newScreenshot = await page.screenshot({
disableAnimations: true,
});
expect(newScreenshot.equals(screenshot)).toBe(true);
}
});
it('should stop animations that happen right before screenshot', async ({ page, server, mode }) => {
it.skip(mode !== 'default');
await page.goto(server.PREFIX + '/rotate-z.html');
// Stop rotating bar.
await page.$eval('div', el => el.style.setProperty('animation', 'none'));
const buffer1 = await page.screenshot({
disableAnimations: true,
// Start rotating bar right before screenshot.
__testHookBeforeScreenshot: async () => {
await page.$eval('div', el => el.style.removeProperty('animation'));
},
} as any);
await rafraf(page);
const buffer2 = await page.screenshot({
disableAnimations: true,
});
expect(buffer1.equals(buffer2)).toBe(true);
});
it('should resume infinite animations', async ({ page, server }) => {
await page.goto(server.PREFIX + '/rotate-z.html');
await page.screenshot({
disableAnimations: true,
});
const buffer1 = await page.screenshot();
await rafraf(page);
const buffer2 = await page.screenshot();
expect(buffer1.equals(buffer2)).toBe(false);
});
it('should not capture infinite web animations', async ({ page, server }) => {
await page.goto(server.PREFIX + '/web-animation.html');
const div = page.locator('div');
const screenshot = await div.screenshot({
disableAnimations: true,
});
for (let i = 0; i < 10; ++i) {
await rafraf(page);
const newScreenshot = await div.screenshot({
disableAnimations: true,
});
expect(newScreenshot.equals(screenshot)).toBe(true);
}
// Should resume infinite web animation.
const buffer1 = await page.screenshot();
await rafraf(page);
const buffer2 = await page.screenshot();
expect(buffer1.equals(buffer2)).toBe(false);
});
it('should fire transitionend for finite transitions', async ({ page, server }) => {
await page.goto(server.PREFIX + '/css-transition.html');
const div = page.locator('div');
await div.evaluate(el => {
el.addEventListener('transitionend', () => window['__TRANSITION_END'] = true, false);
});
await it.step('make sure transition is actually running', async () => {
const screenshot1 = await page.screenshot();
await rafraf(page);
const screenshot2 = await page.screenshot();
expect(screenshot1.equals(screenshot2)).toBe(false);
});
// Make a screenshot that finishes all finite animations.
const screenshot1 = await div.screenshot({
disableAnimations: true,
});
await rafraf(page);
// Make sure finite transition is not restarted.
const screenshot2 = await div.screenshot();
expect(screenshot1.equals(screenshot2)).toBe(true);
expect(await page.evaluate(() => window['__TRANSITION_END'])).toBe(true);
});
it('should capture screenshots after layoutchanges in transitionend event', async ({ page, server }) => {
await page.goto(server.PREFIX + '/css-transition.html');
const div = page.locator('div');
await div.evaluate(el => {
el.addEventListener('transitionend', () => {
const time = Date.now();
// Block main thread for 200ms, emulating heavy layout.
while (Date.now() - time < 200) ;
const h1 = document.createElement('h1');
h1.textContent = 'woof-woof';
document.body.append(h1);
}, false);
});
await it.step('make sure transition is actually running', async () => {
const screenshot1 = await page.screenshot();
await rafraf(page);
const screenshot2 = await page.screenshot();
expect(screenshot1.equals(screenshot2)).toBe(false);
});
// 1. Make a screenshot that finishes all finite animations
// and triggers layout.
const screenshot1 = await page.screenshot({
disableAnimations: true,
});
// 2. Make a second screenshot after h1 is on screen.
await expect(page.locator('h1')).toBeVisible();
await expect(page.locator('h1')).toHaveText('woof-woof');
const screenshot2 = await page.screenshot();
// 3. Make sure both screenshots are equal, meaning that
// first screenshot actually was taken after transitionend
// changed layout.
expect(screenshot1.equals(screenshot2)).toBe(true);
});
it('should not change animation with playbackRate equal to 0', async ({ page, server }) => {
await page.goto(server.PREFIX + '/rotate-z.html');
await page.evaluate(async () => {
window.animation = document.getAnimations()[0];
window.animation.updatePlaybackRate(0);
await window.animation.ready;
window.animation.currentTime = 500;
});
const screenshot1 = await page.screenshot({
disableAnimations: true,
});
await rafraf(page);
const screenshot2 = await page.screenshot({
disableAnimations: true,
});
expect(screenshot1.equals(screenshot2)).toBe(true);
expect(await page.evaluate(() => ({
playbackRate: window.animation.playbackRate,
currentTime: window.animation.currentTime,
}))).toEqual({
playbackRate: 0,
currentTime: 500,
});
});
it('should trigger particular events for css transitions', async ({ page, server }) => {
await page.goto(server.PREFIX + '/css-transition.html');
const div = page.locator('div');
await div.evaluate(async el => {
window._EVENTS = [];
el.addEventListener('transitionend', () => {
window._EVENTS.push('transitionend');
console.log('transitionend');
}, false);
const animation = el.getAnimations()[0];
animation.oncancel = () => window._EVENTS.push('oncancel');
animation.onfinish = () => window._EVENTS.push('onfinish');
animation.onremove = () => window._EVENTS.push('onremove');
await animation.ready;
});
await Promise.all([
page.screenshot({ disableAnimations: true }),
page.waitForEvent('console', msg => msg.text() === 'transitionend'),
]);
expect(await page.evaluate(() => window._EVENTS)).toEqual([
'onfinish', 'transitionend'
]);
});
it('should trigger particular events for INfinite css animation', async ({ page, server }) => {
await page.goto(server.PREFIX + '/rotate-z.html');
const div = page.locator('div');
await div.evaluate(async el => {
window._EVENTS = [];
el.addEventListener('animationcancel', () => {
window._EVENTS.push('animationcancel');
console.log('animationcancel');
}, false);
const animation = el.getAnimations()[0];
animation.oncancel = () => window._EVENTS.push('oncancel');
animation.onfinish = () => window._EVENTS.push('onfinish');
animation.onremove = () => window._EVENTS.push('onremove');
await animation.ready;
});
await Promise.all([
page.screenshot({ disableAnimations: true }),
page.waitForEvent('console', msg => msg.text() === 'animationcancel'),
]);
expect(await page.evaluate(() => window._EVENTS)).toEqual([
'oncancel', 'animationcancel'
]);
});
it('should trigger particular events for finite css animation', async ({ page, server }) => {
await page.goto(server.PREFIX + '/rotate-z.html');
const div = page.locator('div');
await div.evaluate(async el => {
window._EVENTS = [];
// Make CSS animation to be finite.
el.style.setProperty('animation-iteration-count', '1000');
el.addEventListener('animationend', () => {
window._EVENTS.push('animationend');
console.log('animationend');
}, false);
const animation = el.getAnimations()[0];
animation.oncancel = () => window._EVENTS.push('oncancel');
animation.onfinish = () => window._EVENTS.push('onfinish');
animation.onremove = () => window._EVENTS.push('onremove');
await animation.ready;
});
// Ensure CSS animation is finite.
expect(await div.evaluate(async el => Number.isFinite(el.getAnimations()[0].effect.getComputedTiming().endTime))).toBe(true);
await Promise.all([
page.screenshot({ disableAnimations: true }),
page.waitForEvent('console', msg => msg.text() === 'animationend'),
]);
expect(await page.evaluate(() => window._EVENTS)).toEqual([
'onfinish', 'animationend'
]);
});
});