feat(trace): highlight action target (#5776)

This commit is contained in:
Pavel Feldman 2021-03-10 11:43:26 -08:00 committed by GitHub
parent 42e9a4703c
commit fea6669473
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 256 additions and 163 deletions

View file

@ -144,9 +144,10 @@ commandWithOpenOptions('pdf <url> <filename>', 'save page as pdf',
if (process.env.PWTRACE) {
program
.command('show-trace [trace]')
.option('--resources <dir>', 'load resources from shared folder')
.description('Show trace viewer')
.action(function(trace, command) {
showTraceViewer(trace);
showTraceViewer(trace, command.resources);
}).on('--help', function() {
console.log('');
console.log('Examples:');

View file

@ -41,6 +41,16 @@ export class CRExecutionContext implements js.ExecutionContextDelegate {
return remoteObject.objectId!;
}
rawCallFunctionNoReply(func: Function, ...args: any[]) {
this._client.send('Runtime.callFunctionOn', {
functionDeclaration: func.toString(),
arguments: args.map(a => a instanceof js.JSHandle ? { objectId: a._objectId } : { value: a }),
returnByValue: true,
executionContextId: this._contextId,
userGesture: true
}).catch(() => {});
}
async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle<any>, values: any[], objectIds: string[]): Promise<any> {
const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.callFunctionOn', {
functionDeclaration: expression,

View file

@ -383,7 +383,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers);
progress.log(` performing ${actionName} action`);
progress.metadata.point = point;
await progress.beforeInputAction();
await progress.beforeInputAction(this);
await action(point);
progress.log(` ${actionName} action done`);
progress.log(' waiting for scheduled navigations to finish');
@ -458,7 +458,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
progress.throwIfAborted(); // Avoid action that has side-effects.
progress.log(' selecting specified option(s)');
await progress.beforeInputAction();
await progress.beforeInputAction(this);
const poll = await this._evaluateHandleInUtility(([injected, node, optionsToSelect]) => {
return injected.waitForElementStatesAndPerformAction(node, ['visible', 'enabled'], injected.selectOptions.bind(injected, optionsToSelect));
}, optionsToSelect);
@ -490,7 +490,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
if (filled === 'error:notconnected')
return filled;
progress.log(' element is visible, enabled and editable');
await progress.beforeInputAction();
await progress.beforeInputAction(this);
if (filled === 'needsinput') {
progress.throwIfAborted(); // Avoid action that has side-effects.
if (value)
@ -537,7 +537,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
assert(multiple || files.length <= 1, 'Non-multiple file input can only accept single file!');
await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => {
progress.throwIfAborted(); // Avoid action that has side-effects.
await progress.beforeInputAction();
await progress.beforeInputAction(this);
await this._page._delegate.setInputFiles(this as any as ElementHandle<HTMLInputElement>, files);
});
await this._page._doSlowMo();
@ -574,7 +574,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
if (result !== 'done')
return result;
progress.throwIfAborted(); // Avoid action that has side-effects.
await progress.beforeInputAction();
await progress.beforeInputAction(this);
await this._page.keyboard.type(text, options);
return 'done';
}, 'input');
@ -595,7 +595,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
if (result !== 'done')
return result;
progress.throwIfAborted(); // Avoid action that has side-effects.
await progress.beforeInputAction();
await progress.beforeInputAction(this);
await this._page.keyboard.press(key, options);
return 'done';
}, 'input');

View file

@ -40,6 +40,15 @@ export class FFExecutionContext implements js.ExecutionContextDelegate {
return payload.result!.objectId!;
}
rawCallFunctionNoReply(func: Function, ...args: any[]) {
this._session.send('Runtime.callFunction', {
functionDeclaration: func.toString(),
args: args.map(a => a instanceof js.JSHandle ? { objectId: a._objectId } : { value: a }) as any,
returnByValue: true,
executionContextId: this._executionContextId
}).catch(() => {});
}
async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle<any>, values: any[], objectIds: string[]): Promise<any> {
const payload = await this._session.send('Runtime.callFunction', {
functionDeclaration: expression,

View file

@ -19,6 +19,7 @@ import { Point, StackFrame } from '../common/types';
import type { Browser } from './browser';
import type { BrowserContext } from './browserContext';
import type { BrowserType } from './browserType';
import { ElementHandle } from './dom';
import type { Frame } from './frames';
import type { Page } from './page';
@ -66,7 +67,7 @@ export interface Instrumentation {
onContextDidDestroy(context: BrowserContext): Promise<void>;
onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise<void>;
onAfterInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): void;
onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
@ -78,7 +79,7 @@ export interface InstrumentationListener {
onContextDidDestroy?(context: BrowserContext): Promise<void>;
onBeforeCall?(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onBeforeInputAction?(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onBeforeInputAction?(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise<void>;
onAfterInputAction?(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onCallLog?(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): void;
onAfterCall?(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;

View file

@ -44,6 +44,7 @@ export type SmartHandle<T> = T extends Node ? dom.ElementHandle<T> : JSHandle<T>
export interface ExecutionContextDelegate {
rawEvaluate(expression: string): Promise<ObjectId>;
rawCallFunctionNoReply(func: Function, ...args: any[]): void;
evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: JSHandle<any>, values: any[], objectIds: ObjectId[]): Promise<any>;
getProperties(handle: JSHandle): Promise<Map<string, JSHandle>>;
createHandle(context: ExecutionContext, remoteObject: RemoteObject): JSHandle;
@ -109,6 +110,10 @@ export class JSHandle<T = any> extends SdkObject {
this._preview = 'JSHandle@' + String(this._objectId ? this._objectType : this._value);
}
callFunctionNoReply(func: Function, arg: any) {
this._context._delegate.rawCallFunctionNoReply(func, this, arg);
}
async evaluate<R, Arg>(pageFunction: FuncOn<T, Arg, R>, arg: Arg): Promise<R>;
async evaluate<R>(pageFunction: FuncOn<T, void, R>, arg?: any): Promise<R>;
async evaluate<R, Arg>(pageFunction: FuncOn<T, Arg, R>, arg: Arg): Promise<R> {

View file

@ -18,6 +18,7 @@ import { TimeoutError } from '../utils/errors';
import { assert, monotonicTime } from '../utils/utils';
import { LogName } from '../utils/debugLogger';
import { CallMetadata, Instrumentation, SdkObject } from './instrumentation';
import { ElementHandle } from './dom';
export interface Progress {
log(message: string): void;
@ -25,7 +26,7 @@ export interface Progress {
isRunning(): boolean;
cleanupWhenAborted(cleanup: () => any): void;
throwIfAborted(): void;
beforeInputAction(): Promise<void>;
beforeInputAction(element: ElementHandle): Promise<void>;
afterInputAction(): Promise<void>;
metadata: CallMetadata;
}
@ -87,8 +88,8 @@ export class ProgressController {
if (this._state === 'aborted')
throw new AbortedError();
},
beforeInputAction: async () => {
await this.instrumentation.onBeforeInputAction(this.sdkObject, this.metadata);
beforeInputAction: async (element: ElementHandle) => {
await this.instrumentation.onBeforeInputAction(this.sdkObject, this.metadata, element);
},
afterInputAction: async () => {
await this.instrumentation.onAfterInputAction(this.sdkObject, this.metadata);

View file

@ -23,6 +23,7 @@ import { SnapshotRenderer } from './snapshotRenderer';
import { SnapshotServer } from './snapshotServer';
import { BaseSnapshotStorage } from './snapshotStorage';
import { Snapshotter, SnapshotterBlob, SnapshotterDelegate } from './snapshotter';
import { ElementHandle } from '../dom';
const kSnapshotInterval = 25;
@ -52,11 +53,11 @@ export class InMemorySnapshotter extends BaseSnapshotStorage implements Snapshot
await this._server.stop();
}
async captureSnapshot(page: Page, snapshotName: string): Promise<SnapshotRenderer> {
async captureSnapshot(page: Page, snapshotName: string, element?: ElementHandle): Promise<SnapshotRenderer> {
if (this._frameSnapshots.has(snapshotName))
throw new Error('Duplicate snapshot name: ' + snapshotName);
this._snapshotter.captureSnapshot(page, snapshotName);
this._snapshotter.captureSnapshot(page, snapshotName, element);
return new Promise<SnapshotRenderer>(fulfill => {
const listener = helper.addEventListener(this, 'snapshot', (renderer: SnapshotRenderer) => {
if (renderer.snapshotName === snapshotName) {

View file

@ -22,6 +22,7 @@ import { BrowserContext } from '../browserContext';
import { Page } from '../page';
import { FrameSnapshot, ResourceSnapshot } from './snapshotTypes';
import { Snapshotter, SnapshotterBlob, SnapshotterDelegate } from './snapshotter';
import { ElementHandle } from '../dom';
const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs));
@ -47,6 +48,8 @@ export class PersistentSnapshotter extends EventEmitter implements SnapshotterDe
async start(): Promise<void> {
await fsMkdirAsync(this._resourcesDir, {recursive: true}).catch(() => {});
await fsAppendFileAsync(this._networkTrace, Buffer.from([]));
await fsAppendFileAsync(this._snapshotTrace, Buffer.from([]));
await this._snapshotter.initialize();
await this._snapshotter.setAutoSnapshotInterval(kSnapshotInterval);
}
@ -56,13 +59,13 @@ export class PersistentSnapshotter extends EventEmitter implements SnapshotterDe
await this._writeArtifactChain;
}
captureSnapshot(page: Page, snapshotName: string) {
this._snapshotter.captureSnapshot(page, snapshotName);
captureSnapshot(page: Page, snapshotName: string, element?: ElementHandle) {
this._snapshotter.captureSnapshot(page, snapshotName, element);
}
onBlob(blob: SnapshotterBlob): void {
this._writeArtifactChain = this._writeArtifactChain.then(async () => {
await fsWriteFileAsync(path.join(this._resourcesDir, blob.sha1), blob.buffer);
await fsWriteFileAsync(path.join(this._resourcesDir, blob.sha1), blob.buffer).catch(() => {});
});
}

View file

@ -71,9 +71,15 @@ export class SnapshotRenderer {
const snapshot = this._snapshots[this._index];
let html = visit(snapshot.html, this._index);
if (!html)
return { html: '', resources: {} };
if (snapshot.doctype)
html = `<!DOCTYPE ${snapshot.doctype}>` + html;
html += `<script>${snapshotScript()}</script>`;
html += `
<style>*[__playwright_target__="${this.snapshotName}"] { background-color: #6fa8dc7f; }</style>
<script>${snapshotScript()}</script>
`;
const resources: { [key: string]: { resourceId: string, sha1?: string } } = {};
for (const [url, contextResources] of this._contextResources) {

View file

@ -19,6 +19,7 @@ import querystring from 'querystring';
import { HttpServer } from '../../utils/httpServer';
import type { RenderedFrameSnapshot } from './snapshotTypes';
import { SnapshotStorage } from './snapshotStorage';
import type { Point } from '../../common/types';
export class SnapshotServer {
private _snapshotStorage: SnapshotStorage;
@ -51,35 +52,7 @@ export class SnapshotServer {
</style>
<body>
<script>
if (navigator.serviceWorker) {
navigator.serviceWorker.register('./service-worker.js');
let showPromise = Promise.resolve();
if (!navigator.serviceWorker.controller)
showPromise = new Promise(resolve => navigator.serviceWorker.oncontrollerchange = resolve);
let current = document.createElement('iframe');
document.body.appendChild(current);
let next = document.createElement('iframe');
document.body.appendChild(next);
next.style.visibility = 'hidden';
const onload = () => {
let temp = current;
current = next;
next = temp;
current.style.visibility = 'visible';
next.style.visibility = 'hidden';
};
current.onload = onload;
next.onload = onload;
window.showSnapshot = async url => {
await showPromise;
next.src = url;
};
window.addEventListener('message', event => {
window.showSnapshot(window.location.href + event.data.snapshotUrl);
}, false);
}
(${rootScript})();
</script>
</body>
`);
@ -245,3 +218,59 @@ export class SnapshotServer {
}
}
}
declare global {
interface Window {
showSnapshot: (url: string, point?: Point) => Promise<void>;
}
}
function rootScript() {
if (!navigator.serviceWorker)
return;
navigator.serviceWorker.register('./service-worker.js');
let showPromise = Promise.resolve();
if (!navigator.serviceWorker.controller) {
showPromise = new Promise(resolve => {
navigator.serviceWorker.oncontrollerchange = () => resolve();
});
}
const pointElement = document.createElement('div');
pointElement.style.position = 'fixed';
pointElement.style.backgroundColor = 'red';
pointElement.style.width = '20px';
pointElement.style.height = '20px';
pointElement.style.borderRadius = '10px';
pointElement.style.margin = '-10px 0 0 -10px';
pointElement.style.zIndex = '2147483647';
let current = document.createElement('iframe');
document.body.appendChild(current);
let next = document.createElement('iframe');
document.body.appendChild(next);
next.style.visibility = 'hidden';
const onload = () => {
const temp = current;
current = next;
next = temp;
current.style.visibility = 'visible';
next.style.visibility = 'hidden';
};
current.onload = onload;
next.onload = onload;
(window as any).showSnapshot = async (url: string, options: { point?: Point } = {}) => {
await showPromise;
next.src = url;
if (options.point) {
pointElement.style.left = options.point.x + 'px';
pointElement.style.top = options.point.y + 'px';
document.documentElement.appendChild(pointElement);
} else {
pointElement.remove();
}
};
window.addEventListener('message', event => {
window.showSnapshot(window.location.href + event.data.snapshotUrl);
}, false);
}

View file

@ -23,6 +23,7 @@ import { Frame } from '../frames';
import { SnapshotData, frameSnapshotStreamer, kSnapshotBinding, kSnapshotStreamer } from './snapshotterInjected';
import { calculateSha1, createGuid, monotonicTime } from '../../utils/utils';
import { FrameSnapshot, ResourceSnapshot } from './snapshotTypes';
import { ElementHandle } from '../dom';
export type SnapshotterBlob = {
buffer: Buffer,
@ -92,9 +93,12 @@ export class Snapshotter {
helper.removeEventListeners(this._eventListeners);
}
captureSnapshot(page: Page, snapshotName?: string) {
captureSnapshot(page: Page, snapshotName: string, element?: ElementHandle) {
// This needs to be sync, as in not awaiting for anything before we issue the command.
const expression = `window[${JSON.stringify(kSnapshotStreamer)}].captureSnapshot(${JSON.stringify(snapshotName)})`;
element?.callFunctionNoReply((element: Element, snapshotName: string) => {
element.setAttribute('__playwright_target__', snapshotName);
}, snapshotName);
const snapshotFrame = (frame: Frame) => {
const context = frame._existingMainContext();
context?.rawEvaluate(expression).catch(debugExceptionHandler);

View file

@ -317,8 +317,6 @@ export function frameSnapshotStreamer() {
if (nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
// if (node === target)
// attrs[' __playwright_target__] = '';
if (nodeName === 'INPUT') {
const value = (element as HTMLInputElement).value;
expectValue('value');

View file

@ -26,7 +26,6 @@ declare global {
_playwrightRecorderRecordAction: (action: actions.Action) => Promise<void>;
_playwrightRecorderCommitAction: () => Promise<void>;
_playwrightRecorderState: () => Promise<UIState>;
_playwrightResume: () => Promise<void>;
_playwrightRecorderSetSelector: (selector: string) => Promise<void>;
_playwrightRefreshOverlay: () => void;
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
import { StackFrame } from '../../../common/types';
import { CallMetadata } from '../../instrumentation';
export type ContextCreatedTraceEvent = {
timestamp: number,
@ -47,27 +47,11 @@ export type PageDestroyedTraceEvent = {
pageId: string,
};
export type PageVideoTraceEvent = {
timestamp: number,
type: 'page-video',
contextId: string,
pageId: string,
fileName: string,
};
export type ActionTraceEvent = {
timestamp: number,
type: 'action',
contextId: string,
objectType: string,
method: string,
params: any,
stack?: StackFrame[],
pageId?: string,
startTime: number,
endTime: number,
logs?: string[],
error?: string,
metadata: CallMetadata,
snapshots?: { title: string, snapshotName: string }[],
};
@ -109,7 +93,6 @@ export type TraceEvent =
ContextDestroyedTraceEvent |
PageCreatedTraceEvent |
PageDestroyedTraceEvent |
PageVideoTraceEvent |
ActionTraceEvent |
DialogOpenedEvent |
DialogClosedEvent |

View file

@ -18,8 +18,9 @@ import fs from 'fs';
import path from 'path';
import * as util from 'util';
import { createGuid, getFromENV, mkdirIfNeeded, monotonicTime } from '../../../utils/utils';
import { BrowserContext, Video } from '../../browserContext';
import { BrowserContext } from '../../browserContext';
import { Dialog } from '../../dialog';
import { ElementHandle } from '../../dom';
import { Frame, NavigationEvent } from '../../frames';
import { helper, RegisteredListener } from '../../helper';
import { CallMetadata, InstrumentationListener, SdkObject } from '../../instrumentation';
@ -28,18 +29,18 @@ import { PersistentSnapshotter } from '../../snapshot/persistentSnapshotter';
import * as trace from '../common/traceEvents';
const fsAppendFileAsync = util.promisify(fs.appendFile.bind(fs));
const envTrace = getFromENV('PW_TRACE_DIR');
const envTrace = getFromENV('PWTRACE_RESOURCE_DIR');
export class Tracer implements InstrumentationListener {
private _contextTracers = new Map<BrowserContext, ContextTracer>();
async onContextCreated(context: BrowserContext): Promise<void> {
const traceDir = envTrace || context._options._traceDir;
const traceDir = context._options._traceDir;
if (!traceDir)
return;
const traceStorageDir = path.join(traceDir, 'resources');
const resourcesDir = envTrace || path.join(traceDir, 'resources');
const tracePath = path.join(traceDir, createGuid());
const contextTracer = new ContextTracer(context, traceStorageDir, tracePath);
const contextTracer = new ContextTracer(context, resourcesDir, tracePath);
await contextTracer.start();
this._contextTracers.set(context, contextTracer);
}
@ -52,15 +53,16 @@ export class Tracer implements InstrumentationListener {
}
}
async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
this._contextTracers.get(sdkObject.attribution.context!)?.onActionCheckpoint('before', sdkObject, metadata);
async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise<void> {
this._contextTracers.get(sdkObject.attribution.context!)?._captureSnapshot('action', sdkObject, metadata, element);
}
async onAfterInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
this._contextTracers.get(sdkObject.attribution.context!)?.onActionCheckpoint('after', sdkObject, metadata);
async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata, element?: ElementHandle): Promise<void> {
this._contextTracers.get(sdkObject.attribution.context!)?._captureSnapshot('before', sdkObject, metadata, element);
}
async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
this._contextTracers.get(sdkObject.attribution.context!)?._captureSnapshot('after', sdkObject, metadata);
this._contextTracers.get(sdkObject.attribution.context!)?.onAfterCall(sdkObject, metadata);
}
}
@ -80,12 +82,10 @@ class ContextTracer {
private _snapshotter: PersistentSnapshotter;
private _eventListeners: RegisteredListener[];
private _disposed = false;
private _traceFile: string;
constructor(context: BrowserContext, traceStorageDir: string, tracePrefix: string) {
constructor(context: BrowserContext, resourcesDir: string, tracePrefix: string) {
const traceFile = tracePrefix + '-actions.trace';
this._contextId = 'context@' + createGuid();
this._traceFile = traceFile;
this._appendEventChain = mkdirIfNeeded(traceFile).then(() => traceFile);
const event: trace.ContextCreatedTraceEvent = {
timestamp: monotonicTime(),
@ -98,7 +98,7 @@ class ContextTracer {
debugName: context._options._debugName,
};
this._appendTraceEvent(event);
this._snapshotter = new PersistentSnapshotter(context, tracePrefix, traceStorageDir);
this._snapshotter = new PersistentSnapshotter(context, tracePrefix, resourcesDir);
this._eventListeners = [
helper.addEventListener(context, BrowserContext.Events.Page, this._onPage.bind(this)),
];
@ -108,12 +108,12 @@ class ContextTracer {
await this._snapshotter.start();
}
async onActionCheckpoint(name: string, sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
async _captureSnapshot(name: string, sdkObject: SdkObject, metadata: CallMetadata, element?: ElementHandle): Promise<void> {
if (!sdkObject.attribution.page)
return;
const snapshotName = `${name}@${metadata.id}`;
snapshotsForMetadata(metadata).push({ title: name, snapshotName });
this._snapshotter.captureSnapshot(sdkObject.attribution.page, snapshotName);
this._snapshotter.captureSnapshot(sdkObject.attribution.page, snapshotName, element);
}
async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
@ -123,16 +123,7 @@ class ContextTracer {
timestamp: monotonicTime(),
type: 'action',
contextId: this._contextId,
pageId: sdkObject.attribution.page.uniqueId,
objectType: metadata.type,
method: metadata.method,
// FIXME: filter out evaluation snippets, binary
params: metadata.params,
stack: metadata.stack,
startTime: metadata.startTime,
endTime: metadata.endTime,
logs: metadata.log.slice(),
error: metadata.error,
metadata,
snapshots: snapshotsForMetadata(metadata),
};
this._appendTraceEvent(event);
@ -149,19 +140,6 @@ class ContextTracer {
};
this._appendTraceEvent(event);
page.on(Page.Events.VideoStarted, (video: Video) => {
if (this._disposed)
return;
const event: trace.PageVideoTraceEvent = {
timestamp: monotonicTime(),
type: 'page-video',
contextId: this._contextId,
pageId,
fileName: path.relative(path.dirname(this._traceFile), video._path),
};
this._appendTraceEvent(event);
});
page.on(Page.Events.Dialog, (dialog: Dialog) => {
if (this._disposed)
return;

View file

@ -38,7 +38,7 @@ export class TraceModel {
actions.reverse();
for (const action of actions) {
while (resources.length && resources[0].timestamp > action.action.timestamp)
while (resources.length && resources[0].timestamp > action.timestamp)
action.resources.push(resources.shift()!);
action.resources.reverse();
}
@ -79,14 +79,15 @@ export class TraceModel {
break;
}
case 'action': {
if (!kInterestingActions.includes(event.method))
const metadata = event.metadata;
if (metadata.method === 'waitForEventInfo')
break;
const { pageEntry } = this.pageEntries.get(event.pageId!)!;
const actionId = event.contextId + '/' + event.pageId + '/' + pageEntry.actions.length;
const { pageEntry } = this.pageEntries.get(metadata.pageId!)!;
const actionId = event.contextId + '/' + metadata.pageId + '/' + pageEntry.actions.length;
const action: ActionEntry = {
actionId,
action: event,
resources: []
resources: [],
...event,
};
pageEntry.actions.push(action);
break;
@ -146,10 +147,7 @@ export type PageEntry = {
interestingEvents: InterestingPageEvent[];
}
export type ActionEntry = {
export type ActionEntry = trace.ActionTraceEvent & {
actionId: string;
action: trace.ActionTraceEvent;
resources: ResourceSnapshot[]
};
const kInterestingActions = ['click', 'dblclick', 'hover', 'check', 'uncheck', 'tap', 'fill', 'press', 'type', 'selectOption', 'setInputFiles', 'goto', 'setContent', 'goBack', 'goForward', 'reload'];

View file

@ -16,13 +16,17 @@
import fs from 'fs';
import path from 'path';
import * as playwright from '../../../..';
import { createPlaywright } from '../../playwright';
import * as util from 'util';
import { TraceModel } from './traceModel';
import { TraceEvent } from '../common/traceEvents';
import { ServerRouteHandler, HttpServer } from '../../../utils/httpServer';
import { SnapshotServer } from '../../snapshot/snapshotServer';
import { PersistentSnapshotStorage } from '../../snapshot/snapshotStorage';
import * as consoleApiSource from '../../../generated/consoleApiSource';
import { isUnderTest } from '../../../utils/utils';
import { internalCallMetadata } from '../../instrumentation';
import { ProgressController } from '../../progress';
const fsReadFileAsync = util.promisify(fs.readFile.bind(fs));
@ -34,8 +38,9 @@ type TraceViewerDocument = {
class TraceViewer {
private _document: TraceViewerDocument | undefined;
async show(traceDir: string) {
const resourcesDir = path.join(traceDir, 'resources');
async show(traceDir: string, resourcesDir?: string) {
if (!resourcesDir)
resourcesDir = path.join(traceDir, 'resources');
const model = new TraceModel();
this._document = {
model,
@ -56,7 +61,6 @@ class TraceViewer {
// - "/snapshot/pageId/..." - actual snapshot html.
// - "/snapshot/service-worker.js" - service worker that intercepts snapshot resources
// and translates them into "/resources/<resourceId>".
const actionsTrace = fs.readdirSync(traceDir).find(name => name.endsWith('-actions.trace'))!;
const tracePrefix = path.join(traceDir, actionsTrace.substring(0, actionsTrace.indexOf('-actions.trace')));
const server = new HttpServer();
@ -108,14 +112,33 @@ class TraceViewer {
const urlPrefix = await server.start();
const browser = await playwright.chromium.launch({ headless: false });
const uiPage = await browser.newPage({ viewport: null });
uiPage.on('close', () => process.exit(0));
await uiPage.goto(urlPrefix + '/traceviewer/traceViewer/index.html');
const traceViewerPlaywright = createPlaywright(true);
const args = [
'--app=data:text/html,',
'--window-position=1280,10',
];
if (isUnderTest())
args.push(`--remote-debugging-port=0`);
const context = await traceViewerPlaywright.chromium.launchPersistentContext(internalCallMetadata(), '', {
// TODO: store language in the trace.
sdkLanguage: 'javascript',
args,
noDefaultViewport: true,
headless: !!process.env.PWCLI_HEADLESS_FOR_TEST,
useWebSocket: isUnderTest()
});
const controller = new ProgressController(internalCallMetadata(), context._browser);
await controller.run(async progress => {
await context._browser._defaultContext!._loadDefaultContextAsIs(progress);
});
await context.extendInjectedScript(consoleApiSource.source);
const [page] = context.pages();
page.on('close', () => process.exit(0));
await page.mainFrame().goto(internalCallMetadata(), urlPrefix + '/traceviewer/traceViewer/index.html');
}
}
export async function showTraceViewer(traceDir: string) {
export async function showTraceViewer(traceDir: string, resourcesDir?: string) {
const traceViewer = new TraceViewer();
await traceViewer.show(traceDir);
await traceViewer.show(traceDir, resourcesDir);
}

View file

@ -53,6 +53,16 @@ export class WKExecutionContext implements js.ExecutionContextDelegate {
}
}
rawCallFunctionNoReply(func: Function, ...args: any[]) {
this._session.send('Runtime.callFunctionOn', {
functionDeclaration: func.toString(),
objectId: args.find(a => a instanceof js.JSHandle)!._objectId,
arguments: args.map(a => a instanceof js.JSHandle ? { objectId: a._objectId } : { value: a }),
returnByValue: true,
emulateUserGesture: true
}).catch(() => {});
}
async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle<any>, values: any[], objectIds: string[]): Promise<any> {
try {
let response = await this._session.send('Runtime.callFunctionOn', {

View file

@ -35,7 +35,7 @@ export const ActionList: React.FC<ActionListProps> = ({
}) => {
const targetAction = highlightedAction || selectedAction;
return <div className='action-list'>{actions.map(actionEntry => {
const { action, actionId } = actionEntry;
const { metadata, actionId } = actionEntry;
return <div
className={'action-entry' + (actionEntry === targetAction ? ' selected' : '')}
key={actionId}
@ -44,10 +44,10 @@ export const ActionList: React.FC<ActionListProps> = ({
onMouseLeave={() => (highlightedAction === actionEntry) && onHighlighted(undefined)}
>
<div className='action-header'>
<div className={'action-error codicon codicon-issues'} hidden={!actionEntry.action.error} />
<div className='action-title'>{action.method}</div>
{action.params.selector && <div className='action-selector' title={action.params.selector}>{action.params.selector}</div>}
{action.method === 'goto' && action.params.url && <div className='action-url' title={action.params.url}>{action.params.url}</div>}
<div className={'action-error codicon codicon-issues'} hidden={!metadata.error} />
<div className='action-title'>{metadata.method}</div>
{metadata.params.selector && <div className='action-selector' title={metadata.params.selector}>{metadata.params.selector}</div>}
{metadata.method === 'goto' && metadata.params.url && <div className='action-url' title={metadata.params.url}>{metadata.params.url}</div>}
</div>
</div>;
})}</div>;

View file

@ -23,9 +23,9 @@ export const LogsTab: React.FunctionComponent<{
}> = ({ actionEntry }) => {
let logs: string[] = [];
if (actionEntry) {
logs = actionEntry.action.logs || [];
if (actionEntry.action.error)
logs = [actionEntry.action.error, ...logs];
logs = actionEntry.metadata.log || [];
if (actionEntry.metadata.error)
logs = [actionEntry.metadata.error, ...logs];
}
return <div className='logs-tab'>{
logs.map((logLine, index) => {

View file

@ -19,8 +19,6 @@ import * as React from 'react';
import { Expandable } from './helpers';
import type { ResourceSnapshot } from '../../../server/snapshot/snapshotTypes';
const utf8Encoder = new TextDecoder('utf-8');
export const NetworkResourceDetails: React.FunctionComponent<{
resource: ResourceSnapshot,
index: number,

View file

@ -20,6 +20,7 @@ import './snapshotTab.css';
import * as React from 'react';
import { useMeasure } from './helpers';
import { msToString } from '../../uiUtils';
import type { Point } from '../../../common/types';
export const SnapshotTab: React.FunctionComponent<{
actionEntry: ActionEntry | undefined,
@ -30,7 +31,7 @@ export const SnapshotTab: React.FunctionComponent<{
const [measure, ref] = useMeasure<HTMLDivElement>();
const [snapshotIndex, setSnapshotIndex] = React.useState(0);
const snapshots = actionEntry ? (actionEntry.action.snapshots || []) : [];
const snapshots = actionEntry ? (actionEntry.snapshots || []) : [];
const { pageId, time } = selection || { pageId: undefined, time: 0 };
const iframeRef = React.createRef<HTMLIFrameElement>();
@ -39,16 +40,20 @@ export const SnapshotTab: React.FunctionComponent<{
return;
let snapshotUri = undefined;
let point: Point | undefined = undefined;
if (pageId) {
snapshotUri = `${pageId}?time=${time}`;
} else if (actionEntry) {
const snapshot = snapshots[snapshotIndex];
if (snapshot && snapshot.snapshotName)
snapshotUri = `${actionEntry.action.pageId}?name=${snapshot.snapshotName}`;
if (snapshot && snapshot.snapshotName) {
snapshotUri = `${actionEntry.metadata.pageId}?name=${snapshot.snapshotName}`;
if (snapshot.snapshotName.includes('action'))
point = actionEntry.metadata.point;
}
}
const snapshotUrl = snapshotUri ? `${window.location.origin}/snapshot/${snapshotUri}` : 'data:text/html,Snapshot is not available';
try {
(iframeRef.current.contentWindow as any).showSnapshot(snapshotUrl);
(iframeRef.current.contentWindow as any).showSnapshot(snapshotUrl, { point });
} catch (e) {
}
}, [actionEntry, snapshotIndex, pageId, time]);

View file

@ -43,10 +43,10 @@ export const SourceTab: React.FunctionComponent<{
const stackInfo = React.useMemo<StackInfo>(() => {
if (!actionEntry)
return '';
const { action } = actionEntry;
if (!action.stack)
const { metadata } = actionEntry;
if (!metadata.stack)
return '';
const frames = action.stack;
const frames = metadata.stack;
return {
frames,
fileContent: new Map(),

View file

@ -54,17 +54,17 @@ export const Timeline: React.FunctionComponent<{
const bars: TimelineBar[] = [];
for (const page of context.pages) {
for (const entry of page.actions) {
let detail = entry.action.params.selector || '';
if (entry.action.method === 'goto')
detail = entry.action.params.url || '';
let detail = entry.metadata.params.selector || '';
if (entry.metadata.method === 'goto')
detail = entry.metadata.params.url || '';
bars.push({
entry,
leftTime: entry.action.startTime,
rightTime: entry.action.endTime,
leftPosition: timeToPosition(measure.width, boundaries, entry.action.startTime),
rightPosition: timeToPosition(measure.width, boundaries, entry.action.endTime),
label: entry.action.method + ' ' + detail,
type: entry.action.method,
leftTime: entry.metadata.startTime,
rightTime: entry.metadata.endTime,
leftPosition: timeToPosition(measure.width, boundaries, entry.metadata.startTime),
rightPosition: timeToPosition(measure.width, boundaries, entry.metadata.endTime),
label: entry.metadata.method + ' ' + detail,
type: entry.metadata.method,
priority: 0,
});
}

View file

@ -14,7 +14,7 @@
limitations under the License.
*/
import { ActionEntry, ContextEntry, TraceModel } from '../../../server/trace/viewer/traceModel';
import { ActionEntry, ContextEntry } from '../../../server/trace/viewer/traceModel';
import { ActionList } from './actionList';
import { TabbedPane } from './tabbedPane';
import { Timeline } from './timeline';

View file

@ -144,13 +144,10 @@ fixtures.isLinux.init(async ({ platform }, run) => {
}, { scope: 'worker' });
fixtures.contextOptions.init(async ({ video, testInfo }, run) => {
if (video) {
await run({
recordVideo: { dir: testInfo.outputPath('') },
});
} else {
await run({});
}
await run({
recordVideo: video ? { dir: testInfo.outputPath('') } : undefined,
_traceDir: process.env.PWTRACE ? testInfo.outputPath('') : undefined,
} as any);
});
fixtures.contextFactory.init(async ({ browser, contextOptions, testInfo, screenshotOnFailure }, run) => {

View file

@ -175,12 +175,46 @@ describe('snapshots', (suite, { mode }) => {
const button = await previewPage.frames()[3].waitForSelector('button');
expect(await button.textContent()).toBe('Hello iframe');
});
it('should capture snapshot target', async ({ snapshotter, page, toImpl }) => {
await page.setContent('<button>Hello</button><button>World</button>');
{
const handle = await page.$('text=Hello');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot', toImpl(handle));
expect(distillSnapshot(snapshot)).toBe('<BUTTON __playwright_target__=\"snapshot\">Hello</BUTTON><BUTTON>World</BUTTON>');
}
{
const handle = await page.$('text=World');
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2', toImpl(handle));
expect(distillSnapshot(snapshot)).toBe('<BUTTON __playwright_target__=\"snapshot\">Hello</BUTTON><BUTTON __playwright_target__=\"snapshot2\">World</BUTTON>');
}
});
it('should collect on attribute change', async ({ snapshotter, page, toImpl }) => {
await page.setContent('<button>Hello</button>');
{
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot');
expect(distillSnapshot(snapshot)).toBe('<BUTTON>Hello</BUTTON>');
}
const handle = await page.$('text=Hello')!;
await handle.evaluate(element => element.setAttribute('data', 'one'));
{
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2');
expect(distillSnapshot(snapshot)).toBe('<BUTTON data="one">Hello</BUTTON>');
}
await handle.evaluate(element => element.setAttribute('data', 'two'));
{
const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2');
expect(distillSnapshot(snapshot)).toBe('<BUTTON data="two">Hello</BUTTON>');
}
});
});
function distillSnapshot(snapshot) {
const { html } = snapshot.render();
return html
.replace(/<script>[.\s\S]+<\/script>/, '')
.replace(/<style>.*__playwright_target__.*<\/style>/, '')
.replace(/<BASE href="about:blank">/, '')
.replace(/<BASE href="http:\/\/localhost:[\d]+\/empty.html">/, '')
.replace(/<HTML>/, '')
@ -188,5 +222,5 @@ function distillSnapshot(snapshot) {
.replace(/<HEAD>/, '')
.replace(/<\/HEAD>/, '')
.replace(/<BODY>/, '')
.replace(/<\/BODY>/, '');
.replace(/<\/BODY>/, '').trim();
}