mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
feat(tracing): trace actions (#3825)
- Fill and click actions pass metadata to Progress. - Progress reports success/failure through instrumentation. - Tracer consumes ActionResult and ActionMetadata and records them. Currently, only click and fill actions pass metadata to contain the size of the change. Everything else should follow.
This commit is contained in:
parent
a597004780
commit
38ed8de23d
@ -17,8 +17,7 @@
|
|||||||
import { BrowserContext } from '../server/browserContext';
|
import { BrowserContext } from '../server/browserContext';
|
||||||
import * as frames from '../server/frames';
|
import * as frames from '../server/frames';
|
||||||
import { Page } from '../server/page';
|
import { Page } from '../server/page';
|
||||||
import { InstrumentingAgent } from '../server/instrumentation';
|
import { ActionMetadata, ActionResult, InstrumentingAgent } from '../server/instrumentation';
|
||||||
import { Progress } from '../server/progress';
|
|
||||||
import { isDebugMode } from '../utils/utils';
|
import { isDebugMode } from '../utils/utils';
|
||||||
import * as debugScriptSource from '../generated/debugScriptSource';
|
import * as debugScriptSource from '../generated/debugScriptSource';
|
||||||
|
|
||||||
@ -43,6 +42,6 @@ export class DebugController implements InstrumentingAgent {
|
|||||||
async onContextDestroyed(context: BrowserContext): Promise<void> {
|
async onContextDestroyed(context: BrowserContext): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async onBeforePageAction(page: Page, progress: Progress): Promise<void> {
|
async onAfterAction(result: ActionResult, metadata?: ActionMetadata): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,8 @@ import * as channels from '../protocol/channels';
|
|||||||
import { DispatcherScope, lookupNullableDispatcher } from './dispatcher';
|
import { DispatcherScope, lookupNullableDispatcher } from './dispatcher';
|
||||||
import { JSHandleDispatcher, serializeResult, parseArgument } from './jsHandleDispatcher';
|
import { JSHandleDispatcher, serializeResult, parseArgument } from './jsHandleDispatcher';
|
||||||
import { FrameDispatcher } from './frameDispatcher';
|
import { FrameDispatcher } from './frameDispatcher';
|
||||||
|
import { runAbortableTask } from '../server/progress';
|
||||||
|
import { ActionMetadata } from '../server/instrumentation';
|
||||||
|
|
||||||
export function createHandle(scope: DispatcherScope, handle: js.JSHandle): JSHandleDispatcher {
|
export function createHandle(scope: DispatcherScope, handle: js.JSHandle): JSHandleDispatcher {
|
||||||
return handle.asElement() ? new ElementHandleDispatcher(scope, handle.asElement()!) : new JSHandleDispatcher(scope, handle);
|
return handle.asElement() ? new ElementHandleDispatcher(scope, handle.asElement()!) : new JSHandleDispatcher(scope, handle);
|
||||||
@ -77,8 +79,11 @@ export class ElementHandleDispatcher extends JSHandleDispatcher implements chann
|
|||||||
await this._elementHandle.hover(params);
|
await this._elementHandle.hover(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
async click(params: channels.ElementHandleClickParams): Promise<void> {
|
async click(params: channels.ElementHandleClickParams, metadata?: channels.Metadata): Promise<void> {
|
||||||
await this._elementHandle.click(params);
|
const clickMetadata: ActionMetadata = { ...metadata, type: 'click', target: this._elementHandle, page: this._elementHandle._page };
|
||||||
|
return runAbortableTask(async progress => {
|
||||||
|
return await this._elementHandle.click(progress, params);
|
||||||
|
}, this._elementHandle._page._timeoutSettings.timeout(params), clickMetadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
async dblclick(params: channels.ElementHandleDblclickParams): Promise<void> {
|
async dblclick(params: channels.ElementHandleDblclickParams): Promise<void> {
|
||||||
@ -90,8 +95,11 @@ export class ElementHandleDispatcher extends JSHandleDispatcher implements chann
|
|||||||
return { values: await this._elementHandle.selectOption(elements, params.options || [], params) };
|
return { values: await this._elementHandle.selectOption(elements, params.options || [], params) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async fill(params: channels.ElementHandleFillParams): Promise<void> {
|
async fill(params: channels.ElementHandleFillParams, metadata?: channels.Metadata): Promise<void> {
|
||||||
await this._elementHandle.fill(params.value, params);
|
const fillMetadata: ActionMetadata = { ...metadata, type: 'fill', value: params.value, target: this._elementHandle, page: this._elementHandle._page };
|
||||||
|
return runAbortableTask(async progress => {
|
||||||
|
return await this._elementHandle.fill(progress, params.value, params);
|
||||||
|
}, this._elementHandle._page._timeoutSettings.timeout(params), fillMetadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
async selectText(params: channels.ElementHandleSelectTextParams): Promise<void> {
|
async selectText(params: channels.ElementHandleSelectTextParams): Promise<void> {
|
||||||
|
@ -20,6 +20,8 @@ import { Dispatcher, DispatcherScope, lookupNullableDispatcher, existingDispatch
|
|||||||
import { ElementHandleDispatcher, createHandle } from './elementHandlerDispatcher';
|
import { ElementHandleDispatcher, createHandle } from './elementHandlerDispatcher';
|
||||||
import { parseArgument, serializeResult } from './jsHandleDispatcher';
|
import { parseArgument, serializeResult } from './jsHandleDispatcher';
|
||||||
import { ResponseDispatcher, RequestDispatcher } from './networkDispatchers';
|
import { ResponseDispatcher, RequestDispatcher } from './networkDispatchers';
|
||||||
|
import { ActionMetadata } from '../server/instrumentation';
|
||||||
|
import { runAbortableTask } from '../server/progress';
|
||||||
|
|
||||||
export class FrameDispatcher extends Dispatcher<Frame, channels.FrameInitializer> implements channels.FrameChannel {
|
export class FrameDispatcher extends Dispatcher<Frame, channels.FrameInitializer> implements channels.FrameChannel {
|
||||||
private _frame: Frame;
|
private _frame: Frame;
|
||||||
@ -108,16 +110,22 @@ export class FrameDispatcher extends Dispatcher<Frame, channels.FrameInitializer
|
|||||||
return { element: new ElementHandleDispatcher(this._scope, await this._frame.addStyleTag(params)) };
|
return { element: new ElementHandleDispatcher(this._scope, await this._frame.addStyleTag(params)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
async click(params: channels.FrameClickParams): Promise<void> {
|
async click(params: channels.FrameClickParams, metadata?: channels.Metadata): Promise<void> {
|
||||||
await this._frame.click(params.selector, params);
|
const clickMetadata: ActionMetadata = { ...metadata, type: 'click', target: params.selector, page: this._frame._page };
|
||||||
|
await runAbortableTask(async progress => {
|
||||||
|
return await this._frame.click(progress, params.selector, params);
|
||||||
|
}, this._frame._page._timeoutSettings.timeout(params), clickMetadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
async dblclick(params: channels.FrameDblclickParams): Promise<void> {
|
async dblclick(params: channels.FrameDblclickParams): Promise<void> {
|
||||||
await this._frame.dblclick(params.selector, params);
|
await this._frame.dblclick(params.selector, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fill(params: channels.FrameFillParams): Promise<void> {
|
async fill(params: channels.FrameFillParams, metadata?: channels.Metadata): Promise<void> {
|
||||||
await this._frame.fill(params.selector, params.value, params);
|
const fillMetadata: ActionMetadata = { ...metadata, type: 'fill', value: params.value, target: params.selector, page: this._frame._page };
|
||||||
|
await runAbortableTask(async progress => {
|
||||||
|
return await this._frame.fill(progress, params.selector, params.value, params);
|
||||||
|
}, this._frame._page._timeoutSettings.timeout(params), fillMetadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
async focus(params: channels.FrameFocusParams): Promise<void> {
|
async focus(params: channels.FrameFocusParams): Promise<void> {
|
||||||
|
@ -99,18 +99,20 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
|||||||
readonly _context: FrameExecutionContext;
|
readonly _context: FrameExecutionContext;
|
||||||
readonly _page: Page;
|
readonly _page: Page;
|
||||||
readonly _objectId: string;
|
readonly _objectId: string;
|
||||||
|
readonly _previewPromise: Promise<string>;
|
||||||
|
|
||||||
constructor(context: FrameExecutionContext, objectId: string) {
|
constructor(context: FrameExecutionContext, objectId: string) {
|
||||||
super(context, 'node', objectId);
|
super(context, 'node', objectId);
|
||||||
this._objectId = objectId;
|
this._objectId = objectId;
|
||||||
this._context = context;
|
this._context = context;
|
||||||
this._page = context.frame._page;
|
this._page = context.frame._page;
|
||||||
this._initializePreview().catch(e => {});
|
this._previewPromise = this._initializePreview().catch(e => 'node');
|
||||||
|
this._previewPromise.then(preview => this._setPreview('JSHandle@' + preview));
|
||||||
}
|
}
|
||||||
|
|
||||||
async _initializePreview() {
|
async _initializePreview() {
|
||||||
const utility = await this._context.injectedScript();
|
const utility = await this._context.injectedScript();
|
||||||
this._setPreview(await utility.evaluate((injected, e) => 'JSHandle@' + injected.previewNode(e), this));
|
return utility.evaluate((injected, e) => injected.previewNode(e), this);
|
||||||
}
|
}
|
||||||
|
|
||||||
asElement(): ElementHandle<T> | null {
|
asElement(): ElementHandle<T> | null {
|
||||||
@ -367,11 +369,9 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
|||||||
return this._retryPointerAction(progress, 'hover', false /* waitForEnabled */, point => this._page.mouse.move(point.x, point.y), options);
|
return this._retryPointerAction(progress, 'hover', false /* waitForEnabled */, point => this._page.mouse.move(point.x, point.y), options);
|
||||||
}
|
}
|
||||||
|
|
||||||
click(options: types.MouseClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise<void> {
|
async click(progress: Progress, options: types.MouseClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise<void> {
|
||||||
return this._page._runAbortableTask(async progress => {
|
|
||||||
const result = await this._click(progress, options);
|
const result = await this._click(progress, options);
|
||||||
return assertDone(throwRetargetableDOMError(result));
|
return assertDone(throwRetargetableDOMError(result));
|
||||||
}, this._page._timeoutSettings.timeout(options));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_click(progress: Progress, options: types.MouseClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> {
|
_click(progress: Progress, options: types.MouseClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> {
|
||||||
@ -406,11 +406,9 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async fill(value: string, options: types.NavigatingActionWaitOptions = {}): Promise<void> {
|
async fill(progress: Progress, value: string, options: types.NavigatingActionWaitOptions = {}): Promise<void> {
|
||||||
return this._page._runAbortableTask(async progress => {
|
|
||||||
const result = await this._fill(progress, value, options);
|
const result = await this._fill(progress, value, options);
|
||||||
assertDone(throwRetargetableDOMError(result));
|
assertDone(throwRetargetableDOMError(result));
|
||||||
}, this._page._timeoutSettings.timeout(options));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async _fill(progress: Progress, value: string, options: types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> {
|
async _fill(progress: Progress, value: string, options: types.NavigatingActionWaitOptions): Promise<'error:notconnected' | 'done'> {
|
||||||
|
@ -770,11 +770,11 @@ export class Frame extends EventEmitter {
|
|||||||
return result!;
|
return result!;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _retryWithSelectorIfNotConnected<R>(
|
private async _retryWithProgressIfNotConnected<R>(
|
||||||
selector: string, options: types.TimeoutOptions,
|
progress: Progress,
|
||||||
action: (progress: Progress, handle: dom.ElementHandle<Element>) => Promise<R | 'error:notconnected'>): Promise<R> {
|
selector: string,
|
||||||
|
action: (handle: dom.ElementHandle<Element>) => Promise<R | 'error:notconnected'>): Promise<R> {
|
||||||
const info = this._page.selectors._parseSelector(selector);
|
const info = this._page.selectors._parseSelector(selector);
|
||||||
return this._page._runAbortableTask(async progress => {
|
|
||||||
while (progress.isRunning()) {
|
while (progress.isRunning()) {
|
||||||
progress.log(`waiting for selector "${selector}"`);
|
progress.log(`waiting for selector "${selector}"`);
|
||||||
const task = dom.waitForSelectorTask(info, 'attached');
|
const task = dom.waitForSelectorTask(info, 'attached');
|
||||||
@ -785,7 +785,7 @@ export class Frame extends EventEmitter {
|
|||||||
// page (e.g. alert) or unresolved navigation in Chromium.
|
// page (e.g. alert) or unresolved navigation in Chromium.
|
||||||
element.dispose();
|
element.dispose();
|
||||||
});
|
});
|
||||||
const result = await action(progress, element);
|
const result = await action(element);
|
||||||
element.dispose();
|
element.dispose();
|
||||||
if (result === 'error:notconnected') {
|
if (result === 'error:notconnected') {
|
||||||
progress.log('element was detached from the DOM, retrying');
|
progress.log('element was detached from the DOM, retrying');
|
||||||
@ -794,19 +794,26 @@ export class Frame extends EventEmitter {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
return undefined as any;
|
return undefined as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _retryWithSelectorIfNotConnected<R>(
|
||||||
|
selector: string, options: types.TimeoutOptions,
|
||||||
|
action: (progress: Progress, handle: dom.ElementHandle<Element>) => Promise<R | 'error:notconnected'>): Promise<R> {
|
||||||
|
return this._page._runAbortableTask(async progress => {
|
||||||
|
return this._retryWithProgressIfNotConnected(progress, selector, handle => action(progress, handle));
|
||||||
}, this._page._timeoutSettings.timeout(options));
|
}, this._page._timeoutSettings.timeout(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
async click(selector: string, options: types.MouseClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
|
async click(progress: Progress, selector: string, options: types.MouseClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions) {
|
||||||
await this._retryWithSelectorIfNotConnected(selector, options, (progress, handle) => handle._click(progress, options));
|
return this._retryWithProgressIfNotConnected(progress, selector, handle => handle._click(progress, options));
|
||||||
}
|
}
|
||||||
|
|
||||||
async dblclick(selector: string, options: types.MouseMultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
|
async dblclick(selector: string, options: types.MouseMultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}) {
|
||||||
await this._retryWithSelectorIfNotConnected(selector, options, (progress, handle) => handle._dblclick(progress, options));
|
await this._retryWithSelectorIfNotConnected(selector, options, (progress, handle) => handle._dblclick(progress, options));
|
||||||
}
|
}
|
||||||
|
|
||||||
async fill(selector: string, value: string, options: types.NavigatingActionWaitOptions = {}) {
|
async fill(progress: Progress, selector: string, value: string, options: types.NavigatingActionWaitOptions) {
|
||||||
await this._retryWithSelectorIfNotConnected(selector, options, (progress, handle) => handle._fill(progress, value, options));
|
return this._retryWithProgressIfNotConnected(progress, selector, handle => handle._fill(progress, value, options));
|
||||||
}
|
}
|
||||||
|
|
||||||
async focus(selector: string, options: types.TimeoutOptions = {}) {
|
async focus(selector: string, options: types.TimeoutOptions = {}) {
|
||||||
|
@ -14,14 +14,29 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BrowserContext } from './browserContext';
|
import type { BrowserContext } from './browserContext';
|
||||||
import { Page } from './page';
|
import type { ElementHandle } from './dom';
|
||||||
import { Progress } from './progress';
|
import type { Page } from './page';
|
||||||
|
|
||||||
|
export type ActionMetadata = {
|
||||||
|
type: 'click' | 'fill',
|
||||||
|
page: Page,
|
||||||
|
target: ElementHandle | string,
|
||||||
|
value?: string,
|
||||||
|
stack?: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionResult = {
|
||||||
|
logs: string[],
|
||||||
|
startTime: number,
|
||||||
|
endTime: number,
|
||||||
|
error?: Error,
|
||||||
|
};
|
||||||
|
|
||||||
export interface InstrumentingAgent {
|
export interface InstrumentingAgent {
|
||||||
onContextCreated(context: BrowserContext): Promise<void>;
|
onContextCreated(context: BrowserContext): Promise<void>;
|
||||||
onContextDestroyed(context: BrowserContext): Promise<void>;
|
onContextDestroyed(context: BrowserContext): Promise<void>;
|
||||||
onBeforePageAction(page: Page, progress: Progress): Promise<void>;
|
onAfterAction(result: ActionResult, metadata?: ActionMetadata): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const instrumentingAgents = new Set<InstrumentingAgent>();
|
export const instrumentingAgents = new Set<InstrumentingAgent>();
|
||||||
|
@ -32,7 +32,6 @@ import { Progress, runAbortableTask } from './progress';
|
|||||||
import { assert, isError } from '../utils/utils';
|
import { assert, isError } from '../utils/utils';
|
||||||
import { debugLogger } from '../utils/debugLogger';
|
import { debugLogger } from '../utils/debugLogger';
|
||||||
import { Selectors } from './selectors';
|
import { Selectors } from './selectors';
|
||||||
import { instrumentingAgents } from './instrumentation';
|
|
||||||
|
|
||||||
export interface PageDelegate {
|
export interface PageDelegate {
|
||||||
readonly rawMouse: input.RawMouse;
|
readonly rawMouse: input.RawMouse;
|
||||||
@ -200,11 +199,7 @@ export class Page extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _runAbortableTask<T>(task: (progress: Progress) => Promise<T>, timeout: number): Promise<T> {
|
async _runAbortableTask<T>(task: (progress: Progress) => Promise<T>, timeout: number): Promise<T> {
|
||||||
return runAbortableTask(async progress => {
|
return runAbortableTask(task, timeout);
|
||||||
for (const agent of instrumentingAgents)
|
|
||||||
await agent.onBeforePageAction(this, progress);
|
|
||||||
return task(progress);
|
|
||||||
}, timeout);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async _onFileChooserOpened(handle: dom.ElementHandle) {
|
async _onFileChooserOpened(handle: dom.ElementHandle) {
|
||||||
|
@ -18,6 +18,7 @@ import { TimeoutError } from '../utils/errors';
|
|||||||
import { assert, monotonicTime } from '../utils/utils';
|
import { assert, monotonicTime } from '../utils/utils';
|
||||||
import { rewriteErrorMessage } from '../utils/stackTrace';
|
import { rewriteErrorMessage } from '../utils/stackTrace';
|
||||||
import { debugLogger, LogName } from '../utils/debugLogger';
|
import { debugLogger, LogName } from '../utils/debugLogger';
|
||||||
|
import { ActionResult, instrumentingAgents, ActionMetadata } from './instrumentation';
|
||||||
|
|
||||||
export interface Progress {
|
export interface Progress {
|
||||||
readonly aborted: Promise<void>;
|
readonly aborted: Promise<void>;
|
||||||
@ -28,8 +29,8 @@ export interface Progress {
|
|||||||
throwIfAborted(): void;
|
throwIfAborted(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runAbortableTask<T>(task: (progress: Progress) => Promise<T>, timeout: number): Promise<T> {
|
export async function runAbortableTask<T>(task: (progress: Progress) => Promise<T>, timeout: number, metadata?: ActionMetadata): Promise<T> {
|
||||||
const controller = new ProgressController(timeout);
|
const controller = new ProgressController(timeout, metadata);
|
||||||
return controller.run(task);
|
return controller.run(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,15 +48,17 @@ export class ProgressController {
|
|||||||
// Cleanups to be run only in the case of abort.
|
// Cleanups to be run only in the case of abort.
|
||||||
private _cleanups: (() => any)[] = [];
|
private _cleanups: (() => any)[] = [];
|
||||||
|
|
||||||
|
private _metadata?: ActionMetadata;
|
||||||
private _logName: LogName = 'api';
|
private _logName: LogName = 'api';
|
||||||
private _state: 'before' | 'running' | 'aborted' | 'finished' = 'before';
|
private _state: 'before' | 'running' | 'aborted' | 'finished' = 'before';
|
||||||
private _deadline: number;
|
private _deadline: number;
|
||||||
private _timeout: number;
|
private _timeout: number;
|
||||||
private _logRecordring: string[] = [];
|
private _logRecordring: string[] = [];
|
||||||
|
|
||||||
constructor(timeout: number) {
|
constructor(timeout: number, metadata?: ActionMetadata) {
|
||||||
this._timeout = timeout;
|
this._timeout = timeout;
|
||||||
this._deadline = timeout ? monotonicTime() + timeout : 0;
|
this._deadline = timeout ? monotonicTime() + timeout : 0;
|
||||||
|
this._metadata = metadata;
|
||||||
|
|
||||||
this._forceAbortPromise = new Promise((resolve, reject) => this._forceAbort = reject);
|
this._forceAbortPromise = new Promise((resolve, reject) => this._forceAbort = reject);
|
||||||
this._forceAbortPromise.catch(e => null); // Prevent unhandle promsie rejection.
|
this._forceAbortPromise.catch(e => null); // Prevent unhandle promsie rejection.
|
||||||
@ -93,11 +96,19 @@ export class ProgressController {
|
|||||||
|
|
||||||
const timeoutError = new TimeoutError(`Timeout ${this._timeout}ms exceeded.`);
|
const timeoutError = new TimeoutError(`Timeout ${this._timeout}ms exceeded.`);
|
||||||
const timer = setTimeout(() => this._forceAbort(timeoutError), progress.timeUntilDeadline());
|
const timer = setTimeout(() => this._forceAbort(timeoutError), progress.timeUntilDeadline());
|
||||||
|
const startTime = monotonicTime();
|
||||||
try {
|
try {
|
||||||
const promise = task(progress);
|
const promise = task(progress);
|
||||||
const result = await Promise.race([promise, this._forceAbortPromise]);
|
const result = await Promise.race([promise, this._forceAbortPromise]);
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
this._state = 'finished';
|
this._state = 'finished';
|
||||||
|
const actionResult: ActionResult = {
|
||||||
|
startTime,
|
||||||
|
endTime: monotonicTime(),
|
||||||
|
logs: this._logRecordring,
|
||||||
|
};
|
||||||
|
for (const agent of instrumentingAgents)
|
||||||
|
await agent.onAfterAction(actionResult, this._metadata);
|
||||||
this._logRecordring = [];
|
this._logRecordring = [];
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -108,8 +119,16 @@ export class ProgressController {
|
|||||||
kLoggingNote);
|
kLoggingNote);
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
this._state = 'aborted';
|
this._state = 'aborted';
|
||||||
this._logRecordring = [];
|
|
||||||
await Promise.all(this._cleanups.splice(0).map(cleanup => runCleanup(cleanup)));
|
await Promise.all(this._cleanups.splice(0).map(cleanup => runCleanup(cleanup)));
|
||||||
|
const actionResult: ActionResult = {
|
||||||
|
startTime,
|
||||||
|
endTime: monotonicTime(),
|
||||||
|
logs: this._logRecordring,
|
||||||
|
error: e,
|
||||||
|
};
|
||||||
|
for (const agent of instrumentingAgents)
|
||||||
|
await agent.onAfterAction(actionResult, this._metadata);
|
||||||
|
this._logRecordring = [];
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -45,7 +45,6 @@ export type FrameSnapshot = {
|
|||||||
resourceOverrides: { url: string, sha1: string }[],
|
resourceOverrides: { url: string, sha1: string }[],
|
||||||
};
|
};
|
||||||
export type PageSnapshot = {
|
export type PageSnapshot = {
|
||||||
label: string,
|
|
||||||
viewportSize?: { width: number, height: number },
|
viewportSize?: { width: number, height: number },
|
||||||
// First frame is the main frame.
|
// First frame is the main frame.
|
||||||
frames: FrameSnapshot[],
|
frames: FrameSnapshot[],
|
||||||
@ -54,7 +53,6 @@ export type PageSnapshot = {
|
|||||||
export interface SnapshotterDelegate {
|
export interface SnapshotterDelegate {
|
||||||
onBlob(blob: SnapshotterBlob): void;
|
onBlob(blob: SnapshotterBlob): void;
|
||||||
onResource(resource: SanpshotterResource): void;
|
onResource(resource: SanpshotterResource): void;
|
||||||
onSnapshot(snapshot: PageSnapshot): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Snapshotter {
|
export class Snapshotter {
|
||||||
@ -74,13 +72,6 @@ export class Snapshotter {
|
|||||||
helper.removeEventListeners(this._eventListeners);
|
helper.removeEventListeners(this._eventListeners);
|
||||||
}
|
}
|
||||||
|
|
||||||
async takeSnapshot(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(snapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _onPage(page: Page) {
|
private _onPage(page: Page) {
|
||||||
this._eventListeners.push(helper.addEventListener(page, Page.Events.Response, (response: network.Response) => {
|
this._eventListeners.push(helper.addEventListener(page, Page.Events.Response, (response: network.Response) => {
|
||||||
this._saveResource(response).catch(e => debugLogger.log('error', e));
|
this._saveResource(response).catch(e => debugLogger.log('error', e));
|
||||||
@ -118,7 +109,9 @@ export class Snapshotter {
|
|||||||
this._delegate.onBlob({ sha1, buffer: body });
|
this._delegate.onBlob({ sha1, buffer: body });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _snapshotPage(progress: Progress, page: Page, label: string): Promise<PageSnapshot | null> {
|
async takeSnapshot(progress: Progress, page: Page): Promise<PageSnapshot | null> {
|
||||||
|
assert(page.context() === this._context);
|
||||||
|
|
||||||
const frames = page.frames();
|
const frames = page.frames();
|
||||||
const promises = frames.map(frame => this._snapshotFrame(progress, frame));
|
const promises = frames.map(frame => this._snapshotFrame(progress, frame));
|
||||||
const results = await Promise.all(promises);
|
const results = await Promise.all(promises);
|
||||||
@ -169,7 +162,6 @@ export class Snapshotter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label,
|
|
||||||
viewportSize,
|
viewportSize,
|
||||||
frames: [mainFrame.snapshot, ...childFrames],
|
frames: [mainFrame.snapshot, ...childFrames],
|
||||||
};
|
};
|
||||||
|
@ -38,9 +38,20 @@ export type NetworkResourceTraceEvent = {
|
|||||||
sha1: string,
|
sha1: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SnapshotTraceEvent = {
|
export type ActionTraceEvent = {
|
||||||
type: 'snapshot',
|
type: 'action',
|
||||||
contextId: string,
|
contextId: string,
|
||||||
label: string,
|
action: string,
|
||||||
|
target?: string,
|
||||||
|
label?: string,
|
||||||
|
value?: string,
|
||||||
|
startTime?: number,
|
||||||
|
endTime?: number,
|
||||||
|
logs?: string[],
|
||||||
|
snapshot?: {
|
||||||
sha1: string,
|
sha1: string,
|
||||||
|
duration: number,
|
||||||
|
},
|
||||||
|
stack?: string,
|
||||||
|
error?: string,
|
||||||
};
|
};
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as util from 'util';
|
import * as util from 'util';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import type { NetworkResourceTraceEvent, SnapshotTraceEvent, ContextCreatedTraceEvent, ContextDestroyedTraceEvent } from './traceTypes';
|
import type { NetworkResourceTraceEvent, ActionTraceEvent, ContextCreatedTraceEvent, ContextDestroyedTraceEvent } from './traceTypes';
|
||||||
import type { FrameSnapshot, PageSnapshot } from './snapshotter';
|
import type { FrameSnapshot, PageSnapshot } from './snapshotter';
|
||||||
import type { Browser, BrowserContext, Frame, Page, Route } from '../client/api';
|
import type { Browser, BrowserContext, Frame, Page, Route } from '../client/api';
|
||||||
import type { Playwright } from '../client/playwright';
|
import type { Playwright } from '../client/playwright';
|
||||||
@ -27,7 +27,7 @@ type TraceEvent =
|
|||||||
ContextCreatedTraceEvent |
|
ContextCreatedTraceEvent |
|
||||||
ContextDestroyedTraceEvent |
|
ContextDestroyedTraceEvent |
|
||||||
NetworkResourceTraceEvent |
|
NetworkResourceTraceEvent |
|
||||||
SnapshotTraceEvent;
|
ActionTraceEvent;
|
||||||
|
|
||||||
class TraceViewer {
|
class TraceViewer {
|
||||||
private _playwright: Playwright;
|
private _playwright: Playwright;
|
||||||
@ -71,47 +71,86 @@ class TraceViewer {
|
|||||||
async show(browserName: string) {
|
async show(browserName: string) {
|
||||||
const browser = await this._playwright[browserName as ('chromium' | 'firefox' | 'webkit')].launch({ headless: false });
|
const browser = await this._playwright[browserName as ('chromium' | 'firefox' | 'webkit')].launch({ headless: false });
|
||||||
const uiPage = await browser.newPage();
|
const uiPage = await browser.newPage();
|
||||||
await uiPage.exposeBinding('renderSnapshot', async (source, event: SnapshotTraceEvent) => {
|
await uiPage.exposeBinding('renderSnapshot', async (source, action: ActionTraceEvent) => {
|
||||||
const snapshot = await fsReadFileAsync(path.join(this._traceStorageDir, event.sha1), 'utf8');
|
const snapshot = await fsReadFileAsync(path.join(this._traceStorageDir, action.snapshot!.sha1), 'utf8');
|
||||||
const context = await this._ensureContext(browser, event.contextId);
|
const context = await this._ensureContext(browser, action.contextId);
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await this._renderSnapshot(page, JSON.parse(snapshot), event.contextId);
|
await this._renderSnapshot(page, JSON.parse(snapshot), action.contextId);
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshotsPerContext: { [contextId: string]: { label: string, snapshots: SnapshotTraceEvent[] } } = {};
|
const contextData: { [contextId: string]: { label: string, actions: ActionTraceEvent[] } } = {};
|
||||||
for (const trace of this._traces) {
|
for (const trace of this._traces) {
|
||||||
let contextId = 0;
|
let contextId = 0;
|
||||||
for (const event of trace.events) {
|
for (const event of trace.events) {
|
||||||
if (event.type !== 'snapshot')
|
if (event.type !== 'action')
|
||||||
continue;
|
continue;
|
||||||
const contextEvent = this._contextEventById.get(event.contextId)!;
|
const contextEvent = this._contextEventById.get(event.contextId)!;
|
||||||
if (contextEvent.browserName !== browserName)
|
if (contextEvent.browserName !== browserName)
|
||||||
continue;
|
continue;
|
||||||
let contextSnapshots = snapshotsPerContext[contextEvent.contextId];
|
let data = contextData[contextEvent.contextId];
|
||||||
if (!contextSnapshots) {
|
if (!data) {
|
||||||
contextSnapshots = { label: trace.traceFile + ' :: context' + (++contextId), snapshots: [] };
|
data = { label: trace.traceFile + ' :: context' + (++contextId), actions: [] };
|
||||||
snapshotsPerContext[contextEvent.contextId] = contextSnapshots;
|
contextData[contextEvent.contextId] = data;
|
||||||
}
|
}
|
||||||
contextSnapshots.snapshots.push(event);
|
data.actions.push(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await uiPage.evaluate(snapshotsPerContext => {
|
await uiPage.evaluate(contextData => {
|
||||||
for (const contextSnapshots of Object.values(snapshotsPerContext)) {
|
for (const data of Object.values(contextData)) {
|
||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
header.textContent = contextSnapshots.label;
|
header.textContent = data.label;
|
||||||
header.style.margin = '10px';
|
header.style.margin = '10px';
|
||||||
document.body.appendChild(header);
|
document.body.appendChild(header);
|
||||||
for (const event of contextSnapshots.snapshots) {
|
for (const action of data.actions) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.style.whiteSpace = 'pre';
|
||||||
|
div.style.borderBottom = '1px solid black';
|
||||||
|
const lines = [];
|
||||||
|
lines.push(`action: ${action.action}`);
|
||||||
|
if (action.label)
|
||||||
|
lines.push(`label: ${action.label}`);
|
||||||
|
if (action.target)
|
||||||
|
lines.push(`target: ${action.target}`);
|
||||||
|
if (action.value)
|
||||||
|
lines.push(`value: ${action.value}`);
|
||||||
|
if (action.startTime && action.endTime)
|
||||||
|
lines.push(`duration: ${action.endTime - action.startTime}ms`);
|
||||||
|
div.textContent = lines.join('\n');
|
||||||
|
if (action.error) {
|
||||||
|
const details = document.createElement('details');
|
||||||
|
const summary = document.createElement('summary');
|
||||||
|
summary.textContent = 'error';
|
||||||
|
details.appendChild(summary);
|
||||||
|
details.appendChild(document.createTextNode(action.error));
|
||||||
|
div.appendChild(details);
|
||||||
|
}
|
||||||
|
if (action.stack) {
|
||||||
|
const details = document.createElement('details');
|
||||||
|
const summary = document.createElement('summary');
|
||||||
|
summary.textContent = 'callstack';
|
||||||
|
details.appendChild(summary);
|
||||||
|
details.appendChild(document.createTextNode(action.stack));
|
||||||
|
div.appendChild(details);
|
||||||
|
}
|
||||||
|
if (action.logs && action.logs.length) {
|
||||||
|
const details = document.createElement('details');
|
||||||
|
const summary = document.createElement('summary');
|
||||||
|
summary.textContent = 'logs';
|
||||||
|
details.appendChild(summary);
|
||||||
|
details.appendChild(document.createTextNode(action.logs.join('\n')));
|
||||||
|
div.appendChild(details);
|
||||||
|
}
|
||||||
|
if (action.snapshot) {
|
||||||
const button = document.createElement('button');
|
const button = document.createElement('button');
|
||||||
button.style.display = 'block';
|
button.style.display = 'block';
|
||||||
button.textContent = `${event.label}`;
|
button.textContent = `snapshot after (${action.snapshot.duration}ms)`;
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => (window as any).renderSnapshot(action));
|
||||||
(window as any).renderSnapshot(event);
|
div.appendChild(button);
|
||||||
});
|
}
|
||||||
document.body.appendChild(button);
|
document.body.appendChild(div);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, snapshotsPerContext);
|
}, contextData);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _ensureContext(browser: Browser, contextId: string): Promise<BrowserContext> {
|
private async _ensureContext(browser: Browser, contextId: string): Promise<BrowserContext> {
|
||||||
|
@ -15,17 +15,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { BrowserContext } from '../server/browserContext';
|
import type { BrowserContext } from '../server/browserContext';
|
||||||
import type { PageSnapshot, SanpshotterResource, SnapshotterBlob, SnapshotterDelegate } from './snapshotter';
|
import type { SanpshotterResource, SnapshotterBlob, SnapshotterDelegate } from './snapshotter';
|
||||||
import { ContextCreatedTraceEvent, ContextDestroyedTraceEvent, NetworkResourceTraceEvent, SnapshotTraceEvent } from './traceTypes';
|
import { ContextCreatedTraceEvent, ContextDestroyedTraceEvent, NetworkResourceTraceEvent, ActionTraceEvent } from './traceTypes';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as util from 'util';
|
import * as util from 'util';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { calculateSha1, createGuid, mkdirIfNeeded, monotonicTime } from '../utils/utils';
|
import { calculateSha1, createGuid, mkdirIfNeeded, monotonicTime } from '../utils/utils';
|
||||||
import { InstrumentingAgent, instrumentingAgents } from '../server/instrumentation';
|
import { ActionResult, InstrumentingAgent, instrumentingAgents, ActionMetadata } from '../server/instrumentation';
|
||||||
import { Page } from '../server/page';
|
import type { Page } from '../server/page';
|
||||||
import { Progress, runAbortableTask } from '../server/progress';
|
import { Progress, runAbortableTask } from '../server/progress';
|
||||||
import { Snapshotter } from './snapshotter';
|
import { Snapshotter } from './snapshotter';
|
||||||
import * as types from '../server/types';
|
import * as types from '../server/types';
|
||||||
|
import type { ElementHandle } from '../server/dom';
|
||||||
|
|
||||||
const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs));
|
const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs));
|
||||||
const fsAppendFileAsync = util.promisify(fs.appendFile.bind(fs));
|
const fsAppendFileAsync = util.promisify(fs.appendFile.bind(fs));
|
||||||
@ -48,11 +49,9 @@ export class Tracer implements InstrumentingAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async captureSnapshot(page: Page, options: types.TimeoutOptions & { label?: string } = {}): Promise<void> {
|
async captureSnapshot(page: Page, options: types.TimeoutOptions & { label?: string } = {}): Promise<void> {
|
||||||
return runAbortableTask(async progress => {
|
|
||||||
const contextTracer = this._contextTracers.get(page.context());
|
const contextTracer = this._contextTracers.get(page.context());
|
||||||
if (contextTracer)
|
if (contextTracer)
|
||||||
await contextTracer._snapshotter.takeSnapshot(progress, page, options.label || 'snapshot');
|
await contextTracer.captureSnapshot(page, options);
|
||||||
}, page._timeoutSettings.timeout(options));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async onContextCreated(context: BrowserContext): Promise<void> {
|
async onContextCreated(context: BrowserContext): Promise<void> {
|
||||||
@ -66,10 +65,13 @@ export class Tracer implements InstrumentingAgent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async onBeforePageAction(page: Page, progress: Progress): Promise<void> {
|
async onAfterAction(result: ActionResult, metadata?: ActionMetadata): Promise<void> {
|
||||||
const contextTracer = this._contextTracers.get(page.context());
|
if (!metadata)
|
||||||
if (contextTracer)
|
return;
|
||||||
await contextTracer._snapshotter.takeSnapshot(progress, page, 'progress');
|
const contextTracer = this._contextTracers.get(metadata.page.context());
|
||||||
|
if (!contextTracer)
|
||||||
|
return;
|
||||||
|
await contextTracer.recordAction(result, metadata);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,17 +116,63 @@ class ContextTracer implements SnapshotterDelegate {
|
|||||||
this._appendTraceEvent(event);
|
this._appendTraceEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
onSnapshot(snapshot: PageSnapshot): void {
|
async captureSnapshot(page: Page, options: types.TimeoutOptions & { label?: string } = {}): Promise<void> {
|
||||||
const buffer = Buffer.from(JSON.stringify(snapshot));
|
await runAbortableTask(async progress => {
|
||||||
const sha1 = calculateSha1(buffer);
|
const label = options.label || 'snapshot';
|
||||||
const event: SnapshotTraceEvent = {
|
const snapshot = await this._takeSnapshot(progress, page);
|
||||||
type: 'snapshot',
|
if (!snapshot)
|
||||||
|
return;
|
||||||
|
const event: ActionTraceEvent = {
|
||||||
|
type: 'action',
|
||||||
contextId: this._contextId,
|
contextId: this._contextId,
|
||||||
label: snapshot.label,
|
action: 'snapshot',
|
||||||
sha1,
|
label,
|
||||||
|
snapshot,
|
||||||
};
|
};
|
||||||
this._appendTraceEvent(event);
|
this._appendTraceEvent(event);
|
||||||
|
}, page._timeoutSettings.timeout(options));
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordAction(result: ActionResult, metadata: ActionMetadata) {
|
||||||
|
let snapshot: { sha1: string, duration: number } | undefined;
|
||||||
|
try {
|
||||||
|
// Use 20% of the default timeout.
|
||||||
|
// Never use zero timeout to avoid stalling because of snapshot.
|
||||||
|
const timeout = (metadata.page._timeoutSettings.timeout({}) / 5) || 6000;
|
||||||
|
snapshot = await runAbortableTask(progress => this._takeSnapshot(progress, metadata.page), timeout);
|
||||||
|
} catch (e) {
|
||||||
|
snapshot = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event: ActionTraceEvent = {
|
||||||
|
type: 'action',
|
||||||
|
contextId: this._contextId,
|
||||||
|
action: metadata.type,
|
||||||
|
target: await this._targetToString(metadata.target),
|
||||||
|
value: metadata.value,
|
||||||
|
snapshot,
|
||||||
|
startTime: result.startTime,
|
||||||
|
endTime: result.endTime,
|
||||||
|
stack: metadata.stack,
|
||||||
|
logs: result.logs.slice(),
|
||||||
|
error: result.error ? result.error.stack : undefined,
|
||||||
|
};
|
||||||
|
this._appendTraceEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _targetToString(target: ElementHandle | string): Promise<string> {
|
||||||
|
return typeof target === 'string' ? target : await target._previewPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _takeSnapshot(progress: Progress, page: Page): Promise<{ sha1: string, duration: number } | undefined> {
|
||||||
|
const startTime = monotonicTime();
|
||||||
|
const snapshot = await this._snapshotter.takeSnapshot(progress, page);
|
||||||
|
if (!snapshot)
|
||||||
|
return;
|
||||||
|
const buffer = Buffer.from(JSON.stringify(snapshot));
|
||||||
|
const sha1 = calculateSha1(buffer);
|
||||||
this._writeArtifact(sha1, buffer);
|
this._writeArtifact(sha1, buffer);
|
||||||
|
return { sha1, duration: monotonicTime() - startTime };
|
||||||
}
|
}
|
||||||
|
|
||||||
async dispose() {
|
async dispose() {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user