diff --git a/docs/src/api/class-elementhandle.md b/docs/src/api/class-elementhandle.md index 9ac9946eab..6291bb5809 100644 --- a/docs/src/api/class-elementhandle.md +++ b/docs/src/api/class-elementhandle.md @@ -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 diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 0ff10a88b9..4ebef599a6 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -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 diff --git a/docs/src/api/class-page.md b/docs/src/api/class-page.md index 04a1221744..4e8e94fcdf 100644 --- a/docs/src/api/class-page.md +++ b/docs/src/api/class-page.md @@ -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 diff --git a/packages/playwright-core/src/protocol/channels.ts b/packages/playwright-core/src/protocol/channels.ts index 9b5068d797..fee13995ec 100644 --- a/packages/playwright-core/src/protocol/channels.ts +++ b/packages/playwright-core/src/protocol/channels.ts @@ -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, diff --git a/packages/playwright-core/src/protocol/protocol.yml b/packages/playwright-core/src/protocol/protocol.yml index 6266515d39..16cc70bc96 100644 --- a/packages/playwright-core/src/protocol/protocol.yml +++ b/packages/playwright-core/src/protocol/protocol.yml @@ -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 diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 4e52219081..645883f2bd 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -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), diff --git a/packages/playwright-core/src/server/screenshotter.ts b/packages/playwright-core/src/server/screenshotter.ts index 920a697d6d..3606217c6d 100644 --- a/packages/playwright-core/src/server/screenshotter.ts +++ b/packages/playwright-core/src/server/screenshotter.ts @@ -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 = 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 { 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. diff --git a/packages/playwright-core/src/server/types.ts b/packages/playwright-core/src/server/types.ts index 5715e19bda..f3c4baae96 100644 --- a/packages/playwright-core/src/server/types.ts +++ b/packages/playwright-core/src/server/types.ts @@ -51,6 +51,7 @@ export type ElementScreenshotOptions = TimeoutOptions & { type?: 'png' | 'jpeg', quality?: number, omitBackground?: boolean, + disableAnimations?: boolean, }; export type ScreenshotOptions = ElementScreenshotOptions & { diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 2fa0b5cf14..fe881dcabc 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -8064,6 +8064,12 @@ export interface ElementHandle extends JSHandle { * @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`. diff --git a/tests/assets/css-transition.html b/tests/assets/css-transition.html new file mode 100644 index 0000000000..449c1548df --- /dev/null +++ b/tests/assets/css-transition.html @@ -0,0 +1,21 @@ + + +
+ diff --git a/tests/assets/rotate-pseudo.html b/tests/assets/rotate-pseudo.html new file mode 100644 index 0000000000..dca33ecdea --- /dev/null +++ b/tests/assets/rotate-pseudo.html @@ -0,0 +1,32 @@ + + + + + +
+ + diff --git a/tests/assets/rotate-z-shadow-dom.html b/tests/assets/rotate-z-shadow-dom.html new file mode 100644 index 0000000000..0a6e3f723e --- /dev/null +++ b/tests/assets/rotate-z-shadow-dom.html @@ -0,0 +1,31 @@ + + + + + diff --git a/tests/assets/rotate-z.html b/tests/assets/rotate-z.html index 9a272dc807..5397789419 100644 --- a/tests/assets/rotate-z.html +++ b/tests/assets/rotate-z.html @@ -13,7 +13,7 @@ animation-iteration-count: infinite; animation-timing-function: linear; } - + @keyframes z-spin { 0% { transform: rotateZ(0deg); } 100% { transform: rotateZ(360deg); } diff --git a/tests/assets/web-animation.html b/tests/assets/web-animation.html new file mode 100644 index 0000000000..ea7a5df71b --- /dev/null +++ b/tests/assets/web-animation.html @@ -0,0 +1,23 @@ + + +
+ diff --git a/tests/page/page-screenshot.spec.ts b/tests/page/page-screenshot.spec.ts index 27a46c4c08..2ad6d564f9 100644 --- a/tests/page/page-screenshot.spec.ts +++ b/tests/page/page-screenshot.spec.ts @@ -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' + ]); + }); +});