diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 53ac4a443c..625eef6330 100755 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -144,9 +144,10 @@ commandWithOpenOptions('pdf ', 'save page as pdf', if (process.env.PWTRACE) { program .command('show-trace [trace]') + .option('--resources ', 'load resources from shared folder') .description('Show trace viewer') .action(function(trace, command) { - showTraceViewer(trace); + showTraceViewer(trace, command.resources); }).on('--help', function() { console.log(''); console.log('Examples:'); diff --git a/src/server/chromium/crExecutionContext.ts b/src/server/chromium/crExecutionContext.ts index 9202230774..02caf71bca 100644 --- a/src/server/chromium/crExecutionContext.ts +++ b/src/server/chromium/crExecutionContext.ts @@ -41,6 +41,16 @@ export class CRExecutionContext implements js.ExecutionContextDelegate { return remoteObject.objectId!; } + rawCallFunctionNoReply(func: Function, ...args: any[]) { + this._client.send('Runtime.callFunctionOn', { + functionDeclaration: func.toString(), + arguments: args.map(a => a instanceof js.JSHandle ? { objectId: a._objectId } : { value: a }), + returnByValue: true, + executionContextId: this._contextId, + userGesture: true + }).catch(() => {}); + } + async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], objectIds: string[]): Promise { const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.callFunctionOn', { functionDeclaration: expression, diff --git a/src/server/dom.ts b/src/server/dom.ts index 16face0140..b7d04f54f2 100644 --- a/src/server/dom.ts +++ b/src/server/dom.ts @@ -383,7 +383,7 @@ export class ElementHandle extends js.JSHandle { restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers); progress.log(` performing ${actionName} action`); progress.metadata.point = point; - await progress.beforeInputAction(); + await progress.beforeInputAction(this); await action(point); progress.log(` ${actionName} action done`); progress.log(' waiting for scheduled navigations to finish'); @@ -458,7 +458,7 @@ export class ElementHandle extends js.JSHandle { return this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => { progress.throwIfAborted(); // Avoid action that has side-effects. progress.log(' selecting specified option(s)'); - await progress.beforeInputAction(); + await progress.beforeInputAction(this); const poll = await this._evaluateHandleInUtility(([injected, node, optionsToSelect]) => { return injected.waitForElementStatesAndPerformAction(node, ['visible', 'enabled'], injected.selectOptions.bind(injected, optionsToSelect)); }, optionsToSelect); @@ -490,7 +490,7 @@ export class ElementHandle extends js.JSHandle { if (filled === 'error:notconnected') return filled; progress.log(' element is visible, enabled and editable'); - await progress.beforeInputAction(); + await progress.beforeInputAction(this); if (filled === 'needsinput') { progress.throwIfAborted(); // Avoid action that has side-effects. if (value) @@ -537,7 +537,7 @@ export class ElementHandle extends js.JSHandle { assert(multiple || files.length <= 1, 'Non-multiple file input can only accept single file!'); await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => { progress.throwIfAborted(); // Avoid action that has side-effects. - await progress.beforeInputAction(); + await progress.beforeInputAction(this); await this._page._delegate.setInputFiles(this as any as ElementHandle, files); }); await this._page._doSlowMo(); @@ -574,7 +574,7 @@ export class ElementHandle extends js.JSHandle { if (result !== 'done') return result; progress.throwIfAborted(); // Avoid action that has side-effects. - await progress.beforeInputAction(); + await progress.beforeInputAction(this); await this._page.keyboard.type(text, options); return 'done'; }, 'input'); @@ -595,7 +595,7 @@ export class ElementHandle extends js.JSHandle { if (result !== 'done') return result; progress.throwIfAborted(); // Avoid action that has side-effects. - await progress.beforeInputAction(); + await progress.beforeInputAction(this); await this._page.keyboard.press(key, options); return 'done'; }, 'input'); diff --git a/src/server/firefox/ffExecutionContext.ts b/src/server/firefox/ffExecutionContext.ts index 3d2e20d98d..7af3563c31 100644 --- a/src/server/firefox/ffExecutionContext.ts +++ b/src/server/firefox/ffExecutionContext.ts @@ -40,6 +40,15 @@ export class FFExecutionContext implements js.ExecutionContextDelegate { return payload.result!.objectId!; } + rawCallFunctionNoReply(func: Function, ...args: any[]) { + this._session.send('Runtime.callFunction', { + functionDeclaration: func.toString(), + args: args.map(a => a instanceof js.JSHandle ? { objectId: a._objectId } : { value: a }) as any, + returnByValue: true, + executionContextId: this._executionContextId + }).catch(() => {}); + } + async evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: js.JSHandle, values: any[], objectIds: string[]): Promise { const payload = await this._session.send('Runtime.callFunction', { functionDeclaration: expression, diff --git a/src/server/instrumentation.ts b/src/server/instrumentation.ts index 6e09d2a99f..445a2c1b97 100644 --- a/src/server/instrumentation.ts +++ b/src/server/instrumentation.ts @@ -19,6 +19,7 @@ import { Point, StackFrame } from '../common/types'; import type { Browser } from './browser'; import type { BrowserContext } from './browserContext'; import type { BrowserType } from './browserType'; +import { ElementHandle } from './dom'; import type { Frame } from './frames'; import type { Page } from './page'; @@ -66,7 +67,7 @@ export interface Instrumentation { onContextDidDestroy(context: BrowserContext): Promise; onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise; - onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise; + onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise; onAfterInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise; onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): void; onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise; @@ -78,7 +79,7 @@ export interface InstrumentationListener { onContextDidDestroy?(context: BrowserContext): Promise; onBeforeCall?(sdkObject: SdkObject, metadata: CallMetadata): Promise; - onBeforeInputAction?(sdkObject: SdkObject, metadata: CallMetadata): Promise; + onBeforeInputAction?(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise; onAfterInputAction?(sdkObject: SdkObject, metadata: CallMetadata): Promise; onCallLog?(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): void; onAfterCall?(sdkObject: SdkObject, metadata: CallMetadata): Promise; diff --git a/src/server/javascript.ts b/src/server/javascript.ts index 5ac41877bf..22cff825b2 100644 --- a/src/server/javascript.ts +++ b/src/server/javascript.ts @@ -44,6 +44,7 @@ export type SmartHandle = T extends Node ? dom.ElementHandle : JSHandle export interface ExecutionContextDelegate { rawEvaluate(expression: string): Promise; + rawCallFunctionNoReply(func: Function, ...args: any[]): void; evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: JSHandle, values: any[], objectIds: ObjectId[]): Promise; getProperties(handle: JSHandle): Promise>; createHandle(context: ExecutionContext, remoteObject: RemoteObject): JSHandle; @@ -109,6 +110,10 @@ export class JSHandle extends SdkObject { this._preview = 'JSHandle@' + String(this._objectId ? this._objectType : this._value); } + callFunctionNoReply(func: Function, arg: any) { + this._context._delegate.rawCallFunctionNoReply(func, this, arg); + } + async evaluate(pageFunction: FuncOn, arg: Arg): Promise; async evaluate(pageFunction: FuncOn, arg?: any): Promise; async evaluate(pageFunction: FuncOn, arg: Arg): Promise { diff --git a/src/server/progress.ts b/src/server/progress.ts index efc5cad610..cf146a597d 100644 --- a/src/server/progress.ts +++ b/src/server/progress.ts @@ -18,6 +18,7 @@ import { TimeoutError } from '../utils/errors'; import { assert, monotonicTime } from '../utils/utils'; import { LogName } from '../utils/debugLogger'; import { CallMetadata, Instrumentation, SdkObject } from './instrumentation'; +import { ElementHandle } from './dom'; export interface Progress { log(message: string): void; @@ -25,7 +26,7 @@ export interface Progress { isRunning(): boolean; cleanupWhenAborted(cleanup: () => any): void; throwIfAborted(): void; - beforeInputAction(): Promise; + beforeInputAction(element: ElementHandle): Promise; afterInputAction(): Promise; metadata: CallMetadata; } @@ -87,8 +88,8 @@ export class ProgressController { if (this._state === 'aborted') throw new AbortedError(); }, - beforeInputAction: async () => { - await this.instrumentation.onBeforeInputAction(this.sdkObject, this.metadata); + beforeInputAction: async (element: ElementHandle) => { + await this.instrumentation.onBeforeInputAction(this.sdkObject, this.metadata, element); }, afterInputAction: async () => { await this.instrumentation.onAfterInputAction(this.sdkObject, this.metadata); diff --git a/src/server/snapshot/inMemorySnapshotter.ts b/src/server/snapshot/inMemorySnapshotter.ts index 6f44e39f67..c28bf03508 100644 --- a/src/server/snapshot/inMemorySnapshotter.ts +++ b/src/server/snapshot/inMemorySnapshotter.ts @@ -23,6 +23,7 @@ import { SnapshotRenderer } from './snapshotRenderer'; import { SnapshotServer } from './snapshotServer'; import { BaseSnapshotStorage } from './snapshotStorage'; import { Snapshotter, SnapshotterBlob, SnapshotterDelegate } from './snapshotter'; +import { ElementHandle } from '../dom'; const kSnapshotInterval = 25; @@ -52,11 +53,11 @@ export class InMemorySnapshotter extends BaseSnapshotStorage implements Snapshot await this._server.stop(); } - async captureSnapshot(page: Page, snapshotName: string): Promise { + async captureSnapshot(page: Page, snapshotName: string, element?: ElementHandle): Promise { if (this._frameSnapshots.has(snapshotName)) throw new Error('Duplicate snapshot name: ' + snapshotName); - this._snapshotter.captureSnapshot(page, snapshotName); + this._snapshotter.captureSnapshot(page, snapshotName, element); return new Promise(fulfill => { const listener = helper.addEventListener(this, 'snapshot', (renderer: SnapshotRenderer) => { if (renderer.snapshotName === snapshotName) { diff --git a/src/server/snapshot/persistentSnapshotter.ts b/src/server/snapshot/persistentSnapshotter.ts index 887c71ab82..c5619a1665 100644 --- a/src/server/snapshot/persistentSnapshotter.ts +++ b/src/server/snapshot/persistentSnapshotter.ts @@ -22,6 +22,7 @@ import { BrowserContext } from '../browserContext'; import { Page } from '../page'; import { FrameSnapshot, ResourceSnapshot } from './snapshotTypes'; import { Snapshotter, SnapshotterBlob, SnapshotterDelegate } from './snapshotter'; +import { ElementHandle } from '../dom'; const fsWriteFileAsync = util.promisify(fs.writeFile.bind(fs)); @@ -47,6 +48,8 @@ export class PersistentSnapshotter extends EventEmitter implements SnapshotterDe async start(): Promise { await fsMkdirAsync(this._resourcesDir, {recursive: true}).catch(() => {}); + await fsAppendFileAsync(this._networkTrace, Buffer.from([])); + await fsAppendFileAsync(this._snapshotTrace, Buffer.from([])); await this._snapshotter.initialize(); await this._snapshotter.setAutoSnapshotInterval(kSnapshotInterval); } @@ -56,13 +59,13 @@ export class PersistentSnapshotter extends EventEmitter implements SnapshotterDe await this._writeArtifactChain; } - captureSnapshot(page: Page, snapshotName: string) { - this._snapshotter.captureSnapshot(page, snapshotName); + captureSnapshot(page: Page, snapshotName: string, element?: ElementHandle) { + this._snapshotter.captureSnapshot(page, snapshotName, element); } onBlob(blob: SnapshotterBlob): void { this._writeArtifactChain = this._writeArtifactChain.then(async () => { - await fsWriteFileAsync(path.join(this._resourcesDir, blob.sha1), blob.buffer); + await fsWriteFileAsync(path.join(this._resourcesDir, blob.sha1), blob.buffer).catch(() => {}); }); } diff --git a/src/server/snapshot/snapshotRenderer.ts b/src/server/snapshot/snapshotRenderer.ts index 9ff0b84f0f..aa13f6dc16 100644 --- a/src/server/snapshot/snapshotRenderer.ts +++ b/src/server/snapshot/snapshotRenderer.ts @@ -71,9 +71,15 @@ export class SnapshotRenderer { const snapshot = this._snapshots[this._index]; let html = visit(snapshot.html, this._index); + if (!html) + return { html: '', resources: {} }; + if (snapshot.doctype) html = `` + html; - html += ``; + html += ` + + + `; const resources: { [key: string]: { resourceId: string, sha1?: string } } = {}; for (const [url, contextResources] of this._contextResources) { diff --git a/src/server/snapshot/snapshotServer.ts b/src/server/snapshot/snapshotServer.ts index a91cf7d173..40a1edd9ac 100644 --- a/src/server/snapshot/snapshotServer.ts +++ b/src/server/snapshot/snapshotServer.ts @@ -19,6 +19,7 @@ import querystring from 'querystring'; import { HttpServer } from '../../utils/httpServer'; import type { RenderedFrameSnapshot } from './snapshotTypes'; import { SnapshotStorage } from './snapshotStorage'; +import type { Point } from '../../common/types'; export class SnapshotServer { private _snapshotStorage: SnapshotStorage; @@ -51,35 +52,7 @@ export class SnapshotServer { `); @@ -245,3 +218,59 @@ export class SnapshotServer { } } } + +declare global { + interface Window { + showSnapshot: (url: string, point?: Point) => Promise; + } +} +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); +} diff --git a/src/server/snapshot/snapshotter.ts b/src/server/snapshot/snapshotter.ts index b625dc808f..782bbf3bdd 100644 --- a/src/server/snapshot/snapshotter.ts +++ b/src/server/snapshot/snapshotter.ts @@ -23,6 +23,7 @@ import { Frame } from '../frames'; import { SnapshotData, frameSnapshotStreamer, kSnapshotBinding, kSnapshotStreamer } from './snapshotterInjected'; import { calculateSha1, createGuid, monotonicTime } from '../../utils/utils'; import { FrameSnapshot, ResourceSnapshot } from './snapshotTypes'; +import { ElementHandle } from '../dom'; export type SnapshotterBlob = { buffer: Buffer, @@ -92,9 +93,12 @@ export class Snapshotter { helper.removeEventListeners(this._eventListeners); } - captureSnapshot(page: Page, snapshotName?: string) { + captureSnapshot(page: Page, snapshotName: string, element?: ElementHandle) { // This needs to be sync, as in not awaiting for anything before we issue the command. const expression = `window[${JSON.stringify(kSnapshotStreamer)}].captureSnapshot(${JSON.stringify(snapshotName)})`; + element?.callFunctionNoReply((element: Element, snapshotName: string) => { + element.setAttribute('__playwright_target__', snapshotName); + }, snapshotName); const snapshotFrame = (frame: Frame) => { const context = frame._existingMainContext(); context?.rawEvaluate(expression).catch(debugExceptionHandler); diff --git a/src/server/snapshot/snapshotterInjected.ts b/src/server/snapshot/snapshotterInjected.ts index c26f98679a..4ab3cba88a 100644 --- a/src/server/snapshot/snapshotterInjected.ts +++ b/src/server/snapshot/snapshotterInjected.ts @@ -317,8 +317,6 @@ export function frameSnapshotStreamer() { if (nodeType === Node.ELEMENT_NODE) { const element = node as Element; - // if (node === target) - // attrs[' __playwright_target__] = ''; if (nodeName === 'INPUT') { const value = (element as HTMLInputElement).value; expectValue('value'); diff --git a/src/server/supplements/injected/recorder.ts b/src/server/supplements/injected/recorder.ts index ece9f5e74e..081a154d3e 100644 --- a/src/server/supplements/injected/recorder.ts +++ b/src/server/supplements/injected/recorder.ts @@ -26,7 +26,6 @@ declare global { _playwrightRecorderRecordAction: (action: actions.Action) => Promise; _playwrightRecorderCommitAction: () => Promise; _playwrightRecorderState: () => Promise; - _playwrightResume: () => Promise; _playwrightRecorderSetSelector: (selector: string) => Promise; _playwrightRefreshOverlay: () => void; } diff --git a/src/server/trace/common/traceEvents.ts b/src/server/trace/common/traceEvents.ts index b41a44a174..14ebcc9f88 100644 --- a/src/server/trace/common/traceEvents.ts +++ b/src/server/trace/common/traceEvents.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { StackFrame } from '../../../common/types'; +import { CallMetadata } from '../../instrumentation'; export type ContextCreatedTraceEvent = { timestamp: number, @@ -47,27 +47,11 @@ export type PageDestroyedTraceEvent = { pageId: string, }; -export type PageVideoTraceEvent = { - timestamp: number, - type: 'page-video', - contextId: string, - pageId: string, - fileName: string, -}; - export type ActionTraceEvent = { timestamp: number, type: 'action', contextId: string, - objectType: string, - method: string, - params: any, - stack?: StackFrame[], - pageId?: string, - startTime: number, - endTime: number, - logs?: string[], - error?: string, + metadata: CallMetadata, snapshots?: { title: string, snapshotName: string }[], }; @@ -109,7 +93,6 @@ export type TraceEvent = ContextDestroyedTraceEvent | PageCreatedTraceEvent | PageDestroyedTraceEvent | - PageVideoTraceEvent | ActionTraceEvent | DialogOpenedEvent | DialogClosedEvent | diff --git a/src/server/trace/recorder/tracer.ts b/src/server/trace/recorder/tracer.ts index 309bcb6aae..3f23fad025 100644 --- a/src/server/trace/recorder/tracer.ts +++ b/src/server/trace/recorder/tracer.ts @@ -18,8 +18,9 @@ import fs from 'fs'; import path from 'path'; import * as util from 'util'; import { createGuid, getFromENV, mkdirIfNeeded, monotonicTime } from '../../../utils/utils'; -import { BrowserContext, Video } from '../../browserContext'; +import { BrowserContext } from '../../browserContext'; import { Dialog } from '../../dialog'; +import { ElementHandle } from '../../dom'; import { Frame, NavigationEvent } from '../../frames'; import { helper, RegisteredListener } from '../../helper'; import { CallMetadata, InstrumentationListener, SdkObject } from '../../instrumentation'; @@ -28,18 +29,18 @@ import { PersistentSnapshotter } from '../../snapshot/persistentSnapshotter'; import * as trace from '../common/traceEvents'; const fsAppendFileAsync = util.promisify(fs.appendFile.bind(fs)); -const envTrace = getFromENV('PW_TRACE_DIR'); +const envTrace = getFromENV('PWTRACE_RESOURCE_DIR'); export class Tracer implements InstrumentationListener { private _contextTracers = new Map(); async onContextCreated(context: BrowserContext): Promise { - const traceDir = envTrace || context._options._traceDir; + const traceDir = context._options._traceDir; if (!traceDir) return; - const traceStorageDir = path.join(traceDir, 'resources'); + const resourcesDir = envTrace || path.join(traceDir, 'resources'); const tracePath = path.join(traceDir, createGuid()); - const contextTracer = new ContextTracer(context, traceStorageDir, tracePath); + const contextTracer = new ContextTracer(context, resourcesDir, tracePath); await contextTracer.start(); this._contextTracers.set(context, contextTracer); } @@ -52,15 +53,16 @@ export class Tracer implements InstrumentationListener { } } - async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise { - this._contextTracers.get(sdkObject.attribution.context!)?.onActionCheckpoint('before', sdkObject, metadata); + async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle): Promise { + this._contextTracers.get(sdkObject.attribution.context!)?._captureSnapshot('action', sdkObject, metadata, element); } - async onAfterInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise { - this._contextTracers.get(sdkObject.attribution.context!)?.onActionCheckpoint('after', sdkObject, metadata); + async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata, element?: ElementHandle): Promise { + this._contextTracers.get(sdkObject.attribution.context!)?._captureSnapshot('before', sdkObject, metadata, element); } async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { + this._contextTracers.get(sdkObject.attribution.context!)?._captureSnapshot('after', sdkObject, metadata); this._contextTracers.get(sdkObject.attribution.context!)?.onAfterCall(sdkObject, metadata); } } @@ -80,12 +82,10 @@ class ContextTracer { private _snapshotter: PersistentSnapshotter; private _eventListeners: RegisteredListener[]; private _disposed = false; - private _traceFile: string; - constructor(context: BrowserContext, traceStorageDir: string, tracePrefix: string) { + constructor(context: BrowserContext, resourcesDir: string, tracePrefix: string) { const traceFile = tracePrefix + '-actions.trace'; this._contextId = 'context@' + createGuid(); - this._traceFile = traceFile; this._appendEventChain = mkdirIfNeeded(traceFile).then(() => traceFile); const event: trace.ContextCreatedTraceEvent = { timestamp: monotonicTime(), @@ -98,7 +98,7 @@ class ContextTracer { debugName: context._options._debugName, }; this._appendTraceEvent(event); - this._snapshotter = new PersistentSnapshotter(context, tracePrefix, traceStorageDir); + this._snapshotter = new PersistentSnapshotter(context, tracePrefix, resourcesDir); this._eventListeners = [ helper.addEventListener(context, BrowserContext.Events.Page, this._onPage.bind(this)), ]; @@ -108,12 +108,12 @@ class ContextTracer { await this._snapshotter.start(); } - async onActionCheckpoint(name: string, sdkObject: SdkObject, metadata: CallMetadata): Promise { + async _captureSnapshot(name: string, sdkObject: SdkObject, metadata: CallMetadata, element?: ElementHandle): Promise { if (!sdkObject.attribution.page) return; const snapshotName = `${name}@${metadata.id}`; snapshotsForMetadata(metadata).push({ title: name, snapshotName }); - this._snapshotter.captureSnapshot(sdkObject.attribution.page, snapshotName); + this._snapshotter.captureSnapshot(sdkObject.attribution.page, snapshotName, element); } async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { @@ -123,16 +123,7 @@ class ContextTracer { timestamp: monotonicTime(), type: 'action', contextId: this._contextId, - pageId: sdkObject.attribution.page.uniqueId, - objectType: metadata.type, - method: metadata.method, - // FIXME: filter out evaluation snippets, binary - params: metadata.params, - stack: metadata.stack, - startTime: metadata.startTime, - endTime: metadata.endTime, - logs: metadata.log.slice(), - error: metadata.error, + metadata, snapshots: snapshotsForMetadata(metadata), }; this._appendTraceEvent(event); @@ -149,19 +140,6 @@ class ContextTracer { }; this._appendTraceEvent(event); - page.on(Page.Events.VideoStarted, (video: Video) => { - if (this._disposed) - return; - const event: trace.PageVideoTraceEvent = { - timestamp: monotonicTime(), - type: 'page-video', - contextId: this._contextId, - pageId, - fileName: path.relative(path.dirname(this._traceFile), video._path), - }; - this._appendTraceEvent(event); - }); - page.on(Page.Events.Dialog, (dialog: Dialog) => { if (this._disposed) return; diff --git a/src/server/trace/viewer/traceModel.ts b/src/server/trace/viewer/traceModel.ts index 208028fa7b..4fcaed8c66 100644 --- a/src/server/trace/viewer/traceModel.ts +++ b/src/server/trace/viewer/traceModel.ts @@ -38,7 +38,7 @@ export class TraceModel { actions.reverse(); for (const action of actions) { - while (resources.length && resources[0].timestamp > action.action.timestamp) + while (resources.length && resources[0].timestamp > action.timestamp) action.resources.push(resources.shift()!); action.resources.reverse(); } @@ -79,14 +79,15 @@ export class TraceModel { break; } case 'action': { - if (!kInterestingActions.includes(event.method)) + const metadata = event.metadata; + if (metadata.method === 'waitForEventInfo') break; - const { pageEntry } = this.pageEntries.get(event.pageId!)!; - const actionId = event.contextId + '/' + event.pageId + '/' + pageEntry.actions.length; + const { pageEntry } = this.pageEntries.get(metadata.pageId!)!; + const actionId = event.contextId + '/' + metadata.pageId + '/' + pageEntry.actions.length; const action: ActionEntry = { actionId, - action: event, - resources: [] + resources: [], + ...event, }; pageEntry.actions.push(action); break; @@ -146,10 +147,7 @@ export type PageEntry = { interestingEvents: InterestingPageEvent[]; } -export type ActionEntry = { +export type ActionEntry = trace.ActionTraceEvent & { actionId: string; - action: trace.ActionTraceEvent; resources: ResourceSnapshot[] }; - -const kInterestingActions = ['click', 'dblclick', 'hover', 'check', 'uncheck', 'tap', 'fill', 'press', 'type', 'selectOption', 'setInputFiles', 'goto', 'setContent', 'goBack', 'goForward', 'reload']; diff --git a/src/server/trace/viewer/traceViewer.ts b/src/server/trace/viewer/traceViewer.ts index e0b0954d59..4d80f2f2f6 100644 --- a/src/server/trace/viewer/traceViewer.ts +++ b/src/server/trace/viewer/traceViewer.ts @@ -16,13 +16,17 @@ import fs from 'fs'; import path from 'path'; -import * as playwright from '../../../..'; +import { createPlaywright } from '../../playwright'; import * as util from 'util'; import { TraceModel } from './traceModel'; import { TraceEvent } from '../common/traceEvents'; import { ServerRouteHandler, HttpServer } from '../../../utils/httpServer'; import { SnapshotServer } from '../../snapshot/snapshotServer'; import { PersistentSnapshotStorage } from '../../snapshot/snapshotStorage'; +import * as consoleApiSource from '../../../generated/consoleApiSource'; +import { isUnderTest } from '../../../utils/utils'; +import { internalCallMetadata } from '../../instrumentation'; +import { ProgressController } from '../../progress'; const fsReadFileAsync = util.promisify(fs.readFile.bind(fs)); @@ -34,8 +38,9 @@ type TraceViewerDocument = { class TraceViewer { private _document: TraceViewerDocument | undefined; - async show(traceDir: string) { - const resourcesDir = path.join(traceDir, 'resources'); + async show(traceDir: string, resourcesDir?: string) { + if (!resourcesDir) + resourcesDir = path.join(traceDir, 'resources'); const model = new TraceModel(); this._document = { model, @@ -56,7 +61,6 @@ class TraceViewer { // - "/snapshot/pageId/..." - actual snapshot html. // - "/snapshot/service-worker.js" - service worker that intercepts snapshot resources // and translates them into "/resources/". - const actionsTrace = fs.readdirSync(traceDir).find(name => name.endsWith('-actions.trace'))!; const tracePrefix = path.join(traceDir, actionsTrace.substring(0, actionsTrace.indexOf('-actions.trace'))); const server = new HttpServer(); @@ -108,14 +112,33 @@ class TraceViewer { const urlPrefix = await server.start(); - const browser = await playwright.chromium.launch({ headless: false }); - const uiPage = await browser.newPage({ viewport: null }); - uiPage.on('close', () => process.exit(0)); - await uiPage.goto(urlPrefix + '/traceviewer/traceViewer/index.html'); + const traceViewerPlaywright = createPlaywright(true); + const args = [ + '--app=data:text/html,', + '--window-position=1280,10', + ]; + if (isUnderTest()) + args.push(`--remote-debugging-port=0`); + const context = await traceViewerPlaywright.chromium.launchPersistentContext(internalCallMetadata(), '', { + // TODO: store language in the trace. + sdkLanguage: 'javascript', + args, + noDefaultViewport: true, + headless: !!process.env.PWCLI_HEADLESS_FOR_TEST, + useWebSocket: isUnderTest() + }); + const controller = new ProgressController(internalCallMetadata(), context._browser); + await controller.run(async progress => { + await context._browser._defaultContext!._loadDefaultContextAsIs(progress); + }); + await context.extendInjectedScript(consoleApiSource.source); + const [page] = context.pages(); + page.on('close', () => process.exit(0)); + await page.mainFrame().goto(internalCallMetadata(), urlPrefix + '/traceviewer/traceViewer/index.html'); } } -export async function showTraceViewer(traceDir: string) { +export async function showTraceViewer(traceDir: string, resourcesDir?: string) { const traceViewer = new TraceViewer(); - await traceViewer.show(traceDir); + await traceViewer.show(traceDir, resourcesDir); } diff --git a/src/server/webkit/wkExecutionContext.ts b/src/server/webkit/wkExecutionContext.ts index ff5f36e2a3..4cabce197c 100644 --- a/src/server/webkit/wkExecutionContext.ts +++ b/src/server/webkit/wkExecutionContext.ts @@ -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, values: any[], objectIds: string[]): Promise { try { let response = await this._session.send('Runtime.callFunctionOn', { diff --git a/src/web/traceViewer/ui/actionList.tsx b/src/web/traceViewer/ui/actionList.tsx index d578f139eb..ce5f95f7f3 100644 --- a/src/web/traceViewer/ui/actionList.tsx +++ b/src/web/traceViewer/ui/actionList.tsx @@ -35,7 +35,7 @@ export const ActionList: React.FC = ({ }) => { const targetAction = highlightedAction || selectedAction; return
{actions.map(actionEntry => { - const { action, actionId } = actionEntry; + const { metadata, actionId } = actionEntry; return
= ({ onMouseLeave={() => (highlightedAction === actionEntry) && onHighlighted(undefined)} >
- ; })}
; diff --git a/src/web/traceViewer/ui/logsTab.tsx b/src/web/traceViewer/ui/logsTab.tsx index 98f40b2f28..07604e7457 100644 --- a/src/web/traceViewer/ui/logsTab.tsx +++ b/src/web/traceViewer/ui/logsTab.tsx @@ -23,9 +23,9 @@ export const LogsTab: React.FunctionComponent<{ }> = ({ actionEntry }) => { let logs: string[] = []; if (actionEntry) { - logs = actionEntry.action.logs || []; - if (actionEntry.action.error) - logs = [actionEntry.action.error, ...logs]; + logs = actionEntry.metadata.log || []; + if (actionEntry.metadata.error) + logs = [actionEntry.metadata.error, ...logs]; } return
{ logs.map((logLine, index) => { diff --git a/src/web/traceViewer/ui/networkResourceDetails.tsx b/src/web/traceViewer/ui/networkResourceDetails.tsx index f3eee7b020..96ec636677 100644 --- a/src/web/traceViewer/ui/networkResourceDetails.tsx +++ b/src/web/traceViewer/ui/networkResourceDetails.tsx @@ -19,8 +19,6 @@ import * as React from 'react'; import { Expandable } from './helpers'; import type { ResourceSnapshot } from '../../../server/snapshot/snapshotTypes'; -const utf8Encoder = new TextDecoder('utf-8'); - export const NetworkResourceDetails: React.FunctionComponent<{ resource: ResourceSnapshot, index: number, diff --git a/src/web/traceViewer/ui/snapshotTab.tsx b/src/web/traceViewer/ui/snapshotTab.tsx index c30f3bbc72..ce29b68e61 100644 --- a/src/web/traceViewer/ui/snapshotTab.tsx +++ b/src/web/traceViewer/ui/snapshotTab.tsx @@ -20,6 +20,7 @@ import './snapshotTab.css'; import * as React from 'react'; import { useMeasure } from './helpers'; import { msToString } from '../../uiUtils'; +import type { Point } from '../../../common/types'; export const SnapshotTab: React.FunctionComponent<{ actionEntry: ActionEntry | undefined, @@ -30,7 +31,7 @@ export const SnapshotTab: React.FunctionComponent<{ const [measure, ref] = useMeasure(); const [snapshotIndex, setSnapshotIndex] = React.useState(0); - const snapshots = actionEntry ? (actionEntry.action.snapshots || []) : []; + const snapshots = actionEntry ? (actionEntry.snapshots || []) : []; const { pageId, time } = selection || { pageId: undefined, time: 0 }; const iframeRef = React.createRef(); @@ -39,16 +40,20 @@ export const SnapshotTab: React.FunctionComponent<{ return; let snapshotUri = undefined; + let point: Point | undefined = undefined; if (pageId) { snapshotUri = `${pageId}?time=${time}`; } else if (actionEntry) { const snapshot = snapshots[snapshotIndex]; - if (snapshot && snapshot.snapshotName) - snapshotUri = `${actionEntry.action.pageId}?name=${snapshot.snapshotName}`; + if (snapshot && snapshot.snapshotName) { + snapshotUri = `${actionEntry.metadata.pageId}?name=${snapshot.snapshotName}`; + if (snapshot.snapshotName.includes('action')) + point = actionEntry.metadata.point; + } } const snapshotUrl = snapshotUri ? `${window.location.origin}/snapshot/${snapshotUri}` : 'data:text/html,Snapshot is not available'; try { - (iframeRef.current.contentWindow as any).showSnapshot(snapshotUrl); + (iframeRef.current.contentWindow as any).showSnapshot(snapshotUrl, { point }); } catch (e) { } }, [actionEntry, snapshotIndex, pageId, time]); diff --git a/src/web/traceViewer/ui/sourceTab.tsx b/src/web/traceViewer/ui/sourceTab.tsx index bbac07965d..f917b8819f 100644 --- a/src/web/traceViewer/ui/sourceTab.tsx +++ b/src/web/traceViewer/ui/sourceTab.tsx @@ -43,10 +43,10 @@ export const SourceTab: React.FunctionComponent<{ const stackInfo = React.useMemo(() => { if (!actionEntry) return ''; - const { action } = actionEntry; - if (!action.stack) + const { metadata } = actionEntry; + if (!metadata.stack) return ''; - const frames = action.stack; + const frames = metadata.stack; return { frames, fileContent: new Map(), diff --git a/src/web/traceViewer/ui/timeline.tsx b/src/web/traceViewer/ui/timeline.tsx index 0b10845610..249d043e04 100644 --- a/src/web/traceViewer/ui/timeline.tsx +++ b/src/web/traceViewer/ui/timeline.tsx @@ -54,17 +54,17 @@ export const Timeline: React.FunctionComponent<{ const bars: TimelineBar[] = []; for (const page of context.pages) { for (const entry of page.actions) { - let detail = entry.action.params.selector || ''; - if (entry.action.method === 'goto') - detail = entry.action.params.url || ''; + let detail = entry.metadata.params.selector || ''; + if (entry.metadata.method === 'goto') + detail = entry.metadata.params.url || ''; bars.push({ entry, - leftTime: entry.action.startTime, - rightTime: entry.action.endTime, - leftPosition: timeToPosition(measure.width, boundaries, entry.action.startTime), - rightPosition: timeToPosition(measure.width, boundaries, entry.action.endTime), - label: entry.action.method + ' ' + detail, - type: entry.action.method, + leftTime: entry.metadata.startTime, + rightTime: entry.metadata.endTime, + leftPosition: timeToPosition(measure.width, boundaries, entry.metadata.startTime), + rightPosition: timeToPosition(measure.width, boundaries, entry.metadata.endTime), + label: entry.metadata.method + ' ' + detail, + type: entry.metadata.method, priority: 0, }); } diff --git a/src/web/traceViewer/ui/workbench.tsx b/src/web/traceViewer/ui/workbench.tsx index dcbd44fe4c..02aeb83d22 100644 --- a/src/web/traceViewer/ui/workbench.tsx +++ b/src/web/traceViewer/ui/workbench.tsx @@ -14,7 +14,7 @@ limitations under the License. */ -import { ActionEntry, ContextEntry, TraceModel } from '../../../server/trace/viewer/traceModel'; +import { ActionEntry, ContextEntry } from '../../../server/trace/viewer/traceModel'; import { ActionList } from './actionList'; import { TabbedPane } from './tabbedPane'; import { Timeline } from './timeline'; diff --git a/test/playwright.fixtures.ts b/test/playwright.fixtures.ts index 6e4f103741..9235ddfb06 100644 --- a/test/playwright.fixtures.ts +++ b/test/playwright.fixtures.ts @@ -144,13 +144,10 @@ fixtures.isLinux.init(async ({ platform }, run) => { }, { scope: 'worker' }); fixtures.contextOptions.init(async ({ video, testInfo }, run) => { - if (video) { - await run({ - recordVideo: { dir: testInfo.outputPath('') }, - }); - } else { - await run({}); - } + await run({ + recordVideo: video ? { dir: testInfo.outputPath('') } : undefined, + _traceDir: process.env.PWTRACE ? testInfo.outputPath('') : undefined, + } as any); }); fixtures.contextFactory.init(async ({ browser, contextOptions, testInfo, screenshotOnFailure }, run) => { diff --git a/test/snapshotter.spec.ts b/test/snapshotter.spec.ts index 3f6641a7bd..fe8f97cde1 100644 --- a/test/snapshotter.spec.ts +++ b/test/snapshotter.spec.ts @@ -175,12 +175,46 @@ describe('snapshots', (suite, { mode }) => { const button = await previewPage.frames()[3].waitForSelector('button'); expect(await button.textContent()).toBe('Hello iframe'); }); + + it('should capture snapshot target', async ({ snapshotter, page, toImpl }) => { + await page.setContent(''); + { + const handle = await page.$('text=Hello'); + const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot', toImpl(handle)); + expect(distillSnapshot(snapshot)).toBe(''); + } + { + const handle = await page.$('text=World'); + const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2', toImpl(handle)); + expect(distillSnapshot(snapshot)).toBe(''); + } + }); + + it('should collect on attribute change', async ({ snapshotter, page, toImpl }) => { + await page.setContent(''); + { + const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot'); + expect(distillSnapshot(snapshot)).toBe(''); + } + 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(''); + } + await handle.evaluate(element => element.setAttribute('data', 'two')); + { + const snapshot = await snapshotter.captureSnapshot(toImpl(page), 'snapshot2'); + expect(distillSnapshot(snapshot)).toBe(''); + } + }); }); function distillSnapshot(snapshot) { const { html } = snapshot.render(); return html .replace(/