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) { if (process.env.PWTRACE) {
program program
.command('show-trace [trace]') .command('show-trace [trace]')
.option('--resources <dir>', 'load resources from shared folder')
.description('Show trace viewer') .description('Show trace viewer')
.action(function(trace, command) { .action(function(trace, command) {
showTraceViewer(trace); showTraceViewer(trace, command.resources);
}).on('--help', function() { }).on('--help', function() {
console.log(''); console.log('');
console.log('Examples:'); console.log('Examples:');

View file

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

View file

@ -40,6 +40,15 @@ export class FFExecutionContext implements js.ExecutionContextDelegate {
return payload.result!.objectId!; 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> { async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle<any>, values: any[], objectIds: string[]): Promise<any> {
const payload = await this._session.send('Runtime.callFunction', { const payload = await this._session.send('Runtime.callFunction', {
functionDeclaration: expression, functionDeclaration: expression,

View file

@ -19,6 +19,7 @@ import { Point, StackFrame } from '../common/types';
import type { Browser } from './browser'; import type { Browser } from './browser';
import type { BrowserContext } from './browserContext'; import type { BrowserContext } from './browserContext';
import type { BrowserType } from './browserType'; import type { BrowserType } from './browserType';
import { ElementHandle } from './dom';
import type { Frame } from './frames'; import type { Frame } from './frames';
import type { Page } from './page'; import type { Page } from './page';
@ -66,7 +67,7 @@ export interface Instrumentation {
onContextDidDestroy(context: BrowserContext): Promise<void>; onContextDidDestroy(context: BrowserContext): Promise<void>;
onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): 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>; onAfterInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): void; onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): void;
onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>; onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
@ -78,7 +79,7 @@ export interface InstrumentationListener {
onContextDidDestroy?(context: BrowserContext): Promise<void>; onContextDidDestroy?(context: BrowserContext): Promise<void>;
onBeforeCall?(sdkObject: SdkObject, metadata: CallMetadata): 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>; onAfterInputAction?(sdkObject: SdkObject, metadata: CallMetadata): Promise<void>;
onCallLog?(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): void; onCallLog?(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): void;
onAfterCall?(sdkObject: SdkObject, metadata: CallMetadata): Promise<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 { export interface ExecutionContextDelegate {
rawEvaluate(expression: string): Promise<ObjectId>; 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>; evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: JSHandle<any>, values: any[], objectIds: ObjectId[]): Promise<any>;
getProperties(handle: JSHandle): Promise<Map<string, JSHandle>>; getProperties(handle: JSHandle): Promise<Map<string, JSHandle>>;
createHandle(context: ExecutionContext, remoteObject: RemoteObject): 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); 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, 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>(pageFunction: FuncOn<T, void, R>, arg?: any): Promise<R>;
async evaluate<R, Arg>(pageFunction: FuncOn<T, Arg, R>, arg: Arg): 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 { assert, monotonicTime } from '../utils/utils';
import { LogName } from '../utils/debugLogger'; import { LogName } from '../utils/debugLogger';
import { CallMetadata, Instrumentation, SdkObject } from './instrumentation'; import { CallMetadata, Instrumentation, SdkObject } from './instrumentation';
import { ElementHandle } from './dom';
export interface Progress { export interface Progress {
log(message: string): void; log(message: string): void;
@ -25,7 +26,7 @@ export interface Progress {
isRunning(): boolean; isRunning(): boolean;
cleanupWhenAborted(cleanup: () => any): void; cleanupWhenAborted(cleanup: () => any): void;
throwIfAborted(): void; throwIfAborted(): void;
beforeInputAction(): Promise<void>; beforeInputAction(element: ElementHandle): Promise<void>;
afterInputAction(): Promise<void>; afterInputAction(): Promise<void>;
metadata: CallMetadata; metadata: CallMetadata;
} }
@ -87,8 +88,8 @@ export class ProgressController {
if (this._state === 'aborted') if (this._state === 'aborted')
throw new AbortedError(); throw new AbortedError();
}, },
beforeInputAction: async () => { beforeInputAction: async (element: ElementHandle) => {
await this.instrumentation.onBeforeInputAction(this.sdkObject, this.metadata); await this.instrumentation.onBeforeInputAction(this.sdkObject, this.metadata, element);
}, },
afterInputAction: async () => { afterInputAction: async () => {
await this.instrumentation.onAfterInputAction(this.sdkObject, this.metadata); await this.instrumentation.onAfterInputAction(this.sdkObject, this.metadata);

View file

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

View file

@ -22,6 +22,7 @@ import { BrowserContext } from '../browserContext';
import { Page } from '../page'; import { Page } from '../page';
import { FrameSnapshot, ResourceSnapshot } from './snapshotTypes'; import { FrameSnapshot, ResourceSnapshot } from './snapshotTypes';
import { Snapshotter, SnapshotterBlob, SnapshotterDelegate } from './snapshotter'; import { Snapshotter, SnapshotterBlob, SnapshotterDelegate } from './snapshotter';
import { ElementHandle } from '../dom';
const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs)); const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs));
@ -47,6 +48,8 @@ export class PersistentSnapshotter extends EventEmitter implements SnapshotterDe
async start(): Promise<void> { async start(): Promise<void> {
await fsMkdirAsync(this._resourcesDir, {recursive: true}).catch(() => {}); 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.initialize();
await this._snapshotter.setAutoSnapshotInterval(kSnapshotInterval); await this._snapshotter.setAutoSnapshotInterval(kSnapshotInterval);
} }
@ -56,13 +59,13 @@ export class PersistentSnapshotter extends EventEmitter implements SnapshotterDe
await this._writeArtifactChain; await this._writeArtifactChain;
} }
captureSnapshot(page: Page, snapshotName: string) { captureSnapshot(page: Page, snapshotName: string, element?: ElementHandle) {
this._snapshotter.captureSnapshot(page, snapshotName); this._snapshotter.captureSnapshot(page, snapshotName, element);
} }
onBlob(blob: SnapshotterBlob): void { onBlob(blob: SnapshotterBlob): void {
this._writeArtifactChain = this._writeArtifactChain.then(async () => { 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]; const snapshot = this._snapshots[this._index];
let html = visit(snapshot.html, this._index); let html = visit(snapshot.html, this._index);
if (!html)
return { html: '', resources: {} };
if (snapshot.doctype) if (snapshot.doctype)
html = `<!DOCTYPE ${snapshot.doctype}>` + html; 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 } } = {}; const resources: { [key: string]: { resourceId: string, sha1?: string } } = {};
for (const [url, contextResources] of this._contextResources) { for (const [url, contextResources] of this._contextResources) {

View file

@ -19,6 +19,7 @@ import querystring from 'querystring';
import { HttpServer } from '../../utils/httpServer'; import { HttpServer } from '../../utils/httpServer';
import type { RenderedFrameSnapshot } from './snapshotTypes'; import type { RenderedFrameSnapshot } from './snapshotTypes';
import { SnapshotStorage } from './snapshotStorage'; import { SnapshotStorage } from './snapshotStorage';
import type { Point } from '../../common/types';
export class SnapshotServer { export class SnapshotServer {
private _snapshotStorage: SnapshotStorage; private _snapshotStorage: SnapshotStorage;
@ -51,35 +52,7 @@ export class SnapshotServer {
</style> </style>
<body> <body>
<script> <script>
if (navigator.serviceWorker) { (${rootScript})();
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);
}
</script> </script>
</body> </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 { SnapshotData, frameSnapshotStreamer, kSnapshotBinding, kSnapshotStreamer } from './snapshotterInjected';
import { calculateSha1, createGuid, monotonicTime } from '../../utils/utils'; import { calculateSha1, createGuid, monotonicTime } from '../../utils/utils';
import { FrameSnapshot, ResourceSnapshot } from './snapshotTypes'; import { FrameSnapshot, ResourceSnapshot } from './snapshotTypes';
import { ElementHandle } from '../dom';
export type SnapshotterBlob = { export type SnapshotterBlob = {
buffer: Buffer, buffer: Buffer,
@ -92,9 +93,12 @@ export class Snapshotter {
helper.removeEventListeners(this._eventListeners); 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. // 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)})`; 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 snapshotFrame = (frame: Frame) => {
const context = frame._existingMainContext(); const context = frame._existingMainContext();
context?.rawEvaluate(expression).catch(debugExceptionHandler); context?.rawEvaluate(expression).catch(debugExceptionHandler);

View file

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

View file

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

View file

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

View file

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

View file

@ -38,7 +38,7 @@ export class TraceModel {
actions.reverse(); actions.reverse();
for (const action of actions) { 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.push(resources.shift()!);
action.resources.reverse(); action.resources.reverse();
} }
@ -79,14 +79,15 @@ export class TraceModel {
break; break;
} }
case 'action': { case 'action': {
if (!kInterestingActions.includes(event.method)) const metadata = event.metadata;
if (metadata.method === 'waitForEventInfo')
break; break;
const { pageEntry } = this.pageEntries.get(event.pageId!)!; const { pageEntry } = this.pageEntries.get(metadata.pageId!)!;
const actionId = event.contextId + '/' + event.pageId + '/' + pageEntry.actions.length; const actionId = event.contextId + '/' + metadata.pageId + '/' + pageEntry.actions.length;
const action: ActionEntry = { const action: ActionEntry = {
actionId, actionId,
action: event, resources: [],
resources: [] ...event,
}; };
pageEntry.actions.push(action); pageEntry.actions.push(action);
break; break;
@ -146,10 +147,7 @@ export type PageEntry = {
interestingEvents: InterestingPageEvent[]; interestingEvents: InterestingPageEvent[];
} }
export type ActionEntry = { export type ActionEntry = trace.ActionTraceEvent & {
actionId: string; actionId: string;
action: trace.ActionTraceEvent;
resources: ResourceSnapshot[] 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 fs from 'fs';
import path from 'path'; import path from 'path';
import * as playwright from '../../../..'; import { createPlaywright } from '../../playwright';
import * as util from 'util'; import * as util from 'util';
import { TraceModel } from './traceModel'; import { TraceModel } from './traceModel';
import { TraceEvent } from '../common/traceEvents'; import { TraceEvent } from '../common/traceEvents';
import { ServerRouteHandler, HttpServer } from '../../../utils/httpServer'; import { ServerRouteHandler, HttpServer } from '../../../utils/httpServer';
import { SnapshotServer } from '../../snapshot/snapshotServer'; import { SnapshotServer } from '../../snapshot/snapshotServer';
import { PersistentSnapshotStorage } from '../../snapshot/snapshotStorage'; 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)); const fsReadFileAsync = util.promisify(fs.readFile.bind(fs));
@ -34,8 +38,9 @@ type TraceViewerDocument = {
class TraceViewer { class TraceViewer {
private _document: TraceViewerDocument | undefined; private _document: TraceViewerDocument | undefined;
async show(traceDir: string) { async show(traceDir: string, resourcesDir?: string) {
const resourcesDir = path.join(traceDir, 'resources'); if (!resourcesDir)
resourcesDir = path.join(traceDir, 'resources');
const model = new TraceModel(); const model = new TraceModel();
this._document = { this._document = {
model, model,
@ -56,7 +61,6 @@ class TraceViewer {
// - "/snapshot/pageId/..." - actual snapshot html. // - "/snapshot/pageId/..." - actual snapshot html.
// - "/snapshot/service-worker.js" - service worker that intercepts snapshot resources // - "/snapshot/service-worker.js" - service worker that intercepts snapshot resources
// and translates them into "/resources/<resourceId>". // and translates them into "/resources/<resourceId>".
const actionsTrace = fs.readdirSync(traceDir).find(name => name.endsWith('-actions.trace'))!; const actionsTrace = fs.readdirSync(traceDir).find(name => name.endsWith('-actions.trace'))!;
const tracePrefix = path.join(traceDir, actionsTrace.substring(0, actionsTrace.indexOf('-actions.trace'))); const tracePrefix = path.join(traceDir, actionsTrace.substring(0, actionsTrace.indexOf('-actions.trace')));
const server = new HttpServer(); const server = new HttpServer();
@ -108,14 +112,33 @@ class TraceViewer {
const urlPrefix = await server.start(); const urlPrefix = await server.start();
const browser = await playwright.chromium.launch({ headless: false }); const traceViewerPlaywright = createPlaywright(true);
const uiPage = await browser.newPage({ viewport: null }); const args = [
uiPage.on('close', () => process.exit(0)); '--app=data:text/html,',
await uiPage.goto(urlPrefix + '/traceviewer/traceViewer/index.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(); 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> { async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle<any>, values: any[], objectIds: string[]): Promise<any> {
try { try {
let response = await this._session.send('Runtime.callFunctionOn', { let response = await this._session.send('Runtime.callFunctionOn', {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,7 +14,7 @@
limitations under the License. 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 { ActionList } from './actionList';
import { TabbedPane } from './tabbedPane'; import { TabbedPane } from './tabbedPane';
import { Timeline } from './timeline'; import { Timeline } from './timeline';

View file

@ -144,13 +144,10 @@ fixtures.isLinux.init(async ({ platform }, run) => {
}, { scope: 'worker' }); }, { scope: 'worker' });
fixtures.contextOptions.init(async ({ video, testInfo }, run) => { fixtures.contextOptions.init(async ({ video, testInfo }, run) => {
if (video) { await run({
await run({ recordVideo: video ? { dir: testInfo.outputPath('') } : undefined,
recordVideo: { dir: testInfo.outputPath('') }, _traceDir: process.env.PWTRACE ? testInfo.outputPath('') : undefined,
}); } as any);
} else {
await run({});
}
}); });
fixtures.contextFactory.init(async ({ browser, contextOptions, testInfo, screenshotOnFailure }, run) => { 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'); const button = await previewPage.frames()[3].waitForSelector('button');
expect(await button.textContent()).toBe('Hello iframe'); 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) { function distillSnapshot(snapshot) {
const { html } = snapshot.render(); const { html } = snapshot.render();
return html return html
.replace(/<script>[.\s\S]+<\/script>/, '') .replace(/<script>[.\s\S]+<\/script>/, '')
.replace(/<style>.*__playwright_target__.*<\/style>/, '')
.replace(/<BASE href="about:blank">/, '') .replace(/<BASE href="about:blank">/, '')
.replace(/<BASE href="http:\/\/localhost:[\d]+\/empty.html">/, '') .replace(/<BASE href="http:\/\/localhost:[\d]+\/empty.html">/, '')
.replace(/<HTML>/, '') .replace(/<HTML>/, '')
@ -188,5 +222,5 @@ function distillSnapshot(snapshot) {
.replace(/<HEAD>/, '') .replace(/<HEAD>/, '')
.replace(/<\/HEAD>/, '') .replace(/<\/HEAD>/, '')
.replace(/<BODY>/, '') .replace(/<BODY>/, '')
.replace(/<\/BODY>/, ''); .replace(/<\/BODY>/, '').trim();
} }