chrome-devtools-frontend
Version:
Chrome DevTools UI
1,315 lines (1,200 loc) • 74.7 kB
text/typescript
// 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/kit/kit.js';
import * as Common from '../../core/common/common.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 Root from '../../core/root/root.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Bindings from '../../models/bindings/bindings.js';
import * as PublicExtensions from '../../models/extensions/extensions.js';
import type * as Trace from '../../models/trace/trace.js';
import * as PanelCommon from '../../panels/common/common.js';
import * as Emulation from '../../panels/emulation/emulation.js';
import * as Tracing from '../../services/tracing/tracing.js';
import * as Buttons from '../../ui/components/buttons/buttons.js';
import type * as Dialogs from '../../ui/components/dialogs/dialogs.js';
import type * as Menus from '../../ui/components/menus/menus.js';
import * as UI from '../../ui/legacy/legacy.js';
import {Directives, html, type LitTemplate, render} from '../../ui/lit/lit.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import * as Converters from './converters/converters.js';
import {CreateRecordingView} from './CreateRecordingView.js';
import * as Extensions from './extensions/extensions.js';
import * as Models from './models/models.js';
import * as Actions from './recorder-actions/recorder-actions.js';
import * as Events from './RecorderEvents.js';
import recorderPanelStyles from './recorderPanel.css.js';
import {RecordingListView} from './RecordingListView.js';
import {
type PlayRecordingEvent as ViewPlayRecordingEvent,
RecordingView,
type ReplayState,
TargetPanel,
} from './RecordingView.js';
import {
AddStepPosition,
} from './StepView.js';
const {ref, repeat} = Directives;
let recorderPanelInstance: RecorderPanel;
const UIStrings = {
/**
* @description The title of the button that leads to a page for creating a new recording.
*/
createRecording: 'Create recording',
/**
* @description The title of the button that allows importing a recording.
*/
importRecording: 'Import recording',
/**
* @description The announcement text for screen readers when a recording is imported.
*/
recordingImported: 'Recording imported',
/**
* @description The title of the button that deletes the recording.
*/
deleteRecording: 'Delete recording',
/**
* @description The announcement text for screen readers when a recording is deleted.
*/
recordingDeleted: 'Recording deleted',
/**
* @description The title of the select option if the user has no saved recordings.
*/
noRecordings: 'No recordings',
/**
* @description The title of the select option for one or more recording number followed by this text - 1 recording(s) or 4 recording(s).
*/
numberOfRecordings: 'recording(s)',
/**
* @description The title of the button that continues the replay.
*/
continueReplay: 'Continue',
/**
* @description The title of the button that executes only one step in the replay.
*/
stepOverReplay: 'Execute one step',
/**
* @description The title of the button that opens a menu with various options of exporting a recording to file.
*/
exportRecording: 'Export recording',
/**
* @description The title of shortcut for starting and stopping recording.
*/
startStopRecording: 'Start/stop recording',
/**
* @description The title of shortcut for replaying recording.
*/
replayRecording: 'Replay recording',
/**
* @description The title of shortcut for copying a recording or selected step.
*/
copyShortcut: 'Copy recording or selected step',
/**
* @description The title of shortcut for toggling code view.
*/
toggleCode: 'Toggle code view',
/**
* @description The title of the menu group in the export menu of the Recorder
* panel that is followed by the list of built-in export formats.
*/
export: 'Export',
/**
* @description The announcement text for screen readers when a recording is exported successfully.
*/
recordingExported: 'Recording exported',
/**
* @description The title of the menu group in the export menu of the Recorder
* panel that is followed by the list of export formats available via browser
* extensions.
*/
exportViaExtensions: 'Export via extensions',
/**
* @description The title of the menu option that leads to a page that lists
* all browser extensions available for the Recorder panel.
*/
getExtensions: 'Get extensions…',
/**
* @description The button label that leads to the feedback form for the Recorder panel.
*/
sendFeedback: 'Send feedback',
/**
* @description The header of the start page in the Recorder panel.
*/
header: 'Nothing recorded yet',
/**
* @description Text to explain the usage of the Recorder panel.
*/
recordingDescription: 'Use recordings to create automated end-to-end tests or performance traces.',
/**
* @description Link text to forward to a documentation page on the Recorder panel.
*/
learnMore: 'Learn more',
/**
* @description Headline of warning shown when users import a recording into the Recorder panel.
*/
doYouTrustThisCode: 'Do you trust this recording?',
/**
* @description Warning shown to users when importing code into the Recorder panel. IMPORTANT: keep double quotes around PH1 and do not use single quotes.
* @example {allow importing} PH1
*/
doNotImport:
'Don’t import recordings you don’t understand or haven’t reviewed yourself into DevTools. This could allow attackers to steal your identity or take control of your computer. Type "{PH1}" below to allow importing.',
/**
* @description Text a user needs to type in order to confirm that they
* are aware of the danger of importing code into the Recorder panel.
*/
allowImporting: 'allow importing',
/**
* @description Input box placeholder which instructs the user to type 'allow importing' into the input box. IMPORTANT: keep double quotes around PH1 and do not use single quotes.
* @example {allow importing} PH1
*/
typeAllowImporting: 'Type "{PH1}"',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/recorder/RecorderPanel.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
const {widget} = UI.Widget;
const GET_EXTENSIONS_MENU_ITEM = 'get-extensions-link';
const GET_EXTENSIONS_URL = 'https://goo.gle/recorder-extension-list' as Platform.DevToolsPath.UrlString;
const RECORDER_EXPLANATION_URL = 'https://developer.chrome.com/docs/devtools/recorder';
const FEEDBACK_URL = 'https://goo.gle/recorder-feedback' as Platform.DevToolsPath.UrlString;
export interface StoredRecording {
storageName: string;
flow: Models.Schema.UserFlow;
}
interface SetCurrentRecordingOptions {
/**
* Whether to keep breakpoints in the recording.
*/
keepBreakpoints: boolean;
/**
* Whether to upstream the recording to a recording session if it exists.
*/
updateSession: boolean;
}
export const enum Pages {
START_PAGE = 'StartPage',
ALL_RECORDINGS_PAGE = 'AllRecordingsPage',
CREATE_RECORDING_PAGE = 'CreateRecordingPage',
RECORDING_PAGE = 'RecordingPage',
}
const CONVERTER_ID_TO_METRIC: Record<string, Host.UserMetrics.RecordingExported|undefined> = {
[Models.ConverterIds.ConverterIds.JSON]: Host.UserMetrics.RecordingExported.TO_JSON,
[Models.ConverterIds.ConverterIds.REPLAY]: Host.UserMetrics.RecordingExported.TO_PUPPETEER_REPLAY,
[Models.ConverterIds.ConverterIds.PUPPETEER]: Host.UserMetrics.RecordingExported.TO_PUPPETEER,
[Models.ConverterIds.ConverterIds.PUPPETEER_FIREFOX]: Host.UserMetrics.RecordingExported.TO_PUPPETEER,
[Models.ConverterIds.ConverterIds.LIGHTHOUSE]: Host.UserMetrics.RecordingExported.TO_LIGHTHOUSE,
};
/** Provide some defaults to prevent OOM issues like crbug.com/491027421 */
function verifyFlowSize(flow: Models.Schema.UserFlow): void {
if (flow.steps.length > 4096) {
throw new Error('Recording with steps over 4096 is not allowed');
}
if (flow.title.length > 300) {
throw new Error('Recording with title over 300 characters is not allowed');
}
}
export interface ViewInput {
recordings: StoredRecording[];
currentRecording?: StoredRecording;
currentPage: Pages;
isRecording: boolean;
isToggling: boolean;
importError?: Error;
recordingError?: Error;
sections: Models.Section.Section[];
settings?: Models.RecordingSettings.RecordingSettings;
recorderSettings: Models.RecorderSettings.RecorderSettings;
lastReplayResult?: Models.RecordingPlayer.ReplayResult;
replayAllowed: boolean;
breakpointIndexes: Set<number>;
builtInConverters: readonly Converters.Converter.Converter[];
extensionConverters: Converters.Converter.Converter[];
replayExtensions: Extensions.ExtensionManager.Extension[];
extensionDescriptor?: PublicExtensions.RecorderPluginManager.ViewDescriptor;
exportMenuExpanded: boolean;
replayState: ReplayState;
shortcutsInfo: Dialogs.ShortcutDialog.Shortcut[];
currentStep?: Models.Schema.Step;
onCreateNewRecording: (event?: Event) => void;
onImportRecording: (event: Event) => void;
onExportRecording: (event: Event) => void;
onDeleteRecording: (event: Event|string) => void;
onRecordingSelected: (event: Event|string) => void;
onPlayRecordingByName: (storageName: string) => void;
onPlayRecording: (event: ViewPlayRecordingEvent) => void;
onAbortReplay: () => void;
onNetworkConditionsChanged: (data?: SDK.NetworkManager.Conditions) => void;
onTimeoutChanged: (timeout?: number) => void;
handleRecordingTitleChanged: (title: string) => void;
handleRecordingChanged: (currentStep: Models.Schema.Step, newStep: Models.Schema.Step) => void;
handleStepAdded: (stepOrSection: Models.Schema.Step|Models.Section.Section, position: AddStepPosition) => void;
handleStepRemoved: (step: Models.Schema.Step) => void;
onAddBreakpoint: (index: number) => void;
onRemoveBreakpoint: (index: number) => void;
onExtensionViewClosed: () => void;
onExportMenuClosed: () => void;
onExportOptionSelected: (event: Menus.SelectMenu.SelectMenuItemSelectedEvent) => void;
onRecordingFinished: () => void;
handleAddAssertionEvent: () => void;
onSetRecording: (event: Event) => void;
onContinueReplay: () => void;
onStepOverReplay: () => void;
getExportMenuButton: () => Buttons.Button.Button;
onRecordingStarted:
(data: {name: string, selectorTypesToRecord: Models.Schema.SelectorType[], selectorAttribute?: string}) => void;
onRecordingCancelled: () => void;
}
export interface ViewOutput {
exportMenuButton: Buttons.Button.Button;
recordingView: RecordingView;
createRecordingView: CreateRecordingView;
}
export type View = (input: ViewInput, output: ViewOutput, target: DocumentFragment) => void;
export const DEFAULT_VIEW: View = (input, output, target): void => {
function renderCurrentPage(): LitTemplate {
switch (input.currentPage) {
case Pages.START_PAGE:
return renderStartPage();
case Pages.ALL_RECORDINGS_PAGE:
return renderAllRecordingsPage();
case Pages.RECORDING_PAGE:
return renderRecordingPage();
case Pages.CREATE_RECORDING_PAGE:
return renderCreateRecordingPage();
}
}
function renderAllRecordingsPage(): LitTemplate {
// clang-format off
return html`
<devtools-widget
${widget(RecordingListView, {
recordings: input.recordings.map(recording => ({
storageName: recording.storageName,
name: recording.flow.title,
})),
replayAllowed: input.replayAllowed,
onCreateRecording: input.onCreateNewRecording,
onDeleteRecording: input.onDeleteRecording,
onOpenRecording: input.onRecordingSelected,
onPlayRecording: input.onPlayRecordingByName,
})}
>
</devtools-widget>
`;
// clang-format on
}
function renderStartPage(): LitTemplate {
// clang-format off
return html`
<div class="empty-state" jslog=${VisualLogging.section().context('start-view')}>
<div class="empty-state-header">${i18nString(UIStrings.header)}</div>
<div class="empty-state-description">
<span>${i18nString(UIStrings.recordingDescription)}</span>
<devtools-link
class="devtools-link"
href=${RECORDER_EXPLANATION_URL}
jslogcontext="learn-more"
>${i18nString(UIStrings.learnMore)}</devtools-link>
</div>
<devtools-button .variant=${Buttons.Button.Variant.TONAL} jslogContext=${Actions.RecorderActions.CREATE_RECORDING} @click=${input.onCreateNewRecording}>${i18nString(UIStrings.createRecording)}</devtools-button>
</div>
`;
// clang-format on
}
function renderRecordingPage(): LitTemplate {
// clang-format off
return html`
<devtools-widget
class="recording-view"
${widget(RecordingView, {
recording: input.currentRecording?.flow ?? {title: '', steps: []},
replayState: input.replayState,
isRecording: input.isRecording,
recordingTogglingInProgress: input.isToggling,
currentStep: input.currentStep,
currentError: input.recordingError,
sections: input.sections ?? [],
settings: input.settings,
recorderSettings: input.recorderSettings,
lastReplayResult: input.lastReplayResult,
replayAllowed: input.replayAllowed,
breakpointIndexes: input.breakpointIndexes,
builtInConverters: input.builtInConverters,
extensionConverters: input.extensionConverters,
replayExtensions: input.replayExtensions,
extensionDescriptor: input.extensionDescriptor,
onRecordingFinished: input.onRecordingFinished,
onAddAssertion: input.handleAddAssertionEvent,
onAbortReplay: input.onAbortReplay,
onPlayRecording: input.onPlayRecording,
onNetworkConditionsChanged: input.onNetworkConditionsChanged,
onTimeoutChanged: input.onTimeoutChanged,
onTitleChanged: input.handleRecordingTitleChanged,
onStepChanged: input.handleRecordingChanged,
onAddStep: input.handleStepAdded,
onRemoveStep: input.handleStepRemoved,
onAddBreakpoint: input.onAddBreakpoint,
onRemoveBreakpoint: input.onRemoveBreakpoint,
onAttributeRequested: (send: (attribute?: string) => void) => {
send(input.currentRecording?.flow.selectorAttribute);
},
})}
@recorderextensionviewclosed=${input.onExtensionViewClosed}
${UI.Widget.widgetRef(RecordingView, widget => {output.recordingView = widget; })}
></devtools-widget>
`;
// clang-format on
}
function renderCreateRecordingPage(): LitTemplate {
// clang-format off
return html`
<devtools-widget
class="recording-view"
${widget(CreateRecordingView, {
recorderSettings: input.recorderSettings,
onRecordingStarted: input.onRecordingStarted,
onRecordingCancelled: input.onRecordingCancelled,
})}
${UI.Widget.widgetRef(
CreateRecordingView,
widget => {
output.createRecordingView = widget;
},
)}
></devtools-widget>
`;
// clang-format on
}
const selectValue: string = input.currentRecording ? input.currentRecording.storageName : input.currentPage;
// clang-format off
const values = [
input.recordings.length === 0
? {
value: Pages.START_PAGE,
name: i18nString(UIStrings.noRecordings),
selected: selectValue === Pages.START_PAGE,
}
: {
value: Pages.ALL_RECORDINGS_PAGE,
name: `${input.recordings.length} ${i18nString(UIStrings.numberOfRecordings)}`,
selected: selectValue === Pages.ALL_RECORDINGS_PAGE,
},
...input.recordings.map(recording => ({
value: recording.storageName,
name: recording.flow.title,
selected: selectValue === recording.storageName,
})),
];
render(html`
<style>${UI.inspectorCommonStyles}</style>
<style>${recorderPanelStyles}</style>
<div class="wrapper">
<div class="header" jslog=${VisualLogging.toolbar()}>
<devtools-button
@click=${input.onCreateNewRecording}
.data=${
{
variant: Buttons.Button.Variant.TOOLBAR,
iconName: 'plus',
disabled:
input.replayState.isPlaying ||
input.isRecording ||
input.isToggling,
title: Models.Tooltip.getTooltipForActions(
i18nString(UIStrings.createRecording),
Actions.RecorderActions.CREATE_RECORDING,
),
jslogContext: Actions.RecorderActions.CREATE_RECORDING,
} as Buttons.Button.ButtonData
}
></devtools-button>
<div class="separator"></div>
<select
.disabled=${
input.recordings.length === 0 ||
input.replayState.isPlaying ||
input.isRecording ||
input.isToggling
}
@click=${(e: Event) => e.stopPropagation()}
@change=${input.onRecordingSelected}
jslog=${VisualLogging.dropDown('recordings').track({change: true})}
>
${repeat(
values,
item => item.value,
item => {
return html`<option .selected=${item.selected} value=${item.value}>${item.name}</option>`;
},
)}
</select>
<div class="separator"></div>
<devtools-button
@click=${input.onImportRecording}
.data=${
{
variant: Buttons.Button.Variant.TOOLBAR,
iconName: 'import',
title: i18nString(UIStrings.importRecording),
jslogContext: 'import-recording',
} as Buttons.Button.ButtonData
}
></devtools-button>
<devtools-button
id='origin'
@click=${input.onExportRecording}
${ref(el => {
if (el instanceof HTMLElement) {
output.exportMenuButton = el as Buttons.Button.Button;
}
})}
.data=${
{
variant: Buttons.Button.Variant.TOOLBAR,
iconName: 'download',
title: i18nString(UIStrings.exportRecording),
disabled: !input.currentRecording,
} as Buttons.Button.ButtonData
}
jslog=${VisualLogging.dropDown('export-recording').track({click: true})}
></devtools-button>
<devtools-menu
@menucloserequest=${input.onExportMenuClosed}
@menuitemselected=${input.onExportOptionSelected}
.origin=${input.getExportMenuButton}
.showDivider=${false}
.showSelectedItem=${false}
.open=${input.exportMenuExpanded}
>
<devtools-menu-group .name=${i18nString(UIStrings.export)}>
${repeat(
input.builtInConverters,
converter => {
return html`
<devtools-menu-item
.value=${converter.getId()}
jslog=${VisualLogging.item(`converter-${Platform.StringUtilities.toKebabCase(converter.getId())}`).track({click: true})}>
${converter.getFormatName()}
</devtools-menu-item>
`;
},
)}
</devtools-menu-group>
<devtools-menu-group .name=${i18nString(UIStrings.exportViaExtensions)}>
${repeat(
input.extensionConverters,
converter => {
return html`
<devtools-menu-item
.value=${converter.getId()}
jslog=${VisualLogging.item('converter-extension').track({click: true})}>
${converter.getFormatName()}
</devtools-menu-item>
`;
},
)}
<devtools-menu-item .value=${GET_EXTENSIONS_MENU_ITEM}>
${i18nString(UIStrings.getExtensions)}
</devtools-menu-item>
</devtools-menu-group>
</devtools-menu>
<devtools-button
@click=${input.onDeleteRecording}
.data=${
{
variant: Buttons.Button.Variant.TOOLBAR,
iconName: 'bin',
disabled:
!input.currentRecording ||
input.replayState.isPlaying ||
input.isRecording ||
input.isToggling,
title: i18nString(UIStrings.deleteRecording),
jslogContext: 'delete-recording',
} as Buttons.Button.ButtonData
}
></devtools-button>
<div class="separator"></div>
<devtools-button
@click=${input.onContinueReplay}
.data=${
{
variant: Buttons.Button.Variant.PRIMARY_TOOLBAR,
iconName: 'resume',
disabled: !input.replayState.isPausedOnBreakpoint,
title: i18nString(UIStrings.continueReplay),
jslogContext: 'continue-replay',
} as Buttons.Button.ButtonData
}
></devtools-button>
<devtools-button
@click=${input.onStepOverReplay}
.data=${
{
variant: Buttons.Button.Variant.TOOLBAR,
iconName: 'step-over',
disabled: !input.replayState.isPausedOnBreakpoint,
title: i18nString(UIStrings.stepOverReplay),
jslogContext: 'step-over',
} as Buttons.Button.ButtonData
}
></devtools-button>
<div class="feedback">
<devtools-link class="devtools-link" title=${i18nString(UIStrings.sendFeedback)} href=${
FEEDBACK_URL
} jslogcontext="feedback">${i18nString(UIStrings.sendFeedback)}</devtools-link>
</div>
<div class="separator"></div>
<devtools-shortcut-dialog
.data=${
{
shortcuts: input.shortcutsInfo,
} as Dialogs.ShortcutDialog.ShortcutDialogData
} jslog=${VisualLogging.action('show-shortcuts').track({click: true})}
></devtools-shortcut-dialog>
</div>
${
input.importError
? html`<div class='error'>Import error: ${
input.importError.message
}</div>`
: ''
}
${renderCurrentPage()}
</div>
`, target, { container: { listeners: {setrecording: input.onSetRecording}}});
// clang-format on
};
export class RecorderPanel extends UI.Widget.VBox<DocumentFragment> {
static panelName = 'chrome-recorder';
static instance(
opts: {forceNew?: boolean} = {},
): RecorderPanel {
const {forceNew} = opts;
if (!recorderPanelInstance || forceNew) {
recorderPanelInstance = new RecorderPanel();
}
return recorderPanelInstance;
}
#currentRecordingSession?: Models.RecordingSession.RecordingSession;
get currentRecordingSession(): Models.RecordingSession.RecordingSession|undefined {
return this.#currentRecordingSession;
}
set currentRecordingSession(value: Models.RecordingSession.RecordingSession|undefined) {
if (this.#currentRecordingSession !== value) {
this.#currentRecordingSession = value;
this.requestUpdate();
}
}
#currentRecording: StoredRecording|undefined;
get currentRecording(): StoredRecording|undefined {
return this.#currentRecording;
}
set currentRecording(value: StoredRecording|undefined) {
if (this.#currentRecording !== value) {
this.#currentRecording = value;
this.requestUpdate();
}
}
#currentStep?: Models.Schema.Step;
get currentStep(): Models.Schema.Step|undefined {
return this.#currentStep;
}
set currentStep(value: Models.Schema.Step|undefined) {
if (this.#currentStep !== value) {
this.#currentStep = value;
this.requestUpdate();
}
}
#recordingError?: Error;
get recordingError(): Error|undefined {
return this.#recordingError;
}
set recordingError(value: Error|undefined) {
if (this.#recordingError !== value) {
this.#recordingError = value;
this.requestUpdate();
}
}
#storage = Models.RecordingStorage.RecordingStorage.instance();
#screenshotStorage = Models.ScreenshotStorage.ScreenshotStorage.instance();
#isRecording = false;
get isRecording(): boolean {
return this.#isRecording;
}
set isRecording(value: boolean) {
if (this.#isRecording !== value) {
this.#isRecording = value;
this.requestUpdate();
}
}
#isToggling = false;
get isToggling(): boolean {
return this.#isToggling;
}
set isToggling(value: boolean) {
if (this.#isToggling !== value) {
this.#isToggling = value;
this.requestUpdate();
}
}
// TODO: we keep the functionality to allow/disallow replay but right now it's not used.
// It can be used to decide if we allow replay on a certain target for example.
#replayAllowed = true;
#recordingPlayer?: Models.RecordingPlayer.RecordingPlayer;
get recordingPlayer(): Models.RecordingPlayer.RecordingPlayer|undefined {
return this.#recordingPlayer;
}
set recordingPlayer(value: Models.RecordingPlayer.RecordingPlayer|undefined) {
if (this.#recordingPlayer !== value) {
this.#recordingPlayer = value;
this.requestUpdate();
}
}
#lastReplayResult?: Models.RecordingPlayer.ReplayResult;
get lastReplayResult(): Models.RecordingPlayer.ReplayResult|undefined {
return this.#lastReplayResult;
}
set lastReplayResult(value: Models.RecordingPlayer.ReplayResult|undefined) {
if (this.#lastReplayResult !== value) {
this.#lastReplayResult = value;
this.requestUpdate();
}
}
readonly #replayState: ReplayState = {isPlaying: false, isPausedOnBreakpoint: false};
#currentPage: Pages = Pages.START_PAGE;
get currentPage(): Pages {
return this.#currentPage;
}
set currentPage(value: Pages) {
if (this.#currentPage !== value) {
this.#currentPage = value;
this.requestUpdate();
}
}
#previousPage?: Pages;
get previousPage(): Pages|undefined {
return this.#previousPage;
}
set previousPage(value: Pages|undefined) {
if (this.#previousPage !== value) {
this.#previousPage = value;
this.requestUpdate();
}
}
#fileSelector?: HTMLInputElement;
#sections?: Models.Section.Section[];
get sections(): Models.Section.Section[]|undefined {
return this.#sections;
}
set sections(value: Models.Section.Section[]|undefined) {
if (this.#sections !== value) {
this.#sections = value;
this.requestUpdate();
}
}
#settings?: Models.RecordingSettings.RecordingSettings;
get settings(): Models.RecordingSettings.RecordingSettings|undefined {
return this.#settings;
}
set settings(value: Models.RecordingSettings.RecordingSettings|undefined) {
if (this.#settings !== value) {
this.#settings = value;
this.requestUpdate();
}
}
#importError?: Error;
get importError(): Error|undefined {
return this.#importError;
}
set importError(value: Error|undefined) {
if (this.#importError !== value) {
this.#importError = value;
this.requestUpdate();
}
}
#exportMenuExpanded = false;
get exportMenuExpanded(): boolean {
return this.#exportMenuExpanded;
}
set exportMenuExpanded(value: boolean) {
if (this.#exportMenuExpanded !== value) {
this.#exportMenuExpanded = value;
this.requestUpdate();
}
}
#exportMenuButton: Buttons.Button.Button|undefined;
#stepBreakpointIndexes = new Set<number>();
#builtInConverters: readonly Converters.Converter.Converter[];
#extensionConverters: Converters.Converter.Converter[] = [];
get extensionConverters(): Converters.Converter.Converter[] {
return this.#extensionConverters;
}
set extensionConverters(value: Converters.Converter.Converter[]) {
if (this.#extensionConverters !== value) {
this.#extensionConverters = value;
this.requestUpdate();
}
}
#replayExtensions: Extensions.ExtensionManager.Extension[] = [];
get replayExtensions(): Extensions.ExtensionManager.Extension[] {
return this.#replayExtensions;
}
set replayExtensions(value: Extensions.ExtensionManager.Extension[]) {
if (this.#replayExtensions !== value) {
this.#replayExtensions = value;
this.requestUpdate();
}
}
#viewDescriptor?: PublicExtensions.RecorderPluginManager.ViewDescriptor;
get viewDescriptor(): PublicExtensions.RecorderPluginManager.ViewDescriptor|undefined {
return this.#viewDescriptor;
}
set viewDescriptor(value: PublicExtensions.RecorderPluginManager.ViewDescriptor|undefined) {
if (this.#viewDescriptor !== value) {
this.#viewDescriptor = value;
this.requestUpdate();
}
}
#extensionViewShowRequestedListener?:
(event: Common.EventTarget.EventTargetEvent<PublicExtensions.RecorderPluginManager.ViewDescriptor>) => void;
#recorderSettings = new Models.RecorderSettings.RecorderSettings();
#shortcutHelper = new Models.RecorderShortcutHelper.RecorderShortcutHelper();
#disableRecorderImportWarningSetting = Common.Settings.Settings.instance().createSetting(
'disable-recorder-import-warning', false, Common.Settings.SettingStorageType.SYNCED);
#selfXssWarningDisabledSetting = Common.Settings.Settings.instance().createSetting(
'disable-self-xss-warning', false, Common.Settings.SettingStorageType.SYNCED);
#recordingView?: RecordingView;
#createRecordingView?: CreateRecordingView;
#view: typeof DEFAULT_VIEW;
constructor(element?: HTMLElement, view?: typeof DEFAULT_VIEW) {
const el = element || document.createElement('devtools-recorder-panel');
super(el, {useShadowDom: 'pure'});
this.#view = view || DEFAULT_VIEW;
this.setHideOnDetach();
this.isRecording = false;
this.isToggling = false;
this.exportMenuExpanded = false;
this.currentPage = Pages.START_PAGE;
if (this.#storage.getRecordings().length) {
this.#setCurrentPage(Pages.ALL_RECORDINGS_PAGE);
}
const textEditorIndent = Common.Settings.Settings.instance().moduleSetting('text-editor-indent').get();
this.#builtInConverters = Object.freeze([
new Converters.JSONConverter.JSONConverter(textEditorIndent),
new Converters.PuppeteerReplayConverter.PuppeteerReplayConverter(textEditorIndent),
new Converters.PuppeteerConverter.PuppeteerConverter(textEditorIndent),
new Converters.PuppeteerFirefoxConverter.PuppeteerFirefoxConverter(textEditorIndent),
new Converters.LighthouseConverter.LighthouseConverter(textEditorIndent),
]);
const extensionManager = Extensions.ExtensionManager.ExtensionManager.instance();
this.#updateExtensions(extensionManager.extensions());
extensionManager.addEventListener(Extensions.ExtensionManager.Events.EXTENSIONS_UPDATED, event => {
this.#updateExtensions(event.data);
});
}
override wasShown(): void {
super.wasShown();
UI.Context.Context.instance().setFlavor(RecorderPanel, this);
this.requestUpdate();
void this.updateComplete.then(() => {
this.focus();
});
}
override willHide(): void {
super.willHide();
UI.Context.Context.instance().setFlavor(RecorderPanel, null);
}
override onDetach(): void {
super.onDetach();
if (this.currentRecordingSession) {
void this.currentRecordingSession.stop();
}
if (this.#extensionViewShowRequestedListener) {
PublicExtensions.RecorderPluginManager.RecorderPluginManager.instance().removeEventListener(
PublicExtensions.RecorderPluginManager.Events.SHOW_VIEW_REQUESTED,
this.#extensionViewShowRequestedListener,
);
this.#extensionViewShowRequestedListener = undefined;
}
}
#updateExtensions(extensions: Extensions.ExtensionManager.Extension[]): void {
this.extensionConverters =
extensions.filter(extension => extension.getCapabilities().includes('export')).map((extension, idx) => {
return new Converters.ExtensionConverter.ExtensionConverter(idx, extension);
});
this.replayExtensions = extensions.filter(extension => extension.getCapabilities().includes('replay'));
}
setIsRecordingStateForTesting(isRecording: boolean): void {
this.isRecording = isRecording;
}
setRecordingStateForTesting(state: ReplayState): void {
this.#replayState.isPlaying = state.isPlaying;
this.#replayState.isPausedOnBreakpoint = state.isPausedOnBreakpoint;
}
setCurrentPageForTesting(page: Pages): void {
this.#setCurrentPage(page);
}
getCurrentPageForTesting(): Pages {
return this.currentPage;
}
getCurrentRecordingForTesting(): StoredRecording|undefined {
return this.currentRecording;
}
getStepBreakpointIndexesForTesting(): number[] {
return [...this.#stepBreakpointIndexes.values()];
}
/**
* We should clear errors on every new action in the controller.
* TODO: think how to make handle this centrally so that in no case
* the error remains shown for longer than needed. Maybe a timer?
*/
#clearError(): void {
this.importError = undefined;
}
async #importFile(file: File): Promise<void> {
const outputStream = new Common.StringOutputStream.StringOutputStream();
const reader = new Bindings.FileUtils.ChunkedFileReader(file,
/* chunkSize */ 10000000);
const success = await reader.read(outputStream);
if (!success) {
throw reader.error() ?? new Error('Unknown');
}
let flow: Models.Schema.UserFlow|undefined;
try {
flow = Models.SchemaUtils.parse(JSON.parse(outputStream.data()));
verifyFlowSize(flow);
} catch (error) {
this.importError = error;
return;
}
this.#setCurrentRecording(await this.#storage.upsertRecording(flow));
this.#setCurrentPage(Pages.RECORDING_PAGE);
this.#clearError();
UI.ARIAUtils.LiveAnnouncer.alert(i18nString(UIStrings.recordingImported));
}
setCurrentRecordingForTesting(recording: StoredRecording|undefined): void {
this.#setCurrentRecording(recording);
}
getSectionsForTesting(): Models.Section.Section[]|undefined {
return this.sections;
}
#setCurrentRecording(recording: StoredRecording|undefined, opts: Partial<SetCurrentRecordingOptions> = {}): void {
const {keepBreakpoints = false, updateSession = false} = opts;
this.recordingPlayer?.abort();
this.currentStep = undefined;
this.recordingError = undefined;
this.lastReplayResult = undefined;
this.recordingPlayer = undefined;
this.#replayState.isPlaying = false;
this.#replayState.isPausedOnBreakpoint = false;
this.#stepBreakpointIndexes = keepBreakpoints ? this.#stepBreakpointIndexes : new Set();
if (recording) {
this.currentRecording = recording;
this.sections = Models.Section.buildSections(recording.flow.steps);
this.settings = this.#buildSettings(recording.flow);
if (updateSession && this.currentRecordingSession) {
this.currentRecordingSession.overwriteUserFlow(recording.flow);
}
} else {
this.currentRecording = undefined;
this.sections = undefined;
this.settings = undefined;
}
this.#updateScreenshotsForSections();
}
#setCurrentPage(page: Pages): void {
if (page === this.currentPage) {
return;
}
this.previousPage = this.currentPage;
this.currentPage = page;
}
#buildSettings(flow: Models.Schema.UserFlow): Models.RecordingSettings.RecordingSettings {
const steps = flow.steps;
const navigateStepIdx = steps.findIndex(step => step.type === 'navigate');
const settings: Models.RecordingSettings.RecordingSettings = {timeout: flow.timeout};
for (let i = navigateStepIdx - 1; i >= 0; i--) {
const step = steps[i];
if (!settings.viewportSettings && step.type === 'setViewport') {
settings.viewportSettings = step;
}
if (!settings.networkConditionsSettings && step.type === 'emulateNetworkConditions') {
settings.networkConditionsSettings = {...step};
for (const preset of [SDK.NetworkManager.OfflineConditions, SDK.NetworkManager.Slow3GConditions,
SDK.NetworkManager.Slow4GConditions, SDK.NetworkManager.Fast4GConditions]) {
// Using i18nTitleKey as a title here because we only want to compare the parameters of the network conditions.
if (SDK.NetworkManager.networkConditionsEqual(
{...preset, title: preset.i18nTitleKey || ''},
// The key below is not used, but we need it to satisfy TS.
{
...step,
title: preset.i18nTitleKey || '',
key: `step_${i}_recorder_key` as SDK.NetworkManager.UserDefinedThrottlingConditionKey,
})) {
settings.networkConditionsSettings.title = preset.title instanceof Function ? preset.title() : preset.title;
settings.networkConditionsSettings.i18nTitleKey = preset.i18nTitleKey;
}
}
}
}
return settings;
}
#getMainTarget(): SDK.Target.Target {
const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
if (!target) {
throw new Error('Missing main page target');
}
return target;
}
#getSectionFromStep(step: Models.Schema.Step): Models.Section.Section|null {
if (!this.sections) {
return null;
}
for (const section of this.sections) {
if (section.steps.indexOf(step) !== -1) {
return section;
}
}
return null;
}
#updateScreenshotsForSections(): void {
if (!this.sections || !this.currentRecording) {
return;
}
const storageName = this.currentRecording.storageName;
for (let i = 0; i < this.sections.length; i++) {
const screenshot = this.#screenshotStorage.getScreenshotForSection(storageName, i);
this.sections[i].screenshot = screenshot || undefined;
}
this.requestUpdate();
}
#onAbortReplay(): void {
this.recordingPlayer?.abort();
}
async #onPlayViaExtension(extension: Extensions.ExtensionManager.Extension): Promise<void> {
if (!this.currentRecording || !this.#replayAllowed) {
return;
}
const pluginManager = PublicExtensions.RecorderPluginManager.RecorderPluginManager.instance();
if (this.#extensionViewShowRequestedListener) {
pluginManager.removeEventListener(
PublicExtensions.RecorderPluginManager.Events.SHOW_VIEW_REQUESTED,
this.#extensionViewShowRequestedListener,
);
this.#extensionViewShowRequestedListener = undefined;
}
let resolveView: (descriptor: PublicExtensions.RecorderPluginManager.ViewDescriptor) => void;
const promise = new Promise<PublicExtensions.RecorderPluginManager.ViewDescriptor>(resolve => {
resolveView = resolve;
});
this.#extensionViewShowRequestedListener =
(event: Common.EventTarget.EventTargetEvent<PublicExtensions.RecorderPluginManager.ViewDescriptor>): void => {
const descriptor = event.data;
if (descriptor.extensionOrigin === extension.getOrigin()) {
if (this.#extensionViewShowRequestedListener) {
pluginManager.removeEventListener(
PublicExtensions.RecorderPluginManager.Events.SHOW_VIEW_REQUESTED,
this.#extensionViewShowRequestedListener,
);
this.#extensionViewShowRequestedListener = undefined;
}
resolveView(descriptor);
}
};
pluginManager.addEventListener(
PublicExtensions.RecorderPluginManager.Events.SHOW_VIEW_REQUESTED,
this.#extensionViewShowRequestedListener,
);
extension.replay(this.currentRecording.flow);
const descriptor = await promise;
this.viewDescriptor = descriptor;
Host.userMetrics.recordingReplayStarted(Host.UserMetrics.RecordingReplayStarted.REPLAY_VIA_EXTENSION);
}
async #onPlayRecording(event: ViewPlayRecordingEvent): Promise<void> {
if (!this.currentRecording || !this.#replayAllowed) {
return;
}
if (this.viewDescriptor) {
this.viewDescriptor = undefined;
}
if (this.#extensionViewShowRequestedListener) {
PublicExtensions.RecorderPluginManager.RecorderPluginManager.instance().removeEventListener(
PublicExtensions.RecorderPluginManager.Events.SHOW_VIEW_REQUESTED,
this.#extensionViewShowRequestedListener,
);
this.#extensionViewShowRequestedListener = undefined;
}
if (event.extension) {
return await this.#onPlayViaExtension(event.extension);
}
Host.userMetrics.recordingReplayStarted(
event.targetPanel !== TargetPanel.DEFAULT ?
Host.UserMetrics.RecordingReplayStarted.REPLAY_WITH_PERFORMANCE_TRACING :
Host.UserMetrics.RecordingReplayStarted.REPLAY_ONLY);
this.#replayState.isPlaying = true;
this.currentStep = undefined;
this.recordingError = undefined;
this.lastReplayResult = undefined;
const currentRecording = this.currentRecording;
this.#clearError();
await this.#disableDeviceModeIfEnabled();
this.recordingPlayer = new Models.RecordingPlayer.RecordingPlayer(
this.currentRecording.flow, {speed: event.speed, breakpointIndexes: this.#stepBreakpointIndexes});
const withPerformanceTrace = event.targetPanel === TargetPanel.PERFORMANCE_PANEL;
const sectionsWithScreenshot = new Set();
this.recordingPlayer.addEventListener(Models.RecordingPlayer.Events.STEP, async ({data: {step, resolve}}) => {
this.currentStep = step;
const currentSection = this.#getSectionFromStep(step);
if (this.sections && currentSection && !sectionsWithScreenshot.has(currentSection)) {
sectionsWithScreenshot.add(currentSection);
const currentSectionIndex = this.sections.indexOf(currentSection);
const screenshot = await Models.ScreenshotUtils.takeScreenshot();
currentSection.screenshot = screenshot;
Models.ScreenshotStorage.ScreenshotStorage.instance().storeScreenshotForSection(
currentRecording.storageName, currentSectionIndex, screenshot);
}
resolve();
});
this.recordingPlayer.addEventListener(Models.RecordingPlayer.Events.STOP, () => {
this.#replayState.isPausedOnBreakpoint = true;
this.requestUpdate();
});
this.recordingPlayer.addEventListener(Models.RecordingPlayer.Events.CONTINUE, () => {
this.#replayState.isPausedOnBreakpoint = false;
this.requestUpdate();
});
this.recordingPlayer.addEventListener(Models.RecordingPlayer.Events.ERROR, ({data: error}) => {
this.recordingError = error;
if (!withPerformanceTrace) {
this.#replayState.isPlaying = false;
this.recordingPlayer = undefined;
}
this.lastReplayResult = Models.RecordingPlayer.ReplayResult.FAILURE;
const errorMessage = error.message.toLowerCase();
if (errorMessage.startsWith('could not find element')) {
Host.userMetrics.recordingReplayFinished(Host.UserMetrics.RecordingReplayFinished.TIMEOUT_ERROR_SELECTORS);
} else if (errorMessage.startsWith('waiting for target failed')) {
Host.userMetrics.recordingReplayFinished(Host.UserMetrics.RecordingReplayFinished.TIMEOUT_ERROR_TARGET);
} else {
Host.userMetrics.recordingReplayFinished(Host.UserMetrics.RecordingReplayFinished.OTHER_ERROR);
}
// Dispatch an event for e2e testing.
this.element.dispatchEvent(new Events.ReplayFinishedEvent());
});
this.recordingPlayer.addEventListener(Models.RecordingPlayer.Events.DONE, () => {
if (!withPerformanceTrace) {
this.#replayState.isPlaying = false;
this.recordingPlayer = undefined;
}
this.lastReplayResult = Models.RecordingPlayer.ReplayResult.SUCCESS;
// Dispatch an event for e2e testing.
this.element.dispatchEvent(new Events.ReplayFinishedEvent());
Host.userMetrics.recordingReplayFinished(Host.UserMetrics.RecordingReplayFinished.SUCCESS);
});
this.recordingPlayer.addEventListener(Models.RecordingPlayer.Events.ABORT, () => {
this.currentStep = undefined;
this.recordingError = undefined;
this.lastReplayResult = undefined;
this.#replayState.isPlaying = false;
});
let resolveWithEvents = (_events: Object[]): void => {};
const eventsPromise = new Promise<Object[]>((resolve): void => {
resolveWithEvents = resolve;
});
let performanceTracing = null;
switch (event.targetPanel) {
case TargetPanel.PERFORMANCE_PANEL:
performanceTracing = new Tracing.PerformanceTracing.PerformanceTracing(this.#getMainTarget(), {
tracingBufferUsage(): void{},
eventsRetrievalProgress(): void{},
tracingComplete(events: Object[]): void {
resolveWithEvents(events);
},
});
break;
}
if (performanceTracing) {
await performanceTracing.start();
}
this.#setTouchEmulationAllowed(false);
await this.recordingPlayer.play();
this.#setTouchEmulationAllowed(true);
if (performanceTracing) {
await performanceTracing.stop();
const events = await eventsPromise;
this.#replayState.isPlaying = false;
this.recordingPlayer = undefined;
await UI.InspectorView.InspectorView.instance().showPanel(event.targetPanel as string);
if (event.targetPanel === TargetPanel.PERFORMANCE_PANEL) {
// Note: this is not passing any metadata to the Performance panel.
const trace = new SDK.TraceObject.TraceObject(events as Trace.Types.Events.Event[]);
void Common.Revealer.reveal(trace);
}
}
}
async #disableDeviceModeIfEnabled(): Promise<void> {
try {
const deviceModeWrapper = Emulation.DeviceModeWrapper.DeviceModeWrapper.instance();
if (deviceModeWrapper.isDeviceModeOn()) {
deviceModeWrapper.toggleDeviceMode();
const emulationModel = this.#getMainTarget().model(SDK.EmulationModel.EmulationModel);
await emulationModel?.emulateDevice(null);
}
} catch {
// in the hosted mode, when the DeviceMode toolbar is not supported,
// Emulation.DeviceModeWrapper.DeviceModeWrapper.instance throws an exception.
}
}
#setTouchEmulationAllowed(touchEmulationAllowed: boolean): void {
const emulationModel = this.#getMainTarget().model(SDK.EmulationModel.EmulationModel);
emulationModel?.setTouchEmulationAllowed(touchEmulationAllowed);
}
async #onSetRecording(event: Event): Promise<void> {
const json = JSON.parse((event as CustomEvent).detail);
this.#setCurrentRecording(await this.#storage.upsertRecording(Models.SchemaUtils.parse(json)));
this.#setCurrentPage(Pages.RECORDING_PAGE);
this.#clearError();
this.element.dispatchEvent(new Events.SetRecordingFinishedEvent());
}
// Used by e2e tests to inspect the current recording.
getUserFlow(): Models.Schema.UserFlow|undefined {
return this.currentRecording?.flow;
}
async #handleRecordingChanged(currentStep: Models.Schema.Step, newStep: Models.Schema.Step): Promise<void> {
if (!this.currentRecording) {
throw new Error('Current recording expected to be defined.');
}
const recording = {
...this.currentRecording,
flow: {
...this.currentRecording.flow,
steps: this.currentRecording.flow.steps.map(step => step === currentStep ? newStep : step),
},
};
this.#setCurrentRecording(await this.#storage.upsertRecording(
recording.flow,
recording.storageName,
),
{keepBreakpoints: true, updateSession: true});
}
async #handleStepAdded(stepOrSection: Models.Schema.Step|Models.Section.Section,
position: AddStepPosition): Promise<void> {
if (!this.currentRecording) {
throw new Error('Current recording expected to be defined.');
}
let step;
let actualPosition = position;
if ('steps' in stepOrSection) {
// section
const sectionIdx = this.sections?.indexOf(stepOrSection);
if (sectionIdx === undefined || sectionIdx === -1) {
throw new Error('There is no section to add a step to');
}
if (position === AddStepPosition.AFTER) {
if (this.sections?.[sectionIdx].steps.length) {
step = this.sections?.[sectionIdx].steps[0];
actualPosition = AddStepPositi