From ea7ef328e7088c52d68880784c28eb80582d884d Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Thu, 8 Sep 2022 09:50:08 -0700 Subject: [PATCH] fix: inject caret-hiding style in every shadow tree (#16907) Style inheritance disregards selector specificity, so we can't dominate local shadow dom styles. To mitigate this, we inject the style tag with caret-hiding style in every shadowDom tree. Fixes #16732 --- .../src/server/screenshotter.ts | 170 ++++++++++-------- tests/page/page-screenshot.spec.ts | 1 - 2 files changed, 92 insertions(+), 79 deletions(-) diff --git a/packages/playwright-core/src/server/screenshotter.ts b/packages/playwright-core/src/server/screenshotter.ts index 66eebc3d82..a2f9971fa7 100644 --- a/packages/playwright-core/src/server/screenshotter.ts +++ b/packages/playwright-core/src/server/screenshotter.ts @@ -44,6 +44,97 @@ export type ScreenshotOptions = { caret?: 'hide' | 'initial', }; +function inPagePrepareForScreenshots(hideCaret: boolean, disableAnimations: boolean) { + 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; + }; + + let documentRoots: (Document|ShadowRoot)[] | undefined; + const memoizedRoots = () => documentRoots ??= collectRoots(document); + + const styleTags: Element[] = []; + if (hideCaret) { + for (const root of memoizedRoots()) { + const styleTag = document.createElement('style'); + styleTag.textContent = ` + *:not(#playwright-aaaaaaaaaa.playwright-bbbbbbbbbbb.playwright-cccccccccc.playwright-dddddddddd.playwright-eeeeeeeee) { + caret-color: transparent !important; + } + `; + if (root === document) + document.documentElement.append(styleTag); + else + root.append(styleTag); + styleTags.push(styleTag); + } + } + const infiniteAnimationsToResume: Set = new Set(); + const cleanupCallbacks: (() => void)[] = []; + + if (disableAnimations) { + 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 memoizedRoots()) { + 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 = () => { + for (const styleTag of styleTags) + 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; + }; +} + export class Screenshotter { private _queue = new TaskQueue(); private _page: Page; @@ -146,84 +237,7 @@ export class Screenshotter { if (disableAnimations) progress.log(' disabled all CSS animations'); await Promise.all(this._page.frames().map(async frame => { - await frame.nonStallingEvaluateInExistingContext('(' + (async function(hideCaret: boolean, disableAnimations: boolean) { - const styleTag = document.createElement('style'); - if (hideCaret) { - styleTag.textContent = ` - *:not(#playwright-aaaaaaaaaa.playwright-bbbbbbbbbbb.playwright-cccccccccc.playwright-dddddddddd.playwright-eeeeeeeee) { - 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() + `)(${hideCaret}, ${disableAnimations})`, false, 'utility').catch(() => {}); + await frame.nonStallingEvaluateInExistingContext('(' + inPagePrepareForScreenshots.toString() + `)(${hideCaret}, ${disableAnimations})`, false, 'utility').catch(() => {}); })); progress.cleanupWhenAborted(() => this._restorePageAfterScreenshot()); } diff --git a/tests/page/page-screenshot.spec.ts b/tests/page/page-screenshot.spec.ts index 6653db6973..47cde60ff5 100644 --- a/tests/page/page-screenshot.spec.ts +++ b/tests/page/page-screenshot.spec.ts @@ -91,7 +91,6 @@ it.describe('page screenshot', () => { it('should capture blinking caret in shadow dom', async ({ page, browserName }) => { it.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/16732' }); - it.fixme(browserName !== 'firefox'); await page.addScriptTag({ content: ` class CustomElementContainer extends HTMLElement {