UNPKG

chrome-devtools-frontend

Version:
730 lines (683 loc) • 30.5 kB
// Copyright 2018 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /* eslint-disable rulesdir/no-imperative-dom-api */ import '../../ui/legacy/legacy.js'; import '../../ui/legacy/components/data_grid/data_grid.js'; import * as Host from '../../core/host/host.js'; import * as i18n from '../../core/i18n/i18n.js'; import * as Platform from '../../core/platform/platform.js'; import * as ProtocolClient from '../../core/protocol_client/protocol_client.js'; import * as SDK from '../../core/sdk/sdk.js'; import * as Bindings from '../../models/bindings/bindings.js'; import * as TextUtils from '../../models/text_utils/text_utils.js'; import * as Buttons from '../../ui/components/buttons/buttons.js'; import * as SourceFrame from '../../ui/legacy/components/source_frame/source_frame.js'; import * as UI from '../../ui/legacy/legacy.js'; import {Directives, html, render} from '../../ui/lit/lit.js'; import * as VisualLogging from '../../ui/visual_logging/visual_logging.js'; import {Events as JSONEditorEvents, JSONEditor, type Parameter} from './JSONEditor.js'; import protocolMonitorStyles from './protocolMonitor.css.js'; const {styleMap} = Directives; const {widgetConfig, widgetRef} = UI.Widget; const UIStrings = { /** *@description Text for one or a group of functions */ method: 'Method', /** * @description Text in Protocol Monitor. Title for a table column which shows in which direction * the particular protocol message was travelling. Values in this column will either be 'sent' or * 'received'. */ type: 'Type', /** * @description Text in Protocol Monitor of the Protocol Monitor tab. Noun relating to a network request. */ request: 'Request', /** *@description Title of a cell content in protocol monitor. A Network response refers to the act of acknowledging a network request. Should not be confused with answer. */ response: 'Response', /** *@description Text for timestamps of items */ timestamp: 'Timestamp', /** *@description Title of a cell content in protocol monitor. It describes the time between sending a request and receiving a response. */ elapsedTime: 'Elapsed time', /** *@description Text in Protocol Monitor of the Protocol Monitor tab */ target: 'Target', /** *@description Text to record a series of actions for analysis */ record: 'Record', /** *@description Text to clear everything */ clearAll: 'Clear all', /** *@description Text to filter result items */ filter: 'Filter', /** *@description Text for the documentation of something */ documentation: 'Documentation', /** *@description Text to open the CDP editor with the selected command */ editAndResend: 'Edit and resend', /** *@description Cell text content in Protocol Monitor of the Protocol Monitor tab *@example {30} PH1 */ sMs: '{PH1} ms', /** *@description Text in Protocol Monitor of the Protocol Monitor tab */ noMessageSelected: 'No message selected', /** *@description Text in Protocol Monitor of the Protocol Monitor tab if no message is selected */ selectAMessageToView: 'Select a message to see its details', /** *@description Text in Protocol Monitor for the save button */ save: 'Save', /** *@description Text in Protocol Monitor to describe the sessions column */ session: 'Session', /** *@description A placeholder for an input in Protocol Monitor. The input accepts commands that are sent to the backend on Enter. CDP stands for Chrome DevTools Protocol. */ sendRawCDPCommand: 'Send a raw `CDP` command', /** * @description A tooltip text for the input in the Protocol Monitor panel. The tooltip describes what format is expected. */ sendRawCDPCommandExplanation: 'Format: `\'Domain.commandName\'` for a command without parameters, or `\'{"command":"Domain.commandName", "parameters": {...}}\'` as a JSON object for a command with parameters. `\'cmd\'`/`\'method\'` and `\'args\'`/`\'params\'`/`\'arguments\'` are also supported as alternative keys for the `JSON` object.', /** * @description A label for a select input that allows selecting a CDP target to send the commands to. */ selectTarget: 'Select a target', /** * @description Tooltip for the the console sidebar toggle in the Console panel. Command to * open/show the sidebar. */ showCDPCommandEditor: 'Show CDP command editor', /** * @description Tooltip for the the console sidebar toggle in the Console panel. Command to * open/show the sidebar. */ hideCDPCommandEditor: 'Hide CDP command editor', } as const; const str_ = i18n.i18n.registerUIStrings('panels/protocol_monitor/ProtocolMonitor.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export const buildProtocolMetadata = (domains: Iterable<ProtocolDomain>): Map<string, {parameters: Parameter[], description: string, replyArgs: string[]}> => { const metadataByCommand = new Map<string, {parameters: Parameter[], description: string, replyArgs: string[]}>(); for (const domain of domains) { for (const command of Object.keys(domain.metadata)) { metadataByCommand.set(command, domain.metadata[command]); } } return metadataByCommand; }; const metadataByCommand = buildProtocolMetadata( ProtocolClient.InspectorBackend.inspectorBackend.agentPrototypes.values() as Iterable<ProtocolDomain>); const typesByName = ProtocolClient.InspectorBackend.inspectorBackend.typeMap as Map<string, Parameter[]>; const enumsByName = ProtocolClient.InspectorBackend.inspectorBackend.enumMap as Map<string, Record<string, string>>; export interface Message { id?: number; method: string; error?: Record<string, unknown>; result?: Record<string, unknown>; params?: Record<string, unknown>; requestTime: number; elapsedTime?: number; sessionId?: string; target?: SDK.Target.Target; } export interface LogMessage { id?: number; domain: string; method: string; params: Object; type: 'send'|'recv'; } export interface ProtocolDomain { readonly domain: string; readonly metadata: Record<string, {parameters: Parameter[], description: string, replyArgs: string[]}>; } export interface ViewInput { messages: Message[]; selectedMessage?: Message; sidebarVisible: boolean; command: string; commandSuggestions: string[]; filterKeys: string[]; filter: string; parseFilter: (filter: string) => TextUtils.TextUtils.ParsedFilter[]; onRecord: (e: Event) => void; onClear: () => void; onSave: () => void; onSplitChange: (e: CustomEvent<string>) => void; onSelect: (e: CustomEvent<HTMLElement|null>) => void; onContextMenu: (e: CustomEvent<{menu: UI.ContextMenu.ContextMenu, element: HTMLElement}>) => void; onFilterChanged: (e: CustomEvent<string>) => void; onCommandChange: (e: CustomEvent<string>) => void; onCommandSubmitted: (e: CustomEvent<string>) => void; onTargetChange: (e: Event) => void; onToggleSidebar: (e: Event) => void; targets: SDK.Target.Target[]; selectedTargetId: string; } export interface ViewOutput { editorWidget: JSONEditor; } export type View = (input: ViewInput, output: ViewOutput, target: HTMLElement) => void; export const DEFAULT_VIEW: View = (input, output, target) => { // clang-format off render(html` <style>${UI.inspectorCommonStyles}</style> <style>${protocolMonitorStyles}</style> <devtools-split-view name="protocol-monitor-split-container" direction="column" sidebar-initial-size="400" sidebar-visibility=${input.sidebarVisible ? 'visible' : 'hidden'} @change=${input.onSplitChange}> <div slot="main" class="vbox"> <devtools-toolbar class="protocol-monitor-toolbar" jslog=${VisualLogging.toolbar('top')}> <devtools-button title=${i18nString(UIStrings.record)} .iconName=${'record-start'} .toggledIconName=${'record-stop'} .jslogContext=${'protocol-monitor.toggle-recording'} .variant=${Buttons.Button.Variant.ICON_TOGGLE} .toggleType=${Buttons.Button.ToggleType.RED} .toggled=${true} @click=${input.onRecord}></devtools-button> <devtools-button title=${i18nString(UIStrings.clearAll)} .iconName=${'clear'} .variant=${Buttons.Button.Variant.TOOLBAR} .jslogContext=${'protocol-monitor.clear-all'} @click=${input.onClear}></devtools-button> <devtools-button title=${i18nString(UIStrings.save)} .iconName=${'download'} .variant=${Buttons.Button.Variant.TOOLBAR} .jslogContext=${'protocol-monitor.save'} @click=${input.onSave}></devtools-button> <devtools-toolbar-input type="filter" list="filter-suggestions" style="flex-grow: 1" value=${input.filter} @change=${input.onFilterChanged}> <datalist id="filter-suggestions"> ${input.filterKeys.map(key => html` <option value=${key + ':'}></option> <option value=${'-' + key + ':'}></option>`)} </datalist> </devtools-toolbar-input> </devtools-toolbar> <devtools-split-view direction="column" sidebar-position="second" name="protocol-monitor-panel-split" sidebar-initial-size="250"> <devtools-data-grid striped slot="main" @select=${input.onSelect} @contextmenu=${input.onContextMenu} .filters=${input.parseFilter(input.filter)}> <table> <tr> <th id="type" sortable style="text-align: center" hideable weight="1"> ${i18nString(UIStrings.type)} </th> <th id="method" weight="5"> ${i18nString(UIStrings.method)} </th> <th id="request" hideable weight="5"> ${i18nString(UIStrings.request)} </th> <th id="response" hideable weight="5"> ${i18nString(UIStrings.response)} </th> <th id="elapsed-time" sortable hideable weight="2"> ${i18nString(UIStrings.elapsedTime)} </th> <th id="timestamp" sortable hideable weight="5"> ${i18nString(UIStrings.timestamp)} </th> <th id="target" sortable hideable weight="5"> ${i18nString(UIStrings.target)} </th> <th id="session" sortable hideable weight="5"> ${i18nString(UIStrings.session)} </th> </tr> ${ input.messages.map( (message, index) => html` <tr data-index=${index} style="--override-data-grid-row-background-color: var(--sys-color-surface3)"> ${'id' in message ? html` <td title="sent"> <devtools-icon name="arrow-up-down" style="color: var(--icon-request-response); width: 16px; height: 16px;"> </devtools-icon> </td>` : html` <td title="received"> <devtools-icon name="arrow-down" style="color: var(--icon-request); width: 16px; height: 16px;"> </devtools-icon> </td>`} <td>${message.method}</td> <td>${message.params ? html`<code>${JSON.stringify(message.params)}</code>` : ''}</td> <td> ${message.result ? html`<code>${JSON.stringify(message.result)}</code>` : message.error ? html`<code>${JSON.stringify(message.error)}</code>` : '(pending)'} </td> <td data-value=${message.elapsedTime || 0}> ${!('id' in message) ? '' : message.elapsedTime ? i18nString(UIStrings.sMs, {PH1: String(message.elapsedTime)}) : '(pending)'} </td> <td data-value=${message.requestTime}>${i18nString(UIStrings.sMs, {PH1: String(message.requestTime)})}</td> <td>${targetToString(message.target)}</td> <td>${message.sessionId || ''}</td> </tr>`)} </table> </devtools-data-grid> <devtools-widget .widgetConfig=${widgetConfig(InfoWidget, { request: input.selectedMessage?.params, response: input.selectedMessage?.result || input.selectedMessage?.error, type: !input.selectedMessage ? undefined : ('id' in input?.selectedMessage) ? 'sent' : 'received', })} class="protocol-monitor-info" slot="sidebar"></devtools-widget> </devtools-split-view> <devtools-toolbar class="protocol-monitor-bottom-toolbar" jslog=${VisualLogging.toolbar('bottom')}> <devtools-button .title=${input.sidebarVisible ? i18nString(UIStrings.hideCDPCommandEditor) : i18nString(UIStrings.showCDPCommandEditor)} .iconName=${input.sidebarVisible ? 'left-panel-close' : 'left-panel-open'} .variant=${Buttons.Button.Variant.TOOLBAR} .jslogContext=${'protocol-monitor.toggle-command-editor'} @click=${input.onToggleSidebar}></devtools-button> </devtools-button> <devtools-toolbar-input id="command-input" style=${styleMap({ 'flex-grow': 1, display: input.sidebarVisible ? 'none' : 'flex'})} value=${input.command} list="command-input-suggestions" placeholder=${i18nString(UIStrings.sendRawCDPCommand)} title=${i18nString(UIStrings.sendRawCDPCommandExplanation)} @change=${input.onCommandChange} @submit=${input.onCommandSubmitted}> <datalist id="command-input-suggestions"> ${input.commandSuggestions.map(c => html`<option value=${c}></option>`)} </datalist> </devtools-toolbar-input> <select class="target-selector" title=${i18nString(UIStrings.selectTarget)} style=${styleMap({display: input.sidebarVisible ? 'none' : 'flex'})} jslog=${VisualLogging.dropDown('target-selector').track({change: true})} @change=${input.onTargetChange}> ${input.targets.map(target => html` <option jslog=${VisualLogging.item('target').track({click: true})} value=${target.id()} ?selected=${target.id() === input.selectedTargetId}> ${target.name()} (${target.inspectedURL()}) </option>`)} </select> </devtools-toolbar> </div> <devtools-widget slot="sidebar" .widgetConfig=${widgetConfig(JSONEditor, { metadataByCommand, typesByName, enumsByName})} ${widgetRef(JSONEditor, e => {output.editorWidget = e;})}> </devtools-widget> </devtools-split-view>`, target, {host: input} ); // clang-format on }; export class ProtocolMonitorImpl extends UI.Panel.Panel { private started: boolean; private startTime: number; private readonly messageForId = new Map<number, Message>(); private readonly filterParser: TextUtils.TextUtils.FilterParser; #filterKeys = ['method', 'request', 'response', 'target', 'session']; #commandAutocompleteSuggestionProvider = new CommandAutocompleteSuggestionProvider(); #selectedTargetId: string; #command = ''; #sidebarVisible = false; #view: View; #messages: Message[] = []; #selectedMessage: Message|undefined; #filter = ''; #editorWidget!: JSONEditor; constructor(view: View = DEFAULT_VIEW) { super('protocol-monitor', true); this.#view = view; this.started = false; this.startTime = 0; this.#filterKeys = ['method', 'request', 'response', 'type', 'target', 'session']; this.filterParser = new TextUtils.TextUtils.FilterParser(this.#filterKeys); this.#selectedTargetId = 'main'; this.performUpdate(); this.#editorWidget.addEventListener(JSONEditorEvents.SUBMIT_EDITOR, event => { this.onCommandSend(event.data.command, event.data.parameters, event.data.targetId); }); SDK.TargetManager.TargetManager.instance().addEventListener( SDK.TargetManager.Events.AVAILABLE_TARGETS_CHANGED, () => { this.requestUpdate(); }); } #populateToolbarInput(): void { const commandJson = this.#editorWidget.getCommandJson(); const targetId = this.#editorWidget.targetId; if (targetId) { this.#selectedTargetId = targetId; } if (commandJson) { this.#command = commandJson; this.requestUpdate(); } } override performUpdate(): void { const viewInput = { messages: this.#messages, selectedMessage: this.#selectedMessage, sidebarVisible: this.#sidebarVisible, command: this.#command, commandSuggestions: this.#commandAutocompleteSuggestionProvider.allSuggestions(), filterKeys: this.#filterKeys, filter: this.#filter, parseFilter: this.filterParser.parse.bind(this.filterParser), onSplitChange: (e: CustomEvent<string>) => { if (e.detail === 'OnlyMain') { this.#populateToolbarInput(); this.#sidebarVisible = false; } else { const {command, parameters} = parseCommandInput(this.#command); this.#editorWidget.displayCommand(command, parameters, this.#selectedTargetId); this.#sidebarVisible = true; } this.requestUpdate(); }, onRecord: (e: Event) => { this.setRecording((e.target as Buttons.Button.Button).toggled); }, onClear: () => { this.#messages = []; this.messageForId.clear(); this.requestUpdate(); }, onSave: () => { void this.saveAsFile(); }, onSelect: (e: CustomEvent<HTMLElement|null>) => { const index = parseInt(e.detail?.dataset?.index ?? '', 10); this.#selectedMessage = !isNaN(index) ? this.#messages[index] : undefined; this.requestUpdate(); }, onContextMenu: (e: CustomEvent<{menu: UI.ContextMenu.ContextMenu, element: HTMLElement}>) => { const message = this.#messages[parseInt(e.detail?.element?.dataset?.index || '', 10)]; if (message) { this.#populateContextMenu(e.detail.menu, message); } }, onCommandChange: (e: CustomEvent<string>) => { this.#command = e.detail; }, onCommandSubmitted: (e: CustomEvent<string>) => { this.#commandAutocompleteSuggestionProvider.addEntry(e.detail); const {command, parameters} = parseCommandInput(e.detail); this.onCommandSend(command, parameters, this.#selectedTargetId); }, onFilterChanged: (e: CustomEvent<string>) => { this.#filter = e.detail; this.requestUpdate(); }, onTargetChange: (e: Event) => { if (e.target instanceof HTMLSelectElement) { this.#selectedTargetId = e.target.value; } }, onToggleSidebar: (_e: Event) => { this.#sidebarVisible = !this.#sidebarVisible; this.requestUpdate(); }, targets: SDK.TargetManager.TargetManager.instance().targets(), selectedTargetId: this.#selectedTargetId, }; const that = this; const viewOutput = { set editorWidget(value: JSONEditor) { that.#editorWidget = value; } }; this.#view(viewInput, viewOutput, this.contentElement); } #populateContextMenu(menu: UI.ContextMenu.ContextMenu, message: Message): void { /** * You can click the "Edit and resend" item in the context menu to be * taken to the CDP editor with the filled with the selected command. */ menu.editSection().appendItem(i18nString(UIStrings.editAndResend), () => { if (!this.#selectedMessage) { return; } const parameters = this.#selectedMessage.params as Record<string, unknown>; const targetId = this.#selectedMessage.target?.id() || ''; const command = message.method; this.#command = JSON.stringify({command, parameters}); if (!this.#sidebarVisible) { this.#sidebarVisible = true; this.requestUpdate(); } else { this.#editorWidget.displayCommand(command, parameters, targetId); } }, {jslogContext: 'edit-and-resend', disabled: !('id' in message)}); /** * You can click the "Filter" item in the context menu to filter the * protocol monitor entries to those that match the method of the * current row. */ menu.editSection().appendItem(i18nString(UIStrings.filter), () => { this.#filter = `method:${message.method}`; this.requestUpdate(); }, {jslogContext: 'filter'}); /** * You can click the "Documentation" item in the context menu to be * taken to the CDP Documentation site entry for the given method. */ menu.footerSection().appendItem(i18nString(UIStrings.documentation), () => { const [domain, method] = message.method.split('.'); const type = 'id' in message ? 'method' : 'event'; Host.InspectorFrontendHost.InspectorFrontendHostInstance.openInNewTab( `https://chromedevtools.github.io/devtools-protocol/tot/${domain}#${type}-${method}` as Platform.DevToolsPath.UrlString); }, {jslogContext: 'documentation'}); } onCommandSend(command: string, parameters: object, target?: string): void { const test = ProtocolClient.InspectorBackend.test; const targetManager = SDK.TargetManager.TargetManager.instance(); const selectedTarget = target ? targetManager.targetById(target) : null; const sessionId = selectedTarget ? selectedTarget.sessionId : ''; // TS thinks that properties are read-only because // in TS test is defined as a namespace. // @ts-expect-error test.sendRawMessage(command, parameters, () => {}, sessionId); } override wasShown(): void { if (this.started) { return; } this.started = true; this.startTime = Date.now(); this.setRecording(true); } private setRecording(recording: boolean): void { const test = ProtocolClient.InspectorBackend.test; if (recording) { // @ts-expect-error test.onMessageSent = this.messageSent.bind(this); // @ts-expect-error test.onMessageReceived = this.messageReceived.bind(this); } else { test.onMessageSent = null; test.onMessageReceived = null; } } private messageReceived(message: Message, target: ProtocolClient.InspectorBackend.TargetBase|null): void { if ('id' in message && message.id) { const existingMessage = this.messageForId.get(message.id); if (!existingMessage) { return; } existingMessage.result = message.result; existingMessage.error = message.error; existingMessage.elapsedTime = Date.now() - this.startTime - existingMessage.requestTime; // Now we've updated the message, it won't be updated again, so we can delete it from the tracking map. this.messageForId.delete(message.id); this.requestUpdate(); return; } this.#messages.push({ method: message.method, sessionId: message.sessionId, target: (target ?? undefined) as SDK.Target.Target | undefined, requestTime: Date.now() - this.startTime, result: message.params, }); this.requestUpdate(); } private messageSent( message: {domain: string, method: string, params: Record<string, unknown>, id: number, sessionId?: string}, target: ProtocolClient.InspectorBackend.TargetBase|null): void { const messageRecord = { method: message.method, params: message.params, id: message.id, sessionId: message.sessionId, target: (target ?? undefined) as SDK.Target.Target | undefined, requestTime: Date.now() - this.startTime, }; this.#messages.push(messageRecord); this.requestUpdate(); this.messageForId.set(message.id, messageRecord); } private async saveAsFile(): Promise<void> { const now = new Date(); const fileName = 'ProtocolMonitor-' + Platform.DateUtilities.toISO8601Compact(now) + '.json' as Platform.DevToolsPath.RawPathString; const stream = new Bindings.FileUtils.FileOutputStream(); const accepted = await stream.open(fileName); if (!accepted) { return; } const rowEntries = this.#messages.map(m => ({...m, target: m.target?.id()})); void stream.write(JSON.stringify(rowEntries, null, ' ')); void stream.close(); } } export class CommandAutocompleteSuggestionProvider { #maxHistorySize = 200; #commandHistory = new Set<string>(); constructor(maxHistorySize?: number) { if (maxHistorySize !== undefined) { this.#maxHistorySize = maxHistorySize; } } allSuggestions(): string[] { const newestToOldest = [...this.#commandHistory].reverse(); newestToOldest.push(...metadataByCommand.keys()); return newestToOldest; } buildTextPromptCompletions = async(expression: string, prefix: string, force?: boolean): Promise<UI.SuggestBox.Suggestions> => { if (!prefix && !force && expression) { return []; } const newestToOldest = this.allSuggestions(); return newestToOldest.filter(cmd => cmd.startsWith(prefix)).map(text => ({ text, })); }; addEntry(value: string): void { if (this.#commandHistory.has(value)) { this.#commandHistory.delete(value); } this.#commandHistory.add(value); if (this.#commandHistory.size > this.#maxHistorySize) { const earliestEntry = this.#commandHistory.values().next().value as string; this.#commandHistory.delete(earliestEntry); } } } export class InfoWidget extends UI.Widget.VBox { private readonly tabbedPane: UI.TabbedPane.TabbedPane; request: Record<string, unknown>|undefined; response: Record<string, unknown>|undefined; type: 'sent'|'received'|undefined; selectedTab: 'request'|'response'|undefined; constructor(element: HTMLElement) { super(undefined, undefined, element); this.tabbedPane = new UI.TabbedPane.TabbedPane(); this.tabbedPane.appendTab('request', i18nString(UIStrings.request), new UI.Widget.Widget()); this.tabbedPane.appendTab('response', i18nString(UIStrings.response), new UI.Widget.Widget()); this.tabbedPane.show(this.contentElement); this.tabbedPane.selectTab('response'); this.request = {}; } override performUpdate(): void { if (!this.request && !this.response) { this.tabbedPane.changeTabView( 'request', new UI.EmptyWidget.EmptyWidget( i18nString(UIStrings.noMessageSelected), i18nString(UIStrings.selectAMessageToView))); this.tabbedPane.changeTabView( 'response', new UI.EmptyWidget.EmptyWidget( i18nString(UIStrings.noMessageSelected), i18nString(UIStrings.selectAMessageToView))); return; } const requestEnabled = this.type && this.type === 'sent'; this.tabbedPane.setTabEnabled('request', Boolean(requestEnabled)); if (!requestEnabled) { this.tabbedPane.selectTab('response'); } this.tabbedPane.changeTabView('request', SourceFrame.JSONView.JSONView.createViewSync(this.request || null)); this.tabbedPane.changeTabView('response', SourceFrame.JSONView.JSONView.createViewSync(this.response || null)); if (this.selectedTab) { this.tabbedPane.selectTab(this.selectedTab); } } } export function parseCommandInput(input: string): {command: string, parameters: Record<string, unknown>} { // If input cannot be parsed as json, we assume it's the command name // for a command without parameters. Otherwise, we expect an object // with "command"/"method"/"cmd" and "parameters"/"params"/"args"/"arguments" attributes. let json = null; try { json = JSON.parse(input); } catch { } const command = json ? json.command || json.method || json.cmd || '' : input; const parameters = json?.parameters || json?.params || json?.args || json?.arguments || {}; return {command, parameters}; } function targetToString(target: SDK.Target.Target|undefined): string { if (!target) { return ''; } return target.decorateLabel( `${target.name()} ${target === SDK.TargetManager.TargetManager.instance().rootTarget() ? '' : target.id()}`); }