chore: allow inspecting element from server (#16324)

This commit is contained in:
Pavel Feldman 2022-08-05 19:34:57 -07:00 коммит произвёл GitHub
Родитель 850e14eccf
Коммит 8ed238843b
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
12 изменённых файлов: 117 добавлений и 48 удалений

Просмотреть файл

@ -570,7 +570,7 @@ async function codegen(options: Options, url: string | undefined, language: stri
contextOptions, contextOptions,
device: options.device, device: options.device,
saveStorage: options.saveStorage, saveStorage: options.saveStorage,
startRecording: true, mode: 'recording',
outputFile: outputFile ? path.resolve(outputFile) : undefined outputFile: outputFile ? path.resolve(outputFile) : undefined
}); });
await openPage(context, url); await openPage(context, url);

Просмотреть файл

@ -21,9 +21,13 @@ import * as playwright from '../..';
import type { BrowserType } from '../client/browserType'; import type { BrowserType } from '../client/browserType';
import type { LaunchServerOptions } from '../client/types'; import type { LaunchServerOptions } from '../client/types';
import { createPlaywright, DispatcherConnection, Root, PlaywrightDispatcher } from '../server'; import { createPlaywright, DispatcherConnection, Root, PlaywrightDispatcher } from '../server';
import type { Playwright } from '../server';
import { IpcTransport, PipeTransport } from '../protocol/transport'; import { IpcTransport, PipeTransport } from '../protocol/transport';
import { PlaywrightServer } from '../remote/playwrightServer'; import { PlaywrightServer } from '../remote/playwrightServer';
import { gracefullyCloseAll } from '../utils/processLauncher'; 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() { export function printApiJson() {
// Note: this file is generated by build-playwright-driver.sh // 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)); process.on('exit', () => server.close().catch(console.error));
console.log('Listening on ' + wsEndpoint); // eslint-disable-line no-console console.log('Listening on ' + wsEndpoint); // eslint-disable-line no-console
process.stdin.on('close', () => selfDestruct()); process.stdin.on('close', () => selfDestruct());
process.stdin.on('data', data => { if (process.send && server.preLaunchedPlaywright())
if (data.toString() === '<EOL>') wireController(server.preLaunchedPlaywright()!, wsEndpoint);
selfDestruct();
});
} }
export async function launchBrowserServer(browserName: string, configFile?: string) { export async function launchBrowserServer(browserName: string, configFile?: string) {
@ -77,3 +79,42 @@ function selfDestruct() {
process.exit(0); 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<Recorder[]> {
const contexts = new Set<BrowserContext>();
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<void> {
process.send!({ method: 'inspectRequested', params: { selector } });
}
}

Просмотреть файл

@ -373,7 +373,7 @@ export class BrowserContext extends ChannelOwner<channels.BrowserContextChannel>
contextOptions?: BrowserContextOptions, contextOptions?: BrowserContextOptions,
device?: string, device?: string,
saveStorage?: string, saveStorage?: string,
startRecording?: boolean, mode?: 'recording' | 'inspecting',
outputFile?: string outputFile?: string
}) { }) {
await this._channel.recorderSupplementEnable(params); await this._channel.recorderSupplementEnable(params);

Просмотреть файл

@ -1416,7 +1416,7 @@ export type BrowserContextPauseOptions = {};
export type BrowserContextPauseResult = void; export type BrowserContextPauseResult = void;
export type BrowserContextRecorderSupplementEnableParams = { export type BrowserContextRecorderSupplementEnableParams = {
language?: string, language?: string,
startRecording?: boolean, mode?: 'inspecting' | 'recording',
pauseOnNextStatement?: boolean, pauseOnNextStatement?: boolean,
launchOptions?: any, launchOptions?: any,
contextOptions?: any, contextOptions?: any,
@ -1426,7 +1426,7 @@ export type BrowserContextRecorderSupplementEnableParams = {
}; };
export type BrowserContextRecorderSupplementEnableOptions = { export type BrowserContextRecorderSupplementEnableOptions = {
language?: string, language?: string,
startRecording?: boolean, mode?: 'inspecting' | 'recording',
pauseOnNextStatement?: boolean, pauseOnNextStatement?: boolean,
launchOptions?: any, launchOptions?: any,
contextOptions?: any, contextOptions?: any,

Просмотреть файл

@ -937,7 +937,11 @@ BrowserContext:
experimental: True experimental: True
parameters: parameters:
language: string? language: string?
startRecording: boolean? mode:
type: enum?
literals:
- inspecting
- recording
pauseOnNextStatement: boolean? pauseOnNextStatement: boolean?
launchOptions: json? launchOptions: json?
contextOptions: json? contextOptions: json?

Просмотреть файл

@ -765,7 +765,7 @@ scheme.BrowserContextPauseParams = tOptional(tObject({}));
scheme.BrowserContextPauseResult = tOptional(tObject({})); scheme.BrowserContextPauseResult = tOptional(tObject({}));
scheme.BrowserContextRecorderSupplementEnableParams = tObject({ scheme.BrowserContextRecorderSupplementEnableParams = tObject({
language: tOptional(tString), language: tOptional(tString),
startRecording: tOptional(tBoolean), mode: tOptional(tEnum(['inspecting', 'recording'])),
pauseOnNextStatement: tOptional(tBoolean), pauseOnNextStatement: tOptional(tBoolean),
launchOptions: tOptional(tAny), launchOptions: tOptional(tAny),
contextOptions: tOptional(tAny), contextOptions: tOptional(tAny),

Просмотреть файл

@ -62,6 +62,10 @@ export class PlaywrightServer {
this._preLaunchedPlaywright = createPlaywright('javascript'); this._preLaunchedPlaywright = createPlaywright('javascript');
} }
preLaunchedPlaywright(): Playwright | null {
return this._preLaunchedPlaywright;
}
async listen(port: number = 0): Promise<string> { async listen(port: number = 0): Promise<string> {
const server = http.createServer((request, response) => { const server = http.createServer((request, response) => {
response.end('Running'); response.end('Running');

Просмотреть файл

@ -85,7 +85,7 @@ class Recorder {
} }
private async _pollRecorderMode() { private async _pollRecorderMode() {
const pollPeriod = 1000; const pollPeriod = 500;
if (this._pollRecorderModeTimer) if (this._pollRecorderModeTimer)
clearTimeout(this._pollRecorderModeTimer); clearTimeout(this._pollRecorderModeTimer);
const state = await globalThis.__pw_recorderState().catch(e => null); const state = await globalThis.__pw_recorderState().catch(e => null);

Просмотреть файл

@ -69,6 +69,10 @@ export class Playwright extends SdkObject {
allBrowsers(): Browser[] { allBrowsers(): Browser[] {
return [...this._allBrowsers]; return [...this._allBrowsers];
} }
allPages(): Page[] {
return [...this._allPages];
}
} }
export function createPlaywright(sdkLanguage: string, isInternalPlaywright: boolean = false) { export function createPlaywright(sdkLanguage: string, isInternalPlaywright: boolean = false) {

Просмотреть файл

@ -29,6 +29,7 @@ import { CSharpLanguageGenerator } from './recorder/csharp';
import { PythonLanguageGenerator } from './recorder/python'; import { PythonLanguageGenerator } from './recorder/python';
import * as recorderSource from '../generated/recorderSource'; import * as recorderSource from '../generated/recorderSource';
import * as consoleApiSource from '../generated/consoleApiSource'; import * as consoleApiSource from '../generated/consoleApiSource';
import { EmptyRecorderApp } from './recorder/recorderApp';
import type { IRecorderApp } from './recorder/recorderApp'; import type { IRecorderApp } from './recorder/recorderApp';
import { RecorderApp } from './recorder/recorderApp'; import { RecorderApp } from './recorder/recorderApp';
import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation'; import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation';
@ -42,7 +43,7 @@ import { raceAgainstTimeout } from '../utils/timeoutRunner';
type BindingSource = { frame: Frame, page: Page }; type BindingSource = { frame: Frame, page: Page };
const symbol = Symbol('RecorderSupplement'); const recorderSymbol = Symbol('recorderSymbol');
export class Recorder implements InstrumentationListener { export class Recorder implements InstrumentationListener {
private _context: BrowserContext; private _context: BrowserContext;
@ -55,31 +56,39 @@ export class Recorder implements InstrumentationListener {
private _allMetadatas = new Map<string, CallMetadata>(); private _allMetadatas = new Map<string, CallMetadata>();
private _debugger: Debugger; private _debugger: Debugger;
private _contextRecorder: ContextRecorder; private _contextRecorder: ContextRecorder;
private _recorderAppFactory: (recorder: Recorder) => Promise<IRecorderApp>;
static showInspector(context: BrowserContext) { static showInspector(context: BrowserContext) {
Recorder.show(context, {}).catch(() => {}); Recorder.show(context, {}).catch(() => {});
} }
static show(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<Recorder> { static show(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}, recorderAppFactory = Recorder.defaultRecorderAppFactory): Promise<Recorder> {
let recorderPromise = (context as any)[symbol] as Promise<Recorder>; let recorderPromise = (context as any)[recorderSymbol] as Promise<Recorder>;
if (!recorderPromise) { if (!recorderPromise) {
const recorder = new Recorder(context, params); const recorder = new Recorder(context, params, recorderAppFactory);
recorderPromise = recorder.install().then(() => recorder); recorderPromise = recorder.install().then(() => recorder);
(context as any)[symbol] = recorderPromise; (context as any)[recorderSymbol] = recorderPromise;
} }
return recorderPromise; return recorderPromise;
} }
constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) { constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams, recorderAppFactory: (recorder: Recorder) => Promise<IRecorderApp>) {
this._mode = params.startRecording ? 'recording' : 'none'; this._mode = params.mode || 'none';
this._recorderAppFactory = recorderAppFactory;
this._contextRecorder = new ContextRecorder(context, params); this._contextRecorder = new ContextRecorder(context, params);
this._context = context; this._context = context;
this._debugger = Debugger.lookup(context)!; this._debugger = Debugger.lookup(context)!;
context.instrumentation.addListener(this, 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() { 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; this._recorderApp = recorderApp;
recorderApp.once('close', () => { recorderApp.once('close', () => {
this._debugger.resume(false); this._debugger.resume(false);
@ -87,7 +96,7 @@ export class Recorder implements InstrumentationListener {
}); });
recorderApp.on('event', (data: EventData) => { recorderApp.on('event', (data: EventData) => {
if (data.event === 'setMode') { if (data.event === 'setMode') {
this._setMode(data.params.mode); this.setMode(data.params.mode);
this._refreshOverlay(); this._refreshOverlay();
return; return;
} }
@ -149,9 +158,7 @@ export class Recorder implements InstrumentationListener {
}); });
await this._context.exposeBinding('__pw_recorderSetSelector', false, async (_, selector: string) => { await this._context.exposeBinding('__pw_recorderSetSelector', false, async (_, selector: string) => {
this._setMode('none');
await this._recorderApp?.setSelector(selector, true); await this._recorderApp?.setSelector(selector, true);
await this._recorderApp?.bringToFront();
}); });
await this._context.exposeBinding('__pw_resume', false, () => { await this._context.exposeBinding('__pw_resume', false, () => {
@ -179,15 +186,22 @@ export class Recorder implements InstrumentationListener {
this.updateCallLog([...this._currentCallsMetadata.keys()]); this.updateCallLog([...this._currentCallsMetadata.keys()]);
} }
private _setMode(mode: Mode) { setMode(mode: Mode) {
if (this._mode === mode)
return;
this._mode = mode; this._mode = mode;
this._recorderApp?.setMode(this._mode); this._recorderApp?.setMode(this._mode);
this._contextRecorder.setEnabled(this._mode === 'recording'); this._contextRecorder.setEnabled(this._mode === 'recording');
this._debugger.setMuted(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(() => {}); this._context.pages()[0].bringToFront().catch(() => {});
} }
setHighlightedSelector(selector: string) {
this._highlightedSelector = selector;
this._refreshOverlay();
}
private _refreshOverlay() { private _refreshOverlay() {
for (const page of this._context.pages()) for (const page of this._context.pages())
page.mainFrame().evaluateExpression('window.__pw_refreshOverlay()', false, undefined, 'main').catch(() => {}); page.mainFrame().evaluateExpression('window.__pw_refreshOverlay()', false, undefined, 'main').catch(() => {});
@ -320,7 +334,7 @@ class ContextRecorder extends EventEmitter {
const orderedLanguages = [primaryLanguage, ...languages]; const orderedLanguages = [primaryLanguage, ...languages];
this._recorderSources = []; 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; const throttledOutputFile = params.outputFile ? new ThrottledFile(params.outputFile) : null;
generator.on('change', () => { generator.on('change', () => {
this._recorderSources = []; this._recorderSources = [];
@ -421,7 +435,7 @@ class ContextRecorder extends EventEmitter {
clearScript(): void { clearScript(): void {
this._generator.restart(); this._generator.restart();
if (!!this._params.startRecording) { if (this._params.mode === 'recording') {
for (const page of this._context.pages()) for (const page of this._context.pages())
this._onFrameNavigated(page.mainFrame(), page); this._onFrameNavigated(page.mainFrame(), page);
} }

Просмотреть файл

@ -25,6 +25,8 @@ import { isUnderTest } from '../../utils';
import { mime } from '../../utilsBundle'; import { mime } from '../../utilsBundle';
import { installAppIcon } from '../chromium/crApp'; import { installAppIcon } from '../chromium/crApp';
import { findChromiumChannel } from '../registry'; import { findChromiumChannel } from '../registry';
import type { Recorder } from '../recorder';
import type { BrowserContext } from '../browserContext';
declare global { declare global {
interface Window { interface Window {
@ -45,17 +47,28 @@ export interface IRecorderApp extends EventEmitter {
setFileIfNeeded(file: string): Promise<void>; setFileIfNeeded(file: string): Promise<void>;
setSelector(selector: string, focus?: boolean): Promise<void>; setSelector(selector: string, focus?: boolean): Promise<void>;
updateCallLogs(callLogs: CallLog[]): Promise<void>; updateCallLogs(callLogs: CallLog[]): Promise<void>;
bringToFront(): void;
setSources(sources: Source[]): Promise<void>; setSources(sources: Source[]): Promise<void>;
} }
export class EmptyRecorderApp extends EventEmitter implements IRecorderApp {
async close(): Promise<void> {}
async setPaused(paused: boolean): Promise<void> {}
async setMode(mode: 'none' | 'recording' | 'inspecting'): Promise<void> {}
async setFileIfNeeded(file: string): Promise<void> {}
async setSelector(selector: string, focus?: boolean): Promise<void> {}
async updateCallLogs(callLogs: CallLog[]): Promise<void> {}
async setSources(sources: Source[]): Promise<void> {}
}
export class RecorderApp extends EventEmitter implements IRecorderApp { export class RecorderApp extends EventEmitter implements IRecorderApp {
private _page: Page; private _page: Page;
readonly wsEndpoint: string | undefined; readonly wsEndpoint: string | undefined;
private _recorder: Recorder;
constructor(page: Page, wsEndpoint: string | undefined) { constructor(recorder: Recorder, page: Page, wsEndpoint: string | undefined) {
super(); super();
this.setMaxListeners(0); this.setMaxListeners(0);
this._recorder = recorder;
this._page = page; this._page = page;
this.wsEndpoint = wsEndpoint; this.wsEndpoint = wsEndpoint;
} }
@ -96,9 +109,9 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
await mainFrame.goto(serverSideCallMetadata(), 'https://playwright/index.html'); await mainFrame.goto(serverSideCallMetadata(), 'https://playwright/index.html');
} }
static async open(sdkLanguage: string, headed: boolean): Promise<IRecorderApp> { static async open(recorder: Recorder, inspectedContext: BrowserContext): Promise<IRecorderApp> {
if (process.env.PW_CODEGEN_NO_INSPECTOR) const sdkLanguage = inspectedContext._browser.options.sdkLanguage;
return new HeadlessRecorderApp(); const headed = !!inspectedContext._browser.options.headful;
const recorderPlaywright = (require('../playwright').createPlaywright as typeof import('../playwright').createPlaywright)('javascript', true); const recorderPlaywright = (require('../playwright').createPlaywright as typeof import('../playwright').createPlaywright)('javascript', true);
const args = [ const args = [
'--app=data:text/html,', '--app=data:text/html,',
@ -122,7 +135,7 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
}); });
const [page] = context.pages(); 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(); await result._init();
return result; return result;
} }
@ -161,6 +174,10 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
} }
async setSelector(selector: string, focus?: boolean): Promise<void> { async setSelector(selector: string, focus?: boolean): Promise<void> {
if (focus) {
this._recorder.setMode('none');
this._page.bringToFront();
}
await this._page.mainFrame().evaluateExpression(((arg: any) => { await this._page.mainFrame().evaluateExpression(((arg: any) => {
window.playwrightSetSelector(arg.selector, arg.focus); window.playwrightSetSelector(arg.selector, arg.focus);
}).toString(), true, { selector, focus }, 'main').catch(() => {}); }).toString(), true, { selector, focus }, 'main').catch(() => {});
@ -171,19 +188,4 @@ export class RecorderApp extends EventEmitter implements IRecorderApp {
window.playwrightUpdateLogs(callLogs); window.playwrightUpdateLogs(callLogs);
}).toString(), true, callLogs, 'main').catch(() => {}); }).toString(), true, callLogs, 'main').catch(() => {});
} }
async bringToFront() {
await this._page.bringToFront();
}
}
class HeadlessRecorderApp extends EventEmitter implements IRecorderApp {
async close(): Promise<void> {}
async setPaused(paused: boolean): Promise<void> {}
async setMode(mode: 'none' | 'recording' | 'inspecting'): Promise<void> {}
async setFileIfNeeded(file: string): Promise<void> {}
async setSelector(selector: string, focus?: boolean): Promise<void> {}
async updateCallLogs(callLogs: CallLog[]): Promise<void> {}
bringToFront(): void {}
async setSources(sources: Source[]): Promise<void> {}
} }

Просмотреть файл

@ -65,7 +65,7 @@ export const test = contextTest.extend<CLITestArgs>({
openRecorder: async ({ page, recorderPageGetter }, run) => { openRecorder: async ({ page, recorderPageGetter }, run) => {
await run(async () => { 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()); return new Recorder(page, await recorderPageGetter());
}); });
}, },