chore: allow inspecting element from server (#16324)
This commit is contained in:
Родитель
850e14eccf
Коммит
8ed238843b
|
@ -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);
|
||||
|
|
|
@ -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() === '<EOL>')
|
||||
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<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,
|
||||
device?: string,
|
||||
saveStorage?: string,
|
||||
startRecording?: boolean,
|
||||
mode?: 'recording' | 'inspecting',
|
||||
outputFile?: string
|
||||
}) {
|
||||
await this._channel.recorderSupplementEnable(params);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -937,7 +937,11 @@ BrowserContext:
|
|||
experimental: True
|
||||
parameters:
|
||||
language: string?
|
||||
startRecording: boolean?
|
||||
mode:
|
||||
type: enum?
|
||||
literals:
|
||||
- inspecting
|
||||
- recording
|
||||
pauseOnNextStatement: boolean?
|
||||
launchOptions: json?
|
||||
contextOptions: json?
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -62,6 +62,10 @@ export class PlaywrightServer {
|
|||
this._preLaunchedPlaywright = createPlaywright('javascript');
|
||||
}
|
||||
|
||||
preLaunchedPlaywright(): Playwright | null {
|
||||
return this._preLaunchedPlaywright;
|
||||
}
|
||||
|
||||
async listen(port: number = 0): Promise<string> {
|
||||
const server = http.createServer((request, response) => {
|
||||
response.end('Running');
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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<string, CallMetadata>();
|
||||
private _debugger: Debugger;
|
||||
private _contextRecorder: ContextRecorder;
|
||||
private _recorderAppFactory: (recorder: Recorder) => Promise<IRecorderApp>;
|
||||
|
||||
static showInspector(context: BrowserContext) {
|
||||
Recorder.show(context, {}).catch(() => {});
|
||||
}
|
||||
|
||||
static show(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise<Recorder> {
|
||||
let recorderPromise = (context as any)[symbol] as Promise<Recorder>;
|
||||
static show(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}, recorderAppFactory = Recorder.defaultRecorderAppFactory): Promise<Recorder> {
|
||||
let recorderPromise = (context as any)[recorderSymbol] as Promise<Recorder>;
|
||||
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<IRecorderApp>) {
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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<void>;
|
||||
setSelector(selector: string, focus?: boolean): Promise<void>;
|
||||
updateCallLogs(callLogs: CallLog[]): Promise<void>;
|
||||
bringToFront(): 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 {
|
||||
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<IRecorderApp> {
|
||||
if (process.env.PW_CODEGEN_NO_INSPECTOR)
|
||||
return new HeadlessRecorderApp();
|
||||
static async open(recorder: Recorder, inspectedContext: BrowserContext): Promise<IRecorderApp> {
|
||||
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<void> {
|
||||
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<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) => {
|
||||
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());
|
||||
});
|
||||
},
|
||||
|
|
Загрузка…
Ссылка в новой задаче