From f3648a66a3f5556099f3c5ce1674a00728b9575d Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 1 Oct 2021 12:07:35 -0700 Subject: [PATCH] chore: split ContextRecorder from inspector (#9250) --- src/server/supplements/recorderSupplement.ts | 443 ++++++++++--------- 1 file changed, 240 insertions(+), 203 deletions(-) diff --git a/src/server/supplements/recorderSupplement.ts b/src/server/supplements/recorderSupplement.ts index 9a233ddd44..07138f4b08 100644 --- a/src/server/supplements/recorderSupplement.ts +++ b/src/server/supplements/recorderSupplement.ts @@ -35,28 +35,23 @@ import { CallLog, CallLogStatus, EventData, Mode, Source, UIState } from './reco import { createGuid, isUnderTest, monotonicTime } from '../../utils/utils'; import { metadataToCallLog } from './recorder/recorderUtils'; import { Debugger } from './debugger'; +import { EventEmitter } from 'events'; type BindingSource = { frame: Frame, page: Page }; const symbol = Symbol('RecorderSupplement'); export class RecorderSupplement implements InstrumentationListener { - private _generator: CodeGenerator; - private _pageAliases = new Map(); - private _lastPopupOrdinal = 0; - private _lastDialogOrdinal = 0; - private _lastDownloadOrdinal = 0; - private _timers = new Set(); private _context: BrowserContext; private _mode: Mode; private _highlightedSelector = ''; private _recorderApp: RecorderApp | null = null; - private _params: channels.BrowserContextRecorderSupplementEnableParams; private _currentCallsMetadata = new Map(); - private _recorderSources: Source[]; + private _recorderSources: Source[] = []; private _userSources = new Map(); private _allMetadatas = new Map(); private _debugger: Debugger; + private _contextRecorder: ContextRecorder; static showInspector(context: BrowserContext) { RecorderSupplement.show(context, {}).catch(() => {}); @@ -73,11 +68,235 @@ export class RecorderSupplement implements InstrumentationListener { } constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) { + this._mode = params.startRecording ? 'recording' : 'none'; + this._contextRecorder = new ContextRecorder(context, params); this._context = context; this._debugger = Debugger.lookup(context)!; context.instrumentation.addListener(this); + } + + async install() { + const recorderApp = await RecorderApp.open(this._context); + this._recorderApp = recorderApp; + recorderApp.once('close', () => { + this._debugger.resume(false); + this._recorderApp = null; + }); + recorderApp.on('event', (data: EventData) => { + if (data.event === 'setMode') { + this._setMode(data.params.mode); + this._refreshOverlay(); + return; + } + if (data.event === 'selectorUpdated') { + this._highlightedSelector = data.params.selector; + this._refreshOverlay(); + return; + } + if (data.event === 'step') { + this._debugger.resume(true); + return; + } + if (data.event === 'resume') { + this._debugger.resume(false); + return; + } + if (data.event === 'pause') { + this._debugger.pauseOnNextStatement(); + return; + } + if (data.event === 'clear') { + this._contextRecorder.clearScript(); + return; + } + }); + + await Promise.all([ + recorderApp.setMode(this._mode), + recorderApp.setPaused(this._debugger.isPaused()), + this._pushAllSources() + ]); + + this._context.once(BrowserContext.Events.Close, () => { + this._contextRecorder.dispose(); + recorderApp.close().catch(() => {}); + }); + this._contextRecorder.on(ContextRecorder.Events.Change, (data: { sources: Source[], primaryFileName: string }) => { + this._recorderSources = data.sources; + this._pushAllSources(); + this._recorderApp?.setFile(data.primaryFileName); + }); + + await this._context.exposeBinding('_playwrightRecorderState', false, source => { + let actionSelector = this._highlightedSelector; + let actionPoint: Point | undefined; + for (const [metadata, sdkObject] of this._currentCallsMetadata) { + if (source.page === sdkObject.attribution.page) { + actionPoint = metadata.point || actionPoint; + actionSelector = actionSelector || metadata.params.selector; + } + } + const uiState: UIState = { + mode: this._mode, + actionPoint, + actionSelector, + }; + return uiState; + }); + + await this._context.exposeBinding('_playwrightRecorderSetSelector', false, async (_, selector: string) => { + this._setMode('none'); + await this._recorderApp?.setSelector(selector, true); + await this._recorderApp?.bringToFront(); + }); + + await this._context.exposeBinding('_playwrightResume', false, () => { + this._debugger.resume(false); + }); + await this._context.extendInjectedScript(consoleApiSource.source); + + await this._contextRecorder.install(); + + if (this._debugger.isPaused()) + this._pausedStateChanged(); + this._debugger.on(Debugger.Events.PausedStateChanged, () => this._pausedStateChanged()); + + (this._context as any).recorderAppForTest = recorderApp; + } + + _pausedStateChanged() { + // If we are called upon page.pause, we don't have metadatas, populate them. + for (const { metadata, sdkObject } of this._debugger.pausedDetails()) { + if (!this._currentCallsMetadata.has(metadata)) + this.onBeforeCall(sdkObject, metadata); + } + this._recorderApp?.setPaused(this._debugger.isPaused()); + this._updateUserSources(); + this.updateCallLog([...this._currentCallsMetadata.keys()]); + } + + private _setMode(mode: Mode) { + this._mode = mode; + this._recorderApp?.setMode(this._mode); + this._contextRecorder.setEnabled(this._mode === 'recording'); + this._debugger.setMuted(this._mode === 'recording'); + if (this._mode !== 'none') + this._context.pages()[0].bringToFront().catch(() => {}); + } + + private _refreshOverlay() { + for (const page of this._context.pages()) + page.mainFrame().evaluateExpression('window._playwrightRefreshOverlay()', false, undefined, 'main').catch(() => {}); + } + + async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) { + if (this._mode === 'recording') + return; + this._currentCallsMetadata.set(metadata, sdkObject); + this._allMetadatas.set(metadata.id, metadata); + this._updateUserSources(); + this.updateCallLog([metadata]); + if (metadata.params && metadata.params.selector) { + this._highlightedSelector = metadata.params.selector; + this._recorderApp?.setSelector(this._highlightedSelector).catch(() => {}); + } + } + + async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata) { + if (this._mode === 'recording') + return; + if (!metadata.error) + this._currentCallsMetadata.delete(metadata); + this._updateUserSources(); + this.updateCallLog([metadata]); + } + + private _updateUserSources() { + // Remove old decorations. + for (const source of this._userSources.values()) { + source.highlight = []; + source.revealLine = undefined; + } + + // Apply new decorations. + let fileToSelect = undefined; + for (const metadata of this._currentCallsMetadata.keys()) { + if (!metadata.stack || !metadata.stack[0]) + continue; + const { file, line } = metadata.stack[0]; + let source = this._userSources.get(file); + if (!source) { + source = { file, text: this._readSource(file), highlight: [], language: languageForFile(file) }; + this._userSources.set(file, source); + } + if (line) { + const paused = this._debugger.isPaused(metadata); + source.highlight.push({ line, type: metadata.error ? 'error' : (paused ? 'paused' : 'running') }); + source.revealLine = line; + fileToSelect = source.file; + } + } + this._pushAllSources(); + if (fileToSelect) + this._recorderApp?.setFile(fileToSelect); + } + + private _pushAllSources() { + this._recorderApp?.setSources([...this._recorderSources, ...this._userSources.values()]); + } + + async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata) { + } + + async onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): Promise { + this.updateCallLog([metadata]); + } + + updateCallLog(metadatas: CallMetadata[]) { + if (this._mode === 'recording') + return; + const logs: CallLog[] = []; + for (const metadata of metadatas) { + if (!metadata.method) + continue; + let status: CallLogStatus = 'done'; + if (this._currentCallsMetadata.has(metadata)) + status = 'in-progress'; + if (this._debugger.isPaused(metadata)) + status = 'paused'; + logs.push(metadataToCallLog(metadata, status)); + } + this._recorderApp?.updateCallLogs(logs); + } + + private _readSource(fileName: string): string { + try { + return fs.readFileSync(fileName, 'utf-8'); + } catch (e) { + return '// No source available'; + } + } +} + +class ContextRecorder extends EventEmitter { + static Events = { + Change: 'change' + }; + + private _generator: CodeGenerator; + private _pageAliases = new Map(); + private _lastPopupOrdinal = 0; + private _lastDialogOrdinal = 0; + private _lastDownloadOrdinal = 0; + private _timers = new Set(); + private _context: BrowserContext; + private _params: channels.BrowserContextRecorderSupplementEnableParams; + private _recorderSources: Source[]; + + constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) { + super(); + this._context = context; this._params = params; - this._mode = params.startRecording ? 'recording' : 'none'; const language = params.language || context._browser.options.sdkLanguage; const languages = new Set([ @@ -112,8 +331,10 @@ export class RecorderSupplement implements InstrumentationListener { if (languageGenerator === orderedLanguages[0]) text = source.text; } - this._pushAllSources(); - this._recorderApp?.setFile(primaryLanguage.fileName); + this.emit(ContextRecorder.Events.Change, { + sources: this._recorderSources, + primaryFileName: primaryLanguage.fileName + }); }); if (params.outputFile) { context.on(BrowserContext.Events.BeforeClose, () => { @@ -129,58 +350,10 @@ export class RecorderSupplement implements InstrumentationListener { } async install() { - const recorderApp = await RecorderApp.open(this._context); - this._recorderApp = recorderApp; - recorderApp.once('close', () => { - this._debugger.resume(false); - this._recorderApp = null; - }); - recorderApp.on('event', (data: EventData) => { - if (data.event === 'setMode') { - this._setMode(data.params.mode); - this._refreshOverlay(); - return; - } - if (data.event === 'selectorUpdated') { - this._highlightedSelector = data.params.selector; - this._refreshOverlay(); - return; - } - if (data.event === 'step') { - this._debugger.resume(true); - return; - } - if (data.event === 'resume') { - this._debugger.resume(false); - return; - } - if (data.event === 'pause') { - this._debugger.pauseOnNextStatement(); - return; - } - if (data.event === 'clear') { - this._clearScript(); - return; - } - }); - - await Promise.all([ - recorderApp.setMode(this._mode), - recorderApp.setPaused(this._debugger.isPaused()), - this._pushAllSources() - ]); - this._context.on(BrowserContext.Events.Page, page => this._onPage(page)); for (const page of this._context.pages()) this._onPage(page); - this._context.once(BrowserContext.Events.Close, () => { - for (const timer of this._timers) - clearTimeout(timer); - this._timers.clear(); - recorderApp.close().catch(() => {}); - }); - // Input actions that potentially lead to navigation are intercepted on the page and are // performed by the Playwright. await this._context.exposeBinding('_playwrightRecorderPerformAction', false, @@ -190,66 +363,17 @@ export class RecorderSupplement implements InstrumentationListener { await this._context.exposeBinding('_playwrightRecorderRecordAction', false, (source: BindingSource, action: actions.Action) => this._recordAction(source.frame, action)); - await this._context.exposeBinding('_playwrightRecorderState', false, source => { - let actionSelector = this._highlightedSelector; - let actionPoint: Point | undefined; - for (const [metadata, sdkObject] of this._currentCallsMetadata) { - if (source.page === sdkObject.attribution.page) { - actionPoint = metadata.point || actionPoint; - actionSelector = actionSelector || metadata.params.selector; - } - } - const uiState: UIState = { - mode: this._mode, - actionPoint, - actionSelector, - }; - return uiState; - }); - - await this._context.exposeBinding('_playwrightRecorderSetSelector', false, async (_, selector: string) => { - this._setMode('none'); - await this._recorderApp?.setSelector(selector, true); - await this._recorderApp?.bringToFront(); - }); - - await this._context.exposeBinding('_playwrightResume', false, () => { - this._debugger.resume(false); - }); - await this._context.extendInjectedScript(recorderSource.source, { isUnderTest: isUnderTest() }); - await this._context.extendInjectedScript(consoleApiSource.source); - - if (this._debugger.isPaused()) - this._pausedStateChanged(); - this._debugger.on(Debugger.Events.PausedStateChanged, () => this._pausedStateChanged()); - - (this._context as any).recorderAppForTest = recorderApp; } - _pausedStateChanged() { - // If we are called upon page.pause, we don't have metadatas, populate them. - for (const { metadata, sdkObject } of this._debugger.pausedDetails()) { - if (!this._currentCallsMetadata.has(metadata)) - this.onBeforeCall(sdkObject, metadata); - } - this._recorderApp?.setPaused(this._debugger.isPaused()); - this._updateUserSources(); - this.updateCallLog([...this._currentCallsMetadata.keys()]); + setEnabled(enabled: boolean) { + this._generator.setEnabled(enabled); } - private _setMode(mode: Mode) { - this._mode = mode; - this._recorderApp?.setMode(this._mode); - this._generator.setEnabled(this._mode === 'recording'); - Debugger.lookup(this._context)!.setMuted(this._mode === 'recording'); - if (this._mode !== 'none') - this._context.pages()[0].bringToFront().catch(() => {}); - } - - private _refreshOverlay() { - for (const page of this._context.pages()) - page.mainFrame().evaluateExpression('window._playwrightRefreshOverlay()', false, undefined, 'main').catch(() => {}); + dispose() { + for (const timer of this._timers) + clearTimeout(timer); + this._timers.clear(); } private async _onPage(page: Page) { @@ -290,7 +414,7 @@ export class RecorderSupplement implements InstrumentationListener { } } - private _clearScript(): void { + clearScript(): void { this._generator.restart(); if (!!this._params.startRecording) { for (const page of this._context.pages()) @@ -389,6 +513,7 @@ export class RecorderSupplement implements InstrumentationListener { const popupAlias = this._pageAliases.get(popup)!; this._generator.signal(pageAlias, page.mainFrame(), { name: 'popup', popupAlias }); } + private _onDownload(page: Page) { const pageAlias = this._pageAliases.get(page)!; this._generator.signal(pageAlias, page.mainFrame(), { name: 'download', downloadAlias: String(++this._lastDownloadOrdinal) }); @@ -398,94 +523,6 @@ export class RecorderSupplement implements InstrumentationListener { const pageAlias = this._pageAliases.get(page)!; this._generator.signal(pageAlias, page.mainFrame(), { name: 'dialog', dialogAlias: String(++this._lastDialogOrdinal) }); } - - async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) { - if (this._mode === 'recording') - return; - this._currentCallsMetadata.set(metadata, sdkObject); - this._allMetadatas.set(metadata.id, metadata); - this._updateUserSources(); - this.updateCallLog([metadata]); - if (metadata.params && metadata.params.selector) { - this._highlightedSelector = metadata.params.selector; - this._recorderApp?.setSelector(this._highlightedSelector).catch(() => {}); - } - } - - async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata) { - if (this._mode === 'recording') - return; - if (!metadata.error) - this._currentCallsMetadata.delete(metadata); - this._updateUserSources(); - this.updateCallLog([metadata]); - } - - private _updateUserSources() { - // Remove old decorations. - for (const source of this._userSources.values()) { - source.highlight = []; - source.revealLine = undefined; - } - - // Apply new decorations. - let fileToSelect = undefined; - for (const metadata of this._currentCallsMetadata.keys()) { - if (!metadata.stack || !metadata.stack[0]) - continue; - const { file, line } = metadata.stack[0]; - let source = this._userSources.get(file); - if (!source) { - source = { file, text: this._readSource(file), highlight: [], language: languageForFile(file) }; - this._userSources.set(file, source); - } - if (line) { - const paused = this._debugger.isPaused(metadata); - source.highlight.push({ line, type: metadata.error ? 'error' : (paused ? 'paused' : 'running') }); - source.revealLine = line; - fileToSelect = source.file; - } - } - this._pushAllSources(); - if (fileToSelect) - this._recorderApp?.setFile(fileToSelect); - } - - private _pushAllSources() { - this._recorderApp?.setSources([...this._recorderSources, ...this._userSources.values()]); - } - - async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata) { - } - - async onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): Promise { - this.updateCallLog([metadata]); - } - - updateCallLog(metadatas: CallMetadata[]) { - if (this._mode === 'recording') - return; - const logs: CallLog[] = []; - for (const metadata of metadatas) { - if (!metadata.method) - continue; - let status: CallLogStatus = 'done'; - if (this._currentCallsMetadata.has(metadata)) - status = 'in-progress'; - if (this._debugger.isPaused(metadata)) - status = 'paused'; - logs.push(metadataToCallLog(metadata, status)); - } - this._recorderApp?.updateCallLogs(logs); - } - - private _readSource(fileName: string): string { - try { - return fs.readFileSync(fileName, 'utf-8'); - } catch (e) { - return '// No source available'; - } - } } function languageForFile(file: string) {