diff --git a/src/dispatchers/dispatcher.ts b/src/dispatchers/dispatcher.ts index 481486f596335..f42f4b1d2ab10 100644 --- a/src/dispatchers/dispatcher.ts +++ b/src/dispatchers/dispatcher.ts @@ -217,7 +217,7 @@ export class DispatcherConnection { } const sdkObject = dispatcher._object instanceof SdkObject ? dispatcher._object : undefined; - let callMetadata: CallMetadata = { + const callMetadata: CallMetadata = { id: `call@${id}`, ...validMetadata, objectId: sdkObject?.guid, @@ -232,59 +232,52 @@ export class DispatcherConnection { snapshots: [] }; - try { - if (sdkObject) { - // Process logs for waitForNavigation/waitForLoadState - if (params?.info?.waitId) { - const info = params.info; - switch (info.phase) { - case 'before': - callMetadata.apiName = info.apiName; - this._waitOperations.set(info.waitId, callMetadata); - break; - case 'log': - const originalMetadata = this._waitOperations.get(info.waitId)!; - originalMetadata.log.push(info.message); - sdkObject.instrumentation.onCallLog('api', info.message, sdkObject, originalMetadata); - // Fall through. - case 'after': - return; - } + if (sdkObject && params?.info?.waitId) { + // Process logs for waitForNavigation/waitForLoadState + const info = params.info; + switch (info.phase) { + case 'before': { + callMetadata.apiName = info.apiName; + this._waitOperations.set(info.waitId, callMetadata); + await sdkObject.instrumentation.onBeforeCall(sdkObject, callMetadata); + return; + } case 'log': { + const originalMetadata = this._waitOperations.get(info.waitId)!; + originalMetadata.log.push(info.message); + sdkObject.instrumentation.onCallLog('api', info.message, sdkObject, originalMetadata); + return; + } case 'after': { + const originalMetadata = this._waitOperations.get(info.waitId)!; + originalMetadata.endTime = monotonicTime(); + originalMetadata.error = info.error; + this._waitOperations.delete(info.waitId); + await sdkObject.instrumentation.onAfterCall(sdkObject, originalMetadata); + return; } - await sdkObject.instrumentation.onBeforeCall(sdkObject, callMetadata); } - const result = await (dispatcher as any)[method](validParams, callMetadata); - this.onmessage({ id, result: this._replaceDispatchersWithGuids(result) }); + } + + + let result: any; + let error: any; + await sdkObject?.instrumentation.onBeforeCall(sdkObject, callMetadata); + try { + result = await (dispatcher as any)[method](validParams, callMetadata); } catch (e) { // Dispatching error callMetadata.error = e.message; if (callMetadata.log.length) rewriteErrorMessage(e, e.message + formatLogRecording(callMetadata.log) + kLoggingNote); - this.onmessage({ id, error: serializeError(e) }); + error = serializeError(e); } finally { callMetadata.endTime = monotonicTime(); - if (sdkObject) { - // Process logs for waitForNavigation/waitForLoadState - if (params?.info?.waitId) { - const info = params.info; - switch (info.phase) { - case 'before': - callMetadata.endTime = 0; - // Fall through. - case 'log': - return; - case 'after': - const originalMetadata = this._waitOperations.get(info.waitId)!; - originalMetadata.endTime = callMetadata.endTime; - originalMetadata.error = info.error; - this._waitOperations.delete(info.waitId); - callMetadata = originalMetadata; - break; - } - } - await sdkObject.instrumentation.onAfterCall(sdkObject, callMetadata); - } + await sdkObject?.instrumentation.onAfterCall(sdkObject, callMetadata); } + + if (error) + this.onmessage({ id, error }); + else + this.onmessage({ id, result: this._replaceDispatchersWithGuids(result) }); } private _replaceDispatchersWithGuids(payload: any): any { diff --git a/src/server/chromium/crExecutionContext.ts b/src/server/chromium/crExecutionContext.ts index 78b9976d588c9..02b65e2fb20ea 100644 --- a/src/server/chromium/crExecutionContext.ts +++ b/src/server/chromium/crExecutionContext.ts @@ -31,7 +31,18 @@ export class CRExecutionContext implements js.ExecutionContextDelegate { this._contextId = contextPayload.id; } - async rawEvaluate(expression: string): Promise { + async rawEvaluateJSON(expression: string): Promise { + const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.evaluate', { + expression, + contextId: this._contextId, + returnByValue: true, + }).catch(rewriteError); + if (exceptionDetails) + throw new Error('Evaluation failed: ' + getExceptionMessage(exceptionDetails)); + return remoteObject.value; + } + + async rawEvaluateHandle(expression: string): Promise { const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.evaluate', { expression, contextId: this._contextId, diff --git a/src/server/dom.ts b/src/server/dom.ts index 9c3903361ff92..db23ab2552067 100644 --- a/src/server/dom.ts +++ b/src/server/dom.ts @@ -98,7 +98,7 @@ export class FrameExecutionContext extends js.ExecutionContext { ); })(); `; - this._injectedScriptPromise = this._delegate.rawEvaluate(source).then(objectId => new js.JSHandle(this, 'object', objectId)); + this._injectedScriptPromise = this._delegate.rawEvaluateHandle(source).then(objectId => new js.JSHandle(this, 'object', objectId)); } return this._injectedScriptPromise; } diff --git a/src/server/firefox/ffExecutionContext.ts b/src/server/firefox/ffExecutionContext.ts index e786b416357b3..d23fe1c4a269f 100644 --- a/src/server/firefox/ffExecutionContext.ts +++ b/src/server/firefox/ffExecutionContext.ts @@ -30,7 +30,17 @@ export class FFExecutionContext implements js.ExecutionContextDelegate { this._executionContextId = executionContextId; } - async rawEvaluate(expression: string): Promise { + async rawEvaluateJSON(expression: string): Promise { + const payload = await this._session.send('Runtime.evaluate', { + expression, + returnByValue: true, + executionContextId: this._executionContextId, + }).catch(rewriteError); + checkException(payload.exceptionDetails); + return payload.result!.value; + } + + async rawEvaluateHandle(expression: string): Promise { const payload = await this._session.send('Runtime.evaluate', { expression, returnByValue: false, diff --git a/src/server/frames.ts b/src/server/frames.ts index 776ddab4c96b6..65e7cbfed6952 100644 --- a/src/server/frames.ts +++ b/src/server/frames.ts @@ -158,11 +158,11 @@ export class FrameManager { return; for (const barrier of this._signalBarriers) barrier.addFrameNavigation(frame); - if (frame._pendingDocument && frame._pendingDocument.documentId === documentId) { + if (frame.pendingDocument() && frame.pendingDocument()!.documentId === documentId) { // Do not override request with undefined. return; } - frame._pendingDocument = { documentId, request: undefined }; + frame.setPendingDocument({ documentId, request: undefined }); } frameCommittedNewDocumentNavigation(frameId: string, url: string, name: string, documentId: string, initial: boolean) { @@ -173,24 +173,25 @@ export class FrameManager { frame._name = name; let keepPending: DocumentInfo | undefined; - if (frame._pendingDocument) { - if (frame._pendingDocument.documentId === undefined) { + const pendingDocument = frame.pendingDocument(); + if (pendingDocument) { + if (pendingDocument.documentId === undefined) { // Pending with unknown documentId - assume it is the one being committed. - frame._pendingDocument.documentId = documentId; + pendingDocument.documentId = documentId; } - if (frame._pendingDocument.documentId === documentId) { + if (pendingDocument.documentId === documentId) { // Committing a pending document. - frame._currentDocument = frame._pendingDocument; + frame._currentDocument = pendingDocument; } else { // Sometimes, we already have a new pending when the old one commits. // An example would be Chromium error page followed by a new navigation request, // where the error page commit arrives after Network.requestWillBeSent for the // new navigation. // We commit, but keep the pending request since it's not done yet. - keepPending = frame._pendingDocument; + keepPending = pendingDocument; frame._currentDocument = { documentId, request: undefined }; } - frame._pendingDocument = undefined; + frame.setPendingDocument(undefined); } else { // No pending - just commit a new document. frame._currentDocument = { documentId, request: undefined }; @@ -205,7 +206,7 @@ export class FrameManager { this._page.frameNavigatedToNewDocument(frame); } // Restore pending if any - see comments above about keepPending. - frame._pendingDocument = keepPending; + frame.setPendingDocument(keepPending); } frameCommittedSameDocumentNavigation(frameId: string, url: string) { @@ -220,17 +221,17 @@ export class FrameManager { frameAbortedNavigation(frameId: string, errorText: string, documentId?: string) { const frame = this._frames.get(frameId); - if (!frame || !frame._pendingDocument) + if (!frame || !frame.pendingDocument()) return; - if (documentId !== undefined && frame._pendingDocument.documentId !== documentId) + if (documentId !== undefined && frame.pendingDocument()!.documentId !== documentId) return; const navigationEvent: NavigationEvent = { url: frame._url, name: frame._name, - newDocument: frame._pendingDocument, + newDocument: frame.pendingDocument(), error: new Error(errorText), }; - frame._pendingDocument = undefined; + frame.setPendingDocument(undefined); frame.emit(Frame.Events.Navigation, navigationEvent); } @@ -255,7 +256,7 @@ export class FrameManager { const frame = request.frame(); this._inflightRequestStarted(request); if (request._documentId) - frame._pendingDocument = { documentId: request._documentId, request }; + frame.setPendingDocument({ documentId: request._documentId, request }); if (request._isFavicon) { const route = request._route(); if (route) @@ -281,11 +282,11 @@ export class FrameManager { requestFailed(request: network.Request, canceled: boolean) { const frame = request.frame(); this._inflightRequestFinished(request); - if (frame._pendingDocument && frame._pendingDocument.request === request) { + if (frame.pendingDocument() && frame.pendingDocument()!.request === request) { let errorText = request.failure()!.errorText; if (canceled) errorText += '; maybe frame was detached?'; - this.frameAbortedNavigation(frame._id, errorText, frame._pendingDocument.documentId); + this.frameAbortedNavigation(frame._id, errorText, frame.pendingDocument()!.documentId); } if (!request._isFavicon) this._page.emit(Page.Events.RequestFailed, request); @@ -399,7 +400,7 @@ export class Frame extends SdkObject { private _firedLifecycleEvents = new Set(); _subtreeLifecycleEvents = new Set(); _currentDocument: DocumentInfo; - _pendingDocument?: DocumentInfo; + private _pendingDocument: DocumentInfo | undefined; readonly _page: Page; private _parentFrame: Frame | null; _url = ''; @@ -412,6 +413,7 @@ export class Frame extends SdkObject { private _setContentCounter = 0; readonly _detachedPromise: Promise; private _detachedCallback = () => {}; + private _nonStallingEvaluations = new Set<(error: Error) => void>(); constructor(page: Page, id: string, parentFrame: Frame | null) { super(page, 'frame'); @@ -451,6 +453,44 @@ export class Frame extends SdkObject { this._startNetworkIdleTimer(); } + setPendingDocument(documentInfo: DocumentInfo | undefined) { + this._pendingDocument = documentInfo; + if (documentInfo) + this._invalidateNonStallingEvaluations(); + } + + pendingDocument(): DocumentInfo | undefined { + return this._pendingDocument; + } + + private async _invalidateNonStallingEvaluations() { + if (!this._nonStallingEvaluations) + return; + const error = new Error('Navigation interrupted the evaluation'); + for (const callback of this._nonStallingEvaluations) + callback(error); + } + + async nonStallingRawEvaluateInExistingMainContext(expression: string): Promise { + if (this._pendingDocument) + throw new Error('Frame is currently attempting a navigation'); + const context = this._existingMainContext(); + if (!context) + throw new Error('Frame does not yet have a main execution context'); + + let callback = () => {}; + const frameInvalidated = new Promise((f, r) => callback = r); + this._nonStallingEvaluations.add(callback); + try { + return await Promise.race([ + context.rawEvaluateJSON(expression), + frameInvalidated + ]); + } finally { + this._nonStallingEvaluations.delete(callback); + } + } + private _recalculateLifecycle() { const events = new Set(this._firedLifecycleEvents); for (const child of this._childFrames) { @@ -584,7 +624,7 @@ export class Frame extends SdkObject { return this._context('main'); } - _existingMainContext(): dom.FrameExecutionContext | null { + private _existingMainContext(): dom.FrameExecutionContext | null { return this._contextData.get('main')?.context || null; } diff --git a/src/server/javascript.ts b/src/server/javascript.ts index 46ac390f4d860..ce3aef5cb54b7 100644 --- a/src/server/javascript.ts +++ b/src/server/javascript.ts @@ -43,7 +43,8 @@ export type FuncOn = string | ((on: On, arg2: Unboxed) => R | export type SmartHandle = T extends Node ? dom.ElementHandle : JSHandle; export interface ExecutionContextDelegate { - rawEvaluate(expression: string): Promise; + rawEvaluateJSON(expression: string): Promise; + rawEvaluateHandle(expression: string): Promise; rawCallFunctionNoReply(func: Function, ...args: any[]): void; evaluateWithArguments(expression: string, returnByValue: boolean, utilityScript: JSHandle, values: any[], objectIds: ObjectId[]): Promise; getProperties(context: ExecutionContext, objectId: ObjectId): Promise>; @@ -75,7 +76,7 @@ export class ExecutionContext extends SdkObject { ${utilityScriptSource.source} return new pwExport(); })();`; - this._utilityScriptPromise = this._delegate.rawEvaluate(source).then(objectId => new JSHandle(this, 'object', objectId)); + this._utilityScriptPromise = this._delegate.rawEvaluateHandle(source).then(objectId => new JSHandle(this, 'object', objectId)); } return this._utilityScriptPromise; } @@ -84,9 +85,8 @@ export class ExecutionContext extends SdkObject { return this._delegate.createHandle(this, remoteObject); } - async rawEvaluate(expression: string): Promise { - // Make sure to never return a value. - await this._delegate.rawEvaluate(expression + '; 0'); + async rawEvaluateJSON(expression: string): Promise { + return await this._delegate.rawEvaluateJSON(expression); } async doSlowMo() { diff --git a/src/server/snapshot/inMemorySnapshotter.ts b/src/server/snapshot/inMemorySnapshotter.ts index f0842aef770fd..f04807319bbee 100644 --- a/src/server/snapshot/inMemorySnapshotter.ts +++ b/src/server/snapshot/inMemorySnapshotter.ts @@ -51,7 +51,7 @@ export class InMemorySnapshotter extends BaseSnapshotStorage implements Snapshot if (this._frameSnapshots.has(snapshotName)) throw new Error('Duplicate snapshot name: ' + snapshotName); - this._snapshotter.captureSnapshot(page, snapshotName, element); + this._snapshotter.captureSnapshot(page, snapshotName, element).catch(() => {}); return new Promise(fulfill => { const listener = helper.addEventListener(this, 'snapshot', (renderer: SnapshotRenderer) => { if (renderer.snapshotName === snapshotName) { diff --git a/src/server/snapshot/snapshotter.ts b/src/server/snapshot/snapshotter.ts index 15ef05cc15f4d..9e547a0ea5517 100644 --- a/src/server/snapshot/snapshotter.ts +++ b/src/server/snapshot/snapshotter.ts @@ -20,7 +20,7 @@ import * as network from '../network'; import { helper, RegisteredListener } from '../helper'; import { debugLogger } from '../../utils/debugLogger'; import { Frame } from '../frames'; -import { SnapshotData, frameSnapshotStreamer } from './snapshotterInjected'; +import { frameSnapshotStreamer, SnapshotData } from './snapshotterInjected'; import { calculateSha1, createGuid, monotonicTime } from '../../utils/utils'; import { FrameSnapshot, ResourceSnapshot } from './snapshotTypes'; import { ElementHandle } from '../dom'; @@ -41,7 +41,6 @@ export class Snapshotter { private _delegate: SnapshotterDelegate; private _eventListeners: RegisteredListener[] = []; private _snapshotStreamer: string; - private _snapshotBinding: string; private _initialized = false; private _started = false; private _fetchedResponses = new Map(); @@ -51,7 +50,6 @@ export class Snapshotter { this._delegate = delegate; const guid = createGuid(); this._snapshotStreamer = '__playwright_snapshot_streamer_' + guid; - this._snapshotBinding = '__playwright_snapshot_binding_' + guid; } async start() { @@ -60,7 +58,7 @@ export class Snapshotter { this._initialized = true; await this._initialize(); } - this._runInAllFrames(`window["${this._snapshotStreamer}"].reset()`); + await this._runInAllFrames(`window["${this._snapshotStreamer}"].reset()`); // Replay resources loaded in all pages. for (const page of this._context.pages()) { @@ -80,11 +78,44 @@ export class Snapshotter { helper.addEventListener(this._context, BrowserContext.Events.Page, this._onPage.bind(this)), ]; - await this._context.exposeBinding(this._snapshotBinding, false, (source, data: SnapshotData) => { + const initScript = `(${frameSnapshotStreamer})("${this._snapshotStreamer}")`; + await this._context._doAddInitScript(initScript); + await this._runInAllFrames(initScript); + } + + private async _runInAllFrames(expression: string) { + const frames = []; + for (const page of this._context.pages()) + frames.push(...page.frames()); + await Promise.all(frames.map(frame => { + return frame.nonStallingRawEvaluateInExistingMainContext(expression).catch(debugExceptionHandler); + })); + } + + dispose() { + helper.removeEventListeners(this._eventListeners); + } + + async captureSnapshot(page: Page, snapshotName: string, element?: ElementHandle): Promise { + // Prepare expression synchronously. + const expression = `window["${this._snapshotStreamer}"].captureSnapshot(${JSON.stringify(snapshotName)})`; + + // In a best-effort manner, without waiting for it, mark target element. + element?.callFunctionNoReply((element: Element, snapshotName: string) => { + element.setAttribute('__playwright_target__', snapshotName); + }, snapshotName); + + // In each frame, in a non-stalling manner, capture the snapshots. + const snapshots = page.frames().map(async frame => { + const data = await frame.nonStallingRawEvaluateInExistingMainContext(expression).catch(debugExceptionHandler) as SnapshotData; + // Something went wrong -> bail out, our snapshots are best-efforty. + if (!data) + return; + const snapshot: FrameSnapshot = { - snapshotName: data.snapshotName, - pageId: source.page.guid, - frameId: source.frame.guid, + snapshotName, + pageId: page.guid, + frameId: frame.guid, frameUrl: data.url, doctype: data.doctype, html: data.html, @@ -93,7 +124,7 @@ export class Snapshotter { pageTimestamp: data.timestamp, collectionTime: data.collectionTime, resourceOverrides: [], - isMainFrame: source.page.mainFrame() === source.frame + isMainFrame: page.mainFrame() === frame }; for (const { url, content } of data.resourceOverrides) { if (typeof content === 'string') { @@ -107,35 +138,7 @@ export class Snapshotter { } this._delegate.onFrameSnapshot(snapshot); }); - const initScript = `(${frameSnapshotStreamer})("${this._snapshotStreamer}", "${this._snapshotBinding}")`; - await this._context._doAddInitScript(initScript); - this._runInAllFrames(initScript); - } - - private _runInAllFrames(expression: string) { - const frames = []; - for (const page of this._context.pages()) - frames.push(...page.frames()); - frames.map(frame => { - frame._existingMainContext()?.rawEvaluate(expression).catch(debugExceptionHandler); - }); - } - - dispose() { - helper.removeEventListeners(this._eventListeners); - } - - 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["${this._snapshotStreamer}"].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); - }; - page.frames().map(frame => snapshotFrame(frame)); + await Promise.all(snapshots); } private _onPage(page: Page) { diff --git a/src/server/snapshot/snapshotterInjected.ts b/src/server/snapshot/snapshotterInjected.ts index 490d6f29cc72b..a463a8b78a34a 100644 --- a/src/server/snapshot/snapshotterInjected.ts +++ b/src/server/snapshot/snapshotterInjected.ts @@ -26,12 +26,11 @@ export type SnapshotData = { }[], viewport: { width: number, height: number }, url: string, - snapshotName?: string, timestamp: number, collectionTime: number, }; -export function frameSnapshotStreamer(snapshotStreamer: string, snapshotBinding: string) { +export function frameSnapshotStreamer(snapshotStreamer: string) { // Communication with Playwright. if ((window as any)[snapshotStreamer]) return; @@ -178,15 +177,6 @@ export function frameSnapshotStreamer(snapshotStreamer: string, snapshotBinding: visitNode(document.documentElement); } - captureSnapshot(snapshotName: string) { - try { - const snapshot = this._captureSnapshot(snapshotName); - if (snapshot) - (window as any)[snapshotBinding](snapshot); - } catch (e) { - } - } - private _sanitizeUrl(url: string): string { if (url.startsWith('javascript:')) return ''; @@ -234,7 +224,7 @@ export function frameSnapshotStreamer(snapshotStreamer: string, snapshotBinding: } } - private _captureSnapshot(snapshotName?: string): SnapshotData | undefined { + captureSnapshot(): SnapshotData | undefined { const timestamp = performance.now(); const snapshotNumber = ++this._lastSnapshotNumber; let nodeCounter = 0; @@ -408,10 +398,8 @@ export function frameSnapshotStreamer(snapshotStreamer: string, snapshotBinding: }; let html: NodeSnapshot; - let htmlEquals = false; if (document.documentElement) { - const { equals, n } = visitNode(document.documentElement)!; - htmlEquals = equals; + const { n } = visitNode(document.documentElement)!; html = n; } else { html = ['html']; @@ -426,12 +414,10 @@ export function frameSnapshotStreamer(snapshotStreamer: string, snapshotBinding: height: Math.max(document.body ? document.body.offsetHeight : 0, document.documentElement ? document.documentElement.offsetHeight : 0), }, url: location.href, - snapshotName, timestamp, collectionTime: 0, }; - let allOverridesAreRefs = true; for (const sheet of this._staleStyleSheets) { if (sheet.href === null) continue; @@ -440,16 +426,12 @@ export function frameSnapshotStreamer(snapshotStreamer: string, snapshotBinding: // Unable to capture stylesheet contents. continue; } - if (typeof content !== 'number') - allOverridesAreRefs = false; const base = this._getSheetBase(sheet); const url = removeHash(this._resolveUrl(base, sheet.href!)); result.resourceOverrides.push({ url, content }); } result.collectionTime = performance.now() - result.timestamp; - if (!snapshotName && htmlEquals && allOverridesAreRefs) - return undefined; return result; } } diff --git a/src/server/trace/recorder/traceSnapshotter.ts b/src/server/trace/recorder/traceSnapshotter.ts index d50b6a40c8ace..43e849a73407b 100644 --- a/src/server/trace/recorder/traceSnapshotter.ts +++ b/src/server/trace/recorder/traceSnapshotter.ts @@ -51,8 +51,8 @@ export class TraceSnapshotter extends EventEmitter implements SnapshotterDelegat await this._writeArtifactChain; } - captureSnapshot(page: Page, snapshotName: string, element?: ElementHandle) { - this._snapshotter.captureSnapshot(page, snapshotName, element); + async captureSnapshot(page: Page, snapshotName: string, element?: ElementHandle) { + await this._snapshotter.captureSnapshot(page, snapshotName, element).catch(() => {}); } onBlob(blob: SnapshotterBlob): void { diff --git a/src/server/trace/recorder/tracing.ts b/src/server/trace/recorder/tracing.ts index 748dfd0ef192a..0736d05b8fb03 100644 --- a/src/server/trace/recorder/tracing.ts +++ b/src/server/trace/recorder/tracing.ts @@ -100,7 +100,7 @@ export class Tracing implements InstrumentationListener { this._context.instrumentation.removeListener(this); helper.removeEventListeners(this._eventListeners); for (const { sdkObject, metadata } of this._pendingCalls.values()) - this.onAfterCall(sdkObject, metadata); + await this.onAfterCall(sdkObject, metadata); for (const page of this._context.pages()) page.setScreencastEnabled(false); @@ -130,38 +130,38 @@ export class Tracing implements InstrumentationListener { return artifact; } - _captureSnapshot(name: 'before' | 'after' | 'action' | 'event', sdkObject: SdkObject, metadata: CallMetadata, element?: ElementHandle) { + async _captureSnapshot(name: 'before' | 'after' | 'action' | 'event', sdkObject: SdkObject, metadata: CallMetadata, element?: ElementHandle) { if (!sdkObject.attribution.page) return; if (!this._snapshotter) return; const snapshotName = `${name}@${metadata.id}`; metadata.snapshots.push({ title: name, snapshotName }); - this._snapshotter!.captureSnapshot(sdkObject.attribution.page, snapshotName, element); + await this._snapshotter!.captureSnapshot(sdkObject.attribution.page, snapshotName, element); } async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) { - this._captureSnapshot('before', sdkObject, metadata); + await this._captureSnapshot('before', sdkObject, metadata); this._pendingCalls.set(metadata.id, { sdkObject, metadata }); } async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata, element: ElementHandle) { - this._captureSnapshot('action', sdkObject, metadata, element); + await this._captureSnapshot('action', sdkObject, metadata, element); } async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata) { if (!this._pendingCalls.has(metadata.id)) return; - this._captureSnapshot('after', sdkObject, metadata); + this._pendingCalls.delete(metadata.id); if (!sdkObject.attribution.page) return; + await this._captureSnapshot('after', sdkObject, metadata); const event: trace.ActionTraceEvent = { timestamp: metadata.startTime, type: 'action', metadata, }; this._appendTraceEvent(event); - this._pendingCalls.delete(metadata.id); } onEvent(sdkObject: SdkObject, metadata: CallMetadata) { diff --git a/src/server/webkit/wkExecutionContext.ts b/src/server/webkit/wkExecutionContext.ts index e9e7f47b419b0..645378f397b4b 100644 --- a/src/server/webkit/wkExecutionContext.ts +++ b/src/server/webkit/wkExecutionContext.ts @@ -29,7 +29,7 @@ export class WKExecutionContext implements js.ExecutionContextDelegate { constructor(session: WKSession, contextId: number | undefined) { this._session = session; this._contextId = contextId; - this._executionContextDestroyedPromise = new Promise((resolve, reject) => { + this._executionContextDestroyedPromise = new Promise((resolve, reject) => { this._contextDestroyedCallback = resolve; }); } @@ -38,7 +38,22 @@ export class WKExecutionContext implements js.ExecutionContextDelegate { this._contextDestroyedCallback(); } - async rawEvaluate(expression: string): Promise { + async rawEvaluateJSON(expression: string): Promise { + try { + const response = await this._session.send('Runtime.evaluate', { + expression, + contextId: this._contextId, + returnByValue: true + }); + if (response.wasThrown) + throw new Error('Evaluation failed: ' + response.result.description); + return response.result.value; + } catch (error) { + throw rewriteError(error); + } + } + + async rawEvaluateHandle(expression: string): Promise { try { const response = await this._session.send('Runtime.evaluate', { expression, diff --git a/src/utils/stackTrace.ts b/src/utils/stackTrace.ts index 6dc008f1a72ce..242ca05835e83 100644 --- a/src/utils/stackTrace.ts +++ b/src/utils/stackTrace.ts @@ -44,7 +44,10 @@ const PW_LIB_DIRS = [ ].map(packageName => path.sep + path.join(packageName, 'lib')); export function captureStackTrace(): { stack: string, frames: StackFrame[] } { + const stackTraceLimit = Error.stackTraceLimit; + Error.stackTraceLimit = 30; const stack = new Error().stack!; + Error.stackTraceLimit = stackTraceLimit; const frames: StackFrame[] = []; for (const line of stack.split('\n')) { const frame = stackUtils.parseLine(line); diff --git a/tests/page-evaluate-no-stall.spec.ts b/tests/page-evaluate-no-stall.spec.ts new file mode 100644 index 0000000000000..062ab86220a16 --- /dev/null +++ b/tests/page-evaluate-no-stall.spec.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect } from './config/pageTest'; + +test.describe('non-stalling evaluate', () => { + test.beforeEach(async ({mode}) => { + test.skip(mode !== 'default'); + }); + + test('should work', async ({page, server, toImpl}) => { + await page.goto(server.EMPTY_PAGE); + const result = await toImpl(page.mainFrame()).nonStallingRawEvaluateInExistingMainContext('2+2'); + expect(result).toBe(4); + }); + + test('should throw while pending navigation', async ({page, server, toImpl}) => { + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => document.body.textContent = 'HELLO WORLD'); + let error; + await page.route('**/empty.html', async (route, request) => { + error = await toImpl(page.mainFrame()).nonStallingRawEvaluateInExistingMainContext('2+2').catch(e => e); + route.abort(); + }); + await page.goto(server.EMPTY_PAGE).catch(() => {}); + expect(error.message).toContain('Frame is currently attempting a navigation'); + }); + + test('should throw when no main execution context', async ({page, toImpl}) => { + let errorPromise; + page.on('frameattached', frame => { + errorPromise = toImpl(frame).nonStallingRawEvaluateInExistingMainContext('2+2').catch(e => e); + }); + await page.setContent(''); + const error = await errorPromise; + // Testing this as a race. + const success = error.message === 'Frame does not yet have a main execution context' || 'Frame is currently attempting a navigation'; + expect(success).toBeTruthy(); + }); +});