feat(inspector): instrument wait for event (#5457)

This commit is contained in:
Pavel Feldman 2021-02-13 20:31:06 -08:00 committed by GitHub
parent e7b431d2f7
commit ae2ffb3fb9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 151 additions and 39 deletions

View File

@ -239,7 +239,7 @@ export class AndroidDevice extends ChannelOwner<channels.AndroidDeviceChannel, c
async waitForEvent(event: string, optionsOrPredicate: types.WaitForEventOptions = {}): Promise<any> { async waitForEvent(event: string, optionsOrPredicate: types.WaitForEventOptions = {}): 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 = new Waiter(); const waiter = Waiter.createForEvent(this, 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

@ -217,7 +217,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel,
async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise<any> { async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): 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 = new Waiter(); const waiter = Waiter.createForEvent(this, 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

@ -21,6 +21,7 @@ import type { Logger } from './types';
import { debugLogger } from '../utils/debugLogger'; import { debugLogger } from '../utils/debugLogger';
import { rewriteErrorMessage } from '../utils/stackTrace'; import { rewriteErrorMessage } from '../utils/stackTrace';
import { createScheme, Validator, ValidationError } from '../protocol/validator'; import { createScheme, Validator, ValidationError } from '../protocol/validator';
import { StackFrame } from '../common/types';
export abstract class ChannelOwner<T extends channels.Channel = channels.Channel, Initializer = {}> extends EventEmitter { export abstract class ChannelOwner<T extends channels.Channel = channels.Channel, Initializer = {}> extends EventEmitter {
private _connection: Connection; private _connection: Connection;
@ -97,6 +98,18 @@ export abstract class ChannelOwner<T extends channels.Channel = channels.Channel
} }
} }
_waitForEventInfoBefore(waitId: string, name: string, stack: StackFrame[]) {
this._connection.sendMessageToServer(this._guid, 'waitForEventInfo', { info: { name, waitId, phase: 'before', stack } }).catch(() => {});
}
_waitForEventInfoAfter(waitId: string, error?: string) {
this._connection.sendMessageToServer(this._guid, 'waitForEventInfo', { info: { waitId, phase: 'after', error } }).catch(() => {});
}
_waitForEventInfoLog(waitId: string, message: string) {
this._connection.sendMessageToServer(this._guid, 'waitForEventInfo', { info: { waitId, phase: 'log', message } }).catch(() => {});
}
private toJSON() { private toJSON() {
// Jest's expect library tries to print objects sometimes. // Jest's expect library tries to print objects sometimes.
// RPC objects can contain links to lots of other objects, // RPC objects can contain links to lots of other objects,

View File

@ -81,7 +81,7 @@ export class Connection {
try { try {
return await new Promise((resolve, reject) => this._callbacks.set(id, { resolve, reject })); return await new Promise((resolve, reject) => this._callbacks.set(id, { resolve, reject }));
} catch (e) { } catch (e) {
const innerStack = (isUnderTest() && e.stack) ? e.stack.substring(e.stack.indexOf(e.message) + e.message.length) : ''; const innerStack = ((process.env.PWDEBUGIMPL || isUnderTest()) && e.stack) ? e.stack.substring(e.stack.indexOf(e.message) + e.message.length) : '';
e.stack = e.message + innerStack + stack; e.stack = e.message + innerStack + stack;
throw e; throw e;
} }

View File

@ -101,7 +101,7 @@ export class ElectronApplication extends ChannelOwner<channels.ElectronApplicati
async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise<any> { async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): 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 = new Waiter(); const waiter = Waiter.createForEvent(this, 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

@ -100,21 +100,20 @@ export class Frame extends ChannelOwner<channels.FrameChannel, channels.FrameIni
}); });
} }
private _setupNavigationWaiter(options: { timeout?: number }): Waiter { private _setupNavigationWaiter(name: string, options: { timeout?: number }): Waiter {
const waiter = new Waiter(); const waiter = new Waiter(this, name);
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!'));
waiter.rejectOnEvent(this._page!, Events.Page.Crash, new Error('Navigation failed because page crashed!')); waiter.rejectOnEvent(this._page!, Events.Page.Crash, new Error('Navigation failed because page crashed!'));
waiter.rejectOnEvent<Frame>(this._page!, Events.Page.FrameDetached, new Error('Navigating frame was detached!'), frame => frame === this); waiter.rejectOnEvent<Frame>(this._page!, Events.Page.FrameDetached, new Error('Navigating frame was detached!'), frame => frame === this);
const timeout = this._page!._timeoutSettings.navigationTimeout(options); const timeout = this._page!._timeoutSettings.navigationTimeout(options);
waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded.`); waiter.rejectOnTimeout(timeout, `Timeout ${timeout}ms exceeded.`);
return waiter; return waiter;
} }
async waitForNavigation(options: WaitForNavigationOptions = {}): Promise<network.Response | null> { async waitForNavigation(options: WaitForNavigationOptions = {}): Promise<network.Response | null> {
return this._wrapApiCall(this._apiName('waitForNavigation'), async () => { return this._wrapApiCall(this._apiName('waitForNavigation'), async () => {
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('waitForNavigation', 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}"`);
@ -151,7 +150,7 @@ export class Frame extends ChannelOwner<channels.FrameChannel, channels.FrameIni
if (this._loadStates.has(state)) if (this._loadStates.has(state))
return; return;
return this._wrapApiCall(this._apiName('waitForLoadState'), async () => { return this._wrapApiCall(this._apiName('waitForLoadState'), async () => {
const waiter = this._setupNavigationWaiter(options); const waiter = this._setupNavigationWaiter('waitForLoadState', 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

@ -365,7 +365,7 @@ export class WebSocket extends ChannelOwner<channels.WebSocketChannel, channels.
async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise<any> { async waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions = {}): Promise<any> {
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 = new Waiter(); const waiter = Waiter.createForEvent(this, 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

@ -395,7 +395,7 @@ export class Page extends ChannelOwner<channels.PageChannel, channels.PageInitia
private async _waitForEvent(event: string, optionsOrPredicate: WaitForEventOptions, logLine?: string): Promise<any> { private async _waitForEvent(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 = new Waiter(); const waiter = Waiter.createForEvent(this, 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

@ -15,15 +15,32 @@
*/ */
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { rewriteErrorMessage } from '../utils/stackTrace'; import { captureStackTrace, rewriteErrorMessage } from '../utils/stackTrace';
import { TimeoutError } from '../utils/errors'; import { TimeoutError } from '../utils/errors';
import { debugLogger } from '../utils/debugLogger'; import { createGuid } from '../utils/utils';
import { ChannelOwner } from './channelOwner';
export class Waiter { export class Waiter {
private _dispose: (() => void)[] = []; private _dispose: (() => void)[];
private _failures: Promise<any>[] = []; private _failures: Promise<any>[] = [];
// TODO: can/should we move these logs into wrapApiCall? // TODO: can/should we move these logs into wrapApiCall?
private _logs: string[] = []; private _logs: string[] = [];
private _channelOwner: ChannelOwner;
private _waitId: string;
private _error: string | undefined;
constructor(channelOwner: ChannelOwner, name: string) {
this._waitId = createGuid();
this._channelOwner = channelOwner;
this._channelOwner._waitForEventInfoBefore(this._waitId, name, captureStackTrace().frames);
this._dispose = [
() => this._channelOwner._waitForEventInfoAfter(this._waitId, this._error)
];
}
static createForEvent(channelOwner: ChannelOwner, event: string) {
return new Waiter(channelOwner, `waitForEvent(${event})`);
}
async waitForEvent<T = void>(emitter: EventEmitter, event: string, predicate?: (arg: T) => boolean): Promise<T> { async waitForEvent<T = void>(emitter: EventEmitter, event: string, predicate?: (arg: T) => boolean): Promise<T> {
const { promise, dispose } = waitForEvent(emitter, event, predicate); const { promise, dispose } = waitForEvent(emitter, event, predicate);
@ -56,6 +73,7 @@ export class Waiter {
} catch (e) { } catch (e) {
if (dispose) if (dispose)
dispose(); dispose();
this._error = e.message;
this.dispose(); this.dispose();
rewriteErrorMessage(e, e.message + formatLogRecording(this._logs) + kLoggingNote); rewriteErrorMessage(e, e.message + formatLogRecording(this._logs) + kLoggingNote);
throw e; throw e;
@ -64,7 +82,7 @@ export class Waiter {
log(s: string) { log(s: string) {
this._logs.push(s); this._logs.push(s);
debugLogger.log('api', s); this._channelOwner._waitForEventInfoLog(this._waitId, s);
} }
private _rejectOn(promise: Promise<any>, dispose?: () => void) { private _rejectOn(promise: Promise<any>, dispose?: () => void) {

View File

@ -112,6 +112,10 @@ export class Dispatcher<Type, Initializer> extends EventEmitter implements chann
objects: Array.from(this._dispatchers.values()).map(o => o._debugScopeState()), objects: Array.from(this._dispatchers.values()).map(o => o._debugScopeState()),
}; };
} }
async waitForEventInfo(): Promise<void> {
// Instrumentation takes care of this.
}
} }
export type DispatcherScope = Dispatcher<any, any>; export type DispatcherScope = Dispatcher<any, any>;
@ -151,6 +155,8 @@ export class DispatcherConnection {
}; };
const scheme = createScheme(tChannel); const scheme = createScheme(tChannel);
this._validateParams = (type: string, method: string, params: any): any => { this._validateParams = (type: string, method: string, params: any): any => {
if (method === 'waitForEventInfo')
return tOptional(scheme['WaitForEventInfo'])(params.info, '');
const name = type + method[0].toUpperCase() + method.substring(1) + 'Params'; const name = type + method[0].toUpperCase() + method.substring(1) + 'Params';
if (!scheme[name]) if (!scheme[name])
throw new ValidationError(`Unknown scheme for ${type}.${method}`); throw new ValidationError(`Unknown scheme for ${type}.${method}`);

View File

@ -23,13 +23,24 @@ export type Binary = string;
export interface Channel extends EventEmitter { export interface Channel extends EventEmitter {
} }
export type StackFrame = {
file: string,
line?: number,
column?: number,
function?: string,
};
export type Metadata = { export type Metadata = {
stack?: { stack?: StackFrame[],
file: string, };
line?: number,
column?: number, export type WaitForEventInfo = {
function?: string, waitId: string,
}[], phase: 'before' | 'after' | 'log',
name?: string,
stack?: StackFrame[],
message?: string,
error?: string,
}; };
export type Point = { export type Point = {

View File

@ -12,20 +12,40 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
StackFrame:
type: object
properties:
file: string
line: number?
column: number?
function: string?
# This object can be send with any rpc call in the "metadata" field. # This object can be send with any rpc call in the "metadata" field.
Metadata: Metadata:
type: object type: object
properties: properties:
stack: stack:
type: array? type: array?
items: items: StackFrame
type: object
properties:
file: string WaitForEventInfo:
line: number? type: object
column: number? properties:
function: string? waitId: string
phase:
type: enum
literals:
- before
- after
- log
name: string?
stack:
type: array?
items: StackFrame
message: string?
error: string?
Point: Point:

View File

@ -33,13 +33,22 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
}; };
}; };
scheme.StackFrame = tObject({
file: tString,
line: tOptional(tNumber),
column: tOptional(tNumber),
function: tOptional(tString),
});
scheme.Metadata = tObject({ scheme.Metadata = tObject({
stack: tOptional(tArray(tObject({ stack: tOptional(tArray(tType('StackFrame'))),
file: tString, });
line: tOptional(tNumber), scheme.WaitForEventInfo = tObject({
column: tOptional(tNumber), waitId: tString,
function: tOptional(tString), phase: tEnum(['before', 'after', 'log']),
}))), name: tOptional(tString),
stack: tOptional(tArray(tType('StackFrame'))),
message: tOptional(tString),
error: tOptional(tString),
}); });
scheme.Point = tObject({ scheme.Point = tObject({
x: tNumber, x: tNumber,

View File

@ -22,6 +22,7 @@ import { isDebugMode, isUnderTest } from '../../utils/utils';
export class InspectorController implements InstrumentationListener { export class InspectorController implements InstrumentationListener {
private _recorders = new Map<BrowserContext, Promise<RecorderSupplement>>(); private _recorders = new Map<BrowserContext, Promise<RecorderSupplement>>();
private _waitOperations = new Map<string, CallMetadata>();
async onContextCreated(context: BrowserContext): Promise<void> { async onContextCreated(context: BrowserContext): Promise<void> {
if (isDebugMode()) if (isDebugMode())
@ -37,6 +38,25 @@ export class InspectorController implements InstrumentationListener {
if (!context) if (!context)
return; return;
// Process logs for waitForNavigation/waitForLoadState
if (metadata.params?.info?.waitId) {
const info = metadata.params.info;
switch (info.phase) {
case 'before':
metadata.method = info.name;
metadata.stack = info.stack;
this._waitOperations.set(info.waitId, metadata);
break;
case 'log':
const originalMetadata = this._waitOperations.get(info.waitId)!;
originalMetadata.log.push(info.message);
this.onCallLog('api', info.message, sdkObject, originalMetadata);
// Fall through.
case 'after':
return;
}
}
if (metadata.method === 'pause') { if (metadata.method === 'pause') {
// Force create recorder on pause. // Force create recorder on pause.
if (!context._browser.options.headful && !isUnderTest()) if (!context._browser.options.headful && !isUnderTest())
@ -51,22 +71,38 @@ export class InspectorController implements InstrumentationListener {
async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> { async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
if (!sdkObject.attribution.context) if (!sdkObject.attribution.context)
return; return;
const recorder = await this._recorders.get(sdkObject.attribution.context!);
// Process logs for waitForNavigation/waitForLoadState
if (metadata.params?.info?.waitId) {
const info = metadata.params.info;
switch (info.phase) {
case 'before':
// Fall through.
case 'log':
return;
case 'after':
metadata = this._waitOperations.get(info.waitId)!;
this._waitOperations.delete(info.waitId);
break;
}
}
const recorder = await this._recorders.get(sdkObject.attribution.context);
await recorder?.onAfterCall(metadata); await recorder?.onAfterCall(metadata);
} }
async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> { async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
if (!sdkObject.attribution.page) if (!sdkObject.attribution.context)
return; return;
const recorder = await this._recorders.get(sdkObject.attribution.context!); const recorder = await this._recorders.get(sdkObject.attribution.context);
await recorder?.onBeforeInputAction(metadata); await recorder?.onBeforeInputAction(metadata);
} }
async onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): Promise<void> { async onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
debugLogger.log(logName as any, message); debugLogger.log(logName as any, message);
if (!sdkObject.attribution.page) if (!sdkObject.attribution.context)
return; return;
const recorder = await this._recorders.get(sdkObject.attribution.context!); const recorder = await this._recorders.get(sdkObject.attribution.context);
await recorder?.updateCallLog([metadata]); await recorder?.updateCallLog([metadata]);
} }
} }