diff --git a/packages/playwright-core/src/cli/driver.ts b/packages/playwright-core/src/cli/driver.ts index 4c7573ec83..26fb476e13 100644 --- a/packages/playwright-core/src/cli/driver.ts +++ b/packages/playwright-core/src/cli/driver.ts @@ -29,6 +29,7 @@ import { Recorder } from '../server/recorder'; import { EmptyRecorderApp } from '../server/recorder/recorderApp'; import type { BrowserContext } from '../server/browserContext'; import { serverSideCallMetadata } from '../server/instrumentation'; +import type { Mode } from '../server/recorder/recorderTypes'; export function printApiJson() { // Note: this file is generated by build-playwright-driver.sh @@ -83,55 +84,94 @@ function selfDestruct() { const internalMetadata = serverSideCallMetadata(); +class ProtocolHandler { + private _playwright: Playwright; + private _autoCloseTimer: NodeJS.Timeout | undefined; + + constructor(playwright: Playwright) { + this._playwright = playwright; + } + + async setMode(params: { mode: Mode, language?: string, file?: string }) { + await gc(this._playwright); + + if (params.mode === 'none') { + for (const recorder of await allRecorders(this._playwright)) { + recorder.setHighlightedSelector(''); + recorder.setMode('none'); + } + this.setAutoClose({ enabled: true }); + return; + } + + const browsers = this._playwright.allBrowsers(); + if (!browsers.length) + await this._playwright.chromium.launch(internalMetadata, { headless: false }); + // Create page if none. + const pages = this._playwright.allPages(); + if (!pages.length) { + const [browser] = this._playwright.allBrowsers(); + const { context } = await browser.newContextForReuse({}, internalMetadata); + await context.newPage(internalMetadata); + } + // Toggle the mode. + for (const recorder of await allRecorders(this._playwright)) { + recorder.setHighlightedSelector(''); + if (params.mode === 'recording') + recorder.setOutput(params.language!, params.file); + recorder.setMode(params.mode); + } + this.setAutoClose({ enabled: true }); + } + + async setAutoClose(params: { enabled: boolean }) { + if (this._autoCloseTimer) + clearTimeout(this._autoCloseTimer); + if (!params.enabled) + return; + const heartBeat = () => { + if (!this._playwright.allPages().length) + selfDestruct(); + else + this._autoCloseTimer = setTimeout(heartBeat, 5000); + }; + this._autoCloseTimer = setTimeout(heartBeat, 30000); + } + + async highlight(params: { selector: string }) { + for (const recorder of await allRecorders(this._playwright)) + recorder.setHighlightedSelector(params.selector); + } + + async kill() { + selfDestruct(); + } +} + function wireController(playwright: Playwright, wsEndpoint: string) { process.send!({ method: 'ready', params: { wsEndpoint } }); + const handler = new ProtocolHandler(playwright); process.on('message', async message => { try { - if (message.method === 'kill') { - selfDestruct(); - return; - } - - if (message.method === 'inspect') { - if (!message.params.enabled) { - for (const recorder of await allRecorders(playwright)) { - recorder.setHighlightedSelector(''); - recorder.setMode('none'); - } - return; - } - - // Create browser if none. - const browsers = playwright.allBrowsers(); - if (!browsers.length) - await playwright.chromium.launch(internalMetadata, { headless: false }); - // Create page if none. - const pages = playwright.allPages(); - if (!pages.length) { - const [browser] = playwright.allBrowsers(); - const { context } = await browser.newContextForReuse({}, internalMetadata); - await context.newPage(internalMetadata); - } - // Toggle inspect mode. - for (const recorder of await allRecorders(playwright)) { - recorder.setHighlightedSelector(''); - recorder.setMode('inspecting'); - } - return; - } - - if (message.method === 'highlight') { - for (const recorder of await allRecorders(playwright)) - recorder.setHighlightedSelector(message.params.selector); - return; - } - + const result = await (handler as any)[message.method](message.params); + process.send!({ id: message.id, result }); } catch (e) { - process.send!({ method: 'error', params: { error: e.toString() } }); + process.send!({ id: message.id, error: e.toString() }); } }); } +async function gc(playwright: Playwright) { + for (const browser of playwright.allBrowsers()) { + for (const context of browser.contexts()) { + if (!context.pages().length) + await context.close(serverSideCallMetadata()); + } + if (!browser.contexts()) + await browser.close(); + } +} + async function allRecorders(playwright: Playwright): Promise { const contexts = new Set(); for (const page of playwright.allPages()) diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 2b2bcd0276..243f8f343d 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -40,6 +40,7 @@ import { metadataToCallLog } from './recorder/recorderUtils'; import { Debugger } from './debugger'; import { EventEmitter } from 'events'; import { raceAgainstTimeout } from '../utils/timeoutRunner'; +import type { LanguageGenerator } from './recorder/language'; type BindingSource = { frame: Frame, page: Page }; @@ -204,6 +205,10 @@ export class Recorder implements InstrumentationListener { this._refreshOverlay(); } + setOutput(language: string, outputFile: string | undefined) { + this._contextRecorder.setOutput(language, outputFile); + } + private _refreshOverlay() { for (const page of this._context.pages()) page.mainFrame().evaluateExpression('window.__pw_refreshOverlay()', false, undefined, 'main').catch(() => {}); @@ -312,35 +317,20 @@ class ContextRecorder extends EventEmitter { private _context: BrowserContext; private _params: channels.BrowserContextRecorderSupplementEnableParams; private _recorderSources: Source[]; + private _throttledOutputFile: ThrottledFile | null = null; + private _orderedLanguages: LanguageGenerator[] = []; constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) { super(); this._context = context; this._params = params; - const language = params.language || context._browser.options.sdkLanguage; - - const languages = new Set([ - new JavaLanguageGenerator(), - new JavaScriptLanguageGenerator(false), - new JavaScriptLanguageGenerator(true), - new PythonLanguageGenerator(false, false), - new PythonLanguageGenerator(true, false), - new PythonLanguageGenerator(false, true), - new CSharpLanguageGenerator(), - ]); - const primaryLanguage = [...languages].find(l => l.id === language)!; - if (!primaryLanguage) - throw new Error(`\n===============================\nUnsupported language: '${language}'\n===============================\n`); - - languages.delete(primaryLanguage); - const orderedLanguages = [primaryLanguage, ...languages]; - this._recorderSources = []; + const language = params.language || context._browser.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); - const throttledOutputFile = params.outputFile ? new ThrottledFile(params.outputFile) : null; generator.on('change', () => { this._recorderSources = []; - for (const languageGenerator of orderedLanguages) { + for (const languageGenerator of this._orderedLanguages) { const source: Source = { isRecorded: true, file: languageGenerator.fileName, @@ -350,25 +340,43 @@ class ContextRecorder extends EventEmitter { }; source.revealLine = source.text.split('\n').length - 1; this._recorderSources.push(source); - if (languageGenerator === orderedLanguages[0]) - throttledOutputFile?.setContent(source.text); + if (languageGenerator === this._orderedLanguages[0]) + this._throttledOutputFile?.setContent(source.text); } this.emit(ContextRecorder.Events.Change, { sources: this._recorderSources, - primaryFileName: primaryLanguage.fileName + primaryFileName: this._orderedLanguages[0].fileName }); }); - if (throttledOutputFile) { - context.on(BrowserContext.Events.BeforeClose, () => { - throttledOutputFile.flush(); - }); - process.on('exit', () => { - throttledOutputFile.flush(); - }); - } + context.on(BrowserContext.Events.BeforeClose, () => { + this._throttledOutputFile?.flush(); + }); + process.on('exit', () => { + this._throttledOutputFile?.flush(); + }); this._generator = generator; } + setOutput(language: string, outputFile: string | undefined) { + const languages = new Set([ + new JavaLanguageGenerator(), + new JavaScriptLanguageGenerator(false), + new JavaScriptLanguageGenerator(true), + new PythonLanguageGenerator(false, false), + new PythonLanguageGenerator(true, false), + new PythonLanguageGenerator(false, true), + new CSharpLanguageGenerator(), + ]); + const primaryLanguage = [...languages].find(l => l.id === language); + if (!primaryLanguage) + throw new Error(`\n===============================\nUnsupported language: '${language}'\n===============================\n`); + + languages.delete(primaryLanguage); + this._orderedLanguages = [primaryLanguage, ...languages]; + this._throttledOutputFile = outputFile ? new ThrottledFile(outputFile) : null; + this._generator?.restart(); + } + async install() { this._context.on(BrowserContext.Events.Page, page => this._onPage(page)); for (const page of this._context.pages()) @@ -625,7 +633,7 @@ class ThrottledFile { setContent(text: string) { this._text = text; if (!this._timer) - this._timer = setTimeout(() => this.flush(), 1000); + this._timer = setTimeout(() => this.flush(), 250); } flush(): void { diff --git a/packages/playwright-core/src/server/recorder/codeGenerator.ts b/packages/playwright-core/src/server/recorder/codeGenerator.ts index 9320ed329f..cb592c7570 100644 --- a/packages/playwright-core/src/server/recorder/codeGenerator.ts +++ b/packages/playwright-core/src/server/recorder/codeGenerator.ts @@ -33,14 +33,14 @@ export class CodeGenerator extends EventEmitter { private _enabled: boolean; private _options: LanguageGeneratorOptions; - constructor(browserName: string, generateHeaders: boolean, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, deviceName: string | undefined, saveStorage: string | undefined) { + constructor(browserName: string, enabled: boolean, launchOptions: LaunchOptions, contextOptions: BrowserContextOptions, deviceName: string | undefined, saveStorage: string | undefined) { super(); // Make a copy of options to modify them later. launchOptions = { headless: false, ...launchOptions }; contextOptions = { ...contextOptions }; - this._enabled = generateHeaders; - this._options = { browserName, generateHeaders, launchOptions, contextOptions, deviceName, saveStorage }; + this._enabled = enabled; + this._options = { browserName, launchOptions, contextOptions, deviceName, saveStorage }; this.restart(); } @@ -160,15 +160,13 @@ export class CodeGenerator extends EventEmitter { generateText(languageGenerator: LanguageGenerator) { const text = []; - if (this._options.generateHeaders) - text.push(languageGenerator.generateHeader(this._options)); + text.push(languageGenerator.generateHeader(this._options)); for (const action of this._actions) { const actionText = languageGenerator.generateAction(action); if (actionText) text.push(actionText); } - if (this._options.generateHeaders) - text.push(languageGenerator.generateFooter(this._options.saveStorage)); + text.push(languageGenerator.generateFooter(this._options.saveStorage)); return text.join('\n'); } } diff --git a/packages/playwright-core/src/server/recorder/language.ts b/packages/playwright-core/src/server/recorder/language.ts index 9eaa15db24..2d93fbbed7 100644 --- a/packages/playwright-core/src/server/recorder/language.ts +++ b/packages/playwright-core/src/server/recorder/language.ts @@ -20,7 +20,6 @@ import type { Action, DialogSignal, DownloadSignal, NavigationSignal, PopupSigna export type LanguageGeneratorOptions = { browserName: string; - generateHeaders: boolean; launchOptions: LaunchOptions; contextOptions: BrowserContextOptions; deviceName?: string;