UNPKG

chrome-devtools-frontend

Version:
1,299 lines (1,218 loc) • 46 kB
// Copyright 2023 The Chromium Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import '../../../ui/components/icon_button/icon_button.js'; import './ExtensionView.js'; import './ControlButton.js'; import './ReplaySection.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 SDK from '../../../core/sdk/sdk.js'; import type * as PublicExtensions from '../../../models/extensions/extensions.js'; import * as CodeMirror from '../../../third_party/codemirror.next/codemirror.next.js'; import type * as PuppeteerReplay from '../../../third_party/puppeteer-replay/puppeteer-replay.js'; import * as Buttons from '../../../ui/components/buttons/buttons.js'; import * as CodeHighlighter from '../../../ui/components/code_highlighter/code_highlighter.js'; import * as Dialogs from '../../../ui/components/dialogs/dialogs.js'; import * as Input from '../../../ui/components/input/input.js'; import type * as Menus from '../../../ui/components/menus/menus.js'; import * as TextEditor from '../../../ui/components/text_editor/text_editor.js'; import * as UI from '../../../ui/legacy/legacy.js'; import * as Lit from '../../../ui/lit/lit.js'; import * as VisualLogging from '../../../ui/visual_logging/visual_logging.js'; import type * as Converters from '../converters/converters.js'; import type * as Extensions from '../extensions/extensions.js'; import * as Models from '../models/models.js'; import {PlayRecordingSpeed} from '../models/RecordingPlayer.js'; import * as Actions from '../recorder-actions/recorder-actions.js'; import recordingViewStyles from './recordingView.css.js'; import type {ReplaySectionData, StartReplayEvent} from './ReplaySection.js'; import { type CopyStepEvent, State, type StepView, type StepViewData, } from './StepView.js'; const {html} = Lit; const UIStrings = { /** * @description Depicts that the recording was done on a mobile device (e.g., a smartphone or tablet). */ mobile: 'Mobile', /** * @description Depicts that the recording was done on a desktop device (e.g., on a PC or laptop). */ desktop: 'Desktop', /** * @description Network latency in milliseconds. * @example {10} value */ latency: 'Latency: {value} ms', /** * @description Upload speed. * @example {42 kB} value */ upload: 'Upload: {value}', /** * @description Download speed. * @example {8 kB} value */ download: 'Download: {value}', /** * @description Title of the button to edit replay settings. */ editReplaySettings: 'Edit replay settings', /** * @description Title of the section that contains replay settings. */ replaySettings: 'Replay settings', /** * @description The string is shown when a default value is used for some replay settings. */ default: 'Default', /** * @description The title of the section with environment settings. */ environment: 'Environment', /** * @description The title of the screenshot image that is shown for every section in the recordign view. */ screenshotForSection: 'Screenshot for this section', /** * @description The title of the button that edits the current recording's title. */ editTitle: 'Edit title', /** * @description The error for when the title is missing. */ requiredTitleError: 'Title is required', /** * @description The status text that is shown while the recording is ongoing. */ recording: 'Recording…', /** * @description The title of the button to end the current recording. */ endRecording: 'End recording', /** * @description The title of the button while the recording is being ended. */ recordingIsBeingStopped: 'Stopping recording…', /** * @description The text that describes a timeout setting of {value} milliseconds. * @example {1000} value */ timeout: 'Timeout: {value} ms', /** * @description The label for the input that allows entering network throttling configuration. */ network: 'Network', /** * @description The label for the input that allows entering timeout (a number in ms) configuration. */ timeoutLabel: 'Timeout', /** * @description The text in a tooltip for the timeout input that explains what timeout settings do. */ timeoutExplanation: 'The timeout setting (in milliseconds) applies to every action when replaying the recording. For example, if a DOM element identified by a CSS selector does not appear on the page within the specified timeout, the replay fails with an error.', /** * @description The label for the button that cancels replaying. */ cancelReplay: 'Cancel replay', /** * @description Button title that shows the code view when clicked. */ showCode: 'Show code', /** * @description Button title that hides the code view when clicked. */ hideCode: 'Hide code', /** * @description Button title that adds an assertion to the step editor. */ addAssertion: 'Add assertion', /** * @description The title of the button that open current recording in Performance panel. */ performancePanel: 'Performance panel', } as const; const str_ = i18n.i18n.registerUIStrings( 'panels/recorder/components/RecordingView.ts', UIStrings, ); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); declare global { interface HTMLElementTagNameMap { 'devtools-recording-view': RecordingView; } } export interface ReplayState { isPlaying: boolean; // Replay is in progress isPausedOnBreakpoint: boolean; // Replay is in progress and is in stopped state } export const enum TargetPanel { PERFORMANCE_PANEL = 'timeline', DEFAULT = 'chrome-recorder', } export interface PlayRecordingEvent { targetPanel: TargetPanel; speed: PlayRecordingSpeed; extension?: Extensions.ExtensionManager.Extension; } const networkConditionPresets = [ SDK.NetworkManager.NoThrottlingConditions, SDK.NetworkManager.OfflineConditions, SDK.NetworkManager.Slow3GConditions, SDK.NetworkManager.Slow4GConditions, SDK.NetworkManager.Fast4GConditions, ]; function converterIdToFlowMetric( converterId: string, ): Host.UserMetrics.RecordingCopiedToClipboard { switch (converterId) { case Models.ConverterIds.ConverterIds.PUPPETEER: case Models.ConverterIds.ConverterIds.PUPPETEER_FIREFOX: return Host.UserMetrics.RecordingCopiedToClipboard.COPIED_RECORDING_WITH_PUPPETEER; case Models.ConverterIds.ConverterIds.JSON: return Host.UserMetrics.RecordingCopiedToClipboard.COPIED_RECORDING_WITH_JSON; case Models.ConverterIds.ConverterIds.REPLAY: return Host.UserMetrics.RecordingCopiedToClipboard.COPIED_RECORDING_WITH_REPLAY; default: return Host.UserMetrics.RecordingCopiedToClipboard.COPIED_RECORDING_WITH_EXTENSION; } } function converterIdToStepMetric( converterId: string, ): Host.UserMetrics.RecordingCopiedToClipboard { switch (converterId) { case Models.ConverterIds.ConverterIds.PUPPETEER: case Models.ConverterIds.ConverterIds.PUPPETEER_FIREFOX: return Host.UserMetrics.RecordingCopiedToClipboard.COPIED_STEP_WITH_PUPPETEER; case Models.ConverterIds.ConverterIds.JSON: return Host.UserMetrics.RecordingCopiedToClipboard.COPIED_STEP_WITH_JSON; case Models.ConverterIds.ConverterIds.REPLAY: return Host.UserMetrics.RecordingCopiedToClipboard.COPIED_STEP_WITH_REPLAY; default: return Host.UserMetrics.RecordingCopiedToClipboard.COPIED_STEP_WITH_EXTENSION; } } function renderSettings({ settings, replaySettingsExpanded, onSelectMenuLabelClick, onNetworkConditionsChange, onTimeoutInput, isRecording, replayState, onReplaySettingsKeydown, onToggleReplaySettings }: ViewInput): Lit.LitTemplate { if (!settings) { return Lit.nothing; } const environmentFragments = []; if (settings.viewportSettings) { // clang-format off environmentFragments.push( html`<div>${ settings.viewportSettings.isMobile ? i18nString(UIStrings.mobile) : i18nString(UIStrings.desktop) }</div>`, ); environmentFragments.push(html`<div class="separator"></div>`); environmentFragments.push( html`<div>${settings.viewportSettings.width}×${ settings.viewportSettings.height } px</div>`, ); // clang-format on } const replaySettingsFragments = []; if (!replaySettingsExpanded) { if (settings.networkConditionsSettings) { if (settings.networkConditionsSettings.title) { // clang-format off replaySettingsFragments.push( html`<div>${ settings.networkConditionsSettings.title }</div>`, ); // clang-format on } else { // clang-format off replaySettingsFragments.push(html`<div> ${i18nString(UIStrings.download, { value: i18n.ByteUtilities.bytesToString( settings.networkConditionsSettings.download, ), })}, ${i18nString(UIStrings.upload, { value: i18n.ByteUtilities.bytesToString( settings.networkConditionsSettings.upload, ), })}, ${i18nString(UIStrings.latency, { value: settings.networkConditionsSettings.latency, })} </div>`); // clang-format on } } else { // clang-format off replaySettingsFragments.push( html`<div>${ SDK.NetworkManager.NoThrottlingConditions.title instanceof Function ? SDK.NetworkManager.NoThrottlingConditions.title() : SDK.NetworkManager.NoThrottlingConditions.title }</div>`, ); // clang-format on } // clang-format off replaySettingsFragments.push(html`<div class="separator"></div>`); replaySettingsFragments.push( html`<div>${i18nString(UIStrings.timeout, { value: settings.timeout || Models.RecordingPlayer.defaultTimeout, })}</div>`, ); // clang-format on } else { // clang-format off const selectedOption = settings.networkConditionsSettings?.i18nTitleKey || SDK.NetworkManager.NoThrottlingConditions.i18nTitleKey; const selectedOptionTitle = networkConditionPresets.find( preset => preset.i18nTitleKey === selectedOption, ); let menuButtonTitle = ''; if (selectedOptionTitle) { menuButtonTitle = selectedOptionTitle.title instanceof Function ? selectedOptionTitle.title() : selectedOptionTitle.title; } replaySettingsFragments.push(html`<div class="editable-setting"> <label class="wrapping-label" @click=${onSelectMenuLabelClick}> ${i18nString(UIStrings.network)} <select title=${menuButtonTitle} jslog=${VisualLogging.dropDown('network-conditions').track({change: true})} @change=${onNetworkConditionsChange}> ${networkConditionPresets.map(condition => html` <option jslog=${VisualLogging.item(Platform.StringUtilities.toKebabCase(condition.i18nTitleKey || ''))} value=${condition.i18nTitleKey || ''} ?selected=${selectedOption === condition.i18nTitleKey}> ${ condition.title instanceof Function ? condition.title() : condition.title } </option>`)} </select> </label> </div>`); replaySettingsFragments.push(html`<div class="editable-setting"> <label class="wrapping-label" title=${i18nString( UIStrings.timeoutExplanation, )}> ${i18nString(UIStrings.timeoutLabel)} <input @input=${onTimeoutInput} required min=${Models.SchemaUtils.minTimeout} max=${Models.SchemaUtils.maxTimeout} value=${ settings.timeout || Models.RecordingPlayer.defaultTimeout } jslog=${VisualLogging.textField('timeout').track({change: true})} class="devtools-text-input" type="number"> </label> </div>`); // clang-format on } const isEditable = !isRecording && !replayState.isPlaying; const replaySettingsButtonClassMap = { 'settings-title': true, expanded: replaySettingsExpanded, }; const replaySettingsClassMap = { expanded: replaySettingsExpanded, settings: true, }; // clang-format off return html` <div class="settings-row"> <div class="settings-container"> <div class=${Lit.Directives.classMap(replaySettingsButtonClassMap)} @keydown=${isEditable && onReplaySettingsKeydown} @click=${isEditable && onToggleReplaySettings} tabindex="0" role="button" jslog=${VisualLogging.action('replay-settings').track({click: true})} aria-label=${i18nString(UIStrings.editReplaySettings)}> <span>${i18nString(UIStrings.replaySettings)}</span> ${ isEditable ? html`<devtools-icon class="chevron" name="triangle-down"> </devtools-icon>` : '' } </div> <div class=${Lit.Directives.classMap(replaySettingsClassMap)}> ${ replaySettingsFragments.length ? replaySettingsFragments : html`<div>${i18nString(UIStrings.default)}</div>` } </div> </div> <div class="settings-container"> <div class="settings-title">${i18nString(UIStrings.environment)}</div> <div class="settings"> ${ environmentFragments.length ? environmentFragments : html`<div>${i18nString(UIStrings.default)}</div>` } </div> </div> </div> `; // clang-format on } function renderTimelineArea(input: ViewInput, output: ViewOutput): Lit.LitTemplate { if (input.extensionDescriptor) { // clang-format off return html` <devtools-recorder-extension-view .descriptor=${input.extensionDescriptor}> </devtools-recorder-extension-view> `; // clang-format on } // clang-format off /* eslint-disable rulesdir/no-deprecated-component-usages */ return html` <devtools-split-view direction="auto" sidebar-position="second" sidebar-initial-size="300" sidebar-visibility=${input.showCodeView ? '' : 'hidden'} > <div slot="main"> ${renderSections(input)} </div> <div slot="sidebar" jslog=${VisualLogging.pane('source-code').track({resize: true})}> ${input.showCodeView ? html` <div class="section-toolbar" jslog=${VisualLogging.toolbar()}> <devtools-select-menu @selectmenuselected=${input.onCodeFormatChange} .showDivider=${true} .showArrow=${true} .sideButton=${false} .showSelectedItem=${true} .position=${Dialogs.Dialog.DialogVerticalPosition.BOTTOM} .buttonTitle=${input.converterName || ''} .jslogContext=${'code-format'} > ${input.builtInConverters.map(converter => { return html`<devtools-menu-item .value=${converter.getId()} .selected=${input.converterId === converter.getId()} jslog=${VisualLogging.action().track({click: true}).context(`converter-${Platform.StringUtilities.toKebabCase(converter.getId())}`)} > ${converter.getFormatName()} </devtools-menu-item>`; })} ${input.extensionConverters.map(converter => { return html`<devtools-menu-item .value=${converter.getId()} .selected=${input.converterId === converter.getId()} jslog=${VisualLogging.action().track({click: true}).context('converter-extension')} > ${converter.getFormatName()} </devtools-menu-item>`; })} </devtools-select-menu> <devtools-button title=${Models.Tooltip.getTooltipForActions( i18nString(UIStrings.hideCode), Actions.RecorderActions.TOGGLE_CODE_VIEW, )} .data=${ { variant: Buttons.Button.Variant.ICON, size: Buttons.Button.Size.SMALL, iconName: 'cross', } as Buttons.Button.ButtonData } @click=${input.showCodeToggle} jslog=${VisualLogging.close().track({click: true})} ></devtools-button> </div> ${renderTextEditor(input, output)}` : Lit.nothing} </div> </devtools-split-view> `; /* eslint-enable rulesdir/no-deprecated-component-usages */ // clang-format on } function renderTextEditor(input: ViewInput, output: ViewOutput): Lit.TemplateResult { if (!input.editorState) { throw new Error('Unexpected: trying to render the text editor without editorState'); } // clang-format off return html` <div class="text-editor" jslog=${VisualLogging.textField().track({change: true})}> <devtools-text-editor .state=${input.editorState} ${Lit.Directives.ref((editor: Element | undefined) => { if (!editor || !(editor instanceof TextEditor.TextEditor.TextEditor)) { return; } output.highlightLinesInEditor = (line: number, length: number, scroll = false) => { const cm = editor.editor; let selection = editor.createSelection( {lineNumber: line + length, columnNumber: 0}, {lineNumber: line, columnNumber: 0}, ); const lastLine = editor.state.doc.lineAt(selection.main.anchor); selection = editor.createSelection( {lineNumber: line + length - 1, columnNumber: lastLine.length + 1}, {lineNumber: line, columnNumber: 0}, ); cm.dispatch({ selection, effects: scroll ? [ CodeMirror.EditorView.scrollIntoView(selection.main, { y: 'nearest', }), ] : undefined, }); }; })}></devtools-text-editor> </div> `; // clang-format on } function renderScreenshot( section: Models.Section.Section, ): Lit.TemplateResult|null { if (!section.screenshot) { return null; } // clang-format off return html` <img class="screenshot" src=${section.screenshot} alt=${i18nString( UIStrings.screenshotForSection, )} /> `; // clang-format on } function renderReplayOrAbortButton(input: ViewInput): Lit.LitTemplate { if (input.replayState.isPlaying) { return html` <devtools-button .jslogContext=${'abort-replay'} @click=${input.onAbortReplay} .iconName=${'pause'} .variant=${ Buttons.Button.Variant.OUTLINED}> ${i18nString(UIStrings.cancelReplay)} </devtools-button>`; } if (!input.recorderSettings) { return Lit.nothing; } // clang-format off return html`<devtools-replay-section .data=${ { settings: input.recorderSettings, replayExtensions: input.replayExtensions, } as ReplaySectionData } .disabled=${input.replayState.isPlaying} @startreplay=${input.onTogglePlaying} > </devtools-replay-section>`; // clang-format on } function renderSections(input: ViewInput): Lit.LitTemplate { // clang-format off return html` <div class="sections"> ${ !input.showCodeView ? html`<div class="section-toolbar"> <devtools-button @click=${input.showCodeToggle} class="show-code" .data=${ { variant: Buttons.Button.Variant.OUTLINED, title: Models.Tooltip.getTooltipForActions( i18nString(UIStrings.showCode), Actions.RecorderActions.TOGGLE_CODE_VIEW, ), } as Buttons.Button.ButtonData } jslog=${VisualLogging.toggleSubpane(Actions.RecorderActions.TOGGLE_CODE_VIEW).track({click: true})} > ${i18nString(UIStrings.showCode)} </devtools-button> </div>` : '' } ${input.sections.map( (section, i) => html` <div class="section"> <div class="screenshot-wrapper"> ${renderScreenshot(section)} </div> <div class="content"> <div class="steps"> <devtools-step-view @click=${input.onStepClick} @mouseover=${input.onStepHover} .data=${ { section, state: input.getSectionState(section), isStartOfGroup: true, isEndOfGroup: section.steps.length === 0, isFirstSection: i === 0, isLastSection: i === input.sections.length - 1 && section.steps.length === 0, isSelected: input.selectedStep === (section.causingStep || null), sectionIndex: i, isRecording: input.isRecording, isPlaying: input.replayState.isPlaying, error: input.getSectionState(section) === State.ERROR ? input.currentError : undefined, hasBreakpoint: false, removable: input.recording.steps.length > 1 && section.causingStep, } as StepViewData } > </devtools-step-view> ${section.steps.map(step => { const stepIndex = input.recording.steps.indexOf(step); return html` <devtools-step-view @click=${input.onStepClick} @mouseover=${input.onStepHover} @copystep=${input.onCopyStep} .data=${ { step, state: input.getStepState(step), error: input.currentStep === step ? input.currentError : undefined, isFirstSection: false, isLastSection: i === input.sections.length - 1 && input.recording.steps[input.recording.steps.length - 1] === step, isStartOfGroup: false, isEndOfGroup: section.steps[section.steps.length - 1] === step, stepIndex, hasBreakpoint: input.breakpointIndexes.has(stepIndex), sectionIndex: -1, isRecording: input.isRecording, isPlaying: input.replayState.isPlaying, removable: input.recording.steps.length > 1, builtInConverters: input.builtInConverters, extensionConverters: input.extensionConverters, isSelected: input.selectedStep === step, recorderSettings: input.recorderSettings, } as StepViewData } jslog=${VisualLogging.section('step').track({click: true})} ></devtools-step-view> `; })} ${!input.recordingTogglingInProgress && input.isRecording && i === input.sections.length - 1 ? html`<devtools-button class="step add-assertion-button" .data=${ { variant: Buttons.Button.Variant.OUTLINED, title: i18nString(UIStrings.addAssertion), jslogContext: 'add-assertion', } as Buttons.Button.ButtonData } @click=${input.onAddAssertion} >${i18nString(UIStrings.addAssertion)}</devtools-button>` : undefined} ${ input.isRecording && i === input.sections.length - 1 ? html`<div class="step recording">${i18nString( UIStrings.recording, )}</div>` : null } </div> </div> </div> `, )} </div> `; // clang-format on } function renderHeader(input: ViewInput): Lit.LitTemplate { if (!input.recording) { return Lit.nothing; } const {title} = input.recording; const isTitleEditable = !input.replayState.isPlaying && !input.isRecording; // clang-format off return html` <div class="header"> <div class="header-title-wrapper"> <div class="header-title"> <input @blur=${input.onTitleBlur} @keydown=${input.onTitleInputKeyDown} id="title-input" jslog=${VisualLogging.value('title').track({change: true})} class=${Lit.Directives.classMap({ 'has-error': input.isTitleInvalid, disabled: !isTitleEditable, })} .value=${Lit.Directives.live(title)} .disabled=${!isTitleEditable} > <div class="title-button-bar"> <devtools-button @click=${input.onEditTitleButtonClick} .data=${ { disabled: !isTitleEditable, variant: Buttons.Button.Variant.TOOLBAR, iconName: 'edit', title: i18nString(UIStrings.editTitle), jslogContext: 'edit-title', } as Buttons.Button.ButtonData } ></devtools-button> </div> </div> ${ input.isTitleInvalid ? html`<div class="title-input-error-text"> ${ i18nString(UIStrings.requiredTitleError) } </div>` : Lit.nothing } </div> ${ !input.isRecording && input.replayAllowed ? html`<div class="actions"> <devtools-button @click=${input.onMeasurePerformanceClick} .data=${ { disabled: input.replayState.isPlaying, variant: Buttons.Button.Variant.OUTLINED, iconName: 'performance', title: i18nString(UIStrings.performancePanel), jslogContext: 'measure-performance', } as Buttons.Button.ButtonData } > ${i18nString(UIStrings.performancePanel)} </devtools-button> <div class="separator"></div> ${renderReplayOrAbortButton(input)} </div>` : Lit.nothing } </div>`; // clang-format on } interface ViewInput { breakpointIndexes: Set<number>; builtInConverters: readonly Converters.Converter.Converter[]; converterId: string; converterName: string|null; currentError: Error|null; currentStep: Models.Schema.Step|null; editorState: CodeMirror.EditorState|null; extensionConverters: readonly Converters.Converter.Converter[]; extensionDescriptor?: PublicExtensions.RecorderPluginManager.ViewDescriptor; isRecording: boolean; isTitleInvalid: boolean; lastReplayResult: Models.RecordingPlayer.ReplayResult|null; recorderSettings: Models.RecorderSettings.RecorderSettings|null; recording: Models.Schema.UserFlow; recordingTogglingInProgress: boolean; replayAllowed: boolean; replayExtensions: Extensions.ExtensionManager.Extension[]; replaySettingsExpanded: boolean; replayState: ReplayState; sections: Models.Section.Section[]; selectedStep: Models.Schema.Step|null; settings: Models.RecordingSettings.RecordingSettings|null; showCodeView: boolean; onAddAssertion: () => void; onRecordingFinished: () => void; getSectionState: (section: Models.Section.Section) => State; getStepState: (step: Models.Schema.Step) => State; onAbortReplay: () => void; onMeasurePerformanceClick: (event: Event) => void; onTogglePlaying: (event: StartReplayEvent) => void; onCodeFormatChange: (event: Menus.SelectMenu.SelectMenuItemSelectedEvent) => void; onCopyStep: (event: CopyStepEvent) => void; onEditTitleButtonClick: (event: Event) => void; onNetworkConditionsChange: (event: Event) => void; onReplaySettingsKeydown: (event: Event) => void; onSelectMenuLabelClick: (event: Event) => void; onStepClick: (event: Event) => void; onStepHover: (event: MouseEvent) => void; onTimeoutInput: (event: Event) => void; onTitleBlur: (event: Event) => void; onTitleInputKeyDown: (event: KeyboardEvent) => void; onToggleReplaySettings: (event: Event) => void; onWrapperClick: () => void; showCodeToggle: () => void; } export interface ViewOutput { highlightLinesInEditor?: (line: number, length: number, scroll?: boolean) => void; } export const DEFAULT_VIEW = (input: ViewInput, output: ViewOutput, target: HTMLElement): void => { const classNames = { wrapper: true, 'is-recording': input.isRecording, 'is-playing': input.replayState.isPlaying, 'was-successful': input.lastReplayResult === Models.RecordingPlayer.ReplayResult.SUCCESS, 'was-failure': input.lastReplayResult === Models.RecordingPlayer.ReplayResult.FAILURE, }; const footerButtonTitle = input.recordingTogglingInProgress ? i18nString(UIStrings.recordingIsBeingStopped) : i18nString(UIStrings.endRecording); // clang-format off Lit.render( html` <style>${UI.inspectorCommonStyles}</style> <style>${recordingViewStyles}</style> <style>${Input.textInputStyles}</style> <div @click=${input.onWrapperClick} class=${Lit.Directives.classMap( classNames, )}> <div class="recording-view main"> ${renderHeader(input)} ${ input.extensionDescriptor ? html` <devtools-recorder-extension-view .descriptor=${ input.extensionDescriptor }></devtools-recorder-extension-view>` : html` ${renderSettings(input)} ${renderTimelineArea(input, output)} `} ${input.isRecording ? html`<div class="footer"> <div class="controls"> <devtools-control-button jslog=${VisualLogging.toggle('toggle-recording').track({click: true})} @click=${input.onRecordingFinished} .disabled=${input.recordingTogglingInProgress} .shape=${'square'} .label=${footerButtonTitle} title=${Models.Tooltip.getTooltipForActions( footerButtonTitle, Actions.RecorderActions.START_RECORDING, )} > </devtools-control-button> </div> </div>`: Lit.nothing} </div> </div> `, target, ); // clang-format on }; export class RecordingView extends UI.Widget.Widget { replayState: ReplayState = {isPlaying: false, isPausedOnBreakpoint: false}; isRecording = false; recordingTogglingInProgress = false; recording: Models.Schema.UserFlow = { title: '', steps: [], }; currentStep?: Models.Schema.Step; currentError?: Error; sections: Models.Section.Section[] = []; settings?: Models.RecordingSettings.RecordingSettings; lastReplayResult?: Models.RecordingPlayer.ReplayResult; replayAllowed = false; breakpointIndexes = new Set<number>(); extensionConverters: readonly Converters.Converter.Converter[] = []; replayExtensions?: Extensions.ExtensionManager.Extension[]; extensionDescriptor?: PublicExtensions.RecorderPluginManager.ViewDescriptor; addAssertion?: () => void; abortReplay?: () => void; recordingFinished?: () => void; playRecording?: (event: PlayRecordingEvent) => void; networkConditionsChanged?: (data?: SDK.NetworkManager.Conditions) => void; timeoutChanged?: (timeout?: number) => void; titleChanged?: (title: string) => void; #recorderSettings?: Models.RecorderSettings.RecorderSettings; get recorderSettings(): Models.RecorderSettings.RecorderSettings|undefined { return this.#recorderSettings; } set recorderSettings(settings: Models.RecorderSettings.RecorderSettings|undefined) { this.#recorderSettings = settings; this.#converterId = this.recorderSettings?.preferredCopyFormat ?? this.#builtInConverters[0]?.getId(); void this.#convertToCode(); } #builtInConverters: readonly Converters.Converter.Converter[] = []; get builtInConverters(): readonly Converters.Converter.Converter[] { return this.#builtInConverters; } set builtInConverters(converters: readonly Converters.Converter.Converter[]) { this.#builtInConverters = converters; this.#converterId = this.recorderSettings?.preferredCopyFormat ?? this.#builtInConverters[0]?.getId(); void this.#convertToCode(); } #isTitleInvalid = false; #selectedStep?: Models.Schema.Step|null; #replaySettingsExpanded = false; #showCodeView = false; #code = ''; #converterId = ''; #sourceMap: PuppeteerReplay.SourceMap|undefined; #editorState?: CodeMirror.EditorState; #onCopyBound = this.#onCopy.bind(this); #view: typeof DEFAULT_VIEW; #viewOutput: ViewOutput = {}; constructor(element?: HTMLElement, view?: typeof DEFAULT_VIEW) { super(element, {useShadowDom: true}); this.#view = view || DEFAULT_VIEW; } override performUpdate(): void { const converter = [ ...(this.builtInConverters || []), ...(this.extensionConverters || []), ].find(converter => converter.getId() === this.#converterId) ?? this.builtInConverters[0]; this.#view( { breakpointIndexes: this.breakpointIndexes, builtInConverters: this.builtInConverters, converterId: this.#converterId, converterName: converter?.getFormatName(), currentError: this.currentError ?? null, currentStep: this.currentStep ?? null, editorState: this.#editorState ?? null, extensionConverters: this.extensionConverters, extensionDescriptor: this.extensionDescriptor, isRecording: this.isRecording, isTitleInvalid: this.#isTitleInvalid, lastReplayResult: this.lastReplayResult ?? null, recorderSettings: this.#recorderSettings ?? null, recording: this.recording, recordingTogglingInProgress: this.recordingTogglingInProgress, replayAllowed: this.replayAllowed, replayExtensions: this.replayExtensions ?? [], replaySettingsExpanded: this.#replaySettingsExpanded, replayState: this.replayState, sections: this.sections, selectedStep: this.#selectedStep ?? null, settings: this.settings ?? null, showCodeView: this.#showCodeView, onAddAssertion: () => { this.addAssertion?.(); }, onRecordingFinished: () => { this.recordingFinished?.(); }, getSectionState: this.#getSectionState.bind(this), getStepState: this.#getStepState.bind(this), onAbortReplay: () => { this.abortReplay?.(); }, onMeasurePerformanceClick: this.#handleMeasurePerformanceClickEvent.bind(this), onTogglePlaying: (event: StartReplayEvent) => { this.playRecording?.({ targetPanel: TargetPanel.DEFAULT, speed: event.speed, extension: event.extension, }); }, onCodeFormatChange: this.#onCodeFormatChange.bind(this), onCopyStep: this.#onCopyStepEvent.bind(this), onEditTitleButtonClick: this.#onEditTitleButtonClick.bind(this), onNetworkConditionsChange: this.#onNetworkConditionsChange.bind(this), onReplaySettingsKeydown: this.#onReplaySettingsKeydown.bind(this), onSelectMenuLabelClick: this.#onSelectMenuLabelClick.bind(this), onStepClick: this.#onStepClick.bind(this), onStepHover: this.#onStepHover.bind(this), onTimeoutInput: this.#onTimeoutInput.bind(this), onTitleBlur: this.#onTitleBlur.bind(this), onTitleInputKeyDown: this.#onTitleInputKeyDown.bind(this), onToggleReplaySettings: this.#onToggleReplaySettings.bind(this), onWrapperClick: this.#onWrapperClick.bind(this), showCodeToggle: this.showCodeToggle.bind(this), }, this.#viewOutput, this.contentElement); } override wasShown(): void { super.wasShown(); document.addEventListener('copy', this.#onCopyBound); this.performUpdate(); } override willHide(): void { super.willHide(); document.removeEventListener('copy', this.#onCopyBound); } scrollToBottom(): void { const wrapper = this.contentElement?.querySelector('.sections'); if (!wrapper) { return; } wrapper.scrollTop = wrapper.scrollHeight; } #getStepState(step: Models.Schema.Step): State { if (!this.currentStep) { return State.DEFAULT; } if (step === this.currentStep) { if (this.currentError) { return State.ERROR; } if (!this.replayState?.isPlaying) { return State.SUCCESS; } if (this.replayState?.isPausedOnBreakpoint) { return State.STOPPED; } return State.CURRENT; } const currentIndex = this.recording.steps.indexOf(this.currentStep); if (currentIndex === -1) { return State.DEFAULT; } const index = this.recording.steps.indexOf(step); return index < currentIndex ? State.SUCCESS : State.OUTSTANDING; } #getSectionState(section: Models.Section.Section): State { const currentStep = this.currentStep; if (!currentStep) { return State.DEFAULT; } const currentSection = this.sections.find( section => section.steps.includes(currentStep), ) as Models.Section.Section; if (!currentSection) { if (this.currentError) { return State.ERROR; } } if (section === currentSection) { return State.SUCCESS; } const index = this.sections.indexOf(currentSection); const ownIndex = this.sections.indexOf(section); return index >= ownIndex ? State.SUCCESS : State.OUTSTANDING; } #onStepHover = (event: MouseEvent): void => { const stepView = event.target as StepView; const step = stepView.step || stepView.section?.causingStep; if (!step || this.#selectedStep) { return; } this.#highlightCodeForStep(step); }; #onStepClick(event: Event): void { event.stopPropagation(); const stepView = event.target as StepView; const selectedStep = stepView.step || stepView.section?.causingStep || null; if (this.#selectedStep === selectedStep) { return; } this.#selectedStep = selectedStep; this.performUpdate(); if (selectedStep) { this.#highlightCodeForStep(selectedStep, /* scroll=*/ true); } } #onWrapperClick(): void { if (this.#selectedStep === undefined) { return; } this.#selectedStep = undefined; this.performUpdate(); } #onReplaySettingsKeydown(event: Event): void { if ((event as KeyboardEvent).key !== 'Enter') { return; } event.preventDefault(); this.#onToggleReplaySettings(event); } #onToggleReplaySettings(event: Event): void { event.stopPropagation(); this.#replaySettingsExpanded = !this.#replaySettingsExpanded; this.performUpdate(); } #onNetworkConditionsChange(event: Event): void { const throttlingMenu = event.target; if (throttlingMenu instanceof HTMLSelectElement) { const preset = networkConditionPresets.find( preset => preset.i18nTitleKey === throttlingMenu.value, ); this.networkConditionsChanged?.( preset?.i18nTitleKey === SDK.NetworkManager.NoThrottlingConditions.i18nTitleKey ? undefined : preset, ); } } #onTimeoutInput(event: Event): void { const target = event.target as HTMLInputElement; if (!target.checkValidity()) { target.reportValidity(); return; } this.timeoutChanged?.(Number(target.value)); } #onTitleBlur = (event: Event): void => { const target = event.target as HTMLInputElement; const title = target.value.trim(); if (!title) { this.#isTitleInvalid = true; this.performUpdate(); return; } this.titleChanged?.(title); }; #onTitleInputKeyDown = (event: KeyboardEvent): void => { switch (event.code) { case 'Escape': case 'Enter': (event.target as HTMLElement).blur(); event.stopPropagation(); break; } }; #onEditTitleButtonClick = (): void => { const input = this.contentElement.querySelector<HTMLInputElement>('#title-input'); if (!input) { throw new Error('Missing #title-input'); } input.focus(); }; #onSelectMenuLabelClick = (event: Event): void => { const target = event.target as HTMLElement; if (target.matches('.wrapping-label')) { target.querySelector('devtools-select-menu')?.click(); } }; async #copyCurrentSelection(step?: Models.Schema.Step|null): Promise<void> { let converter = [ ...this.builtInConverters, ...this.extensionConverters, ] .find( converter => converter.getId() === this.recorderSettings?.preferredCopyFormat, ); if (!converter) { converter = this.builtInConverters[0]; } if (!converter) { throw new Error('No default converter found'); } let text = ''; if (step) { text = await converter.stringifyStep(step); } else if (this.recording) { [text] = await converter.stringify(this.recording); } Host.InspectorFrontendHost.InspectorFrontendHostInstance.copyText(text); const metric = step ? converterIdToStepMetric(converter.getId()) : converterIdToFlowMetric(converter.getId()); Host.userMetrics.recordingCopiedToClipboard(metric); } #onCopyStepEvent(event: CopyStepEvent): void { event.stopPropagation(); void this.#copyCurrentSelection(event.step); } async #onCopy(event: ClipboardEvent): Promise<void> { if (event.target !== document.body) { return; } event.preventDefault(); await this.#copyCurrentSelection(this.#selectedStep); Host.userMetrics.keyboardShortcutFired(Actions.RecorderActions.COPY_RECORDING_OR_STEP); } #handleMeasurePerformanceClickEvent(event: Event): void { event.stopPropagation(); this.playRecording?.({ targetPanel: TargetPanel.PERFORMANCE_PANEL, speed: PlayRecordingSpeed.NORMAL, }); } showCodeToggle = (): void => { this.#showCodeView = !this.#showCodeView; Host.userMetrics.recordingCodeToggled( this.#showCodeView ? Host.UserMetrics.RecordingCodeToggled.CODE_SHOWN : Host.UserMetrics.RecordingCodeToggled.CODE_HIDDEN, ); void this.#convertToCode(); }; #convertToCode = async(): Promise<void> => { if (!this.recording) { return; } const converter = [ ...(this.builtInConverters || []), ...(this.extensionConverters || []), ].find(converter => converter.getId() === this.#converterId) ?? this.builtInConverters[0]; if (!converter) { return; } const [code, sourceMap] = await converter.stringify(this.recording); this.#code = code; this.#sourceMap = sourceMap; this.#sourceMap?.shift(); const mediaType = converter.getMediaType(); const languageSupport = mediaType ? await CodeHighlighter.CodeHighlighter.languageFromMIME(mediaType) : null; this.#editorState = CodeMirror.EditorState.create({ doc: this.#code, extensions: [ TextEditor.Config.baseConfiguration(this.#code), CodeMirror.EditorState.readOnly.of(true), CodeMirror.EditorView.lineWrapping, languageSupport ? languageSupport : [], ], }); this.performUpdate(); // Used by tests. this.contentElement.dispatchEvent(new Event('code-generated')); }; #highlightCodeForStep = (step: Models.Schema.Step, scroll = false): void => { if (!this.#sourceMap) { return; } const stepIndex = this.recording.steps.indexOf(step); if (stepIndex === -1) { return; } const line = this.#sourceMap[stepIndex * 2]; const length = this.#sourceMap[stepIndex * 2 + 1]; this.#viewOutput.highlightLinesInEditor?.(line, length, scroll); }; #onCodeFormatChange = (event: Menus.SelectMenu.SelectMenuItemSelectedEvent): void => { this.#converterId = event.itemValue as string; if (this.recorderSettings) { this.recorderSettings.preferredCopyFormat = event.itemValue as string; } void this.#convertToCode(); }; }