/** * 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 type * as channels from '@protocol/channels'; import type { CallLog, CallLogStatus, EventData, Mode, OverlayState, Source, UIState } from '@recorder/recorderTypes'; import * as fs from 'fs'; import type { Point } from '../common/types'; import * as consoleApiSource from '../generated/consoleApiSource'; import { isUnderTest } from '../utils'; import { locatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser'; import { BrowserContext } from './browserContext'; import { type Language } from './codegen/types'; import { Debugger } from './debugger'; import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation'; import { ContextRecorder, generateFrameSelector } from './recorder/contextRecorder'; import type { IRecorderAppFactory, IRecorderApp, IRecorder } from './recorder/recorderFrontend'; import { metadataToCallLog } from './recorder/recorderUtils'; import type * as actions from '@recorder/actions'; import { buildFullSelector } from '../utils/isomorphic/recorderUtils'; const recorderSymbol = Symbol('recorderSymbol'); export class Recorder implements InstrumentationListener, IRecorder { private _context: BrowserContext; private _mode: Mode; private _highlightedSelector = ''; private _overlayState: OverlayState = { offsetX: 0 }; private _recorderApp: IRecorderApp | null = null; private _currentCallsMetadata = new Map(); private _recorderSources: Source[] = []; private _userSources = new Map(); private _debugger: Debugger; private _contextRecorder: ContextRecorder; private _omitCallTracking = false; private _currentLanguage: Language; static async showInspector(context: BrowserContext, params: channels.BrowserContextEnableRecorderParams, recorderAppFactory: IRecorderAppFactory) { if (isUnderTest()) params.language = process.env.TEST_INSPECTOR_LANGUAGE; return await Recorder.show('actions', context, recorderAppFactory, params); } static showInspectorNoReply(context: BrowserContext, recorderAppFactory: IRecorderAppFactory) { Recorder.showInspector(context, {}, recorderAppFactory).catch(() => {}); } static show(codegenMode: 'actions' | 'trace-events', context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextEnableRecorderParams): Promise { let recorderPromise = (context as any)[recorderSymbol] as Promise; if (!recorderPromise) { recorderPromise = Recorder._create(codegenMode, context, recorderAppFactory, params); (context as any)[recorderSymbol] = recorderPromise; } return recorderPromise; } private static async _create(codegenMode: 'actions' | 'trace-events', context: BrowserContext, recorderAppFactory: IRecorderAppFactory, params: channels.BrowserContextEnableRecorderParams = {}): Promise { const recorder = new Recorder(codegenMode, context, params); const recorderApp = await recorderAppFactory(recorder); await recorder._install(recorderApp); return recorder; } constructor(codegenMode: 'actions' | 'trace-events', context: BrowserContext, params: channels.BrowserContextEnableRecorderParams) { this._mode = params.mode || 'none'; this._contextRecorder = new ContextRecorder(codegenMode, context, params, {}); this._context = context; this._omitCallTracking = !!params.omitCallTracking; this._debugger = context.debugger(); context.instrumentation.addListener(this, context); this._currentLanguage = this._contextRecorder.languageName(); if (isUnderTest()) { // Most of our tests put elements at the top left, so get out of the way. this._overlayState.offsetX = 200; } } private async _install(recorderApp: IRecorderApp) { 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); return; } if (data.event === 'selectorUpdated') { this.setHighlightedSelector(this._currentLanguage, data.params.selector); return; } if (data.event === 'step') { this._debugger.resume(true); return; } if (data.event === 'fileChanged') { this._currentLanguage = this._contextRecorder.languageName(data.params.file); this._refreshOverlay(); 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(); this._context.instrumentation.removeListener(this); this._recorderApp?.close().catch(() => {}); }); this._contextRecorder.on(ContextRecorder.Events.Change, (data: { sources: Source[], actions: actions.ActionInContext[] }) => { this._recorderSources = data.sources; recorderApp.setActions(data.actions, data.sources); recorderApp.setRunningFile(undefined); this._pushAllSources(); }); await this._context.exposeBinding('__pw_recorderState', false, source => { let actionSelector = ''; let actionPoint: Point | undefined; const hasActiveScreenshotCommand = [...this._currentCallsMetadata.keys()].some(isScreenshotCommand); if (!hasActiveScreenshotCommand) { actionSelector = this._highlightedSelector; 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, language: this._currentLanguage, testIdAttributeName: this._contextRecorder.testIdAttributeName(), overlay: this._overlayState, }; return uiState; }); await this._context.exposeBinding('__pw_recorderSetSelector', false, async ({ frame }, selector: string) => { const selectorChain = await generateFrameSelector(frame); await this._recorderApp?.setSelector(buildFullSelector(selectorChain, selector), true); }); await this._context.exposeBinding('__pw_recorderSetMode', false, async ({ frame }, mode: Mode) => { if (frame.parentFrame()) return; this.setMode(mode); }); await this._context.exposeBinding('__pw_recorderSetOverlayState', false, async ({ frame }, state: OverlayState) => { if (frame.parentFrame()) return; this._overlayState = state; }); await this._context.exposeBinding('__pw_resume', 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 = this._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()]); } setMode(mode: Mode) { if (this._mode === mode) return; this._highlightedSelector = ''; this._mode = mode; this._recorderApp?.setMode(this._mode); this._contextRecorder.setEnabled(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue'); this._debugger.setMuted(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue'); if (this._mode !== 'none' && this._mode !== 'standby' && this._context.pages().length === 1) this._context.pages()[0].bringToFront().catch(() => {}); this._refreshOverlay(); } resume() { this._debugger.resume(false); } mode() { return this._mode; } setHighlightedSelector(language: Language, selector: string) { this._highlightedSelector = locatorOrSelectorAsSelector(language, selector, this._context.selectors().testIdAttributeName()); this._refreshOverlay(); } hideHighlightedSelector() { this._highlightedSelector = ''; this._refreshOverlay(); } setOutput(codegenId: string, outputFile: string | undefined) { this._contextRecorder.setOutput(codegenId, outputFile); } private _refreshOverlay() { for (const page of this._context.pages()) page.mainFrame().evaluateExpression('window.__pw_refreshOverlay()').catch(() => {}); } async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) { if (this._omitCallTracking || this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue') return; this._currentCallsMetadata.set(metadata, sdkObject); this._updateUserSources(); this.updateCallLog([metadata]); if (isScreenshotCommand(metadata)) { this.hideHighlightedSelector(); } else 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._omitCallTracking || this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue') 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.location) continue; const { file, line } = metadata.location; let source = this._userSources.get(file); if (!source) { source = { isRecorded: false, label: file, id: 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.id; } } this._pushAllSources(); if (fileToSelect) this._recorderApp?.setRunningFile(fileToSelect); } private _pushAllSources() { this._recorderApp?.setSources([...this._recorderSources, ...this._userSources.values()]); } async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata) { } async onCallLog(sdkObject: SdkObject, metadata: CallMetadata, logName: string, message: string): Promise { this.updateCallLog([metadata]); } updateCallLog(metadatas: CallMetadata[]) { if (this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue') return; const logs: CallLog[] = []; for (const metadata of metadatas) { if (!metadata.method || metadata.internal) 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 isScreenshotCommand(metadata: CallMetadata) { return metadata.method.toLowerCase().includes('screenshot'); } function languageForFile(file: string) { if (file.endsWith('.py')) return 'python'; if (file.endsWith('.java')) return 'java'; if (file.endsWith('.cs')) return 'csharp'; return 'javascript'; }