feat(tracing): make context.request appear in the trace (#9555)

This commit is contained in:
Pavel Feldman 2021-10-15 14:22:49 -08:00 committed by GitHub
parent 4ce765c3ae
commit e31b96cc26
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 177 additions and 268 deletions

View file

@ -238,7 +238,7 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel, c
return this._wrapApiCall(async (channel: channels.AndroidDeviceChannel) => { return this._wrapApiCall(async (channel: channels.AndroidDeviceChannel) => {
const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate);
const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate;
const waiter = Waiter.createForEvent(this, event); const waiter = Waiter.createForEvent(channel, event);
waiter.rejectOnTimeout(timeout, `Timeout while waiting for event "${event}"`); waiter.rejectOnTimeout(timeout, `Timeout while waiting for event "${event}"`);
if (event !== Events.AndroidDevice.Close) if (event !== Events.AndroidDevice.Close)
waiter.rejectOnEvent(this, Events.AndroidDevice.Close, new Error('Device closed')); waiter.rejectOnEvent(this, Events.AndroidDevice.Close, new Error('Device closed'));

View file

@ -151,7 +151,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel,
} }
if (!handled) { if (!handled) {
// it can race with BrowserContext.close() which then throws since its closed // it can race with BrowserContext.close() which then throws since its closed
route.continue().catch(() => {}); route._internalContinue();
} else { } else {
this._routes = this._routes.filter(route => !route.expired()); this._routes = this._routes.filter(route => !route.expired());
} }
@ -293,7 +293,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel,
return this._wrapApiCall(async (channel: channels.BrowserContextChannel) => { return this._wrapApiCall(async (channel: channels.BrowserContextChannel) => {
const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate);
const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate;
const waiter = Waiter.createForEvent(this, event); const waiter = Waiter.createForEvent(channel, event);
waiter.rejectOnTimeout(timeout, `Timeout while waiting for event "${event}"`); waiter.rejectOnTimeout(timeout, `Timeout while waiting for event "${event}"`);
if (event !== Events.BrowserContext.Close) if (event !== Events.BrowserContext.Close)
waiter.rejectOnEvent(this, Events.BrowserContext.Close, new Error('Context closed')); waiter.rejectOnEvent(this, Events.BrowserContext.Close, new Error('Context closed'));

View file

@ -82,7 +82,7 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
if (validator) { if (validator) {
return (params: any) => { return (params: any) => {
if (callCookie && csi) { if (callCookie && csi) {
callCookie.userObject = csi.onApiCallBegin(renderCallWithParams(stackTrace!.apiName, params)).userObject; callCookie.userObject = csi.onApiCallBegin(renderCallWithParams(stackTrace!.apiName!, params)).userObject;
csi = undefined; csi = undefined;
} }
return this._connection.sendMessageToServer(this, prop, validator(params, ''), stackTrace); return this._connection.sendMessageToServer(this, prop, validator(params, ''), stackTrace);
@ -96,7 +96,7 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
return channel; return channel;
} }
async _wrapApiCall<R, C extends channels.Channel = T>(func: (channel: C, stackTrace: ParsedStackTrace) => Promise<R>, logger?: Logger): Promise<R> { async _wrapApiCall<R, C extends channels.Channel = T>(func: (channel: C, stackTrace: ParsedStackTrace) => Promise<R>, logger?: Logger, isInternal?: boolean): Promise<R> {
logger = logger || this._logger; logger = logger || this._logger;
const stackTrace = captureStackTrace(); const stackTrace = captureStackTrace();
const { apiName, frameTexts } = stackTrace; const { apiName, frameTexts } = stackTrace;
@ -106,23 +106,25 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
ancestorWithCSI = ancestorWithCSI._parent; ancestorWithCSI = ancestorWithCSI._parent;
// Do not report nested async calls to _wrapApiCall. // Do not report nested async calls to _wrapApiCall.
const isNested = stackTrace.allFrames.filter(f => f.function?.includes('_wrapApiCall')).length > 1; isInternal = isInternal || stackTrace.allFrames.filter(f => f.function?.includes('_wrapApiCall')).length > 1;
const csi = isNested ? undefined : ancestorWithCSI._csi; if (isInternal)
delete stackTrace.apiName;
const csi = isInternal ? undefined : ancestorWithCSI._csi;
const callCookie: { userObject: any } = { userObject: null }; const callCookie: { userObject: any } = { userObject: null };
try { try {
logApiCall(logger, `=> ${apiName} started`, isNested); logApiCall(logger, `=> ${apiName} started`, isInternal);
const channel = this._createChannel({}, stackTrace, csi, callCookie); const channel = this._createChannel({}, stackTrace, csi, callCookie);
const result = await func(channel as any, stackTrace); const result = await func(channel as any, stackTrace);
csi?.onApiCallEnd(callCookie); csi?.onApiCallEnd(callCookie);
logApiCall(logger, `<= ${apiName} succeeded`, isNested); logApiCall(logger, `<= ${apiName} succeeded`, isInternal);
return result; return result;
} catch (e) { } catch (e) {
const innerError = ((process.env.PWDEBUGIMPL || isUnderTest()) && e.stack) ? '\n<inner error>\n' + e.stack : ''; const innerError = ((process.env.PWDEBUGIMPL || isUnderTest()) && e.stack) ? '\n<inner error>\n' + e.stack : '';
e.message = apiName + ': ' + e.message; e.message = apiName + ': ' + e.message;
e.stack = e.message + '\n' + frameTexts.join('\n') + innerError; e.stack = e.message + '\n' + frameTexts.join('\n') + innerError;
csi?.onApiCallEnd(callCookie, e); csi?.onApiCallEnd(callCookie, e);
logApiCall(logger, `<= ${apiName} failed`, isNested); logApiCall(logger, `<= ${apiName} failed`, isInternal);
throw e; throw e;
} }
} }

View file

@ -108,7 +108,7 @@ export class ElectronApplication extends ChannelOwner<channels.ElectronApplicati
return this._wrapApiCall(async (channel: channels.ElectronApplicationChannel) => { return this._wrapApiCall(async (channel: channels.ElectronApplicationChannel) => {
const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate);
const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate;
const waiter = Waiter.createForEvent(this, event); const waiter = Waiter.createForEvent(channel, event);
waiter.rejectOnTimeout(timeout, `Timeout while waiting for event "${event}"`); waiter.rejectOnTimeout(timeout, `Timeout while waiting for event "${event}"`);
if (event !== Events.ElectronApplication.Close) if (event !== Events.ElectronApplication.Close)
waiter.rejectOnEvent(this, Events.ElectronApplication.Close, new Error('Electron application closed')); waiter.rejectOnEvent(this, Events.ElectronApplication.Close, new Error('Electron application closed'));

View file

@ -94,8 +94,8 @@ export class Frame extends ChannelOwner<channels.FrameChannel, channels.FrameIni
}); });
} }
private _setupNavigationWaiter(options: { timeout?: number }): Waiter { private _setupNavigationWaiter(channel: channels.EventTargetChannel, options: { timeout?: number }): Waiter {
const waiter = new Waiter(this._page!, ''); const waiter = new Waiter(channel, '');
if (this._page!.isClosed()) if (this._page!.isClosed())
waiter.rejectImmediately(new Error('Navigation failed because page was closed!')); waiter.rejectImmediately(new Error('Navigation failed because page was closed!'));
waiter.rejectOnEvent(this._page!, Events.Page.Close, new Error('Navigation failed because page was closed!')); waiter.rejectOnEvent(this._page!, Events.Page.Close, new Error('Navigation failed because page was closed!'));
@ -107,9 +107,9 @@ export class Frame extends ChannelOwner<channels.FrameChannel, channels.FrameIni
} }
async waitForNavigation(options: WaitForNavigationOptions = {}): Promise<network.Response | null> { async waitForNavigation(options: WaitForNavigationOptions = {}): Promise<network.Response | null> {
return this._wrapApiCall(async (channel: channels.FrameChannel) => { return this._page!._wrapApiCall(async (channel: channels.PageChannel) => {
const waitUntil = verifyLoadState('waitUntil', options.waitUntil === undefined ? 'load' : options.waitUntil); const waitUntil = verifyLoadState('waitUntil', options.waitUntil === undefined ? 'load' : options.waitUntil);
const waiter = this._setupNavigationWaiter(options); const waiter = this._setupNavigationWaiter(channel, options);
const toUrl = typeof options.url === 'string' ? ` to "${options.url}"` : ''; const toUrl = typeof options.url === 'string' ? ` to "${options.url}"` : '';
waiter.log(`waiting for navigation${toUrl} until "${waitUntil}"`); waiter.log(`waiting for navigation${toUrl} until "${waitUntil}"`);
@ -135,7 +135,7 @@ export class Frame extends ChannelOwner<channels.FrameChannel, channels.FrameIni
} }
const request = navigatedEvent.newDocument ? network.Request.fromNullable(navigatedEvent.newDocument.request) : null; const request = navigatedEvent.newDocument ? network.Request.fromNullable(navigatedEvent.newDocument.request) : null;
const response = request ? await waiter.waitForPromise(request._finalRequest().response()) : null; const response = request ? await waiter.waitForPromise(request._finalRequest()._internalResponse()) : null;
waiter.dispose(); waiter.dispose();
return response; return response;
}); });
@ -145,8 +145,8 @@ export class Frame extends ChannelOwner<channels.FrameChannel, channels.FrameIni
state = verifyLoadState('state', state); state = verifyLoadState('state', state);
if (this._loadStates.has(state)) if (this._loadStates.has(state))
return; return;
return this._wrapApiCall(async (channel: channels.FrameChannel) => { return this._page!._wrapApiCall(async (channel: channels.PageChannel) => {
const waiter = this._setupNavigationWaiter(options); const waiter = this._setupNavigationWaiter(channel, options);
await waiter.waitForEvent<LifecycleEvent>(this._eventEmitter, 'loadstate', s => { await waiter.waitForEvent<LifecycleEvent>(this._eventEmitter, 'loadstate', s => {
waiter.log(` "${s}" event fired`); waiter.log(` "${s}" event fired`);
return s === state; return s === state;

View file

@ -167,6 +167,12 @@ export class Request extends ChannelOwner<channels.RequestChannel, channels.Requ
}); });
} }
async _internalResponse(): Promise<Response | null> {
return this._wrapApiCall(async (channel: channels.RequestChannel) => {
return Response.fromNullable((await channel.response()).response);
}, undefined, true);
}
frame(): Frame { frame(): Frame {
return Frame.from(this._initializer.frame); return Frame.from(this._initializer.frame);
} }
@ -386,9 +392,13 @@ export class Route extends ChannelOwner<channels.RouteChannel, channels.RouteIni
await this._continue(options, false); await this._continue(options, false);
} }
async _continue(options: { url?: string, method?: string, headers?: Headers, postData?: string | Buffer }, interceptResponse: NotInterceptResponse): Promise<null>; async _internalContinue(options: { url?: string, method?: string, headers?: Headers, postData?: string | Buffer } = {}) {
async _continue(options: { url?: string, method?: string, headers?: Headers, postData?: string | Buffer }, interceptResponse: InterceptResponse): Promise<api.Response>; await this._continue(options, false, true).catch(() => {});
async _continue(options: { url?: string, method?: string, headers?: Headers, postData?: string | Buffer }, interceptResponse: boolean): Promise<null|api.Response> { }
async _continue(options: { url?: string, method?: string, headers?: Headers, postData?: string | Buffer }, interceptResponse: NotInterceptResponse, isInternal?: boolean): Promise<null>;
async _continue(options: { url?: string, method?: string, headers?: Headers, postData?: string | Buffer }, interceptResponse: InterceptResponse, isInternal?: boolean): Promise<api.Response>;
async _continue(options: { url?: string, method?: string, headers?: Headers, postData?: string | Buffer }, interceptResponse: boolean, isInternal?: boolean): Promise<null|api.Response> {
return await this._wrapApiCall(async (channel: channels.RouteChannel) => { return await this._wrapApiCall(async (channel: channels.RouteChannel) => {
const postDataBuffer = isString(options.postData) ? Buffer.from(options.postData, 'utf8') : options.postData; const postDataBuffer = isString(options.postData) ? Buffer.from(options.postData, 'utf8') : options.postData;
const result = await channel.continue({ const result = await channel.continue({
@ -401,7 +411,7 @@ export class Route extends ChannelOwner<channels.RouteChannel, channels.RouteIni
if (result.response) if (result.response)
return new InterceptedResponse(this, result.response); return new InterceptedResponse(this, result.response);
return null; return null;
}); }, undefined, isInternal);
} }
async _responseBody(): Promise<Buffer> { async _responseBody(): Promise<Buffer> {
@ -585,7 +595,7 @@ export class WebSocket extends ChannelOwner<channels.WebSocketChannel, channels.
return this._wrapApiCall(async (channel: channels.WebSocketChannel) => { return this._wrapApiCall(async (channel: channels.WebSocketChannel) => {
const timeout = this._page._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); const timeout = this._page._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate);
const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate;
const waiter = Waiter.createForEvent(this, event); const waiter = Waiter.createForEvent(channel, event);
waiter.rejectOnTimeout(timeout, `Timeout while waiting for event "${event}"`); waiter.rejectOnTimeout(timeout, `Timeout while waiting for event "${event}"`);
if (event !== Events.WebSocket.Error) if (event !== Events.WebSocket.Error)
waiter.rejectOnEvent(this, Events.WebSocket.Error, new Error('Socket error')); waiter.rejectOnEvent(this, Events.WebSocket.Error, new Error('Socket error'));

View file

@ -369,7 +369,7 @@ export class Page extends ChannelOwner<channels.PageChannel, channels.PageInitia
}; };
const trimmedUrl = trimUrl(urlOrPredicate); const trimmedUrl = trimUrl(urlOrPredicate);
const logLine = trimmedUrl ? `waiting for request ${trimmedUrl}` : undefined; const logLine = trimmedUrl ? `waiting for request ${trimmedUrl}` : undefined;
return this._waitForEvent(Events.Page.Request, { predicate, timeout: options.timeout }, logLine); return this._waitForEvent(channel, Events.Page.Request, { predicate, timeout: options.timeout }, logLine);
}); });
} }
@ -382,18 +382,20 @@ export class Page extends ChannelOwner<channels.PageChannel, channels.PageInitia
}; };
const trimmedUrl = trimUrl(urlOrPredicate); const trimmedUrl = trimUrl(urlOrPredicate);
const logLine = trimmedUrl ? `waiting for response ${trimmedUrl}` : undefined; const logLine = trimmedUrl ? `waiting for response ${trimmedUrl}` : undefined;
return this._waitForEvent(Events.Page.Response, { predicate, timeout: options.timeout }, logLine); return this._waitForEvent(channel, Events.Page.Response, { predicate, timeout: options.timeout }, logLine);
}); });
} }
async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise<any> { async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise<any> {
return this._waitForEvent(event, optionsOrPredicate, `waiting for event "${event}"`); return this._wrapApiCall(async channel => {
return this._waitForEvent(channel, event, optionsOrPredicate, `waiting for event "${event}"`);
});
} }
private async _waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions, logLine?: string): Promise<any> { private async _waitForEvent(channel: channels.EventTargetChannel, event: string, optionsOrPredicate: WaitForEventOptions, logLine?: string): Promise<any> {
const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate); const timeout = this._timeoutSettings.timeout(typeof optionsOrPredicate === 'function' ? {} : optionsOrPredicate);
const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate; const predicate = typeof optionsOrPredicate === 'function' ? optionsOrPredicate : optionsOrPredicate.predicate;
const waiter = Waiter.createForEvent(this, event); const waiter = Waiter.createForEvent(channel, event);
if (logLine) if (logLine)
waiter.log(logLine); waiter.log(logLine);
waiter.rejectOnTimeout(timeout, `Timeout while waiting for event "${event}"`); waiter.rejectOnTimeout(timeout, `Timeout while waiting for event "${event}"`);

View file

@ -19,7 +19,6 @@ import { rewriteErrorMessage } from '../utils/stackTrace';
import { TimeoutError } from '../utils/errors'; import { TimeoutError } from '../utils/errors';
import { createGuid } from '../utils/utils'; import { createGuid } from '../utils/utils';
import * as channels from '../protocol/channels'; import * as channels from '../protocol/channels';
import { ChannelOwner } from './channelOwner';
export class Waiter { export class Waiter {
private _dispose: (() => void)[]; private _dispose: (() => void)[];
@ -31,19 +30,17 @@ export class Waiter {
private _waitId: string; private _waitId: string;
private _error: string | undefined; private _error: string | undefined;
constructor(channelOwner: ChannelOwner<channels.EventTargetChannel>, event: string) { constructor(channel: channels.EventTargetChannel, event: string) {
this._waitId = createGuid(); this._waitId = createGuid();
this._channel = channelOwner._channel; this._channel = channel;
channelOwner._wrapApiCall(async (channel: channels.EventTargetChannel) => { this._channel.waitForEventInfo({ info: { waitId: this._waitId, phase: 'before', event } }).catch(() => {});
channel.waitForEventInfo({ info: { waitId: this._waitId, phase: 'before', event } }).catch(() => {});
});
this._dispose = [ this._dispose = [
() => this._channel.waitForEventInfo({ info: { waitId: this._waitId, phase: 'after', error: this._error } }).catch(() => {}) () => this._channel.waitForEventInfo({ info: { waitId: this._waitId, phase: 'after', error: this._error } }).catch(() => {})
]; ];
} }
static createForEvent(channelOwner: ChannelOwner<channels.EventTargetChannel>, event: string) { static createForEvent(channel: channels.EventTargetChannel, event: string) {
return new Waiter(channelOwner, event); return new Waiter(channel, event);
} }
async waitForEvent<T = void>(emitter: EventEmitter, event: string, predicate?: (arg: T) => boolean | Promise<boolean>): Promise<T> { async waitForEvent<T = void>(emitter: EventEmitter, event: string, predicate?: (arg: T) => boolean | Promise<boolean>): Promise<T> {

View file

@ -75,6 +75,9 @@ export abstract class BrowserContext extends SdkObject {
this._isPersistentContext = !browserContextId; this._isPersistentContext = !browserContextId;
this._closePromise = new Promise(fulfill => this._closePromiseFulfill = fulfill); this._closePromise = new Promise(fulfill => this._closePromiseFulfill = fulfill);
// Create instrumentation per context.
this.instrumentation = createInstrumentation();
if (this._options.recordHar) if (this._options.recordHar)
this._harRecorder = new HarRecorder(this, { ...this._options.recordHar, path: path.join(this._browser.options.artifactsDir, `${createGuid()}.har`) }); this._harRecorder = new HarRecorder(this, { ...this._options.recordHar, path: path.join(this._browser.options.artifactsDir, `${createGuid()}.har`) });
@ -93,9 +96,6 @@ export abstract class BrowserContext extends SdkObject {
async _initialize() { async _initialize() {
if (this.attribution.isInternal) if (this.attribution.isInternal)
return; return;
// Create instrumentation per context.
this.instrumentation = createInstrumentation();
// Debugger will pause execution upon page.pause in headed mode. // Debugger will pause execution upon page.pause in headed mode.
const contextDebugger = new Debugger(this); const contextDebugger = new Debugger(this);
this.instrumentation.addListener(contextDebugger); this.instrumentation.addListener(contextDebugger);

View file

@ -45,7 +45,6 @@ export type ScreencastFrameTraceEvent = {
export type ActionTraceEvent = { export type ActionTraceEvent = {
type: 'action' | 'event', type: 'action' | 'event',
hasSnapshot: boolean,
metadata: CallMetadata, metadata: CallMetadata,
}; };

View file

@ -260,21 +260,21 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate, Ha
const pendingCall = this._pendingCalls.get(metadata.id); const pendingCall = this._pendingCalls.get(metadata.id);
if (!pendingCall || pendingCall.afterSnapshot) if (!pendingCall || pendingCall.afterSnapshot)
return; return;
if (!sdkObject.attribution.page) { if (!sdkObject.attribution.context) {
this._pendingCalls.delete(metadata.id); this._pendingCalls.delete(metadata.id);
return; return;
} }
pendingCall.afterSnapshot = this._captureSnapshot('after', sdkObject, metadata); pendingCall.afterSnapshot = this._captureSnapshot('after', sdkObject, metadata);
await pendingCall.afterSnapshot; await pendingCall.afterSnapshot;
const event: trace.ActionTraceEvent = { type: 'action', metadata, hasSnapshot: shouldCaptureSnapshot(metadata) }; const event: trace.ActionTraceEvent = { type: 'action', metadata };
this._appendTraceEvent(event); this._appendTraceEvent(event);
this._pendingCalls.delete(metadata.id); this._pendingCalls.delete(metadata.id);
} }
onEvent(sdkObject: SdkObject, metadata: CallMetadata) { onEvent(sdkObject: SdkObject, metadata: CallMetadata) {
if (!sdkObject.attribution.page) if (!sdkObject.attribution.context)
return; return;
const event: trace.ActionTraceEvent = { type: 'event', metadata, hasSnapshot: false }; const event: trace.ActionTraceEvent = { type: 'event', metadata };
this._appendTraceEvent(event); this._appendTraceEvent(event);
} }

View file

@ -38,7 +38,7 @@ export type ParsedStackTrace = {
allFrames: StackFrame[]; allFrames: StackFrame[];
frames: StackFrame[]; frames: StackFrame[];
frameTexts: string[]; frameTexts: string[];
apiName: string; apiName: string | undefined;
}; };
export function captureStackTrace(): ParsedStackTrace { export function captureStackTrace(): ParsedStackTrace {

View file

@ -0,0 +1,58 @@
/**
* 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 { ResourceSnapshot } from '../../server/trace/common/snapshotTypes';
import * as trace from '../../server/trace/common/traceEvents';
export type ContextEntry = {
startTime: number;
endTime: number;
browserName: string;
options: trace.BrowserContextEventOptions;
pages: PageEntry[];
resources: ResourceSnapshot[];
actions: trace.ActionTraceEvent[];
events: trace.ActionTraceEvent[];
objects: { [key: string]: any };
};
export type PageEntry = {
screencastFrames: {
sha1: string,
timestamp: number,
width: number,
height: number,
}[];
};
export function createEmptyContext(): ContextEntry {
const now = performance.now();
return {
startTime: now,
endTime: now,
browserName: '',
options: {
deviceScaleFactor: 1,
isMobile: false,
viewport: { width: 1280, height: 800 },
_debugName: '<empty>',
},
pages: [],
resources: [],
actions: [],
events: [],
objects: {},
};
}

View file

@ -15,10 +15,12 @@
*/ */
import * as trace from '../../server/trace/common/traceEvents'; import * as trace from '../../server/trace/common/traceEvents';
import type { ResourceSnapshot } from '../../server/trace/common/snapshotTypes';
import { BaseSnapshotStorage } from './snapshotStorage'; import { BaseSnapshotStorage } from './snapshotStorage';
import type zip from '@zip.js/zip.js'; import type zip from '@zip.js/zip.js';
import { ContextEntry, createEmptyContext, PageEntry } from './entries';
import type { CallMetadata } from '../../protocol/callMetadata';
// @ts-ignore // @ts-ignore
self.importScripts('zip.min.js'); self.importScripts('zip.min.js');
@ -32,14 +34,7 @@ export class TraceModel {
private _version: number | undefined; private _version: number | undefined;
constructor() { constructor() {
this.contextEntry = { this.contextEntry = createEmptyContext();
startTime: Number.MAX_VALUE,
endTime: Number.MIN_VALUE,
browserName: '',
options: { },
pages: [],
resources: [],
};
} }
async load(traceURL: string) { async load(traceURL: string) {
@ -85,8 +80,7 @@ export class TraceModel {
} }
private _build() { private _build() {
for (const page of this.contextEntry!.pages) this.contextEntry!.actions.sort((a1, a2) => a1.metadata.startTime - a2.metadata.startTime);
page.actions.sort((a1, a2) => a1.metadata.startTime - a2.metadata.startTime);
this.contextEntry!.resources = this._snapshotStorage!.resources(); this.contextEntry!.resources = this._snapshotStorage!.resources();
} }
@ -94,9 +88,6 @@ export class TraceModel {
let pageEntry = this.pageEntries.get(pageId); let pageEntry = this.pageEntries.get(pageId);
if (!pageEntry) { if (!pageEntry) {
pageEntry = { pageEntry = {
actions: [],
events: [],
objects: {},
screencastFrames: [], screencastFrames: [],
}; };
this.pageEntries.set(pageId, pageEntry); this.pageEntries.set(pageId, pageEntry);
@ -120,19 +111,18 @@ export class TraceModel {
break; break;
} }
case 'action': { case 'action': {
const metadata = event.metadata; const include = !!event.metadata.apiName && !isTracing(event.metadata);
const include = event.hasSnapshot; if (include)
if (include && metadata.pageId) this.contextEntry!.actions.push(event);
this._pageEntry(metadata.pageId).actions.push(event);
break; break;
} }
case 'event': { case 'event': {
const metadata = event.metadata; const metadata = event.metadata;
if (metadata.pageId) { if (metadata.pageId) {
if (metadata.method === '__create__') if (metadata.method === '__create__')
this._pageEntry(metadata.pageId).objects[metadata.params.guid] = metadata.params.initializer; this.contextEntry!.objects[metadata.params.guid] = metadata.params.initializer;
else else
this._pageEntry(metadata.pageId).events.push(event); this.contextEntry!.events.push(event);
} }
break; break;
} }
@ -161,8 +151,6 @@ export class TraceModel {
if (event.type === 'action') { if (event.type === 'action') {
if (typeof event.metadata.error === 'string') if (typeof event.metadata.error === 'string')
event.metadata.error = { error: { name: 'Error', message: event.metadata.error } }; event.metadata.error = { error: { name: 'Error', message: event.metadata.error } };
if (event.metadata && typeof event.hasSnapshot !== 'boolean')
event.hasSnapshot = commandsWithTracingSnapshots.has(event.metadata);
} }
return event; return event;
} }
@ -202,27 +190,6 @@ export class TraceModel {
} }
} }
export type ContextEntry = {
startTime: number;
endTime: number;
browserName: string;
options: trace.BrowserContextEventOptions;
pages: PageEntry[];
resources: ResourceSnapshot[];
};
export type PageEntry = {
actions: trace.ActionTraceEvent[];
events: trace.ActionTraceEvent[];
objects: { [key: string]: any };
screencastFrames: {
sha1: string,
timestamp: number,
width: number,
height: number,
}[];
};
export class PersistentSnapshotStorage extends BaseSnapshotStorage { export class PersistentSnapshotStorage extends BaseSnapshotStorage {
private _entries: Map<string, zip.Entry>; private _entries: Map<string, zip.Entry>;
@ -239,96 +206,6 @@ export class PersistentSnapshotStorage extends BaseSnapshotStorage {
} }
} }
// Prior to version 2 we did not have a hasSnapshot bit on. function isTracing(metadata: CallMetadata): boolean {
export const commandsWithTracingSnapshots = new Set([ return metadata.method.startsWith('tracing');
'EventTarget.waitForEventInfo', }
'BrowserContext.waitForEventInfo',
'Page.waitForEventInfo',
'WebSocket.waitForEventInfo',
'ElectronApplication.waitForEventInfo',
'AndroidDevice.waitForEventInfo',
'Page.goBack',
'Page.goForward',
'Page.reload',
'Page.setViewportSize',
'Page.keyboardDown',
'Page.keyboardUp',
'Page.keyboardInsertText',
'Page.keyboardType',
'Page.keyboardPress',
'Page.mouseMove',
'Page.mouseDown',
'Page.mouseUp',
'Page.mouseClick',
'Page.mouseWheel',
'Page.touchscreenTap',
'Frame.evalOnSelector',
'Frame.evalOnSelectorAll',
'Frame.addScriptTag',
'Frame.addStyleTag',
'Frame.check',
'Frame.click',
'Frame.dragAndDrop',
'Frame.dblclick',
'Frame.dispatchEvent',
'Frame.evaluateExpression',
'Frame.evaluateExpressionHandle',
'Frame.fill',
'Frame.focus',
'Frame.getAttribute',
'Frame.goto',
'Frame.hover',
'Frame.innerHTML',
'Frame.innerText',
'Frame.inputValue',
'Frame.isChecked',
'Frame.isDisabled',
'Frame.isEnabled',
'Frame.isHidden',
'Frame.isVisible',
'Frame.isEditable',
'Frame.press',
'Frame.selectOption',
'Frame.setContent',
'Frame.setInputFiles',
'Frame.tap',
'Frame.textContent',
'Frame.type',
'Frame.uncheck',
'Frame.waitForTimeout',
'Frame.waitForFunction',
'Frame.waitForSelector',
'Frame.expect',
'JSHandle.evaluateExpression',
'ElementHandle.evaluateExpression',
'JSHandle.evaluateExpressionHandle',
'ElementHandle.evaluateExpressionHandle',
'ElementHandle.evalOnSelector',
'ElementHandle.evalOnSelectorAll',
'ElementHandle.check',
'ElementHandle.click',
'ElementHandle.dblclick',
'ElementHandle.dispatchEvent',
'ElementHandle.fill',
'ElementHandle.hover',
'ElementHandle.innerHTML',
'ElementHandle.innerText',
'ElementHandle.inputValue',
'ElementHandle.isChecked',
'ElementHandle.isDisabled',
'ElementHandle.isEditable',
'ElementHandle.isEnabled',
'ElementHandle.isHidden',
'ElementHandle.isVisible',
'ElementHandle.press',
'ElementHandle.scrollIntoViewIfNeeded',
'ElementHandle.selectOption',
'ElementHandle.selectText',
'ElementHandle.setInputFiles',
'ElementHandle.tap',
'ElementHandle.textContent',
'ElementHandle.type',
'ElementHandle.uncheck',
'ElementHandle.waitForElementState',
'ElementHandle.waitForSelector'
]);

View file

@ -27,13 +27,13 @@ export const ConsoleTab: React.FunctionComponent<{
if (!action) if (!action)
return []; return [];
const entries: { message?: channels.ConsoleMessageInitializer, error?: channels.SerializedError }[] = []; const entries: { message?: channels.ConsoleMessageInitializer, error?: channels.SerializedError }[] = [];
const page = modelUtil.page(action); const context = modelUtil.context(action);
for (const event of modelUtil.eventsForAction(action)) { for (const event of modelUtil.eventsForAction(action)) {
if (event.metadata.method !== 'console' && event.metadata.method !== 'pageError') if (event.metadata.method !== 'console' && event.metadata.method !== 'pageError')
continue; continue;
if (event.metadata.method === 'console') { if (event.metadata.method === 'console') {
const { guid } = event.metadata.params.message; const { guid } = event.metadata.params.message;
entries.push({ message: page.objects[guid] }); entries.push({ message: context.objects[guid] });
} }
if (event.metadata.method === 'pageError') if (event.metadata.method === 'pageError')
entries.push({ error: event.metadata.params.error }); entries.push({ error: event.metadata.params.error });

View file

@ -19,7 +19,7 @@ import { Boundaries, Size } from '../geometry';
import * as React from 'react'; import * as React from 'react';
import { useMeasure } from './helpers'; import { useMeasure } from './helpers';
import { upperBound } from '../../uiUtils'; import { upperBound } from '../../uiUtils';
import { ContextEntry, PageEntry } from '../traceModel'; import { ContextEntry, PageEntry } from '../entries';
const tileSize = { width: 200, height: 45 }; const tileSize = { width: 200, height: 45 };

View file

@ -16,38 +16,29 @@
import { ResourceSnapshot } from '../../../server/trace/common/snapshotTypes'; import { ResourceSnapshot } from '../../../server/trace/common/snapshotTypes';
import { ActionTraceEvent } from '../../../server/trace/common/traceEvents'; import { ActionTraceEvent } from '../../../server/trace/common/traceEvents';
import { ContextEntry, PageEntry } from '../traceModel'; import { ContextEntry } from '../entries';
const contextSymbol = Symbol('context'); const contextSymbol = Symbol('context');
const pageSymbol = Symbol('context');
const nextSymbol = Symbol('next'); const nextSymbol = Symbol('next');
const eventsSymbol = Symbol('events'); const eventsSymbol = Symbol('events');
const resourcesSymbol = Symbol('resources'); const resourcesSymbol = Symbol('resources');
export function indexModel(context: ContextEntry) { export function indexModel(context: ContextEntry) {
for (const page of context.pages) { for (const page of context.pages)
(page as any)[contextSymbol] = context; (page as any)[contextSymbol] = context;
for (let i = 0; i < page.actions.length; ++i) { for (let i = 0; i < context.actions.length; ++i) {
const action = page.actions[i] as any; const action = context.actions[i] as any;
action[contextSymbol] = context; action[contextSymbol] = context;
action[pageSymbol] = page; action[nextSymbol] = context.actions[i + 1];
action[nextSymbol] = page.actions[i + 1];
} }
for (const event of page.events) { for (const event of context.events)
(event as any)[contextSymbol] = context; (event as any)[contextSymbol] = context;
(event as any)[pageSymbol] = page;
}
}
} }
export function context(action: ActionTraceEvent): ContextEntry { export function context(action: ActionTraceEvent): ContextEntry {
return (action as any)[contextSymbol]; return (action as any)[contextSymbol];
} }
export function page(action: ActionTraceEvent): PageEntry {
return (action as any)[pageSymbol];
}
export function next(action: ActionTraceEvent): ActionTraceEvent { export function next(action: ActionTraceEvent): ActionTraceEvent {
return (action as any)[nextSymbol]; return (action as any)[nextSymbol];
} }
@ -55,11 +46,11 @@ export function next(action: ActionTraceEvent): ActionTraceEvent {
export function stats(action: ActionTraceEvent): { errors: number, warnings: number } { export function stats(action: ActionTraceEvent): { errors: number, warnings: number } {
let errors = 0; let errors = 0;
let warnings = 0; let warnings = 0;
const p = page(action); const c = context(action);
for (const event of eventsForAction(action)) { for (const event of eventsForAction(action)) {
if (event.metadata.method === 'console') { if (event.metadata.method === 'console') {
const { guid } = event.metadata.params.message; const { guid } = event.metadata.params.message;
const type = p.objects[guid]?.type; const type = c.objects[guid]?.type;
if (type === 'warning') if (type === 'warning')
++warnings; ++warnings;
else if (type === 'error') else if (type === 'error')
@ -77,7 +68,7 @@ export function eventsForAction(action: ActionTraceEvent): ActionTraceEvent[] {
return result; return result;
const nextAction = next(action); const nextAction = next(action);
result = page(action).events.filter(event => { result = context(action).events.filter(event => {
return event.metadata.startTime >= action.metadata.startTime && (!nextAction || event.metadata.startTime < nextAction.metadata.startTime); return event.metadata.startTime >= action.metadata.startTime && (!nextAction || event.metadata.startTime < nextAction.metadata.startTime);
}); });
(action as any)[eventsSymbol] = result; (action as any)[eventsSymbol] = result;

View file

@ -16,7 +16,7 @@
*/ */
import { ActionTraceEvent } from '../../../server/trace/common/traceEvents'; import { ActionTraceEvent } from '../../../server/trace/common/traceEvents';
import { ContextEntry } from '../traceModel'; import { ContextEntry } from '../entries';
import './timeline.css'; import './timeline.css';
import { Boundaries } from '../geometry'; import { Boundaries } from '../geometry';
import * as React from 'react'; import * as React from 'react';
@ -56,8 +56,7 @@ export const Timeline: React.FunctionComponent<{
const bars = React.useMemo(() => { const bars = React.useMemo(() => {
const bars: TimelineBar[] = []; const bars: TimelineBar[] = [];
for (const page of context.pages) { for (const entry of context.actions) {
for (const entry of page.actions) {
let detail = trimRight(entry.metadata.params.selector || '', 50); let detail = trimRight(entry.metadata.params.selector || '', 50);
if (entry.metadata.method === 'goto') if (entry.metadata.method === 'goto')
detail = trimRight(entry.metadata.params.url || '', 50); detail = trimRight(entry.metadata.params.url || '', 50);
@ -73,7 +72,7 @@ export const Timeline: React.FunctionComponent<{
}); });
} }
for (const event of page.events) { for (const event of context.events) {
const startTime = event.metadata.startTime; const startTime = event.metadata.startTime;
bars.push({ bars.push({
event, event,
@ -86,7 +85,6 @@ export const Timeline: React.FunctionComponent<{
className: `${event.metadata.type}_${event.metadata.method}`.toLowerCase() className: `${event.metadata.type}_${event.metadata.method}`.toLowerCase()
}); });
} }
}
return bars; return bars;
}, [context, boundaries, measure.width]); }, [context, boundaries, measure.width]);

View file

@ -15,7 +15,7 @@
*/ */
import { ActionTraceEvent } from '../../../server/trace/common/traceEvents'; import { ActionTraceEvent } from '../../../server/trace/common/traceEvents';
import { ContextEntry } from '../traceModel'; import { ContextEntry, createEmptyContext } from '../entries';
import { ActionList } from './actionList'; import { ActionList } from './actionList';
import { TabbedPane } from './tabbedPane'; import { TabbedPane } from './tabbedPane';
import { Timeline } from './timeline'; import { Timeline } from './timeline';
@ -49,13 +49,6 @@ export const Workbench: React.FunctionComponent<{
})(); })();
}, [traceURL]); }, [traceURL]);
const actions = React.useMemo(() => {
const actions: ActionTraceEvent[] = [];
for (const page of contextEntry.pages)
actions.push(...page.actions);
return actions;
}, [contextEntry]);
const defaultSnapshotSize = contextEntry.options.viewport || { width: 1280, height: 720 }; const defaultSnapshotSize = contextEntry.options.viewport || { width: 1280, height: 720 };
const boundaries = { minimum: contextEntry.startTime, maximum: contextEntry.endTime }; const boundaries = { minimum: contextEntry.startTime, maximum: contextEntry.endTime };
@ -98,7 +91,7 @@ export const Workbench: React.FunctionComponent<{
]} selectedTab={selectedTab} setSelectedTab={setSelectedTab}/> ]} selectedTab={selectedTab} setSelectedTab={setSelectedTab}/>
</SplitView> </SplitView>
<ActionList <ActionList
actions={actions} actions={contextEntry.actions}
selectedAction={selectedAction} selectedAction={selectedAction}
highlightedAction={highlightedAction} highlightedAction={highlightedAction}
onSelected={action => { onSelected={action => {
@ -111,17 +104,4 @@ export const Workbench: React.FunctionComponent<{
</div>; </div>;
}; };
const now = performance.now(); const emptyContext = createEmptyContext();
const emptyContext: ContextEntry = {
startTime: now,
endTime: now,
browserName: '',
options: {
deviceScaleFactor: 1,
isMobile: false,
viewport: { width: 1280, height: 800 },
_debugName: '<empty>',
},
pages: [],
resources: [],
};

View file

@ -127,7 +127,7 @@ class HtmlReporter {
const stats = builder.build(reports); const stats = builder.build(reports);
if (!stats.ok && !process.env.CI && !process.env.PWTEST_SKIP_TEST_OUTPUT) { if (!stats.ok && !process.env.CI && !process.env.PWTEST_SKIP_TEST_OUTPUT) {
showHTMLReport(reportFolder); await showHTMLReport(reportFolder);
} else { } else {
console.log(''); console.log('');
console.log(''); console.log('');
@ -168,7 +168,6 @@ export async function showHTMLReport(reportFolder: string | undefined) {
const url = await server.start(9323); const url = await server.start(9323);
console.log(''); console.log('');
console.log(colors.cyan(` Serving HTML report at ${url}. Press Ctrl+C to quit.`)); console.log(colors.cyan(` Serving HTML report at ${url}. Press Ctrl+C to quit.`));
console.log('');
open(url); open(url);
process.on('SIGINT', () => process.exit(0)); process.on('SIGINT', () => process.exit(0));
await new Promise(() => {}); await new Promise(() => {});

View file

@ -904,7 +904,7 @@ it('context request should export same storage state as context', async ({ conte
expect(pageState).toEqual(contextState); expect(pageState).toEqual(contextState);
}); });
it('should accept bool and numeric params', async ({ context, page, server }) => { it('should accept bool and numeric params', async ({ page, server }) => {
let request; let request;
const url = new URL(server.EMPTY_PAGE); const url = new URL(server.EMPTY_PAGE);
url.searchParams.set('str', 's'); url.searchParams.set('str', 's');

View file

@ -180,17 +180,19 @@ test('should show empty trace viewer', async ({ showTraceViewer }, testInfo) =>
test('should open simple trace viewer', async ({ showTraceViewer }) => { test('should open simple trace viewer', async ({ showTraceViewer }) => {
const traceViewer = await showTraceViewer(traceFile); const traceViewer = await showTraceViewer(traceFile);
await expect(traceViewer.actionTitles).toHaveText([ await expect(traceViewer.actionTitles).toHaveText([
/browserContext.newPage— [\d.ms]+/,
/page.gotodata:text\/html,<html>Hello world<\/html>— [\d.ms]+/, /page.gotodata:text\/html,<html>Hello world<\/html>— [\d.ms]+/,
/page.setContent— [\d.ms]+/, /page.setContent— [\d.ms]+/,
/expect.toHaveTextbutton— [\d.ms]+/, /expect.toHaveTextbutton— [\d.ms]+/,
/page.evaluate— [\d.ms]+/, /page.evaluate— [\d.ms]+/,
/page.click"Click"— [\d.ms]+/, /page.click"Click"— [\d.ms]+/,
/page.waitForEvent— [\d.ms]+/, /page.waitForEvent— [\d.ms]+/,
/page.route— [\d.ms]+/,
/page.waitForNavigation— [\d.ms]+/, /page.waitForNavigation— [\d.ms]+/,
/page.waitForTimeout— [\d.ms]+/, /page.waitForTimeout— [\d.ms]+/,
/page.gotohttp:\/\/localhost:\d+\/frames\/frame.html— [\d.ms]+/, /page.gotohttp:\/\/localhost:\d+\/frames\/frame.html— [\d.ms]+/,
/route.continue— [\d.ms]+/,
/page.setViewportSize— [\d.ms]+/, /page.setViewportSize— [\d.ms]+/,
/page.hoverbody— [\d.ms]+/,
]); ]);
}); });
@ -261,12 +263,6 @@ test('should have correct stack trace', async ({ showTraceViewer }) => {
/doClick\s+trace-viewer.spec.ts\s+:\d+/, /doClick\s+trace-viewer.spec.ts\s+:\d+/,
/recordTrace\s+trace-viewer.spec.ts\s+:\d+/, /recordTrace\s+trace-viewer.spec.ts\s+:\d+/,
], { useInnerText: true }); ], { useInnerText: true });
await traceViewer.selectAction('page.hover');
await traceViewer.showSourceTab();
await expect(traceViewer.stackFrames).toContainText([
/BrowserType.browserType._onWillCloseContext\s+trace-viewer.spec.ts\s+:\d+/,
], { useInnerText: true });
}); });
test('should have network requests', async ({ showTraceViewer }) => { test('should have network requests', async ({ showTraceViewer }) => {

View file

@ -175,7 +175,7 @@ DEPS['src/cli/driver.ts'] = DEPS['src/inProcessFactory.ts'] = DEPS['src/browserS
// Tracing is a client/server plugin, nothing should depend on it. // Tracing is a client/server plugin, nothing should depend on it.
DEPS['src/web/recorder/'] = ['src/common/', 'src/web/', 'src/web/components/', 'src/server/supplements/recorder/recorderTypes.ts']; DEPS['src/web/recorder/'] = ['src/common/', 'src/web/', 'src/web/components/', 'src/server/supplements/recorder/recorderTypes.ts'];
DEPS['src/web/traceViewer/'] = ['src/common/', 'src/web/', 'src/server/trace/common/']; DEPS['src/web/traceViewer/'] = ['src/common/', 'src/web/', 'src/server/trace/common/', 'src/protocol/callMetadata.ts'];
DEPS['src/web/traceViewer/ui/'] = ['src/common/', 'src/protocol/', 'src/web/traceViewer/', 'src/web/', 'src/server/trace/viewer/', 'src/server/trace/', 'src/server/trace/common/', 'src/server/snapshot/snapshotTypes.ts', 'src/protocol/channels.ts']; DEPS['src/web/traceViewer/ui/'] = ['src/common/', 'src/protocol/', 'src/web/traceViewer/', 'src/web/', 'src/server/trace/viewer/', 'src/server/trace/', 'src/server/trace/common/', 'src/server/snapshot/snapshotTypes.ts', 'src/protocol/channels.ts'];
DEPS['src/web/traceViewer/inMemorySnapshotter.ts'] = ['src/**']; DEPS['src/web/traceViewer/inMemorySnapshotter.ts'] = ['src/**'];