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-01-24 19:21:19 -08:00
|
|
|
import * as actions from './recorder/recorderActions';
|
2021-01-25 14:49:26 -08:00
|
|
|
import type * as channels from '../../protocol/channels';
|
|
|
|
import { CodeGenerator, ActionInContext } from './recorder/codeGenerator';
|
2021-01-27 13:19:36 -08:00
|
|
|
import { describeFrame, toClickOptions, toModifiers } from './recorder/utils';
|
2021-01-24 19:21:19 -08:00
|
|
|
import { Page } from '../page';
|
|
|
|
import { Frame } from '../frames';
|
|
|
|
import { BrowserContext } from '../browserContext';
|
|
|
|
import { LanguageGenerator } from './recorder/language';
|
|
|
|
import { JavaScriptLanguageGenerator } from './recorder/javascript';
|
|
|
|
import { CSharpLanguageGenerator } from './recorder/csharp';
|
|
|
|
import { PythonLanguageGenerator } from './recorder/python';
|
|
|
|
import { ProgressController } from '../progress';
|
|
|
|
import * as recorderSource from '../../generated/recorderSource';
|
2021-01-25 14:49:26 -08:00
|
|
|
import * as consoleApiSource from '../../generated/consoleApiSource';
|
2021-01-27 17:02:09 -08:00
|
|
|
import { BufferedOutput, FileOutput, FlushingTerminalOutput, OutputMultiplexer, RecorderOutput, TerminalOutput, Writable } from './recorder/outputs';
|
|
|
|
import type { State, UIState } from './recorder/state';
|
2020-12-28 14:50:12 -08:00
|
|
|
|
|
|
|
type BindingSource = { frame: Frame, page: Page };
|
2021-01-25 19:01:04 -08:00
|
|
|
type App = 'codegen' | 'debug' | 'pause';
|
2021-01-25 14:49:26 -08:00
|
|
|
|
|
|
|
const symbol = Symbol('RecorderSupplement');
|
|
|
|
|
2020-12-28 14:50:12 -08:00
|
|
|
|
2021-01-24 19:21:19 -08:00
|
|
|
export class RecorderSupplement {
|
2020-12-28 14:50:12 -08:00
|
|
|
private _generator: CodeGenerator;
|
|
|
|
private _pageAliases = new Map<Page, string>();
|
|
|
|
private _lastPopupOrdinal = 0;
|
|
|
|
private _lastDialogOrdinal = 0;
|
|
|
|
private _timers = new Set<NodeJS.Timeout>();
|
2021-01-24 19:21:19 -08:00
|
|
|
private _context: BrowserContext;
|
2021-01-25 14:49:26 -08:00
|
|
|
private _resumeCallback: (() => void) | null = null;
|
2021-01-27 17:02:09 -08:00
|
|
|
private _recorderUIState: UIState;
|
2021-01-25 14:49:26 -08:00
|
|
|
private _paused = false;
|
2021-01-25 19:01:04 -08:00
|
|
|
private _app: App;
|
2021-01-25 14:49:26 -08:00
|
|
|
private _output: OutputMultiplexer;
|
2021-01-27 17:02:09 -08:00
|
|
|
private _bufferedOutput: BufferedOutput;
|
2021-01-25 14:49:26 -08:00
|
|
|
|
2021-01-25 19:01:04 -08:00
|
|
|
static getOrCreate(context: BrowserContext, app: App, params: channels.BrowserContextRecorderSupplementEnableParams): Promise<RecorderSupplement> {
|
2021-01-25 14:49:26 -08:00
|
|
|
let recorderPromise = (context as any)[symbol] as Promise<RecorderSupplement>;
|
|
|
|
if (!recorderPromise) {
|
2021-01-25 19:01:04 -08:00
|
|
|
const recorder = new RecorderSupplement(context, app, params);
|
2021-01-25 14:49:26 -08:00
|
|
|
recorderPromise = recorder.install().then(() => recorder);
|
|
|
|
(context as any)[symbol] = recorderPromise;
|
|
|
|
}
|
|
|
|
return recorderPromise;
|
|
|
|
}
|
2021-01-24 19:21:19 -08:00
|
|
|
|
2021-01-25 19:01:04 -08:00
|
|
|
constructor(context: BrowserContext, app: App, params: channels.BrowserContextRecorderSupplementEnableParams) {
|
2021-01-24 19:21:19 -08:00
|
|
|
this._context = context;
|
2021-01-25 19:01:04 -08:00
|
|
|
this._app = app;
|
2021-01-27 17:02:09 -08:00
|
|
|
this._recorderUIState = {
|
|
|
|
mode: app === 'codegen' ? 'recording' : 'none',
|
|
|
|
drawerVisible: false
|
|
|
|
};
|
2021-01-24 19:21:19 -08:00
|
|
|
let languageGenerator: LanguageGenerator;
|
|
|
|
switch (params.language) {
|
|
|
|
case 'javascript': languageGenerator = new JavaScriptLanguageGenerator(); break;
|
|
|
|
case 'csharp': languageGenerator = new CSharpLanguageGenerator(); break;
|
|
|
|
case 'python':
|
|
|
|
case 'python-async': languageGenerator = new PythonLanguageGenerator(params.language === 'python-async'); break;
|
|
|
|
default: throw new Error(`Invalid target: '${params.language}'`);
|
|
|
|
}
|
2021-01-25 14:49:26 -08:00
|
|
|
let highlighterType = params.language;
|
|
|
|
if (highlighterType === 'python-async')
|
|
|
|
highlighterType = 'python';
|
|
|
|
|
|
|
|
const writable: Writable = {
|
|
|
|
write: (text: string) => context.emit(BrowserContext.Events.StdOut, text)
|
|
|
|
};
|
|
|
|
const outputs: RecorderOutput[] = [params.terminal ? new TerminalOutput(writable, highlighterType) : new FlushingTerminalOutput(writable)];
|
2021-01-27 17:02:09 -08:00
|
|
|
this._bufferedOutput = new BufferedOutput(highlighterType);
|
|
|
|
outputs.push(this._bufferedOutput);
|
2021-01-25 14:49:26 -08:00
|
|
|
if (params.outputFile)
|
|
|
|
outputs.push(new FileOutput(params.outputFile));
|
|
|
|
this._output = new OutputMultiplexer(outputs);
|
2021-01-25 19:01:04 -08:00
|
|
|
this._output.setEnabled(app === 'codegen');
|
2021-01-25 14:49:26 -08:00
|
|
|
context.on(BrowserContext.Events.BeforeClose, () => this._output.flush());
|
|
|
|
|
2021-01-29 16:00:56 -08:00
|
|
|
const generator = new CodeGenerator(context._browser.options.name, app === 'codegen', params.launchOptions || {}, params.contextOptions || {}, this._output, languageGenerator, params.device, params.saveStorage);
|
2021-01-24 19:21:19 -08:00
|
|
|
this._generator = generator;
|
|
|
|
}
|
2020-12-28 14:50:12 -08:00
|
|
|
|
2021-01-24 19:21:19 -08:00
|
|
|
async install() {
|
|
|
|
this._context.on('page', page => this._onPage(page));
|
|
|
|
for (const page of this._context.pages())
|
|
|
|
this._onPage(page);
|
2020-12-28 14:50:12 -08:00
|
|
|
|
2021-01-24 19:21:19 -08:00
|
|
|
this._context.once('close', () => {
|
|
|
|
for (const timer of this._timers)
|
|
|
|
clearTimeout(timer);
|
|
|
|
this._timers.clear();
|
|
|
|
});
|
2020-12-28 14:50:12 -08:00
|
|
|
|
|
|
|
// Input actions that potentially lead to navigation are intercepted on the page and are
|
|
|
|
// performed by the Playwright.
|
2021-01-24 19:21:19 -08:00
|
|
|
await this._context.exposeBinding('playwrightRecorderPerformAction', false,
|
|
|
|
(source: BindingSource, action: actions.Action) => this._performAction(source.frame, action));
|
2020-12-28 14:50:12 -08:00
|
|
|
|
|
|
|
// Other non-essential actions are simply being recorded.
|
2021-01-24 19:21:19 -08:00
|
|
|
await this._context.exposeBinding('playwrightRecorderRecordAction', false,
|
|
|
|
(source: BindingSource, action: actions.Action) => this._recordAction(source.frame, action));
|
2020-12-28 14:50:12 -08:00
|
|
|
|
|
|
|
// Commits last action so that no further signals are added to it.
|
2021-01-24 19:21:19 -08:00
|
|
|
await this._context.exposeBinding('playwrightRecorderCommitAction', false,
|
|
|
|
(source: BindingSource, action: actions.Action) => this._generator.commitLastAction());
|
2020-12-28 14:50:12 -08:00
|
|
|
|
2021-01-27 17:02:09 -08:00
|
|
|
await this._context.exposeBinding('playwrightRecorderClearScript', false,
|
|
|
|
(source: BindingSource, action: actions.Action) => {
|
|
|
|
this._bufferedOutput.clear();
|
|
|
|
this._generator.restart();
|
|
|
|
if (this._app === 'codegen') {
|
|
|
|
for (const page of this._context.pages())
|
|
|
|
this._onFrameNavigated(page.mainFrame(), page);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
await this._context.exposeBinding('playwrightRecorderState', false, ({ page }) => {
|
|
|
|
const state: State = {
|
|
|
|
isController: page === this._context.pages()[0],
|
|
|
|
uiState: this._recorderUIState,
|
|
|
|
canResume: this._app === 'pause',
|
|
|
|
isPaused: this._paused,
|
|
|
|
codegenScript: this._bufferedOutput.buffer()
|
2021-01-25 14:49:26 -08:00
|
|
|
};
|
2021-01-27 17:02:09 -08:00
|
|
|
return state;
|
2021-01-25 14:49:26 -08:00
|
|
|
});
|
|
|
|
|
2021-01-27 17:02:09 -08:00
|
|
|
await this._context.exposeBinding('playwrightRecorderSetUIState', false, (source, state: UIState) => {
|
|
|
|
const isController = source.page === this._context.pages()[0];
|
|
|
|
if (isController)
|
|
|
|
this._recorderUIState = { ...this._recorderUIState, ...state };
|
|
|
|
else
|
|
|
|
this._recorderUIState = { ...this._recorderUIState, mode: state.mode };
|
2021-01-25 14:49:26 -08:00
|
|
|
this._output.setEnabled(state.mode === 'recording');
|
|
|
|
});
|
|
|
|
|
|
|
|
await this._context.exposeBinding('playwrightRecorderResume', false, () => {
|
|
|
|
if (this._resumeCallback) {
|
|
|
|
this._resumeCallback();
|
|
|
|
this._resumeCallback = null;
|
|
|
|
}
|
|
|
|
this._paused = false;
|
|
|
|
});
|
|
|
|
|
2021-01-24 19:21:19 -08:00
|
|
|
await this._context.extendInjectedScript(recorderSource.source);
|
2021-01-25 14:49:26 -08:00
|
|
|
await this._context.extendInjectedScript(consoleApiSource.source);
|
|
|
|
}
|
|
|
|
|
|
|
|
async pause() {
|
|
|
|
this._paused = true;
|
|
|
|
return new Promise(f => this._resumeCallback = f);
|
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._pageAliases.delete(page);
|
|
|
|
this._generator.addAction({
|
|
|
|
pageAlias,
|
2021-01-27 13:19:36 -08:00
|
|
|
...describeFrame(page.mainFrame()),
|
2020-12-28 14:50:12 -08:00
|
|
|
committed: true,
|
|
|
|
action: {
|
|
|
|
name: 'closePage',
|
|
|
|
signals: [],
|
|
|
|
}
|
|
|
|
});
|
|
|
|
});
|
2021-01-24 19:21:19 -08:00
|
|
|
frame.on(Frame.Events.Navigation, () => this._onFrameNavigated(frame, page));
|
|
|
|
page.on(Page.Events.Download, () => this._onDownload(page));
|
|
|
|
page.on(Page.Events.Popup, popup => this._onPopup(page, popup));
|
|
|
|
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);
|
|
|
|
|
|
|
|
const isPopup = !!await page.opener();
|
|
|
|
// Could happen due to the await above.
|
|
|
|
if (page.isClosed())
|
|
|
|
return;
|
|
|
|
if (!isPopup) {
|
|
|
|
this._generator.addAction({
|
|
|
|
pageAlias,
|
2021-01-27 13:19:36 -08:00
|
|
|
...describeFrame(page.mainFrame()),
|
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: [],
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private async _performAction(frame: Frame, action: actions.Action) {
|
2021-01-24 19:21:19 -08:00
|
|
|
const page = frame._page;
|
|
|
|
const controller = new ProgressController();
|
2020-12-28 14:50:12 -08:00
|
|
|
const actionInContext: ActionInContext = {
|
|
|
|
pageAlias: this._pageAliases.get(page)!,
|
2021-01-27 13:19:36 -08:00
|
|
|
...describeFrame(frame),
|
2020-12-28 14:50:12 -08:00
|
|
|
action
|
|
|
|
};
|
|
|
|
this._generator.willPerformAction(actionInContext);
|
2021-01-27 15:57:28 -08:00
|
|
|
try {
|
|
|
|
const kActionTimeout = 5000;
|
|
|
|
if (action.name === 'click') {
|
|
|
|
const { options } = toClickOptions(action);
|
|
|
|
await frame.click(controller, action.selector, { ...options, timeout: kActionTimeout });
|
|
|
|
}
|
|
|
|
if (action.name === 'press') {
|
|
|
|
const modifiers = toModifiers(action.modifiers);
|
|
|
|
const shortcut = [...modifiers, action.key].join('+');
|
|
|
|
await frame.press(controller, action.selector, shortcut, { timeout: kActionTimeout });
|
|
|
|
}
|
|
|
|
if (action.name === 'check')
|
|
|
|
await frame.check(controller, action.selector, { timeout: kActionTimeout });
|
|
|
|
if (action.name === 'uncheck')
|
|
|
|
await frame.uncheck(controller, action.selector, { timeout: kActionTimeout });
|
|
|
|
if (action.name === 'select')
|
|
|
|
await frame.selectOption(controller, action.selector, [], action.options.map(value => ({ value })), { timeout: kActionTimeout });
|
|
|
|
} catch (e) {
|
|
|
|
this._generator.performedActionFailed(actionInContext);
|
|
|
|
return;
|
2020-12-28 14:50:12 -08:00
|
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
actionInContext.committed = true;
|
|
|
|
this._timers.delete(timer);
|
|
|
|
}, 5000);
|
|
|
|
this._generator.didPerformAction(actionInContext);
|
|
|
|
this._timers.add(timer);
|
|
|
|
}
|
|
|
|
|
|
|
|
private async _recordAction(frame: Frame, action: actions.Action) {
|
|
|
|
this._generator.addAction({
|
2021-01-24 19:21:19 -08:00
|
|
|
pageAlias: this._pageAliases.get(frame._page)!,
|
2021-01-27 13:19:36 -08:00
|
|
|
...describeFrame(frame),
|
2020-12-28 14:50:12 -08:00
|
|
|
action
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
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-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)!;
|
|
|
|
this._generator.signal(pageAlias, page.mainFrame(), { name: 'download' });
|
|
|
|
}
|
|
|
|
|
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)!;
|
|
|
|
this._generator.signal(pageAlias, page.mainFrame(), { name: 'dialog', dialogAlias: String(++this._lastDialogOrdinal) });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|