mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
chore: split recorder into files (#32345)
This commit is contained in:
parent
bc87467b25
commit
a1df11011c
@ -20,10 +20,3 @@
|
|||||||
./electron/
|
./electron/
|
||||||
./firefox/
|
./firefox/
|
||||||
./webkit/
|
./webkit/
|
||||||
|
|
||||||
[recorder.ts]
|
|
||||||
./codegen/codeGenerator.ts
|
|
||||||
./codegen/languages.ts
|
|
||||||
|
|
||||||
[recorderRunner.ts]
|
|
||||||
./codegen/language.ts
|
|
||||||
|
|||||||
@ -15,8 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { BrowserContextOptions } from '../../../types/types';
|
import type { BrowserContextOptions } from '../../../types/types';
|
||||||
import type { ActionInContext } from './codeGenerator';
|
import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types';
|
||||||
import type { Language, LanguageGenerator, LanguageGeneratorOptions } from './language';
|
|
||||||
import { sanitizeDeviceOptions, toClickOptions, toKeyboardModifiers, toSignalMap } from './language';
|
import { sanitizeDeviceOptions, toClickOptions, toKeyboardModifiers, toSignalMap } from './language';
|
||||||
import { escapeWithQuotes, asLocator } from '../../utils';
|
import { escapeWithQuotes, asLocator } from '../../utils';
|
||||||
import { deviceDescriptors } from '../deviceDescriptors';
|
import { deviceDescriptors } from '../deviceDescriptors';
|
||||||
|
|||||||
@ -16,8 +16,7 @@
|
|||||||
|
|
||||||
import type { BrowserContextOptions } from '../../../types/types';
|
import type { BrowserContextOptions } from '../../../types/types';
|
||||||
import type * as types from '../types';
|
import type * as types from '../types';
|
||||||
import type { ActionInContext } from './codeGenerator';
|
import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types';
|
||||||
import type { Language, LanguageGenerator, LanguageGeneratorOptions } from './language';
|
|
||||||
import { toClickOptions, toKeyboardModifiers, toSignalMap } from './language';
|
import { toClickOptions, toKeyboardModifiers, toSignalMap } from './language';
|
||||||
import { deviceDescriptors } from '../deviceDescriptors';
|
import { deviceDescriptors } from '../deviceDescriptors';
|
||||||
import { JavaScriptFormatter } from './javascript';
|
import { JavaScriptFormatter } from './javascript';
|
||||||
|
|||||||
@ -15,8 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { BrowserContextOptions } from '../../../types/types';
|
import type { BrowserContextOptions } from '../../../types/types';
|
||||||
import type { ActionInContext } from './codeGenerator';
|
import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types';
|
||||||
import type { Language, LanguageGenerator, LanguageGeneratorOptions } from './language';
|
|
||||||
import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptions } from './language';
|
import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptions } from './language';
|
||||||
import { deviceDescriptors } from '../deviceDescriptors';
|
import { deviceDescriptors } from '../deviceDescriptors';
|
||||||
import { escapeWithQuotes, asLocator } from '../../utils';
|
import { escapeWithQuotes, asLocator } from '../../utils';
|
||||||
|
|||||||
@ -15,8 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { asLocator } from '../../utils';
|
import { asLocator } from '../../utils';
|
||||||
import type { ActionInContext } from './codeGenerator';
|
import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types';
|
||||||
import type { Language, LanguageGenerator, LanguageGeneratorOptions } from './language';
|
|
||||||
|
|
||||||
export class JsonlLanguageGenerator implements LanguageGenerator {
|
export class JsonlLanguageGenerator implements LanguageGenerator {
|
||||||
id = 'jsonl';
|
id = 'jsonl';
|
||||||
|
|||||||
@ -14,32 +14,17 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { BrowserContextOptions, LaunchOptions } from '../../..';
|
import type { BrowserContextOptions } from '../../..';
|
||||||
import type { Language } from '../../utils';
|
|
||||||
import type * as actions from '../recorder/recorderActions';
|
import type * as actions from '../recorder/recorderActions';
|
||||||
import type * as types from '../types';
|
import type * as types from '../types';
|
||||||
import type { ActionInContext } from './codeGenerator';
|
import type { ActionInContext, LanguageGenerator, LanguageGeneratorOptions } from './types';
|
||||||
export type { Language } from '../../utils';
|
|
||||||
|
|
||||||
export type LanguageGeneratorOptions = {
|
export function generateCode(actions: ActionInContext[], languageGenerator: LanguageGenerator, options: LanguageGeneratorOptions) {
|
||||||
browserName: string;
|
const header = languageGenerator.generateHeader(options);
|
||||||
launchOptions: LaunchOptions;
|
const footer = languageGenerator.generateFooter(options.saveStorage);
|
||||||
contextOptions: BrowserContextOptions;
|
const actionTexts = actions.map(a => languageGenerator.generateAction(a)).filter(Boolean);
|
||||||
deviceName?: string;
|
const text = [header, ...actionTexts, footer].join('\n');
|
||||||
saveStorage?: string;
|
return { header, footer, actionTexts, text };
|
||||||
};
|
|
||||||
|
|
||||||
export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text';
|
|
||||||
export type LocatorBase = 'page' | 'locator' | 'frame-locator';
|
|
||||||
|
|
||||||
export interface LanguageGenerator {
|
|
||||||
id: string;
|
|
||||||
groupName: string;
|
|
||||||
name: string;
|
|
||||||
highlighter: Language;
|
|
||||||
generateHeader(options: LanguageGeneratorOptions): string;
|
|
||||||
generateAction(actionInContext: ActionInContext): string;
|
|
||||||
generateFooter(saveStorage: string | undefined): string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeDeviceOptions(device: any, options: BrowserContextOptions): BrowserContextOptions {
|
export function sanitizeDeviceOptions(device: any, options: BrowserContextOptions): BrowserContextOptions {
|
||||||
|
|||||||
@ -15,8 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { BrowserContextOptions } from '../../../types/types';
|
import type { BrowserContextOptions } from '../../../types/types';
|
||||||
import type { ActionInContext } from './codeGenerator';
|
import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types';
|
||||||
import type { Language, LanguageGenerator, LanguageGeneratorOptions } from './language';
|
|
||||||
import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptions } from './language';
|
import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptions } from './language';
|
||||||
import { escapeWithQuotes, toSnakeCase, asLocator } from '../../utils';
|
import { escapeWithQuotes, toSnakeCase, asLocator } from '../../utils';
|
||||||
import { deviceDescriptors } from '../deviceDescriptors';
|
import { deviceDescriptors } from '../deviceDescriptors';
|
||||||
|
|||||||
50
packages/playwright-core/src/server/codegen/types.ts
Normal file
50
packages/playwright-core/src/server/codegen/types.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* 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 { BrowserContextOptions, LaunchOptions } from '../../../types/types';
|
||||||
|
import type * as actions from '../recorder/recorderActions';
|
||||||
|
import type { Language } from '../../utils';
|
||||||
|
export type { Language } from '../../utils';
|
||||||
|
|
||||||
|
export type LanguageGeneratorOptions = {
|
||||||
|
browserName: string;
|
||||||
|
launchOptions: LaunchOptions;
|
||||||
|
contextOptions: BrowserContextOptions;
|
||||||
|
deviceName?: string;
|
||||||
|
saveStorage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FrameDescription = {
|
||||||
|
pageAlias: string;
|
||||||
|
framePath: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionInContext = {
|
||||||
|
frame: FrameDescription;
|
||||||
|
description?: string;
|
||||||
|
action: actions.Action;
|
||||||
|
committed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LanguageGenerator {
|
||||||
|
id: string;
|
||||||
|
groupName: string;
|
||||||
|
name: string;
|
||||||
|
highlighter: Language;
|
||||||
|
generateHeader(options: LanguageGeneratorOptions): string;
|
||||||
|
generateAction(actionInContext: ActionInContext): string;
|
||||||
|
generateFooter(saveStorage: string | undefined): string;
|
||||||
|
}
|
||||||
@ -14,36 +14,21 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import type * as actions from './recorder/recorderActions';
|
|
||||||
import type * as channels from '@protocol/channels';
|
import type * as channels from '@protocol/channels';
|
||||||
import type { ActionInContext, FrameDescription } from './codegen/codeGenerator';
|
|
||||||
import { CodeGenerator } from './codegen/codeGenerator';
|
|
||||||
import { Page } from './page';
|
|
||||||
import { Frame } from './frames';
|
|
||||||
import { BrowserContext } from './browserContext';
|
|
||||||
import * as recorderSource from '../generated/recorderSource';
|
|
||||||
import * as consoleApiSource from '../generated/consoleApiSource';
|
|
||||||
import { EmptyRecorderApp } from './recorder/recorderApp';
|
|
||||||
import type { IRecorderApp } from './recorder/recorderApp';
|
|
||||||
import { RecorderApp } from './recorder/recorderApp';
|
|
||||||
import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation';
|
|
||||||
import type { Point } from '../common/types';
|
|
||||||
import type { CallLog, CallLogStatus, EventData, Mode, OverlayState, Source, UIState } from '@recorder/recorderTypes';
|
import type { CallLog, CallLogStatus, EventData, Mode, OverlayState, Source, UIState } from '@recorder/recorderTypes';
|
||||||
import { isUnderTest, monotonicTime } from '../utils';
|
import * as fs from 'fs';
|
||||||
import { metadataToCallLog } from './recorder/recorderUtils';
|
import type { Point } from '../common/types';
|
||||||
import { Debugger } from './debugger';
|
import * as consoleApiSource from '../generated/consoleApiSource';
|
||||||
import { EventEmitter } from 'events';
|
import { isUnderTest } from '../utils';
|
||||||
import { raceAgainstDeadline } from '../utils/timeoutRunner';
|
|
||||||
import { type Language, type LanguageGenerator } from './codegen/language';
|
|
||||||
import { locatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser';
|
import { locatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser';
|
||||||
import { quoteCSSAttributeValue, eventsHelper, type RegisteredListener } from '../utils';
|
import { BrowserContext } from './browserContext';
|
||||||
import type { Dialog } from './dialog';
|
import { type Language } from './codegen/types';
|
||||||
import { performAction } from './recorderRunner';
|
import { Debugger } from './debugger';
|
||||||
import { languageSet } from './codegen/languages';
|
import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation';
|
||||||
import type { SimpleDomNode } from './injected/simpleDom';
|
import { ContextRecorder, generateFrameSelector } from './recorder/contextRecorder';
|
||||||
|
import type { IRecorderApp } from './recorder/recorderApp';
|
||||||
type BindingSource = { frame: Frame, page: Page };
|
import { EmptyRecorderApp, RecorderApp } from './recorder/recorderApp';
|
||||||
|
import { metadataToCallLog } from './recorder/recorderUtils';
|
||||||
|
|
||||||
const recorderSymbol = Symbol('recorderSymbol');
|
const recorderSymbol = Symbol('recorderSymbol');
|
||||||
|
|
||||||
@ -361,245 +346,8 @@ export class Recorder implements InstrumentationListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ContextRecorder extends EventEmitter {
|
function isScreenshotCommand(metadata: CallMetadata) {
|
||||||
static Events = {
|
return metadata.method.toLowerCase().includes('screenshot');
|
||||||
Change: 'change'
|
|
||||||
};
|
|
||||||
|
|
||||||
private _generator: CodeGenerator;
|
|
||||||
private _pageAliases = new Map<Page, string>();
|
|
||||||
private _lastPopupOrdinal = 0;
|
|
||||||
private _lastDialogOrdinal = -1;
|
|
||||||
private _lastDownloadOrdinal = -1;
|
|
||||||
private _timers = new Set<NodeJS.Timeout>();
|
|
||||||
private _context: BrowserContext;
|
|
||||||
private _params: channels.BrowserContextRecorderSupplementEnableParams;
|
|
||||||
private _recorderSources: Source[];
|
|
||||||
private _throttledOutputFile: ThrottledFile | null = null;
|
|
||||||
private _orderedLanguages: LanguageGenerator[] = [];
|
|
||||||
private _listeners: RegisteredListener[] = [];
|
|
||||||
|
|
||||||
constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) {
|
|
||||||
super();
|
|
||||||
this._context = context;
|
|
||||||
this._params = params;
|
|
||||||
this._recorderSources = [];
|
|
||||||
const language = params.language || context.attribution.playwright.options.sdkLanguage;
|
|
||||||
this.setOutput(language, params.outputFile);
|
|
||||||
const generator = new CodeGenerator(context._browser.options.name, params.mode === 'recording', params.launchOptions || {}, params.contextOptions || {}, params.device, params.saveStorage);
|
|
||||||
generator.on('change', () => {
|
|
||||||
this._recorderSources = [];
|
|
||||||
for (const languageGenerator of this._orderedLanguages) {
|
|
||||||
const { header, footer, actions, text } = generator.generateStructure(languageGenerator);
|
|
||||||
const source: Source = {
|
|
||||||
isRecorded: true,
|
|
||||||
label: languageGenerator.name,
|
|
||||||
group: languageGenerator.groupName,
|
|
||||||
id: languageGenerator.id,
|
|
||||||
text,
|
|
||||||
header,
|
|
||||||
footer,
|
|
||||||
actions,
|
|
||||||
language: languageGenerator.highlighter,
|
|
||||||
highlight: []
|
|
||||||
};
|
|
||||||
source.revealLine = text.split('\n').length - 1;
|
|
||||||
this._recorderSources.push(source);
|
|
||||||
if (languageGenerator === this._orderedLanguages[0])
|
|
||||||
this._throttledOutputFile?.setContent(source.text);
|
|
||||||
}
|
|
||||||
this.emit(ContextRecorder.Events.Change, {
|
|
||||||
sources: this._recorderSources,
|
|
||||||
primaryFileName: this._orderedLanguages[0].id
|
|
||||||
});
|
|
||||||
});
|
|
||||||
context.on(BrowserContext.Events.BeforeClose, () => {
|
|
||||||
this._throttledOutputFile?.flush();
|
|
||||||
});
|
|
||||||
this._listeners.push(eventsHelper.addEventListener(process, 'exit', () => {
|
|
||||||
this._throttledOutputFile?.flush();
|
|
||||||
}));
|
|
||||||
this._generator = generator;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOutput(codegenId: string, outputFile?: string) {
|
|
||||||
const languages = languageSet();
|
|
||||||
const primaryLanguage = [...languages].find(l => l.id === codegenId);
|
|
||||||
if (!primaryLanguage)
|
|
||||||
throw new Error(`\n===============================\nUnsupported language: '${codegenId}'\n===============================\n`);
|
|
||||||
languages.delete(primaryLanguage);
|
|
||||||
this._orderedLanguages = [primaryLanguage, ...languages];
|
|
||||||
this._throttledOutputFile = outputFile ? new ThrottledFile(outputFile) : null;
|
|
||||||
this._generator?.restart();
|
|
||||||
}
|
|
||||||
|
|
||||||
languageName(id?: string): Language {
|
|
||||||
for (const lang of this._orderedLanguages) {
|
|
||||||
if (!id || lang.id === id)
|
|
||||||
return lang.highlighter;
|
|
||||||
}
|
|
||||||
return 'javascript';
|
|
||||||
}
|
|
||||||
|
|
||||||
async install() {
|
|
||||||
this._context.on(BrowserContext.Events.Page, (page: Page) => this._onPage(page));
|
|
||||||
for (const page of this._context.pages())
|
|
||||||
this._onPage(page);
|
|
||||||
this._context.on(BrowserContext.Events.Dialog, (dialog: Dialog) => this._onDialog(dialog.page()));
|
|
||||||
|
|
||||||
// Input actions that potentially lead to navigation are intercepted on the page and are
|
|
||||||
// performed by the Playwright.
|
|
||||||
await this._context.exposeBinding('__pw_recorderPerformAction', false,
|
|
||||||
(source: BindingSource, action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode) => this._performAction(source.frame, action, simpleDomNode));
|
|
||||||
|
|
||||||
// Other non-essential actions are simply being recorded.
|
|
||||||
await this._context.exposeBinding('__pw_recorderRecordAction', false,
|
|
||||||
(source: BindingSource, action: actions.Action, simpleDomNode?: SimpleDomNode) => this._recordAction(source.frame, action, simpleDomNode));
|
|
||||||
|
|
||||||
await this._context.extendInjectedScript(recorderSource.source);
|
|
||||||
}
|
|
||||||
|
|
||||||
setEnabled(enabled: boolean) {
|
|
||||||
this._generator.setEnabled(enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
for (const timer of this._timers)
|
|
||||||
clearTimeout(timer);
|
|
||||||
this._timers.clear();
|
|
||||||
eventsHelper.removeEventListeners(this._listeners);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _onPage(page: Page) {
|
|
||||||
// First page is called page, others are called popup1, popup2, etc.
|
|
||||||
const frame = page.mainFrame();
|
|
||||||
page.on('close', () => {
|
|
||||||
this._generator.addAction({
|
|
||||||
frame: this._describeMainFrame(page),
|
|
||||||
committed: true,
|
|
||||||
action: {
|
|
||||||
name: 'closePage',
|
|
||||||
signals: [],
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this._pageAliases.delete(page);
|
|
||||||
});
|
|
||||||
frame.on(Frame.Events.InternalNavigation, event => {
|
|
||||||
if (event.isPublic)
|
|
||||||
this._onFrameNavigated(frame, page);
|
|
||||||
});
|
|
||||||
page.on(Page.Events.Download, () => this._onDownload(page));
|
|
||||||
const suffix = this._pageAliases.size ? String(++this._lastPopupOrdinal) : '';
|
|
||||||
const pageAlias = 'page' + suffix;
|
|
||||||
this._pageAliases.set(page, pageAlias);
|
|
||||||
|
|
||||||
if (page.opener()) {
|
|
||||||
this._onPopup(page.opener()!, page);
|
|
||||||
} else {
|
|
||||||
this._generator.addAction({
|
|
||||||
frame: this._describeMainFrame(page),
|
|
||||||
committed: true,
|
|
||||||
action: {
|
|
||||||
name: 'openPage',
|
|
||||||
url: page.mainFrame().url(),
|
|
||||||
signals: [],
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearScript(): void {
|
|
||||||
this._generator.restart();
|
|
||||||
if (this._params.mode === 'recording') {
|
|
||||||
for (const page of this._context.pages())
|
|
||||||
this._onFrameNavigated(page.mainFrame(), page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _describeMainFrame(page: Page): FrameDescription {
|
|
||||||
return {
|
|
||||||
pageAlias: this._pageAliases.get(page)!,
|
|
||||||
framePath: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _describeFrame(frame: Frame): Promise<FrameDescription> {
|
|
||||||
return {
|
|
||||||
pageAlias: this._pageAliases.get(frame._page)!,
|
|
||||||
framePath: await generateFrameSelector(frame),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
testIdAttributeName(): string {
|
|
||||||
return this._params.testIdAttributeName || this._context.selectors().testIdAttributeName() || 'data-testid';
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _performAction(frame: Frame, action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode) {
|
|
||||||
// Commit last action so that no further signals are added to it.
|
|
||||||
this._generator.commitLastAction();
|
|
||||||
|
|
||||||
const frameDescription = await this._describeFrame(frame);
|
|
||||||
const actionInContext: ActionInContext = {
|
|
||||||
frame: frameDescription,
|
|
||||||
action,
|
|
||||||
description: undefined, // TODO: generate description based on simple dom node.
|
|
||||||
};
|
|
||||||
|
|
||||||
this._generator.willPerformAction(actionInContext);
|
|
||||||
const success = await performAction(frame, action);
|
|
||||||
if (success) {
|
|
||||||
this._generator.didPerformAction(actionInContext);
|
|
||||||
this._setCommittedAfterTimeout(actionInContext);
|
|
||||||
} else {
|
|
||||||
this._generator.performedActionFailed(actionInContext);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _recordAction(frame: Frame, action: actions.Action, simpleDomNode?: SimpleDomNode) {
|
|
||||||
// Commit last action so that no further signals are added to it.
|
|
||||||
this._generator.commitLastAction();
|
|
||||||
|
|
||||||
const frameDescription = await this._describeFrame(frame);
|
|
||||||
const actionInContext: ActionInContext = {
|
|
||||||
frame: frameDescription,
|
|
||||||
action,
|
|
||||||
description: undefined, // TODO: generate description based on simple dom node.
|
|
||||||
};
|
|
||||||
this._setCommittedAfterTimeout(actionInContext);
|
|
||||||
this._generator.addAction(actionInContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _setCommittedAfterTimeout(actionInContext: ActionInContext) {
|
|
||||||
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);
|
|
||||||
}, isUnderTest() ? 500 : 5000);
|
|
||||||
this._timers.add(timer);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
private _onDownload(page: Page) {
|
|
||||||
const pageAlias = this._pageAliases.get(page)!;
|
|
||||||
++this._lastDownloadOrdinal;
|
|
||||||
this._generator.signal(pageAlias, page.mainFrame(), { name: 'download', downloadAlias: this._lastDownloadOrdinal ? String(this._lastDownloadOrdinal) : '' });
|
|
||||||
}
|
|
||||||
|
|
||||||
private _onDialog(page: Page) {
|
|
||||||
const pageAlias = this._pageAliases.get(page)!;
|
|
||||||
++this._lastDialogOrdinal;
|
|
||||||
this._generator.signal(pageAlias, page.mainFrame(), { name: 'dialog', dialogAlias: this._lastDialogOrdinal ? String(this._lastDialogOrdinal) : '' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function languageForFile(file: string) {
|
function languageForFile(file: string) {
|
||||||
@ -611,70 +359,3 @@ function languageForFile(file: string) {
|
|||||||
return 'csharp';
|
return 'csharp';
|
||||||
return 'javascript';
|
return 'javascript';
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
this._timer = setTimeout(() => this.flush(), 250);
|
|
||||||
}
|
|
||||||
|
|
||||||
flush(): void {
|
|
||||||
if (this._timer) {
|
|
||||||
clearTimeout(this._timer);
|
|
||||||
this._timer = undefined;
|
|
||||||
}
|
|
||||||
if (this._text)
|
|
||||||
fs.writeFileSync(this._file, this._text);
|
|
||||||
this._text = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isScreenshotCommand(metadata: CallMetadata) {
|
|
||||||
return metadata.method.toLowerCase().includes('screenshot');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateFrameSelector(frame: Frame): Promise<string[]> {
|
|
||||||
const selectorPromises: Promise<string>[] = [];
|
|
||||||
while (frame) {
|
|
||||||
const parent = frame.parentFrame();
|
|
||||||
if (!parent)
|
|
||||||
break;
|
|
||||||
selectorPromises.push(generateFrameSelectorInParent(parent, frame));
|
|
||||||
frame = parent;
|
|
||||||
}
|
|
||||||
const result = await Promise.all(selectorPromises);
|
|
||||||
return result.reverse();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateFrameSelectorInParent(parent: Frame, frame: Frame): Promise<string> {
|
|
||||||
const result = await raceAgainstDeadline(async () => {
|
|
||||||
try {
|
|
||||||
const frameElement = await frame.frameElement();
|
|
||||||
if (!frameElement || !parent)
|
|
||||||
return;
|
|
||||||
const utility = await parent._utilityContext();
|
|
||||||
const injected = await utility.injectedScript();
|
|
||||||
const selector = await injected.evaluate((injected, element) => {
|
|
||||||
return injected.generateSelectorSimple(element as Element);
|
|
||||||
}, frameElement);
|
|
||||||
return selector;
|
|
||||||
} catch (e) {
|
|
||||||
return e.toString();
|
|
||||||
}
|
|
||||||
}, monotonicTime() + 2000);
|
|
||||||
if (!result.timedOut && result.result)
|
|
||||||
return result.result;
|
|
||||||
|
|
||||||
if (frame.name())
|
|
||||||
return `iframe[name=${quoteCSSAttributeValue(frame.name())}]`;
|
|
||||||
return `iframe[src=${quoteCSSAttributeValue(frame.url())}]`;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
[*]
|
[*]
|
||||||
../
|
../
|
||||||
|
../codegen/language.ts
|
||||||
|
../codegen/languages.ts
|
||||||
../isomorphic/**
|
../isomorphic/**
|
||||||
../registry/**
|
../registry/**
|
||||||
../../common/
|
../../common/
|
||||||
|
../../generated/recorderSource.ts
|
||||||
../../protocol/
|
../../protocol/
|
||||||
../../utils/**
|
../../utils/**
|
||||||
../../utilsBundle.ts
|
../../utilsBundle.ts
|
||||||
|
|||||||
324
packages/playwright-core/src/server/recorder/contextRecorder.ts
Normal file
324
packages/playwright-core/src/server/recorder/contextRecorder.ts
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* 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 { Source } from '@recorder/recorderTypes';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import * as recorderSource from '../../generated/recorderSource';
|
||||||
|
import { eventsHelper, isUnderTest, monotonicTime, quoteCSSAttributeValue, type RegisteredListener } from '../../utils';
|
||||||
|
import { raceAgainstDeadline } from '../../utils/timeoutRunner';
|
||||||
|
import { BrowserContext } from '../browserContext';
|
||||||
|
import type { ActionInContext, FrameDescription, LanguageGeneratorOptions, Language, LanguageGenerator } from '../codegen/types';
|
||||||
|
import { languageSet } from '../codegen/languages';
|
||||||
|
import type { Dialog } from '../dialog';
|
||||||
|
import { Frame } from '../frames';
|
||||||
|
import type { SimpleDomNode } from '../injected/simpleDom';
|
||||||
|
import { Page } from '../page';
|
||||||
|
import type * as actions from './recorderActions';
|
||||||
|
import { performAction } from './recorderRunner';
|
||||||
|
import { ThrottledFile } from './throttledFile';
|
||||||
|
import { RecorderCollection } from './recorderCollection';
|
||||||
|
import { generateCode } from '../codegen/language';
|
||||||
|
|
||||||
|
type BindingSource = { frame: Frame, page: Page };
|
||||||
|
|
||||||
|
export class ContextRecorder extends EventEmitter {
|
||||||
|
static Events = {
|
||||||
|
Change: 'change'
|
||||||
|
};
|
||||||
|
|
||||||
|
private _collection: RecorderCollection;
|
||||||
|
private _pageAliases = new Map<Page, string>();
|
||||||
|
private _lastPopupOrdinal = 0;
|
||||||
|
private _lastDialogOrdinal = -1;
|
||||||
|
private _lastDownloadOrdinal = -1;
|
||||||
|
private _timers = new Set<NodeJS.Timeout>();
|
||||||
|
private _context: BrowserContext;
|
||||||
|
private _params: channels.BrowserContextRecorderSupplementEnableParams;
|
||||||
|
private _recorderSources: Source[];
|
||||||
|
private _throttledOutputFile: ThrottledFile | null = null;
|
||||||
|
private _orderedLanguages: LanguageGenerator[] = [];
|
||||||
|
private _listeners: RegisteredListener[] = [];
|
||||||
|
|
||||||
|
constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) {
|
||||||
|
super();
|
||||||
|
this._context = context;
|
||||||
|
this._params = params;
|
||||||
|
this._recorderSources = [];
|
||||||
|
const language = params.language || context.attribution.playwright.options.sdkLanguage;
|
||||||
|
this.setOutput(language, params.outputFile);
|
||||||
|
|
||||||
|
// Make a copy of options to modify them later.
|
||||||
|
const languageGeneratorOptions: LanguageGeneratorOptions = {
|
||||||
|
browserName: context._browser.options.name,
|
||||||
|
launchOptions: { headless: false, ...params.launchOptions },
|
||||||
|
contextOptions: { ...params.contextOptions },
|
||||||
|
deviceName: params.device,
|
||||||
|
saveStorage: params.saveStorage,
|
||||||
|
};
|
||||||
|
|
||||||
|
const collection = new RecorderCollection(params.mode === 'recording');
|
||||||
|
collection.on('change', () => {
|
||||||
|
this._recorderSources = [];
|
||||||
|
for (const languageGenerator of this._orderedLanguages) {
|
||||||
|
const { header, footer, actionTexts, text } = generateCode(collection.actions(), languageGenerator, languageGeneratorOptions);
|
||||||
|
const source: Source = {
|
||||||
|
isRecorded: true,
|
||||||
|
label: languageGenerator.name,
|
||||||
|
group: languageGenerator.groupName,
|
||||||
|
id: languageGenerator.id,
|
||||||
|
text,
|
||||||
|
header,
|
||||||
|
footer,
|
||||||
|
actions: actionTexts,
|
||||||
|
language: languageGenerator.highlighter,
|
||||||
|
highlight: []
|
||||||
|
};
|
||||||
|
source.revealLine = text.split('\n').length - 1;
|
||||||
|
this._recorderSources.push(source);
|
||||||
|
if (languageGenerator === this._orderedLanguages[0])
|
||||||
|
this._throttledOutputFile?.setContent(source.text);
|
||||||
|
}
|
||||||
|
this.emit(ContextRecorder.Events.Change, {
|
||||||
|
sources: this._recorderSources,
|
||||||
|
primaryFileName: this._orderedLanguages[0].id
|
||||||
|
});
|
||||||
|
});
|
||||||
|
context.on(BrowserContext.Events.BeforeClose, () => {
|
||||||
|
this._throttledOutputFile?.flush();
|
||||||
|
});
|
||||||
|
this._listeners.push(eventsHelper.addEventListener(process, 'exit', () => {
|
||||||
|
this._throttledOutputFile?.flush();
|
||||||
|
}));
|
||||||
|
this._collection = collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOutput(codegenId: string, outputFile?: string) {
|
||||||
|
const languages = languageSet();
|
||||||
|
const primaryLanguage = [...languages].find(l => l.id === codegenId);
|
||||||
|
if (!primaryLanguage)
|
||||||
|
throw new Error(`\n===============================\nUnsupported language: '${codegenId}'\n===============================\n`);
|
||||||
|
languages.delete(primaryLanguage);
|
||||||
|
this._orderedLanguages = [primaryLanguage, ...languages];
|
||||||
|
this._throttledOutputFile = outputFile ? new ThrottledFile(outputFile) : null;
|
||||||
|
this._collection?.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
languageName(id?: string): Language {
|
||||||
|
for (const lang of this._orderedLanguages) {
|
||||||
|
if (!id || lang.id === id)
|
||||||
|
return lang.highlighter;
|
||||||
|
}
|
||||||
|
return 'javascript';
|
||||||
|
}
|
||||||
|
|
||||||
|
async install() {
|
||||||
|
this._context.on(BrowserContext.Events.Page, (page: Page) => this._onPage(page));
|
||||||
|
for (const page of this._context.pages())
|
||||||
|
this._onPage(page);
|
||||||
|
this._context.on(BrowserContext.Events.Dialog, (dialog: Dialog) => this._onDialog(dialog.page()));
|
||||||
|
|
||||||
|
// Input actions that potentially lead to navigation are intercepted on the page and are
|
||||||
|
// performed by the Playwright.
|
||||||
|
await this._context.exposeBinding('__pw_recorderPerformAction', false,
|
||||||
|
(source: BindingSource, action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode) => this._performAction(source.frame, action, simpleDomNode));
|
||||||
|
|
||||||
|
// Other non-essential actions are simply being recorded.
|
||||||
|
await this._context.exposeBinding('__pw_recorderRecordAction', false,
|
||||||
|
(source: BindingSource, action: actions.Action, simpleDomNode?: SimpleDomNode) => this._recordAction(source.frame, action, simpleDomNode));
|
||||||
|
|
||||||
|
await this._context.extendInjectedScript(recorderSource.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnabled(enabled: boolean) {
|
||||||
|
this._collection.setEnabled(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
for (const timer of this._timers)
|
||||||
|
clearTimeout(timer);
|
||||||
|
this._timers.clear();
|
||||||
|
eventsHelper.removeEventListeners(this._listeners);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _onPage(page: Page) {
|
||||||
|
// First page is called page, others are called popup1, popup2, etc.
|
||||||
|
const frame = page.mainFrame();
|
||||||
|
page.on('close', () => {
|
||||||
|
this._collection.addAction({
|
||||||
|
frame: this._describeMainFrame(page),
|
||||||
|
committed: true,
|
||||||
|
action: {
|
||||||
|
name: 'closePage',
|
||||||
|
signals: [],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._pageAliases.delete(page);
|
||||||
|
});
|
||||||
|
frame.on(Frame.Events.InternalNavigation, event => {
|
||||||
|
if (event.isPublic)
|
||||||
|
this._onFrameNavigated(frame, page);
|
||||||
|
});
|
||||||
|
page.on(Page.Events.Download, () => this._onDownload(page));
|
||||||
|
const suffix = this._pageAliases.size ? String(++this._lastPopupOrdinal) : '';
|
||||||
|
const pageAlias = 'page' + suffix;
|
||||||
|
this._pageAliases.set(page, pageAlias);
|
||||||
|
|
||||||
|
if (page.opener()) {
|
||||||
|
this._onPopup(page.opener()!, page);
|
||||||
|
} else {
|
||||||
|
this._collection.addAction({
|
||||||
|
frame: this._describeMainFrame(page),
|
||||||
|
committed: true,
|
||||||
|
action: {
|
||||||
|
name: 'openPage',
|
||||||
|
url: page.mainFrame().url(),
|
||||||
|
signals: [],
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearScript(): void {
|
||||||
|
this._collection.restart();
|
||||||
|
if (this._params.mode === 'recording') {
|
||||||
|
for (const page of this._context.pages())
|
||||||
|
this._onFrameNavigated(page.mainFrame(), page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _describeMainFrame(page: Page): FrameDescription {
|
||||||
|
return {
|
||||||
|
pageAlias: this._pageAliases.get(page)!,
|
||||||
|
framePath: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _describeFrame(frame: Frame): Promise<FrameDescription> {
|
||||||
|
return {
|
||||||
|
pageAlias: this._pageAliases.get(frame._page)!,
|
||||||
|
framePath: await generateFrameSelector(frame),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
testIdAttributeName(): string {
|
||||||
|
return this._params.testIdAttributeName || this._context.selectors().testIdAttributeName() || 'data-testid';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _performAction(frame: Frame, action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode) {
|
||||||
|
// Commit last action so that no further signals are added to it.
|
||||||
|
this._collection.commitLastAction();
|
||||||
|
|
||||||
|
const frameDescription = await this._describeFrame(frame);
|
||||||
|
const actionInContext: ActionInContext = {
|
||||||
|
frame: frameDescription,
|
||||||
|
action,
|
||||||
|
description: undefined, // TODO: generate description based on simple dom node.
|
||||||
|
};
|
||||||
|
|
||||||
|
this._collection.willPerformAction(actionInContext);
|
||||||
|
const success = await performAction(frame, action);
|
||||||
|
if (success) {
|
||||||
|
this._collection.didPerformAction(actionInContext);
|
||||||
|
this._setCommittedAfterTimeout(actionInContext);
|
||||||
|
} else {
|
||||||
|
this._collection.performedActionFailed(actionInContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _recordAction(frame: Frame, action: actions.Action, simpleDomNode?: SimpleDomNode) {
|
||||||
|
// Commit last action so that no further signals are added to it.
|
||||||
|
this._collection.commitLastAction();
|
||||||
|
|
||||||
|
const frameDescription = await this._describeFrame(frame);
|
||||||
|
const actionInContext: ActionInContext = {
|
||||||
|
frame: frameDescription,
|
||||||
|
action,
|
||||||
|
description: undefined, // TODO: generate description based on simple dom node.
|
||||||
|
};
|
||||||
|
this._setCommittedAfterTimeout(actionInContext);
|
||||||
|
this._collection.addAction(actionInContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setCommittedAfterTimeout(actionInContext: ActionInContext) {
|
||||||
|
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);
|
||||||
|
}, isUnderTest() ? 500 : 5000);
|
||||||
|
this._timers.add(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onFrameNavigated(frame: Frame, page: Page) {
|
||||||
|
const pageAlias = this._pageAliases.get(page);
|
||||||
|
this._collection.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._collection.signal(pageAlias, page.mainFrame(), { name: 'popup', popupAlias });
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onDownload(page: Page) {
|
||||||
|
const pageAlias = this._pageAliases.get(page)!;
|
||||||
|
++this._lastDownloadOrdinal;
|
||||||
|
this._collection.signal(pageAlias, page.mainFrame(), { name: 'download', downloadAlias: this._lastDownloadOrdinal ? String(this._lastDownloadOrdinal) : '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onDialog(page: Page) {
|
||||||
|
const pageAlias = this._pageAliases.get(page)!;
|
||||||
|
++this._lastDialogOrdinal;
|
||||||
|
this._collection.signal(pageAlias, page.mainFrame(), { name: 'dialog', dialogAlias: this._lastDialogOrdinal ? String(this._lastDialogOrdinal) : '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateFrameSelector(frame: Frame): Promise<string[]> {
|
||||||
|
const selectorPromises: Promise<string>[] = [];
|
||||||
|
while (frame) {
|
||||||
|
const parent = frame.parentFrame();
|
||||||
|
if (!parent)
|
||||||
|
break;
|
||||||
|
selectorPromises.push(generateFrameSelectorInParent(parent, frame));
|
||||||
|
frame = parent;
|
||||||
|
}
|
||||||
|
const result = await Promise.all(selectorPromises);
|
||||||
|
return result.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateFrameSelectorInParent(parent: Frame, frame: Frame): Promise<string> {
|
||||||
|
const result = await raceAgainstDeadline(async () => {
|
||||||
|
try {
|
||||||
|
const frameElement = await frame.frameElement();
|
||||||
|
if (!frameElement || !parent)
|
||||||
|
return;
|
||||||
|
const utility = await parent._utilityContext();
|
||||||
|
const injected = await utility.injectedScript();
|
||||||
|
const selector = await injected.evaluate((injected, element) => {
|
||||||
|
return injected.generateSelectorSimple(element as Element);
|
||||||
|
}, frameElement);
|
||||||
|
return selector;
|
||||||
|
} catch (e) {
|
||||||
|
return e.toString();
|
||||||
|
}
|
||||||
|
}, monotonicTime() + 2000);
|
||||||
|
if (!result.timedOut && result.result)
|
||||||
|
return result.result;
|
||||||
|
|
||||||
|
if (frame.name())
|
||||||
|
return `iframe[name=${quoteCSSAttributeValue(frame.name())}]`;
|
||||||
|
return `iframe[src=${quoteCSSAttributeValue(frame.url())}]`;
|
||||||
|
}
|
||||||
@ -15,38 +15,19 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import type { BrowserContextOptions, LaunchOptions } from '../../../types/types';
|
|
||||||
import type { Frame } from '../frames';
|
import type { Frame } from '../frames';
|
||||||
import type { LanguageGenerator, LanguageGeneratorOptions } from './language';
|
import type { Signal } from './recorderActions';
|
||||||
import type { Action, Signal } from '../recorder/recorderActions';
|
import type { ActionInContext } from '../codegen/types';
|
||||||
|
|
||||||
export type FrameDescription = {
|
export class RecorderCollection extends EventEmitter {
|
||||||
pageAlias: string;
|
|
||||||
framePath: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ActionInContext = {
|
|
||||||
frame: FrameDescription;
|
|
||||||
description?: string;
|
|
||||||
action: Action;
|
|
||||||
committed?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class CodeGenerator extends EventEmitter {
|
|
||||||
private _currentAction: ActionInContext | null = null;
|
private _currentAction: ActionInContext | null = null;
|
||||||
private _lastAction: ActionInContext | null = null;
|
private _lastAction: ActionInContext | null = null;
|
||||||
private _actions: ActionInContext[] = [];
|
private _actions: ActionInContext[] = [];
|
||||||
private _enabled: boolean;
|
private _enabled: boolean;
|
||||||
private _options: LanguageGeneratorOptions;
|
|
||||||
|
|
||||||
constructor(browserName: string, enabled: boolean, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, deviceName: string | undefined, saveStorage: string | undefined) {
|
constructor(enabled: boolean) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
// Make a copy of options to modify them later.
|
|
||||||
launchOptions = { headless: false, ...launchOptions };
|
|
||||||
contextOptions = { ...contextOptions };
|
|
||||||
this._enabled = enabled;
|
this._enabled = enabled;
|
||||||
this._options = { browserName, launchOptions, contextOptions, deviceName, saveStorage };
|
|
||||||
this.restart();
|
this.restart();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,6 +38,10 @@ export class CodeGenerator extends EventEmitter {
|
|||||||
this.emit('change');
|
this.emit('change');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actions() {
|
||||||
|
return this._actions;
|
||||||
|
}
|
||||||
|
|
||||||
setEnabled(enabled: boolean) {
|
setEnabled(enabled: boolean) {
|
||||||
this._enabled = enabled;
|
this._enabled = enabled;
|
||||||
}
|
}
|
||||||
@ -163,12 +148,4 @@ export class CodeGenerator extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
generateStructure(languageGenerator: LanguageGenerator) {
|
|
||||||
const header = languageGenerator.generateHeader(this._options);
|
|
||||||
const footer = languageGenerator.generateFooter(this._options.saveStorage);
|
|
||||||
const actions = this._actions.map(a => languageGenerator.generateAction(a)).filter(Boolean);
|
|
||||||
const text = [header, ...actions, footer].join('\n');
|
|
||||||
return { header, footer, actions, text };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -14,11 +14,11 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createGuid, monotonicTime, serializeExpectedTextValues } from '../utils';
|
import { createGuid, monotonicTime, serializeExpectedTextValues } from '../../utils';
|
||||||
import { toClickOptions, toKeyboardModifiers } from './codegen/language';
|
import { toClickOptions, toKeyboardModifiers } from '../codegen/language';
|
||||||
import type { Frame } from './frames';
|
import type { Frame } from '../frames';
|
||||||
import type { CallMetadata } from './instrumentation';
|
import type { CallMetadata } from '../instrumentation';
|
||||||
import type * as actions from './recorder/recorderActions';
|
import type * as actions from './recorderActions';
|
||||||
|
|
||||||
async function innerPerformAction(frame: Frame, action: string, params: any, cb: (callMetadata: CallMetadata) => Promise<any>): Promise<boolean> {
|
async function innerPerformAction(frame: Frame, action: string, params: any, cb: (callMetadata: CallMetadata) => Promise<any>): Promise<boolean> {
|
||||||
const callMetadata: CallMetadata = {
|
const callMetadata: CallMetadata = {
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* 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 * as fs from 'fs';
|
||||||
|
|
||||||
|
export 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)
|
||||||
|
this._timer = setTimeout(() => this.flush(), 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
flush(): void {
|
||||||
|
if (this._timer) {
|
||||||
|
clearTimeout(this._timer);
|
||||||
|
this._timer = undefined;
|
||||||
|
}
|
||||||
|
if (this._text)
|
||||||
|
fs.writeFileSync(this._file, this._text);
|
||||||
|
this._text = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user