From 10a82f862c0c9b37ad8a35df55a1da42e3ccd3d0 Mon Sep 17 00:00:00 2001 From: Joel Einbinder Date: Thu, 17 Jun 2021 09:41:29 -0700 Subject: [PATCH] fix(snapshotter): support constructed CSSStyleSheet Fixes #7085 --- src/server/snapshot/snapshotRenderer.ts | 16 ++++++- src/server/snapshot/snapshotterInjected.ts | 34 +++++++++++++++ tests/snapshotter.spec.ts | 51 ++++++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/server/snapshot/snapshotRenderer.ts b/src/server/snapshot/snapshotRenderer.ts index 30d18d82ec1a6..d6d9997201950 100644 --- a/src/server/snapshot/snapshotRenderer.ts +++ b/src/server/snapshot/snapshotRenderer.ts @@ -124,7 +124,7 @@ function snapshotNodes(snapshot: FrameSnapshot): NodeSnapshot[] { } function snapshotScript() { - function applyPlaywrightAttributes(shadowAttribute: string, scrollTopAttribute: string, scrollLeftAttribute: string) { + function applyPlaywrightAttributes(shadowAttribute: string, scrollTopAttribute: string, scrollLeftAttribute: string, styleSheetAttribute: string) { const scrollTops: Element[] = []; const scrollLefts: Element[] = []; @@ -152,6 +152,17 @@ function snapshotScript() { template.remove(); visit(shadowRoot); } + + if ('adoptedStyleSheets' in (root as any)) { + const adoptedSheets: CSSStyleSheet[] = [...(root as any).adoptedStyleSheets]; + for (const element of root.querySelectorAll(`template[${styleSheetAttribute}]`)) { + const template = element as HTMLTemplateElement; + const sheet = new CSSStyleSheet(); + (sheet as any).replaceSync(template.getAttribute(styleSheetAttribute)); + adoptedSheets.push(sheet); + } + (root as any).adoptedStyleSheets = adoptedSheets; + } }; visit(document); @@ -172,5 +183,6 @@ function snapshotScript() { const kShadowAttribute = '__playwright_shadow_root_'; const kScrollTopAttribute = '__playwright_scroll_top_'; const kScrollLeftAttribute = '__playwright_scroll_left_'; - return `\n(${applyPlaywrightAttributes.toString()})('${kShadowAttribute}', '${kScrollTopAttribute}', '${kScrollLeftAttribute}')`; + const kStyleSheetAttribute = '__playwright_style_sheet_'; + return `\n(${applyPlaywrightAttributes.toString()})('${kShadowAttribute}', '${kScrollTopAttribute}', '${kScrollLeftAttribute}', '${kStyleSheetAttribute}')`; } diff --git a/src/server/snapshot/snapshotterInjected.ts b/src/server/snapshot/snapshotterInjected.ts index 05e4178053404..50823160dd120 100644 --- a/src/server/snapshot/snapshotterInjected.ts +++ b/src/server/snapshot/snapshotterInjected.ts @@ -39,6 +39,7 @@ export function frameSnapshotStreamer(snapshotStreamer: string) { const kShadowAttribute = '__playwright_shadow_root_'; const kScrollTopAttribute = '__playwright_scroll_top_'; const kScrollLeftAttribute = '__playwright_scroll_left_'; + const kStyleSheetAttribute = '__playwright_style_sheet_'; // Symbols for our own info on Nodes/StyleSheets. const kSnapshotFrameId = Symbol('__playwright_snapshot_frameid_'); @@ -296,6 +297,15 @@ export function frameSnapshotStreamer(snapshotStreamer: string) { } }; + const visitChildStyleSheet = (child: CSSStyleSheet) => { + const snapshot = visitStyleSheet(child); + if (snapshot) { + result.push(snapshot.n); + expectValue(child); + equals = equals && snapshot.equals; + } + }; + if (nodeType === Node.DOCUMENT_FRAGMENT_NODE) attrs[kShadowAttribute] = 'open'; @@ -345,6 +355,15 @@ export function frameSnapshotStreamer(snapshotStreamer: string) { } for (let child = node.firstChild; child; child = child.nextSibling) visitChild(child); + let documentOrShadowRoot = null; + if (node.ownerDocument!.documentElement === node) + documentOrShadowRoot = node.ownerDocument; + else if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) + documentOrShadowRoot = node; + if (documentOrShadowRoot) { + for (const sheet of (documentOrShadowRoot as any).adoptedStyleSheets || []) + visitChildStyleSheet(sheet); + } } // Process iframe src attribute before bailing out since it depends on a symbol, not the DOM. @@ -397,6 +416,21 @@ export function frameSnapshotStreamer(snapshotStreamer: string) { return checkAndReturn(result); }; + const visitStyleSheet = (sheet: CSSStyleSheet) => { + const data = ensureCachedData(sheet); + const oldCSSText = data.cssText; + const cssText = this._updateStyleElementStyleSheetTextIfNeeded(sheet) || ''; + if (cssText === oldCSSText) + return { equals: true, n: [[ snapshotNumber - data.ref![0], data.ref![1] ]] }; + data.ref = [snapshotNumber, nodeCounter++]; + return { + equals: false, + n: ['template', { + [kStyleSheetAttribute]: cssText, + }] + }; + }; + let html: NodeSnapshot; if (document.documentElement) { const { n } = visitNode(document.documentElement)!; diff --git a/tests/snapshotter.spec.ts b/tests/snapshotter.spec.ts index 6a65872f7e4a9..6c1b344ba29d4 100644 --- a/tests/snapshotter.spec.ts +++ b/tests/snapshotter.spec.ts @@ -75,6 +75,14 @@ it.describe('snapshots', () => { expect(distillSnapshot(snapshot2)).toBe(''); }); + it('should have a custom doctype', async ({page, server, toImpl, snapshotter}) => { + await page.goto(server.EMPTY_PAGE); + await page.setContent('hi'); + + const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot'); + expect(distillSnapshot(snapshot)).toBe('hi'); + }); + it('should respect subresource CSSOM change', async ({ page, server, toImpl, snapshotter }) => { await page.goto(server.EMPTY_PAGE); await page.route('**/style.css', route => { @@ -166,6 +174,49 @@ it.describe('snapshots', () => { expect(distillSnapshot(snapshot)).toBe(''); } }); + + it('should contain adopted style sheets', async ({ page, toImpl, contextFactory, snapshotPort, snapshotter, browserName }) => { + it.skip(browserName !== 'chromium', 'Constructed stylesheets are only in Chromium.'); + await page.setContent(''); + await page.evaluate(() => { + const sheet = new CSSStyleSheet(); + sheet.addRule('button', 'color: red'); + (document as any).adoptedStyleSheets = [sheet]; + + const div = document.createElement('div'); + const root = div.attachShadow({ + mode: 'open' + }); + root.append('foo'); + const sheet2 = new CSSStyleSheet(); + sheet2.addRule(':host', 'color: blue'); + (root as any).adoptedStyleSheets = [sheet2]; + document.body.appendChild(div); + }); + const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot1'); + + const previewContext = await contextFactory(); + const previewPage = await previewContext.newPage(); + previewPage.on('console', console.log); + await previewPage.goto(`http://localhost:${snapshotPort}/snapshot/`); + await previewPage.evaluate(snapshotId => { + (window as any).showSnapshot(snapshotId); + }, `${snapshot1.snapshot().pageId}?name=snapshot1`); + // wait for the render frame to load + while (previewPage.frames().length < 2) + await new Promise(f => previewPage.once('frameattached', f)); + // wait for it to render + await previewPage.frames()[1].waitForSelector('button'); + const buttonColor = await previewPage.frames()[1].$eval('button', button => { + return window.getComputedStyle(button).color; + }); + expect(buttonColor).toBe('rgb(255, 0, 0)'); + const divColor = await previewPage.frames()[1].$eval('div', div => { + return window.getComputedStyle(div).color; + }); + expect(divColor).toBe('rgb(0, 0, 255)'); + await previewContext.close(); + }); }); function distillSnapshot(snapshot) {