diff --git a/src/dispatchers/browserContextDispatcher.ts b/src/dispatchers/browserContextDispatcher.ts index bb8e1f7af2..8e05f74631 100644 --- a/src/dispatchers/browserContextDispatcher.ts +++ b/src/dispatchers/browserContextDispatcher.ts @@ -144,7 +144,7 @@ export class BrowserContextDispatcher extends Dispatcher { - await RecorderSupplement.getOrCreate(this._context, params); + await RecorderSupplement.show(this._context, params); } async pause(params: channels.BrowserContextPauseParams, metadata: CallMetadata) { diff --git a/src/server/playwright.ts b/src/server/playwright.ts index 8e8205ab7b..2caf33cf6b 100644 --- a/src/server/playwright.ts +++ b/src/server/playwright.ts @@ -28,6 +28,7 @@ import { InspectorController } from './supplements/inspectorController'; import { WebKit } from './webkit/webkit'; import { Registry } from '../utils/registry'; import { InstrumentationListener, multiplexInstrumentation, SdkObject } from './instrumentation'; +import { Debugger } from './supplements/debugger'; export class Playwright extends SdkObject { readonly selectors: Selectors; @@ -41,6 +42,7 @@ export class Playwright extends SdkObject { constructor(isInternal: boolean) { const listeners: InstrumentationListener[] = []; if (!isInternal) { + listeners.push(new Debugger()); listeners.push(new Tracer()); listeners.push(new HarTracer()); listeners.push(new InspectorController()); diff --git a/src/server/supplements/debugger.ts b/src/server/supplements/debugger.ts new file mode 100644 index 0000000000..a09189d9f0 --- /dev/null +++ b/src/server/supplements/debugger.ts @@ -0,0 +1,129 @@ +/** + * 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 { EventEmitter } from 'events'; +import { debugMode, isUnderTest, monotonicTime } from '../../utils/utils'; +import { BrowserContext } from '../browserContext'; +import { CallMetadata, InstrumentationListener, SdkObject } from '../instrumentation'; +import * as consoleApiSource from '../../generated/consoleApiSource'; + +export class Debugger implements InstrumentationListener { + async onContextCreated(context: BrowserContext): Promise { + ContextDebugger.getOrCreate(context); + if (debugMode() === 'console') + await context.extendInjectedScript(consoleApiSource.source); + } + + async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { + await ContextDebugger.lookup(sdkObject.attribution.context!)?.onBeforeCall(sdkObject, metadata); + } + + async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise { + await ContextDebugger.lookup(sdkObject.attribution.context!)?.onBeforeInputAction(sdkObject, metadata); + } +} + +const symbol = Symbol('ContextDebugger'); + +export class ContextDebugger extends EventEmitter { + private _pauseOnNextStatement = false; + private _pausedCallsMetadata = new Map void, sdkObject: SdkObject }>(); + private _enabled: boolean; + + static Events = { + PausedStateChanged: 'pausedstatechanged' + }; + + static getOrCreate(context: BrowserContext): ContextDebugger { + let contextDebugger = (context as any)[symbol] as ContextDebugger; + if (!contextDebugger) { + contextDebugger = new ContextDebugger(); + (context as any)[symbol] = contextDebugger; + } + return contextDebugger; + } + + constructor() { + super(); + this._enabled = debugMode() === 'inspector'; + if (this._enabled) + this.pauseOnNextStatement(); + } + + static lookup(context?: BrowserContext): ContextDebugger | undefined { + if (!context) + return; + return (context as any)[symbol] as ContextDebugger | undefined; + } + + async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { + if (shouldPauseOnCall(sdkObject, metadata) || (this._pauseOnNextStatement && shouldPauseOnStep(sdkObject, metadata))) + await this.pause(sdkObject, metadata); + } + + async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise { + if (this._enabled && this._pauseOnNextStatement) + await this.pause(sdkObject, metadata); + } + + async pause(sdkObject: SdkObject, metadata: CallMetadata) { + this._enabled = true; + metadata.pauseStartTime = monotonicTime(); + const result = new Promise(resolve => { + this._pausedCallsMetadata.set(metadata, { resolve, sdkObject }); + }); + this.emit(ContextDebugger.Events.PausedStateChanged); + return result; + } + + resume(step: boolean) { + this._pauseOnNextStatement = step; + const endTime = monotonicTime(); + for (const [metadata, { resolve }] of this._pausedCallsMetadata) { + metadata.pauseEndTime = endTime; + resolve(); + } + this._pausedCallsMetadata.clear(); + this.emit(ContextDebugger.Events.PausedStateChanged); + } + + pauseOnNextStatement() { + this._pauseOnNextStatement = true; + } + + isPaused(metadata?: CallMetadata): boolean { + if (metadata) + return this._pausedCallsMetadata.has(metadata); + return !!this._pausedCallsMetadata.size; + } + + pausedDetails(): { metadata: CallMetadata, sdkObject: SdkObject }[] { + const result: { metadata: CallMetadata, sdkObject: SdkObject }[] = []; + for (const [metadata, { sdkObject }] of this._pausedCallsMetadata) + result.push({ metadata, sdkObject }); + return result; + } +} + +function shouldPauseOnCall(sdkObject: SdkObject, metadata: CallMetadata): boolean { + if (!sdkObject.attribution.browser?.options.headful && !isUnderTest()) + return false; + return metadata.method === 'pause'; +} + +function shouldPauseOnStep(sdkObject: SdkObject, metadata: CallMetadata): boolean { + return metadata.method === 'goto' || metadata.method === 'close'; +} diff --git a/src/server/supplements/inspectorController.ts b/src/server/supplements/inspectorController.ts index 49835c30c3..93483062ed 100644 --- a/src/server/supplements/inspectorController.ts +++ b/src/server/supplements/inspectorController.ts @@ -18,54 +18,36 @@ import { BrowserContext } from '../browserContext'; import { RecorderSupplement } from './recorderSupplement'; import { debugLogger } from '../../utils/debugLogger'; import { CallMetadata, InstrumentationListener, SdkObject } from '../instrumentation'; -import { debugMode, isUnderTest } from '../../utils/utils'; -import * as consoleApiSource from '../../generated/consoleApiSource'; +import { ContextDebugger } from './debugger'; export class InspectorController implements InstrumentationListener { async onContextCreated(context: BrowserContext): Promise { - if (debugMode() === 'inspector') - await RecorderSupplement.getOrCreate(context, { pauseOnNextStatement: true }); - else if (debugMode() === 'console') - await context.extendInjectedScript(consoleApiSource.source); + const contextDebugger = ContextDebugger.lookup(context)!; + if (contextDebugger.isPaused()) + RecorderSupplement.show(context, {}).catch(() => {}); + contextDebugger.on(ContextDebugger.Events.PausedStateChanged, () => { + RecorderSupplement.show(context, {}).catch(() => {}); + }); } async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { - const context = sdkObject.attribution.context; - if (!context) - return; - - if (shouldOpenInspector(sdkObject, metadata)) - await RecorderSupplement.getOrCreate(context, { pauseOnNextStatement: true }); - - const recorder = await RecorderSupplement.getNoCreate(context); - await recorder?.onBeforeCall(sdkObject, metadata); + const recorder = await RecorderSupplement.lookup(sdkObject.attribution.context); + recorder?.onBeforeCall(sdkObject, metadata); } async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { - if (!sdkObject.attribution.context) - return; - const recorder = await RecorderSupplement.getNoCreate(sdkObject.attribution.context); - await recorder?.onAfterCall(sdkObject, metadata); + const recorder = await RecorderSupplement.lookup(sdkObject.attribution.context); + recorder?.onAfterCall(sdkObject, metadata); } async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise { - if (!sdkObject.attribution.context) - return; - const recorder = await RecorderSupplement.getNoCreate(sdkObject.attribution.context); - await recorder?.onBeforeInputAction(sdkObject, metadata); + const recorder = await RecorderSupplement.lookup(sdkObject.attribution.context); + recorder?.onBeforeInputAction(sdkObject, metadata); } async onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): Promise { debugLogger.log(logName as any, message); - if (!sdkObject.attribution.context) - return; - const recorder = await RecorderSupplement.getNoCreate(sdkObject.attribution.context); + const recorder = await RecorderSupplement.lookup(sdkObject.attribution.context); recorder?.updateCallLog([metadata]); } } - -function shouldOpenInspector(sdkObject: SdkObject, metadata: CallMetadata): boolean { - if (!sdkObject.attribution.browser?.options.headful && !isUnderTest()) - return false; - return metadata.method === 'pause'; -} diff --git a/src/server/supplements/recorderSupplement.ts b/src/server/supplements/recorderSupplement.ts index bff37524e2..1c5d47900b 100644 --- a/src/server/supplements/recorderSupplement.ts +++ b/src/server/supplements/recorderSupplement.ts @@ -32,9 +32,10 @@ import { RecorderApp } from './recorder/recorderApp'; import { CallMetadata, internalCallMetadata, SdkObject } from '../instrumentation'; import { Point } from '../../common/types'; import { CallLog, CallLogStatus, EventData, Mode, Source, UIState } from './recorder/recorderTypes'; -import { isUnderTest, monotonicTime } from '../../utils/utils'; +import { isUnderTest } from '../../utils/utils'; import { InMemorySnapshotter } from '../snapshot/inMemorySnapshotter'; import { metadataToCallLog } from './recorder/recorderUtils'; +import { ContextDebugger } from './debugger'; type BindingSource = { frame: Frame, page: Page }; @@ -52,16 +53,15 @@ export class RecorderSupplement { private _recorderApp: RecorderApp | null = null; private _params: channels.BrowserContextRecorderSupplementEnableParams; private _currentCallsMetadata = new Map(); - private _pausedCallsMetadata = new Map void>(); - private _pauseOnNextStatement: boolean; private _recorderSources: Source[]; private _userSources = new Map(); private _snapshotter: InMemorySnapshotter; private _hoveredSnapshot: { callLogId: number, phase: 'before' | 'after' | 'action' } | undefined; private _snapshots = new Set(); private _allMetadatas = new Map(); + private _contextDebugger: ContextDebugger; - static getOrCreate(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { + static show(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { let recorderPromise = (context as any)[symbol] as Promise; if (!recorderPromise) { const recorder = new RecorderSupplement(context, params); @@ -71,15 +71,17 @@ export class RecorderSupplement { return recorderPromise; } - static getNoCreate(context: BrowserContext): Promise | undefined { + static lookup(context: BrowserContext | undefined): Promise | undefined { + if (!context) + return; return (context as any)[symbol] as Promise | undefined; } constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) { this._context = context; + this._contextDebugger = ContextDebugger.getOrCreate(context); this._params = params; this._mode = params.startRecording ? 'recording' : 'none'; - this._pauseOnNextStatement = !!params.pauseOnNextStatement; const language = params.language || context._options.sdkLanguage; const languages = new Set([ @@ -150,21 +152,21 @@ export class RecorderSupplement { } if (data.event === 'callLogHovered') { this._hoveredSnapshot = undefined; - if (this._isPaused() && data.params.callLogId) + if (this._contextDebugger.isPaused() && data.params.callLogId) this._hoveredSnapshot = data.params; this._refreshOverlay(); return; } if (data.event === 'step') { - this._resume(true); + this._contextDebugger.resume(true); return; } if (data.event === 'resume') { - this._resume(false); + this._contextDebugger.resume(false); return; } if (data.event === 'pause') { - this._pauseOnNextStatement = true; + this._contextDebugger.pauseOnNextStatement(); return; } if (data.event === 'clear') { @@ -175,7 +177,7 @@ export class RecorderSupplement { await Promise.all([ recorderApp.setMode(this._mode), - recorderApp.setPaused(!!this._pausedCallsMetadata.size), + recorderApp.setPaused(this._contextDebugger.isPaused()), this._pushAllSources() ]); @@ -231,28 +233,29 @@ export class RecorderSupplement { }); await this._context.exposeBinding('_playwrightResume', false, () => { - this._resume(false).catch(() => {}); + this._contextDebugger.resume(false); }); const snapshotBaseUrl = await this._snapshotter.initialize() + '/snapshot/'; await this._context.extendInjectedScript(recorderSource.source, { isUnderTest: isUnderTest(), snapshotBaseUrl }); await this._context.extendInjectedScript(consoleApiSource.source); + + if (this._contextDebugger.isPaused()) + this._pausedStateChanged(); + this._contextDebugger.on(ContextDebugger.Events.PausedStateChanged, () => this._pausedStateChanged()); + (this._context as any).recorderAppForTest = recorderApp; } - async pause(metadata: CallMetadata) { - const result = new Promise(f => { - this._pausedCallsMetadata.set(metadata, f); - }); - this._recorderApp!.setPaused(true); - metadata.pauseStartTime = monotonicTime(); + _pausedStateChanged() { + // If we are called upon page.pause, we don't have metadatas, populate them. + for (const { metadata, sdkObject } of this._contextDebugger.pausedDetails()) { + if (!this._currentCallsMetadata.has(metadata)) + this.onBeforeCall(sdkObject, metadata); + } + this._recorderApp!.setPaused(this._contextDebugger.isPaused()); this._updateUserSources(); - this.updateCallLog([metadata]); - return result; - } - - _isPaused(): boolean { - return !!this._pausedCallsMetadata.size; + this.updateCallLog([...this._currentCallsMetadata.keys()]); } private _setMode(mode: Mode) { @@ -263,21 +266,6 @@ export class RecorderSupplement { this._context.pages()[0].bringToFront().catch(() => {}); } - private async _resume(step: boolean) { - this._pauseOnNextStatement = step; - this._recorderApp?.setPaused(false); - - const endTime = monotonicTime(); - for (const [metadata, callback] of this._pausedCallsMetadata) { - metadata.pauseEndTime = endTime; - callback(); - } - this._pausedCallsMetadata.clear(); - - this._updateUserSources(); - this.updateCallLog([...this._currentCallsMetadata.keys()]); - } - private _refreshOverlay() { for (const page of this._context.pages()) page.mainFrame().evaluateExpression('window._playwrightRefreshOverlay()', false, undefined, 'main').catch(() => {}); @@ -410,7 +398,7 @@ export class RecorderSupplement { } } - async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { + onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) { if (this._mode === 'recording') return; this._captureSnapshot(sdkObject, metadata, 'before'); @@ -418,21 +406,18 @@ export class RecorderSupplement { this._allMetadatas.set(metadata.id, metadata); this._updateUserSources(); this.updateCallLog([metadata]); - if (shouldPauseOnCall(sdkObject, metadata) || (this._pauseOnNextStatement && shouldPauseOnStep(sdkObject, metadata))) - await this.pause(metadata); if (metadata.params && metadata.params.selector) { this._highlightedSelector = metadata.params.selector; - await this._recorderApp?.setSelector(this._highlightedSelector); + this._recorderApp?.setSelector(this._highlightedSelector).catch(() => {}); } } - async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { + onAfterCall(sdkObject: SdkObject, metadata: CallMetadata) { if (this._mode === 'recording') return; this._captureSnapshot(sdkObject, metadata, 'after'); if (!metadata.error) this._currentCallsMetadata.delete(metadata); - this._pausedCallsMetadata.delete(metadata); this._updateUserSources(); this.updateCallLog([metadata]); } @@ -456,7 +441,7 @@ export class RecorderSupplement { this._userSources.set(file, source); } if (line) { - const paused = this._pausedCallsMetadata.has(metadata); + const paused = this._contextDebugger.isPaused(metadata); source.highlight.push({ line, type: metadata.error ? 'error' : (paused ? 'paused' : 'running') }); source.revealLine = line; fileToSelect = source.file; @@ -471,12 +456,10 @@ export class RecorderSupplement { this._recorderApp?.setSources([...this._recorderSources, ...this._userSources.values()]); } - async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise { + onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata) { if (this._mode === 'recording') return; this._captureSnapshot(sdkObject, metadata, 'action'); - if (this._pauseOnNextStatement) - await this.pause(metadata); } updateCallLog(metadatas: CallMetadata[]) { @@ -489,7 +472,7 @@ export class RecorderSupplement { let status: CallLogStatus = 'done'; if (this._currentCallsMetadata.has(metadata)) status = 'in-progress'; - if (this._pausedCallsMetadata.has(metadata)) + if (this._contextDebugger.isPaused(metadata)) status = 'paused'; logs.push(metadataToCallLog(metadata, status, this._snapshots)); } @@ -514,13 +497,3 @@ function languageForFile(file: string) { return 'csharp'; return 'javascript'; } - -function shouldPauseOnCall(sdkObject: SdkObject, metadata: CallMetadata): boolean { - if (!sdkObject.attribution.browser?.options.headful && !isUnderTest()) - return false; - return metadata.method === 'pause'; -} - -function shouldPauseOnStep(sdkObject: SdkObject, metadata: CallMetadata): boolean { - return metadata.method === 'goto' || metadata.method === 'close'; -}