diff --git a/packages/playwright-core/src/server/trace/recorder/snapshotterInjected.ts b/packages/playwright-core/src/server/trace/recorder/snapshotterInjected.ts index 697e830b91..ae3752cb30 100644 --- a/packages/playwright-core/src/server/trace/recorder/snapshotterInjected.ts +++ b/packages/playwright-core/src/server/trace/recorder/snapshotterInjected.ts @@ -333,7 +333,7 @@ export function frameSnapshotStreamer(snapshotStreamer: string) { expectValue(cssText); // Compensate for the extra 'cssText' text node. extraNodes++; - return checkAndReturn(['style', {}, cssText]); + return checkAndReturn([nodeName, {}, cssText]); } const attrs: { [attr: string]: string } = {}; diff --git a/packages/trace-viewer/src/snapshotRenderer.ts b/packages/trace-viewer/src/snapshotRenderer.ts index 6ff7f6bd9b..00be17b7f1 100644 --- a/packages/trace-viewer/src/snapshotRenderer.ts +++ b/packages/trace-viewer/src/snapshotRenderer.ts @@ -40,10 +40,16 @@ export class SnapshotRenderer { } render(): RenderedFrameSnapshot { - const visit = (n: NodeSnapshot, snapshotIndex: number): string => { + const visit = (n: NodeSnapshot, snapshotIndex: number, parentTag: string | undefined): string => { // Text node. - if (typeof n === 'string') - return escapeText(n); + if (typeof n === 'string') { + const text = escapeText(n); + // Best-effort Electron support: rewrite custom protocol in url() links in stylesheets. + // Old snapshotter was sending lower-case. + if (parentTag === 'STYLE' || parentTag === 'style') + return rewriteURLsInStyleSheetForCustomProtocol(text); + return text; + } if (!(n as any)._string) { if (Array.isArray(n[0])) { @@ -53,7 +59,7 @@ export class SnapshotRenderer { const nodes = snapshotNodes(this._snapshots[referenceIndex]); const nodeIndex = n[0][1]; if (nodeIndex >= 0 && nodeIndex < nodes.length) - (n as any)._string = visit(nodes[nodeIndex], referenceIndex); + (n as any)._string = visit(nodes[nodeIndex], referenceIndex, parentTag); } } else if (typeof n[0] === 'string') { // Element node. @@ -68,7 +74,7 @@ export class SnapshotRenderer { } builder.push('>'); for (let i = 2; i < n.length; i++) - builder.push(visit(n[i], snapshotIndex)); + builder.push(visit(n[i], snapshotIndex, n[0])); if (!autoClosing.has(n[0])) builder.push(''); (n as any)._string = builder.join(''); @@ -81,7 +87,7 @@ export class SnapshotRenderer { }; const snapshot = this._snapshot; - let html = visit(snapshot.html, this._index); + let html = visit(snapshot.html, this._index, undefined); if (!html) return { html: '', pageId: snapshot.pageId, frameId: snapshot.frameId, index: this._index }; @@ -275,8 +281,12 @@ function snapshotScript() { return `\n(${applyPlaywrightAttributes.toString()})()`; } -const schemas = ['about:', 'blob:', 'data:', 'file:', 'ftp:', 'http:', 'https:', 'mailto:', 'sftp:', 'ws:', 'wss:' ]; +/** + * Best-effort Electron support: rewrite custom protocol in DOM. + * vscode-file://vscode-app/ -> https://pw-vscode-file--vscode-app/ + */ +const schemas = ['about:', 'blob:', 'data:', 'file:', 'ftp:', 'http:', 'https:', 'mailto:', 'sftp:', 'ws:', 'wss:' ]; const kLegacyBlobPrefix = 'http://playwright.bloburl/#'; export function rewriteURLForCustomProtocol(href: string): string { @@ -298,9 +308,24 @@ export function rewriteURLForCustomProtocol(href: string): string { // Rewrite blob and custom schemas. const prefix = 'pw-' + url.protocol.slice(0, url.protocol.length - 1); url.protocol = 'https:'; - url.hostname = url.hostname ? `${prefix}.${url.hostname}` : prefix; + url.hostname = url.hostname ? `${prefix}--${url.hostname}` : prefix; return url.toString(); } catch { return href; } } + +/** + * Best-effort Electron support: rewrite custom protocol in inline stylesheets. + * vscode-file://vscode-app/ -> https://pw-vscode-file--vscode-app/ + */ +const urlInCSSRegex = /url\(['"]?([\w-]+:)\/\//ig; + +function rewriteURLsInStyleSheetForCustomProtocol(text: string): string { + return text.replace(urlInCSSRegex, (match: string, protocol: string) => { + const isBlob = protocol === 'blob:'; + if (!isBlob && schemas.includes(protocol)) + return match; + return match.replace(protocol + '//', `https://pw-${protocol.slice(0, -1)}--`); + }); +} diff --git a/tests/config/traceViewerFixtures.ts b/tests/config/traceViewerFixtures.ts new file mode 100644 index 0000000000..00ecf7a4da --- /dev/null +++ b/tests/config/traceViewerFixtures.ts @@ -0,0 +1,133 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Fixtures, Frame, Locator, Page, Browser, BrowserContext } from '@playwright/test'; +import { showTraceViewer } from '../../packages/playwright-core/lib/server/trace/viewer/traceViewer'; + +type BaseTestFixtures = { + context: BrowserContext; +}; + +type BaseWorkerFixtures = { + headless: boolean; + browser: Browser; + browserName: 'chromium' | 'firefox' | 'webkit'; + playwright: typeof import('@playwright/test'); +}; + +export type TraceViewerFixtures = { + showTraceViewer: (trace: string[]) => Promise; + runAndTrace: (body: () => Promise) => Promise; +}; + +class TraceViewerPage { + actionTitles: Locator; + callLines: Locator; + consoleLines: Locator; + consoleLineMessages: Locator; + consoleStacks: Locator; + stackFrames: Locator; + networkRequests: Locator; + snapshotContainer: Locator; + + constructor(public page: Page) { + this.actionTitles = page.locator('.action-title'); + this.callLines = page.locator('.call-line'); + this.consoleLines = page.locator('.console-line'); + this.consoleLineMessages = page.locator('.console-line-message'); + this.consoleStacks = page.locator('.console-stack'); + this.stackFrames = page.locator('.stack-trace-frame'); + this.networkRequests = page.locator('.network-request-title'); + this.snapshotContainer = page.locator('.snapshot-container'); + } + + async actionIconsText(action: string) { + const entry = await this.page.waitForSelector(`.action-entry:has-text("${action}")`); + await entry.waitForSelector('.action-icon-value:visible'); + return await entry.$$eval('.action-icon-value:visible', ee => ee.map(e => e.textContent)); + } + + async actionIcons(action: string) { + return await this.page.waitForSelector(`.action-entry:has-text("${action}") .action-icons`); + } + + async selectAction(title: string, ordinal: number = 0) { + await this.page.locator(`.action-title:has-text("${title}")`).nth(ordinal).click(); + } + + async selectSnapshot(name: string) { + await this.page.click(`.snapshot-tab .tab-label:has-text("${name}")`); + } + + async showConsoleTab() { + await this.page.click('text="Console"'); + } + + async showSourceTab() { + await this.page.click('text="Source"'); + } + + async showNetworkTab() { + await this.page.click('text="Network"'); + } + + async eventBars() { + await this.page.waitForSelector('.timeline-bar.event:visible'); + const list = await this.page.$$eval('.timeline-bar.event:visible', ee => ee.map(e => e.className)); + const set = new Set(); + for (const item of list) { + for (const className of item.split(' ')) + set.add(className); + } + const result = [...set]; + return result.sort(); + } + + async snapshotFrame(actionName: string, ordinal: number = 0, hasSubframe: boolean = false): Promise { + const existing = this.page.mainFrame().childFrames()[0]; + await Promise.all([ + existing ? existing.waitForNavigation() as any : Promise.resolve(), + this.selectAction(actionName, ordinal), + ]); + while (this.page.frames().length < (hasSubframe ? 3 : 2)) + await this.page.waitForEvent('frameattached'); + return this.page.mainFrame().childFrames()[0]; + } +} + +export const traceViewerFixtures: Fixtures = { + showTraceViewer: async ({ playwright, browserName, headless }, use) => { + let browser: Browser; + let contextImpl: any; + await use(async (traces: string[]) => { + contextImpl = await showTraceViewer(traces, browserName, headless); + browser = await playwright.chromium.connectOverCDP(contextImpl._browser.options.wsEndpoint); + return new TraceViewerPage(browser.contexts()[0].pages()[0]); + }); + await browser?.close(); + await contextImpl?._browser.close(); + }, + + runAndTrace: async ({ context, showTraceViewer }, use, testInfo) => { + await use(async (body: () => Promise) => { + const traceFile = testInfo.outputPath('trace.zip'); + await context.tracing.start({ snapshots: true, screenshots: true, sources: true }); + await body(); + await context.tracing.stop({ path: traceFile }); + return showTraceViewer([traceFile]); + }); + }, +}; \ No newline at end of file diff --git a/tests/electron/assets/imported.css b/tests/electron/assets/imported.css new file mode 100644 index 0000000000..5d06825442 --- /dev/null +++ b/tests/electron/assets/imported.css @@ -0,0 +1,3 @@ +button { + font-weight: bold; +} diff --git a/tests/electron/assets/index.html b/tests/electron/assets/index.html new file mode 100644 index 0000000000..28d995bf7c --- /dev/null +++ b/tests/electron/assets/index.html @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/tests/electron/assets/style.css b/tests/electron/assets/style.css new file mode 100644 index 0000000000..23b1956407 --- /dev/null +++ b/tests/electron/assets/style.css @@ -0,0 +1,3 @@ +button { + color: red; +} diff --git a/tests/electron/electron-app.js b/tests/electron/electron-app.js index 2632654629..0e68871199 100644 --- a/tests/electron/electron-app.js +++ b/tests/electron/electron-app.js @@ -1,3 +1,11 @@ -const { app } = require('electron'); +const { app, protocol } = require('electron'); +const path = require('path'); app.on('window-all-closed', e => e.preventDefault()); + +app.whenReady().then(() => { + protocol.registerFileProtocol('vscode-file', (request, callback) => { + const url = request.url.substring('vscode-file'.length + 3); + callback({ path: path.join(__dirname, 'assets', url) }); + }); +}); diff --git a/tests/electron/electron-tracing.spec.ts b/tests/electron/electron-tracing.spec.ts new file mode 100644 index 0000000000..760c400e6d --- /dev/null +++ b/tests/electron/electron-tracing.spec.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { electronTest as test, expect } from './electronTest'; + +test.skip(({ trace }) => trace === 'on'); +// test.slow(); + +test('should record trace', async ({ newWindow, server, runAndTrace }) => { + const traceViewer = await runAndTrace(async () => { + const window = await newWindow(); + await window.goto(server.PREFIX + '/input/button.html'); + await window.click('button'); + expect(await window.evaluate('result')).toBe('Clicked'); + }); + await expect(traceViewer.actionTitles).toHaveText([ + /page.goto/, + /page.click/, + /page.evaluate/, + ]); +}); + +test('should support custom protocol', async ({ electronApp, newWindow, server, runAndTrace }) => { + const window = await newWindow(); + await electronApp.evaluate(async ({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0].loadURL('vscode-file://index.html'); + }); + const traceViewer = await runAndTrace(async () => { + await window.click('button'); + }); + const frame = await traceViewer.snapshotFrame('page.click'); + await expect(frame.locator('button')).toHaveCSS('color', 'rgb(255, 0, 0)'); + await expect(frame.locator('button')).toHaveCSS('font-weight', '700'); +}); diff --git a/tests/electron/electronTest.ts b/tests/electron/electronTest.ts index 88c95b0cd4..b2d254fcf7 100644 --- a/tests/electron/electronTest.ts +++ b/tests/electron/electronTest.ts @@ -16,8 +16,9 @@ import { baseTest } from '../config/baseTest'; import * as path from 'path'; -import { ElectronApplication, Page } from 'playwright-core'; +import { ElectronApplication, Page } from '@playwright/test'; import { PageTestFixtures, PageWorkerFixtures } from '../page/pageTestApi'; +import { traceViewerFixtures, TraceViewerFixtures } from '../config/traceViewerFixtures'; export { expect } from '@playwright/test'; type ElectronTestFixtures = PageTestFixtures & { @@ -27,7 +28,7 @@ type ElectronTestFixtures = PageTestFixtures & { const electronVersion = require('electron/package.json').version; -export const electronTest = baseTest.extend({ +export const electronTest = baseTest.extend(traceViewerFixtures).extend({ browserVersion: [electronVersion, { scope: 'worker' }], browserMajorVersion: [Number(electronVersion.split('.')[0]), { scope: 'worker' }], isAndroid: [false, { scope: 'worker' }], @@ -71,4 +72,8 @@ export const electronTest = baseTest.extend { await run(await newWindow()); }, + + context: async ({ electronApp }, run) => { + await run(electronApp.context()); + }, }); diff --git a/tests/library/snapshotter.spec.ts b/tests/library/snapshotter.spec.ts index e107add77e..41de4d90cd 100644 --- a/tests/library/snapshotter.spec.ts +++ b/tests/library/snapshotter.spec.ts @@ -68,11 +68,11 @@ it.describe('snapshots', () => { it('should respect inline CSSOM change', async ({ page, toImpl, snapshotter }) => { await page.setContent(''); const snapshot1 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot1'); - expect(distillSnapshot(snapshot1)).toBe(''); + expect(distillSnapshot(snapshot1)).toBe(''); await page.evaluate(() => { (document.styleSheets[0].cssRules[0] as any).style.color = 'blue'; }); const snapshot2 = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2'); - expect(distillSnapshot(snapshot2)).toBe(''); + expect(distillSnapshot(snapshot2)).toBe(''); }); it('should respect node removal', async ({ page, toImpl, snapshotter }) => { diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index a2741bcb36..79376889e7 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -14,110 +14,12 @@ * limitations under the License. */ +import { TraceViewerFixtures, traceViewerFixtures } from '../config/traceViewerFixtures'; import fs from 'fs'; import path from 'path'; -import type { Browser, Frame, Locator, Page } from 'playwright-core'; -import { showTraceViewer } from '../../packages/playwright-core/lib/server/trace/viewer/traceViewer'; -import { playwrightTest, expect } from '../config/browserTest'; +import { expect, playwrightTest } from '../config/browserTest'; -class TraceViewerPage { - actionTitles: Locator; - callLines: Locator; - consoleLines: Locator; - consoleLineMessages: Locator; - consoleStacks: Locator; - stackFrames: Locator; - networkRequests: Locator; - snapshotContainer: Locator; - - constructor(public page: Page) { - this.actionTitles = page.locator('.action-title'); - this.callLines = page.locator('.call-line'); - this.consoleLines = page.locator('.console-line'); - this.consoleLineMessages = page.locator('.console-line-message'); - this.consoleStacks = page.locator('.console-stack'); - this.stackFrames = page.locator('.stack-trace-frame'); - this.networkRequests = page.locator('.network-request-title'); - this.snapshotContainer = page.locator('.snapshot-container'); - } - - async actionIconsText(action: string) { - const entry = await this.page.waitForSelector(`.action-entry:has-text("${action}")`); - await entry.waitForSelector('.action-icon-value:visible'); - return await entry.$$eval('.action-icon-value:visible', ee => ee.map(e => e.textContent)); - } - - async actionIcons(action: string) { - return await this.page.waitForSelector(`.action-entry:has-text("${action}") .action-icons`); - } - - async selectAction(title: string, ordinal: number = 0) { - await this.page.locator(`.action-title:has-text("${title}")`).nth(ordinal).click(); - } - - async selectSnapshot(name: string) { - await this.page.click(`.snapshot-tab .tab-label:has-text("${name}")`); - } - - async showConsoleTab() { - await this.page.click('text="Console"'); - } - - async showSourceTab() { - await this.page.click('text="Source"'); - } - - async showNetworkTab() { - await this.page.click('text="Network"'); - } - - async eventBars() { - await this.page.waitForSelector('.timeline-bar.event:visible'); - const list = await this.page.$$eval('.timeline-bar.event:visible', ee => ee.map(e => e.className)); - const set = new Set(); - for (const item of list) { - for (const className of item.split(' ')) - set.add(className); - } - const result = [...set]; - return result.sort(); - } - - async snapshotFrame(actionName: string, ordinal: number = 0, hasSubframe: boolean = false): Promise { - const existing = this.page.mainFrame().childFrames()[0]; - await Promise.all([ - existing ? existing.waitForNavigation() as any : Promise.resolve(), - this.selectAction(actionName, ordinal), - ]); - while (this.page.frames().length < (hasSubframe ? 3 : 2)) - await this.page.waitForEvent('frameattached'); - return this.page.mainFrame().childFrames()[0]; - } -} - -const test = playwrightTest.extend<{ showTraceViewer: (trace: string[]) => Promise, runAndTrace: (body: () => Promise) => Promise }>({ - showTraceViewer: async ({ playwright, browserName, headless }, use) => { - let browser: Browser; - let contextImpl: any; - await use(async (traces: string[]) => { - contextImpl = await showTraceViewer(traces, browserName, headless); - browser = await playwright.chromium.connectOverCDP({ endpointURL: contextImpl._browser.options.wsEndpoint }); - return new TraceViewerPage(browser.contexts()[0].pages()[0]); - }); - await browser?.close(); - await contextImpl?._browser.close(); - }, - - runAndTrace: async ({ context, showTraceViewer }, use, testInfo) => { - await use(async (body: () => Promise) => { - const traceFile = testInfo.outputPath('trace.zip'); - await context.tracing.start({ snapshots: true, screenshots: true, sources: true }); - await body(); - await context.tracing.stop({ path: traceFile }); - return showTraceViewer([traceFile]); - }); - } -}); +const test = playwrightTest.extend(traceViewerFixtures); test.skip(({ trace }) => trace === 'on'); test.slow();