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.
This commit is contained in:
Dmitry Gozman 2020-08-28 10:51:55 -07:00 committed by GitHub
parent 19f21b1bde
commit b34d9aba25
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1112 additions and 22 deletions

View File

@ -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"
},

View File

@ -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}`;

View File

@ -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<Type, Initializer> extends EventEmitter implements chann
readonly _scope: Dispatcher<any, any>;
_object: Type;
constructor(parent: Dispatcher<any, any> | DispatcherConnection, object: Type, type: string, initializer: Initializer, isScope?: boolean, guid = type + '@' + helper.guid()) {
constructor(parent: Dispatcher<any, any> | DispatcherConnection, object: Type, type: string, initializer: Initializer, isScope?: boolean, guid = type + '@' + createGuid()) {
super();
this._connection = parent instanceof DispatcherConnection ? parent : parent._connection;

View File

@ -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<Download>();
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<Snapshotter> {
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;

View File

@ -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='));

View File

@ -196,6 +196,8 @@ export class Page extends EventEmitter {
async _runAbortableTask<T>(task: (progress: Progress) => Promise<T>, timeout: number): Promise<T> {
return runAbortableTask(async progress => {
if (this._browserContext._snapshotter)
await this._browserContext._snapshotter._doSnapshot(progress, this, 'progress');
return task(progress);
}, timeout);
}

View File

@ -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 {}

240
src/server/snapshotter.ts Normal file
View File

@ -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<void> {
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<void> {
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<PageSnapshot | null> {
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<Frame, string>();
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<FrameSnapshotAndMapping | null> {
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<Frame, string>();
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<Frame, string>,
};

View File

@ -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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' };
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<string, string>();
const frameUrlToFrameElement = new Map<string, Element>();
const styleNodeToStyleSheetText = new Map<Node, string>();
const styleSheetUrlToContentOverride = new Map<string, string>();
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(`<!DOCTYPE ${docType.name}>`);
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('<style>');
builder.push(cssText);
builder.push('</style>');
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('<base href="');
builder.push(escapeAttribute(baseHref));
builder.push('"');
if (baseTarget) {
builder.push(' target="');
builder.push(escapeAttribute(baseTarget));
builder.push('"');
}
builder.push('>');
}
for (let child = node.firstChild; child; child = child.nextSibling)
visit(child, builder);
if (node.nodeName === 'BODY' && chunks.size) {
builder.push('<script>');
const shadowChunks = Array.from(chunks).map(([chunkId, html]) => {
return ` ['${chunkId}', '${escapeScriptString(html)}']`;
}).join(',\n');
const scriptContent = `\n(${applyShadowsInPage.toString()})('${shadowAttribute}', new Map([\n${shadowChunks}\n]))\n`;
builder.push(scriptContent);
builder.push('</script>');
}
if (nodeType === Node.ELEMENT_NODE && !autoClosing.has(nodeName)) {
builder.push('</');
builder.push(nodeName);
builder.push('>');
}
};
function applyShadowsInPage(shadowAttribute: string, shadowContent: Map<string, string>) {
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()),
};
}

View File

@ -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));
}

51
src/trace/traceTypes.ts Normal file
View File

@ -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;

242
src/trace/traceViewer.ts Normal file
View File

@ -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<string>();
private _resourceEventsByUrl = new Map<string, NetworkResourceTraceEvent[]>();
private _contextEventById = new Map<string, ContextCreatedTraceEvent>();
private _contextById = new Map<string, BrowserContext>();
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<string> {
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<BrowserContext> {
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<void> {
const frameBySrc = new Map<string, FrameSnapshot>();
for (const frameSnapshot of snapshot.frames)
frameBySrc.set(frameSnapshot.url, frameSnapshot);
const intercepted: Promise<any>[] = [];
const unknownUrls = new Set<string>();
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;
}
}

124
src/trace/tracer.ts Normal file
View File

@ -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<BrowserContext, string>();
private _traceStoragePromise: Promise<string>;
private _appendEventChain: Promise<string>;
private _writeArtifactChain: Promise<void>;
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<void> {
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;
});
}
}

View File

@ -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');
}

View File

@ -0,0 +1,5 @@
@import url(./two.css);
body {
background-color: pink;
}

View File

@ -0,0 +1,44 @@
<link rel='stylesheet' href='./one.css'>
<style>
.root {
width: 800px;
height: 800px;
background: cyan;
}
</style>
<div>hello, world!</div>
<div class=root></div>
<script>
let shadow;
window.addEventListener('DOMContentLoaded', () => {
const root = document.querySelector('.root');
shadow = root.attachShadow({ mode: 'open' });
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = './one.css';
shadow.appendChild(link);
const imaged = document.createElement('div');
imaged.className = 'imaged';
shadow.appendChild(imaged);
const iframe = document.createElement('iframe');
iframe.width = '600px';
iframe.height = '600px';
iframe.src = '../frames/nested-frames.html';
shadow.appendChild(iframe);
});
window.addEventListener('load', () => {
for (const rule of shadow.styleSheets[0].cssRules) {
if (rule.styleSheet) {
for (const rule2 of rule.styleSheet.cssRules) {
if (rule2.cssText.includes('width: 200px'))
rule2.style.width = '400px';
}
}
}
});
</script>

View File

@ -0,0 +1,5 @@
.imaged {
width: 200px;
height: 200px;
background: url(../pptr.png);
}

View File

@ -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' });
}
});

22
test/snapshot.spec.ts Normal file
View File

@ -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' });
});

View File

@ -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();

38
utils/showTestTraces.js Normal file
View File

@ -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;
}