From b34d9aba257d7e519c6e4c83e6a18b8b1f0bacba Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 28 Aug 2020 10:51:55 -0700 Subject: [PATCH] feat(trace): experimental traces for our tests (#3567) This introduces basic tracing enabled in our tests. What is captured: - network resources; - snapshots at the start of most actions; - snapshot after the test failure. How this integrates with test runner: - context fixture calls private method context._initSnapshotter() and uses Tracer to trace all events; - all tests share a single test-results/trace-storage directory to store blobs; - each test has its own trace file. - npm run show-trace opens a bare-minimum trace viewer that renders snapshots. --- package.json | 1 + src/browserServerImpl.ts | 4 +- src/dispatchers/dispatcher.ts | 5 +- src/server/browserContext.ts | 10 + src/server/helper.ts | 5 - src/server/page.ts | 2 + src/server/progress.ts | 7 +- src/server/snapshotter.ts | 240 +++++++++++++++++ src/server/snapshotterInjected.ts | 284 ++++++++++++++++++++ src/server/webkit/wkPage.ts | 4 +- src/trace/traceTypes.ts | 51 ++++ src/trace/traceViewer.ts | 242 +++++++++++++++++ src/trace/tracer.ts | 124 +++++++++ src/utils/utils.ts | 16 ++ test/assets/snapshot/one.css | 5 + test/assets/snapshot/snapshot-with-css.html | 44 +++ test/assets/snapshot/two.css | 5 + test/playwright.fixtures.ts | 22 +- test/snapshot.spec.ts | 22 ++ utils/check_deps.js | 3 + utils/showTestTraces.js | 38 +++ 21 files changed, 1112 insertions(+), 22 deletions(-) create mode 100644 src/server/snapshotter.ts create mode 100644 src/server/snapshotterInjected.ts create mode 100644 src/trace/traceTypes.ts create mode 100644 src/trace/traceViewer.ts create mode 100644 src/trace/tracer.ts create mode 100644 test/assets/snapshot/one.css create mode 100644 test/assets/snapshot/snapshot-with-css.html create mode 100644 test/assets/snapshot/two.css create mode 100644 test/snapshot.spec.ts create mode 100644 utils/showTestTraces.js diff --git a/package.json b/package.json index 5ac04e08cc..01c45ad495 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "roll-browser": "node utils/roll_browser.js", "coverage": "node test/checkCoverage.js", "check-deps": "node utils/check_deps.js", + "show-trace": "node utils/showTestTraces.js", "build-testrunner": "tsc -p test-runner", "test-testrunner": "node test-runner/cli test-runner/test" }, diff --git a/src/browserServerImpl.ts b/src/browserServerImpl.ts index 99c60d769e..97b513d95f 100644 --- a/src/browserServerImpl.ts +++ b/src/browserServerImpl.ts @@ -17,7 +17,6 @@ import { LaunchServerOptions } from './client/types'; import { BrowserTypeBase } from './server/browserType'; import * as ws from 'ws'; -import { helper } from './server/helper'; import { Browser } from './server/browser'; import { ChildProcess } from 'child_process'; import { EventEmitter } from 'ws'; @@ -27,6 +26,7 @@ import { BrowserContextDispatcher } from './dispatchers/browserContextDispatcher import { BrowserNewContextParams, BrowserContextChannel } from './protocol/channels'; import { BrowserServerLauncher, BrowserServer } from './client/browserType'; import { envObjectToArray } from './client/clientHelper'; +import { createGuid } from './utils/utils'; export class BrowserServerLauncherImpl implements BrowserServerLauncher { private _browserType: BrowserTypeBase; @@ -59,7 +59,7 @@ export class BrowserServerImpl extends EventEmitter implements BrowserServer { this._browserType = browserType; this._browser = browser; - const token = helper.guid(); + const token = createGuid(); this._server = new ws.Server({ port }); const address = this._server.address(); this._wsEndpoint = typeof address === 'string' ? `${address}/${token}` : `ws://127.0.0.1:${address.port}/${token}`; diff --git a/src/dispatchers/dispatcher.ts b/src/dispatchers/dispatcher.ts index 4a4d07b802..d9f5bf1fbe 100644 --- a/src/dispatchers/dispatcher.ts +++ b/src/dispatchers/dispatcher.ts @@ -15,11 +15,10 @@ */ import { EventEmitter } from 'events'; -import { helper } from '../server/helper'; import * as channels from '../protocol/channels'; import { serializeError } from '../protocol/serializers'; import { createScheme, Validator, ValidationError } from '../protocol/validator'; -import { assert, debugAssert } from '../utils/utils'; +import { assert, createGuid, debugAssert } from '../utils/utils'; export const dispatcherSymbol = Symbol('dispatcher'); @@ -51,7 +50,7 @@ export class Dispatcher extends EventEmitter implements chann readonly _scope: Dispatcher; _object: Type; - constructor(parent: Dispatcher | DispatcherConnection, object: Type, type: string, initializer: Initializer, isScope?: boolean, guid = type + '@' + helper.guid()) { + constructor(parent: Dispatcher | DispatcherConnection, object: Type, type: string, initializer: Initializer, isScope?: boolean, guid = type + '@' + createGuid()) { super(); this._connection = parent instanceof DispatcherConnection ? parent : parent._connection; diff --git a/src/server/browserContext.ts b/src/server/browserContext.ts index cf1d34862c..0cf5aeff22 100644 --- a/src/server/browserContext.ts +++ b/src/server/browserContext.ts @@ -29,6 +29,7 @@ import { EventEmitter } from 'events'; import { Progress } from './progress'; import { DebugController } from './debug/debugController'; import { isDebugMode } from '../utils/utils'; +import { Snapshotter, SnapshotterDelegate } from './snapshotter'; export class Screencast { readonly page: Page; @@ -68,6 +69,7 @@ export abstract class BrowserContext extends EventEmitter { readonly _downloads = new Set(); readonly _browser: Browser; readonly _browserContextId: string | undefined; + _snapshotter?: Snapshotter; constructor(browser: Browser, options: types.BrowserContextOptions, browserContextId: string | undefined) { super(); @@ -83,6 +85,12 @@ export abstract class BrowserContext extends EventEmitter { new DebugController(this); } + // Used by test runner. + async _initSnapshotter(delegate: SnapshotterDelegate): Promise { + this._snapshotter = new Snapshotter(this, delegate); + return this._snapshotter; + } + _browserClosed() { for (const page of this.pages()) page._didClose(); @@ -233,6 +241,8 @@ export abstract class BrowserContext extends EventEmitter { this._closedStatus = 'closing'; await this._doClose(); await Promise.all([...this._downloads].map(d => d.delete())); + if (this._snapshotter) + this._snapshotter._dispose(); this._didCloseInternal(); } await this._closePromise; diff --git a/src/server/helper.ts b/src/server/helper.ts index c48c166f14..d52ec07087 100644 --- a/src/server/helper.ts +++ b/src/server/helper.ts @@ -15,7 +15,6 @@ * limitations under the License. */ -import * as crypto from 'crypto'; import { EventEmitter } from 'events'; import * as removeFolder from 'rimraf'; import * as util from 'util'; @@ -67,10 +66,6 @@ class Helper { return { width: Math.floor(size.width + 1e-3), height: Math.floor(size.height + 1e-3) }; } - static guid(): string { - return crypto.randomBytes(16).toString('hex'); - } - static getViewportSizeFromWindowFeatures(features: string[]): types.Size | null { const widthString = features.find(f => f.startsWith('width=')); const heightString = features.find(f => f.startsWith('height=')); diff --git a/src/server/page.ts b/src/server/page.ts index 813e7b8765..ba89b5fe77 100644 --- a/src/server/page.ts +++ b/src/server/page.ts @@ -196,6 +196,8 @@ export class Page extends EventEmitter { async _runAbortableTask(task: (progress: Progress) => Promise, timeout: number): Promise { return runAbortableTask(async progress => { + if (this._browserContext._snapshotter) + await this._browserContext._snapshotter._doSnapshot(progress, this, 'progress'); return task(progress); }, timeout); } diff --git a/src/server/progress.ts b/src/server/progress.ts index 1623ec0984..49dce675f3 100644 --- a/src/server/progress.ts +++ b/src/server/progress.ts @@ -15,7 +15,7 @@ */ import { TimeoutError } from '../utils/errors'; -import { assert } from '../utils/utils'; +import { assert, monotonicTime } from '../utils/utils'; import { rewriteErrorMessage } from '../utils/stackTrace'; import { debugLogger, LogName } from '../utils/debugLogger'; @@ -135,9 +135,4 @@ function formatLogRecording(log: string[]): string { return `\n${'='.repeat(leftLength)}${header}${'='.repeat(rightLength)}\n${log.join('\n')}\n${'='.repeat(headerLength)}`; } -function monotonicTime(): number { - const [seconds, nanoseconds] = process.hrtime(); - return seconds * 1000 + (nanoseconds / 1000000 | 0); -} - class AbortedError extends Error {} diff --git a/src/server/snapshotter.ts b/src/server/snapshotter.ts new file mode 100644 index 0000000000..4347b8a2c9 --- /dev/null +++ b/src/server/snapshotter.ts @@ -0,0 +1,240 @@ +/** + * 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 { BrowserContext } from './browserContext'; +import { Page } from './page'; +import * as network from './network'; +import { helper, RegisteredListener } from './helper'; +import { Progress, runAbortableTask } from './progress'; +import { debugLogger } from '../utils/debugLogger'; +import { Frame } from './frames'; +import * as js from './javascript'; +import * as types from './types'; +import { SnapshotData, takeSnapshotInFrame } from './snapshotterInjected'; +import { assert, calculateSha1, createGuid } from '../utils/utils'; + +export type SanpshotterResource = { + frameId: string, + url: string, + contentType: string, + responseHeaders: { name: string, value: string }[], + sha1: string, +}; + +export type SnapshotterBlob = { + buffer: Buffer, + sha1: string, +}; + +export type FrameSnapshot = { + frameId: string, + url: string, + html: string, + resourceOverrides: { url: string, sha1: string }[], +}; +export type PageSnapshot = { + label: string, + viewportSize?: { width: number, height: number }, + // First frame is the main frame. + frames: FrameSnapshot[], +}; + +export interface SnapshotterDelegate { + onContextCreated(context: BrowserContext): void; + onContextDestroyed(context: BrowserContext): void; + onBlob(context: BrowserContext, blob: SnapshotterBlob): void; + onResource(context: BrowserContext, resource: SanpshotterResource): void; + onSnapshot(context: BrowserContext, snapshot: PageSnapshot): void; +} + +export class Snapshotter { + private _context: BrowserContext; + private _delegate: SnapshotterDelegate; + private _eventListeners: RegisteredListener[]; + + constructor(context: BrowserContext, delegate: SnapshotterDelegate) { + this._context = context; + this._delegate = delegate; + this._eventListeners = [ + helper.addEventListener(this._context, BrowserContext.Events.Page, this._onPage.bind(this)), + ]; + this._delegate.onContextCreated(this._context); + } + + async captureSnapshot(page: Page, options: types.TimeoutOptions & { label?: string } = {}): Promise { + return runAbortableTask(async progress => { + await this._doSnapshot(progress, page, options.label || 'snapshot'); + }, page._timeoutSettings.timeout(options)); + } + + _dispose() { + helper.removeEventListeners(this._eventListeners); + this._delegate.onContextDestroyed(this._context); + } + + async _doSnapshot(progress: Progress, page: Page, label: string): Promise { + assert(page.context() === this._context); + const snapshot = await this._snapshotPage(progress, page, label); + if (snapshot) + this._delegate.onSnapshot(this._context, snapshot); + } + + private _onPage(page: Page) { + this._eventListeners.push(helper.addEventListener(page, Page.Events.Response, (response: network.Response) => { + this._saveResource(response).catch(e => debugLogger.log('error', e)); + })); + } + + private async _saveResource(response: network.Response) { + const isRedirect = response.status() >= 300 && response.status() <= 399; + if (isRedirect) + return; + + // Shortcut all redirects - we cannot intercept them properly. + let original = response.request(); + while (original.redirectedFrom()) + original = original.redirectedFrom()!; + const url = original.url(); + + let contentType = ''; + for (const { name, value } of response.headers()) { + if (name.toLowerCase() === 'content-type') + contentType = value; + } + + const body = await response.body().catch(e => debugLogger.log('error', e)); + const sha1 = body ? calculateSha1(body) : 'none'; + const resource: SanpshotterResource = { + frameId: response.frame()._id, + url, + contentType, + responseHeaders: response.headers(), + sha1, + }; + this._delegate.onResource(this._context, resource); + if (body) + this._delegate.onBlob(this._context, { sha1, buffer: body }); + } + + private async _snapshotPage(progress: Progress, page: Page, label: string): Promise { + const frames = page.frames(); + const promises = frames.map(frame => this._snapshotFrame(progress, frame)); + const results = await Promise.all(promises); + + const mainFrame = results[0]; + if (!mainFrame) + return null; + if (!mainFrame.snapshot.url.startsWith('http')) + mainFrame.snapshot.url = 'http://playwright.snapshot/'; + + const mapping = new Map(); + for (const result of results) { + if (!result) + continue; + for (const [key, value] of result.mapping) + mapping.set(key, value); + } + + const childFrames: FrameSnapshot[] = []; + for (let i = 1; i < results.length; i++) { + const result = results[i]; + if (!result) + continue; + const frame = frames[i]; + if (!mapping.has(frame)) + continue; + const frameSnapshot = result.snapshot; + frameSnapshot.url = mapping.get(frame)!; + childFrames.push(frameSnapshot); + } + + let viewportSize = page.viewportSize(); + if (!viewportSize) { + try { + if (!progress.isRunning()) + return null; + + const context = await page.mainFrame()._utilityContext(); + viewportSize = await context.evaluateInternal(() => { + return { + width: Math.max(document.body.offsetWidth, document.documentElement.offsetWidth), + height: Math.max(document.body.offsetHeight, document.documentElement.offsetHeight), + }; + }); + } catch (e) { + return null; + } + } + + return { + label, + viewportSize, + frames: [mainFrame.snapshot, ...childFrames], + }; + } + + private async _snapshotFrame(progress: Progress, frame: Frame): Promise { + try { + if (!progress.isRunning()) + return null; + + const context = await frame._utilityContext(); + const guid = createGuid(); + const removeNoScript = !frame._page.context()._options.javaScriptEnabled; + const result = await js.evaluate(context, false /* returnByValue */, takeSnapshotInFrame, guid, removeNoScript) as js.JSHandle; + if (!progress.isRunning()) + return null; + + const properties = await result.getProperties(); + const data = await properties.get('data')!.jsonValue() as SnapshotData; + const frameElements = await properties.get('frameElements')!.getProperties(); + result.dispose(); + + const snapshot: FrameSnapshot = { + frameId: frame._id, + url: frame.url(), + html: data.html, + resourceOverrides: [], + }; + const mapping = new Map(); + + for (const { url, content } of data.resourceOverrides) { + const buffer = Buffer.from(content); + const sha1 = calculateSha1(buffer); + this._delegate.onBlob(this._context, { sha1, buffer }); + snapshot.resourceOverrides.push({ url, sha1 }); + } + + for (let i = 0; i < data.frameUrls.length; i++) { + const element = frameElements.get(String(i))!.asElement(); + if (!element) + continue; + const frame = await element.contentFrame().catch(e => null); + if (frame) + mapping.set(frame, data.frameUrls[i]); + } + + return { snapshot, mapping }; + } catch (e) { + return null; + } + } +} + +type FrameSnapshotAndMapping = { + snapshot: FrameSnapshot, + mapping: Map, +}; diff --git a/src/server/snapshotterInjected.ts b/src/server/snapshotterInjected.ts new file mode 100644 index 0000000000..bca9703f76 --- /dev/null +++ b/src/server/snapshotterInjected.ts @@ -0,0 +1,284 @@ +/** + * 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. + */ + +export type SnapshotData = { + html: string, + resourceOverrides: { url: string, content: string }[], + frameUrls: string[], +}; + +type SnapshotResult = { + data: SnapshotData, + frameElements: Element[], +}; + +export function takeSnapshotInFrame(guid: string, removeNoScript: boolean): SnapshotResult { + const shadowAttribute = 'playwright-shadow-root'; + const win = window; + const doc = win.document; + + const autoClosing = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']); + const escaped = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' }; + + const escapeAttribute = (s: string): string => { + return s.replace(/[&<>"']/ug, char => (escaped as any)[char]); + }; + const escapeText = (s: string): string => { + return s.replace(/[&<]/ug, char => (escaped as any)[char]); + }; + const escapeScriptString = (s: string): string => { + return s.replace(/'/g, '\\\''); + }; + + const chunks = new Map(); + const frameUrlToFrameElement = new Map(); + const styleNodeToStyleSheetText = new Map(); + const styleSheetUrlToContentOverride = new Map(); + + let counter = 0; + const nextId = (): string => { + return guid + (++counter); + }; + + const resolve = (base: string, url: string): string => { + if (url === '') + return ''; + try { + return new URL(url, base).href; + } catch (e) { + return url; + } + }; + + const sanitizeUrl = (url: string): string => { + if (url.startsWith('javascript:')) + return ''; + return url; + }; + + const sanitizeSrcSet = (srcset: string): string => { + return srcset.split(',').map(src => { + src = src.trim(); + const spaceIndex = src.lastIndexOf(' '); + if (spaceIndex === -1) + return sanitizeUrl(src); + return sanitizeUrl(src.substring(0, spaceIndex).trim()) + src.substring(spaceIndex); + }).join(','); + }; + + const getSheetBase = (sheet: CSSStyleSheet): string => { + let rootSheet = sheet; + while (rootSheet.parentStyleSheet) + rootSheet = rootSheet.parentStyleSheet; + if (rootSheet.ownerNode) + return rootSheet.ownerNode.baseURI; + return document.baseURI; + }; + + const getSheetText = (sheet: CSSStyleSheet): string => { + const rules: string[] = []; + for (const rule of sheet.cssRules) + rules.push(rule.cssText); + return rules.join('\n'); + }; + + const visitStyleSheet = (sheet: CSSStyleSheet) => { + try { + for (const rule of sheet.cssRules) { + if ((rule as CSSImportRule).styleSheet) + visitStyleSheet((rule as CSSImportRule).styleSheet); + } + + const cssText = getSheetText(sheet); + if (sheet.ownerNode && sheet.ownerNode.nodeName === 'STYLE') { + // Stylesheets with owner STYLE nodes will be rewritten. + styleNodeToStyleSheetText.set(sheet.ownerNode, cssText); + } else if (sheet.href !== null) { + // Other stylesheets will have resource overrides. + const base = getSheetBase(sheet); + const url = resolve(base, sheet.href); + styleSheetUrlToContentOverride.set(url, cssText); + } + } catch (e) { + // Sometimes we cannot access cross-origin stylesheets. + } + }; + + const visit = (node: Node | ShadowRoot, builder: string[]) => { + const nodeName = node.nodeName; + const nodeType = node.nodeType; + + if (nodeType === Node.DOCUMENT_TYPE_NODE) { + const docType = node as DocumentType; + builder.push(``); + return; + } + + if (nodeType === Node.TEXT_NODE) { + builder.push(escapeText(node.nodeValue || '')); + return; + } + + if (nodeType !== Node.ELEMENT_NODE && + nodeType !== Node.DOCUMENT_NODE && + nodeType !== Node.DOCUMENT_FRAGMENT_NODE) + return; + + if (nodeType === Node.DOCUMENT_NODE || nodeType === Node.DOCUMENT_FRAGMENT_NODE) { + const documentOrShadowRoot = node as DocumentOrShadowRoot; + for (const sheet of documentOrShadowRoot.styleSheets) + visitStyleSheet(sheet); + } + + if (nodeName === 'SCRIPT' || nodeName === 'BASE') + return; + + if (removeNoScript && nodeName === 'NOSCRIPT') + return; + + if (nodeName === 'STYLE') { + const cssText = styleNodeToStyleSheetText.get(node) || node.textContent || ''; + builder.push(''); + return; + } + + if (nodeType === Node.ELEMENT_NODE) { + const element = node as Element; + builder.push('<'); + builder.push(nodeName); + for (let i = 0; i < element.attributes.length; i++) { + const name = element.attributes[i].name; + let value = element.attributes[i].value; + if (name === 'value' && (nodeName === 'INPUT' || nodeName === 'TEXTAREA')) + continue; + if (name === 'checked' || name === 'disabled' || name === 'checked') + continue; + if (name === 'src' && (nodeName === 'IFRAME' || nodeName === 'FRAME')) { + // TODO: handle srcdoc? + let protocol = win.location.protocol; + if (!protocol.startsWith('http')) + protocol = 'http:'; + value = protocol + '//' + nextId() + '/'; + frameUrlToFrameElement.set(value, element); + } else if (name === 'src' && (nodeName === 'IMG')) { + value = sanitizeUrl(value); + } else if (name === 'srcset' && (nodeName === 'IMG')) { + value = sanitizeSrcSet(value); + } else if (name === 'srcset' && (nodeName === 'SOURCE')) { + value = sanitizeSrcSet(value); + } else if (name === 'href' && (nodeName === 'LINK')) { + value = sanitizeUrl(value); + } else if (name.startsWith('on')) { + value = ''; + } + builder.push(' '); + builder.push(name); + builder.push('="'); + builder.push(escapeAttribute(value)); + builder.push('"'); + } + if (nodeName === 'INPUT' || nodeName === 'TEXTAREA') { + builder.push(' value="'); + builder.push(escapeAttribute((element as HTMLInputElement | HTMLTextAreaElement).value)); + builder.push('"'); + } + if ((element as any).checked) + builder.push(' checked'); + if ((element as any).disabled) + builder.push(' disabled'); + if ((element as any).readOnly) + builder.push(' readonly'); + if (element.shadowRoot) { + const b: string[] = []; + visit(element.shadowRoot, b); + const chunkId = nextId(); + chunks.set(chunkId, b.join('')); + builder.push(' '); + builder.push(shadowAttribute); + builder.push('="'); + builder.push(chunkId); + builder.push('"'); + } + builder.push('>'); + } + if (nodeName === 'HEAD') { + let baseHref = document.baseURI; + let baseTarget: string | undefined; + for (let child = node.firstChild; child; child = child.nextSibling) { + if (child.nodeName === 'BASE') { + baseHref = (child as HTMLBaseElement).href; + baseTarget = (child as HTMLBaseElement).target; + } + } + builder.push(''); + } + for (let child = node.firstChild; child; child = child.nextSibling) + visit(child, builder); + if (node.nodeName === 'BODY' && chunks.size) { + builder.push(''); + } + if (nodeType === Node.ELEMENT_NODE && !autoClosing.has(nodeName)) { + builder.push(''); + } + }; + + function applyShadowsInPage(shadowAttribute: string, shadowContent: Map) { + const visitShadows = (root: Document | ShadowRoot) => { + const elements = root.querySelectorAll(`[${shadowAttribute}]`); + for (let i = 0; i < elements.length; i++) { + const host = elements[i]; + const chunkId = host.getAttribute(shadowAttribute)!; + host.removeAttribute(shadowAttribute); + const shadow = host.attachShadow({ mode: 'open' }); + const html = shadowContent.get(chunkId); + if (html) { + shadow.innerHTML = html; + visitShadows(shadow); + } + } + }; + visitShadows(document); + } + + const root: string[] = []; + visit(doc, root); + return { + data: { + html: root.join(''), + frameUrls: Array.from(frameUrlToFrameElement.keys()), + resourceOverrides: Array.from(styleSheetUrlToContentOverride).map(([url, content]) => ({ url, content })), + }, + frameElements: Array.from(frameUrlToFrameElement.values()), + }; +} diff --git a/src/server/webkit/wkPage.ts b/src/server/webkit/wkPage.ts index ea6c796ecf..916578ad3d 100644 --- a/src/server/webkit/wkPage.ts +++ b/src/server/webkit/wkPage.ts @@ -37,7 +37,7 @@ import { selectors } from '../selectors'; import * as jpeg from 'jpeg-js'; import * as png from 'pngjs'; import { JSHandle } from '../javascript'; -import { assert, debugAssert, headersArrayToObject } from '../../utils/utils'; +import { assert, createGuid, debugAssert, headersArrayToObject } from '../../utils/utils'; const UTILITY_WORLD_NAME = '__playwright_utility_world__'; const BINDING_CALL_MESSAGE = '__playwright_binding_call__'; @@ -116,7 +116,7 @@ export class WKPage implements PageDelegate { } if (this._browserContext._screencastOptions) { const contextOptions = this._browserContext._screencastOptions; - const outputFile = path.join(contextOptions.dir, helper.guid() + '.webm'); + const outputFile = path.join(contextOptions.dir, createGuid() + '.webm'); const options = Object.assign({}, contextOptions, {outputFile}); promises.push(this.startScreencast(options)); } diff --git a/src/trace/traceTypes.ts b/src/trace/traceTypes.ts new file mode 100644 index 0000000000..57ccd2f8c9 --- /dev/null +++ b/src/trace/traceTypes.ts @@ -0,0 +1,51 @@ +/** + * 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 * as snapshotter from '../server/snapshotter'; + +export type ContextCreatedTraceEvent = { + type: 'context-created', + browserName: string, + contextId: string, + deviceScaleFactor: number, + isMobile: boolean, + viewportSize?: { width: number, height: number }, +}; + +export type ContextDestroyedTraceEvent = { + type: 'context-destroyed', + contextId: string, +}; + +export type NetworkResourceTraceEvent = { + type: 'resource', + contextId: string, + frameId: string, + url: string, + contentType: string, + responseHeaders: { name: string, value: string }[], + sha1: string, +}; + +export type SnapshotTraceEvent = { + type: 'snapshot', + contextId: string, + label: string, + sha1: string, +}; + +export type FrameSnapshot = snapshotter.FrameSnapshot; +export type PageSnapshot = snapshotter.PageSnapshot; diff --git a/src/trace/traceViewer.ts b/src/trace/traceViewer.ts new file mode 100644 index 0000000000..427d13b8ac --- /dev/null +++ b/src/trace/traceViewer.ts @@ -0,0 +1,242 @@ +/** + * 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 * as path from 'path'; +import * as util from 'util'; +import * as fs from 'fs'; +import { NetworkResourceTraceEvent, SnapshotTraceEvent, ContextCreatedTraceEvent, ContextDestroyedTraceEvent, FrameSnapshot, PageSnapshot } from './traceTypes'; +import type { Browser, BrowserContext, Frame, Page, Route } from '../client/api'; +import type { Playwright } from '../client/playwright'; + +const fsReadFileAsync = util.promisify(fs.readFile.bind(fs)); +type TraceEvent = + ContextCreatedTraceEvent | + ContextDestroyedTraceEvent | + NetworkResourceTraceEvent | + SnapshotTraceEvent; + +class TraceViewer { + private _playwright: Playwright; + private _traceStorageDir: string; + private _traces: { traceFile: string, events: TraceEvent[] }[] = []; + private _browserNames = new Set(); + private _resourceEventsByUrl = new Map(); + private _contextEventById = new Map(); + private _contextById = new Map(); + + constructor(playwright: Playwright, traceStorageDir: string) { + this._playwright = playwright; + this._traceStorageDir = traceStorageDir; + } + + async load(traceFile: string) { + // TODO: validate trace? + const traceContent = await fsReadFileAsync(traceFile, 'utf8'); + const events = traceContent.split('\n').map(line => line.trim()).filter(line => !!line).map(line => JSON.parse(line)); + for (const event of events) { + if (event.type === 'context-created') + this._browserNames.add(event.browserName); + if (event.type === 'resource') { + let responseEvents = this._resourceEventsByUrl.get(event.url); + if (!responseEvents) { + responseEvents = []; + this._resourceEventsByUrl.set(event.url, responseEvents); + } + responseEvents.push(event); + } + if (event.type === 'context-created') + this._contextEventById.set(event.contextId, event); + } + this._traces.push({ traceFile, events }); + } + + browserNames(): Set { + return this._browserNames; + } + + async show(browserName: string) { + const browser = await this._playwright[browserName as ('chromium' | 'firefox' | 'webkit')].launch({ headless: false }); + const uiPage = await browser.newPage(); + await uiPage.exposeBinding('renderSnapshot', async (source, event: SnapshotTraceEvent) => { + const snapshot = await fsReadFileAsync(path.join(this._traceStorageDir, event.sha1), 'utf8'); + const context = await this._ensureContext(browser, event.contextId); + const page = await context.newPage(); + await this._renderSnapshot(page, JSON.parse(snapshot), event.contextId); + }); + + const snapshotsPerContext: { [contextId: string]: { label: string, snapshots: SnapshotTraceEvent[] } } = {}; + for (const trace of this._traces) { + let contextId = 0; + for (const event of trace.events) { + if (event.type !== 'snapshot') + continue; + const contextEvent = this._contextEventById.get(event.contextId)!; + if (contextEvent.browserName !== browserName) + continue; + let contextSnapshots = snapshotsPerContext[contextEvent.contextId]; + if (!contextSnapshots) { + contextSnapshots = { label: trace.traceFile + ' :: context' + (++contextId), snapshots: [] }; + snapshotsPerContext[contextEvent.contextId] = contextSnapshots; + } + contextSnapshots.snapshots.push(event); + } + } + await uiPage.evaluate(snapshotsPerContext => { + for (const contextSnapshots of Object.values(snapshotsPerContext)) { + const header = document.createElement('div'); + header.textContent = contextSnapshots.label; + header.style.margin = '10px'; + document.body.appendChild(header); + for (const event of contextSnapshots.snapshots) { + const button = document.createElement('button'); + button.style.display = 'block'; + button.textContent = `${event.label}`; + button.addEventListener('click', () => { + (window as any).renderSnapshot(event); + }); + document.body.appendChild(button); + } + } + }, snapshotsPerContext); + } + + private async _ensureContext(browser: Browser, contextId: string): Promise { + let context = this._contextById.get(contextId); + if (!context) { + const event = this._contextEventById.get(contextId)!; + context = await browser.newContext({ + isMobile: event.isMobile, + viewport: event.viewportSize || null, + deviceScaleFactor: event.deviceScaleFactor, + }); + this._contextById.set(contextId, context); + } + return context; + } + + private async _readResource(event: NetworkResourceTraceEvent, overrideSha1: string | undefined) { + try { + const body = await fsReadFileAsync(path.join(this._traceStorageDir, overrideSha1 || event.sha1)); + return { + contentType: event.contentType, + body, + headers: event.responseHeaders, + }; + } catch (e) { + return undefined; + } + } + + private async _renderSnapshot(page: Page, snapshot: PageSnapshot, contextId: string): Promise { + const frameBySrc = new Map(); + for (const frameSnapshot of snapshot.frames) + frameBySrc.set(frameSnapshot.url, frameSnapshot); + + const intercepted: Promise[] = []; + + const unknownUrls = new Set(); + const unknown = (route: Route): void => { + const url = route.request().url(); + if (!unknownUrls.has(url)) { + console.log(`Request to unknown url: ${url}`); /* eslint-disable-line no-console */ + unknownUrls.add(url); + } + intercepted.push(route.abort()); + }; + + await page.route('**', async route => { + const url = route.request().url(); + if (frameBySrc.has(url)) { + const frameSnapshot = frameBySrc.get(url)!; + intercepted.push(route.fulfill({ + contentType: 'text/html', + body: Buffer.from(frameSnapshot.html), + })); + return; + } + + const frameSrc = route.request().frame().url(); + const frameSnapshot = frameBySrc.get(frameSrc); + if (!frameSnapshot) + return unknown(route); + + // Find a matching resource from the same context, preferrably from the same frame. + // Note: resources are stored without hash, but page may reference them with hash. + let resource: NetworkResourceTraceEvent | null = null; + for (const resourceEvent of this._resourceEventsByUrl.get(removeHash(url)) || []) { + if (resourceEvent.contextId !== contextId) + continue; + if (resource && resourceEvent.frameId !== frameSnapshot.frameId) + continue; + resource = resourceEvent; + if (resourceEvent.frameId === frameSnapshot.frameId) + break; + } + if (!resource) + return unknown(route); + + // This particular frame might have a resource content override, for example when + // stylesheet is modified using CSSOM. + const resourceOverride = frameSnapshot.resourceOverrides.find(o => o.url === url); + const overrideSha1 = resourceOverride ? resourceOverride.sha1 : undefined; + const resourceData = await this._readResource(resource, overrideSha1); + if (!resourceData) + return unknown(route); + const headers: { [key: string]: string } = {}; + for (const { name, value } of resourceData.headers) + headers[name] = value; + headers['Access-Control-Allow-Origin'] = '*'; + intercepted.push(route.fulfill({ + contentType: resourceData.contentType, + body: resourceData.body, + headers, + })); + }); + + await page.goto(snapshot.frames[0].url); + await this._postprocessSnapshotFrame(snapshot, snapshot.frames[0], page.mainFrame()); + await Promise.all(intercepted); + } + + private async _postprocessSnapshotFrame(snapshot: PageSnapshot, frameSnapshot: FrameSnapshot, frame: Frame) { + for (const childFrame of frame.childFrames()) { + await childFrame.waitForLoadState(); + const url = childFrame.url(); + for (const childData of snapshot.frames) { + if (url.endsWith(childData.url)) + await this._postprocessSnapshotFrame(snapshot, childData, childFrame); + } + } + } +} + +export async function showTraceViewer(playwright: Playwright, traceStorageDir: string, traceFiles: string[]) { + const traceViewer = new TraceViewer(playwright, traceStorageDir); + for (const traceFile of traceFiles) + await traceViewer.load(traceFile); + for (const browserName of traceViewer.browserNames()) + await traceViewer.show(browserName); +} + +function removeHash(url: string) { + try { + const u = new URL(url); + u.hash = ''; + return u.toString(); + } catch (e) { + return url; + } +} diff --git a/src/trace/tracer.ts b/src/trace/tracer.ts new file mode 100644 index 0000000000..fbbcc010d4 --- /dev/null +++ b/src/trace/tracer.ts @@ -0,0 +1,124 @@ +/** + * 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 { BrowserContext } from '../server/browserContext'; +import type { PageSnapshot, SanpshotterResource, SnapshotterBlob, SnapshotterDelegate } from '../server/snapshotter'; +import { ContextCreatedTraceEvent, ContextDestroyedTraceEvent, NetworkResourceTraceEvent, SnapshotTraceEvent } from './traceTypes'; +import * as path from 'path'; +import * as util from 'util'; +import * as fs from 'fs'; +import { calculateSha1, createGuid, mkdirIfNeeded, monotonicTime } from '../utils/utils'; + +const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs)); +const fsAppendFileAsync = util.promisify(fs.appendFile.bind(fs)); +const fsAccessAsync = util.promisify(fs.access.bind(fs)); + +export class Tracer implements SnapshotterDelegate { + private _contextIds = new Map(); + private _traceStoragePromise: Promise; + private _appendEventChain: Promise; + private _writeArtifactChain: Promise; + + constructor(traceStorageDir: string, traceFile: string) { + this._traceStoragePromise = mkdirIfNeeded(path.join(traceStorageDir, 'sha1')).then(() => traceStorageDir); + this._appendEventChain = mkdirIfNeeded(traceFile).then(() => traceFile); + this._writeArtifactChain = Promise.resolve(); + } + + onContextCreated(context: BrowserContext): void { + const contextId = 'context@' + createGuid(); + this._contextIds.set(context, contextId); + const event: ContextCreatedTraceEvent = { + type: 'context-created', + browserName: context._browser._options.name, + contextId, + isMobile: !!context._options.isMobile, + deviceScaleFactor: context._options.deviceScaleFactor || 1, + viewportSize: context._options.viewport || undefined, + }; + this._appendTraceEvent(event); + } + + onContextDestroyed(context: BrowserContext): void { + const event: ContextDestroyedTraceEvent = { + type: 'context-destroyed', + contextId: this._contextIds.get(context)!, + }; + this._appendTraceEvent(event); + } + + onBlob(context: BrowserContext, blob: SnapshotterBlob): void { + this._writeArtifact(blob.sha1, blob.buffer); + } + + onResource(context: BrowserContext, resource: SanpshotterResource): void { + const event: NetworkResourceTraceEvent = { + type: 'resource', + contextId: this._contextIds.get(context)!, + frameId: resource.frameId, + url: resource.url, + contentType: resource.contentType, + responseHeaders: resource.responseHeaders, + sha1: resource.sha1, + }; + this._appendTraceEvent(event); + } + + onSnapshot(context: BrowserContext, snapshot: PageSnapshot): void { + const buffer = Buffer.from(JSON.stringify(snapshot)); + const sha1 = calculateSha1(buffer); + const event: SnapshotTraceEvent = { + type: 'snapshot', + contextId: this._contextIds.get(context)!, + label: snapshot.label, + sha1, + }; + this._appendTraceEvent(event); + this._writeArtifact(sha1, buffer); + } + + async dispose() { + // Ensure all writes are finished. + await this._appendEventChain; + await this._writeArtifactChain; + } + + private _writeArtifact(sha1: string, buffer: Buffer) { + // Save all write promises to wait for them in dispose. + const promise = this._innerWriteArtifact(sha1, buffer); + this._writeArtifactChain = this._writeArtifactChain.then(() => promise); + } + + private async _innerWriteArtifact(sha1: string, buffer: Buffer): Promise { + const traceDirectory = await this._traceStoragePromise; + const filePath = path.join(traceDirectory, sha1); + try { + await fsAccessAsync(filePath); + } catch (e) { + // File does not exist - write it. + await fsWriteFileAsync(filePath, buffer); + } + } + + private _appendTraceEvent(event: any) { + // Serialize all writes to the trace file. + const timestamp = monotonicTime(); + this._appendEventChain = this._appendEventChain.then(async traceFile => { + await fsAppendFileAsync(traceFile, JSON.stringify({...event, timestamp}) + '\n'); + return traceFile; + }); + } +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index a396378790..56d11c69e0 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -17,6 +17,7 @@ import * as path from 'path'; import * as fs from 'fs'; import * as util from 'util'; +import * as crypto from 'crypto'; const mkdirAsync = util.promisify(fs.mkdir.bind(fs)); @@ -124,3 +125,18 @@ export function headersArrayToObject(headers: HeadersArray, lowerCase: boolean): result[lowerCase ? name.toLowerCase() : name] = value; return result; } + +export function monotonicTime(): number { + const [seconds, nanoseconds] = process.hrtime(); + return seconds * 1000 + (nanoseconds / 1000000 | 0); +} + +export function calculateSha1(buffer: Buffer): string { + const hash = crypto.createHash('sha1'); + hash.update(buffer); + return hash.digest('hex'); +} + +export function createGuid(): string { + return crypto.randomBytes(16).toString('hex'); +} diff --git a/test/assets/snapshot/one.css b/test/assets/snapshot/one.css new file mode 100644 index 0000000000..85a2ba850b --- /dev/null +++ b/test/assets/snapshot/one.css @@ -0,0 +1,5 @@ +@import url(./two.css); + +body { + background-color: pink; +} diff --git a/test/assets/snapshot/snapshot-with-css.html b/test/assets/snapshot/snapshot-with-css.html new file mode 100644 index 0000000000..92cd82cf6d --- /dev/null +++ b/test/assets/snapshot/snapshot-with-css.html @@ -0,0 +1,44 @@ + + +
hello, world!
+
+ diff --git a/test/assets/snapshot/two.css b/test/assets/snapshot/two.css new file mode 100644 index 0000000000..a29db76856 --- /dev/null +++ b/test/assets/snapshot/two.css @@ -0,0 +1,5 @@ +.imaged { + width: 200px; + height: 200px; + background: url(../pptr.png); +} diff --git a/test/playwright.fixtures.ts b/test/playwright.fixtures.ts index 779c0409e5..22d0a13803 100644 --- a/test/playwright.fixtures.ts +++ b/test/playwright.fixtures.ts @@ -180,13 +180,25 @@ registerWorkerFixture('golden', async ({browserName}, test) => { await test(p => path.join(browserName, p)); }); -registerFixture('context', async ({browser}, test) => { +registerFixture('context', async ({browser, toImpl}, runTest, info) => { const context = await browser.newContext(); - await test(context); + const { test, config } = info; + if (toImpl) { + const traceStorageDir = path.join(config.outputDir, 'trace-storage'); + const relativePath = path.relative(config.testDir, test.file).replace(/\.spec\.[jt]s/, ''); + const sanitizedTitle = test.title.replace(/[^\w\d]+/g, '_'); + const traceFile = path.join(config.outputDir, relativePath, sanitizedTitle + '.trace'); + const tracerFactory = require('../lib/trace/tracer').Tracer; + (context as any).__tracer = new tracerFactory(traceStorageDir, traceFile); + (context as any).__snapshotter = await toImpl(context)._initSnapshotter((context as any).__tracer); + } + await runTest(context); await context.close(); + if ((context as any).__tracer) + await (context as any).__tracer.dispose(); }); -registerFixture('page', async ({context}, runTest, info) => { +registerFixture('page', async ({context, toImpl}, runTest, info) => { const page = await context.newPage(); await runTest(page); const { test, config, result } = info; @@ -194,7 +206,9 @@ registerFixture('page', async ({context}, runTest, info) => { const relativePath = path.relative(config.testDir, test.file).replace(/\.spec\.[jt]s/, ''); const sanitizedTitle = test.title.replace(/[^\w\d]+/g, '_'); const assetPath = path.join(config.outputDir, relativePath, sanitizedTitle) + '-failed.png'; - await page.screenshot({ path: assetPath }); + await page.screenshot({ timeout: 5000, path: assetPath }); + if ((context as any).__snapshotter) + await (context as any).__snapshotter.captureSnapshot(toImpl(page), { timeout: 5000, label: 'Test Failed' }); } }); diff --git a/test/snapshot.spec.ts b/test/snapshot.spec.ts new file mode 100644 index 0000000000..a6a70614ce --- /dev/null +++ b/test/snapshot.spec.ts @@ -0,0 +1,22 @@ +/** + * 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 { options } from './playwright.fixtures'; + +it.skip(options.WIRE)('should not throw', async ({page, server, context, toImpl}) => { + await page.goto(server.PREFIX + '/snapshot/snapshot-with-css.html'); + await (context as any).__snapshotter.captureSnapshot(toImpl(page), { timeout: 5000, label: 'snapshot' }); +}); diff --git a/utils/check_deps.js b/utils/check_deps.js index 101ea765f6..90190d6654 100644 --- a/utils/check_deps.js +++ b/utils/check_deps.js @@ -117,4 +117,7 @@ DEPS['src/server/electron/'] = [...DEPS['src/server/'], 'src/server/chromium/']; DEPS['src/server/playwright.ts'] = [...DEPS['src/server/'], 'src/server/chromium/', 'src/server/webkit/', 'src/server/firefox/']; DEPS['src/server.ts'] = DEPS['src/inprocess.ts'] = DEPS['src/browserServerImpl.ts'] = ['src/**']; +// Tracing depends on client and server, but nothing should depend on tracing. +DEPS['src/trace/'] = ['src/utils/', 'src/client/**', 'src/server/**']; + checkDeps(); diff --git a/utils/showTestTraces.js b/utils/showTestTraces.js new file mode 100644 index 0000000000..a018b70e76 --- /dev/null +++ b/utils/showTestTraces.js @@ -0,0 +1,38 @@ +/** + * Copyright Microsoft Corporation. All rights reserved. + * + * 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. + */ + +const path = require('path'); +const fs = require('fs'); +const playwright = require('..'); +const { showTraceViewer } = require('../lib/client/traceViewer'); + +const testResultsDir = process.argv[2] || path.join(__dirname, '..', 'test-results'); +const files = collectFiles(testResultsDir, ''); +const traceStorageDir = path.join(testResultsDir, 'trace-storage'); +console.log(`Found ${files.length} trace files`); +showTraceViewer(playwright, traceStorageDir, files); + +function collectFiles(dir) { + const files = []; + for (const name of fs.readdirSync(dir)) { + const fullName = path.join(dir, name); + if (fs.lstatSync(fullName).isDirectory()) + files.push(...collectFiles(fullName)); + else if (name.endsWith('.trace')) + files.push(fullName); + } + return files; +}