feat(trace): highlight action target (#5776)
This commit is contained in:
parent
42e9a4703c
commit
fea6669473
|
|
@ -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:');
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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(() => {});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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'];
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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', {
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue