From a1df11011c57128c7f6b5d73a99ded4139a9617e Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 27 Aug 2024 14:10:21 -0700 Subject: [PATCH] chore: split recorder into files (#32345) --- packages/playwright-core/src/server/DEPS.list | 7 - .../src/server/codegen/csharp.ts | 3 +- .../src/server/codegen/java.ts | 3 +- .../src/server/codegen/javascript.ts | 3 +- .../src/server/codegen/jsonl.ts | 3 +- .../src/server/codegen/language.ts | 31 +- .../src/server/codegen/python.ts | 3 +- .../src/server/codegen/types.ts | 50 +++ .../playwright-core/src/server/recorder.ts | 347 +----------------- .../src/server/recorder/DEPS.list | 3 + .../src/server/recorder/contextRecorder.ts | 324 ++++++++++++++++ .../recorderCollection.ts} | 39 +- .../server/{ => recorder}/recorderRunner.ts | 10 +- .../src/server/recorder/throttledFile.ts | 43 +++ 14 files changed, 460 insertions(+), 409 deletions(-) create mode 100644 packages/playwright-core/src/server/codegen/types.ts create mode 100644 packages/playwright-core/src/server/recorder/contextRecorder.ts rename packages/playwright-core/src/server/{codegen/codeGenerator.ts => recorder/recorderCollection.ts} (75%) rename packages/playwright-core/src/server/{ => recorder}/recorderRunner.ts (95%) create mode 100644 packages/playwright-core/src/server/recorder/throttledFile.ts diff --git a/packages/playwright-core/src/server/DEPS.list b/packages/playwright-core/src/server/DEPS.list index 0e2b8301d6..bc32bb8486 100644 --- a/packages/playwright-core/src/server/DEPS.list +++ b/packages/playwright-core/src/server/DEPS.list @@ -20,10 +20,3 @@ ./electron/ ./firefox/ ./webkit/ - -[recorder.ts] -./codegen/codeGenerator.ts -./codegen/languages.ts - -[recorderRunner.ts] -./codegen/language.ts diff --git a/packages/playwright-core/src/server/codegen/csharp.ts b/packages/playwright-core/src/server/codegen/csharp.ts index 41f91d259b..f11435a0c2 100644 --- a/packages/playwright-core/src/server/codegen/csharp.ts +++ b/packages/playwright-core/src/server/codegen/csharp.ts @@ -15,8 +15,7 @@ */ import type { BrowserContextOptions } from '../../../types/types'; -import type { ActionInContext } from './codeGenerator'; -import type { Language, LanguageGenerator, LanguageGeneratorOptions } from './language'; +import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; import { sanitizeDeviceOptions, toClickOptions, toKeyboardModifiers, toSignalMap } from './language'; import { escapeWithQuotes, asLocator } from '../../utils'; import { deviceDescriptors } from '../deviceDescriptors'; diff --git a/packages/playwright-core/src/server/codegen/java.ts b/packages/playwright-core/src/server/codegen/java.ts index a59546f7ba..47c6fa3619 100644 --- a/packages/playwright-core/src/server/codegen/java.ts +++ b/packages/playwright-core/src/server/codegen/java.ts @@ -16,8 +16,7 @@ import type { BrowserContextOptions } from '../../../types/types'; import type * as types from '../types'; -import type { ActionInContext } from './codeGenerator'; -import type { Language, LanguageGenerator, LanguageGeneratorOptions } from './language'; +import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; import { toClickOptions, toKeyboardModifiers, toSignalMap } from './language'; import { deviceDescriptors } from '../deviceDescriptors'; import { JavaScriptFormatter } from './javascript'; diff --git a/packages/playwright-core/src/server/codegen/javascript.ts b/packages/playwright-core/src/server/codegen/javascript.ts index 1d1f82bae0..1c1ba3f1cb 100644 --- a/packages/playwright-core/src/server/codegen/javascript.ts +++ b/packages/playwright-core/src/server/codegen/javascript.ts @@ -15,8 +15,7 @@ */ import type { BrowserContextOptions } from '../../../types/types'; -import type { ActionInContext } from './codeGenerator'; -import type { Language, LanguageGenerator, LanguageGeneratorOptions } from './language'; +import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptions } from './language'; import { deviceDescriptors } from '../deviceDescriptors'; import { escapeWithQuotes, asLocator } from '../../utils'; diff --git a/packages/playwright-core/src/server/codegen/jsonl.ts b/packages/playwright-core/src/server/codegen/jsonl.ts index 108d5eadc6..78485297b6 100644 --- a/packages/playwright-core/src/server/codegen/jsonl.ts +++ b/packages/playwright-core/src/server/codegen/jsonl.ts @@ -15,8 +15,7 @@ */ import { asLocator } from '../../utils'; -import type { ActionInContext } from './codeGenerator'; -import type { Language, LanguageGenerator, LanguageGeneratorOptions } from './language'; +import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; export class JsonlLanguageGenerator implements LanguageGenerator { id = 'jsonl'; diff --git a/packages/playwright-core/src/server/codegen/language.ts b/packages/playwright-core/src/server/codegen/language.ts index 78414733d7..72cfb9083d 100644 --- a/packages/playwright-core/src/server/codegen/language.ts +++ b/packages/playwright-core/src/server/codegen/language.ts @@ -14,32 +14,17 @@ * limitations under the License. */ -import type { BrowserContextOptions, LaunchOptions } from '../../..'; -import type { Language } from '../../utils'; +import type { BrowserContextOptions } from '../../..'; import type * as actions from '../recorder/recorderActions'; import type * as types from '../types'; -import type { ActionInContext } from './codeGenerator'; -export type { Language } from '../../utils'; +import type { ActionInContext, LanguageGenerator, LanguageGeneratorOptions } from './types'; -export type LanguageGeneratorOptions = { - browserName: string; - launchOptions: LaunchOptions; - contextOptions: BrowserContextOptions; - deviceName?: string; - saveStorage?: string; -}; - -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 generateCode(actions: ActionInContext[], languageGenerator: LanguageGenerator, options: LanguageGeneratorOptions) { + const header = languageGenerator.generateHeader(options); + const footer = languageGenerator.generateFooter(options.saveStorage); + const actionTexts = actions.map(a => languageGenerator.generateAction(a)).filter(Boolean); + const text = [header, ...actionTexts, footer].join('\n'); + return { header, footer, actionTexts, text }; } export function sanitizeDeviceOptions(device: any, options: BrowserContextOptions): BrowserContextOptions { diff --git a/packages/playwright-core/src/server/codegen/python.ts b/packages/playwright-core/src/server/codegen/python.ts index 98949320e7..6ed101bcf0 100644 --- a/packages/playwright-core/src/server/codegen/python.ts +++ b/packages/playwright-core/src/server/codegen/python.ts @@ -15,8 +15,7 @@ */ import type { BrowserContextOptions } from '../../../types/types'; -import type { ActionInContext } from './codeGenerator'; -import type { Language, LanguageGenerator, LanguageGeneratorOptions } from './language'; +import type { ActionInContext, Language, LanguageGenerator, LanguageGeneratorOptions } from './types'; import { sanitizeDeviceOptions, toSignalMap, toKeyboardModifiers, toClickOptions } from './language'; import { escapeWithQuotes, toSnakeCase, asLocator } from '../../utils'; import { deviceDescriptors } from '../deviceDescriptors'; diff --git a/packages/playwright-core/src/server/codegen/types.ts b/packages/playwright-core/src/server/codegen/types.ts new file mode 100644 index 0000000000..96f2aa85d1 --- /dev/null +++ b/packages/playwright-core/src/server/codegen/types.ts @@ -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; +} diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 23c68b7297..17c38187e8 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -14,36 +14,21 @@ * 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 { 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 { isUnderTest, monotonicTime } from '../utils'; -import { metadataToCallLog } from './recorder/recorderUtils'; -import { Debugger } from './debugger'; -import { EventEmitter } from 'events'; -import { raceAgainstDeadline } from '../utils/timeoutRunner'; -import { type Language, type LanguageGenerator } from './codegen/language'; +import * as fs from 'fs'; +import type { Point } from '../common/types'; +import * as consoleApiSource from '../generated/consoleApiSource'; +import { isUnderTest } from '../utils'; import { locatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser'; -import { quoteCSSAttributeValue, eventsHelper, type RegisteredListener } from '../utils'; -import type { Dialog } from './dialog'; -import { performAction } from './recorderRunner'; -import { languageSet } from './codegen/languages'; -import type { SimpleDomNode } from './injected/simpleDom'; - -type BindingSource = { frame: Frame, page: Page }; +import { BrowserContext } from './browserContext'; +import { type Language } from './codegen/types'; +import { Debugger } from './debugger'; +import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation'; +import { ContextRecorder, generateFrameSelector } from './recorder/contextRecorder'; +import type { IRecorderApp } from './recorder/recorderApp'; +import { EmptyRecorderApp, RecorderApp } from './recorder/recorderApp'; +import { metadataToCallLog } from './recorder/recorderUtils'; const recorderSymbol = Symbol('recorderSymbol'); @@ -361,245 +346,8 @@ export class Recorder implements InstrumentationListener { } } -class ContextRecorder extends EventEmitter { - static Events = { - Change: 'change' - }; - - private _generator: CodeGenerator; - private _pageAliases = new Map(); - private _lastPopupOrdinal = 0; - private _lastDialogOrdinal = -1; - private _lastDownloadOrdinal = -1; - private _timers = new Set(); - 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 { - 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 isScreenshotCommand(metadata: CallMetadata) { + return metadata.method.toLowerCase().includes('screenshot'); } function languageForFile(file: string) { @@ -611,70 +359,3 @@ function languageForFile(file: string) { return 'csharp'; 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 { - const selectorPromises: Promise[] = []; - 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 { - 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())}]`; -} diff --git a/packages/playwright-core/src/server/recorder/DEPS.list b/packages/playwright-core/src/server/recorder/DEPS.list index 69c4226c68..22ec3dfc2f 100644 --- a/packages/playwright-core/src/server/recorder/DEPS.list +++ b/packages/playwright-core/src/server/recorder/DEPS.list @@ -1,8 +1,11 @@ [*] ../ +../codegen/language.ts +../codegen/languages.ts ../isomorphic/** ../registry/** ../../common/ +../../generated/recorderSource.ts ../../protocol/ ../../utils/** ../../utilsBundle.ts diff --git a/packages/playwright-core/src/server/recorder/contextRecorder.ts b/packages/playwright-core/src/server/recorder/contextRecorder.ts new file mode 100644 index 0000000000..72f972e6b5 --- /dev/null +++ b/packages/playwright-core/src/server/recorder/contextRecorder.ts @@ -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(); + private _lastPopupOrdinal = 0; + private _lastDialogOrdinal = -1; + private _lastDownloadOrdinal = -1; + private _timers = new Set(); + 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 { + 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 { + const selectorPromises: Promise[] = []; + 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 { + 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())}]`; +} diff --git a/packages/playwright-core/src/server/codegen/codeGenerator.ts b/packages/playwright-core/src/server/recorder/recorderCollection.ts similarity index 75% rename from packages/playwright-core/src/server/codegen/codeGenerator.ts rename to packages/playwright-core/src/server/recorder/recorderCollection.ts index 0818b247e1..29da778ffb 100644 --- a/packages/playwright-core/src/server/codegen/codeGenerator.ts +++ b/packages/playwright-core/src/server/recorder/recorderCollection.ts @@ -15,38 +15,19 @@ */ import { EventEmitter } from 'events'; -import type { BrowserContextOptions, LaunchOptions } from '../../../types/types'; import type { Frame } from '../frames'; -import type { LanguageGenerator, LanguageGeneratorOptions } from './language'; -import type { Action, Signal } from '../recorder/recorderActions'; +import type { Signal } from './recorderActions'; +import type { ActionInContext } from '../codegen/types'; -export type FrameDescription = { - pageAlias: string; - framePath: string[]; -}; - -export type ActionInContext = { - frame: FrameDescription; - description?: string; - action: Action; - committed?: boolean; -}; - -export class CodeGenerator extends EventEmitter { +export class RecorderCollection extends EventEmitter { private _currentAction: ActionInContext | null = null; private _lastAction: ActionInContext | null = null; private _actions: ActionInContext[] = []; 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(); - - // Make a copy of options to modify them later. - launchOptions = { headless: false, ...launchOptions }; - contextOptions = { ...contextOptions }; this._enabled = enabled; - this._options = { browserName, launchOptions, contextOptions, deviceName, saveStorage }; this.restart(); } @@ -57,6 +38,10 @@ export class CodeGenerator extends EventEmitter { this.emit('change'); } + actions() { + return this._actions; + } + setEnabled(enabled: boolean) { 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 }; - } } diff --git a/packages/playwright-core/src/server/recorderRunner.ts b/packages/playwright-core/src/server/recorder/recorderRunner.ts similarity index 95% rename from packages/playwright-core/src/server/recorderRunner.ts rename to packages/playwright-core/src/server/recorder/recorderRunner.ts index 4058ae4053..beb74c5a6d 100644 --- a/packages/playwright-core/src/server/recorderRunner.ts +++ b/packages/playwright-core/src/server/recorder/recorderRunner.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import { createGuid, monotonicTime, serializeExpectedTextValues } from '../utils'; -import { toClickOptions, toKeyboardModifiers } from './codegen/language'; -import type { Frame } from './frames'; -import type { CallMetadata } from './instrumentation'; -import type * as actions from './recorder/recorderActions'; +import { createGuid, monotonicTime, serializeExpectedTextValues } from '../../utils'; +import { toClickOptions, toKeyboardModifiers } from '../codegen/language'; +import type { Frame } from '../frames'; +import type { CallMetadata } from '../instrumentation'; +import type * as actions from './recorderActions'; async function innerPerformAction(frame: Frame, action: string, params: any, cb: (callMetadata: CallMetadata) => Promise): Promise { const callMetadata: CallMetadata = { diff --git a/packages/playwright-core/src/server/recorder/throttledFile.ts b/packages/playwright-core/src/server/recorder/throttledFile.ts new file mode 100644 index 0000000000..4a34f41a0c --- /dev/null +++ b/packages/playwright-core/src/server/recorder/throttledFile.ts @@ -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; + } +}