mirror of
https://github.com/microsoft/playwright.git
synced 2025-01-05 19:04:43 +03:00
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:
parent
19f21b1bde
commit
b34d9aba25
@ -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"
|
||||
},
|
||||
|
@ -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}`;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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='));
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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
240
src/server/snapshotter.ts
Normal 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>,
|
||||
};
|
284
src/server/snapshotterInjected.ts
Normal file
284
src/server/snapshotterInjected.ts
Normal 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 = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' };
|
||||
|
||||
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()),
|
||||
};
|
||||
}
|
@ -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
51
src/trace/traceTypes.ts
Normal 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
242
src/trace/traceViewer.ts
Normal 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
124
src/trace/tracer.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
@ -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');
|
||||
}
|
||||
|
5
test/assets/snapshot/one.css
Normal file
5
test/assets/snapshot/one.css
Normal file
@ -0,0 +1,5 @@
|
||||
@import url(./two.css);
|
||||
|
||||
body {
|
||||
background-color: pink;
|
||||
}
|
44
test/assets/snapshot/snapshot-with-css.html
Normal file
44
test/assets/snapshot/snapshot-with-css.html
Normal 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>
|
5
test/assets/snapshot/two.css
Normal file
5
test/assets/snapshot/two.css
Normal file
@ -0,0 +1,5 @@
|
||||
.imaged {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: url(../pptr.png);
|
||||
}
|
@ -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
22
test/snapshot.spec.ts
Normal 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' });
|
||||
});
|
@ -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
38
utils/showTestTraces.js
Normal 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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user