From 8ed238843b03bb626e7b85503c062a08826cbf55 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 5 Aug 2022 19:34:57 -0700 Subject: [PATCH] chore: allow inspecting element from server (#16324) --- packages/playwright-core/src/cli/cli.ts | 2 +- packages/playwright-core/src/cli/driver.ts | 49 +++++++++++++++++-- .../src/client/browserContext.ts | 2 +- .../playwright-core/src/protocol/channels.ts | 4 +- .../playwright-core/src/protocol/protocol.yml | 6 ++- .../playwright-core/src/protocol/validator.ts | 2 +- .../src/remote/playwrightServer.ts | 4 ++ .../src/server/injected/recorder.ts | 2 +- .../playwright-core/src/server/playwright.ts | 4 ++ .../playwright-core/src/server/recorder.ts | 44 +++++++++++------ .../src/server/recorder/recorderApp.ts | 44 +++++++++-------- tests/library/inspector/inspectorTest.ts | 2 +- 12 files changed, 117 insertions(+), 48 deletions(-) diff --git a/packages/playwright-core/src/cli/cli.ts b/packages/playwright-core/src/cli/cli.ts index a47f3ebaab..fb2983d136 100755 --- a/packages/playwright-core/src/cli/cli.ts +++ b/packages/playwright-core/src/cli/cli.ts @@ -570,7 +570,7 @@ async function codegen(options: Options, url: string | undefined, language: stri contextOptions, device: options.device, saveStorage: options.saveStorage, - startRecording: true, + mode: 'recording', outputFile: outputFile ? path.resolve(outputFile) : undefined }); await openPage(context, url); diff --git a/packages/playwright-core/src/cli/driver.ts b/packages/playwright-core/src/cli/driver.ts index 29842ae6d0..dd3d828e58 100644 --- a/packages/playwright-core/src/cli/driver.ts +++ b/packages/playwright-core/src/cli/driver.ts @@ -21,9 +21,13 @@ import * as playwright from '../..'; import type { BrowserType } from '../client/browserType'; import type { LaunchServerOptions } from '../client/types'; import { createPlaywright, DispatcherConnection, Root, PlaywrightDispatcher } from '../server'; +import type { Playwright } from '../server'; import { IpcTransport, PipeTransport } from '../protocol/transport'; import { PlaywrightServer } from '../remote/playwrightServer'; import { gracefullyCloseAll } from '../utils/processLauncher'; +import { Recorder } from '../server/recorder'; +import { EmptyRecorderApp } from '../server/recorder/recorderApp'; +import type { BrowserContext } from '../server/browserContext'; export function printApiJson() { // Note: this file is generated by build-playwright-driver.sh @@ -54,10 +58,8 @@ export async function runServer(port: number | undefined, path = '/', maxClients process.on('exit', () => server.close().catch(console.error)); console.log('Listening on ' + wsEndpoint); // eslint-disable-line no-console process.stdin.on('close', () => selfDestruct()); - process.stdin.on('data', data => { - if (data.toString() === '') - selfDestruct(); - }); + if (process.send && server.preLaunchedPlaywright()) + wireController(server.preLaunchedPlaywright()!, wsEndpoint); } export async function launchBrowserServer(browserName: string, configFile?: string) { @@ -77,3 +79,42 @@ function selfDestruct() { process.exit(0); }); } + +function wireController(playwright: Playwright, wsEndpoint: string) { + process.send!({ method: 'ready', params: { wsEndpoint } }); + process.on('message', async message => { + try { + if (message.method === 'kill') { + selfDestruct(); + return; + } + + if (message.method === 'inspect') { + for (const recorder of await allRecorders(playwright)) + recorder.setMode(message.params.enabled ? 'inspecting' : 'none'); + } + + if (message.method === 'highlight') { + for (const recorder of await allRecorders(playwright)) + recorder.setHighlightedSelector(message.params.selector); + } + + } catch (e) { + process.send!({ method: 'error', params: { error: e.toString() } }); + } + }); +} + +async function allRecorders(playwright: Playwright): Promise { + const contexts = new Set(); + for (const page of playwright.allPages()) + contexts.add(page.context()); + const result = await Promise.all([...contexts].map(c => Recorder.show(c, {}, () => Promise.resolve(new InspectingRecorderApp())))); + return result.filter(Boolean) as Recorder[]; +} + +class InspectingRecorderApp extends EmptyRecorderApp { + override async setSelector(selector: string): Promise { + process.send!({ method: 'inspectRequested', params: { selector } }); + } +} diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index 83bbc5db1f..0d06fe1f55 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -373,7 +373,7 @@ export class BrowserContext extends ChannelOwner contextOptions?: BrowserContextOptions, device?: string, saveStorage?: string, - startRecording?: boolean, + mode?: 'recording' | 'inspecting', outputFile?: string }) { await this._channel.recorderSupplementEnable(params); diff --git a/packages/playwright-core/src/protocol/channels.ts b/packages/playwright-core/src/protocol/channels.ts index d782238a08..a1cc4c4c76 100644 --- a/packages/playwright-core/src/protocol/channels.ts +++ b/packages/playwright-core/src/protocol/channels.ts @@ -1416,7 +1416,7 @@ export type BrowserContextPauseOptions = {}; export type BrowserContextPauseResult = void; export type BrowserContextRecorderSupplementEnableParams = { language?: string, - startRecording?: boolean, + mode?: 'inspecting' | 'recording', pauseOnNextStatement?: boolean, launchOptions?: any, contextOptions?: any, @@ -1426,7 +1426,7 @@ export type BrowserContextRecorderSupplementEnableParams = { }; export type BrowserContextRecorderSupplementEnableOptions = { language?: string, - startRecording?: boolean, + mode?: 'inspecting' | 'recording', pauseOnNextStatement?: boolean, launchOptions?: any, contextOptions?: any, diff --git a/packages/playwright-core/src/protocol/protocol.yml b/packages/playwright-core/src/protocol/protocol.yml index 26fab326a9..aea955d24e 100644 --- a/packages/playwright-core/src/protocol/protocol.yml +++ b/packages/playwright-core/src/protocol/protocol.yml @@ -937,7 +937,11 @@ BrowserContext: experimental: True parameters: language: string? - startRecording: boolean? + mode: + type: enum? + literals: + - inspecting + - recording pauseOnNextStatement: boolean? launchOptions: json? contextOptions: json? diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 551b1b0dfe..9771966703 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -765,7 +765,7 @@ scheme.BrowserContextPauseParams = tOptional(tObject({})); scheme.BrowserContextPauseResult = tOptional(tObject({})); scheme.BrowserContextRecorderSupplementEnableParams = tObject({ language: tOptional(tString), - startRecording: tOptional(tBoolean), + mode: tOptional(tEnum(['inspecting', 'recording'])), pauseOnNextStatement: tOptional(tBoolean), launchOptions: tOptional(tAny), contextOptions: tOptional(tAny), diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index 4658c4589d..1957e9276c 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -62,6 +62,10 @@ export class PlaywrightServer { this._preLaunchedPlaywright = createPlaywright('javascript'); } + preLaunchedPlaywright(): Playwright | null { + return this._preLaunchedPlaywright; + } + async listen(port: number = 0): Promise { const server = http.createServer((request, response) => { response.end('Running'); diff --git a/packages/playwright-core/src/server/injected/recorder.ts b/packages/playwright-core/src/server/injected/recorder.ts index 095f8d9b8d..523a6ed608 100644 --- a/packages/playwright-core/src/server/injected/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder.ts @@ -85,7 +85,7 @@ class Recorder { } private async _pollRecorderMode() { - const pollPeriod = 1000; + const pollPeriod = 500; if (this._pollRecorderModeTimer) clearTimeout(this._pollRecorderModeTimer); const state = await globalThis.__pw_recorderState().catch(e => null); diff --git a/packages/playwright-core/src/server/playwright.ts b/packages/playwright-core/src/server/playwright.ts index 00bb420cef..8ac8d43580 100644 --- a/packages/playwright-core/src/server/playwright.ts +++ b/packages/playwright-core/src/server/playwright.ts @@ -69,6 +69,10 @@ export class Playwright extends SdkObject { allBrowsers(): Browser[] { return [...this._allBrowsers]; } + + allPages(): Page[] { + return [...this._allPages]; + } } export function createPlaywright(sdkLanguage: string, isInternalPlaywright: boolean = false) { diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 08302028ff..551c787d26 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -29,6 +29,7 @@ import { CSharpLanguageGenerator } from './recorder/csharp'; import { PythonLanguageGenerator } from './recorder/python'; 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'; @@ -42,7 +43,7 @@ import { raceAgainstTimeout } from '../utils/timeoutRunner'; type BindingSource = { frame: Frame, page: Page }; -const symbol = Symbol('RecorderSupplement'); +const recorderSymbol = Symbol('recorderSymbol'); export class Recorder implements InstrumentationListener { private _context: BrowserContext; @@ -55,31 +56,39 @@ export class Recorder implements InstrumentationListener { private _allMetadatas = new Map(); private _debugger: Debugger; private _contextRecorder: ContextRecorder; + private _recorderAppFactory: (recorder: Recorder) => Promise; static showInspector(context: BrowserContext) { Recorder.show(context, {}).catch(() => {}); } - static show(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { - let recorderPromise = (context as any)[symbol] as Promise; + static show(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}, recorderAppFactory = Recorder.defaultRecorderAppFactory): Promise { + let recorderPromise = (context as any)[recorderSymbol] as Promise; if (!recorderPromise) { - const recorder = new Recorder(context, params); + const recorder = new Recorder(context, params, recorderAppFactory); recorderPromise = recorder.install().then(() => recorder); - (context as any)[symbol] = recorderPromise; + (context as any)[recorderSymbol] = recorderPromise; } return recorderPromise; } - constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) { - this._mode = params.startRecording ? 'recording' : 'none'; + constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams, recorderAppFactory: (recorder: Recorder) => Promise) { + this._mode = params.mode || 'none'; + this._recorderAppFactory = recorderAppFactory; this._contextRecorder = new ContextRecorder(context, params); this._context = context; this._debugger = Debugger.lookup(context)!; context.instrumentation.addListener(this, context); } + private static async defaultRecorderAppFactory(recorder: Recorder) { + if (process.env.PW_CODEGEN_NO_INSPECTOR) + return new EmptyRecorderApp(); + return await RecorderApp.open(recorder, recorder._context); + } + async install() { - const recorderApp = await RecorderApp.open(this._context._browser.options.sdkLanguage, !!this._context._browser.options.headful); + const recorderApp = await this._recorderAppFactory(this); this._recorderApp = recorderApp; recorderApp.once('close', () => { this._debugger.resume(false); @@ -87,7 +96,7 @@ export class Recorder implements InstrumentationListener { }); recorderApp.on('event', (data: EventData) => { if (data.event === 'setMode') { - this._setMode(data.params.mode); + this.setMode(data.params.mode); this._refreshOverlay(); return; } @@ -149,9 +158,7 @@ export class Recorder implements InstrumentationListener { }); await this._context.exposeBinding('__pw_recorderSetSelector', false, async (_, selector: string) => { - this._setMode('none'); await this._recorderApp?.setSelector(selector, true); - await this._recorderApp?.bringToFront(); }); await this._context.exposeBinding('__pw_resume', false, () => { @@ -179,15 +186,22 @@ export class Recorder implements InstrumentationListener { this.updateCallLog([...this._currentCallsMetadata.keys()]); } - private _setMode(mode: Mode) { + setMode(mode: Mode) { + if (this._mode === mode) + return; this._mode = mode; this._recorderApp?.setMode(this._mode); this._contextRecorder.setEnabled(this._mode === 'recording'); this._debugger.setMuted(this._mode === 'recording'); - if (this._mode !== 'none') + if (this._mode !== 'none' && this._context.pages().length === 1) this._context.pages()[0].bringToFront().catch(() => {}); } + setHighlightedSelector(selector: string) { + this._highlightedSelector = selector; + this._refreshOverlay(); + } + private _refreshOverlay() { for (const page of this._context.pages()) page.mainFrame().evaluateExpression('window.__pw_refreshOverlay()', false, undefined, 'main').catch(() => {}); @@ -320,7 +334,7 @@ class ContextRecorder extends EventEmitter { const orderedLanguages = [primaryLanguage, ...languages]; this._recorderSources = []; - const generator = new CodeGenerator(context._browser.options.name, !!params.startRecording, params.launchOptions || {}, params.contextOptions || {}, params.device, params.saveStorage); + 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 = []; @@ -421,7 +435,7 @@ class ContextRecorder extends EventEmitter { clearScript(): void { this._generator.restart(); - if (!!this._params.startRecording) { + if (this._params.mode === 'recording') { for (const page of this._context.pages()) this._onFrameNavigated(page.mainFrame(), page); } diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index 4f1f9c232b..2ca29e93ca 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -25,6 +25,8 @@ import { isUnderTest } from '../../utils'; import { mime } from '../../utilsBundle'; import { installAppIcon } from '../chromium/crApp'; import { findChromiumChannel } from '../registry'; +import type { Recorder } from '../recorder'; +import type { BrowserContext } from '../browserContext'; declare global { interface Window { @@ -45,17 +47,28 @@ export interface IRecorderApp extends EventEmitter { setFileIfNeeded(file: string): Promise; setSelector(selector: string, focus?: boolean): Promise; updateCallLogs(callLogs: CallLog[]): Promise; - bringToFront(): void; setSources(sources: Source[]): Promise; } +export class EmptyRecorderApp extends EventEmitter implements IRecorderApp { + async close(): Promise {} + async setPaused(paused: boolean): Promise {} + async setMode(mode: 'none' | 'recording' | 'inspecting'): Promise {} + async setFileIfNeeded(file: string): Promise {} + async setSelector(selector: string, focus?: boolean): Promise {} + async updateCallLogs(callLogs: CallLog[]): Promise {} + async setSources(sources: Source[]): Promise {} +} + export class RecorderApp extends EventEmitter implements IRecorderApp { private _page: Page; readonly wsEndpoint: string | undefined; + private _recorder: Recorder; - constructor(page: Page, wsEndpoint: string | undefined) { + constructor(recorder: Recorder, page: Page, wsEndpoint: string | undefined) { super(); this.setMaxListeners(0); + this._recorder = recorder; this._page = page; this.wsEndpoint = wsEndpoint; } @@ -96,9 +109,9 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { await mainFrame.goto(serverSideCallMetadata(), 'https://playwright/index.html'); } - static async open(sdkLanguage: string, headed: boolean): Promise { - if (process.env.PW_CODEGEN_NO_INSPECTOR) - return new HeadlessRecorderApp(); + static async open(recorder: Recorder, inspectedContext: BrowserContext): Promise { + const sdkLanguage = inspectedContext._browser.options.sdkLanguage; + const headed = !!inspectedContext._browser.options.headful; const recorderPlaywright = (require('../playwright').createPlaywright as typeof import('../playwright').createPlaywright)('javascript', true); const args = [ '--app=data:text/html,', @@ -122,7 +135,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { }); const [page] = context.pages(); - const result = new RecorderApp(page, context._browser.options.wsEndpoint); + const result = new RecorderApp(recorder, page, context._browser.options.wsEndpoint); await result._init(); return result; } @@ -161,6 +174,10 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { } async setSelector(selector: string, focus?: boolean): Promise { + if (focus) { + this._recorder.setMode('none'); + this._page.bringToFront(); + } await this._page.mainFrame().evaluateExpression(((arg: any) => { window.playwrightSetSelector(arg.selector, arg.focus); }).toString(), true, { selector, focus }, 'main').catch(() => {}); @@ -171,19 +188,4 @@ export class RecorderApp extends EventEmitter implements IRecorderApp { window.playwrightUpdateLogs(callLogs); }).toString(), true, callLogs, 'main').catch(() => {}); } - - async bringToFront() { - await this._page.bringToFront(); - } -} - -class HeadlessRecorderApp extends EventEmitter implements IRecorderApp { - async close(): Promise {} - async setPaused(paused: boolean): Promise {} - async setMode(mode: 'none' | 'recording' | 'inspecting'): Promise {} - async setFileIfNeeded(file: string): Promise {} - async setSelector(selector: string, focus?: boolean): Promise {} - async updateCallLogs(callLogs: CallLog[]): Promise {} - bringToFront(): void {} - async setSources(sources: Source[]): Promise {} } diff --git a/tests/library/inspector/inspectorTest.ts b/tests/library/inspector/inspectorTest.ts index a422e866f9..2552b209ff 100644 --- a/tests/library/inspector/inspectorTest.ts +++ b/tests/library/inspector/inspectorTest.ts @@ -65,7 +65,7 @@ export const test = contextTest.extend({ openRecorder: async ({ page, recorderPageGetter }, run) => { await run(async () => { - await (page.context() as any)._enableRecorder({ language: 'javascript', startRecording: true }); + await (page.context() as any)._enableRecorder({ language: 'javascript', mode: 'recording' }); return new Recorder(page, await recorderPageGetter()); }); },