2020-12-28 14:50:12 -08:00
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
|
|
|
|
2021-02-12 10:11:30 -08:00
|
|
|
import * as fs from 'fs';
|
2022-04-06 13:57:14 -08:00
|
|
|
import type * as actions from './recorder/recorderActions';
|
2022-09-20 18:41:51 -07:00
|
|
|
import type * as channels from '@protocol/channels';
|
2022-04-06 13:57:14 -08:00
|
|
|
import type { ActionInContext } from './recorder/codeGenerator';
|
|
|
|
import { CodeGenerator } from './recorder/codeGenerator';
|
2022-02-04 19:27:45 -08:00
|
|
|
import { toClickOptions, toModifiers } from './recorder/utils';
|
2022-04-08 11:52:40 -08:00
|
|
|
import { Page } from './page';
|
|
|
|
import { Frame } from './frames';
|
|
|
|
import { BrowserContext } from './browserContext';
|
2021-03-03 14:32:09 -08:00
|
|
|
import { JavaLanguageGenerator } from './recorder/java';
|
2021-01-24 19:21:19 -08:00
|
|
|
import { JavaScriptLanguageGenerator } from './recorder/javascript';
|
|
|
|
import { CSharpLanguageGenerator } from './recorder/csharp';
|
|
|
|
import { PythonLanguageGenerator } from './recorder/python';
|
2022-04-08 11:52:40 -08:00
|
|
|
import * as recorderSource from '../generated/recorderSource';
|
|
|
|
import * as consoleApiSource from '../generated/consoleApiSource';
|
2022-08-05 19:34:57 -07:00
|
|
|
import { EmptyRecorderApp } from './recorder/recorderApp';
|
2022-04-06 13:57:14 -08:00
|
|
|
import type { IRecorderApp } from './recorder/recorderApp';
|
|
|
|
import { RecorderApp } from './recorder/recorderApp';
|
2022-04-08 11:52:40 -08:00
|
|
|
import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation';
|
|
|
|
import type { Point } from '../common/types';
|
2022-09-20 14:32:21 -07:00
|
|
|
import type { CallLog, CallLogStatus, EventData, Mode, Source, UIState } from '@recorder/recorderTypes';
|
2022-04-08 11:52:40 -08:00
|
|
|
import { createGuid, monotonicTime } from '../utils';
|
2021-03-11 11:22:59 -08:00
|
|
|
import { metadataToCallLog } from './recorder/recorderUtils';
|
2021-04-23 18:34:52 -07:00
|
|
|
import { Debugger } from './debugger';
|
2021-10-01 12:07:35 -07:00
|
|
|
import { EventEmitter } from 'events';
|
2022-04-08 11:52:40 -08:00
|
|
|
import { raceAgainstTimeout } from '../utils/timeoutRunner';
|
2022-10-05 16:59:34 -08:00
|
|
|
import type { Language, LanguageGenerator } from './recorder/language';
|
2023-03-06 18:49:14 -08:00
|
|
|
import { locatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser';
|
2023-03-17 16:16:08 +01:00
|
|
|
import { eventsHelper, type RegisteredListener } from './../utils/eventsHelper';
|
2020-12-28 14:50:12 -08:00
|
|
|
|
|
|
|
type BindingSource = { frame: Frame, page: Page };
|
2021-01-25 14:49:26 -08:00
|
|
|
|
2022-08-05 19:34:57 -07:00
|
|
|
const recorderSymbol = Symbol('recorderSymbol');
|
2021-01-25 14:49:26 -08:00
|
|
|
|
2022-04-08 11:52:40 -08:00
|
|
|
export class Recorder implements InstrumentationListener {
|
2021-01-24 19:21:19 -08:00
|
|
|
private _context: BrowserContext;
|
2021-02-05 14:24:27 -08:00
|
|
|
private _mode: Mode;
|
2021-02-19 07:25:08 -08:00
|
|
|
private _highlightedSelector = '';
|
2022-02-10 21:23:16 -08:00
|
|
|
private _recorderApp: IRecorderApp | null = null;
|
2021-02-12 22:06:45 -08:00
|
|
|
private _currentCallsMetadata = new Map<CallMetadata, SdkObject>();
|
2021-10-01 12:07:35 -07:00
|
|
|
private _recorderSources: Source[] = [];
|
2021-02-12 18:53:46 -08:00
|
|
|
private _userSources = new Map<string, Source>();
|
2021-04-23 18:34:52 -07:00
|
|
|
private _debugger: Debugger;
|
2021-10-01 12:07:35 -07:00
|
|
|
private _contextRecorder: ContextRecorder;
|
2022-08-09 00:13:38 +02:00
|
|
|
private _handleSIGINT: boolean | undefined;
|
2022-08-09 16:42:55 -07:00
|
|
|
private _omitCallTracking = false;
|
2022-10-05 16:59:34 -08:00
|
|
|
private _currentLanguage: Language;
|
2021-04-23 18:34:52 -07:00
|
|
|
|
2022-11-10 12:15:29 -08:00
|
|
|
private static recorderAppFactory: ((recorder: Recorder) => Promise<IRecorderApp>) | undefined;
|
|
|
|
|
|
|
|
static setAppFactory(recorderAppFactory: ((recorder: Recorder) => Promise<IRecorderApp>) | undefined) {
|
|
|
|
Recorder.recorderAppFactory = recorderAppFactory;
|
|
|
|
}
|
|
|
|
|
2021-04-23 18:34:52 -07:00
|
|
|
static showInspector(context: BrowserContext) {
|
2022-04-08 11:52:40 -08:00
|
|
|
Recorder.show(context, {}).catch(() => {});
|
2021-04-23 18:34:52 -07:00
|
|
|
}
|
2021-01-25 14:49:26 -08:00
|
|
|
|
2022-11-10 12:15:29 -08:00
|
|
|
static show(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<Recorder> {
|
2022-08-05 19:34:57 -07:00
|
|
|
let recorderPromise = (context as any)[recorderSymbol] as Promise<Recorder>;
|
2021-01-25 14:49:26 -08:00
|
|
|
if (!recorderPromise) {
|
2022-11-10 12:15:29 -08:00
|
|
|
const recorder = new Recorder(context, params);
|
2021-01-25 14:49:26 -08:00
|
|
|
recorderPromise = recorder.install().then(() => recorder);
|
2022-08-05 19:34:57 -07:00
|
|
|
(context as any)[recorderSymbol] = recorderPromise;
|
2021-01-25 14:49:26 -08:00
|
|
|
}
|
|
|
|
return recorderPromise;
|
|
|
|
}
|
2021-01-24 19:21:19 -08:00
|
|
|
|
2022-11-10 12:15:29 -08:00
|
|
|
constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) {
|
2022-08-05 19:34:57 -07:00
|
|
|
this._mode = params.mode || 'none';
|
2021-10-01 12:07:35 -07:00
|
|
|
this._contextRecorder = new ContextRecorder(context, params);
|
2021-01-24 19:21:19 -08:00
|
|
|
this._context = context;
|
2022-08-09 16:42:55 -07:00
|
|
|
this._omitCallTracking = !!params.omitCallTracking;
|
2022-11-22 11:06:45 -08:00
|
|
|
this._debugger = context.debugger();
|
2022-08-09 00:13:38 +02:00
|
|
|
this._handleSIGINT = params.handleSIGINT;
|
2022-01-12 07:37:48 -08:00
|
|
|
context.instrumentation.addListener(this, context);
|
2022-10-05 16:59:34 -08:00
|
|
|
this._currentLanguage = this._contextRecorder.languageName();
|
2021-01-24 19:21:19 -08:00
|
|
|
}
|
2020-12-28 14:50:12 -08:00
|
|
|
|
2022-08-05 19:34:57 -07:00
|
|
|
private static async defaultRecorderAppFactory(recorder: Recorder) {
|
|
|
|
if (process.env.PW_CODEGEN_NO_INSPECTOR)
|
|
|
|
return new EmptyRecorderApp();
|
2022-08-09 00:13:38 +02:00
|
|
|
return await RecorderApp.open(recorder, recorder._context, recorder._handleSIGINT);
|
2022-08-05 19:34:57 -07:00
|
|
|
}
|
|
|
|
|
2021-01-24 19:21:19 -08:00
|
|
|
async install() {
|
2022-11-10 12:15:29 -08:00
|
|
|
const recorderApp = await (Recorder.recorderAppFactory || Recorder.defaultRecorderAppFactory)(this);
|
2021-02-05 14:24:27 -08:00
|
|
|
this._recorderApp = recorderApp;
|
|
|
|
recorderApp.once('close', () => {
|
2021-06-04 18:43:54 -07:00
|
|
|
this._debugger.resume(false);
|
2021-02-05 14:24:27 -08:00
|
|
|
this._recorderApp = null;
|
|
|
|
});
|
|
|
|
recorderApp.on('event', (data: EventData) => {
|
|
|
|
if (data.event === 'setMode') {
|
2022-08-05 19:34:57 -07:00
|
|
|
this.setMode(data.params.mode);
|
2021-02-19 07:25:08 -08:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (data.event === 'selectorUpdated') {
|
2022-11-03 15:17:08 -07:00
|
|
|
this.setHighlightedSelector(data.params.language, data.params.selector);
|
2021-02-26 14:16:32 -08:00
|
|
|
return;
|
|
|
|
}
|
2021-02-12 10:11:30 -08:00
|
|
|
if (data.event === 'step') {
|
2021-04-23 18:34:52 -07:00
|
|
|
this._debugger.resume(true);
|
2021-02-12 10:11:30 -08:00
|
|
|
return;
|
|
|
|
}
|
2022-10-05 16:59:34 -08:00
|
|
|
if (data.event === 'fileChanged') {
|
|
|
|
this._currentLanguage = this._contextRecorder.languageName(data.params.file);
|
|
|
|
this._refreshOverlay();
|
|
|
|
return;
|
|
|
|
}
|
2021-02-05 14:24:27 -08:00
|
|
|
if (data.event === 'resume') {
|
2021-04-23 18:34:52 -07:00
|
|
|
this._debugger.resume(false);
|
2021-02-12 10:11:30 -08:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (data.event === 'pause') {
|
2021-04-23 18:34:52 -07:00
|
|
|
this._debugger.pauseOnNextStatement();
|
2021-02-05 14:24:27 -08:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (data.event === 'clear') {
|
2021-10-01 12:07:35 -07:00
|
|
|
this._contextRecorder.clearScript();
|
2021-02-05 14:24:27 -08:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
await Promise.all([
|
|
|
|
recorderApp.setMode(this._mode),
|
2021-04-23 18:34:52 -07:00
|
|
|
recorderApp.setPaused(this._debugger.isPaused()),
|
2021-02-12 18:53:46 -08:00
|
|
|
this._pushAllSources()
|
2021-02-05 14:24:27 -08:00
|
|
|
]);
|
|
|
|
|
|
|
|
this._context.once(BrowserContext.Events.Close, () => {
|
2021-10-01 12:07:35 -07:00
|
|
|
this._contextRecorder.dispose();
|
2022-05-09 17:34:00 +01:00
|
|
|
this._context.instrumentation.removeListener(this);
|
2021-02-05 14:24:27 -08:00
|
|
|
recorderApp.close().catch(() => {});
|
2021-01-24 19:21:19 -08:00
|
|
|
});
|
2021-10-01 12:07:35 -07:00
|
|
|
this._contextRecorder.on(ContextRecorder.Events.Change, (data: { sources: Source[], primaryFileName: string }) => {
|
|
|
|
this._recorderSources = data.sources;
|
|
|
|
this._pushAllSources();
|
2022-05-04 17:16:24 +01:00
|
|
|
this._recorderApp?.setFileIfNeeded(data.primaryFileName);
|
2021-10-01 12:07:35 -07:00
|
|
|
});
|
2020-12-28 14:50:12 -08:00
|
|
|
|
2022-05-09 06:44:20 -08:00
|
|
|
await this._context.exposeBinding('__pw_recorderState', false, source => {
|
2022-11-07 13:53:15 -08:00
|
|
|
let actionSelector = '';
|
2021-02-26 14:16:32 -08:00
|
|
|
let actionPoint: Point | undefined;
|
2022-11-07 13:53:15 -08:00
|
|
|
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;
|
|
|
|
}
|
2021-02-12 18:53:46 -08:00
|
|
|
}
|
2021-02-12 10:11:30 -08:00
|
|
|
}
|
2021-02-26 14:16:32 -08:00
|
|
|
const uiState: UIState = {
|
|
|
|
mode: this._mode,
|
|
|
|
actionPoint,
|
|
|
|
actionSelector,
|
2022-11-08 12:04:43 -08:00
|
|
|
language: this._currentLanguage,
|
2022-11-29 11:43:47 -08:00
|
|
|
testIdAttributeName: this._context.selectors().testIdAttributeName(),
|
2021-02-26 14:16:32 -08:00
|
|
|
};
|
2021-02-12 10:11:30 -08:00
|
|
|
return uiState;
|
2021-09-08 14:27:05 -07:00
|
|
|
});
|
2021-01-25 14:49:26 -08:00
|
|
|
|
2022-05-09 06:44:20 -08:00
|
|
|
await this._context.exposeBinding('__pw_recorderSetSelector', false, async (_, selector: string) => {
|
2021-02-19 07:25:08 -08:00
|
|
|
await this._recorderApp?.setSelector(selector, true);
|
2021-09-08 14:27:05 -07:00
|
|
|
});
|
2021-02-19 07:25:08 -08:00
|
|
|
|
2022-05-09 06:44:20 -08:00
|
|
|
await this._context.exposeBinding('__pw_resume', false, () => {
|
2021-04-23 18:34:52 -07:00
|
|
|
this._debugger.resume(false);
|
2021-09-08 14:27:05 -07:00
|
|
|
});
|
|
|
|
await this._context.extendInjectedScript(consoleApiSource.source);
|
2021-04-21 20:46:45 -07:00
|
|
|
|
2021-10-01 12:07:35 -07:00
|
|
|
await this._contextRecorder.install();
|
|
|
|
|
2021-04-23 18:34:52 -07:00
|
|
|
if (this._debugger.isPaused())
|
2021-04-21 20:46:45 -07:00
|
|
|
this._pausedStateChanged();
|
2021-04-23 18:34:52 -07:00
|
|
|
this._debugger.on(Debugger.Events.PausedStateChanged, () => this._pausedStateChanged());
|
2021-04-21 20:46:45 -07:00
|
|
|
|
2021-02-05 14:24:27 -08:00
|
|
|
(this._context as any).recorderAppForTest = recorderApp;
|
2021-01-25 14:49:26 -08:00
|
|
|
}
|
|
|
|
|
2021-04-21 20:46:45 -07:00
|
|
|
_pausedStateChanged() {
|
|
|
|
// If we are called upon page.pause, we don't have metadatas, populate them.
|
2021-04-23 18:34:52 -07:00
|
|
|
for (const { metadata, sdkObject } of this._debugger.pausedDetails()) {
|
2021-04-21 20:46:45 -07:00
|
|
|
if (!this._currentCallsMetadata.has(metadata))
|
|
|
|
this.onBeforeCall(sdkObject, metadata);
|
|
|
|
}
|
2021-06-04 18:43:54 -07:00
|
|
|
this._recorderApp?.setPaused(this._debugger.isPaused());
|
2021-02-12 18:53:46 -08:00
|
|
|
this._updateUserSources();
|
2021-04-21 20:46:45 -07:00
|
|
|
this.updateCallLog([...this._currentCallsMetadata.keys()]);
|
2021-02-26 14:16:32 -08:00
|
|
|
}
|
|
|
|
|
2022-08-05 19:34:57 -07:00
|
|
|
setMode(mode: Mode) {
|
|
|
|
if (this._mode === mode)
|
|
|
|
return;
|
2022-08-08 10:39:54 -07:00
|
|
|
this._highlightedSelector = '';
|
2021-02-19 07:25:08 -08:00
|
|
|
this._mode = mode;
|
|
|
|
this._recorderApp?.setMode(this._mode);
|
2021-10-01 12:07:35 -07:00
|
|
|
this._contextRecorder.setEnabled(this._mode === 'recording');
|
|
|
|
this._debugger.setMuted(this._mode === 'recording');
|
2022-08-05 19:34:57 -07:00
|
|
|
if (this._mode !== 'none' && this._context.pages().length === 1)
|
2021-02-19 07:25:08 -08:00
|
|
|
this._context.pages()[0].bringToFront().catch(() => {});
|
2022-08-08 10:39:54 -07:00
|
|
|
this._refreshOverlay();
|
2021-02-19 07:25:08 -08:00
|
|
|
}
|
|
|
|
|
2022-11-10 12:15:29 -08:00
|
|
|
resume() {
|
|
|
|
this._debugger.resume(false);
|
|
|
|
}
|
|
|
|
|
2022-11-03 15:17:08 -07:00
|
|
|
setHighlightedSelector(language: Language, selector: string) {
|
2022-11-29 11:43:47 -08:00
|
|
|
this._highlightedSelector = locatorOrSelectorAsSelector(language, selector, this._context.selectors().testIdAttributeName());
|
2022-11-03 15:17:08 -07:00
|
|
|
this._refreshOverlay();
|
|
|
|
}
|
|
|
|
|
|
|
|
hideHighlightedSelecor() {
|
|
|
|
this._highlightedSelector = '';
|
2022-08-05 19:34:57 -07:00
|
|
|
this._refreshOverlay();
|
|
|
|
}
|
|
|
|
|
2022-10-25 12:55:20 -04:00
|
|
|
setOutput(codegenId: string, outputFile: string | undefined) {
|
|
|
|
this._contextRecorder.setOutput(codegenId, outputFile);
|
2022-08-08 17:16:13 -07:00
|
|
|
}
|
|
|
|
|
2021-02-26 14:16:32 -08:00
|
|
|
private _refreshOverlay() {
|
|
|
|
for (const page of this._context.pages())
|
2022-05-09 06:44:20 -08:00
|
|
|
page.mainFrame().evaluateExpression('window.__pw_refreshOverlay()', false, undefined, 'main').catch(() => {});
|
2021-02-26 14:16:32 -08:00
|
|
|
}
|
|
|
|
|
2021-10-01 12:07:35 -07:00
|
|
|
async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata) {
|
2022-08-09 16:42:55 -07:00
|
|
|
if (this._omitCallTracking || this._mode === 'recording')
|
2021-10-01 12:07:35 -07:00
|
|
|
return;
|
|
|
|
this._currentCallsMetadata.set(metadata, sdkObject);
|
|
|
|
this._updateUserSources();
|
|
|
|
this.updateCallLog([metadata]);
|
2022-11-07 13:53:15 -08:00
|
|
|
if (isScreenshotCommand(metadata)) {
|
|
|
|
this.hideHighlightedSelecor();
|
|
|
|
} else if (metadata.params && metadata.params.selector) {
|
2021-10-01 12:07:35 -07:00
|
|
|
this._highlightedSelector = metadata.params.selector;
|
|
|
|
this._recorderApp?.setSelector(this._highlightedSelector).catch(() => {});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata) {
|
2022-08-09 16:42:55 -07:00
|
|
|
if (this._omitCallTracking || this._mode === 'recording')
|
2021-10-01 12:07:35 -07:00
|
|
|
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()) {
|
2023-02-22 21:08:47 -08:00
|
|
|
if (!metadata.location)
|
2021-10-01 12:07:35 -07:00
|
|
|
continue;
|
2023-02-22 21:08:47 -08:00
|
|
|
const { file, line } = metadata.location;
|
2021-10-01 12:07:35 -07:00
|
|
|
let source = this._userSources.get(file);
|
|
|
|
if (!source) {
|
2022-08-15 19:44:46 +02:00
|
|
|
source = { isRecorded: false, label: file, id: file, text: this._readSource(file), highlight: [], language: languageForFile(file) };
|
2021-10-01 12:07:35 -07:00
|
|
|
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;
|
2022-08-15 19:44:46 +02:00
|
|
|
fileToSelect = source.id;
|
2021-10-01 12:07:35 -07:00
|
|
|
}
|
|
|
|
}
|
|
|
|
this._pushAllSources();
|
|
|
|
if (fileToSelect)
|
2022-05-04 17:16:24 +01:00
|
|
|
this._recorderApp?.setFileIfNeeded(fileToSelect);
|
2021-10-01 12:07:35 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
private _pushAllSources() {
|
|
|
|
this._recorderApp?.setSources([...this._recorderSources, ...this._userSources.values()]);
|
|
|
|
}
|
|
|
|
|
|
|
|
async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata) {
|
|
|
|
}
|
|
|
|
|
2022-01-12 07:37:48 -08:00
|
|
|
async onCallLog(sdkObject: SdkObject, metadata: CallMetadata, logName: string, message: string): Promise<void> {
|
2021-10-01 12:07:35 -07:00
|
|
|
this.updateCallLog([metadata]);
|
|
|
|
}
|
|
|
|
|
|
|
|
updateCallLog(metadatas: CallMetadata[]) {
|
|
|
|
if (this._mode === 'recording')
|
|
|
|
return;
|
|
|
|
const logs: CallLog[] = [];
|
|
|
|
for (const metadata of metadatas) {
|
2021-12-16 17:17:24 -08:00
|
|
|
if (!metadata.method || metadata.internal)
|
2021-10-01 12:07:35 -07:00
|
|
|
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<Page, string>();
|
|
|
|
private _lastPopupOrdinal = 0;
|
2022-12-02 17:33:01 -08:00
|
|
|
private _lastDialogOrdinal = -1;
|
|
|
|
private _lastDownloadOrdinal = -1;
|
2021-10-01 12:07:35 -07:00
|
|
|
private _timers = new Set<NodeJS.Timeout>();
|
|
|
|
private _context: BrowserContext;
|
|
|
|
private _params: channels.BrowserContextRecorderSupplementEnableParams;
|
|
|
|
private _recorderSources: Source[];
|
2022-08-08 17:16:13 -07:00
|
|
|
private _throttledOutputFile: ThrottledFile | null = null;
|
|
|
|
private _orderedLanguages: LanguageGenerator[] = [];
|
2022-11-08 12:04:43 -08:00
|
|
|
private _testIdAttributeName: string = 'data-testid';
|
2023-03-17 16:16:08 +01:00
|
|
|
private _listeners: RegisteredListener[] = [];
|
2021-10-01 12:07:35 -07:00
|
|
|
|
|
|
|
constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) {
|
|
|
|
super();
|
|
|
|
this._context = context;
|
|
|
|
this._params = params;
|
|
|
|
this._recorderSources = [];
|
2022-08-08 17:16:13 -07:00
|
|
|
const language = params.language || context._browser.options.sdkLanguage;
|
|
|
|
this.setOutput(language, params.outputFile);
|
2022-08-05 19:34:57 -07:00
|
|
|
const generator = new CodeGenerator(context._browser.options.name, params.mode === 'recording', params.launchOptions || {}, params.contextOptions || {}, params.device, params.saveStorage);
|
2021-10-01 12:07:35 -07:00
|
|
|
generator.on('change', () => {
|
|
|
|
this._recorderSources = [];
|
2022-08-08 17:16:13 -07:00
|
|
|
for (const languageGenerator of this._orderedLanguages) {
|
2022-11-01 18:02:14 -07:00
|
|
|
const { header, footer, actions, text } = generator.generateStructure(languageGenerator);
|
2021-10-01 12:07:35 -07:00
|
|
|
const source: Source = {
|
2022-05-04 17:16:24 +01:00
|
|
|
isRecorded: true,
|
2022-08-15 19:44:46 +02:00
|
|
|
label: languageGenerator.name,
|
|
|
|
group: languageGenerator.groupName,
|
|
|
|
id: languageGenerator.id,
|
2022-11-01 18:02:14 -07:00
|
|
|
text,
|
|
|
|
header,
|
|
|
|
footer,
|
|
|
|
actions,
|
2021-10-01 12:07:35 -07:00
|
|
|
language: languageGenerator.highlighter,
|
|
|
|
highlight: []
|
|
|
|
};
|
2022-11-01 18:02:14 -07:00
|
|
|
source.revealLine = text.split('\n').length - 1;
|
2021-10-01 12:07:35 -07:00
|
|
|
this._recorderSources.push(source);
|
2022-08-08 17:16:13 -07:00
|
|
|
if (languageGenerator === this._orderedLanguages[0])
|
|
|
|
this._throttledOutputFile?.setContent(source.text);
|
2021-10-01 12:07:35 -07:00
|
|
|
}
|
|
|
|
this.emit(ContextRecorder.Events.Change, {
|
|
|
|
sources: this._recorderSources,
|
2022-08-15 19:44:46 +02:00
|
|
|
primaryFileName: this._orderedLanguages[0].id
|
2021-10-01 12:07:35 -07:00
|
|
|
});
|
|
|
|
});
|
2022-08-08 17:16:13 -07:00
|
|
|
context.on(BrowserContext.Events.BeforeClose, () => {
|
|
|
|
this._throttledOutputFile?.flush();
|
|
|
|
});
|
2023-03-17 16:16:08 +01:00
|
|
|
this._listeners.push(eventsHelper.addEventListener(process, 'exit', () => {
|
2022-08-08 17:16:13 -07:00
|
|
|
this._throttledOutputFile?.flush();
|
2023-03-17 16:16:08 +01:00
|
|
|
}));
|
2021-10-01 12:07:35 -07:00
|
|
|
this._generator = generator;
|
|
|
|
}
|
|
|
|
|
2022-10-25 12:55:20 -04:00
|
|
|
setOutput(codegenId: string, outputFile?: string) {
|
2022-08-08 17:16:13 -07:00
|
|
|
const languages = new Set([
|
|
|
|
new JavaLanguageGenerator(),
|
2022-08-25 11:58:58 +02:00
|
|
|
new JavaScriptLanguageGenerator(/* isPlaywrightTest */false),
|
|
|
|
new JavaScriptLanguageGenerator(/* isPlaywrightTest */true),
|
|
|
|
new PythonLanguageGenerator(/* isAsync */false, /* isPytest */true),
|
|
|
|
new PythonLanguageGenerator(/* isAsync */false, /* isPytest */false),
|
|
|
|
new PythonLanguageGenerator(/* isAsync */true, /* isPytest */false),
|
|
|
|
new CSharpLanguageGenerator('mstest'),
|
|
|
|
new CSharpLanguageGenerator('nunit'),
|
|
|
|
new CSharpLanguageGenerator('library'),
|
2022-08-08 17:16:13 -07:00
|
|
|
]);
|
2022-10-25 12:55:20 -04:00
|
|
|
const primaryLanguage = [...languages].find(l => l.id === codegenId);
|
2022-08-08 17:16:13 -07:00
|
|
|
if (!primaryLanguage)
|
2022-10-25 12:55:20 -04:00
|
|
|
throw new Error(`\n===============================\nUnsupported language: '${codegenId}'\n===============================\n`);
|
2022-08-08 17:16:13 -07:00
|
|
|
languages.delete(primaryLanguage);
|
|
|
|
this._orderedLanguages = [primaryLanguage, ...languages];
|
|
|
|
this._throttledOutputFile = outputFile ? new ThrottledFile(outputFile) : null;
|
|
|
|
this._generator?.restart();
|
|
|
|
}
|
|
|
|
|
2022-10-05 16:59:34 -08:00
|
|
|
languageName(id?: string): Language {
|
|
|
|
for (const lang of this._orderedLanguages) {
|
|
|
|
if (!id || lang.id === id)
|
|
|
|
return lang.highlighter;
|
|
|
|
}
|
|
|
|
return 'javascript';
|
|
|
|
}
|
|
|
|
|
2021-10-01 12:07:35 -07:00
|
|
|
async install() {
|
2023-04-04 13:13:52 -07:00
|
|
|
this._context.on(BrowserContext.Events.Page, page => this._onPage(page));
|
2021-10-01 12:07:35 -07:00
|
|
|
for (const page of this._context.pages())
|
|
|
|
this._onPage(page);
|
|
|
|
|
|
|
|
// Input actions that potentially lead to navigation are intercepted on the page and are
|
|
|
|
// performed by the Playwright.
|
2022-05-09 06:44:20 -08:00
|
|
|
await this._context.exposeBinding('__pw_recorderPerformAction', false,
|
2021-10-01 12:07:35 -07:00
|
|
|
(source: BindingSource, action: actions.Action) => this._performAction(source.frame, action));
|
|
|
|
|
|
|
|
// Other non-essential actions are simply being recorded.
|
2022-05-09 06:44:20 -08:00
|
|
|
await this._context.exposeBinding('__pw_recorderRecordAction', false,
|
2021-10-01 12:07:35 -07:00
|
|
|
(source: BindingSource, action: actions.Action) => this._recordAction(source.frame, action));
|
|
|
|
|
2022-03-01 13:56:21 -08:00
|
|
|
await this._context.extendInjectedScript(recorderSource.source);
|
2021-10-01 12:07:35 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
setEnabled(enabled: boolean) {
|
|
|
|
this._generator.setEnabled(enabled);
|
|
|
|
}
|
|
|
|
|
|
|
|
dispose() {
|
|
|
|
for (const timer of this._timers)
|
|
|
|
clearTimeout(timer);
|
|
|
|
this._timers.clear();
|
2023-03-17 16:16:08 +01:00
|
|
|
eventsHelper.removeEventListeners(this._listeners);
|
2021-10-01 12:07:35 -07:00
|
|
|
}
|
|
|
|
|
2020-12-28 14:50:12 -08:00
|
|
|
private async _onPage(page: Page) {
|
|
|
|
// First page is called page, others are called popup1, popup2, etc.
|
2021-01-24 19:21:19 -08:00
|
|
|
const frame = page.mainFrame();
|
2020-12-28 14:50:12 -08:00
|
|
|
page.on('close', () => {
|
|
|
|
this._generator.addAction({
|
2022-02-04 19:27:45 -08:00
|
|
|
frame: this._describeMainFrame(page),
|
2020-12-28 14:50:12 -08:00
|
|
|
committed: true,
|
|
|
|
action: {
|
|
|
|
name: 'closePage',
|
|
|
|
signals: [],
|
|
|
|
}
|
|
|
|
});
|
2022-02-04 19:27:45 -08:00
|
|
|
this._pageAliases.delete(page);
|
2020-12-28 14:50:12 -08:00
|
|
|
});
|
2022-06-17 21:17:30 -07:00
|
|
|
frame.on(Frame.Events.InternalNavigation, event => {
|
|
|
|
if (event.isPublic)
|
|
|
|
this._onFrameNavigated(frame, page);
|
|
|
|
});
|
2021-01-24 19:21:19 -08:00
|
|
|
page.on(Page.Events.Download, () => this._onDownload(page));
|
2023-04-04 13:13:52 -07:00
|
|
|
page.on(Page.Events.Dialog, () => this._onDialog(page));
|
2020-12-28 14:50:12 -08:00
|
|
|
const suffix = this._pageAliases.size ? String(++this._lastPopupOrdinal) : '';
|
|
|
|
const pageAlias = 'page' + suffix;
|
|
|
|
this._pageAliases.set(page, pageAlias);
|
|
|
|
|
2021-04-02 11:15:07 -07:00
|
|
|
if (page.opener()) {
|
|
|
|
this._onPopup(page.opener()!, page);
|
|
|
|
} else {
|
2020-12-28 14:50:12 -08:00
|
|
|
this._generator.addAction({
|
2022-02-04 19:27:45 -08:00
|
|
|
frame: this._describeMainFrame(page),
|
2020-12-28 14:50:12 -08:00
|
|
|
committed: true,
|
|
|
|
action: {
|
|
|
|
name: 'openPage',
|
2021-01-24 19:21:19 -08:00
|
|
|
url: page.mainFrame().url(),
|
2020-12-28 14:50:12 -08:00
|
|
|
signals: [],
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-10-01 12:07:35 -07:00
|
|
|
clearScript(): void {
|
2021-01-31 16:37:13 -08:00
|
|
|
this._generator.restart();
|
2022-08-05 19:34:57 -07:00
|
|
|
if (this._params.mode === 'recording') {
|
2021-01-31 16:37:13 -08:00
|
|
|
for (const page of this._context.pages())
|
|
|
|
this._onFrameNavigated(page.mainFrame(), page);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-02-04 19:27:45 -08:00
|
|
|
private _describeMainFrame(page: Page): actions.FrameDescription {
|
|
|
|
return {
|
|
|
|
pageAlias: this._pageAliases.get(page)!,
|
|
|
|
isMainFrame: true,
|
|
|
|
url: page.mainFrame().url(),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
private async _describeFrame(frame: Frame): Promise<actions.FrameDescription> {
|
|
|
|
const page = frame._page;
|
|
|
|
const pageAlias = this._pageAliases.get(page)!;
|
|
|
|
const chain: Frame[] = [];
|
|
|
|
for (let ancestor: Frame | null = frame; ancestor; ancestor = ancestor.parentFrame())
|
|
|
|
chain.push(ancestor);
|
|
|
|
chain.reverse();
|
|
|
|
|
|
|
|
if (chain.length === 1)
|
|
|
|
return this._describeMainFrame(page);
|
|
|
|
|
|
|
|
const hasUniqueName = page.frames().filter(f => f.name() === frame.name()).length === 1;
|
|
|
|
const fallback: actions.FrameDescription = {
|
|
|
|
pageAlias,
|
|
|
|
isMainFrame: false,
|
|
|
|
url: frame.url(),
|
|
|
|
name: frame.name() && hasUniqueName ? frame.name() : undefined,
|
|
|
|
};
|
|
|
|
if (chain.length > 3)
|
|
|
|
return fallback;
|
|
|
|
|
|
|
|
const selectorPromises: Promise<string | undefined>[] = [];
|
|
|
|
for (let i = 0; i < chain.length - 1; i++)
|
|
|
|
selectorPromises.push(this._findFrameSelector(chain[i + 1], chain[i]));
|
|
|
|
|
|
|
|
const result = await raceAgainstTimeout(() => Promise.all(selectorPromises), 2000);
|
|
|
|
if (!result.timedOut && result.result.every(selector => !!selector)) {
|
|
|
|
return {
|
|
|
|
...fallback,
|
|
|
|
selectorsChain: result.result as string[],
|
|
|
|
};
|
|
|
|
}
|
|
|
|
return fallback;
|
|
|
|
}
|
|
|
|
|
|
|
|
private async _findFrameSelector(frame: Frame, parent: Frame): Promise<string | undefined> {
|
|
|
|
try {
|
|
|
|
const frameElement = await frame.frameElement();
|
|
|
|
if (!frameElement)
|
|
|
|
return;
|
|
|
|
const utility = await parent._utilityContext();
|
|
|
|
const injected = await utility.injectedScript();
|
2022-11-08 12:04:43 -08:00
|
|
|
const selector = await injected.evaluate((injected, element) => injected.generateSelector(element as Element, this._testIdAttributeName), frameElement);
|
2022-02-04 19:27:45 -08:00
|
|
|
return selector;
|
|
|
|
} catch (e) {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-12-28 14:50:12 -08:00
|
|
|
private async _performAction(frame: Frame, action: actions.Action) {
|
2021-04-20 18:45:52 -07:00
|
|
|
// Commit last action so that no further signals are added to it.
|
|
|
|
this._generator.commitLastAction();
|
|
|
|
|
2022-02-04 19:27:45 -08:00
|
|
|
const frameDescription = await this._describeFrame(frame);
|
2020-12-28 14:50:12 -08:00
|
|
|
const actionInContext: ActionInContext = {
|
2022-02-04 19:27:45 -08:00
|
|
|
frame: frameDescription,
|
2020-12-28 14:50:12 -08:00
|
|
|
action
|
|
|
|
};
|
2021-08-18 07:27:45 -07:00
|
|
|
|
|
|
|
const perform = async (action: string, params: any, cb: (callMetadata: CallMetadata) => Promise<any>) => {
|
|
|
|
const callMetadata: CallMetadata = {
|
|
|
|
id: `call@${createGuid()}`,
|
|
|
|
apiName: 'frame.' + action,
|
|
|
|
objectId: frame.guid,
|
|
|
|
pageId: frame._page.guid,
|
|
|
|
frameId: frame.guid,
|
|
|
|
startTime: monotonicTime(),
|
|
|
|
endTime: 0,
|
2023-03-21 10:03:49 -07:00
|
|
|
wallTime: Date.now(),
|
2021-08-18 07:27:45 -07:00
|
|
|
type: 'Frame',
|
|
|
|
method: action,
|
|
|
|
params,
|
|
|
|
log: [],
|
|
|
|
};
|
|
|
|
this._generator.willPerformAction(actionInContext);
|
|
|
|
|
|
|
|
try {
|
|
|
|
await frame.instrumentation.onBeforeCall(frame, callMetadata);
|
|
|
|
await cb(callMetadata);
|
|
|
|
} catch (e) {
|
|
|
|
callMetadata.endTime = monotonicTime();
|
|
|
|
await frame.instrumentation.onAfterCall(frame, callMetadata);
|
|
|
|
this._generator.performedActionFailed(actionInContext);
|
|
|
|
return;
|
2021-01-27 15:57:28 -08:00
|
|
|
}
|
2021-08-18 07:27:45 -07:00
|
|
|
|
|
|
|
callMetadata.endTime = monotonicTime();
|
|
|
|
await frame.instrumentation.onAfterCall(frame, callMetadata);
|
|
|
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
// Commit the action after 5 seconds so that no further signals are added to it.
|
|
|
|
actionInContext.committed = true;
|
|
|
|
this._timers.delete(timer);
|
|
|
|
}, 5000);
|
|
|
|
this._generator.didPerformAction(actionInContext);
|
|
|
|
this._timers.add(timer);
|
|
|
|
};
|
|
|
|
|
|
|
|
const kActionTimeout = 5000;
|
|
|
|
if (action.name === 'click') {
|
|
|
|
const { options } = toClickOptions(action);
|
2022-02-04 19:27:45 -08:00
|
|
|
await perform('click', { selector: action.selector }, callMetadata => frame.click(callMetadata, action.selector, { ...options, timeout: kActionTimeout, strict: true }));
|
2021-08-18 07:27:45 -07:00
|
|
|
}
|
|
|
|
if (action.name === 'press') {
|
|
|
|
const modifiers = toModifiers(action.modifiers);
|
|
|
|
const shortcut = [...modifiers, action.key].join('+');
|
2022-02-04 19:27:45 -08:00
|
|
|
await perform('press', { selector: action.selector, key: shortcut }, callMetadata => frame.press(callMetadata, action.selector, shortcut, { timeout: kActionTimeout, strict: true }));
|
2021-08-18 07:27:45 -07:00
|
|
|
}
|
|
|
|
if (action.name === 'check')
|
2022-02-04 19:27:45 -08:00
|
|
|
await perform('check', { selector: action.selector }, callMetadata => frame.check(callMetadata, action.selector, { timeout: kActionTimeout, strict: true }));
|
2021-08-18 07:27:45 -07:00
|
|
|
if (action.name === 'uncheck')
|
2022-02-04 19:27:45 -08:00
|
|
|
await perform('uncheck', { selector: action.selector }, callMetadata => frame.uncheck(callMetadata, action.selector, { timeout: kActionTimeout, strict: true }));
|
2021-08-18 07:27:45 -07:00
|
|
|
if (action.name === 'select') {
|
|
|
|
const values = action.options.map(value => ({ value }));
|
2022-02-04 19:27:45 -08:00
|
|
|
await perform('selectOption', { selector: action.selector, values }, callMetadata => frame.selectOption(callMetadata, action.selector, [], values, { timeout: kActionTimeout, strict: true }));
|
2020-12-28 14:50:12 -08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private async _recordAction(frame: Frame, action: actions.Action) {
|
2021-04-20 18:45:52 -07:00
|
|
|
// Commit last action so that no further signals are added to it.
|
|
|
|
this._generator.commitLastAction();
|
|
|
|
|
2022-02-04 19:27:45 -08:00
|
|
|
const frameDescription = await this._describeFrame(frame);
|
|
|
|
const actionInContext: ActionInContext = {
|
|
|
|
frame: frameDescription,
|
2020-12-28 14:50:12 -08:00
|
|
|
action
|
2022-02-04 19:27:45 -08:00
|
|
|
};
|
|
|
|
this._generator.addAction(actionInContext);
|
2020-12-28 14:50:12 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
private _onFrameNavigated(frame: Frame, page: Page) {
|
|
|
|
const pageAlias = this._pageAliases.get(page);
|
|
|
|
this._generator.signal(pageAlias!, frame, { name: 'navigation', url: frame.url() });
|
|
|
|
}
|
|
|
|
|
|
|
|
private _onPopup(page: Page, popup: Page) {
|
|
|
|
const pageAlias = this._pageAliases.get(page)!;
|
|
|
|
const popupAlias = this._pageAliases.get(popup)!;
|
|
|
|
this._generator.signal(pageAlias, page.mainFrame(), { name: 'popup', popupAlias });
|
|
|
|
}
|
2021-10-01 12:07:35 -07:00
|
|
|
|
2021-01-24 19:21:19 -08:00
|
|
|
private _onDownload(page: Page) {
|
2020-12-28 14:50:12 -08:00
|
|
|
const pageAlias = this._pageAliases.get(page)!;
|
2022-12-02 17:33:01 -08:00
|
|
|
++this._lastDownloadOrdinal;
|
|
|
|
this._generator.signal(pageAlias, page.mainFrame(), { name: 'download', downloadAlias: this._lastDownloadOrdinal ? String(this._lastDownloadOrdinal) : '' });
|
2020-12-28 14:50:12 -08:00
|
|
|
}
|
|
|
|
|
2021-01-24 19:21:19 -08:00
|
|
|
private _onDialog(page: Page) {
|
2020-12-28 14:50:12 -08:00
|
|
|
const pageAlias = this._pageAliases.get(page)!;
|
2022-12-02 17:33:01 -08:00
|
|
|
++this._lastDialogOrdinal;
|
|
|
|
this._generator.signal(pageAlias, page.mainFrame(), { name: 'dialog', dialogAlias: this._lastDialogOrdinal ? String(this._lastDialogOrdinal) : '' });
|
2020-12-28 14:50:12 -08:00
|
|
|
}
|
|
|
|
}
|
2021-02-12 18:53:46 -08:00
|
|
|
|
|
|
|
function languageForFile(file: string) {
|
|
|
|
if (file.endsWith('.py'))
|
|
|
|
return 'python';
|
|
|
|
if (file.endsWith('.java'))
|
|
|
|
return 'java';
|
|
|
|
if (file.endsWith('.cs'))
|
|
|
|
return 'csharp';
|
|
|
|
return 'javascript';
|
2021-02-19 09:33:24 -08:00
|
|
|
}
|
2022-02-10 21:23:16 -08:00
|
|
|
|
|
|
|
class ThrottledFile {
|
|
|
|
private _file: string;
|
|
|
|
private _timer: NodeJS.Timeout | undefined;
|
|
|
|
private _text: string | undefined;
|
|
|
|
|
|
|
|
constructor(file: string) {
|
|
|
|
this._file = file;
|
|
|
|
}
|
|
|
|
|
|
|
|
setContent(text: string) {
|
|
|
|
this._text = text;
|
|
|
|
if (!this._timer)
|
2022-08-08 17:16:13 -07:00
|
|
|
this._timer = setTimeout(() => this.flush(), 250);
|
2022-02-10 21:23:16 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
flush(): void {
|
|
|
|
if (this._timer) {
|
|
|
|
clearTimeout(this._timer);
|
|
|
|
this._timer = undefined;
|
|
|
|
}
|
|
|
|
if (this._text)
|
|
|
|
fs.writeFileSync(this._file, this._text);
|
|
|
|
this._text = undefined;
|
|
|
|
}
|
|
|
|
}
|
2022-11-07 13:53:15 -08:00
|
|
|
|
|
|
|
function isScreenshotCommand(metadata: CallMetadata) {
|
2023-02-13 13:15:55 -08:00
|
|
|
return metadata.method.toLowerCase().includes('screenshot');
|
2022-11-07 13:53:15 -08:00
|
|
|
}
|