diff options
Diffstat (limited to 'devtools/client/performance-new')
91 files changed, 12091 insertions, 0 deletions
diff --git a/devtools/client/performance-new/.eslintrc.js b/devtools/client/performance-new/.eslintrc.js new file mode 100644 index 0000000000..bb4f0e5e16 --- /dev/null +++ b/devtools/client/performance-new/.eslintrc.js @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.exports = { + rules: { + // Props are checked by TypeScript, so we don't need dynamic type checking here. + "react/prop-types": "off", + }, +}; diff --git a/devtools/client/performance-new/@types/README.md b/devtools/client/performance-new/@types/README.md new file mode 100644 index 0000000000..204e88a419 --- /dev/null +++ b/devtools/client/performance-new/@types/README.md @@ -0,0 +1,5 @@ +# TypeScript @types + +This folder contains the type files that can be imported into various files. These types are collected here, and are likely cumbersome to type in JSDoc, or are separate enough from the code that it's easier to type them in separate files (such as the Redux store types). + +In addition, there is an ambient type file, `gecko.d.ts` file that contains the ambient types that are unique to Gecko. diff --git a/devtools/client/performance-new/@types/gecko.d.ts b/devtools/client/performance-new/@types/gecko.d.ts new file mode 100644 index 0000000000..e03844cb34 --- /dev/null +++ b/devtools/client/performance-new/@types/gecko.d.ts @@ -0,0 +1,458 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * TS-TODO - Needs typing. + * + * This file contains type stubs for loading things from Gecko. All of these + * types should be used in the correct places eventually. + */ + +/** + * Namespace anything that has its types mocked out here. These definitions are + * only "good enough" to get the type checking to pass in this directory. + * Eventually some more structured solution should be found. This namespace is + * global and makes sure that all the definitions inside do not clash with + * naming. + */ +declare namespace MockedExports { + /** + * This interface teaches ChromeUtils.import how to find modules. + */ + interface KnownModules { + Services: typeof import("Services"); + "resource://gre/modules/AppConstants.sys.mjs": typeof import("resource://gre/modules/AppConstants.sys.mjs"); + "resource:///modules/CustomizableUI.sys.mjs": typeof import("resource:///modules/CustomizableUI.sys.mjs"); + "resource:///modules/CustomizableWidgets.sys.mjs": typeof import("resource:///modules/CustomizableWidgets.sys.mjs"); + "resource://devtools/shared/loader/Loader.sys.mjs": typeof import("resource://devtools/shared/loader/Loader.sys.mjs"); + "resource://devtools/client/performance-new/shared/background.sys.mjs": typeof import("resource://devtools/client/performance-new/shared/background.sys.mjs"); + "resource://devtools/client/performance-new/shared/symbolication.sys.mjs": typeof import("resource://devtools/client/performance-new/shared/symbolication.sys.mjs"); + "resource://devtools/shared/loader/browser-loader.js": any; + "resource://devtools/client/performance-new/popup/menu-button.sys.mjs": typeof import("resource://devtools/client/performance-new/popup/menu-button.sys.mjs"); + "resource://devtools/client/performance-new/shared/typescript-lazy-load.sys.mjs": typeof import("resource://devtools/client/performance-new/shared/typescript-lazy-load.sys.mjs"); + "resource://devtools/client/performance-new/popup/logic.sys.mjs": typeof import("resource://devtools/client/performance-new/popup/logic.sys.mjs"); + "resource:///modules/PanelMultiView.sys.mjs": typeof import("resource:///modules/PanelMultiView.sys.mjs"); + } + + interface ChromeUtils { + /** + * This function reads the KnownModules and resolves which import to use. + * If you are getting the TS2345 error: + * + * Argument of type '"resource:///.../file.jsm"' is not assignable to parameter + * of type + * + * Then add the file path to the KnownModules above. + */ + import: <S extends keyof KnownModules>(module: S) => KnownModules[S]; + importESModule: <S extends keyof KnownModules>( + module: S + ) => KnownModules[S]; + defineModuleGetter: (target: any, variable: string, path: string) => void; + defineESModuleGetters: (target: any, mappings: any) => void; + } + + interface MessageManager { + loadFrameScript(url: string, flag: boolean): void; + sendAsyncMessage: (event: string, data: any) => void; + addMessageListener: (event: string, listener: (event: any) => void) => void; + } + + interface Browser { + addWebTab: (url: string, options: any) => BrowserTab; + contentPrincipal: any; + selectedTab: BrowserTab; + selectedBrowser?: ChromeBrowser; + messageManager: MessageManager; + ownerDocument?: ChromeDocument; + } + + interface BrowserTab { + linkedBrowser: Browser; + } + + interface ChromeWindow { + gBrowser: Browser; + focus(): void; + openWebLinkIn( + url: string, + where: "current" | "tab" | "window", + options: Partial<{ + // Not all possible options are present, please add more if/when needed. + userContextId: number; + forceNonPrivate: boolean; + relatedToCurrent: boolean; + resolveOnContentBrowserCreated: ( + contentBrowser: ChromeBrowser + ) => unknown; + }> + ): void; + } + + interface ChromeBrowser { + browsingContext?: BrowsingContext; + } + + interface BrowsingContext { + /** + * A unique identifier for the browser element that is hosting this + * BrowsingContext tree. Every BrowsingContext in the element's tree will + * return the same ID in all processes and it will remain stable regardless of + * process changes. When a browser element's frameloader is switched to + * another browser element this ID will remain the same but hosted under the + * under the new browser element. + * We are using this identifier for getting the active tab ID and passing to + * the profiler back-end. See `getActiveBrowserID` for the usage. + */ + browserId: number; + } + + type GetPref<T> = (prefName: string, defaultValue?: T) => T; + type SetPref<T> = (prefName: string, value?: T) => T; + type nsIPrefBranch = { + clearUserPref: (prefName: string) => void; + getStringPref: GetPref<string>; + setStringPref: SetPref<string>; + getCharPref: GetPref<string>; + setCharPref: SetPref<string>; + getIntPref: GetPref<number>; + setIntPref: SetPref<number>; + getBoolPref: GetPref<boolean>; + setBoolPref: SetPref<boolean>; + addObserver: ( + aDomain: string, + aObserver: PrefObserver, + aHoldWeak?: boolean + ) => void; + removeObserver: (aDomain: string, aObserver: PrefObserver) => void; + }; + + type PrefObserverFunction = ( + aSubject: nsIPrefBranch, + aTopic: "nsPref:changed", + aData: string + ) => unknown; + type PrefObserver = PrefObserverFunction | { observe: PrefObserverFunction }; + + interface nsIURI {} + + interface SharedLibrary { + start: number; + end: number; + offset: number; + name: string; + path: string; + debugName: string; + debugPath: string; + breakpadId: string; + arch: string; + } + + interface ProfileGenerationAdditionalInformation { + sharedLibraries: SharedLibrary[]; + } + + interface ProfileAndAdditionalInformation { + profile: ArrayBuffer; + additionalInformation?: ProfileGenerationAdditionalInformation; + } + + type Services = { + env: { + set: (name: string, value: string) => void; + get: (name: string) => string; + exists: (name: string) => boolean; + }; + prefs: nsIPrefBranch; + profiler: { + StartProfiler: ( + entryCount: number, + interval: number, + features: string[], + filters?: string[], + activeTabId?: number, + duration?: number + ) => void; + StopProfiler: () => void; + IsPaused: () => boolean; + Pause: () => void; + Resume: () => void; + IsSamplingPaused: () => boolean; + PauseSampling: () => void; + ResumeSampling: () => void; + GetFeatures: () => string[]; + getProfileDataAsync: (sinceTime?: number) => Promise<object>; + getProfileDataAsArrayBuffer: (sinceTime?: number) => Promise<ArrayBuffer>; + getProfileDataAsGzippedArrayBuffer: ( + sinceTime?: number + ) => Promise<ProfileAndAdditionalInformation>; + IsActive: () => boolean; + sharedLibraries: SharedLibrary[]; + }; + platform: string; + obs: { + addObserver: (observer: object, type: string) => void; + removeObserver: (observer: object, type: string) => void; + }; + wm: { + getMostRecentWindow: (name: string) => ChromeWindow; + getMostRecentNonPBWindow: (name: string) => ChromeWindow; + }; + focus: { + activeWindow: ChromeWindow; + }; + io: { + newURI(url: string): nsIURI; + }; + scriptSecurityManager: any; + startup: { + quit: (optionsBitmask: number) => void; + eForceQuit: number; + eRestart: number; + }; + }; + + const EventEmitter: { + decorate: (target: object) => void; + }; + + const AppConstantsSYSMJS: { + AppConstants: { + platform: string; + }; + }; + + interface BrowsingContextStub {} + interface PrincipalStub {} + + interface WebChannelTarget { + browsingContext: BrowsingContextStub; + browser: Browser; + eventTarget: null; + principal: PrincipalStub; + } + + // TS-TODO + const CustomizableUISYSMJS: any; + const CustomizableWidgetsSYSMJS: any; + const PanelMultiViewSYSMJS: any; + + const LoaderESM: { + require: (path: string) => any; + }; + + const Services: Services; + + // This class is needed by the Cc importing mechanism. e.g. + // Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + class nsIFilePicker {} + + interface FilePicker { + init: (window: Window, title: string, mode: number) => void; + open: (callback: (rv: number) => unknown) => void; + // The following are enum values. + modeGetFolder: number; + returnOK: number; + file: { + path: string; + }; + } + + interface Cc { + "@mozilla.org/filepicker;1": { + createInstance(instance: nsIFilePicker): FilePicker; + }; + } + + interface Ci { + nsIFilePicker: nsIFilePicker; + } + + interface Cu { + /** + * This function reads the KnownModules and resolves which import to use. + * If you are getting the TS2345 error: + * + * Argument of type '"resource:///.../file.jsm"' is not assignable to parameter + * of type + * + * Then add the file path to the KnownModules above. + */ + import: <S extends keyof KnownModules>(module: S) => KnownModules[S]; + exportFunction: (fn: Function, scope: object, options?: object) => void; + cloneInto: (value: any, scope: object, options?: object) => void; + isInAutomation: boolean; + } + + interface FluentLocalization { + /** + * This function sets the attributes data-l10n-id and possibly data-l10n-args + * on the element. + */ + setAttributes( + target: Element, + id?: string, + args?: Record<string, string> + ): void; + } +} + +interface PathUtilsInterface { + split: (path: string) => string[]; + isAbsolute: (path: string) => boolean; +} + +declare module "resource://devtools/client/shared/vendor/react.js" { + import * as React from "react"; + export = React; +} + +declare module "resource://devtools/client/shared/vendor/react-dom-factories.js" { + import * as ReactDomFactories from "react-dom-factories"; + export = ReactDomFactories; +} + +declare module "resource://devtools/client/shared/vendor/redux.js" { + import * as Redux from "redux"; + export = Redux; +} + +declare module "resource://devtools/client/shared/vendor/react-redux.js" { + import * as ReactRedux from "react-redux"; + export = ReactRedux; +} + +declare module "resource://devtools/shared/event-emitter2.js" { + export = MockedExports.EventEmitter; +} + +declare module "Services" { + export = MockedExports.Services; +} + +declare module "ChromeUtils" { + export = ChromeUtils; +} + +declare module "resource://gre/modules/AppConstants.sys.mjs" { + export = MockedExports.AppConstantsSYSMJS; +} + +declare module "resource://devtools/client/performance-new/shared/background.sys.mjs" { + import * as Background from "devtools/client/performance-new/shared/background.sys.mjs"; + export = Background; +} + +declare module "resource://devtools/client/performance-new/shared/symbolication.sys.mjs" { + import * as PerfSymbolication from "devtools/client/performance-new/shared/symbolication.sys.mjs"; + export = PerfSymbolication; +} + +declare module "resource:///modules/CustomizableUI.sys.mjs" { + export = MockedExports.CustomizableUISYSMJS; +} + +declare module "resource:///modules/CustomizableWidgets.sys.mjs" { + export = MockedExports.CustomizableWidgetsSYSMJS; +} + +declare module "resource:///modules/PanelMultiView.sys.mjs" { + export = MockedExports.PanelMultiViewSYSMJS; +} + +declare module "resource://devtools/shared/loader/Loader.sys.mjs" { + export = MockedExports.LoaderESM; +} + +declare var ChromeUtils: MockedExports.ChromeUtils; + +declare var PathUtils: PathUtilsInterface; + +// These global objects can be used directly in JSM files only. +declare var Cu: MockedExports.Cu; +declare var Cc: MockedExports.Cc; +declare var Ci: MockedExports.Ci; +declare var Services: MockedExports.Services; + +/** + * This is a variant on the normal Document, as it contains chrome-specific properties. + */ +declare interface ChromeDocument extends Document { + /** + * Create a XUL element of a specific type. Right now this function + * only refines iframes, but more tags could be added. + */ + createXULElement: ((type: "iframe") => XULIframeElement) & + ((type: string) => XULElement); + + /** + * This is a fluent instance connected to this document. + */ + l10n: MockedExports.FluentLocalization; +} + +/** + * This is a variant on the HTMLElement, as it contains chrome-specific properties. + */ +declare interface ChromeHTMLElement extends HTMLElement { + ownerDocument: ChromeDocument; +} + +declare interface XULElement extends HTMLElement { + ownerDocument: ChromeDocument; +} + +declare interface XULIframeElement extends XULElement { + contentWindow: ChromeWindow; + src: string; +} + +declare interface ChromeWindow extends Window { + openWebLinkIn: ( + url: string, + where: "current" | "tab" | "tabshifted" | "window" | "save", + // TS-TODO + params?: unknown + ) => void; + openTrustedLinkIn: ( + url: string, + where: "current" | "tab" | "tabshifted" | "window" | "save", + // TS-TODO + params?: unknown + ) => void; +} + +declare class ChromeWorker extends Worker {} + +declare interface MenuListElement extends XULElement { + value: string; + disabled: boolean; +} + +declare interface XULCommandEvent extends Event { + target: XULElement; +} + +declare interface XULElementWithCommandHandler { + addEventListener: ( + type: "command", + handler: (event: XULCommandEvent) => void, + isCapture?: boolean + ) => void; + removeEventListener: ( + type: "command", + handler: (event: XULCommandEvent) => void, + isCapture?: boolean + ) => void; +} + +declare type nsIPrefBranch = MockedExports.nsIPrefBranch; + +// chrome context-only DOM isInstance method +// XXX: This hackishly extends Function because there is no way to extend DOM constructors. +// Callers should manually narrow the type when needed. +// See also https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/222 +interface Function { + isInstance(obj: any): boolean; +} diff --git a/devtools/client/performance-new/@types/perf.d.ts b/devtools/client/performance-new/@types/perf.d.ts new file mode 100644 index 0000000000..2c7ec7f0b4 --- /dev/null +++ b/devtools/client/performance-new/@types/perf.d.ts @@ -0,0 +1,688 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This file contains the shared types for the performance-new client. + */ + +import { + Reducer as ReduxReducer, + Store as ReduxStore, +} from "resource://devtools/client/shared/vendor/redux.js"; + +export interface PanelWindow { + gToolbox?: any; + gStore?: Store; + gInit( + perfFront: PerfFront, + traits: RootTraits, + pageContext: PageContext, + openAboutProfiling: () => void + ): Promise<void>; + gDestroy(): void; + gIsPanelDestroyed?: boolean; +} + +/** + * TS-TODO - Stub. + */ +export interface Target { + // TODO + client: any; +} + +/** + * TS-TODO - Stub. + */ +export interface Toolbox { + target: Target; +} + +/** + * TS-TODO - Stub. + */ +export interface Commands { + client: any; + targetCommand: { + targetFront: { + getTrait: ( + traitName: string + ) => unknown; + }; + }; +} + +/** + * TS-TODO - Stub. + */ +export interface PerfFront { + startProfiler: (options: RecordingSettings) => Promise<boolean>; + getProfileAndStopProfiler: () => Promise<any>; + stopProfilerAndDiscardProfile: () => Promise<void>; + getSymbolTable: ( + path: string, + breakpadId: string + ) => Promise<[number[], number[], number[]]>; + isActive: () => Promise<boolean>; + isSupportedPlatform: () => Promise<boolean>; + on: (type: string, listener: () => void) => void; + off: (type: string, listener: () => void) => void; + destroy: () => void; + getSupportedFeatures: () => Promise<string[]>; +} + +/** + * TS-TODO - Stub + */ +export interface PreferenceFront { + clearUserPref: (prefName: string) => Promise<void>; + getStringPref: (prefName: string) => Promise<string>; + setStringPref: (prefName: string, value: string) => Promise<void>; + getCharPref: (prefName: string) => Promise<string>; + setCharPref: (prefName: string, value: string) => Promise<void>; + getIntPref: (prefName: string) => Promise<number>; + setIntPref: (prefName: string, value: number) => Promise<void>; +} + +export interface RootTraits { + // There are no traits used by the performance front end at the moment. +} + +export type RecordingState = + // The initial state before we've queried the PerfActor + | "not-yet-known" + // The profiler is available, we haven't started recording yet. + | "available-to-record" + // An async request has been sent to start the profiler. + | "request-to-start-recording" + // An async request has been sent to get the profile and stop the profiler. + | "request-to-get-profile-and-stop-profiler" + // An async request has been sent to stop the profiler. + | "request-to-stop-profiler" + // The profiler notified us that our request to start it actually started + // it, or it was already started. + | "recording"; + +// We are currently migrating to a new UX workflow with about:profiling. +// This type provides an easy way to change the implementation based +// on context. +export type PageContext = + | "devtools" + | "devtools-remote" + | "aboutlogging" + | "aboutprofiling" + | "aboutprofiling-remote"; + +export type PrefPostfix = "" | ".remote"; + +export interface State { + recordingState: RecordingState; + recordingUnexpectedlyStopped: boolean; + isSupportedPlatform: boolean | null; + recordingSettings: RecordingSettings; + initializedValues: InitializedValues | null; + promptEnvRestart: null | string; +} + +export type Selector<T> = (state: State) => T; + +export type ThunkDispatch = <Returns>(action: ThunkAction<Returns>) => Returns; +export type PlainDispatch = (action: Action) => Action; +export type GetState = () => State; +export type SymbolTableAsTuple = [Uint32Array, Uint32Array, Uint8Array]; + +/** + * The `dispatch` function can accept either a plain action or a thunk action. + * This is similar to a type `(action: Action | ThunkAction) => any` except this + * allows to type the return value as well. + */ +export type Dispatch = PlainDispatch & ThunkDispatch; + +export type ThunkAction<Returns> = ({ + dispatch, + getState, +}: { + dispatch: Dispatch; + getState: GetState; +}) => Returns; + +export interface Library { + start: number; + end: number; + offset: number; + name: string; + path: string; + debugName: string; + debugPath: string; + breakpadId: string; + arch: string; +} + +/** + * Only provide types for the GeckoProfile as much as we need it. There is no + * reason to maintain a full type definition here. + */ +export interface MinimallyTypedGeckoProfile { + libs: Library[]; + processes: MinimallyTypedGeckoProfile[]; +} + +export type GetSymbolTableCallback = ( + debugName: string, + breakpadId: string +) => Promise<SymbolTableAsTuple>; + +export interface SymbolicationService { + getSymbolTable: GetSymbolTableCallback; + querySymbolicationApi: (path: string, requestJson: string) => Promise<string>; +} + +export type ReceiveProfile = ( + geckoProfile: MinimallyTypedGeckoProfile, + profilerViewMode: ProfilerViewMode | undefined, + getSymbolTableCallback: GetSymbolTableCallback +) => void; + +/** + * This is the type signature for a function to restart the browser with a given + * environment variable. Currently only implemented for the popup. + */ +export type RestartBrowserWithEnvironmentVariable = ( + envName: string, + value: string +) => void; + +/** + * This is the type signature for the event listener that's called once the + * profile has been obtained. + */ +export type OnProfileReceived = (profile: MinimallyTypedGeckoProfile) => void; + +/** + * This is the type signature for a function to query the browser for the + * ID of the active tab. + */ +export type GetActiveBrowserID = () => number; + +/** + * This interface is injected into profiler.firefox.com + */ +interface GeckoProfilerFrameScriptInterface { + getProfile: () => Promise<MinimallyTypedGeckoProfile>; + getSymbolTable: GetSymbolTableCallback; +} + +export interface RecordingSettings { + presetName: string; + entries: number; + interval: number; // in milliseconds + features: string[]; + threads: string[]; + objdirs: string[]; + // The duration is currently not wired up to the UI yet. See Bug 1587165. + duration?: number; +} + +/** + * A Redux Reducer that knows about the performance-new client's Actions. + */ +export type Reducer<S> = (state: S | undefined, action: Action) => S; + +export interface InitializedValues { + // The current list of presets, loaded in from a JSM. + presets: Presets; + // Determine the current page context. + pageContext: PageContext; + // The list of profiler features that the current target supports. + supportedFeatures: string[]; + // Allow about:profiling to switch back to the remote devtools panel. + openRemoteDevTools?: () => void; +} + +/** + * Export a store that is opinionated about our State definition, and the union + * of all Actions, as well as specific Dispatch behavior. + */ +export type Store = ReduxStore<State, Action>; + +export type Action = + | { + type: "REPORT_PROFILER_READY"; + isActive: boolean; + } + | { + type: "REPORT_PROFILER_STARTED"; + } + | { + type: "REPORT_PROFILER_STOPPED"; + } + | { + type: "REQUESTING_TO_START_RECORDING"; + } + | { + type: "REQUESTING_TO_STOP_RECORDING"; + } + | { + type: "REQUESTING_PROFILE"; + } + | { + type: "OBTAINED_PROFILE"; + } + | { + type: "CHANGE_INTERVAL"; + interval: number; + } + | { + type: "CHANGE_ENTRIES"; + entries: number; + } + | { + type: "CHANGE_FEATURES"; + features: string[]; + promptEnvRestart: string | null; + } + | { + type: "CHANGE_THREADS"; + threads: string[]; + } + | { + type: "CHANGE_OBJDIRS"; + objdirs: string[]; + } + | { + type: "INITIALIZE_STORE"; + isSupportedPlatform: boolean; + presets: Presets; + pageContext: PageContext; + openRemoteDevTools?: () => void; + supportedFeatures: string[]; + } + | { + type: "CHANGE_PRESET"; + presetName: string; + preset: PresetDefinition | undefined; + } + | { + type: "UPDATE_SETTINGS_FROM_PREFERENCES"; + recordingSettingsFromPreferences: RecordingSettings; + }; + +export interface InitializeStoreValues { + isSupportedPlatform: boolean; + presets: Presets; + pageContext: PageContext; + supportedFeatures: string[]; + openRemoteDevTools?: () => void; +} + +export type PopupBackgroundFeatures = { [feature: string]: boolean }; + +// TS-TODO - Stub +export interface ContentFrameMessageManager { + addMessageListener: (event: string, listener: (event: any) => void) => void; + addEventListener: (event: string, listener: (event: any) => void) => void; + sendAsyncMessage: (name: string, data: any) => void; +} + +/** + * This interface serves as documentation for all of the prefs used by the + * performance-new client. Each preference access string access can be coerced to + * one of the properties of this interface. + */ +export interface PerformancePref { + /** + * The recording preferences by default are controlled by different presets. + * This pref stores that preset. + */ + Preset: "devtools.performance.recording.preset"; + /** + * Stores the total number of entries to be used in the profile buffer. + */ + Entries: "devtools.performance.recording.entries"; + /** + * The recording interval, stored in microseconds. Note that the StartProfiler + * interface uses milliseconds, but this lets us store higher precision numbers + * inside of an integer preference store. + */ + Interval: "devtools.performance.recording.interval"; + /** + * The features enabled for the profiler, stored as a comma-separated list. + */ + Features: "devtools.performance.recording.features"; + /** + * The threads to profile, stored as a comma-separated list. + */ + Threads: "devtools.performance.recording.threads"; + /** + * The location of the objdirs to use, stored as a comma-separated list. + */ + ObjDirs: "devtools.performance.recording.objdirs"; + /** + * The duration of the profiling window to use in seconds. Setting this to 0 + * will cause no profile window to be used, and the values will naturally roll + * off from the profiling buffer. + * + * This is currently not hooked up to any UI. See Bug 1587165. + */ + Duration: "devtools.performance.recording.duration"; + /** + * Normally this defaults to https://profiler.firefox.com, but this can be overridden + * to point the profiler to a different URL, such as http://localhost:4242/ for + * local development workflows. + */ + UIBaseUrl: "devtools.performance.recording.ui-base-url"; + /** + * This pref allows tests to override the /from-browser in order to more easily + * test the profile injection mechanism. + */ + UIBaseUrlPathPref: "devtools.performance.recording.ui-base-url-path"; + /** + * This controls whether we enable the active tab view when capturing in web + * developer preset. + * We're not enabling the active-tab view in all environments until we + * iron out all its issues. + */ + UIEnableActiveTabView: "devtools.performance.recording.active-tab-view.enabled"; + /** + * The profiler popup has some introductory text explaining what it is the first + * time that you open it. After that, it is not displayed by default. + */ + PopupIntroDisplayed: "devtools.performance.popup.intro-displayed"; + /** + * This preference is used outside of the performance-new type system + * (in DevToolsStartup). It toggles the availability of the profiler menu + * button in the customization palette. + */ + PopupFeatureFlag: "devtools.performance.popup.feature-flag"; +} + +/* The next 2 types bring some duplication from gecko.d.ts, but this is simpler + * this way. */ + +/** + * This is a function called by a preference observer. + */ +export type PrefObserverFunction = ( + aSubject: nsIPrefBranch, + aTopic: "nsPref:changed", + aData: string +) => unknown; + +/** + * This is the type of an observer we can pass to Service.prefs.addObserver and + * Service.prefs.removeObserver. + */ +export type PrefObserver = + | PrefObserverFunction + | { observe: PrefObserverFunction }; + +/** + * Scale a number value. + */ +export type NumberScaler = (value: number) => number; + +/** + * A collection of functions to scale numbers. + */ +export interface ScaleFunctions { + fromFractionToValue: NumberScaler; + fromValueToFraction: NumberScaler; + fromFractionToSingleDigitValue: NumberScaler; + steps: number; +} + +/** + * View mode for the Firefox Profiler front-end timeline. + * `undefined` is defaulted to full automatically. + */ +export type ProfilerViewMode = "full" | "active-tab" | "origins"; + +export interface PresetDefinition { + entries: number; + interval: number; + features: string[]; + threads: string[]; + duration: number; + profilerViewMode?: ProfilerViewMode; + l10nIds: { + popup: { + label: string; + description: string; + }; + devtools: { + label: string; + description: string; + }; + }; +} + +export interface Presets { + [presetName: string]: PresetDefinition; +} + +// Should be kept in sync with the types in https://github.com/firefox-devtools/profiler/blob/main/src/app-logic/web-channel.js . +// Compatibility is handled as follows: +// - The front-end needs to worry about compatibility and handle older browser versions. +// - The browser can require the latest front-end version and does not need to keep any legacy functionality for older front-end versions. + +type MessageFromFrontend = { + requestId: number; +} & RequestFromFrontend; + +export type RequestFromFrontend = + | StatusQueryRequest + | EnableMenuButtonRequest + | GetProfileRequest + | GetExternalPowerTracksRequest + | GetSymbolTableRequest + | QuerySymbolicationApiRequest; + +type StatusQueryRequest = { type: "STATUS_QUERY" }; +type EnableMenuButtonRequest = { type: "ENABLE_MENU_BUTTON" }; +type GetProfileRequest = { type: "GET_PROFILE" }; +type GetExternalPowerTracksRequest = { + type: "GET_EXTERNAL_POWER_TRACKS", + startTime: number, + endTime: number, +}; +type GetSymbolTableRequest = { + type: "GET_SYMBOL_TABLE"; + debugName: string; + breakpadId: string; +}; +type QuerySymbolicationApiRequest = { + type: "QUERY_SYMBOLICATION_API"; + path: string; + requestJson: string; +}; + +export type MessageToFrontend<R> = + | OutOfBandErrorMessageToFrontend + | ErrorResponseMessageToFrontend + | SuccessResponseMessageToFrontend<R>; + +type OutOfBandErrorMessageToFrontend = { + errno: number; + error: string; +}; + +type ErrorResponseMessageToFrontend = { + type: "ERROR_RESPONSE"; + requestId: number; + error: string; +}; + +type SuccessResponseMessageToFrontend<R> = { + type: "SUCCESS_RESPONSE"; + requestId: number; + response: R; +}; + +export type ResponseToFrontend = + | StatusQueryResponse + | EnableMenuButtonResponse + | GetProfileResponse + | GetExternalPowerTracksResponse + | GetSymbolTableResponse + | QuerySymbolicationApiResponse; + +type StatusQueryResponse = { + menuButtonIsEnabled: boolean; + // The version indicates which message types are supported by the browser. + // No version: + // Shipped in Firefox 76. + // Supports the following message types: + // - STATUS_QUERY + // - ENABLE_MENU_BUTTON + // Version 1: + // Shipped in Firefox 93. + // Adds support for the following message types: + // - GET_PROFILE + // - GET_SYMBOL_TABLE + // - QUERY_SYMBOLICATION_API + // Version 2: + // Shipped in Firefox 121. + // Adds support for the following message types: + // - GET_EXTERNAL_POWER_TRACKS + version: number; +}; +type EnableMenuButtonResponse = void; +type GetProfileResponse = ArrayBuffer | MinimallyTypedGeckoProfile; +type GetExternalPowerTracksResponse = Array<object>; +type GetSymbolTableResponse = SymbolTableAsTuple; +type QuerySymbolicationApiResponse = string; + +/** + * This represents an event channel that can talk to a content page on the web. + * This interface is a manually typed version of toolkit/modules/WebChannel.sys.mjs + * and is opinionated about the types of messages we can send with it. + * + * The definition is here rather than gecko.d.ts because it was simpler than getting + * generics working with the ChromeUtils.import machinery. + */ +export class ProfilerWebChannel { + constructor(id: string, url: MockedExports.nsIURI); + send: ( + message: MessageToFrontend<ResponseToFrontend>, + target: MockedExports.WebChannelTarget + ) => void; + listen: ( + handler: ( + idle: string, + message: MessageFromFrontend, + target: MockedExports.WebChannelTarget + ) => void + ) => void; +} + +/** + * The per-tab information that is stored when a new profile is captured + * and a profiler tab is opened, to serve the correct profile to the tab + * that sends the WebChannel message. + */ +export type ProfilerBrowserInfo = { + profileCaptureResult: ProfileCaptureResult; + symbolicationService: SymbolicationService; +}; + +export type ProfileCaptureResult = + | { + type: "SUCCESS"; + profile: MinimallyTypedGeckoProfile | ArrayBuffer; + } + | { + type: "ERROR"; + error: Error; + }; + +/** + * Describes all of the profiling features that can be turned on and + * off in about:profiling. + */ +export interface FeatureDescription { + // The name of the feature as shown in the UI. + name: string; + // The key value of the feature, this will be stored in prefs, and used in the + // nsiProfiler interface. + value: string; + // The full description of the preset, this will need to be localized. + title: string; + // This will give the user a hint that it's recommended on. + recommended?: boolean; + // This will give the user a hint that it's an experimental feature. + experimental?: boolean; + // This will give a reason if the feature is disabled. + disabledReason?: string; +} + +// The key has the shape `${debugName}:${breakpadId}`. +export type LibInfoMapKey = string; + +// This is a subset of the full Library struct. +export type LibInfoMapValue = { + name: string; + path: string; + debugName: string; + debugPath: string; + breakpadId: string; + arch: string; +}; + +export type SymbolicationWorkerInitialMessage = { + request: SymbolicationWorkerRequest; + // A map that allows looking up library info based on debugName + breakpadId. + // This is rather redundant at the moment, but it will make more sense once + // we can request symbols for multiple different libraries with one worker + // message. + libInfoMap: Map<LibInfoMapKey, LibInfoMapValue>; + // An array of objdir paths on the host machine that should be searched for + // relevant build artifacts. + objdirs: string[]; + // The profiler-get-symbols wasm module. + module: WebAssembly.Module; +}; + +export type SymbolicationWorkerRequest = + | { + type: "GET_SYMBOL_TABLE"; + // The debugName of the binary whose symbols should be obtained. + debugName: string; + // The breakpadId for the binary whose symbols should be obtained. + breakpadId: string; + } + | { + type: "QUERY_SYMBOLICATION_API"; + // The API entry path, such as "/symbolicate/v5". + path: string; + // The payload JSON, as a string. + requestJson: string; + }; + +export type SymbolicationWorkerError = { + name: string; + message: string; + fileName?: string; + lineNumber?: number; +}; + +export type SymbolicationWorkerReplyData<R> = + | { + result: R; + } + | { + error: SymbolicationWorkerError; + }; + +// This type is used in the symbolication worker for the return type of the +// FileAndPathHelper's readFile method. +// FIXME: Or rather, this type *would* be used if the worker code was checked +// by TypeScript. +export interface FileHandle { + // Return the length of the file in bytes. + getLength: () => number; + // Synchronously read the bytes at offset `offset` into the array `dest`. + readBytesInto: (dest: Uint8Array, offset: number) => void; + // Called when the file is no longer needed, to allow closing the file. + drop: () => void; +} diff --git a/devtools/client/performance-new/README.md b/devtools/client/performance-new/README.md new file mode 100644 index 0000000000..10e1231fff --- /dev/null +++ b/devtools/client/performance-new/README.md @@ -0,0 +1,46 @@ +# Performance New + +This folder contains the code for the new performance panel that is a simplified recorder that works to record a performance profile, and inject it into profiler.firefox.com. This tool is not in charge of any of the analysis, only the recording. + +## TypeScript + +This project contains TypeScript types in JSDoc comments. To run the type checker point your terminal to this directory, and run `yarn install`, then `yarn test`. In addition type hints should work if your editor is configured to speak TypeScript. + +## Overall Architecture + +This project has a few different views explained below. + +### DevTools + +This is a simplified recording panel that includes a preset dropdown. It's embedded in the DevTools. It's not the preferred way to use the profiler, but is included so that users are comfortable with existing workflows. This is built using React/Redux. The store's code is shared by all the views, but each view initializes it separately. The popup does not use React/Redux (explained later). When editing a custom preset, it takes you to "about:profiling" in a new tab. + +This panel works similarly to the other DevTools panels. The `devtools/client/performance-new/panel/initializer.js` is in charge of initializing the page specifically for the DevTools workflow. This script creates a `PerfActor` that is then used for talking to the Gecko Profiler component. + +### DevTools Remote + +This is the same UI and codebase as the DevTools panel, but it's accessible from about:debugging for remote targets. It uses the PerfFront for a remote target to profile on the remote device. When editing a custom preset, it takes you to "about:profiling" in the same modal. + +This page is initialized with the `PerfActor`, but it will target a remote debuggee, like an Android Phone. + +### about:profiling + +This view uses React/Redux for the UI, and is a full page for configuring the profiler. There are no controls for recording a profile, only editing the settings. It shares the same Redux store code as DevTools (instantiated separately), but uses different React components. + +### about:profiling Remote + +This is the remote view of the about:profiling page. It is embedded in the about:debugging profiler modal dialog, and it is initialized by about:debugging. It uses preferences that are postfixed with ".remote", so that a second set of preferences are shared for how remote profiling is configured. + +### Profiler Popup + +The popup is enabled by default on Nightly and Dev Edition, but it's not added to the navbar. Once the profiler menu button is added to the navbar, or other places in the UI, the shortcuts for the profiler will work. In any release channel the popup can be enabled by visiting [profiler.firefox.com] and clicking `Enable Profiler Menu Button`. This flips the pref `"devtools.performance.popup.feature-flag"` and the profiler button will always be available in the list of buttons for the Firefox UI. + +The popup UI is not a React Redux app, but has a vanilla browser chrome implementation. This was done to make the popup as fast as possible, with a trade-off of some complexity with dealing with the non-standard (i.e. not a normal webpage) browser chrome environment. The popup is designed to be as low overhead as possible in order to get the cleanest performance profiles. Special care must be taken to not impact browser startup times when working with this implementation, as it also turns on the global profiler shortcuts. + +Some code about the popup is also present in `devtools/startup/DevToolsStartup.sys.mjs`, and its markup is defined in `browser/base/content/appmenu-viewcache.inc.xhtml` (search for `PanelUI-profiler`). + +## Injecting profiles into [profiler.firefox.com] + +After a profile has been collected, it needs to be sent to [profiler.firefox.com] for analysis. This is done by using browser APIs to open a new tab, and then setting up a web channel for the communication between the unprivileged page and the privileged chrome code. The page requests the profile data (as well as symbolication requests in some cases) through this Web Channel. +See `handleWebChannelMessage` in `background.sys.mjs` as well as related code in `devtools/startup/DevToolsStartup.sys.mjs` for implementation details. Both the DevTools Panel and the Popup use this channel. + +[profiler.firefox.com]: https://profiler.firefox.com diff --git a/devtools/client/performance-new/aboutprofiling/README.md b/devtools/client/performance-new/aboutprofiling/README.md new file mode 100644 index 0000000000..4055a7cdac --- /dev/null +++ b/devtools/client/performance-new/aboutprofiling/README.md @@ -0,0 +1,3 @@ +# about:profiling + +This directory collects the code that powers about:profiling. See devtools/client/performance-new/README.md for more information. diff --git a/devtools/client/performance-new/aboutprofiling/index.xhtml b/devtools/client/performance-new/aboutprofiling/index.xhtml new file mode 100644 index 0000000000..0968a8f5f5 --- /dev/null +++ b/devtools/client/performance-new/aboutprofiling/index.xhtml @@ -0,0 +1,36 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<html xmlns="http://www.w3.org/1999/xhtml" dir=""> + <head> + <meta + http-equiv="Content-Security-Policy" + content="default-src 'none'; style-src chrome: resource:; img-src chrome: resource:; script-src chrome: resource:; object-src 'none'" + /> + <meta name="color-scheme" content="light dark" /> + <link + rel="icon" + type="image/svg+xml" + href="chrome://devtools/skin/images/tool-profiler.svg" + /> + <link + rel="stylesheet" + href="chrome://global/skin/global.css" + type="text/css" + /> + <link + rel="stylesheet" + href="chrome://global/skin/in-content/common.css" + type="text/css" + /> + <link + rel="stylesheet" + href="chrome://devtools/skin/aboutprofiling.css" + type="text/css" + /> + </head> + <body class="theme-body"> + <div id="root"></div> + <script src="chrome://devtools/content/performance-new/aboutprofiling/initializer.js"></script> + </body> +</html> diff --git a/devtools/client/performance-new/aboutprofiling/initializer.js b/devtools/client/performance-new/aboutprofiling/initializer.js new file mode 100644 index 0000000000..8dd854007c --- /dev/null +++ b/devtools/client/performance-new/aboutprofiling/initializer.js @@ -0,0 +1,152 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check +/** + * @typedef {import("../@types/perf").RecordingSettings} RecordingSettings + * @typedef {import("../@types/perf").PerfFront} PerfFront + * @typedef {import("../@types/perf").PageContext} PageContext + */ +"use strict"; + +/** + * This file initializes the about:profiling page, which can be used to tweak the + * profiler's settings. + */ + +{ + // Create the browser loader, but take care not to conflict with + // TypeScript. See devtools/client/performance-new/typescript.md and + // the section on "Do not overload require" for more information. + + const { BrowserLoader } = ChromeUtils.import( + "resource://devtools/shared/loader/browser-loader.js" + ); + const browserLoader = BrowserLoader({ + baseURI: "resource://devtools/client/performance-new/aboutprofiling", + window, + }); + + /** + * @type {any} - Coerce the current scope into an `any`, and assign the + * loaders to the scope. They can then be used freely below. + */ + const scope = this; + scope.require = browserLoader.require; + scope.loader = browserLoader.loader; +} + +/** + * The background.sys.mjs manages the profiler state, and can be loaded multiple time + * for various components. This page needs a copy, and it is also used by the + * profiler shortcuts. In order to do this, the background code needs to live in a + * JSM module, that can be shared with the DevTools keyboard shortcut manager. + */ +const { presets } = ChromeUtils.importESModule( + "resource://devtools/client/performance-new/shared/background.sys.mjs" +); + +const ReactDOM = require("resource://devtools/client/shared/vendor/react-dom.js"); +const React = require("resource://devtools/client/shared/vendor/react.js"); +const FluentReact = require("resource://devtools/client/shared/vendor/fluent-react.js"); +const { + FluentL10n, +} = require("resource://devtools/client/shared/fluent-l10n/fluent-l10n.js"); +const Provider = React.createFactory( + require("resource://devtools/client/shared/vendor/react-redux.js").Provider +); +const ProfilerPreferenceObserver = React.createFactory( + require("resource://devtools/client/performance-new/components/shared/ProfilerPreferenceObserver.js") +); +const LocalizationProvider = React.createFactory( + FluentReact.LocalizationProvider +); +const AboutProfiling = React.createFactory( + require("resource://devtools/client/performance-new/components/aboutprofiling/AboutProfiling.js") +); +const createStore = require("resource://devtools/client/shared/redux/create-store.js"); +const reducers = require("resource://devtools/client/performance-new/store/reducers.js"); +const actions = require("resource://devtools/client/performance-new/store/actions.js"); + +/** + * Initialize the panel by creating a redux store, and render the root component. + * + * @param {PageContext} pageContext - The context that the UI is being loaded in under. + * @param {boolean} isSupportedPlatform + * @param {string[]} supportedFeatures + * @param {(() => void)} [openRemoteDevTools] Optionally provide a way to go back to + * the remote devtools page. + */ +async function gInit( + pageContext, + isSupportedPlatform, + supportedFeatures, + openRemoteDevTools +) { + const store = createStore(reducers); + + const l10n = new FluentL10n(); + await l10n.init( + [ + "devtools/client/perftools.ftl", + // For -brand-shorter-name used in some profiler preset descriptions. + "branding/brand.ftl", + // Needed for the onboarding UI + "toolkit/branding/brandings.ftl", + ], + { + setAttributesOnDocument: true, + } + ); + + // Do some initialization, especially with privileged things that are part of the + // the browser. + store.dispatch( + actions.initializeStore({ + isSupportedPlatform, + supportedFeatures, + presets, + pageContext, + openRemoteDevTools, + }) + ); + + ReactDOM.render( + Provider( + { store }, + LocalizationProvider( + { bundles: l10n.getBundles() }, + React.createElement( + React.Fragment, + null, + ProfilerPreferenceObserver(), + AboutProfiling() + ) + ) + ), + document.querySelector("#root") + ); + + window.addEventListener("unload", () => gDestroy(), { once: true }); +} + +async function gDestroy() { + // This allows all unregister commands to run. + ReactDOM.unmountComponentAtNode(document.querySelector("#root")); +} + +// Automatically initialize the page if it's not a remote connection, otherwise +// the page will be initialized by about:debugging. +if (window.location.hash !== "#remote") { + document.addEventListener( + "DOMContentLoaded", + () => { + const isSupportedPlatform = "nsIProfiler" in Ci; + const supportedFeatures = isSupportedPlatform + ? Services.profiler.GetFeatures() + : []; + gInit("aboutprofiling", isSupportedPlatform, supportedFeatures); + }, + { once: true } + ); +} diff --git a/devtools/client/performance-new/aboutprofiling/moz.build b/devtools/client/performance-new/aboutprofiling/moz.build new file mode 100644 index 0000000000..958a922236 --- /dev/null +++ b/devtools/client/performance-new/aboutprofiling/moz.build @@ -0,0 +1,7 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Performance Tools (Profiler/Timeline)") diff --git a/devtools/client/performance-new/components/README.md b/devtools/client/performance-new/components/README.md new file mode 100644 index 0000000000..ee33630140 --- /dev/null +++ b/devtools/client/performance-new/components/README.md @@ -0,0 +1,3 @@ +# Performance New Components + +This folder contains the components that are used for about:profiling, the devtools panel, and the about:debugging remote profiling view. The components are NOT used for the popup, which does not use React / Redux. diff --git a/devtools/client/performance-new/components/aboutprofiling/AboutProfiling.js b/devtools/client/performance-new/components/aboutprofiling/AboutProfiling.js new file mode 100644 index 0000000000..0d205093ba --- /dev/null +++ b/devtools/client/performance-new/components/aboutprofiling/AboutProfiling.js @@ -0,0 +1,156 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check + +/** + * @template P + * @typedef {import("react-redux").ResolveThunks<P>} ResolveThunks<P> + */ + +/** + * @typedef {Object} StateProps + * @property {boolean?} isSupportedPlatform + * @property {PageContext} pageContext + * @property {string | null} promptEnvRestart + * @property {(() => void) | undefined} openRemoteDevTools + */ + +/** + * @typedef {StateProps} Props + * @typedef {import("../../@types/perf").State} StoreState + * @typedef {import("../../@types/perf").PageContext} PageContext + */ + +"use strict"; + +const { + PureComponent, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const { + div, + h1, + button, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const Localized = createFactory( + require("resource://devtools/client/shared/vendor/fluent-react.js").Localized +); +const Settings = createFactory( + require("resource://devtools/client/performance-new/components/aboutprofiling/Settings.js") +); +const Presets = createFactory( + require("resource://devtools/client/performance-new/components/aboutprofiling/Presets.js") +); + +const selectors = require("resource://devtools/client/performance-new/store/selectors.js"); +const { + restartBrowserWithEnvironmentVariable, +} = require("resource://devtools/client/performance-new/shared/browser.js"); + +/** + * This is the top level component for the about:profiling page. It shares components + * with the popup and DevTools page. + * + * @extends {React.PureComponent<Props>} + */ +class AboutProfiling extends PureComponent { + handleRestart = () => { + const { promptEnvRestart } = this.props; + if (!promptEnvRestart) { + throw new Error( + "handleRestart() should only be called when promptEnvRestart exists." + ); + } + restartBrowserWithEnvironmentVariable(promptEnvRestart, "1"); + }; + + render() { + const { + isSupportedPlatform, + pageContext, + promptEnvRestart, + openRemoteDevTools, + } = this.props; + + if (isSupportedPlatform === null) { + // We don't know yet if this is a supported platform, wait for a response. + return null; + } + + return div( + { className: `perf perf-${pageContext}` }, + promptEnvRestart + ? div( + { className: "perf-env-restart" }, + div( + { + className: + "perf-photon-message-bar perf-photon-message-bar-warning perf-env-restart-fixed", + }, + div({ className: "perf-photon-message-bar-warning-icon" }), + Localized({ id: "perftools-status-restart-required" }), + button( + { + className: "perf-photon-button perf-photon-button-micro", + type: "button", + onClick: this.handleRestart, + }, + Localized({ id: "perftools-button-restart" }) + ) + ) + ) + : null, + + openRemoteDevTools + ? div( + { className: "perf-back" }, + button( + { + className: "perf-back-button", + type: "button", + onClick: openRemoteDevTools, + }, + Localized({ id: "perftools-button-save-settings" }) + ) + ) + : null, + + div( + { className: "perf-intro" }, + h1( + { className: "perf-intro-title" }, + Localized({ id: "perftools-intro-title" }) + ), + div( + { className: "perf-intro-row" }, + div({}, div({ className: "perf-intro-icon" })), + Localized({ + className: "perf-intro-text", + id: "perftools-intro-description", + }) + ) + ), + Presets(), + Settings() + ); + } +} + +/** + * @param {StoreState} state + * @returns {StateProps} + */ +function mapStateToProps(state) { + return { + isSupportedPlatform: selectors.getIsSupportedPlatform(state), + pageContext: selectors.getPageContext(state), + promptEnvRestart: selectors.getPromptEnvRestart(state), + openRemoteDevTools: selectors.getOpenRemoteDevTools(state), + }; +} + +module.exports = connect(mapStateToProps)(AboutProfiling); diff --git a/devtools/client/performance-new/components/aboutprofiling/DirectoryPicker.js b/devtools/client/performance-new/components/aboutprofiling/DirectoryPicker.js new file mode 100644 index 0000000000..4d9ea8373d --- /dev/null +++ b/devtools/client/performance-new/components/aboutprofiling/DirectoryPicker.js @@ -0,0 +1,119 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check + +/** + * @typedef {Object} Props + * @property {string[]} dirs + * @property {() => void} onAdd + * @property {(index: number) => void} onRemove + */ + +"use strict"; + +const { + PureComponent, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + div, + button, + select, + option, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + withCommonPathPrefixRemoved, +} = require("resource://devtools/client/performance-new/shared/utils.js"); +const Localized = createFactory( + require("resource://devtools/client/shared/vendor/fluent-react.js").Localized +); + +/** + * A list of directories with add and remove buttons. + * Looks like this: + * + * +---------------------------------------------+ + * | code/obj-m-android-opt | + * | code/obj-m-android-debug | + * | test/obj-m-test | + * | | + * +---------------------------------------------+ + * + * [+] [-] + * + * @extends {React.PureComponent<Props>} + */ +class DirectoryPicker extends PureComponent { + /** @param {Props} props */ + constructor(props) { + super(props); + this._listBox = null; + } + + /** + * @param {HTMLSelectElement} element + */ + _takeListBoxRef = element => { + this._listBox = element; + }; + + _handleAddButtonClick = () => { + this.props.onAdd(); + }; + + _handleRemoveButtonClick = () => { + if (this._listBox && this._listBox.selectedIndex !== -1) { + this.props.onRemove(this._listBox.selectedIndex); + } + }; + + render() { + const { dirs } = this.props; + const truncatedDirs = withCommonPathPrefixRemoved(dirs); + return [ + select( + { + className: "perf-settings-dir-list", + size: 4, + ref: this._takeListBoxRef, + key: "directory-picker-select", + }, + dirs.map((fullPath, i) => + option( + { + key: fullPath, + className: "pref-settings-dir-list-item", + title: fullPath, + }, + truncatedDirs[i] + ) + ) + ), + div( + { + className: "perf-settings-dir-list-button-group", + key: "directory-picker-div", + }, + button( + { + type: "button", + className: `perf-photon-button perf-photon-button-default perf-button`, + onClick: this._handleAddButtonClick, + }, + Localized({ id: "perftools-button-add-directory" }) + ), + button( + { + type: "button", + className: `perf-photon-button perf-photon-button-default perf-button`, + onClick: this._handleRemoveButtonClick, + }, + Localized({ id: "perftools-button-remove-directory" }) + ) + ), + ]; + } +} + +module.exports = DirectoryPicker; diff --git a/devtools/client/performance-new/components/aboutprofiling/Presets.js b/devtools/client/performance-new/components/aboutprofiling/Presets.js new file mode 100644 index 0000000000..4dc97d7e32 --- /dev/null +++ b/devtools/client/performance-new/components/aboutprofiling/Presets.js @@ -0,0 +1,167 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check + +/** + * @template P + * @typedef {import("react-redux").ResolveThunks<P>} ResolveThunks<P> + */ + +"use strict"; +const { + PureComponent, + createElement, + createFactory, + Fragment, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + div, + label, + input, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const selectors = require("resource://devtools/client/performance-new/store/selectors.js"); +const actions = require("resource://devtools/client/performance-new/store/actions.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +const Localized = createFactory( + require("resource://devtools/client/shared/vendor/fluent-react.js").Localized +); + +/** + * @typedef {Object} PresetProps + * @property {string} presetName + * @property {boolean} selected + * @property {import("../../@types/perf").PresetDefinition | null} preset + * @property {(presetName: string) => void} onChange + */ + +/** + * Switch between various profiler presets, which will override the individualized + * settings for the profiler. + * + * @extends {React.PureComponent<PresetProps>} + */ +class Preset extends PureComponent { + /** + * Handle the checkbox change. + * @param {React.ChangeEvent<HTMLInputElement>} event + */ + onChange = event => { + this.props.onChange(event.target.value); + }; + + render() { + const { preset, presetName, selected } = this.props; + const presetLabelAndDescription = preset + ? createElement( + Fragment, + null, + Localized( + { id: preset.l10nIds.devtools.label }, + div({ className: "perf-toggle-text-label" }) + ), + Localized( + { id: preset.l10nIds.devtools.description }, + div({ className: "perf-toggle-description" }) + ) + ) + : Localized( + { id: "perftools-presets-custom-label" }, + div({ className: "perf-toggle-text-label" }, "Custom") + ); + + return label( + { className: "perf-toggle-label" }, + input({ + className: "perf-presets-radio-button", + type: "radio", + name: "presets", + value: presetName, + checked: selected, + onChange: this.onChange, + }), + presetLabelAndDescription + ); + } +} + +/** + * @typedef {Object} StateProps + * @property {string} selectedPresetName + * @property {import("../../@types/perf").Presets} presets + */ + +/** + * @typedef {Object} ThunkDispatchProps + * @property {typeof actions.changePreset} changePreset + */ + +/** + * @typedef {ResolveThunks<ThunkDispatchProps>} DispatchProps + * @typedef {StateProps & DispatchProps} Props + * @typedef {import("../../@types/perf").State} StoreState + */ + +/** + * Switch between various profiler presets, which will override the individualized + * settings for the profiler. + * + * @extends {React.PureComponent<Props>} + */ +class Presets extends PureComponent { + /** + * Handle the checkbox change. + * @param {string} presetName + */ + onChange = presetName => { + const { presets } = this.props; + this.props.changePreset(presets, presetName); + }; + + render() { + const { presets, selectedPresetName } = this.props; + + return div( + { className: "perf-presets" }, + Object.entries(presets).map(([presetName, preset]) => + createElement(Preset, { + key: presetName, + presetName, + preset, + selected: presetName === selectedPresetName, + onChange: this.onChange, + }) + ), + createElement(Preset, { + key: "custom", + presetName: "custom", + selected: selectedPresetName == "custom", + preset: null, + onChange: this.onChange, + }) + ); + } +} + +/** + * @param {StoreState} state + * @returns {StateProps} + */ +function mapStateToProps(state) { + return { + selectedPresetName: selectors.getPresetName(state), + presets: selectors.getPresets(state), + }; +} + +/** + * @type {ThunkDispatchProps} + */ +const mapDispatchToProps = { + changePreset: actions.changePreset, +}; + +module.exports = connect(mapStateToProps, mapDispatchToProps)(Presets); diff --git a/devtools/client/performance-new/components/aboutprofiling/Range.js b/devtools/client/performance-new/components/aboutprofiling/Range.js new file mode 100644 index 0000000000..ce787ba334 --- /dev/null +++ b/devtools/client/performance-new/components/aboutprofiling/Range.js @@ -0,0 +1,78 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check + +/** + * @typedef {import("../../@types/perf").ScaleFunctions} ScaleFunctions + */ + +/** + * @typedef {Object} Props + * @property {number} value + * @property {React.ReactNode} label + * @property {string} id + * @property {ScaleFunctions} scale + * @property {(value: number) => unknown} onChange + * @property {(value: number) => React.ReactNode} display + */ +"use strict"; +const { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + div, + input, + label, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); + +/** + * Provide a numeric range slider UI that works off of custom numeric scales. + * @extends React.PureComponent<Props> + */ +class Range extends PureComponent { + /** + * @param {React.ChangeEvent<HTMLInputElement>} event + */ + handleInput = event => { + event.preventDefault(); + const { scale, onChange } = this.props; + const frac = Number(event.target.value) / scale.steps; + onChange(scale.fromFractionToSingleDigitValue(frac)); + }; + + render() { + const { label: labelText, scale, id, value, display } = this.props; + + const min = "0"; + const max = scale.steps; + // Convert the value to the current range. + const rangeValue = scale.fromValueToFraction(value) * max; + + return div( + { className: "perf-settings-range-row" }, + label( + { + className: "perf-settings-label", + htmlFor: id, + }, + labelText + ), + input({ + type: "range", + className: `perf-settings-range-input`, + min, + "aria-valuemin": scale.fromFractionToValue(0), + max, + "aria-valuemax": scale.fromFractionToValue(1), + value: rangeValue, + "aria-valuenow": value, + onChange: this.handleInput, + id, + }), + div({ className: `perf-settings-range-value` }, display(value)) + ); + } +} + +module.exports = Range; diff --git a/devtools/client/performance-new/components/aboutprofiling/Settings.js b/devtools/client/performance-new/components/aboutprofiling/Settings.js new file mode 100644 index 0000000000..3afe3a2d94 --- /dev/null +++ b/devtools/client/performance-new/components/aboutprofiling/Settings.js @@ -0,0 +1,674 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check + +/** + * @typedef {Object} StateProps + * @property {number} interval + * @property {number} entries + * @property {string[]} features + * @property {string[]} threads + * @property {string} threadsString + * @property {string[]} objdirs + * @property {string[]} supportedFeatures + */ + +/** + * @typedef {Object} ThunkDispatchProps + * @property {typeof actions.changeInterval} changeInterval + * @property {typeof actions.changeEntries} changeEntries + * @property {typeof actions.changeFeatures} changeFeatures + * @property {typeof actions.changeThreads} changeThreads + * @property {typeof actions.changeObjdirs} changeObjdirs + */ + +/** + * @typedef {ResolveThunks<ThunkDispatchProps>} DispatchProps + */ + +/** + * @typedef {Object} State + * @property {null | string} temporaryThreadText + */ + +/** + * @typedef {import("../../@types/perf").State} StoreState + * @typedef {import("../../@types/perf").FeatureDescription} FeatureDescription + * + * @typedef {StateProps & DispatchProps} Props + */ + +/** + * @template P + * @typedef {import("react-redux").ResolveThunks<P>} ResolveThunks<P> + */ + +/** + * @template InjectedProps + * @template NeededProps + * @typedef {import("react-redux") + * .InferableComponentEnhancerWithProps<InjectedProps, NeededProps> + * } InferableComponentEnhancerWithProps<InjectedProps, NeededProps> + */ +"use strict"; + +const { + PureComponent, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + div, + label, + input, + h1, + h2, + h3, + section, + p, + span, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const Range = createFactory( + require("resource://devtools/client/performance-new/components/aboutprofiling/Range.js") +); +const DirectoryPicker = createFactory( + require("resource://devtools/client/performance-new/components/aboutprofiling/DirectoryPicker.js") +); +const { + makeLinear10Scale, + makePowerOf2Scale, + formatFileSize, + featureDescriptions, +} = require("resource://devtools/client/performance-new/shared/utils.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const actions = require("resource://devtools/client/performance-new/store/actions.js"); +const selectors = require("resource://devtools/client/performance-new/store/selectors.js"); +const { + openFilePickerForObjdir, +} = require("resource://devtools/client/performance-new/shared/browser.js"); +const Localized = createFactory( + require("resource://devtools/client/shared/vendor/fluent-react.js").Localized +); + +// The Gecko Profiler interprets the "entries" setting as 8 bytes per entry. +const PROFILE_ENTRY_SIZE = 8; + +/** + * @typedef {{ name: string, id: string, l10nId: string }} ThreadColumn + */ + +/** @type {Array<ThreadColumn[]>} */ +const threadColumns = [ + [ + { + name: "GeckoMain", + id: "gecko-main", + // The l10nId take the form `perf-thread-${id}`, but isn't done programmatically + // so that it is easy to search in the codebase. + l10nId: "perftools-thread-gecko-main", + }, + { + name: "Compositor", + id: "compositor", + l10nId: "perftools-thread-compositor", + }, + { + name: "DOM Worker", + id: "dom-worker", + l10nId: "perftools-thread-dom-worker", + }, + { + name: "Renderer", + id: "renderer", + l10nId: "perftools-thread-renderer", + }, + ], + [ + { + name: "RenderBackend", + id: "render-backend", + l10nId: "perftools-thread-render-backend", + }, + { + name: "Timer", + id: "timer", + l10nId: "perftools-thread-timer", + }, + { + name: "StyleThread", + id: "style-thread", + l10nId: "perftools-thread-style-thread", + }, + { + name: "Socket Thread", + id: "socket-thread", + l10nId: "perftools-thread-socket-thread", + }, + ], + [ + { + name: "StreamTrans", + id: "stream-trans", + l10nId: "pref-thread-stream-trans", + }, + { + name: "ImgDecoder", + id: "img-decoder", + l10nId: "perftools-thread-img-decoder", + }, + { + name: "DNS Resolver", + id: "dns-resolver", + l10nId: "perftools-thread-dns-resolver", + }, + { + // Threads that are part of XPCOM's TaskController thread pool. + name: "TaskController", + id: "task-controller", + l10nId: "perftools-thread-task-controller", + }, + ], +]; + +/** @type {Array<ThreadColumn[]>} */ +const jvmThreadColumns = [ + [ + { + name: "Gecko", + id: "gecko", + l10nId: "perftools-thread-jvm-gecko", + }, + { + name: "Nimbus", + id: "nimbus", + l10nId: "perftools-thread-jvm-nimbus", + }, + ], + [ + { + name: "DefaultDispatcher", + id: "default-dispatcher", + l10nId: "perftools-thread-jvm-default-dispatcher", + }, + { + name: "Glean", + id: "glean", + l10nId: "perftools-thread-jvm-glean", + }, + ], + [ + { + name: "arch_disk_io", + id: "arch-disk-io", + l10nId: "perftools-thread-jvm-arch-disk-io", + }, + { + name: "pool-", + id: "pool", + l10nId: "perftools-thread-jvm-pool", + }, + ], +]; + +/** + * This component manages the settings for recording a performance profile. + * @extends {React.PureComponent<Props, State>} + */ +class Settings extends PureComponent { + /** + * @param {Props} props + */ + constructor(props) { + super(props); + /** @type {State} */ + this.state = { + // Allow the textbox to have a temporary tracked value. + temporaryThreadText: null, + }; + + this._intervalExponentialScale = makeLinear10Scale(0.01, 100); + this._entriesExponentialScale = makePowerOf2Scale( + 128 * 1024, + 256 * 1024 * 1024 + ); + } + + /** + * Handle the checkbox change. + * @param {React.ChangeEvent<HTMLInputElement>} event + */ + _handleThreadCheckboxChange = event => { + const { threads, changeThreads } = this.props; + const { checked, value } = event.target; + + if (checked) { + if (!threads.includes(value)) { + changeThreads([...threads, value]); + } + } else { + changeThreads(threads.filter(thread => thread !== value)); + } + }; + + /** + * Handle the checkbox change. + * @param {React.ChangeEvent<HTMLInputElement>} event + */ + _handleFeaturesCheckboxChange = event => { + const { features, changeFeatures } = this.props; + const { checked, value } = event.target; + + if (checked) { + if (!features.includes(value)) { + changeFeatures([value, ...features]); + } + } else { + changeFeatures(features.filter(feature => feature !== value)); + } + }; + + _handleAddObjdir = () => { + const { objdirs, changeObjdirs } = this.props; + openFilePickerForObjdir(window, objdirs, changeObjdirs); + }; + + /** + * @param {number} index + * @return {void} + */ + _handleRemoveObjdir = index => { + const { objdirs, changeObjdirs } = this.props; + const newObjdirs = [...objdirs]; + newObjdirs.splice(index, 1); + changeObjdirs(newObjdirs); + }; + + /** + * @param {React.ChangeEvent<HTMLInputElement>} event + */ + _setThreadTextFromInput = event => { + this.setState({ temporaryThreadText: event.target.value }); + }; + + /** + * @param {React.ChangeEvent<HTMLInputElement>} event + */ + _handleThreadTextCleanup = event => { + this.setState({ temporaryThreadText: null }); + this.props.changeThreads(_threadTextToList(event.target.value)); + }; + + /** + * @param {ThreadColumn[]} threadDisplay + * @param {number} index + * @return {React.ReactNode} + */ + _renderThreadsColumns(threadDisplay, index) { + const { threads } = this.props; + const areAllThreadsIncluded = threads.includes("*"); + return div( + { className: "perf-settings-thread-column", key: index }, + threadDisplay.map(({ name, id, l10nId }) => + Localized( + // The title is localized with a description of the thread. + { id: l10nId, attrs: { title: true }, key: name }, + label( + { + className: `perf-settings-checkbox-label perf-settings-thread-label toggle-container-with-text ${ + areAllThreadsIncluded + ? "perf-settings-checkbox-label-disabled" + : "" + }`, + }, + input({ + className: "perf-settings-checkbox", + id: `perf-settings-thread-checkbox-${id}`, + type: "checkbox", + // Do not localize the value, this is used internally by the profiler. + value: name, + checked: threads.includes(name), + disabled: areAllThreadsIncluded, + onChange: this._handleThreadCheckboxChange, + }), + span(null, name) + ) + ) + ) + ); + } + _renderThreads() { + const { temporaryThreadText } = this.state; + const { threads } = this.props; + + return renderSection( + "perf-settings-threads-summary", + Localized({ id: "perftools-heading-threads" }, "Threads"), + div( + null, + div( + { className: "perf-settings-thread-columns" }, + threadColumns.map((threadDisplay, index) => + this._renderThreadsColumns(threadDisplay, index) + ) + ), + this._renderJvmThreads(), + div( + { + className: "perf-settings-checkbox-label perf-settings-all-threads", + }, + label( + { + className: "toggle-container-with-text", + }, + input({ + id: "perf-settings-thread-checkbox-all-threads", + type: "checkbox", + value: "*", + checked: threads.includes("*"), + onChange: this._handleThreadCheckboxChange, + }), + Localized({ id: "perftools-record-all-registered-threads" }) + ) + ), + div( + { className: "perf-settings-row" }, + Localized( + { id: "perftools-tools-threads-input-label" }, + label( + { className: "perf-settings-text-label" }, + div( + null, + Localized( + { id: "perftools-custom-threads-label" }, + "Add custom threads by name:" + ) + ), + input({ + className: "perf-settings-text-input", + id: "perftools-settings-thread-text", + type: "text", + value: + temporaryThreadText === null + ? threads.join(",") + : temporaryThreadText, + onBlur: this._handleThreadTextCleanup, + onFocus: this._setThreadTextFromInput, + onChange: this._setThreadTextFromInput, + }) + ) + ) + ) + ) + ); + } + + _renderJvmThreads() { + if (!this.props.supportedFeatures.includes("java")) { + return null; + } + + return [ + h2( + null, + Localized({ id: "perftools-heading-threads-jvm" }, "JVM Threads") + ), + div( + { className: "perf-settings-thread-columns" }, + jvmThreadColumns.map((threadDisplay, index) => + this._renderThreadsColumns(threadDisplay, index) + ) + ), + ]; + } + + /** + * @param {React.ReactNode} sectionTitle + * @param {FeatureDescription[]} features + * @param {boolean} isSupported + */ + _renderFeatureSection(sectionTitle, features, isSupported) { + if (features.length === 0) { + return null; + } + + // Note: This area is not localized. This area is pretty deep in the UI, and is mostly + // geared towards Firefox engineers. It may not be worth localizing. This decision + // can be tracked in Bug 1682333. + + return div( + null, + h3(null, sectionTitle), + features.map(featureDescription => { + const { name, value, title, disabledReason } = featureDescription; + const extraClassName = isSupported + ? "" + : "perf-settings-checkbox-label-disabled"; + return label( + { + className: `perf-settings-checkbox-label perf-toggle-label ${extraClassName}`, + key: value, + }, + input({ + id: `perf-settings-feature-checkbox-${value}`, + type: "checkbox", + value, + checked: isSupported && this.props.features.includes(value), + onChange: this._handleFeaturesCheckboxChange, + disabled: !isSupported, + }), + div( + { className: "perf-toggle-text-label" }, + !isSupported && featureDescription.experimental + ? // Note when unsupported features are experimental. + `${name} (Experimental)` + : name + ), + div( + { className: "perf-toggle-description" }, + title, + !isSupported && disabledReason + ? div( + { className: "perf-settings-feature-disabled-reason" }, + disabledReason + ) + : null + ) + ); + }) + ); + } + + _renderFeatures() { + const { supportedFeatures } = this.props; + + // Divvy up the features into their respective groups. + const recommended = []; + const supported = []; + const unsupported = []; + const experimental = []; + + for (const feature of featureDescriptions) { + if (supportedFeatures.includes(feature.value)) { + if (feature.experimental) { + experimental.push(feature); + } else if (feature.recommended) { + recommended.push(feature); + } else { + supported.push(feature); + } + } else { + unsupported.push(feature); + } + } + + return div( + { className: "perf-settings-sections" }, + div( + null, + this._renderFeatureSection( + Localized( + { id: "perftools-heading-features-default" }, + "Features (Recommended on by default)" + ), + recommended, + true + ), + this._renderFeatureSection( + Localized({ id: "perftools-heading-features" }, "Features"), + supported, + true + ), + this._renderFeatureSection( + Localized( + { id: "perftools-heading-features-experimental" }, + "Experimental" + ), + experimental, + true + ), + this._renderFeatureSection( + Localized( + { id: "perftools-heading-features-disabled" }, + "Disabled Features" + ), + unsupported, + false + ) + ) + ); + } + + _renderLocalBuildSection() { + const { objdirs } = this.props; + return renderSection( + "perf-settings-local-build-summary", + Localized({ id: "perftools-heading-local-build" }), + div( + null, + p(null, Localized({ id: "perftools-description-local-build" })), + DirectoryPicker({ + dirs: objdirs, + onAdd: this._handleAddObjdir, + onRemove: this._handleRemoveObjdir, + }) + ) + ); + } + + render() { + return section( + { className: "perf-settings" }, + h1(null, Localized({ id: "perftools-heading-settings" })), + h2( + { className: "perf-settings-title" }, + Localized({ id: "perftools-heading-buffer" }) + ), + Range({ + label: Localized({ id: "perftools-range-interval-label" }), + value: this.props.interval, + id: "perf-range-interval", + scale: this._intervalExponentialScale, + display: _intervalTextDisplay, + onChange: this.props.changeInterval, + }), + Range({ + label: Localized({ id: "perftools-range-entries-label" }), + value: this.props.entries, + id: "perf-range-entries", + scale: this._entriesExponentialScale, + display: _entriesTextDisplay, + onChange: this.props.changeEntries, + }), + this._renderThreads(), + this._renderFeatures(), + this._renderLocalBuildSection() + ); + } +} + +/** + * Clean up the thread list string into a list of values. + * @param {string} threads - Comma separated values. + * @return {string[]} + */ +function _threadTextToList(threads) { + return ( + threads + // Split on commas + .split(",") + // Clean up any extraneous whitespace + .map(string => string.trim()) + // Filter out any blank strings + .filter(string => string) + ); +} + +/** + * Format the interval number for display. + * @param {number} value + * @return {React.ReactNode} + */ +function _intervalTextDisplay(value) { + return Localized({ + id: "perftools-range-interval-milliseconds", + $interval: value, + }); +} + +/** + * Format the entries number for display. + * @param {number} value + * @return {string} + */ +function _entriesTextDisplay(value) { + return formatFileSize(value * PROFILE_ENTRY_SIZE); +} + +/** + * Renders a section for about:profiling. + * + * @param {string} id Unused. + * @param {React.ReactNode} title + * @param {React.ReactNode} children + * @returns React.ReactNode + */ +function renderSection(id, title, children) { + return div( + { className: "perf-settings-sections" }, + div(null, h2(null, title), children) + ); +} + +/** + * @param {StoreState} state + * @returns {StateProps} + */ +function mapStateToProps(state) { + return { + interval: selectors.getInterval(state), + entries: selectors.getEntries(state), + features: selectors.getFeatures(state), + threads: selectors.getThreads(state), + threadsString: selectors.getThreadsString(state), + objdirs: selectors.getObjdirs(state), + supportedFeatures: selectors.getSupportedFeatures(state), + }; +} + +/** @type {ThunkDispatchProps} */ +const mapDispatchToProps = { + changeInterval: actions.changeInterval, + changeEntries: actions.changeEntries, + changeFeatures: actions.changeFeatures, + changeThreads: actions.changeThreads, + changeObjdirs: actions.changeObjdirs, +}; + +const SettingsConnected = connect( + mapStateToProps, + mapDispatchToProps +)(Settings); + +module.exports = SettingsConnected; diff --git a/devtools/client/performance-new/components/aboutprofiling/moz.build b/devtools/client/performance-new/components/aboutprofiling/moz.build new file mode 100644 index 0000000000..792041682e --- /dev/null +++ b/devtools/client/performance-new/components/aboutprofiling/moz.build @@ -0,0 +1,12 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "AboutProfiling.js", + "DirectoryPicker.js", + "Presets.js", + "Range.js", + "Settings.js", +) diff --git a/devtools/client/performance-new/components/moz.build b/devtools/client/performance-new/components/moz.build new file mode 100644 index 0000000000..1f621838fd --- /dev/null +++ b/devtools/client/performance-new/components/moz.build @@ -0,0 +1,10 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "aboutprofiling", + "panel", + "shared", +] diff --git a/devtools/client/performance-new/components/panel/Description.js b/devtools/client/performance-new/components/panel/Description.js new file mode 100644 index 0000000000..f2fb3620f0 --- /dev/null +++ b/devtools/client/performance-new/components/panel/Description.js @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check + +/** + * @typedef {{}} Props - This is an empty object. + */ + +"use strict"; + +const { + PureComponent, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + div, + button, + p, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const Localized = createFactory( + require("resource://devtools/client/shared/vendor/fluent-react.js").Localized +); + +/** + * This component provides a helpful description for what is going on in the component + * and provides some external links. + * @extends {React.PureComponent<Props>} + */ +class Description extends PureComponent { + /** + * @param {React.MouseEvent<HTMLButtonElement>} event + */ + handleLinkClick = event => { + const { + openDocLink, + } = require("resource://devtools/client/shared/link.js"); + + /** @type HTMLButtonElement */ + const target = /** @type {any} */ (event.target); + + openDocLink(target.value, {}); + }; + + render() { + return div( + { className: "perf-description" }, + Localized( + { + id: "perftools-description-intro", + a: button({ + className: "perf-external-link", + onClick: this.handleLinkClick, + value: "https://profiler.firefox.com", + }), + }, + p({}) + ) + ); + } +} + +module.exports = Description; diff --git a/devtools/client/performance-new/components/panel/DevToolsPanel.js b/devtools/client/performance-new/components/panel/DevToolsPanel.js new file mode 100644 index 0000000000..8f1b8fb344 --- /dev/null +++ b/devtools/client/performance-new/components/panel/DevToolsPanel.js @@ -0,0 +1,108 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check + +/** + * @template P + * @typedef {import("react-redux").ResolveThunks<P>} ResolveThunks<P> + */ + +/** + * @typedef {Object} StateProps + * @property {boolean?} isSupportedPlatform + */ + +/** + * @typedef {Object} OwnProps + * @property {import("../../@types/perf").PerfFront} perfFront + * @property {import("../../@types/perf").OnProfileReceived} onProfileReceived + * @property {() => void} onEditSettingsLinkClicked + */ + +/** + * @typedef {StateProps & OwnProps} Props + * @typedef {import("../../@types/perf").State} StoreState + * @typedef {import("../../@types/perf").RecordingState} RecordingState + * @typedef {import("../../@types/perf").PanelWindow} PanelWindow + */ + +"use strict"; + +const { + PureComponent, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const { + div, + hr, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const RecordingButton = createFactory( + require("resource://devtools/client/performance-new/components/panel/RecordingButton.js") +); +const Description = createFactory( + require("resource://devtools/client/performance-new/components/panel/Description.js") +); +const DevToolsPresetSelection = createFactory( + require("resource://devtools/client/performance-new/components/panel/DevToolsPresetSelection.js") +); +const OnboardingMessage = createFactory( + require("resource://devtools/client/performance-new/components/panel/OnboardingMessage.js") +); +const ToolboxHighlightController = createFactory( + require("resource://devtools/client/performance-new/components/panel/ToolboxHighlightController.js") +); + +const selectors = require("resource://devtools/client/performance-new/store/selectors.js"); +const anyWindow = /** @type {any} */ (window); +const panelWindow = /** @type {PanelWindow} */ (anyWindow); + +/** + * This is the top level component for the DevTools panel. + * + * @extends {React.PureComponent<Props>} + */ +class DevToolsPanel extends PureComponent { + render() { + const { + isSupportedPlatform, + perfFront, + onProfileReceived, + onEditSettingsLinkClicked, + } = this.props; + + if (isSupportedPlatform === null) { + // We don't know yet if this is a supported platform, wait for a response. + return null; + } + + return [ + OnboardingMessage(), + div( + { className: `perf perf-devtools` }, + RecordingButton({ perfFront, onProfileReceived }), + panelWindow.gToolbox + ? ToolboxHighlightController({ toolbox: panelWindow.gToolbox }) + : null, + Description(), + hr({ className: "perf-presets-hr" }), + DevToolsPresetSelection({ onEditSettingsLinkClicked }) + ), + ]; + } +} + +/** + * @param {StoreState} state + * @returns {StateProps} + */ +function mapStateToProps(state) { + return { + isSupportedPlatform: selectors.getIsSupportedPlatform(state), + }; +} + +module.exports = connect(mapStateToProps)(DevToolsPanel); diff --git a/devtools/client/performance-new/components/panel/DevToolsPresetSelection.js b/devtools/client/performance-new/components/panel/DevToolsPresetSelection.js new file mode 100644 index 0000000000..8b6cd44727 --- /dev/null +++ b/devtools/client/performance-new/components/panel/DevToolsPresetSelection.js @@ -0,0 +1,219 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check + +/** + * @template P + * @typedef {import("react-redux").ResolveThunks<P>} ResolveThunks<P> + */ + +/** + * @typedef {Object} StateProps + * @property {string} presetName + * @property {number} interval + * @property {string[]} threads + * @property {string[]} features + * @property {import("../../@types/perf").Presets} presets + */ + +/** + * @typedef {Object} ThunkDispatchProps + * @property {typeof actions.changePreset} changePreset + */ + +/** + * @typedef {Object} OwnProps + * @property {() => void} onEditSettingsLinkClicked + */ + +/** + * @typedef {ResolveThunks<ThunkDispatchProps>} DispatchProps + * @typedef {StateProps & DispatchProps & OwnProps} Props + * @typedef {import("../../@types/perf").State} StoreState + * @typedef {import("../../@types/perf").FeatureDescription} FeatureDescription + */ + +"use strict"; + +const { + PureComponent, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + div, + select, + option, + button, + ul, + li, + span, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const actions = require("resource://devtools/client/performance-new/store/actions.js"); +const selectors = require("resource://devtools/client/performance-new/store/selectors.js"); +const { + featureDescriptions, +} = require("resource://devtools/client/performance-new/shared/utils.js"); +const Localized = createFactory( + require("resource://devtools/client/shared/vendor/fluent-react.js").Localized +); + +/** + * This component displays the preset selection for the DevTools panel. It should be + * basically the same implementation as the popup, but done in React. The popup + * is written using vanilla JS and browser chrome elements in order to be more + * performant. + * + * @extends {React.PureComponent<Props>} + */ +class DevToolsPresetSelection extends PureComponent { + /** @param {Props} props */ + constructor(props) { + super(props); + + /** + * Create an object map to easily look up feature description. + * @type {{[key: string]: FeatureDescription}} + */ + this.featureDescriptionMap = {}; + for (const feature of featureDescriptions) { + this.featureDescriptionMap[feature.value] = feature; + } + } + + /** + * Handle the select change. + * @param {React.ChangeEvent<HTMLSelectElement>} event + */ + onPresetChange = event => { + const { presets } = this.props; + this.props.changePreset(presets, event.target.value); + }; + + render() { + const { presetName, presets, onEditSettingsLinkClicked } = this.props; + + let presetDescription; + const currentPreset = presets[presetName]; + if (currentPreset) { + // Display the current preset's description. + presetDescription = Localized({ + id: currentPreset.l10nIds.devtools.description, + }); + } else { + // Build up a display of the details of the custom preset. + const { interval, threads, features } = this.props; + presetDescription = div( + null, + ul( + { className: "perf-presets-custom" }, + li( + null, + Localized( + { id: "perftools-devtools-interval-label" }, + span({ className: "perf-presets-custom-bold" }) + ), + " ", + Localized({ + id: "perftools-range-interval-milliseconds", + $interval: interval, + }) + ), + li( + null, + Localized( + { id: "perftools-devtools-threads-label" }, + span({ className: "perf-presets-custom-bold" }) + ), + " ", + threads.join(", ") + ), + features.map(feature => { + const description = this.featureDescriptionMap[feature]; + if (!description) { + throw new Error( + "Could not find the feature description for " + feature + ); + } + return li( + { key: feature }, + description ? description.name : feature + ); + }) + ) + ); + } + + return div( + { className: "perf-presets" }, + div( + { className: "perf-presets-settings" }, + Localized({ id: "perftools-devtools-settings-label" }) + ), + div( + { className: "perf-presets-details" }, + div( + { className: "perf-presets-details-row" }, + select( + { + className: "perf-presets-select", + onChange: this.onPresetChange, + value: presetName, + }, + Object.entries(presets).map(([name, preset]) => + Localized( + { id: preset.l10nIds.devtools.label }, + option({ key: name, value: name }) + ) + ), + Localized( + { id: "perftools-presets-custom-label" }, + option({ value: "custom" }) + ) + ) + // The overhead component will go here. + ), + div( + { className: "perf-presets-details-row perf-presets-description" }, + presetDescription + ), + button( + { + className: "perf-external-link", + onClick: onEditSettingsLinkClicked, + }, + Localized({ id: "perftools-button-edit-settings" }) + ) + ) + ); + } +} + +/** + * @param {StoreState} state + * @returns {StateProps} + */ +function mapStateToProps(state) { + return { + presetName: selectors.getPresetName(state), + presets: selectors.getPresets(state), + interval: selectors.getInterval(state), + threads: selectors.getThreads(state), + features: selectors.getFeatures(state), + }; +} + +/** + * @type {ThunkDispatchProps} + */ +const mapDispatchToProps = { + changePreset: actions.changePreset, +}; + +module.exports = connect( + mapStateToProps, + mapDispatchToProps +)(DevToolsPresetSelection); diff --git a/devtools/client/performance-new/components/panel/OnboardingMessage.js b/devtools/client/performance-new/components/panel/OnboardingMessage.js new file mode 100644 index 0000000000..614215c6e6 --- /dev/null +++ b/devtools/client/performance-new/components/panel/OnboardingMessage.js @@ -0,0 +1,138 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check + +/** + * @typedef {{}} Props - This is an empty object. + */ + +/** + * @typedef {Object} State + * @property {boolean} isOnboardingEnabled + */ + +/** + * @typedef {import("../../@types/perf").PanelWindow} PanelWindow + */ + +"use strict"; + +const { + PureComponent, + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + b, + button, + div, + p, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const Localized = createFactory( + require("resource://devtools/client/shared/vendor/fluent-react.js").Localized +); + +const { openDocLink } = require("resource://devtools/client/shared/link.js"); + +const LEARN_MORE_URL = "https://profiler.firefox.com/docs"; +const ONBOARDING_PREF = "devtools.performance.new-panel-onboarding"; + +/** + * This component provides a temporary onboarding message for users migrating + * from the old DevTools performance panel. + * @extends {React.PureComponent<Props>} + */ +class OnboardingMessage extends PureComponent { + /** + * @param {Props} props + */ + constructor(props) { + super(props); + + // The preference has no default value for new profiles. + // If it is missing, default to true to show the message by default. + const isOnboardingEnabled = Services.prefs.getBoolPref( + ONBOARDING_PREF, + true + ); + + /** @type {State} */ + this.state = { isOnboardingEnabled }; + } + + componentDidMount() { + Services.prefs.addObserver(ONBOARDING_PREF, this.onPreferenceUpdated); + } + + componentWillUnmount() { + Services.prefs.removeObserver(ONBOARDING_PREF, this.onPreferenceUpdated); + } + + handleCloseIconClick = () => { + Services.prefs.setBoolPref(ONBOARDING_PREF, false); + }; + + handleLearnMoreClick = () => { + openDocLink(LEARN_MORE_URL, {}); + }; + + /** + * Update the state whenever the devtools.performance.new-panel-onboarding + * preference is updated. + */ + onPreferenceUpdated = () => { + const value = Services.prefs.getBoolPref(ONBOARDING_PREF, true); + this.setState({ isOnboardingEnabled: value }); + }; + + render() { + const { isOnboardingEnabled } = this.state; + if (!isOnboardingEnabled) { + return null; + } + + /** @type {any} */ + const anyWindow = window; + + // If gToolbox is not defined on window, the component is rendered in + // about:debugging, and no onboarding message should be displayed. + if (!anyWindow.gToolbox) { + return null; + } + + const learnMoreLink = button({ + className: "perf-external-link", + onClick: this.handleLearnMoreClick, + }); + + const closeButton = Localized( + { + id: "perftools-onboarding-close-button", + attrs: { "aria-label": true }, + }, + button({ + className: + "perf-onboarding-close-button perf-photon-button perf-photon-button-ghost", + onClick: this.handleCloseIconClick, + }) + ); + + return div( + { className: "perf-onboarding" }, + div( + { className: "perf-onboarding-message" }, + Localized( + { + id: "perftools-onboarding-message", + b: b(), + a: learnMoreLink, + }, + p({ className: "perf-onboarding-message-row" }) + ) + ), + closeButton + ); + } +} + +module.exports = OnboardingMessage; diff --git a/devtools/client/performance-new/components/panel/ProfilerEventHandling.js b/devtools/client/performance-new/components/panel/ProfilerEventHandling.js new file mode 100644 index 0000000000..1b415bdf19 --- /dev/null +++ b/devtools/client/performance-new/components/panel/ProfilerEventHandling.js @@ -0,0 +1,128 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check + +/** + * @typedef {import("../../@types/perf").PerfFront} PerfFront + * @typedef {import("../../@types/perf").RecordingState} RecordingState + * @typedef {import("../../@types/perf").State} StoreState + * @typedef {import("../../@types/perf").RootTraits} RootTraits + * @typedef {import("../../@types/perf").PanelWindow} PanelWindow + */ + +/** + * @template P + * @typedef {import("react-redux").ResolveThunks<P>} ResolveThunks<P> + */ + +/** + * @typedef {Object} StateProps + * @property {RecordingState} recordingState + * @property {boolean?} isSupportedPlatform + */ + +/** + * @typedef {Object} ThunkDispatchProps + * @property {typeof actions.reportProfilerReady} reportProfilerReady + * @property {typeof actions.reportProfilerStarted} reportProfilerStarted + * @property {typeof actions.reportProfilerStopped} reportProfilerStopped + */ + +/** + * @typedef {Object} OwnProps + * @property {PerfFront} perfFront + * @property {RootTraits} traits + */ + +/** + * @typedef {ResolveThunks<ThunkDispatchProps>} DispatchProps + * @typedef {StateProps & DispatchProps & OwnProps} Props + */ + +"use strict"; + +const { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const actions = require("resource://devtools/client/performance-new/store/actions.js"); +const selectors = require("resource://devtools/client/performance-new/store/selectors.js"); + +/** + * This component state changes for the performance recording. e.g. If the profiler + * suddenly becomes unavailable, it needs to react to those changes, and update the + * recordingState in the store. + * + * @extends {React.PureComponent<Props>} + */ +class ProfilerEventHandling extends PureComponent { + componentDidMount() { + const { + perfFront, + isSupportedPlatform, + reportProfilerReady, + reportProfilerStarted, + reportProfilerStopped, + } = this.props; + + if (!isSupportedPlatform) { + return; + } + + // Ask for the initial state of the profiler. + perfFront.isActive().then(isActive => reportProfilerReady(isActive)); + + // Handle when the profiler changes state. It might be us, it might be someone else. + this.props.perfFront.on("profiler-started", reportProfilerStarted); + this.props.perfFront.on("profiler-stopped", reportProfilerStopped); + } + + componentWillUnmount() { + switch (this.props.recordingState) { + case "not-yet-known": + case "available-to-record": + case "request-to-stop-profiler": + case "request-to-get-profile-and-stop-profiler": + // Do nothing for these states. + break; + + case "recording": + case "request-to-start-recording": + this.props.perfFront.stopProfilerAndDiscardProfile(); + break; + + default: + throw new Error("Unhandled recording state."); + } + } + + render() { + return null; + } +} + +/** + * @param {StoreState} state + * @returns {StateProps} + */ +function mapStateToProps(state) { + return { + recordingState: selectors.getRecordingState(state), + isSupportedPlatform: selectors.getIsSupportedPlatform(state), + }; +} + +/** @type {ThunkDispatchProps} */ +const mapDispatchToProps = { + reportProfilerReady: actions.reportProfilerReady, + reportProfilerStarted: actions.reportProfilerStarted, + reportProfilerStopped: actions.reportProfilerStopped, +}; + +module.exports = connect( + mapStateToProps, + mapDispatchToProps +)(ProfilerEventHandling); diff --git a/devtools/client/performance-new/components/panel/RecordingButton.js b/devtools/client/performance-new/components/panel/RecordingButton.js new file mode 100644 index 0000000000..352468cbd1 --- /dev/null +++ b/devtools/client/performance-new/components/panel/RecordingButton.js @@ -0,0 +1,265 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check + +/** + * @template P + * @typedef {import("react-redux").ResolveThunks<P>} ResolveThunks<P> + */ + +/** + * @typedef {Object} StateProps + * @property {RecordingState} recordingState + * @property {boolean | null} isSupportedPlatform + * @property {boolean} recordingUnexpectedlyStopped + * @property {PageContext} pageContext + */ + +/** + * @typedef {Object} OwnProps + * @property {import("../../@types/perf").OnProfileReceived} onProfileReceived + * @property {import("../../@types/perf").PerfFront} perfFront + */ + +/** + * @typedef {Object} ThunkDispatchProps + * @property {typeof actions.startRecording} startRecording + * @property {typeof actions.getProfileAndStopProfiler} getProfileAndStopProfiler + * @property {typeof actions.stopProfilerAndDiscardProfile} stopProfilerAndDiscardProfile + + */ + +/** + * @typedef {ResolveThunks<ThunkDispatchProps>} DispatchProps + * @typedef {StateProps & DispatchProps & OwnProps} Props + * @typedef {import("../../@types/perf").RecordingState} RecordingState + * @typedef {import("../../@types/perf").State} StoreState + * @typedef {import("../../@types/perf").PageContext} PageContext + */ + +"use strict"; + +const { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + div, + button, + span, + img, +} = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const actions = require("resource://devtools/client/performance-new/store/actions.js"); +const selectors = require("resource://devtools/client/performance-new/store/selectors.js"); +const React = require("resource://devtools/client/shared/vendor/react.js"); +const Localized = React.createFactory( + require("resource://devtools/client/shared/vendor/fluent-react.js").Localized +); + +/** + * This component is not responsible for the full life cycle of recording a profile. It + * is only responsible for the actual act of stopping and starting recordings. It + * also reacts to the changes of the recording state from external changes. + * + * @extends {React.PureComponent<Props>} + */ +class RecordingButton extends PureComponent { + _onStartButtonClick = () => { + const { startRecording, perfFront } = this.props; + startRecording(perfFront); + }; + + _onCaptureButtonClick = async () => { + const { getProfileAndStopProfiler, onProfileReceived, perfFront } = + this.props; + const profile = await getProfileAndStopProfiler(perfFront); + onProfileReceived(profile); + }; + + _onStopButtonClick = () => { + const { stopProfilerAndDiscardProfile, perfFront } = this.props; + stopProfilerAndDiscardProfile(perfFront); + }; + + render() { + const { + recordingState, + isSupportedPlatform, + recordingUnexpectedlyStopped, + } = this.props; + + if (!isSupportedPlatform) { + return renderButton({ + label: startRecordingLabel(), + isPrimary: true, + disabled: true, + additionalMessage: + // No need to localize as this string is not displayed to Tier-1 platforms. + "Your platform is not supported. The Gecko Profiler only " + + "supports Tier-1 platforms.", + }); + } + + switch (recordingState) { + case "not-yet-known": + return null; + + case "available-to-record": + return renderButton({ + onClick: this._onStartButtonClick, + isPrimary: true, + label: startRecordingLabel(), + additionalMessage: recordingUnexpectedlyStopped + ? Localized( + { id: "perftools-status-recording-stopped-by-another-tool" }, + div(null, "The recording was stopped by another tool.") + ) + : null, + }); + + case "request-to-stop-profiler": + return renderButton({ + label: Localized( + { id: "perftools-request-to-stop-profiler" }, + "Stopping recording" + ), + disabled: true, + }); + + case "request-to-get-profile-and-stop-profiler": + return renderButton({ + label: Localized( + { id: "perftools-request-to-get-profile-and-stop-profiler" }, + "Capturing profile" + ), + disabled: true, + }); + + case "request-to-start-recording": + case "recording": + return renderButton({ + label: span( + null, + Localized( + { id: "perftools-button-capture-recording" }, + "Capture recording" + ), + img({ + className: "perf-button-image", + alt: "", + /* This icon is actually the "open in new page" icon. */ + src: "chrome://devtools/skin/images/dock-undock.svg", + }) + ), + isPrimary: true, + onClick: this._onCaptureButtonClick, + disabled: recordingState === "request-to-start-recording", + additionalButton: { + label: Localized( + { id: "perftools-button-cancel-recording" }, + "Cancel recording" + ), + onClick: this._onStopButtonClick, + }, + }); + + default: + throw new Error("Unhandled recording state"); + } + } +} + +/** + * @param {{ + * disabled?: boolean, + * label?: React.ReactNode, + * onClick?: any, + * additionalMessage?: React.ReactNode, + * isPrimary?: boolean, + * pageContext?: PageContext, + * additionalButton?: { + * label: React.ReactNode, + * onClick: any, + * }, + * }} buttonSettings + */ +function renderButton(buttonSettings) { + const { + disabled, + label, + onClick, + additionalMessage, + isPrimary, + // pageContext, + additionalButton, + } = buttonSettings; + + const buttonClass = isPrimary ? "primary" : "default"; + + return div( + { className: "perf-button-container" }, + div( + null, + button( + { + className: `perf-photon-button perf-photon-button-${buttonClass} perf-button`, + disabled, + onClick, + }, + label + ), + additionalButton + ? button( + { + className: `perf-photon-button perf-photon-button-default perf-button`, + onClick: additionalButton.onClick, + disabled, + }, + additionalButton.label + ) + : null + ), + additionalMessage + ? div({ className: "perf-additional-message" }, additionalMessage) + : null + ); +} + +function startRecordingLabel() { + return span( + null, + Localized({ id: "perftools-button-start-recording" }, "Start recording"), + img({ + className: "perf-button-image", + alt: "", + /* This icon is actually the "open in new page" icon. */ + src: "chrome://devtools/skin/images/dock-undock.svg", + }) + ); +} + +/** + * @param {StoreState} state + * @returns {StateProps} + */ +function mapStateToProps(state) { + return { + recordingState: selectors.getRecordingState(state), + isSupportedPlatform: selectors.getIsSupportedPlatform(state), + recordingUnexpectedlyStopped: + selectors.getRecordingUnexpectedlyStopped(state), + pageContext: selectors.getPageContext(state), + }; +} + +/** @type {ThunkDispatchProps} */ +const mapDispatchToProps = { + startRecording: actions.startRecording, + stopProfilerAndDiscardProfile: actions.stopProfilerAndDiscardProfile, + getProfileAndStopProfiler: actions.getProfileAndStopProfiler, +}; + +module.exports = connect(mapStateToProps, mapDispatchToProps)(RecordingButton); diff --git a/devtools/client/performance-new/components/panel/ToolboxHighlightController.js b/devtools/client/performance-new/components/panel/ToolboxHighlightController.js new file mode 100644 index 0000000000..9d9d594aa0 --- /dev/null +++ b/devtools/client/performance-new/components/panel/ToolboxHighlightController.js @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check + +/** + * @typedef {Object} StateProps + * @property {RecordingState} recordingState + */ + +/** + * @typedef {Object} OwnProps + * @property {any} toolbox + */ + +/** + * @typedef {StateProps & OwnProps} Props + * @typedef {import("../../@types/perf").State} StoreState + * @typedef {import("../../@types/perf").RecordingState} RecordingState + */ + +"use strict"; + +const { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); +const selectors = require("resource://devtools/client/performance-new/store/selectors.js"); + +/** + * @extends {React.PureComponent<Props>} + */ +class ToolboxHighlightController extends PureComponent { + /** @param {Props} prevProps */ + componentDidUpdate(prevProps) { + const { recordingState, toolbox } = this.props; + if (recordingState === "recording") { + toolbox.highlightTool("performance"); + } else if (prevProps.recordingState === "recording") { + toolbox.unhighlightTool("performance"); + } + } + + render() { + return null; + } +} + +/** + * @param {StoreState} state + * @returns {StateProps} + */ +function mapStateToProps(state) { + return { + recordingState: selectors.getRecordingState(state), + }; +} + +module.exports = connect(mapStateToProps)(ToolboxHighlightController); diff --git a/devtools/client/performance-new/components/panel/moz.build b/devtools/client/performance-new/components/panel/moz.build new file mode 100644 index 0000000000..4063f5d333 --- /dev/null +++ b/devtools/client/performance-new/components/panel/moz.build @@ -0,0 +1,14 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "Description.js", + "DevToolsPanel.js", + "DevToolsPresetSelection.js", + "OnboardingMessage.js", + "ProfilerEventHandling.js", + "RecordingButton.js", + "ToolboxHighlightController.js", +) diff --git a/devtools/client/performance-new/components/shared/ProfilerPreferenceObserver.js b/devtools/client/performance-new/components/shared/ProfilerPreferenceObserver.js new file mode 100644 index 0000000000..24a3eecbc5 --- /dev/null +++ b/devtools/client/performance-new/components/shared/ProfilerPreferenceObserver.js @@ -0,0 +1,110 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check + +/** + * @template P + * @typedef {import("react-redux").ResolveThunks<P>} ResolveThunks<P> + */ + +/** + * @typedef {import("../../@types/perf").State} StoreState + */ + +/** + * @typedef {Object} StateProps + * @property {import("../../@types/perf").RecordingSettings} recordingSettingsFromRedux + * @property {import("../../@types/perf").PageContext} pageContext + * @property {string[]} supportedFeatures + */ + +/** + * @typedef {Object} ThunkDispatchProps + * @property {typeof actions.updateSettingsFromPreferences} updateSettingsFromPreferences + */ + +/** + * @typedef {ResolveThunks<ThunkDispatchProps>} DispatchProps + * @typedef {StateProps & DispatchProps} Props + */ + +"use strict"; + +// These functions live in a JSM file so that this can be used both by this +// CommonJS DevTools environment and the popup which isn't in such a context. +const { + getRecordingSettings, + setRecordingSettings, + addPrefObserver, + removePrefObserver, +} = ChromeUtils.importESModule( + "resource://devtools/client/performance-new/shared/background.sys.mjs" +); +const { + PureComponent, +} = require("resource://devtools/client/shared/vendor/react.js"); +const { + connect, +} = require("resource://devtools/client/shared/vendor/react-redux.js"); + +const selectors = require("resource://devtools/client/performance-new/store/selectors.js"); +const actions = require("resource://devtools/client/performance-new/store/actions.js"); + +/** + * This component mirrors the settings in the redux store and the preferences in + * Firefox. + * + * @extends {React.PureComponent<Props>} + */ +class ProfilerPreferenceObserver extends PureComponent { + componentDidMount() { + this._updateSettingsFromPreferences(); + addPrefObserver(this._updateSettingsFromPreferences); + } + + componentDidUpdate() { + const { recordingSettingsFromRedux, pageContext } = this.props; + setRecordingSettings(pageContext, recordingSettingsFromRedux); + } + + componentWillUnmount() { + removePrefObserver(this._updateSettingsFromPreferences); + } + + _updateSettingsFromPreferences = () => { + const { updateSettingsFromPreferences, pageContext, supportedFeatures } = + this.props; + + const recordingSettingsFromPrefs = getRecordingSettings( + pageContext, + supportedFeatures + ); + updateSettingsFromPreferences(recordingSettingsFromPrefs); + }; + + render() { + return null; + } +} + +/** + * @param {StoreState} state + * @returns {StateProps} + */ +function mapStateToProps(state) { + return { + recordingSettingsFromRedux: selectors.getRecordingSettings(state), + pageContext: selectors.getPageContext(state), + supportedFeatures: selectors.getSupportedFeatures(state), + }; +} + +const mapDispatchToProps = { + updateSettingsFromPreferences: actions.updateSettingsFromPreferences, +}; + +module.exports = connect( + mapStateToProps, + mapDispatchToProps +)(ProfilerPreferenceObserver); diff --git a/devtools/client/performance-new/components/shared/moz.build b/devtools/client/performance-new/components/shared/moz.build new file mode 100644 index 0000000000..8c7fc05b79 --- /dev/null +++ b/devtools/client/performance-new/components/shared/moz.build @@ -0,0 +1,8 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "ProfilerPreferenceObserver.js", +) diff --git a/devtools/client/performance-new/moz.build b/devtools/client/performance-new/moz.build new file mode 100644 index 0000000000..7945bd510f --- /dev/null +++ b/devtools/client/performance-new/moz.build @@ -0,0 +1,19 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DIRS += [ + "aboutprofiling", + "components", + "store", + "panel", + "popup", + "shared", +] + +BROWSER_CHROME_MANIFESTS += ["test/browser/browser.toml"] +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"] + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Performance Tools (Profiler/Timeline)") diff --git a/devtools/client/performance-new/package.json b/devtools/client/performance-new/package.json new file mode 100644 index 0000000000..9405965e35 --- /dev/null +++ b/devtools/client/performance-new/package.json @@ -0,0 +1,15 @@ +{ + "name": "devtools-bin", + "version": "1.0.0", + "scripts": { + "test": "tsc", + "test-ci": "tsc" + }, + "license": "MPL-2.0", + "devDependencies": { + "@types/react-dom-factories": "^1.0.2", + "@types/react-redux": "^7.1.18", + "@types/redux": "^3.6.0", + "typescript": "^5.3.3" + } +} diff --git a/devtools/client/performance-new/panel/README.md b/devtools/client/performance-new/panel/README.md new file mode 100644 index 0000000000..d2054521d0 --- /dev/null +++ b/devtools/client/performance-new/panel/README.md @@ -0,0 +1,6 @@ +This directory contains the files specific to the devtools panel. This panel is +used in the devtools toolbox attached to a page, but also when profiling +performance from about:profiling. + +The devtools panel also uses the redux store in `store/` as well as react +components in `components/`. diff --git a/devtools/client/performance-new/panel/index.xhtml b/devtools/client/performance-new/panel/index.xhtml new file mode 100644 index 0000000000..161eb850ed --- /dev/null +++ b/devtools/client/performance-new/panel/index.xhtml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="UTF-8"?> + +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> + +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<html xmlns="http://www.w3.org/1999/xhtml" dir=""> + <head> + <link + rel="stylesheet" + href="chrome://devtools/skin/widgets.css" + type="text/css" + /> + <link + rel="stylesheet" + href="chrome://devtools/skin/perf.css" + type="text/css" + /> + </head> + <body class="theme-body"> + <div id="root"></div> + <script src="resource://devtools/client/performance-new/panel/initializer.js"></script> + <script + src="chrome://devtools/content/shared/theme-switching.js" + defer="true" + ></script> + </body> +</html> diff --git a/devtools/client/performance-new/panel/initializer.js b/devtools/client/performance-new/panel/initializer.js new file mode 100644 index 0000000000..2264e4764f --- /dev/null +++ b/devtools/client/performance-new/panel/initializer.js @@ -0,0 +1,189 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check +/* exported gInit, gDestroy, loader */ + +/** + * @typedef {import("../@types/perf").PerfFront} PerfFront + * @typedef {import("../@types/perf").PreferenceFront} PreferenceFront + * @typedef {import("../@types/perf").RecordingSettings} RecordingSettings + * @typedef {import("../@types/perf").PageContext} PageContext + * @typedef {import("../@types/perf").PanelWindow} PanelWindow + * @typedef {import("../@types/perf").Store} Store + * @typedef {import("../@types/perf").MinimallyTypedGeckoProfile} MinimallyTypedGeckoProfile + * @typedef {import("../@types/perf").ProfileCaptureResult} ProfileCaptureResult + * @typedef {import("../@types/perf").ProfilerViewMode} ProfilerViewMode + * @typedef {import("../@types/perf").RootTraits} RootTraits + */ +"use strict"; + +{ + // Create the browser loader, but take care not to conflict with + // TypeScript. See devtools/client/performance-new/typescript.md and + // the section on "Do not overload require" for more information. + + const { BrowserLoader } = ChromeUtils.import( + "resource://devtools/shared/loader/browser-loader.js" + ); + const browserLoader = BrowserLoader({ + baseURI: "resource://devtools/client/performance-new/", + window, + }); + + /** + * @type {any} - Coerce the current scope into an `any`, and assign the + * loaders to the scope. They can then be used freely below. + */ + const scope = this; + scope.require = browserLoader.require; + scope.loader = browserLoader.loader; +} + +const ReactDOM = require("resource://devtools/client/shared/vendor/react-dom.js"); +const React = require("resource://devtools/client/shared/vendor/react.js"); +const FluentReact = require("resource://devtools/client/shared/vendor/fluent-react.js"); +const { + FluentL10n, +} = require("resource://devtools/client/shared/fluent-l10n/fluent-l10n.js"); +const Provider = React.createFactory( + require("resource://devtools/client/shared/vendor/react-redux.js").Provider +); +const LocalizationProvider = React.createFactory( + FluentReact.LocalizationProvider +); +const DevToolsPanel = React.createFactory( + require("resource://devtools/client/performance-new/components/panel/DevToolsPanel.js") +); +const ProfilerEventHandling = React.createFactory( + require("resource://devtools/client/performance-new/components/panel/ProfilerEventHandling.js") +); +const ProfilerPreferenceObserver = React.createFactory( + require("resource://devtools/client/performance-new/components/shared/ProfilerPreferenceObserver.js") +); +const createStore = require("resource://devtools/client/shared/redux/create-store.js"); +const selectors = require("resource://devtools/client/performance-new/store/selectors.js"); +const reducers = require("resource://devtools/client/performance-new/store/reducers.js"); +const actions = require("resource://devtools/client/performance-new/store/actions.js"); +const { + openProfilerTab, + sharedLibrariesFromProfile, +} = require("resource://devtools/client/performance-new/shared/browser.js"); +const { createLocalSymbolicationService } = ChromeUtils.importESModule( + "resource://devtools/client/performance-new/shared/symbolication.sys.mjs" +); +const { + presets, + getProfilerViewModeForCurrentPreset, + registerProfileCaptureForBrowser, +} = ChromeUtils.importESModule( + "resource://devtools/client/performance-new/shared/background.sys.mjs" +); + +/** + * This file initializes the DevTools Panel UI. It is in charge of initializing + * the DevTools specific environment, and then passing those requirements into + * the UI. + */ + +/** + * Initialize the panel by creating a redux store, and render the root component. + * + * @param {PerfFront} perfFront - The Perf actor's front. Used to start and stop recordings. + * @param {RootTraits} traits - The traits coming from the root actor. This + * makes it possible to change some code path + * depending on the server version. + * @param {PageContext} pageContext - The context that the UI is being loaded in under. + * @param {(() => void)} openAboutProfiling - Optional call to open about:profiling + */ +async function gInit(perfFront, traits, pageContext, openAboutProfiling) { + const store = createStore(reducers); + const isSupportedPlatform = await perfFront.isSupportedPlatform(); + const supportedFeatures = await perfFront.getSupportedFeatures(); + + { + // Expose the store as a global, for testing. + const anyWindow = /** @type {any} */ (window); + const panelWindow = /** @type {PanelWindow} */ (anyWindow); + // The store variable is a `ReduxStore`, not our `Store` type, as defined + // in perf.d.ts. Coerce it into the `Store` type. + const anyStore = /** @type {any} */ (store); + panelWindow.gStore = anyStore; + } + + const l10n = new FluentL10n(); + await l10n.init([ + "devtools/client/perftools.ftl", + // For -brand-shorter-name used in some profiler preset descriptions. + "branding/brand.ftl", + // Needed for the onboarding UI + "devtools/client/toolbox-options.ftl", + "toolkit/branding/brandings.ftl", + ]); + + // Do some initialization, especially with privileged things that are part of the + // the browser. + store.dispatch( + actions.initializeStore({ + isSupportedPlatform, + presets, + supportedFeatures, + pageContext, + }) + ); + + /** + * @param {MinimallyTypedGeckoProfile} profile + */ + const onProfileReceived = async profile => { + const objdirs = selectors.getObjdirs(store.getState()); + const profilerViewMode = getProfilerViewModeForCurrentPreset(pageContext); + const sharedLibraries = sharedLibrariesFromProfile(profile); + const symbolicationService = createLocalSymbolicationService( + sharedLibraries, + objdirs, + perfFront + ); + const browser = await openProfilerTab(profilerViewMode); + + /** + * @type {ProfileCaptureResult} + */ + const profileCaptureResult = { type: "SUCCESS", profile }; + + registerProfileCaptureForBrowser( + browser, + profileCaptureResult, + symbolicationService + ); + }; + + const onEditSettingsLinkClicked = openAboutProfiling; + + ReactDOM.render( + Provider( + { store }, + LocalizationProvider( + { bundles: l10n.getBundles() }, + React.createElement( + React.Fragment, + null, + ProfilerEventHandling({ perfFront, traits }), + ProfilerPreferenceObserver(), + DevToolsPanel({ + perfFront, + onProfileReceived, + onEditSettingsLinkClicked, + }) + ) + ) + ), + document.querySelector("#root") + ); + + window.addEventListener("unload", () => gDestroy(), { once: true }); +} + +function gDestroy() { + ReactDOM.unmountComponentAtNode(document.querySelector("#root")); +} diff --git a/devtools/client/performance-new/panel/moz.build b/devtools/client/performance-new/panel/moz.build new file mode 100644 index 0000000000..298ed5a866 --- /dev/null +++ b/devtools/client/performance-new/panel/moz.build @@ -0,0 +1,12 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "initializer.js", + "panel.js", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Performance Tools (Profiler/Timeline)") diff --git a/devtools/client/performance-new/panel/panel.js b/devtools/client/performance-new/panel/panel.js new file mode 100644 index 0000000000..d099f3c296 --- /dev/null +++ b/devtools/client/performance-new/panel/panel.js @@ -0,0 +1,107 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check +"use strict"; + +/** + * This file contains the PerformancePanel, which uses a common API for DevTools to + * start and load everything. This will call `gInit` from the initializer.js file, + * which does the important initialization for the panel. This code is more concerned + * with wiring this panel into the rest of DevTools and fetching the Actor's fronts. + */ + +/** + * @typedef {import("../@types/perf").PanelWindow} PanelWindow + * @typedef {import("../@types/perf").Toolbox} Toolbox + * @typedef {import("../@types/perf").Target} Target + * @typedef {import("../@types/perf").Commands} Commands + */ + +class PerformancePanel { + /** + * @param {PanelWindow} iframeWindow + * @param {Toolbox} toolbox + * @param {Commands} commands + */ + constructor(iframeWindow, toolbox, commands) { + this.panelWin = iframeWindow; + this.toolbox = toolbox; + this.commands = commands; + + const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + EventEmitter.decorate(this); + } + + /** + * This is implemented (and overwritten) by the EventEmitter. Is there a way + * to use mixins with JSDoc? + * + * @param {string} eventName + */ + emit(eventName) {} + + /** + * Open is effectively an asynchronous constructor. + * @return {Promise<PerformancePanel>} Resolves when the Perf tool completes + * opening. + */ + open() { + if (!this._opening) { + this._opening = this._doOpen(); + } + return this._opening; + } + + /** + * This function is the actual implementation of the open() method. + * @returns Promise<PerformancePanel> + */ + async _doOpen() { + this.panelWin.gToolbox = this.toolbox; + this.panelWin.gIsPanelDestroyed = false; + + const perfFront = await this.commands.client.mainRoot.getFront("perf"); + + // Note: we are not using traits in the panel at the moment but we keep the + // wiring in case we need it later on. + const traits = {}; + + await this.panelWin.gInit( + perfFront, + traits, + "devtools", + this._openAboutProfiling + ); + return this; + } + + _openAboutProfiling() { + const { + openTrustedLink, + } = require("resource://devtools/client/shared/link.js"); + openTrustedLink("about:profiling", {}); + } + + // DevToolPanel API: + + /** + * @returns {Target} target + */ + get target() { + return this.toolbox.target; + } + + destroy() { + // Make sure this panel is not already destroyed. + if (this._destroyed) { + return; + } + this.panelWin.gDestroy(); + this.emit("destroyed"); + this._destroyed = true; + this.panelWin.gIsPanelDestroyed = true; + } +} + +exports.PerformancePanel = PerformancePanel; diff --git a/devtools/client/performance-new/popup/README.md b/devtools/client/performance-new/popup/README.md new file mode 100644 index 0000000000..78ef8e54c7 --- /dev/null +++ b/devtools/client/performance-new/popup/README.md @@ -0,0 +1,3 @@ +# Profiler Popup + +This directory collects the code that powers the profiler popup. See devtools/client/performance-new/README.md for more information. diff --git a/devtools/client/performance-new/popup/logic.sys.mjs b/devtools/client/performance-new/popup/logic.sys.mjs new file mode 100644 index 0000000000..174163d54b --- /dev/null +++ b/devtools/client/performance-new/popup/logic.sys.mjs @@ -0,0 +1,314 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check + +/** + * This file controls the logic of the profiler popup view. + */ + +/** + * @typedef {ReturnType<typeof selectElementsInPanelview>} Elements + * @typedef {ReturnType<typeof createViewControllers>} ViewController + */ + +/** + * @typedef {Object} State - The mutable state of the popup. + * @property {Array<() => void>} cleanup - Functions to cleanup once the view is hidden. + * @property {boolean} isInfoCollapsed + */ + +import { createLazyLoaders } from "resource://devtools/client/performance-new/shared/typescript-lazy-load.sys.mjs"; + +const lazy = createLazyLoaders({ + PanelMultiView: () => + ChromeUtils.importESModule("resource:///modules/PanelMultiView.sys.mjs"), + Background: () => + ChromeUtils.importESModule( + "resource://devtools/client/performance-new/shared/background.sys.mjs" + ), +}); + +/** + * This function collects all of the selection of the elements inside of the panel. + * + * @param {XULElement} panelview + */ +function selectElementsInPanelview(panelview) { + const document = panelview.ownerDocument; + /** + * Get an element or throw an error if it's not found. This is more friendly + * for TypeScript. + * + * @param {string} id + * @return {HTMLElement} + */ + function getElementById(id) { + /** @type {HTMLElement | null} */ + // @ts-ignore - Bug 1674368 + const { PanelMultiView } = lazy.PanelMultiView(); + const element = PanelMultiView.getViewNode(document, id); + if (!element) { + throw new Error(`Could not find the element from the ID "${id}"`); + } + return element; + } + + // Forcefully cast the window to the type ChromeWindow. + /** @type {any} */ + const chromeWindowAny = document.defaultView; + /** @type {ChromeWindow} */ + const chromeWindow = chromeWindowAny; + + return { + document, + panelview, + window: chromeWindow, + inactive: getElementById("PanelUI-profiler-inactive"), + active: getElementById("PanelUI-profiler-active"), + presetDescription: getElementById("PanelUI-profiler-content-description"), + presetsEditSettings: getElementById( + "PanelUI-profiler-content-edit-settings" + ), + presetsMenuList: /** @type {MenuListElement} */ ( + getElementById("PanelUI-profiler-presets") + ), + header: getElementById("PanelUI-profiler-header"), + info: getElementById("PanelUI-profiler-info"), + menupopup: getElementById("PanelUI-profiler-presets-menupopup"), + infoButton: getElementById("PanelUI-profiler-info-button"), + learnMore: getElementById("PanelUI-profiler-learn-more"), + startRecording: getElementById("PanelUI-profiler-startRecording"), + stopAndDiscard: getElementById("PanelUI-profiler-stopAndDiscard"), + stopAndCapture: getElementById("PanelUI-profiler-stopAndCapture"), + settingsSection: getElementById("PanelUI-profiler-content-settings"), + contentRecording: getElementById("PanelUI-profiler-content-recording"), + }; +} + +/** + * This function returns an interface that can be used to control the view of the + * panel based on the current mutable State. + * + * @param {State} state + * @param {Elements} elements + */ +function createViewControllers(state, elements) { + return { + updateInfoCollapse() { + const { header, info, infoButton } = elements; + header.setAttribute( + "isinfocollapsed", + state.isInfoCollapsed ? "true" : "false" + ); + // @ts-ignore - Bug 1674368 + infoButton.checked = !state.isInfoCollapsed; + + if (state.isInfoCollapsed) { + const { height } = info.getBoundingClientRect(); + info.style.marginBlockEnd = `-${height}px`; + } else { + info.style.marginBlockEnd = "0"; + } + }, + + updatePresets() { + const { presets, getRecordingSettings } = lazy.Background(); + const { presetName } = getRecordingSettings( + "aboutprofiling", + Services.profiler.GetFeatures() + ); + const preset = presets[presetName]; + if (preset) { + elements.presetDescription.style.display = "block"; + elements.document.l10n.setAttributes( + elements.presetDescription, + preset.l10nIds.popup.description + ); + elements.presetsMenuList.value = presetName; + } else { + elements.presetDescription.style.display = "none"; + // We don't remove the l10n-id attribute as the element is hidden anyway. + // It will be updated again when it's displayed next time. + elements.presetsMenuList.value = "custom"; + } + }, + + updateProfilerState() { + if (Services.profiler.IsActive()) { + elements.inactive.hidden = true; + elements.active.hidden = false; + elements.settingsSection.hidden = true; + elements.contentRecording.hidden = false; + } else { + elements.inactive.hidden = false; + elements.active.hidden = true; + elements.settingsSection.hidden = false; + elements.contentRecording.hidden = true; + } + }, + + createPresetsList() { + // Check the DOM if the presets were built or not. We can't cache this value + // in the `State` object, as the `State` object will be removed if the + // button is removed from the toolbar, but the DOM changes will still persist. + if (elements.menupopup.getAttribute("presetsbuilt") === "true") { + // The presets were already built. + return; + } + + const { presets } = lazy.Background(); + const currentPreset = Services.prefs.getCharPref( + "devtools.performance.recording.preset" + ); + + const menuitems = Object.entries(presets).map(([id, preset]) => { + const { document, presetsMenuList } = elements; + const menuitem = document.createXULElement("menuitem"); + document.l10n.setAttributes(menuitem, preset.l10nIds.popup.label); + menuitem.setAttribute("value", id); + if (id === currentPreset) { + presetsMenuList.setAttribute("value", id); + } + return menuitem; + }); + + elements.menupopup.prepend(...menuitems); + elements.menupopup.setAttribute("presetsbuilt", "true"); + }, + + hidePopup() { + const panel = elements.panelview.closest("panel"); + if (!panel) { + throw new Error("Could not find the panel from the panelview."); + } + /** @type {any} */ (panel).hidePopup(); + }, + }; +} + +/** + * Perform all of the business logic to present the popup view once it is open. + * + * @param {State} state + * @param {Elements} elements + * @param {ViewController} view + */ +function initializeView(state, elements, view) { + view.createPresetsList(); + + state.cleanup.push(() => { + // The UI should be collapsed by default for the next time the popup + // is open. + state.isInfoCollapsed = true; + view.updateInfoCollapse(); + }); + + // Turn off all animations while initializing the popup. + elements.header.setAttribute("animationready", "false"); + + elements.window.requestAnimationFrame(() => { + // Allow the elements to layout once, the updateInfoCollapse implementation measures + // the size of the container. It needs to wait a second before the bounding box + // returns an actual size. + view.updateInfoCollapse(); + view.updateProfilerState(); + view.updatePresets(); + + // Now wait for another rAF, and turn the animations back on. + elements.window.requestAnimationFrame(() => { + elements.header.setAttribute("animationready", "true"); + }); + }); +} + +/** + * This function is in charge of settings all of the events handlers for the view. + * The handlers must also add themselves to the `state.cleanup` for them to be + * properly cleaned up once the view is destroyed. + * + * @param {State} state + * @param {Elements} elements + * @param {ViewController} view + */ +function addPopupEventHandlers(state, elements, view) { + const { changePreset, startProfiler, stopProfiler, captureProfile } = + lazy.Background(); + + /** + * Adds a handler that automatically is removed once the panel is hidden. + * + * @param {HTMLElement} element + * @param {string} type + * @param {(event: Event) => void} handler + */ + function addHandler(element, type, handler) { + element.addEventListener(type, handler); + state.cleanup.push(() => { + element.removeEventListener(type, handler); + }); + } + + addHandler(elements.infoButton, "click", event => { + // Any button command event in the popup will cause it to close. Prevent this + // from happening on click. + event.preventDefault(); + + state.isInfoCollapsed = !state.isInfoCollapsed; + view.updateInfoCollapse(); + }); + + addHandler(elements.startRecording, "click", () => { + startProfiler("aboutprofiling"); + }); + + addHandler(elements.stopAndDiscard, "click", () => { + stopProfiler(); + }); + + addHandler(elements.stopAndCapture, "click", () => { + captureProfile("aboutprofiling"); + view.hidePopup(); + }); + + addHandler(elements.learnMore, "click", () => { + elements.window.openWebLinkIn("https://profiler.firefox.com/docs/", "tab"); + view.hidePopup(); + }); + + addHandler(elements.presetsMenuList, "command", () => { + changePreset( + "aboutprofiling", + elements.presetsMenuList.value, + Services.profiler.GetFeatures() + ); + view.updatePresets(); + }); + + addHandler(elements.presetsEditSettings, "click", () => { + elements.window.openTrustedLinkIn("about:profiling", "tab"); + view.hidePopup(); + }); + + // Update the view when the profiler starts/stops. + // These are all events that can affect the current state of the profiler. + const events = ["profiler-started", "profiler-stopped"]; + for (const event of events) { + Services.obs.addObserver(view.updateProfilerState, event); + state.cleanup.push(() => { + Services.obs.removeObserver(view.updateProfilerState, event); + }); + } +} + +/** + * Initialize everything needed for the popup to work fine. + * @param {State} panelState + * @param {XULElement} panelview + */ +export function initializePopup(panelState, panelview) { + const panelElements = selectElementsInPanelview(panelview); + const panelviewControllers = createViewControllers(panelState, panelElements); + addPopupEventHandlers(panelState, panelElements, panelviewControllers); + initializeView(panelState, panelElements, panelviewControllers); +} diff --git a/devtools/client/performance-new/popup/menu-button.sys.mjs b/devtools/client/performance-new/popup/menu-button.sys.mjs new file mode 100644 index 0000000000..f1aee09af4 --- /dev/null +++ b/devtools/client/performance-new/popup/menu-button.sys.mjs @@ -0,0 +1,320 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check + +/** + * This file controls the enabling and disabling of the menu button for the profiler. + * Care should be taken to keep it minimal as it can be run with browser initialization. + */ + +import { createLazyLoaders } from "resource://devtools/client/performance-new/shared/typescript-lazy-load.sys.mjs"; + +const lazy = createLazyLoaders({ + CustomizableUI: () => + ChromeUtils.importESModule("resource:///modules/CustomizableUI.sys.mjs"), + CustomizableWidgets: () => + ChromeUtils.importESModule( + "resource:///modules/CustomizableWidgets.sys.mjs" + ), + PopupLogic: () => + ChromeUtils.importESModule( + "resource://devtools/client/performance-new/popup/logic.sys.mjs" + ), + Background: () => + ChromeUtils.importESModule( + "resource://devtools/client/performance-new/shared/background.sys.mjs" + ), +}); + +const WIDGET_ID = "profiler-button"; + +/** + * Add the profiler button to the navbar. + * + * @param {ChromeDocument} document The browser's document. + * @return {void} + */ +function addToNavbar(document) { + const { CustomizableUI } = lazy.CustomizableUI(); + + CustomizableUI.addWidgetToArea(WIDGET_ID, CustomizableUI.AREA_NAVBAR); +} + +/** + * Remove the widget and place it in the customization palette. This will also + * disable the shortcuts. + * + * @return {void} + */ +function remove() { + const { CustomizableUI } = lazy.CustomizableUI(); + CustomizableUI.removeWidgetFromArea(WIDGET_ID); +} + +/** + * See if the profiler menu button is in the navbar, or other active areas. The + * placement is null when it's inactive in the customization palette. + * + * @return {boolean} + */ +function isInNavbar() { + const { CustomizableUI } = lazy.CustomizableUI(); + return Boolean(CustomizableUI.getPlacementOfWidget("profiler-button")); +} + +/** + * Opens the popup for the profiler. + * @param {Document} document + */ +function openPopup(document) { + // First find the button. + /** @type {HTMLButtonElement | null} */ + const button = document.querySelector("#profiler-button"); + if (!button) { + throw new Error("Could not find the profiler button."); + } + + // Sending a click event anywhere on the button could start the profiler + // instead of opening the popup. Sending a command event on a view widget + // will make CustomizableUI show the view. + const cmdEvent = document.createEvent("xulcommandevent"); + // @ts-ignore - Bug 1674368 + cmdEvent.initCommandEvent("command", true, true, button.ownerGlobal); + button.dispatchEvent(cmdEvent); +} + +/** + * This function creates the widget definition for the CustomizableUI. It should + * only be run if the profiler button is enabled. + * @param {(isEnabled: boolean) => void} toggleProfilerKeyShortcuts + * @return {void} + */ +function initialize(toggleProfilerKeyShortcuts) { + const { CustomizableUI } = lazy.CustomizableUI(); + const { CustomizableWidgets } = lazy.CustomizableWidgets(); + + const widget = CustomizableUI.getWidget(WIDGET_ID); + if (widget && widget.provider == CustomizableUI.PROVIDER_API) { + // This widget has already been created. + return; + } + + const viewId = "PanelUI-profiler"; + + /** + * This is mutable state that will be shared between panel displays. + * + * @type {import("devtools/client/performance-new/popup/logic.sys.mjs").State} + */ + const panelState = { + cleanup: [], + isInfoCollapsed: true, + }; + + /** + * Handle when the customization changes for the button. This event is not + * very specific, and fires for any CustomizableUI widget. This event is + * pretty rare to fire, and only affects users of the profiler button, + * so it shouldn't have much overhead even if it runs a lot. + */ + function handleCustomizationChange() { + const isEnabled = isInNavbar(); + toggleProfilerKeyShortcuts(isEnabled); + + if (!isEnabled) { + // The profiler menu button is no longer in the navbar, make sure that the + // "intro-displayed" preference is reset. + /** @type {import("../@types/perf").PerformancePref["PopupIntroDisplayed"]} */ + const popupIntroDisplayedPref = + "devtools.performance.popup.intro-displayed"; + Services.prefs.setBoolPref(popupIntroDisplayedPref, false); + + // We stop the profiler when the button is removed for normal users, + // but we try to avoid interfering with profiling of automated tests. + if ( + Services.profiler.IsActive() && + (!Cu.isInAutomation || !Services.env.exists("MOZ_PROFILER_STARTUP")) + ) { + Services.profiler.StopProfiler(); + } + } + } + + const item = { + id: WIDGET_ID, + type: "button-and-view", + viewId, + l10nId: "profiler-popup-button-idle", + + onViewShowing: + /** + * @type {(event: { + * target: ChromeHTMLElement | XULElement, + * detail: { + * addBlocker: (blocker: Promise<void>) => void + * } + * }) => void} + */ + event => { + try { + // The popup logic is stored in a separate script so it doesn't have + // to be parsed at browser startup, and will only be lazily loaded + // when the popup is viewed. + const { initializePopup } = lazy.PopupLogic(); + + initializePopup(panelState, event.target); + } catch (error) { + // Surface any errors better in the console. + console.error(error); + } + }, + + /** + * @type {(event: { target: ChromeHTMLElement | XULElement }) => void} + */ + onViewHiding(event) { + // Clean-up the view. This removes all of the event listeners. + for (const fn of panelState.cleanup) { + fn(); + } + panelState.cleanup = []; + }, + + /** + * Perform any general initialization for this widget. This is called once per + * browser window. + * + * @type {(document: HTMLDocument) => void} + */ + onBeforeCreated: document => { + /** @type {import("../@types/perf").PerformancePref["PopupIntroDisplayed"]} */ + const popupIntroDisplayedPref = + "devtools.performance.popup.intro-displayed"; + + // Determine the state of the popup's info being collapsed BEFORE the view + // is shown, and update the collapsed state. This way the transition animation + // isn't run. + panelState.isInfoCollapsed = Services.prefs.getBoolPref( + popupIntroDisplayedPref + ); + if (!panelState.isInfoCollapsed) { + // We have displayed the intro, don't show it again by default. + Services.prefs.setBoolPref(popupIntroDisplayedPref, true); + } + + // Handle customization event changes. If the profiler is no longer in the + // navbar, then reset the popup intro preference. + const window = document.defaultView; + if (window) { + /** @type {any} */ (window).gNavToolbox.addEventListener( + "customizationchange", + handleCustomizationChange + ); + } + + toggleProfilerKeyShortcuts(isInNavbar()); + }, + + /** + * This method is used when we need to operate upon the button element itself. + * This is called once per browser window. + * + * @type {(node: ChromeHTMLElement) => void} + */ + onCreated: node => { + const document = node.ownerDocument; + const window = document?.defaultView; + if (!document || !window) { + console.error( + "Unable to find the document or the window of the profiler toolbar item." + ); + return; + } + + const firstButton = node.firstElementChild; + if (!firstButton) { + console.error( + "Unable to find the button element inside the profiler toolbar item." + ); + return; + } + + // Assign the null-checked button element to a new variable so that + // TypeScript doesn't require additional null checks in the functions + // below. + const buttonElement = firstButton; + + // This class is needed to show the subview arrow when our button + // is in the overflow menu. + buttonElement.classList.add("subviewbutton-nav"); + + function setButtonActive() { + document.l10n.setAttributes( + buttonElement, + "profiler-popup-button-recording" + ); + buttonElement.classList.toggle("profiler-active", true); + buttonElement.classList.toggle("profiler-paused", false); + } + function setButtonPaused() { + document.l10n.setAttributes( + buttonElement, + "profiler-popup-button-capturing" + ); + buttonElement.classList.toggle("profiler-active", false); + buttonElement.classList.toggle("profiler-paused", true); + } + function setButtonInactive() { + document.l10n.setAttributes( + buttonElement, + "profiler-popup-button-idle" + ); + buttonElement.classList.toggle("profiler-active", false); + buttonElement.classList.toggle("profiler-paused", false); + } + + if (Services.profiler.IsPaused()) { + setButtonPaused(); + } + if (Services.profiler.IsActive()) { + setButtonActive(); + } + + Services.obs.addObserver(setButtonActive, "profiler-started"); + Services.obs.addObserver(setButtonInactive, "profiler-stopped"); + Services.obs.addObserver(setButtonPaused, "profiler-paused"); + + window.addEventListener("unload", () => { + Services.obs.removeObserver(setButtonActive, "profiler-started"); + Services.obs.removeObserver(setButtonInactive, "profiler-stopped"); + Services.obs.removeObserver(setButtonPaused, "profiler-paused"); + }); + }, + + // @ts-ignore - Bug 1674368 + onCommand: event => { + if (Services.profiler.IsPaused()) { + // A profile is already being captured, ignore this event. + return; + } + const { startProfiler, captureProfile } = lazy.Background(); + if (Services.profiler.IsActive()) { + captureProfile("aboutprofiling"); + } else { + startProfiler("aboutprofiling"); + } + }, + }; + + CustomizableUI.createWidget(item); + CustomizableWidgets.push(item); +} + +export const ProfilerMenuButton = { + initialize, + addToNavbar, + isInNavbar, + openPopup, + remove, +}; diff --git a/devtools/client/performance-new/popup/moz.build b/devtools/client/performance-new/popup/moz.build new file mode 100644 index 0000000000..ed9f4c2a2d --- /dev/null +++ b/devtools/client/performance-new/popup/moz.build @@ -0,0 +1,12 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "logic.sys.mjs", + "menu-button.sys.mjs", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Performance Tools (Profiler/Timeline)") diff --git a/devtools/client/performance-new/shared/#typescript-lazy-load.jsm.js# b/devtools/client/performance-new/shared/#typescript-lazy-load.jsm.js# new file mode 100644 index 0000000000..de982cc3ff --- /dev/null +++ b/devtools/client/performance-new/shared/#typescript-lazy-load.jsm.js# @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check +"use strict"; + +/** + * TypeScript can't understand the lazyRequireGetter mechanism, due to how it defines + * properties as a getter. This function, instead provides lazy loading in a + * TypeScript-friendly manner. It applies the lazy load memoization to each property + * of the provided object. + * + * Example usage: + * + * const lazy = createLazyLoaders({ + * moduleA: () => require("module/a"), + * moduleB: () => require("module/b"), + * }); + * + * Later: + * + * const moduleA = lazy.moduleA(); + * const { objectInModuleB } = lazy.moduleB(); + * + * @template {{ [key: string]: () => any }} T + * @param {T} definition - An object where each property has a function that loads a module. + * @returns {T} - The load memoized version of T. + */ +function createLazyLoaders(definition) { + /** @type {any} */ + const result = {}; + for (const [key, callback] of Object.entries(definition)) { + /** @type {any} */ + let cache; + result[key] = () => { + if (cache === undefined) { + cache = callback(); + } + return cache; + }; + } + return result; +} + +// Provide an exports object for the JSM to be properly read by TypeScript. +/** @type {any} */ +var module = {}; + +module.exports = { + createLazyLoaders, +}; + +// Object.keys() confuses the linting which expects a static array expression. +// eslint-disable-next-line +var EXPORTED_SYMBOLS = Object.keys(module.exports); diff --git a/devtools/client/performance-new/shared/README.md b/devtools/client/performance-new/shared/README.md new file mode 100644 index 0000000000..d9c75eed51 --- /dev/null +++ b/devtools/client/performance-new/shared/README.md @@ -0,0 +1,5 @@ +This directory contains files that are common to all UIs (popup, devtools panel, +about:profiling) interacting with the profiler. +Other UIs external to the profiler (one example is about:logging) can also use +these files, especially background.sys.mjs, to interact with the profiler with +more capabilities than Services.profiler. diff --git a/devtools/client/performance-new/shared/background.sys.mjs b/devtools/client/performance-new/shared/background.sys.mjs new file mode 100644 index 0000000000..f538500a42 --- /dev/null +++ b/devtools/client/performance-new/shared/background.sys.mjs @@ -0,0 +1,928 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check + +/** + * This file contains all of the background logic for controlling the state and + * configuration of the profiler. It is in a JSM so that the logic can be shared + * with both the popup client, and the keyboard shortcuts. The shortcuts don't need + * access to any UI, and need to be loaded independent of the popup. + */ + +// The following are not lazily loaded as they are needed during initialization. + +import { createLazyLoaders } from "resource://devtools/client/performance-new/shared/typescript-lazy-load.sys.mjs"; + +// For some reason TypeScript was giving me an error when de-structuring AppConstants. I +// suspect a bug in TypeScript was at play. +const AppConstants = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +).AppConstants; + +/** + * @typedef {import("../@types/perf").RecordingSettings} RecordingSettings + * @typedef {import("../@types/perf").SymbolTableAsTuple} SymbolTableAsTuple + * @typedef {import("../@types/perf").Library} Library + * @typedef {import("../@types/perf").PerformancePref} PerformancePref + * @typedef {import("../@types/perf").ProfilerWebChannel} ProfilerWebChannel + * @typedef {import("../@types/perf").PageContext} PageContext + * @typedef {import("../@types/perf").PrefObserver} PrefObserver + * @typedef {import("../@types/perf").PrefPostfix} PrefPostfix + * @typedef {import("../@types/perf").Presets} Presets + * @typedef {import("../@types/perf").ProfilerViewMode} ProfilerViewMode + * @typedef {import("../@types/perf").MessageFromFrontend} MessageFromFrontend + * @typedef {import("../@types/perf").RequestFromFrontend} RequestFromFrontend + * @typedef {import("../@types/perf").ResponseToFrontend} ResponseToFrontend + * @typedef {import("../@types/perf").SymbolicationService} SymbolicationService + * @typedef {import("../@types/perf").ProfilerBrowserInfo} ProfilerBrowserInfo + * @typedef {import("../@types/perf").ProfileCaptureResult} ProfileCaptureResult + */ + +/** @type {PerformancePref["Entries"]} */ +const ENTRIES_PREF = "devtools.performance.recording.entries"; +/** @type {PerformancePref["Interval"]} */ +const INTERVAL_PREF = "devtools.performance.recording.interval"; +/** @type {PerformancePref["Features"]} */ +const FEATURES_PREF = "devtools.performance.recording.features"; +/** @type {PerformancePref["Threads"]} */ +const THREADS_PREF = "devtools.performance.recording.threads"; +/** @type {PerformancePref["ObjDirs"]} */ +const OBJDIRS_PREF = "devtools.performance.recording.objdirs"; +/** @type {PerformancePref["Duration"]} */ +const DURATION_PREF = "devtools.performance.recording.duration"; +/** @type {PerformancePref["Preset"]} */ +const PRESET_PREF = "devtools.performance.recording.preset"; +/** @type {PerformancePref["PopupFeatureFlag"]} */ +const POPUP_FEATURE_FLAG_PREF = "devtools.performance.popup.feature-flag"; +/* This will be used to observe all profiler-related prefs. */ +const PREF_PREFIX = "devtools.performance.recording."; + +// The version of the profiler WebChannel. +// This is reported from the STATUS_QUERY message, and identifies the +// capabilities of the WebChannel. The front-end can handle old WebChannel +// versions and has a full list of versions and capabilities here: +// https://github.com/firefox-devtools/profiler/blob/main/src/app-logic/web-channel.js +const CURRENT_WEBCHANNEL_VERSION = 2; + +const lazyRequire = {}; +// eslint-disable-next-line mozilla/lazy-getter-object-name +ChromeUtils.defineESModuleGetters(lazyRequire, { + require: "resource://devtools/shared/loader/Loader.sys.mjs", +}); +// Lazily load the require function, when it's needed. +// Avoid using ChromeUtils.defineESModuleGetters for now as: +// * we can't replace createLazyLoaders as we still load commonjs+jsm+esm +// It will be easier once we only load sys.mjs files. +// * we would need to find a way to accomodate typescript to this special function. +// @ts-ignore:next-line +function require(path) { + // @ts-ignore:next-line + return lazyRequire.require(path); +} + +// The following utilities are lazily loaded as they are not needed when controlling the +// global state of the profiler, and only are used during specific funcationality like +// symbolication or capturing a profile. +const lazy = createLazyLoaders({ + Utils: () => + require("resource://devtools/client/performance-new/shared/utils.js"), + BrowserModule: () => + require("resource://devtools/client/performance-new/shared/browser.js"), + RecordingUtils: () => + require("resource://devtools/shared/performance-new/recording-utils.js"), + CustomizableUI: () => + ChromeUtils.importESModule("resource:///modules/CustomizableUI.sys.mjs"), + PerfSymbolication: () => + ChromeUtils.importESModule( + "resource://devtools/client/performance-new/shared/symbolication.sys.mjs" + ), + ProfilerMenuButton: () => + ChromeUtils.importESModule( + "resource://devtools/client/performance-new/popup/menu-button.sys.mjs" + ), +}); + +// The presets that we find in all interfaces are defined here. + +// The property l10nIds contain all FTL l10n IDs for these cases: +// - properties in "popup" are used in the popup's select box. +// - properties in "devtools" are used in other UIs (about:profiling and devtools panels). +// +// Properties for both cases have the same values, but because they're not used +// in the same way we need to duplicate them. +// Their values for the en-US locale are in the files: +// devtools/client/locales/en-US/perftools.ftl +// browser/locales/en-US/browser/appmenu.ftl +// +// IMPORTANT NOTE: Please keep the existing profiler presets in sync with their +// Fenix counterparts and consider adding any new presets to Fenix: +// https://github.com/mozilla-mobile/firefox-android/blob/1d177e7e78d027e8ab32cedf0fc68316787d7454/fenix/app/src/main/java/org/mozilla/fenix/perf/ProfilerUtils.kt + +/** @type {Presets} */ +export const presets = { + "web-developer": { + entries: 128 * 1024 * 1024, + interval: 1, + features: ["screenshots", "js", "cpu"], + threads: ["GeckoMain", "Compositor", "Renderer", "DOM Worker"], + duration: 0, + profilerViewMode: "active-tab", + l10nIds: { + popup: { + label: "profiler-popup-presets-web-developer-label", + description: "profiler-popup-presets-web-developer-description", + }, + devtools: { + label: "perftools-presets-web-developer-label", + description: "perftools-presets-web-developer-description", + }, + }, + }, + "firefox-platform": { + entries: 128 * 1024 * 1024, + interval: 1, + features: ["screenshots", "js", "stackwalk", "cpu", "java", "processcpu"], + threads: [ + "GeckoMain", + "Compositor", + "Renderer", + "SwComposite", + "DOM Worker", + ], + duration: 0, + l10nIds: { + popup: { + label: "profiler-popup-presets-firefox-label", + description: "profiler-popup-presets-firefox-description", + }, + devtools: { + label: "perftools-presets-firefox-label", + description: "perftools-presets-firefox-description", + }, + }, + }, + graphics: { + entries: 128 * 1024 * 1024, + interval: 1, + features: ["stackwalk", "js", "cpu", "java", "processcpu"], + threads: [ + "GeckoMain", + "Compositor", + "Renderer", + "SwComposite", + "RenderBackend", + "SceneBuilder", + "WrWorker", + "CanvasWorkers", + "TextureUpdate", + ], + duration: 0, + l10nIds: { + popup: { + label: "profiler-popup-presets-graphics-label", + description: "profiler-popup-presets-graphics-description", + }, + devtools: { + label: "perftools-presets-graphics-label", + description: "perftools-presets-graphics-description", + }, + }, + }, + media: { + entries: 128 * 1024 * 1024, + interval: 1, + features: [ + "js", + "stackwalk", + "cpu", + "audiocallbacktracing", + "ipcmessages", + "processcpu", + ], + threads: [ + "cubeb", + "audio", + "BackgroundThreadPool", + "camera", + "capture", + "Compositor", + "decoder", + "GeckoMain", + "gmp", + "graph", + "grph", + "InotifyEventThread", + "IPDL Background", + "media", + "ModuleProcessThread", + "PacerThread", + "RemVidChild", + "RenderBackend", + "Renderer", + "Socket Thread", + "SwComposite", + "webrtc", + "TextureUpdate", + ], + duration: 0, + l10nIds: { + popup: { + label: "profiler-popup-presets-media-label", + description: "profiler-popup-presets-media-description2", + }, + devtools: { + label: "perftools-presets-media-label", + description: "perftools-presets-media-description2", + }, + }, + }, + networking: { + entries: 128 * 1024 * 1024, + interval: 1, + features: [ + "screenshots", + "js", + "stackwalk", + "cpu", + "java", + "processcpu", + "bandwidth", + ], + threads: [ + "Compositor", + "DNS Resolver", + "DOM Worker", + "GeckoMain", + "Renderer", + "Socket Thread", + "StreamTrans", + "SwComposite", + "TRR Background", + ], + duration: 0, + l10nIds: { + popup: { + label: "profiler-popup-presets-networking-label", + description: "profiler-popup-presets-networking-description", + }, + devtools: { + label: "perftools-presets-networking-label", + description: "perftools-presets-networking-description", + }, + }, + }, + power: { + entries: 128 * 1024 * 1024, + interval: 10, + features: [ + "screenshots", + "js", + "stackwalk", + "cpu", + "processcpu", + "nostacksampling", + "ipcmessages", + "markersallthreads", + "power", + "bandwidth", + ], + threads: ["GeckoMain", "Renderer"], + duration: 0, + l10nIds: { + popup: { + label: "profiler-popup-presets-power-label", + description: "profiler-popup-presets-power-description", + }, + devtools: { + label: "perftools-presets-power-label", + description: "perftools-presets-power-description", + }, + }, + }, +}; + +/** + * Return the proper view mode for the Firefox Profiler front-end timeline by + * looking at the proper preset that is selected. + * Return value can be undefined when the preset is unknown or custom. + * @param {PageContext} pageContext + * @return {ProfilerViewMode | undefined} + */ +export function getProfilerViewModeForCurrentPreset(pageContext) { + const prefPostfix = getPrefPostfix(pageContext); + const presetName = Services.prefs.getCharPref(PRESET_PREF + prefPostfix); + + if (presetName === "custom") { + return undefined; + } + + const preset = presets[presetName]; + if (!preset) { + console.error(`Unknown profiler preset was encountered: "${presetName}"`); + return undefined; + } + return preset.profilerViewMode; +} + +/** + * This function is called when the profile is captured with the shortcut + * keys, with the profiler toolbarbutton, or with the button inside the + * popup. + * @param {PageContext} pageContext + * @return {Promise<void>} + */ +export async function captureProfile(pageContext) { + if (!Services.profiler.IsActive()) { + // The profiler is not active, ignore. + return; + } + if (Services.profiler.IsPaused()) { + // The profiler is already paused for capture, ignore. + return; + } + + // Pause profiler before we collect the profile, so that we don't capture + // more samples while the parent process waits for subprocess profiles. + Services.profiler.Pause(); + + /** + * @type {MockedExports.ProfileGenerationAdditionalInformation | undefined} + */ + let additionalInfo; + /** + * @type {ProfileCaptureResult} + */ + const profileCaptureResult = await Services.profiler + .getProfileDataAsGzippedArrayBuffer() + .then( + ({ profile, additionalInformation }) => { + additionalInfo = additionalInformation; + return { type: "SUCCESS", profile }; + }, + error => { + console.error(error); + return { type: "ERROR", error }; + } + ); + + const profilerViewMode = getProfilerViewModeForCurrentPreset(pageContext); + const sharedLibraries = additionalInfo?.sharedLibraries + ? additionalInfo.sharedLibraries + : Services.profiler.sharedLibraries; + const objdirs = getObjdirPrefValue(); + + const { createLocalSymbolicationService } = lazy.PerfSymbolication(); + const symbolicationService = createLocalSymbolicationService( + sharedLibraries, + objdirs + ); + + const { openProfilerTab } = lazy.BrowserModule(); + const browser = await openProfilerTab(profilerViewMode); + registerProfileCaptureForBrowser( + browser, + profileCaptureResult, + symbolicationService + ); + + Services.profiler.StopProfiler(); +} + +/** + * This function is called when the profiler is started with the shortcut + * keys, with the profiler toolbarbutton, or with the button inside the + * popup. + * @param {PageContext} pageContext + */ +export function startProfiler(pageContext) { + const { entries, interval, features, threads, duration } = + getRecordingSettings(pageContext, Services.profiler.GetFeatures()); + + // Get the active Browser ID from browser. + const { getActiveBrowserID } = lazy.RecordingUtils(); + const activeTabID = getActiveBrowserID(); + + Services.profiler.StartProfiler( + entries, + interval, + features, + threads, + activeTabID, + duration + ); +} + +/** + * This function is called directly by devtools/startup/DevToolsStartup.jsm when + * using the shortcut keys to capture a profile. + * @type {() => void} + */ +export function stopProfiler() { + Services.profiler.StopProfiler(); +} + +/** + * This function is called directly by devtools/startup/DevToolsStartup.jsm when + * using the shortcut keys to start and stop the profiler. + * @param {PageContext} pageContext + * @return {void} + */ +export function toggleProfiler(pageContext) { + if (Services.profiler.IsPaused()) { + // The profiler is currently paused, which means that the user is already + // attempting to capture a profile. Ignore this request. + return; + } + if (Services.profiler.IsActive()) { + stopProfiler(); + } else { + startProfiler(pageContext); + } +} + +/** + * @param {PageContext} pageContext + */ +export function restartProfiler(pageContext) { + stopProfiler(); + startProfiler(pageContext); +} + +/** + * @param {string} prefName + * @return {string[]} + */ +function _getArrayOfStringsPref(prefName) { + const text = Services.prefs.getCharPref(prefName); + return JSON.parse(text); +} + +/** + * The profiler recording workflow uses two different pref paths. One set of prefs + * is stored for local profiling, and another for remote profiling. This function + * decides which to use. The remote prefs have ".remote" appended to the end of + * their pref names. + * + * @param {PageContext} pageContext + * @returns {PrefPostfix} + */ +function getPrefPostfix(pageContext) { + switch (pageContext) { + case "devtools": + case "aboutprofiling": + case "aboutlogging": + // Don't use any postfix on the prefs. + return ""; + case "devtools-remote": + case "aboutprofiling-remote": + return ".remote"; + default: { + const { UnhandledCaseError } = lazy.Utils(); + throw new UnhandledCaseError(pageContext, "Page Context"); + } + } +} + +/** + * @param {string[]} objdirs + */ +function setObjdirPrefValue(objdirs) { + Services.prefs.setCharPref(OBJDIRS_PREF, JSON.stringify(objdirs)); +} + +/** + * Before Firefox 92, the objdir lists for local and remote profiling were + * stored in separate lists. In Firefox 92 those two prefs were merged into + * one. This function performs the migration. + */ +function migrateObjdirsPrefsIfNeeded() { + const OLD_REMOTE_OBJDIRS_PREF = OBJDIRS_PREF + ".remote"; + const remoteString = Services.prefs.getCharPref(OLD_REMOTE_OBJDIRS_PREF, ""); + if (remoteString === "") { + // No migration necessary. + return; + } + + const remoteList = JSON.parse(remoteString); + const localList = _getArrayOfStringsPref(OBJDIRS_PREF); + + // Merge the two lists, eliminating any duplicates. + const mergedList = [...new Set(localList.concat(remoteList))]; + setObjdirPrefValue(mergedList); + Services.prefs.clearUserPref(OLD_REMOTE_OBJDIRS_PREF); +} + +/** + * @returns {string[]} + */ +function getObjdirPrefValue() { + migrateObjdirsPrefsIfNeeded(); + return _getArrayOfStringsPref(OBJDIRS_PREF); +} + +/** + * @param {PageContext} pageContext + * @param {string[]} supportedFeatures + * @returns {RecordingSettings} + */ +export function getRecordingSettings(pageContext, supportedFeatures) { + const objdirs = getObjdirPrefValue(); + const prefPostfix = getPrefPostfix(pageContext); + const presetName = Services.prefs.getCharPref(PRESET_PREF + prefPostfix); + + // First try to get the values from a preset. If the preset is "custom" or + // unrecognized, getRecordingSettingsFromPreset will return null and we will + // get the settings from individual prefs instead. + return ( + getRecordingSettingsFromPreset(presetName, supportedFeatures, objdirs) ?? + getRecordingSettingsFromPrefs(supportedFeatures, objdirs, prefPostfix) + ); +} + +/** + * @param {string} presetName + * @param {string[]} supportedFeatures + * @param {string[]} objdirs + * @return {RecordingSettings | null} + */ +function getRecordingSettingsFromPreset( + presetName, + supportedFeatures, + objdirs +) { + if (presetName === "custom") { + return null; + } + + const preset = presets[presetName]; + if (!preset) { + console.error(`Unknown profiler preset was encountered: "${presetName}"`); + return null; + } + + return { + presetName, + entries: preset.entries, + interval: preset.interval, + // Validate the features before passing them to the profiler. + features: preset.features.filter(feature => + supportedFeatures.includes(feature) + ), + threads: preset.threads, + objdirs, + duration: preset.duration, + }; +} + +/** + * @param {string[]} supportedFeatures + * @param {string[]} objdirs + * @param {PrefPostfix} prefPostfix + * @return {RecordingSettings} + */ +function getRecordingSettingsFromPrefs( + supportedFeatures, + objdirs, + prefPostfix +) { + // If you add a new preference here, please do not forget to update + // `revertRecordingSettings` as well. + + const entries = Services.prefs.getIntPref(ENTRIES_PREF + prefPostfix); + const intervalInMicroseconds = Services.prefs.getIntPref( + INTERVAL_PREF + prefPostfix + ); + const interval = intervalInMicroseconds / 1000; + const features = _getArrayOfStringsPref(FEATURES_PREF + prefPostfix); + const threads = _getArrayOfStringsPref(THREADS_PREF + prefPostfix); + const duration = Services.prefs.getIntPref(DURATION_PREF + prefPostfix); + + return { + presetName: "custom", + entries, + interval, + // Validate the features before passing them to the profiler. + features: features.filter(feature => supportedFeatures.includes(feature)), + threads, + objdirs, + duration, + }; +} + +/** + * @param {PageContext} pageContext + * @param {RecordingSettings} prefs + */ +export function setRecordingSettings(pageContext, prefs) { + const prefPostfix = getPrefPostfix(pageContext); + Services.prefs.setCharPref(PRESET_PREF + prefPostfix, prefs.presetName); + Services.prefs.setIntPref(ENTRIES_PREF + prefPostfix, prefs.entries); + // The interval pref stores the value in microseconds for extra precision. + const intervalInMicroseconds = prefs.interval * 1000; + Services.prefs.setIntPref( + INTERVAL_PREF + prefPostfix, + intervalInMicroseconds + ); + Services.prefs.setCharPref( + FEATURES_PREF + prefPostfix, + JSON.stringify(prefs.features) + ); + Services.prefs.setCharPref( + THREADS_PREF + prefPostfix, + JSON.stringify(prefs.threads) + ); + setObjdirPrefValue(prefs.objdirs); +} + +export const platform = AppConstants.platform; + +/** + * Revert the recording prefs for both local and remote profiling. + * @return {void} + */ +export function revertRecordingSettings() { + for (const prefPostfix of ["", ".remote"]) { + Services.prefs.clearUserPref(PRESET_PREF + prefPostfix); + Services.prefs.clearUserPref(ENTRIES_PREF + prefPostfix); + Services.prefs.clearUserPref(INTERVAL_PREF + prefPostfix); + Services.prefs.clearUserPref(FEATURES_PREF + prefPostfix); + Services.prefs.clearUserPref(THREADS_PREF + prefPostfix); + Services.prefs.clearUserPref(DURATION_PREF + prefPostfix); + } + Services.prefs.clearUserPref(OBJDIRS_PREF); + Services.prefs.clearUserPref(POPUP_FEATURE_FLAG_PREF); +} + +/** + * Change the prefs based on a preset. This mechanism is used by the popup to + * easily switch between different settings. + * @param {string} presetName + * @param {PageContext} pageContext + * @param {string[]} supportedFeatures + * @return {void} + */ +export function changePreset(pageContext, presetName, supportedFeatures) { + const prefPostfix = getPrefPostfix(pageContext); + const objdirs = getObjdirPrefValue(); + let recordingSettings = getRecordingSettingsFromPreset( + presetName, + supportedFeatures, + objdirs + ); + + if (!recordingSettings) { + // No recordingSettings were found for that preset. Most likely this means this + // is a custom preset, or it's one that we dont recognize for some reason. + // Get the preferences from the individual preference values. + Services.prefs.setCharPref(PRESET_PREF + prefPostfix, presetName); + recordingSettings = getRecordingSettings(pageContext, supportedFeatures); + } + + setRecordingSettings(pageContext, recordingSettings); +} + +/** + * Add an observer for the profiler-related preferences. + * @param {PrefObserver} observer + * @return {void} + */ +export function addPrefObserver(observer) { + Services.prefs.addObserver(PREF_PREFIX, observer); +} + +/** + * Removes an observer for the profiler-related preferences. + * @param {PrefObserver} observer + * @return {void} + */ +export function removePrefObserver(observer) { + Services.prefs.removeObserver(PREF_PREFIX, observer); +} + +/** + * This map stores information that is associated with a "profile capturing" + * action, so that we can look up this information for WebChannel messages + * from the profiler tab. + * Most importantly, this stores the captured profile. When the profiler tab + * requests the profile, we can respond to the message with the correct profile. + * This works even if the request happens long after the tab opened. It also + * works for an "old" tab even if new profiles have been captured since that + * tab was opened. + * Supporting tab refresh is important because the tab sometimes reloads itself: + * If an old version of the front-end is cached in the service worker, and the + * browser supplies a profile with a newer format version, then the front-end + * updates its service worker and reloads itself, so that the updated version + * can parse the profile. + * + * This is a WeakMap so that the profile can be garbage-collected when the tab + * is closed. + * + * @type {WeakMap<MockedExports.Browser, ProfilerBrowserInfo>} + */ +const infoForBrowserMap = new WeakMap(); + +/** + * This handler computes the response for any messages coming + * from the WebChannel from profiler.firefox.com. + * + * @param {RequestFromFrontend} request + * @param {MockedExports.Browser} browser - The tab's browser. + * @return {Promise<ResponseToFrontend>} + */ +async function getResponseForMessage(request, browser) { + switch (request.type) { + case "STATUS_QUERY": { + // The content page wants to know if this channel exists. It does, so respond + // back to the ping. + const { ProfilerMenuButton } = lazy.ProfilerMenuButton(); + return { + version: CURRENT_WEBCHANNEL_VERSION, + menuButtonIsEnabled: ProfilerMenuButton.isInNavbar(), + }; + } + case "ENABLE_MENU_BUTTON": { + const { ownerDocument } = browser; + if (!ownerDocument) { + throw new Error( + "Could not find the owner document for the current browser while enabling " + + "the profiler menu button" + ); + } + // Ensure the widget is enabled. + Services.prefs.setBoolPref(POPUP_FEATURE_FLAG_PREF, true); + + // Force the preset to be "firefox-platform" if we enable the menu button + // via web channel. If user goes through profiler.firefox.com to enable + // it, it means that either user is a platform developer or filing a bug + // report for performance engineers to look at. + const supportedFeatures = Services.profiler.GetFeatures(); + changePreset("aboutprofiling", "firefox-platform", supportedFeatures); + + // Enable the profiler menu button. + const { ProfilerMenuButton } = lazy.ProfilerMenuButton(); + ProfilerMenuButton.addToNavbar(ownerDocument); + + // Dispatch the change event manually, so that the shortcuts will also be + // added. + const { CustomizableUI } = lazy.CustomizableUI(); + CustomizableUI.dispatchToolboxEvent("customizationchange"); + + // Open the popup with a message. + ProfilerMenuButton.openPopup(ownerDocument); + + // There is no response data for this message. + return undefined; + } + case "GET_PROFILE": { + const infoForBrowser = infoForBrowserMap.get(browser); + if (infoForBrowser === undefined) { + throw new Error("Could not find a profile for this tab."); + } + const { profileCaptureResult } = infoForBrowser; + switch (profileCaptureResult.type) { + case "SUCCESS": + return profileCaptureResult.profile; + case "ERROR": + throw profileCaptureResult.error; + default: + const { UnhandledCaseError } = lazy.Utils(); + throw new UnhandledCaseError( + profileCaptureResult, + "profileCaptureResult" + ); + } + } + case "GET_SYMBOL_TABLE": { + const { debugName, breakpadId } = request; + const symbolicationService = getSymbolicationServiceForBrowser(browser); + return symbolicationService.getSymbolTable(debugName, breakpadId); + } + case "QUERY_SYMBOLICATION_API": { + const { path, requestJson } = request; + const symbolicationService = getSymbolicationServiceForBrowser(browser); + return symbolicationService.querySymbolicationApi(path, requestJson); + } + case "GET_EXTERNAL_POWER_TRACKS": { + const { startTime, endTime } = request; + const externalPowerUrl = Services.prefs.getCharPref( + "devtools.performance.recording.power.external-url", + "" + ); + if (externalPowerUrl) { + const response = await fetch( + `${externalPowerUrl}?start=${startTime}&end=${endTime}` + ); + return response.json(); + } + return []; + } + default: + console.error( + "An unknown message type was received by the profiler's WebChannel handler.", + request + ); + const { UnhandledCaseError } = lazy.Utils(); + throw new UnhandledCaseError(request, "WebChannel request"); + } +} + +/** + * Get the symbolicationService for the capture that opened this browser's + * tab, or a fallback service for browsers from tabs opened by the user. + * + * @param {MockedExports.Browser} browser + * @return {SymbolicationService} + */ +function getSymbolicationServiceForBrowser(browser) { + // We try to serve symbolication requests that come from tabs that we + // opened when a profile was captured, and for tabs that the user opened + // independently, for example because the user wants to load an existing + // profile from a file. + const infoForBrowser = infoForBrowserMap.get(browser); + if (infoForBrowser !== undefined) { + // We opened this tab when a profile was captured. Use the symbolication + // service for that capture. + return infoForBrowser.symbolicationService; + } + + // For the "foreign" tabs, we provide a fallback symbolication service so that + // we can find symbols for any libraries that are loaded in this process. This + // means that symbolication will work if the existing file has been captured + // from the same build. + const { createLocalSymbolicationService } = lazy.PerfSymbolication(); + return createLocalSymbolicationService( + Services.profiler.sharedLibraries, + getObjdirPrefValue() + ); +} + +/** + * This handler handles any messages coming from the WebChannel from profiler.firefox.com. + * + * @param {ProfilerWebChannel} channel + * @param {string} id + * @param {any} message + * @param {MockedExports.WebChannelTarget} target + */ +export async function handleWebChannelMessage(channel, id, message, target) { + if (typeof message !== "object" || typeof message.type !== "string") { + console.error( + "An malformed message was received by the profiler's WebChannel handler.", + message + ); + return; + } + const messageFromFrontend = /** @type {MessageFromFrontend} */ (message); + const { requestId } = messageFromFrontend; + + try { + const response = await getResponseForMessage( + messageFromFrontend, + target.browser + ); + channel.send( + { + type: "SUCCESS_RESPONSE", + requestId, + response, + }, + target + ); + } catch (error) { + let errorMessage; + if (error instanceof Error) { + errorMessage = `${error.name}: ${error.message}`; + } else { + errorMessage = `${error}`; + } + channel.send( + { + type: "ERROR_RESPONSE", + requestId, + error: errorMessage, + }, + target + ); + } +} + +/** + * @param {MockedExports.Browser} browser - The tab's browser. + * @param {ProfileCaptureResult} profileCaptureResult - The Gecko profile. + * @param {SymbolicationService} symbolicationService - An object which implements the + * SymbolicationService interface, whose getSymbolTable method will be invoked + * when profiler.firefox.com sends GET_SYMBOL_TABLE WebChannel messages to us. This + * method should obtain a symbol table for the requested binary and resolve the + * returned promise with it. + */ +export function registerProfileCaptureForBrowser( + browser, + profileCaptureResult, + symbolicationService +) { + infoForBrowserMap.set(browser, { + profileCaptureResult, + symbolicationService, + }); +} diff --git a/devtools/client/performance-new/shared/browser.js b/devtools/client/performance-new/shared/browser.js new file mode 100644 index 0000000000..c97bb0a0ab --- /dev/null +++ b/devtools/client/performance-new/shared/browser.js @@ -0,0 +1,173 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check +"use strict"; + +/** + * @typedef {import("../@types/perf").Action} Action + * @typedef {import("../@types/perf").Library} Library + * @typedef {import("../@types/perf").PerfFront} PerfFront + * @typedef {import("../@types/perf").SymbolTableAsTuple} SymbolTableAsTuple + * @typedef {import("../@types/perf").RecordingState} RecordingState + * @typedef {import("../@types/perf").SymbolicationService} SymbolicationService + * @typedef {import("../@types/perf").PreferenceFront} PreferenceFront + * @typedef {import("../@types/perf").PerformancePref} PerformancePref + * @typedef {import("../@types/perf").RecordingSettings} RecordingSettings + * @typedef {import("../@types/perf").RestartBrowserWithEnvironmentVariable} RestartBrowserWithEnvironmentVariable + * @typedef {import("../@types/perf").GetActiveBrowserID} GetActiveBrowserID + * @typedef {import("../@types/perf").MinimallyTypedGeckoProfile} MinimallyTypedGeckoProfile + * * @typedef {import("../@types/perf").ProfilerViewMode} ProfilerViewMode + */ + +/** @type {PerformancePref["UIBaseUrl"]} */ +const UI_BASE_URL_PREF = "devtools.performance.recording.ui-base-url"; +/** @type {PerformancePref["UIBaseUrlPathPref"]} */ +const UI_BASE_URL_PATH_PREF = "devtools.performance.recording.ui-base-url-path"; + +/** @type {PerformancePref["UIEnableActiveTabView"]} */ +const UI_ENABLE_ACTIVE_TAB_PREF = + "devtools.performance.recording.active-tab-view.enabled"; + +const UI_BASE_URL_DEFAULT = "https://profiler.firefox.com"; +const UI_BASE_URL_PATH_DEFAULT = "/from-browser"; + +/** + * This file contains all of the privileged browser-specific functionality. This helps + * keep a clear separation between the privileged and non-privileged client code. It + * is also helpful in being able to mock out browser behavior for tests, without + * worrying about polluting the browser environment. + */ + +/** + * Once a profile is received from the actor, it needs to be opened up in + * profiler.firefox.com to be analyzed. This function opens up profiler.firefox.com + * into a new browser tab. + * @param {ProfilerViewMode | undefined} profilerViewMode - View mode for the Firefox Profiler + * front-end timeline. While opening the url, we should append a query string + * if a view other than "full" needs to be displayed. + * @returns {Promise<MockedExports.Browser>} The browser for the opened tab. + */ +async function openProfilerTab(profilerViewMode) { + // Allow the user to point to something other than profiler.firefox.com. + const baseUrl = Services.prefs.getStringPref( + UI_BASE_URL_PREF, + UI_BASE_URL_DEFAULT + ); + // Allow tests to override the path. + const baseUrlPath = Services.prefs.getStringPref( + UI_BASE_URL_PATH_PREF, + UI_BASE_URL_PATH_DEFAULT + ); + // This controls whether we enable the active tab view when capturing in web + // developer preset. + const enableActiveTab = Services.prefs.getBoolPref( + UI_ENABLE_ACTIVE_TAB_PREF, + false + ); + + // We automatically open up the "full" mode if no query string is present. + // `undefined` also means nothing is specified, and it should open the "full" + // timeline view in that case. + let viewModeQueryString = ""; + if (profilerViewMode === "active-tab") { + // We're not enabling the active-tab view in all environments until we + // iron out all its issues. + if (enableActiveTab) { + viewModeQueryString = "?view=active-tab&implementation=js"; + } else { + viewModeQueryString = "?implementation=js"; + } + } else if (profilerViewMode !== undefined && profilerViewMode !== "full") { + viewModeQueryString = `?view=${profilerViewMode}`; + } + + const urlToLoad = `${baseUrl}${baseUrlPath}${viewModeQueryString}`; + + // Find the most recently used window, as the DevTools client could be in a variety + // of hosts. + // Note that when running from the browser toolbox, there won't be the browser window, + // but only the browser toolbox document. + const win = + Services.wm.getMostRecentWindow("navigator:browser") || + Services.wm.getMostRecentWindow("devtools:toolbox"); + if (!win) { + throw new Error("No browser window"); + } + win.focus(); + + // The profiler frontend currently doesn't support being loaded in a private + // window, because it does some storage writes in IndexedDB. That's why we + // force the opening of the tab in a non-private window. This might open a new + // non-private window if the only currently opened window is a private window. + const contentBrowser = await new Promise(resolveOnContentBrowserCreated => + win.openWebLinkIn(urlToLoad, "tab", { + forceNonPrivate: true, + resolveOnContentBrowserCreated, + userContextId: win.gBrowser?.contentPrincipal.userContextId, + relatedToCurrent: true, + }) + ); + return contentBrowser; +} + +/** + * Flatten all the sharedLibraries of the different processes in the profile + * into one list of libraries. + * @param {MinimallyTypedGeckoProfile} profile - The profile JSON object + * @returns {Library[]} + */ +function sharedLibrariesFromProfile(profile) { + /** + * @param {MinimallyTypedGeckoProfile} processProfile + * @returns {Library[]} + */ + function getLibsRecursive(processProfile) { + return processProfile.libs.concat( + ...processProfile.processes.map(getLibsRecursive) + ); + } + + return getLibsRecursive(profile); +} + +/** + * Restarts the browser with a given environment variable set to a value. + * + * @type {RestartBrowserWithEnvironmentVariable} + */ +function restartBrowserWithEnvironmentVariable(envName, value) { + Services.env.set(envName, value); + + Services.startup.quit( + Services.startup.eForceQuit | Services.startup.eRestart + ); +} + +/** + * @param {Window} window + * @param {string[]} objdirs + * @param {(objdirs: string[]) => unknown} changeObjdirs + */ +function openFilePickerForObjdir(window, objdirs, changeObjdirs) { + const FilePicker = Cc["@mozilla.org/filepicker;1"].createInstance( + Ci.nsIFilePicker + ); + FilePicker.init(window, "Pick build directory", FilePicker.modeGetFolder); + FilePicker.open(rv => { + if (rv == FilePicker.returnOK) { + const path = FilePicker.file.path; + if (path && !objdirs.includes(path)) { + const newObjdirs = [...objdirs, path]; + changeObjdirs(newObjdirs); + } + } + }); +} + +module.exports = { + openProfilerTab, + sharedLibrariesFromProfile, + restartBrowserWithEnvironmentVariable, + openFilePickerForObjdir, +}; diff --git a/devtools/client/performance-new/shared/moz.build b/devtools/client/performance-new/shared/moz.build new file mode 100644 index 0000000000..f9d5418bb8 --- /dev/null +++ b/devtools/client/performance-new/shared/moz.build @@ -0,0 +1,17 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "background.sys.mjs", + "browser.js", + "profiler_get_symbols.js", + "symbolication.sys.mjs", + "symbolication.worker.js", + "typescript-lazy-load.sys.mjs", + "utils.js", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Performance Tools (Profiler/Timeline)") diff --git a/devtools/client/performance-new/shared/profiler_get_symbols.js b/devtools/client/performance-new/shared/profiler_get_symbols.js new file mode 100644 index 0000000000..2036a529b4 --- /dev/null +++ b/devtools/client/performance-new/shared/profiler_get_symbols.js @@ -0,0 +1,547 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// +// THIS FILE IS AUTOGENERATED by wasm-bindgen. +// +// Generated from: +// https://github.com/mstange/profiler-get-symbols/commit/390b8c4be82c720dd3977ff205fb34bd7d0e00ba +// by following the instructions in that repository's Readme.md +// + +let wasm_bindgen; +(function() { + const __exports = {}; + let script_src; + if (typeof document !== 'undefined' && document.currentScript !== null) { + script_src = new URL(document.currentScript.src, location.href).toString(); + } + let wasm = undefined; + + const heap = new Array(128).fill(undefined); + + heap.push(undefined, null, true, false); + +function getObject(idx) { return heap[idx]; } + +let WASM_VECTOR_LEN = 0; + +let cachedUint8Memory0 = null; + +function getUint8Memory0() { + if (cachedUint8Memory0 === null || cachedUint8Memory0.byteLength === 0) { + cachedUint8Memory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8Memory0; +} + +const cachedTextEncoder = (typeof TextEncoder !== 'undefined' ? new TextEncoder('utf-8') : { encode: () => { throw Error('TextEncoder not available') } } ); + +const encodeString = (typeof cachedTextEncoder.encodeInto === 'function' + ? function (arg, view) { + return cachedTextEncoder.encodeInto(arg, view); +} + : function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; +}); + +function passStringToWasm0(arg, malloc, realloc) { + + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8Memory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8Memory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8Memory0().subarray(ptr + offset, ptr + len); + const ret = encodeString(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +function isLikeNone(x) { + return x === undefined || x === null; +} + +let cachedInt32Memory0 = null; + +function getInt32Memory0() { + if (cachedInt32Memory0 === null || cachedInt32Memory0.byteLength === 0) { + cachedInt32Memory0 = new Int32Array(wasm.memory.buffer); + } + return cachedInt32Memory0; +} + +let heap_next = heap.length; + +function dropObject(idx) { + if (idx < 132) return; + heap[idx] = heap_next; + heap_next = idx; +} + +function takeObject(idx) { + const ret = getObject(idx); + dropObject(idx); + return ret; +} + +function addHeapObject(obj) { + if (heap_next === heap.length) heap.push(heap.length + 1); + const idx = heap_next; + heap_next = heap[idx]; + + heap[idx] = obj; + return idx; +} + +const cachedTextDecoder = (typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }) : { decode: () => { throw Error('TextDecoder not available') } } ); + +if (typeof TextDecoder !== 'undefined') { cachedTextDecoder.decode(); }; + +function getStringFromWasm0(ptr, len) { + ptr = ptr >>> 0; + return cachedTextDecoder.decode(getUint8Memory0().subarray(ptr, ptr + len)); +} + +const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(state => { + wasm.__wbindgen_export_2.get(state.dtor)(state.a, state.b) +}); + +function makeMutClosure(arg0, arg1, dtor, f) { + const state = { a: arg0, b: arg1, cnt: 1, dtor }; + const real = (...args) => { + // First up with a closure we increment the internal reference + // count. This ensures that the Rust closure environment won't + // be deallocated while we're invoking it. + state.cnt++; + const a = state.a; + state.a = 0; + try { + return f(a, state.b, ...args); + } finally { + if (--state.cnt === 0) { + wasm.__wbindgen_export_2.get(state.dtor)(a, state.b); + CLOSURE_DTORS.unregister(state); + } else { + state.a = a; + } + } + }; + real.original = state; + CLOSURE_DTORS.register(real, state, state); + return real; +} +function __wbg_adapter_20(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures__invoke1_mut__h5c75a368331e2dfc(arg0, arg1, addHeapObject(arg2)); +} + +function handleError(f, args) { + try { + return f.apply(this, args); + } catch (e) { + wasm.__wbindgen_exn_store(addHeapObject(e)); + } +} +function __wbg_adapter_41(arg0, arg1, arg2, arg3) { + wasm.wasm_bindgen__convert__closures__invoke2_mut__hb4d9edc8ea00b6ab(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3)); +} + +/** +* Usage: +* +* ```js +* async function getSymbolTable(debugName, breakpadId, libKeyToPathMap) { +* const helper = { +* getCandidatePathsForDebugFile: (info) => { +* const path = libKeyToPathMap.get(`${info.debugName}/${info.breakpadId}`); +* if (path !== undefined) { +* return [path]; +* } +* return []; +* }, +* getCandidatePathsForBinary: (info) => [], +* readFile: async (filename) => { +* const byteLength = await getFileSizeInBytes(filename); +* const fileHandle = getFileHandle(filename); +* return { +* size: byteLength, +* readBytesInto: (array, offset) => { +* syncReadFilePartIntoBuffer(fileHandle, array, offset); +* }, +* close: () => {}, +* }; +* }, +* }; +* +* const [addr, index, buffer] = await getCompactSymbolTable(debugName, breakpadId, helper); +* return [addr, index, buffer]; +* } +* ``` +* @param {string} debug_name +* @param {string} breakpad_id +* @param {any} helper +* @returns {Promise<any>} +*/ +__exports.getCompactSymbolTable = function(debug_name, breakpad_id, helper) { + const ptr0 = passStringToWasm0(debug_name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(breakpad_id, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + const ret = wasm.getCompactSymbolTable(ptr0, len0, ptr1, len1, addHeapObject(helper)); + return takeObject(ret); +}; + +/** +* Usage: +* +* ```js +* async function queryAPIWrapper(url, requestJSONString, libKeyToPathMap) { +* const helper = { +* getCandidatePathsForDebugFile: (info) => { +* const path = libKeyToPathMap.get(`${info.debugName}/${info.breakpadId}`); +* if (path !== undefined) { +* return [path]; +* } +* return []; +* }, +* getCandidatePathsForBinary: (info) => [], +* readFile: async (filename) => { +* const byteLength = await getFileSizeInBytes(filename); +* const fileHandle = getFileHandle(filename); +* return { +* size: byteLength, +* readBytesInto: (array, offset) => { +* syncReadFilePartIntoBuffer(fileHandle, array, offset); +* }, +* close: () => {}, +* }; +* }, +* }; +* +* const responseJSONString = await queryAPI(url, requestJSONString, helper); +* return responseJSONString; +* } +* ``` +* @param {string} url +* @param {string} request_json +* @param {any} helper +* @returns {Promise<any>} +*/ +__exports.queryAPI = function(url, request_json, helper) { + const ptr0 = passStringToWasm0(url, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(request_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + const ret = wasm.queryAPI(ptr0, len0, ptr1, len1, addHeapObject(helper)); + return takeObject(ret); +}; + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + + } catch (e) { + if (module.headers.get('Content-Type') != 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { + throw e; + } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + + } else { + return instance; + } + } +} + +function __wbg_get_imports() { + const imports = {}; + imports.wbg = {}; + imports.wbg.__wbg_get_0ee8ea3c7c984c45 = function(arg0, arg1) { + const ret = getObject(arg0)[arg1 >>> 0]; + return addHeapObject(ret); + }; + imports.wbg.__wbindgen_string_get = function(arg0, arg1) { + const obj = getObject(arg1); + const ret = typeof(obj) === 'string' ? obj : undefined; + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getInt32Memory0()[arg0 / 4 + 1] = len1; + getInt32Memory0()[arg0 / 4 + 0] = ptr1; + }; + imports.wbg.__wbindgen_object_drop_ref = function(arg0) { + takeObject(arg0); + }; + imports.wbg.__wbg_self_05040bd9523805b9 = function() { return handleError(function () { + const ret = self.self; + return addHeapObject(ret); + }, arguments) }; + imports.wbg.__wbg_window_adc720039f2cb14f = function() { return handleError(function () { + const ret = window.window; + return addHeapObject(ret); + }, arguments) }; + imports.wbg.__wbg_globalThis_622105db80c1457d = function() { return handleError(function () { + const ret = globalThis.globalThis; + return addHeapObject(ret); + }, arguments) }; + imports.wbg.__wbg_global_f56b013ed9bcf359 = function() { return handleError(function () { + const ret = global.global; + return addHeapObject(ret); + }, arguments) }; + imports.wbg.__wbindgen_is_undefined = function(arg0) { + const ret = getObject(arg0) === undefined; + return ret; + }; + imports.wbg.__wbg_newnoargs_cfecb3965268594c = function(arg0, arg1) { + const ret = new Function(getStringFromWasm0(arg0, arg1)); + return addHeapObject(ret); + }; + imports.wbg.__wbg_call_3f093dd26d5569f8 = function() { return handleError(function (arg0, arg1) { + const ret = getObject(arg0).call(getObject(arg1)); + return addHeapObject(ret); + }, arguments) }; + imports.wbg.__wbindgen_memory = function() { + const ret = wasm.memory; + return addHeapObject(ret); + }; + imports.wbg.__wbg_buffer_b914fb8b50ebbc3e = function(arg0) { + const ret = getObject(arg0).buffer; + return addHeapObject(ret); + }; + imports.wbg.__wbg_newwithbyteoffsetandlength_7f2d9491ea8c746e = function(arg0, arg1, arg2) { + const ret = new Uint32Array(getObject(arg0), arg1 >>> 0, arg2 >>> 0); + return addHeapObject(ret); + }; + imports.wbg.__wbg_new_437b9c4fbab85dd9 = function(arg0) { + const ret = new Uint32Array(getObject(arg0)); + return addHeapObject(ret); + }; + imports.wbg.__wbg_newwithbyteoffsetandlength_0de9ee56e9f6ee6e = function(arg0, arg1, arg2) { + const ret = new Uint8Array(getObject(arg0), arg1 >>> 0, arg2 >>> 0); + return addHeapObject(ret); + }; + imports.wbg.__wbg_new_b1f2d6842d615181 = function(arg0) { + const ret = new Uint8Array(getObject(arg0)); + return addHeapObject(ret); + }; + imports.wbg.__wbg_of_94ac9e20a3c46ec5 = function(arg0, arg1, arg2) { + const ret = Array.of(getObject(arg0), getObject(arg1), getObject(arg2)); + return addHeapObject(ret); + }; + imports.wbg.__wbg_call_67f2111acd2dfdb6 = function() { return handleError(function (arg0, arg1, arg2) { + const ret = getObject(arg0).call(getObject(arg1), getObject(arg2)); + return addHeapObject(ret); + }, arguments) }; + imports.wbg.__wbindgen_object_clone_ref = function(arg0) { + const ret = getObject(arg0); + return addHeapObject(ret); + }; + imports.wbg.__wbg_getCandidatePathsForDebugFile_19de1ea293153630 = function() { return handleError(function (arg0, arg1) { + const ret = getObject(arg0).getCandidatePathsForDebugFile(takeObject(arg1)); + return addHeapObject(ret); + }, arguments) }; + imports.wbg.__wbg_name_405bb0aa047a1bf5 = function(arg0) { + const ret = getObject(arg0).name; + return addHeapObject(ret); + }; + imports.wbg.__wbg_message_2a19bb5b62cf8e22 = function(arg0) { + const ret = getObject(arg0).message; + return addHeapObject(ret); + }; + imports.wbg.__wbg_from_58c79ccfb68060f5 = function(arg0) { + const ret = Array.from(getObject(arg0)); + return addHeapObject(ret); + }; + imports.wbg.__wbg_length_161c0d89c6535c1d = function(arg0) { + const ret = getObject(arg0).length; + return ret; + }; + imports.wbg.__wbindgen_string_new = function(arg0, arg1) { + const ret = getStringFromWasm0(arg0, arg1); + return addHeapObject(ret); + }; + imports.wbg.__wbg_getCandidatePathsForBinary_8311cb7aeae90263 = function() { return handleError(function (arg0, arg1) { + const ret = getObject(arg0).getCandidatePathsForBinary(takeObject(arg1)); + return addHeapObject(ret); + }, arguments) }; + imports.wbg.__wbindgen_cb_drop = function(arg0) { + const obj = takeObject(arg0).original; + if (obj.cnt-- == 1) { + obj.a = 0; + return true; + } + const ret = false; + return ret; + }; + imports.wbg.__wbg_new_632630b5cec17f21 = function() { + const ret = new Object(); + return addHeapObject(ret); + }; + imports.wbg.__wbg_set_9182712abebf82ef = function(arg0, arg1, arg2) { + getObject(arg0)[takeObject(arg1)] = takeObject(arg2); + }; + imports.wbg.__wbg_close_0c320371e818efa4 = function() { return handleError(function (arg0) { + getObject(arg0).close(); + }, arguments) }; + imports.wbg.__wbg_readBytesInto_8dda8e37c69f2cbd = function() { return handleError(function (arg0, arg1, arg2) { + getObject(arg0).readBytesInto(takeObject(arg1), arg2); + }, arguments) }; + imports.wbg.__wbg_readFile_33b95391c6839d48 = function(arg0, arg1, arg2) { + const ret = getObject(arg0).readFile(getStringFromWasm0(arg1, arg2)); + return addHeapObject(ret); + }; + imports.wbg.__wbg_then_20a5920e447d1cb1 = function(arg0, arg1, arg2) { + const ret = getObject(arg0).then(getObject(arg1), getObject(arg2)); + return addHeapObject(ret); + }; + imports.wbg.__wbg_size_45521802c91634d3 = function() { return handleError(function (arg0) { + const ret = getObject(arg0).size; + return ret; + }, arguments) }; + imports.wbg.__wbg_new_70828a4353259d4b = function(arg0, arg1) { + try { + var state0 = {a: arg0, b: arg1}; + var cb0 = (arg0, arg1) => { + const a = state0.a; + state0.a = 0; + try { + return __wbg_adapter_41(a, state0.b, arg0, arg1); + } finally { + state0.a = a; + } + }; + const ret = new Promise(cb0); + return addHeapObject(ret); + } finally { + state0.a = state0.b = 0; + } + }; + imports.wbg.__wbindgen_throw = function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }; + imports.wbg.__wbg_then_f9e58f5a50f43eae = function(arg0, arg1) { + const ret = getObject(arg0).then(getObject(arg1)); + return addHeapObject(ret); + }; + imports.wbg.__wbg_queueMicrotask_f61ee94ee663068b = function(arg0) { + queueMicrotask(getObject(arg0)); + }; + imports.wbg.__wbg_queueMicrotask_f82fc5d1e8f816ae = function(arg0) { + const ret = getObject(arg0).queueMicrotask; + return addHeapObject(ret); + }; + imports.wbg.__wbindgen_is_function = function(arg0) { + const ret = typeof(getObject(arg0)) === 'function'; + return ret; + }; + imports.wbg.__wbg_resolve_5da6faf2c96fd1d5 = function(arg0) { + const ret = Promise.resolve(getObject(arg0)); + return addHeapObject(ret); + }; + imports.wbg.__wbindgen_closure_wrapper2106 = function(arg0, arg1, arg2) { + const ret = makeMutClosure(arg0, arg1, 159, __wbg_adapter_20); + return addHeapObject(ret); + }; + + return imports; +} + +function __wbg_init_memory(imports, maybe_memory) { + +} + +function __wbg_finalize_init(instance, module) { + wasm = instance.exports; + __wbg_init.__wbindgen_wasm_module = module; + cachedInt32Memory0 = null; + cachedUint8Memory0 = null; + + + return wasm; +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + const imports = __wbg_get_imports(); + + __wbg_init_memory(imports); + + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + + const instance = new WebAssembly.Instance(module, imports); + + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(input) { + if (wasm !== undefined) return wasm; + + if (typeof input === 'undefined' && typeof script_src !== 'undefined') { + input = script_src.replace(/\.js$/, '_bg.wasm'); + } + const imports = __wbg_get_imports(); + + if (typeof input === 'string' || (typeof Request === 'function' && input instanceof Request) || (typeof URL === 'function' && input instanceof URL)) { + input = fetch(input); + } + + __wbg_init_memory(imports); + + const { instance, module } = await __wbg_load(await input, imports); + + return __wbg_finalize_init(instance, module); +} + +wasm_bindgen = Object.assign(__wbg_init, { initSync }, __exports); + +})(); diff --git a/devtools/client/performance-new/shared/symbolication.sys.mjs b/devtools/client/performance-new/shared/symbolication.sys.mjs new file mode 100644 index 0000000000..da73b12107 --- /dev/null +++ b/devtools/client/performance-new/shared/symbolication.sys.mjs @@ -0,0 +1,362 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check + +/** @type {any} */ +const lazy = {}; + +/** + * @typedef {import("../@types/perf").Library} Library + * @typedef {import("../@types/perf").PerfFront} PerfFront + * @typedef {import("../@types/perf").SymbolTableAsTuple} SymbolTableAsTuple + * @typedef {import("../@types/perf").SymbolicationService} SymbolicationService + * @typedef {import("../@types/perf").SymbolicationWorkerInitialMessage} SymbolicationWorkerInitialMessage + */ + +/** + * @template R + * @typedef {import("../@types/perf").SymbolicationWorkerReplyData<R>} SymbolicationWorkerReplyData<R> + */ + +ChromeUtils.defineESModuleGetters(lazy, { + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +/** @type {any} */ +const global = globalThis; + +// This module obtains symbol tables for binaries. +// It does so with the help of a WASM module which gets pulled in from the +// internet on demand. We're doing this purely for the purposes of saving on +// code size. The contents of the WASM module are expected to be static, they +// are checked against the hash specified below. +// The WASM code is run on a ChromeWorker thread. It takes the raw byte +// contents of the to-be-dumped binary (and of an additional optional pdb file +// on Windows) as its input, and returns a set of typed arrays which make up +// the symbol table. + +// Don't let the strange looking URLs and strings below scare you. +// The hash check ensures that the contents of the wasm module are what we +// expect them to be. +// The source code is at https://github.com/mstange/profiler-get-symbols/ . +// Documentation is at https://docs.rs/samply-api/ . +// The sha384 sum can be computed with the following command (tested on macOS): +// shasum -b -a 384 profiler_get_symbols_wasm_bg.wasm | awk '{ print $1 }' | xxd -r -p | base64 + +// Generated from https://github.com/mstange/profiler-get-symbols/commit/390b8c4be82c720dd3977ff205fb34bd7d0e00ba +const WASM_MODULE_URL = + "https://storage.googleapis.com/firefox-profiler-get-symbols/390b8c4be82c720dd3977ff205fb34bd7d0e00ba.wasm"; +const WASM_MODULE_INTEGRITY = + "sha384-P8j6U9jY+M4zSfJKXb1ECjsTPkzQ0hAvgb4zv3gHvlg+THRtVpOrDSywHJBhin00"; + +const EXPIRY_TIME_IN_MS = 5 * 60 * 1000; // 5 minutes + +/** @type {Promise<WebAssembly.Module> | null} */ +let gCachedWASMModulePromise = null; +let gCachedWASMModuleExpiryTimer = 0; + +// Keep active workers alive (see bug 1592227). +const gActiveWorkers = new Set(); + +function clearCachedWASMModule() { + gCachedWASMModulePromise = null; + gCachedWASMModuleExpiryTimer = 0; +} + +function getWASMProfilerGetSymbolsModule() { + if (!gCachedWASMModulePromise) { + gCachedWASMModulePromise = (async function () { + const request = new Request(WASM_MODULE_URL, { + integrity: WASM_MODULE_INTEGRITY, + credentials: "omit", + }); + return WebAssembly.compileStreaming(fetch(request)); + })(); + } + + // Reset expiry timer. + lazy.clearTimeout(gCachedWASMModuleExpiryTimer); + gCachedWASMModuleExpiryTimer = lazy.setTimeout( + clearCachedWASMModule, + EXPIRY_TIME_IN_MS + ); + + return gCachedWASMModulePromise; +} + +/** + * Handle the entire life cycle of a worker, and report its result. + * This method creates a new worker, sends the initial message to it, handles + * any errors, and accepts the result. + * Returns a promise that resolves with the contents of the (singular) result + * message or rejects with an error. + * + * @template M + * @template R + * @param {string} workerURL + * @param {M} initialMessageToWorker + * @returns {Promise<R>} + */ +async function getResultFromWorker(workerURL, initialMessageToWorker) { + return new Promise((resolve, reject) => { + const worker = new ChromeWorker(workerURL); + gActiveWorkers.add(worker); + + /** @param {MessageEvent<SymbolicationWorkerReplyData<R>>} msg */ + worker.onmessage = msg => { + gActiveWorkers.delete(worker); + if ("error" in msg.data) { + const error = msg.data.error; + if (error.name) { + // Turn the JSON error object into a real Error object. + const { name, message, fileName, lineNumber } = error; + const ErrorObjConstructor = + name in global && Error.isPrototypeOf(global[name]) + ? global[name] + : Error; + const e = new ErrorObjConstructor(message, fileName, lineNumber); + e.name = name; + reject(e); + } else { + reject(error); + } + return; + } + resolve(msg.data.result); + }; + + // Handle uncaught errors from the worker script. onerror is called if + // there's a syntax error in the worker script, for example, or when an + // unhandled exception is thrown, but not for unhandled promise + // rejections. Without this handler, mistakes during development such as + // syntax errors can be hard to track down. + worker.onerror = errorEvent => { + gActiveWorkers.delete(worker); + worker.terminate(); + if (ErrorEvent.isInstance(errorEvent)) { + const { message, filename, lineno } = errorEvent; + const error = new Error(`${message} at ${filename}:${lineno}`); + error.name = "WorkerError"; + reject(error); + } else { + reject(new Error("Error in worker")); + } + }; + + // Handle errors from messages that cannot be deserialized. I'm not sure + // how to get into such a state, but having this handler seems like a good + // idea. + worker.onmessageerror = () => { + gActiveWorkers.delete(worker); + worker.terminate(); + reject(new Error("Error in worker")); + }; + + worker.postMessage(initialMessageToWorker); + }); +} + +/** + * @param {PerfFront} perfFront + * @param {string} path + * @param {string} breakpadId + * @returns {Promise<SymbolTableAsTuple>} + */ +async function getSymbolTableFromDebuggee(perfFront, path, breakpadId) { + const [addresses, index, buffer] = await perfFront.getSymbolTable( + path, + breakpadId + ); + // The protocol transmits these arrays as plain JavaScript arrays of + // numbers, but we want to pass them on as typed arrays. Convert them now. + return [ + new Uint32Array(addresses), + new Uint32Array(index), + new Uint8Array(buffer), + ]; +} + +/** + * Profiling through the DevTools remote debugging protocol supports multiple + * different modes. This class is specialized to handle various profiling + * modes such as: + * + * 1) Profiling the same browser on the same machine. + * 2) Profiling a remote browser on the same machine. + * 3) Profiling a remote browser on a different device. + * + * It's also built to handle symbolication requests for both Gecko libraries and + * system libraries. However, it only handles cases where symbol information + * can be found in a local file on this machine. There is one case that is not + * covered by that restriction: Android system libraries. That case requires + * the help of the perf actor and is implemented in + * LocalSymbolicationServiceWithRemoteSymbolTableFallback. + */ +class LocalSymbolicationService { + /** + * @param {Library[]} sharedLibraries - Information about the shared libraries. + * This allows mapping (debugName, breakpadId) pairs to the absolute path of + * the binary and/or PDB file, and it ensures that these absolute paths come + * from a trusted source and not from the profiler UI. + * @param {string[]} objdirs - An array of objdir paths + * on the host machine that should be searched for relevant build artifacts. + */ + constructor(sharedLibraries, objdirs) { + this._libInfoMap = new Map( + sharedLibraries.map(lib => { + const { debugName, breakpadId } = lib; + const key = `${debugName}:${breakpadId}`; + return [key, lib]; + }) + ); + this._objdirs = objdirs; + } + + /** + * @param {string} debugName + * @param {string} breakpadId + * @returns {Promise<SymbolTableAsTuple>} + */ + async getSymbolTable(debugName, breakpadId) { + const module = await getWASMProfilerGetSymbolsModule(); + /** @type {SymbolicationWorkerInitialMessage} */ + const initialMessage = { + request: { + type: "GET_SYMBOL_TABLE", + debugName, + breakpadId, + }, + libInfoMap: this._libInfoMap, + objdirs: this._objdirs, + module, + }; + return getResultFromWorker( + "resource://devtools/client/performance-new/shared/symbolication.worker.js", + initialMessage + ); + } + + /** + * @param {string} path + * @param {string} requestJson + * @returns {Promise<string>} + */ + async querySymbolicationApi(path, requestJson) { + const module = await getWASMProfilerGetSymbolsModule(); + /** @type {SymbolicationWorkerInitialMessage} */ + const initialMessage = { + request: { + type: "QUERY_SYMBOLICATION_API", + path, + requestJson, + }, + libInfoMap: this._libInfoMap, + objdirs: this._objdirs, + module, + }; + return getResultFromWorker( + "resource://devtools/client/performance-new/shared/symbolication.worker.js", + initialMessage + ); + } +} + +/** + * An implementation of the SymbolicationService interface which also + * covers the Android system library case. + * We first try to get symbols from the wrapped SymbolicationService. + * If that fails, we try to get the symbol table through the perf actor. + */ +class LocalSymbolicationServiceWithRemoteSymbolTableFallback { + /** + * @param {SymbolicationService} symbolicationService - The regular symbolication service. + * @param {Library[]} sharedLibraries - Information about the shared libraries + * @param {PerfFront} perfFront - A perf actor, to obtain symbol + * tables from remote targets + */ + constructor(symbolicationService, sharedLibraries, perfFront) { + this._symbolicationService = symbolicationService; + this._libs = sharedLibraries; + this._perfFront = perfFront; + } + + /** + * @param {string} debugName + * @param {string} breakpadId + * @returns {Promise<SymbolTableAsTuple>} + */ + async getSymbolTable(debugName, breakpadId) { + try { + return await this._symbolicationService.getSymbolTable( + debugName, + breakpadId + ); + } catch (errorFromLocalFiles) { + // Try to obtain the symbol table on the debuggee. We get into this + // branch in the following cases: + // - Android system libraries + // - Firefox binaries that have no matching equivalent on the host + // machine, for example because the user didn't point us at the + // corresponding objdir, or if the build was compiled somewhere + // else, or if the build on the device is outdated. + // For now, the "debuggee" is never a Windows machine, which is why we don't + // need to pass the library's debugPath. (path and debugPath are always the + // same on non-Windows.) + const lib = this._libs.find( + l => l.debugName === debugName && l.breakpadId === breakpadId + ); + if (!lib) { + let errorMessage; + if (errorFromLocalFiles instanceof Error) { + errorMessage = errorFromLocalFiles.message; + } else { + errorMessage = `${errorFromLocalFiles}`; + } + + throw new Error( + `Could not find the library for "${debugName}", "${breakpadId}" after falling ` + + `back to remote symbol table querying because regular getSymbolTable failed ` + + `with error: ${errorMessage}.` + ); + } + return getSymbolTableFromDebuggee(this._perfFront, lib.path, breakpadId); + } + } + + /** + * @param {string} path + * @param {string} requestJson + * @returns {Promise<string>} + */ + async querySymbolicationApi(path, requestJson) { + return this._symbolicationService.querySymbolicationApi(path, requestJson); + } +} + +/** + * Return an object that implements the SymbolicationService interface. + * + * @param {Library[]} sharedLibraries - Information about the shared libraries + * @param {string[]} objdirs - An array of objdir paths + * on the host machine that should be searched for relevant build artifacts. + * @param {PerfFront} [perfFront] - An optional perf actor, to obtain symbol + * tables from remote targets + * @return {SymbolicationService} + */ +export function createLocalSymbolicationService( + sharedLibraries, + objdirs, + perfFront +) { + const service = new LocalSymbolicationService(sharedLibraries, objdirs); + if (perfFront) { + return new LocalSymbolicationServiceWithRemoteSymbolTableFallback( + service, + sharedLibraries, + perfFront + ); + } + return service; +} diff --git a/devtools/client/performance-new/shared/symbolication.worker.js b/devtools/client/performance-new/shared/symbolication.worker.js new file mode 100644 index 0000000000..199514ac77 --- /dev/null +++ b/devtools/client/performance-new/shared/symbolication.worker.js @@ -0,0 +1,277 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// FIXME: This file is currently not covered by TypeScript, there is no "@ts-check" comment. +// We should fix this once we know how to deal with the module imports below. +// (Maybe once Firefox supports worker module? Bug 1247687) + +"use strict"; + +/* import-globals-from profiler_get_symbols.js */ +importScripts( + "resource://devtools/client/performance-new/shared/profiler_get_symbols.js" +); + +/** + * @typedef {import("../@types/perf").SymbolicationWorkerInitialMessage} SymbolicationWorkerInitialMessage + * @typedef {import("../@types/perf").FileHandle} FileHandle + */ + +// This worker uses the wasm module that was generated from https://github.com/mstange/profiler-get-symbols. +// See ProfilerGetSymbols.jsm for more information. +// +// The worker instantiates the module, reads the binary into wasm memory, runs +// the wasm code, and returns the symbol table or an error. Then it shuts down +// itself. + +/* eslint camelcase: 0*/ +const { getCompactSymbolTable, queryAPI } = wasm_bindgen; + +// Returns a plain object that is Structured Cloneable and has name and +// message properties. +function createPlainErrorObject(e) { + // Regular errors: just rewrap the object. + const { name, message, fileName, lineNumber } = e; + return { name, message, fileName, lineNumber }; +} + +/** + * A FileAndPathHelper object is passed to getCompactSymbolTable, which calls + * the methods `getCandidatePathsForBinaryOrPdb` and `readFile` on it. + */ +class FileAndPathHelper { + constructor(libInfoMap, objdirs) { + this._libInfoMap = libInfoMap; + this._objdirs = objdirs; + } + + /** + * Enumerate all paths at which we could find files with symbol information. + * This method is called by wasm code (via the bindings). + * + * @param {LibraryInfo} libraryInfo + * @returns {Array<string>} + */ + getCandidatePathsForDebugFile(libraryInfo) { + const { debugName, breakpadId } = libraryInfo; + const key = `${debugName}:${breakpadId}`; + const lib = this._libInfoMap.get(key); + if (!lib) { + throw new Error( + `Could not find the library for "${debugName}", "${breakpadId}".` + ); + } + + const { name, path, debugPath, arch } = lib; + const candidatePaths = []; + + // First, try to find a binary with a matching file name and breakpadId + // in one of the manually specified objdirs. + // This is needed if the debuggee is a build running on a remote machine that + // was compiled by the developer on *this* machine (the "host machine"). In + // that case, the objdir will contain the compiled binary with full symbol and + // debug information, whereas the binary on the device may not exist in + // uncompressed form or may have been stripped of debug information and some + // symbol information. + // An objdir, or "object directory", is a directory on the host machine that's + // used to store build artifacts ("object files") from the compilation process. + // This only works if the binary is one of the Gecko binaries and not + // a system library. + for (const objdirPath of this._objdirs) { + try { + // Binaries are usually expected to exist at objdir/dist/bin/filename. + candidatePaths.push(PathUtils.join(objdirPath, "dist", "bin", name)); + + // Also search in the "objdir" directory itself (not just in dist/bin). + // If, for some unforeseen reason, the relevant binary is not inside the + // objdirs dist/bin/ directory, this provides a way out because it lets the + // user specify the actual location. + candidatePaths.push(PathUtils.join(objdirPath, name)); + } catch (e) { + // PathUtils.join throws if objdirPath is not an absolute path. + // Ignore those invalid objdir paths. + } + } + + // Check the absolute paths of the library last. + // We do this after the objdir search because the library's path may point + // to a stripped binary, which will have fewer symbols than the original + // binaries in the objdir. + if (debugPath !== path) { + // We're on Windows, and debugPath points to a PDB file. + // On non-Windows, path and debugPath are always the same. + + // Check the PDB file before the binary because the PDB has the symbol + // information. The binary is only used as a last-ditch fallback + // for things like Windows system libraries (e.g. graphics drivers). + candidatePaths.push(debugPath); + } + + // The location of the binary. If the profile was obtained on this machine + // (and not, for example, on an Android device), this file should always + // exist. + candidatePaths.push(path); + + // On macOS, for system libraries, add a final fallback for the dyld shared + // cache. Starting with macOS 11, most system libraries are located in this + // system-wide cache file and not present as individual files. + if (arch && (path.startsWith("/usr/") || path.startsWith("/System/"))) { + // Use the special syntax `dyldcache:<dyldcachepath>:<librarypath>`. + + // Dyld cache location used on macOS 13+: + candidatePaths.push( + `dyldcache:/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_${arch}:${path}` + ); + // Dyld cache location used on macOS 11 and 12: + candidatePaths.push( + `dyldcache:/System/Library/dyld/dyld_shared_cache_${arch}:${path}` + ); + } + + return candidatePaths; + } + + /** + * Enumerate all paths at which we could find the binary which matches the + * given libraryInfo, in order to disassemble machine code. + * This method is called by wasm code (via the bindings). + * + * @param {LibraryInfo} libraryInfo + * @returns {Array<string>} + */ + getCandidatePathsForBinary(libraryInfo) { + const { debugName, breakpadId } = libraryInfo; + const key = `${debugName}:${breakpadId}`; + const lib = this._libInfoMap.get(key); + if (!lib) { + throw new Error( + `Could not find the library for "${debugName}", "${breakpadId}".` + ); + } + + const { name, path, arch } = lib; + const candidatePaths = []; + + // The location of the binary. If the profile was obtained on this machine + // (and not, for example, on an Android device), this file should always + // exist. + candidatePaths.push(path); + + // Fall back to searching in the manually specified objdirs. + // This is needed if the debuggee is a build running on a remote machine that + // was compiled by the developer on *this* machine (the "host machine"). In + // that case, the objdir will contain the compiled binary. + for (const objdirPath of this._objdirs) { + try { + // Binaries are usually expected to exist at objdir/dist/bin/filename. + candidatePaths.push(PathUtils.join(objdirPath, "dist", "bin", name)); + + // Also search in the "objdir" directory itself (not just in dist/bin). + // If, for some unforeseen reason, the relevant binary is not inside the + // objdirs dist/bin/ directory, this provides a way out because it lets the + // user specify the actual location. + candidatePaths.push(PathUtils.join(objdirPath, name)); + } catch (e) { + // PathUtils.join throws if objdirPath is not an absolute path. + // Ignore those invalid objdir paths. + } + } + + // On macOS, for system libraries, add a final fallback for the dyld shared + // cache. Starting with macOS 11, most system libraries are located in this + // system-wide cache file and not present as individual files. + if (arch && (path.startsWith("/usr/") || path.startsWith("/System/"))) { + // Use the special syntax `dyldcache:<dyldcachepath>:<librarypath>`. + + // Dyld cache location used on macOS 13+: + candidatePaths.push( + `dyldcache:/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_${arch}:${path}` + ); + // Dyld cache location used on macOS 11 and 12: + candidatePaths.push( + `dyldcache:/System/Library/dyld/dyld_shared_cache_${arch}:${path}` + ); + } + + return candidatePaths; + } + + /** + * Asynchronously prepare the file at `path` for synchronous reading. + * This method is called by wasm code (via the bindings). + * + * @param {string} path + * @returns {FileHandle} + */ + async readFile(path) { + const info = await IOUtils.stat(path); + if (info.type === "directory") { + throw new Error(`Path "${path}" is a directory.`); + } + + return IOUtils.openFileForSyncReading(path); + } +} + +/** @param {MessageEvent<SymbolicationWorkerInitialMessage>} e */ +onmessage = async e => { + try { + const { request, libInfoMap, objdirs, module } = e.data; + + if (!(module instanceof WebAssembly.Module)) { + throw new Error("invalid WebAssembly module"); + } + + // Instantiate the WASM module. + await wasm_bindgen(module); + + const helper = new FileAndPathHelper(libInfoMap, objdirs); + + switch (request.type) { + case "GET_SYMBOL_TABLE": { + const { debugName, breakpadId } = request; + const result = await getCompactSymbolTable( + debugName, + breakpadId, + helper + ); + postMessage( + { result }, + result.map(r => r.buffer) + ); + break; + } + case "QUERY_SYMBOLICATION_API": { + const { path, requestJson } = request; + const result = await queryAPI(path, requestJson, helper); + postMessage({ result }); + break; + } + default: + throw new Error(`Unexpected request type ${request.type}`); + } + } catch (error) { + postMessage({ error: createPlainErrorObject(error) }); + } + close(); +}; + +onunhandledrejection = e => { + // Unhandled rejections can happen if the WASM code throws a + // "RuntimeError: unreachable executed" exception, which can happen + // if the Rust code panics or runs out of memory. + // These panics currently are not propagated to the promise reject + // callback, see https://github.com/rustwasm/wasm-bindgen/issues/2724 . + // Ideally, the Rust code should never panic and handle all error + // cases gracefully. + e.preventDefault(); + postMessage({ error: createPlainErrorObject(e.reason) }); +}; + +// Catch any other unhandled errors, just to be sure. +onerror = e => { + postMessage({ error: createPlainErrorObject(e) }); +}; diff --git a/devtools/client/performance-new/shared/typescript-lazy-load.sys.mjs b/devtools/client/performance-new/shared/typescript-lazy-load.sys.mjs new file mode 100644 index 0000000000..bf11c2c1a3 --- /dev/null +++ b/devtools/client/performance-new/shared/typescript-lazy-load.sys.mjs @@ -0,0 +1,42 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check + +/** + * TypeScript can't understand the lazyRequireGetter mechanism, due to how it defines + * properties as a getter. This function, instead provides lazy loading in a + * TypeScript-friendly manner. It applies the lazy load memoization to each property + * of the provided object. + * + * Example usage: + * + * const lazy = createLazyLoaders({ + * moduleA: () => require("module/a"), + * moduleB: () => require("module/b"), + * }); + * + * Later: + * + * const moduleA = lazy.moduleA(); + * const { objectInModuleB } = lazy.moduleB(); + * + * @template {{ [key: string]: any }} T + * @param {T} definition - An object where each property has a function that loads a module. + * @returns {T} - The load memoized version of T. + */ +export function createLazyLoaders(definition) { + /** @type {any} */ + const result = {}; + for (const [key, callback] of Object.entries(definition)) { + /** @type {any} */ + let cache; + result[key] = () => { + if (cache === undefined) { + cache = callback(); + } + return cache; + }; + } + return result; +} diff --git a/devtools/client/performance-new/shared/utils.js b/devtools/client/performance-new/shared/utils.js new file mode 100644 index 0000000000..ae00b673de --- /dev/null +++ b/devtools/client/performance-new/shared/utils.js @@ -0,0 +1,580 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check +/** + * @typedef {import("../@types/perf").NumberScaler} NumberScaler + * @typedef {import("../@types/perf").ScaleFunctions} ScaleFunctions + * @typedef {import("../@types/perf").FeatureDescription} FeatureDescription + */ +"use strict"; + +const UNITS = ["B", "kiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]; + +const AppConstants = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +).AppConstants; + +/** + * Linearly interpolate between values. + * https://en.wikipedia.org/wiki/Linear_interpolation + * + * @param {number} frac - Value ranged 0 - 1 to interpolate between the range start and range end. + * @param {number} rangeStart - The value to start from. + * @param {number} rangeEnd - The value to interpolate to. + * @returns {number} + */ +function lerp(frac, rangeStart, rangeEnd) { + return (1 - frac) * rangeStart + frac * rangeEnd; +} + +/** + * Make sure a value is clamped between a min and max value. + * + * @param {number} val - The value to clamp. + * @param {number} min - The minimum value. + * @param {number} max - The max value. + * @returns {number} + */ +function clamp(val, min, max) { + return Math.max(min, Math.min(max, val)); +} + +/** + * Formats a file size. + * @param {number} num - The number (in bytes) to format. + * @returns {string} e.g. "10 B", "100 MiB" + */ +function formatFileSize(num) { + if (!Number.isFinite(num)) { + throw new TypeError(`Expected a finite number, got ${typeof num}: ${num}`); + } + + const neg = num < 0; + + if (neg) { + num = -num; + } + + if (num < 1) { + return (neg ? "-" : "") + num + " B"; + } + + const exponent = Math.min( + Math.floor(Math.log2(num) / Math.log2(1024)), + UNITS.length - 1 + ); + const numStr = Number((num / Math.pow(1024, exponent)).toPrecision(3)); + const unit = UNITS[exponent]; + + return (neg ? "-" : "") + numStr + " " + unit; +} + +/** + * Creates numbers that increment linearly within a base 10 scale: + * 0.1, 0.2, 0.3, ..., 0.8, 0.9, 1, 2, 3, ..., 9, 10, 20, 30, etc. + * + * @param {number} rangeStart + * @param {number} rangeEnd + * + * @returns {ScaleFunctions} + */ +function makeLinear10Scale(rangeStart, rangeEnd) { + const start10 = Math.log10(rangeStart); + const end10 = Math.log10(rangeEnd); + + if (!Number.isInteger(start10)) { + throw new Error(`rangeStart is not a power of 10: ${rangeStart}`); + } + + if (!Number.isInteger(end10)) { + throw new Error(`rangeEnd is not a power of 10: ${rangeEnd}`); + } + + // Intervals are base 10 intervals: + // - [0.01 .. 0.09] + // - [0.1 .. 0.9] + // - [1 .. 9] + // - [10 .. 90] + const intervals = end10 - start10; + + // Note that there are only 9 steps per interval, not 10: + // 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9 + const STEP_PER_INTERVAL = 9; + + const steps = intervals * STEP_PER_INTERVAL; + + /** @type {NumberScaler} */ + const fromFractionToValue = frac => { + const step = Math.round(frac * steps); + const base = Math.floor(step / STEP_PER_INTERVAL); + const factor = (step % STEP_PER_INTERVAL) + 1; + return Math.pow(10, base) * factor * rangeStart; + }; + + /** @type {NumberScaler} */ + const fromValueToFraction = value => { + const interval = Math.floor(Math.log10(value / rangeStart)); + const base = rangeStart * Math.pow(10, interval); + return (interval * STEP_PER_INTERVAL + value / base - 1) / steps; + }; + + /** @type {NumberScaler} */ + const fromFractionToSingleDigitValue = frac => { + return +fromFractionToValue(frac).toPrecision(1); + }; + + return { + // Takes a number ranged 0-1 and returns it within the range. + fromFractionToValue, + // Takes a number in the range, and returns a value between 0-1 + fromValueToFraction, + // Takes a number ranged 0-1 and returns a value in the range, but with + // a single digit value. + fromFractionToSingleDigitValue, + // The number of steps available on this scale. + steps, + }; +} + +/** + * Creates numbers that scale exponentially as powers of 2. + * + * @param {number} rangeStart + * @param {number} rangeEnd + * + * @returns {ScaleFunctions} + */ +function makePowerOf2Scale(rangeStart, rangeEnd) { + const startExp = Math.log2(rangeStart); + const endExp = Math.log2(rangeEnd); + + if (!Number.isInteger(startExp)) { + throw new Error(`rangeStart is not a power of 2: ${rangeStart}`); + } + + if (!Number.isInteger(endExp)) { + throw new Error(`rangeEnd is not a power of 2: ${rangeEnd}`); + } + + const steps = endExp - startExp; + + /** @type {NumberScaler} */ + const fromFractionToValue = frac => + Math.pow(2, Math.round((1 - frac) * startExp + frac * endExp)); + + /** @type {NumberScaler} */ + const fromValueToFraction = value => + (Math.log2(value) - startExp) / (endExp - startExp); + + /** @type {NumberScaler} */ + const fromFractionToSingleDigitValue = frac => { + // fromFractionToValue returns an exact power of 2, we don't want to change + // its precision. Note that formatFileSize will display it in a nice binary + // unit with up to 3 digits. + return fromFractionToValue(frac); + }; + + return { + // Takes a number ranged 0-1 and returns it within the range. + fromFractionToValue, + // Takes a number in the range, and returns a value between 0-1 + fromValueToFraction, + // Takes a number ranged 0-1 and returns a value in the range, but with + // a single digit value. + fromFractionToSingleDigitValue, + // The number of steps available on this scale. + steps, + }; +} + +/** + * Scale a source range to a destination range, but clamp it within the + * destination range. + * @param {number} val - The source range value to map to the destination range, + * @param {number} sourceRangeStart, + * @param {number} sourceRangeEnd, + * @param {number} destRangeStart, + * @param {number} destRangeEnd + */ +function scaleRangeWithClamping( + val, + sourceRangeStart, + sourceRangeEnd, + destRangeStart, + destRangeEnd +) { + const frac = clamp( + (val - sourceRangeStart) / (sourceRangeEnd - sourceRangeStart), + 0, + 1 + ); + return lerp(frac, destRangeStart, destRangeEnd); +} + +/** + * Use some heuristics to guess at the overhead of the recording settings. + * + * TODO - Bug 1597383. The UI for this has been removed, but it needs to be reworked + * for new overhead calculations. Keep it for now in tree. + * + * @param {number} interval + * @param {number} bufferSize + * @param {string[]} features - List of the selected features. + */ +function calculateOverhead(interval, bufferSize, features) { + // NOT "nostacksampling" (double negative) means periodic sampling is on. + const periodicSampling = !features.includes("nostacksampling"); + const overheadFromSampling = periodicSampling + ? scaleRangeWithClamping( + Math.log(interval), + Math.log(0.05), + Math.log(1), + 1, + 0 + ) + + scaleRangeWithClamping( + Math.log(interval), + Math.log(1), + Math.log(100), + 0.1, + 0 + ) + : 0; + const overheadFromBuffersize = scaleRangeWithClamping( + Math.log(bufferSize), + Math.log(10), + Math.log(1000000), + 0, + 0.1 + ); + const overheadFromStackwalk = + features.includes("stackwalk") && periodicSampling ? 0.05 : 0; + const overheadFromJavaScript = + features.includes("js") && periodicSampling ? 0.05 : 0; + const overheadFromJSTracer = features.includes("jstracer") ? 0.05 : 0; + const overheadFromJSAllocations = features.includes("jsallocations") + ? 0.05 + : 0; + const overheadFromNativeAllocations = features.includes("nativeallocations") + ? 0.5 + : 0; + + return clamp( + overheadFromSampling + + overheadFromBuffersize + + overheadFromStackwalk + + overheadFromJavaScript + + overheadFromJSTracer + + overheadFromJSAllocations + + overheadFromNativeAllocations, + 0, + 1 + ); +} + +/** + * Given an array of absolute paths on the file system, return an array that + * doesn't contain the common prefix of the paths; in other words, if all paths + * share a common ancestor directory, cut off the path to that ancestor + * directory and only leave the path components that differ. + * This makes some lists look a little nicer. For example, this turns the list + * ["/Users/foo/code/obj-m-android-opt", "/Users/foo/code/obj-m-android-debug"] + * into the list ["obj-m-android-opt", "obj-m-android-debug"]. + * + * @param {string[]} pathArray The array of absolute paths. + * @returns {string[]} A new array with the described adjustment. + */ +function withCommonPathPrefixRemoved(pathArray) { + if (pathArray.length === 0) { + return []; + } + + const firstPath = pathArray[0]; + const isWin = /^[A-Za-z]:/.test(firstPath); + const firstWinDrive = getWinDrive(firstPath); + for (const path of pathArray) { + const winDrive = getWinDrive(path); + + if (!PathUtils.isAbsolute(path) || winDrive !== firstWinDrive) { + // We expect all paths to be absolute and on Windows we expect all + // paths to be on the same disk. If this is not the case return the + // original array. + return pathArray; + } + } + + // At this point we're either not on Windows or all paths are on the same + // Windows disk and all paths are absolute. + // Find the common prefix. Start by assuming the entire path except for the + // last folder is shared. + const splitPaths = pathArray.map(path => PathUtils.split(path)); + const [firstSplitPath, ...otherSplitPaths] = splitPaths; + const prefix = firstSplitPath.slice(0, -1); + for (const sp of otherSplitPaths) { + prefix.length = Math.min(prefix.length, sp.length - 1); + for (let i = 0; i < prefix.length; i++) { + if (prefix[i] !== sp[i]) { + prefix.length = i; + break; + } + } + } + if ( + prefix.length === 0 || + (prefix.length === 1 && (prefix[0] === firstWinDrive || prefix[0] === "/")) + ) { + // There is no shared prefix. + // We treat a prefix of ["/"] as "no prefix", too: Absolute paths on + // non-Windows start with a slash, so PathUtils.split(path) always returns + // an array whose first element is "/" on those platforms. + // Stripping off a prefix of ["/"] from the split paths would simply remove + // the leading slash from the un-split paths, which is not useful. + return pathArray; + } + + // Strip the common prefix from all paths. + return splitPaths.map(sp => { + return sp.slice(prefix.length).join(isWin ? "\\" : "/"); + }); +} + +/** + * This method has been copied from `ospath_win.jsm` as part of the migration + * from `OS.Path` to `PathUtils`. + * + * Return the windows drive name of a path, or |null| if the path does + * not contain a drive name. + * + * Drive name appear either as "DriveName:..." (the return drive + * name includes the ":") or "\\\\DriveName..." (the returned drive name + * includes "\\\\"). + * + * @param {string} path The path from which we are to return the Windows drive name. + * @returns {?string} Windows drive name e.g. "C:" or null if path is not a Windows path. + */ +function getWinDrive(path) { + if (path == null) { + throw new TypeError("path is invalid"); + } + + if (path.startsWith("\\\\")) { + // UNC path + if (path.length == 2) { + return null; + } + const index = path.indexOf("\\", 2); + if (index == -1) { + return path; + } + return path.slice(0, index); + } + // Non-UNC path + const index = path.indexOf(":"); + if (index <= 0) { + return null; + } + return path.slice(0, index + 1); +} + +class UnhandledCaseError extends Error { + /** + * @param {never} value - Check that + * @param {string} typeName - A friendly type name. + */ + constructor(value, typeName) { + super(`There was an unhandled case for "${typeName}": ${value}`); + this.name = "UnhandledCaseError"; + } +} + +/** + * @type {FeatureDescription[]} + */ +const featureDescriptions = [ + { + name: "Native Stacks", + value: "stackwalk", + title: + "Record native stacks (C++ and Rust). This is not available on all platforms.", + recommended: true, + disabledReason: "Native stack walking is not supported on this platform.", + }, + { + name: "JavaScript", + value: "js", + title: + "Record JavaScript stack information, and interleave it with native stacks.", + recommended: true, + }, + { + name: "CPU Utilization", + value: "cpu", + title: + "Record how much CPU has been used between samples by each profiled thread.", + recommended: true, + }, + { + name: "Java", + value: "java", + title: "Profile Java code", + disabledReason: "This feature is only available on Android.", + }, + { + name: "No Periodic Sampling", + value: "nostacksampling", + title: "Disable interval-based stack sampling", + }, + { + name: "Main Thread File IO", + value: "mainthreadio", + title: "Record main thread File I/O markers.", + }, + { + name: "Profiled Threads File IO", + value: "fileio", + title: "Record File I/O markers from only profiled threads.", + }, + { + name: "All File IO", + value: "fileioall", + title: + "Record File I/O markers from all threads, even unregistered threads.", + }, + { + name: "No Marker Stacks", + value: "nomarkerstacks", + title: "Do not capture stacks when recording markers, to reduce overhead.", + }, + { + name: "Sequential Styling", + value: "seqstyle", + title: "Disable parallel traversal in styling.", + }, + { + name: "Screenshots", + value: "screenshots", + title: "Record screenshots of all browser windows.", + }, + { + name: "JSTracer", + value: "jstracer", + title: "Trace JS engine", + experimental: true, + disabledReason: + "JS Tracer is currently disabled due to crashes. See Bug 1565788.", + }, + { + name: "IPC Messages", + value: "ipcmessages", + title: "Track IPC messages.", + }, + { + name: "JS Allocations", + value: "jsallocations", + title: "Track JavaScript allocations", + }, + { + name: "Native Allocations", + value: "nativeallocations", + title: "Track native allocations", + }, + { + name: "Audio Callback Tracing", + value: "audiocallbacktracing", + title: "Trace real-time audio callbacks.", + }, + { + name: "No Timer Resolution Change", + value: "notimerresolutionchange", + title: + "Do not enhance the timer resolution for sampling intervals < 10ms, to " + + "avoid affecting timer-sensitive code. Warning: Sampling interval may " + + "increase in some processes.", + disabledReason: "Windows only.", + }, + { + name: "CPU Utilization - All Threads", + value: "cpuallthreads", + title: + "Record CPU usage of all known threads, even threads which are not being profiled.", + experimental: true, + }, + { + name: "Periodic Sampling - All Threads", + value: "samplingallthreads", + title: "Capture stack samples in ALL registered thread.", + experimental: true, + }, + { + name: "Markers - All Threads", + value: "markersallthreads", + title: "Record markers in ALL registered threads.", + experimental: true, + }, + { + name: "Unregistered Threads", + value: "unregisteredthreads", + title: + "Periodically discover unregistered threads and record them and their " + + "CPU utilization as markers in the main thread -- Beware: expensive!", + experimental: true, + }, + { + name: "Process CPU Utilization", + value: "processcpu", + title: + "Record how much CPU has been used between samples by each process. " + + "To see graphs: When viewing the profile, open the JS console and run: " + + "experimental.enableProcessCPUTracks()", + experimental: true, + }, + { + name: "Power Use", + value: "power", + title: (() => { + switch (AppConstants.platform) { + case "win": + return ( + "Record the value of every energy meter available on the system with " + + "each sample. Only available on Windows 11 with Intel CPUs." + ); + case "linux": + return ( + "Record the power used by the entire system with each sample. " + + "Only available with Intel CPUs and requires setting the sysctl kernel.perf_event_paranoid to 0." + ); + case "macosx": + return "Record the power used by the entire system (Intel) or each process (Apple Silicon) with each sample."; + default: + return "Not supported on this platform."; + } + })(), + experimental: true, + }, + { + name: "CPU Frequency", + value: "cpufreq", + title: + "Record the clock frequency of every CPU core for every profiler sample.", + experimental: true, + disabledReason: + "This feature is only available on Windows, Linux and Android.", + }, + { + name: "Network Bandwidth", + value: "bandwidth", + title: "Record the network bandwidth used between every profiler sample.", + }, +]; + +module.exports = { + formatFileSize, + makeLinear10Scale, + makePowerOf2Scale, + scaleRangeWithClamping, + calculateOverhead, + withCommonPathPrefixRemoved, + UnhandledCaseError, + featureDescriptions, +}; diff --git a/devtools/client/performance-new/store/README.md b/devtools/client/performance-new/store/README.md new file mode 100644 index 0000000000..dc6396bc6b --- /dev/null +++ b/devtools/client/performance-new/store/README.md @@ -0,0 +1,3 @@ +# Performance New Store + +This folder contains the Redux store logic for both the DevTools Panel and about:profiling. The store is NOT used for the popup, which does not use React / Redux. diff --git a/devtools/client/performance-new/store/actions.js b/devtools/client/performance-new/store/actions.js new file mode 100644 index 0000000000..2bb7ce126c --- /dev/null +++ b/devtools/client/performance-new/store/actions.js @@ -0,0 +1,218 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check +"use strict"; + +const selectors = require("resource://devtools/client/performance-new/store/selectors.js"); + +/** + * @typedef {import("../@types/perf").Action} Action + * @typedef {import("../@types/perf").Library} Library + * @typedef {import("../@types/perf").PerfFront} PerfFront + * @typedef {import("../@types/perf").SymbolTableAsTuple} SymbolTableAsTuple + * @typedef {import("../@types/perf").RecordingState} RecordingState + * @typedef {import("../@types/perf").InitializeStoreValues} InitializeStoreValues + * @typedef {import("../@types/perf").RecordingSettings} RecordingSettings + * @typedef {import("../@types/perf").Presets} Presets + * @typedef {import("../@types/perf").PanelWindow} PanelWindow + * @typedef {import("../@types/perf").MinimallyTypedGeckoProfile} MinimallyTypedGeckoProfile + */ + +/** + * @template T + * @typedef {import("../@types/perf").ThunkAction<T>} ThunkAction<T> + */ + +/** + * This is the result of the initial questions about the state of the profiler. + * + * @param {boolean} isActive + * @return {Action} + */ +exports.reportProfilerReady = isActive => ({ + type: "REPORT_PROFILER_READY", + isActive, +}); + +/** + * Dispatched when the profiler starting is observed. + * @return {Action} + */ +exports.reportProfilerStarted = () => ({ + type: "REPORT_PROFILER_STARTED", +}); + +/** + * Dispatched when the profiler stopping is observed. + * @return {Action} + */ +exports.reportProfilerStopped = () => ({ + type: "REPORT_PROFILER_STOPPED", +}); + +/** + * Updates the recording settings for the interval. + * @param {number} interval + * @return {Action} + */ +exports.changeInterval = interval => ({ + type: "CHANGE_INTERVAL", + interval, +}); + +/** + * Updates the recording settings for the entries. + * @param {number} entries + * @return {Action} + */ +exports.changeEntries = entries => ({ + type: "CHANGE_ENTRIES", + entries, +}); + +/** + * Updates the recording settings for the features. + * @param {string[]} features + * @return {ThunkAction<void>} + */ +exports.changeFeatures = features => { + return ({ dispatch, getState }) => { + let promptEnvRestart = null; + if (selectors.getPageContext(getState()) === "aboutprofiling") { + // TODO Bug 1615431 - The old popup supported restarting the browser, but + // this hasn't been updated yet for the about:profiling workflow, because + // jstracer is disabled for now. + if ( + !Services.env.get("JS_TRACE_LOGGING") && + features.includes("jstracer") + ) { + promptEnvRestart = "JS_TRACE_LOGGING"; + } + } + + dispatch({ + type: "CHANGE_FEATURES", + features, + promptEnvRestart, + }); + }; +}; + +/** + * Updates the recording settings for the threads. + * @param {string[]} threads + * @return {Action} + */ +exports.changeThreads = threads => ({ + type: "CHANGE_THREADS", + threads, +}); + +/** + * Change the preset. + * @param {Presets} presets + * @param {string} presetName + * @return {Action} + */ +exports.changePreset = (presets, presetName) => ({ + type: "CHANGE_PRESET", + presetName, + // Also dispatch the preset so that the reducers can pre-fill the values + // from a preset. + preset: presets[presetName], +}); + +/** + * Updates the recording settings for the objdirs. + * @param {string[]} objdirs + * @return {Action} + */ +exports.changeObjdirs = objdirs => ({ + type: "CHANGE_OBJDIRS", + objdirs, +}); + +/** + * Receive the values to initialize the store. See the reducer for what values + * are expected. + * @param {InitializeStoreValues} values + * @return {Action} + */ +exports.initializeStore = values => { + return { + type: "INITIALIZE_STORE", + ...values, + }; +}; + +/** + * Whenever the preferences are updated, this action is dispatched to update the + * redux store. + * @param {RecordingSettings} recordingSettingsFromPreferences + * @return {Action} + */ +exports.updateSettingsFromPreferences = recordingSettingsFromPreferences => { + return { + type: "UPDATE_SETTINGS_FROM_PREFERENCES", + recordingSettingsFromPreferences, + }; +}; + +/** + * Start a new recording with the perfFront and update the internal recording state. + * @param {PerfFront} perfFront + * @return {ThunkAction<void>} + */ +exports.startRecording = perfFront => { + return ({ dispatch, getState }) => { + const recordingSettings = selectors.getRecordingSettings(getState()); + // In the case of the profiler popup, the startProfiler can be synchronous. + // In order to properly allow the React components to handle the state changes + // make sure and change the recording state first, then start the profiler. + dispatch({ type: "REQUESTING_TO_START_RECORDING" }); + perfFront.startProfiler(recordingSettings); + }; +}; + +/** + * Stops the profiler, and opens the profile in a new window. + * @param {PerfFront} perfFront + * @return {ThunkAction<Promise<MinimallyTypedGeckoProfile>>} + */ +exports.getProfileAndStopProfiler = perfFront => { + return async ({ dispatch, getState }) => { + dispatch({ type: "REQUESTING_PROFILE" }); + const profile = await perfFront.getProfileAndStopProfiler(); + dispatch({ type: "OBTAINED_PROFILE" }); + return profile; + }; +}; + +/** + * Stops the profiler, but does not try to retrieve the profile. + * @param {PerfFront} perfFront + * @return {ThunkAction<void>} + */ +exports.stopProfilerAndDiscardProfile = perfFront => { + return async ({ dispatch, getState }) => { + dispatch({ type: "REQUESTING_TO_STOP_RECORDING" }); + + try { + await perfFront.stopProfilerAndDiscardProfile(); + } catch (error) { + /** @type {any} */ + const anyWindow = window; + /** @type {PanelWindow} - Coerce the window into the PanelWindow. */ + const { gIsPanelDestroyed } = anyWindow; + + if (gIsPanelDestroyed) { + // This error is most likely "Connection closed, pending request" as the + // command can race with closing the panel. Do not report an error. It's + // most likely fine. + } else { + throw error; + } + } + }; +}; diff --git a/devtools/client/performance-new/store/moz.build b/devtools/client/performance-new/store/moz.build new file mode 100644 index 0000000000..16c3f8c65a --- /dev/null +++ b/devtools/client/performance-new/store/moz.build @@ -0,0 +1,10 @@ +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "actions.js", + "reducers.js", + "selectors.js", +) diff --git a/devtools/client/performance-new/store/reducers.js b/devtools/client/performance-new/store/reducers.js new file mode 100644 index 0000000000..3ace0f8d16 --- /dev/null +++ b/devtools/client/performance-new/store/reducers.js @@ -0,0 +1,332 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check +"use strict"; + +/** + * @typedef {import("../@types/perf").Action} Action + * @typedef {import("../@types/perf").State} State + * @typedef {import("../@types/perf").RecordingState} RecordingState + * @typedef {import("../@types/perf").InitializedValues} InitializedValues + * @typedef {import("../@types/perf").RecordingSettings} RecordingSettings + */ + +/** + * @template S + * @typedef {import("../@types/perf").Reducer<S>} Reducer<S> + */ + +/** + * The current state of the recording. + * @type {Reducer<RecordingState>} + */ +// eslint-disable-next-line complexity +function recordingState(state = "not-yet-known", action) { + switch (action.type) { + case "REPORT_PROFILER_READY": { + // It's theoretically possible we got an event that already let us know about + // the current state of the profiler. + if (state !== "not-yet-known") { + return state; + } + + const { isActive } = action; + if (isActive) { + return "recording"; + } + return "available-to-record"; + } + + case "REPORT_PROFILER_STARTED": + switch (state) { + case "not-yet-known": + // We couldn't have started it yet, so it must have been someone + // else. (fallthrough) + case "available-to-record": + // We aren't recording, someone else started it up. (fallthrough) + case "request-to-stop-profiler": + // We requested to stop the profiler, but someone else already started + // it up. (fallthrough) + case "request-to-get-profile-and-stop-profiler": + return "recording"; + + case "request-to-start-recording": + // Wait for the profiler to tell us that it has started. + return "recording"; + + case "recording": + // These state cases don't make sense to happen, and means we have a logical + // fallacy somewhere. + throw new Error( + "The profiler started recording, when it shouldn't have " + + `been able to. Current state: "${state}"` + ); + default: + throw new Error("Unhandled recording state"); + } + + case "REPORT_PROFILER_STOPPED": + switch (state) { + case "not-yet-known": + case "request-to-get-profile-and-stop-profiler": + case "request-to-stop-profiler": + return "available-to-record"; + + case "request-to-start-recording": + // Highly unlikely, but someone stopped the recorder, this is fine. + // Do nothing. + return state; + + case "recording": + return "available-to-record"; + + case "available-to-record": + throw new Error( + "The profiler stopped recording, when it shouldn't have been able to." + ); + default: + throw new Error("Unhandled recording state"); + } + + case "REQUESTING_TO_START_RECORDING": + return "request-to-start-recording"; + + case "REQUESTING_TO_STOP_RECORDING": + return "request-to-stop-profiler"; + + case "REQUESTING_PROFILE": + return "request-to-get-profile-and-stop-profiler"; + + case "OBTAINED_PROFILE": + return "available-to-record"; + + default: + return state; + } +} + +/** + * Whether or not the recording state unexpectedly stopped. This allows + * the UI to display a helpful message. + * @param {RecordingState | undefined} recState + * @param {boolean} state + * @param {Action} action + * @returns {boolean} + */ +function recordingUnexpectedlyStopped(recState, state = false, action) { + switch (action.type) { + case "REPORT_PROFILER_STOPPED": + if ( + recState === "recording" || + recState == "request-to-start-recording" + ) { + return true; + } + return state; + case "REPORT_PROFILER_STARTED": + return false; + default: + return state; + } +} + +/** + * The profiler needs to be queried asynchronously on whether or not + * it supports the user's platform. + * @type {Reducer<boolean | null>} + */ +function isSupportedPlatform(state = null, action) { + switch (action.type) { + case "INITIALIZE_STORE": + return action.isSupportedPlatform; + default: + return state; + } +} + +/** + * This object represents the default recording settings. They should be + * overriden by whatever is read from the Firefox preferences at load time. + * @type {RecordingSettings} + */ +const DEFAULT_RECORDING_SETTINGS = { + // The preset name. + presetName: "", + // The setting for the recording interval. Defaults to 1ms. + interval: 1, + // The number of entries in the profiler's circular buffer. + entries: 0, + // The features that are enabled for the profiler. + features: [], + // The thread list + threads: [], + // The objdirs list + objdirs: [], + // The client doesn't implement durations yet. See Bug 1587165. + duration: 0, +}; + +/** + * This small utility returns true if the parameters contain the same values. + * This is essentially a deepEqual operation specific to this structure. + * @param {RecordingSettings} a + * @param {RecordingSettings} b + * @return {boolean} + */ +function areSettingsEquals(a, b) { + if (a === b) { + return true; + } + + /* Simple properties */ + /* These types look redundant, but they actually help TypeScript assess that + * the following code is correct, as well as prevent typos. */ + /** @type {Array<"presetName" | "interval" | "entries" | "duration">} */ + const simpleProperties = ["presetName", "interval", "entries", "duration"]; + + /* arrays */ + /** @type {Array<"features" | "threads" | "objdirs">} */ + const arrayProperties = ["features", "threads", "objdirs"]; + + for (const property of simpleProperties) { + if (a[property] !== b[property]) { + return false; + } + } + + for (const property of arrayProperties) { + if (a[property].length !== b[property].length) { + return false; + } + + const arrayA = a[property].slice().sort(); + const arrayB = b[property].slice().sort(); + if (arrayA.some((valueA, i) => valueA !== arrayB[i])) { + return false; + } + } + + return true; +} + +/** + * This handles all values used as recording settings. + * @type {Reducer<RecordingSettings>} + */ +function recordingSettings(state = DEFAULT_RECORDING_SETTINGS, action) { + /** + * @template {keyof RecordingSettings} K + * @param {K} settingName + * @param {RecordingSettings[K]} settingValue + * @return {RecordingSettings} + */ + function changeOneSetting(settingName, settingValue) { + if (state[settingName] === settingValue) { + // Do not change the state if the new value equals the old value. + return state; + } + + return { + ...state, + [settingName]: settingValue, + presetName: "custom", + }; + } + + switch (action.type) { + case "CHANGE_INTERVAL": + return changeOneSetting("interval", action.interval); + case "CHANGE_ENTRIES": + return changeOneSetting("entries", action.entries); + case "CHANGE_FEATURES": + return changeOneSetting("features", action.features); + case "CHANGE_THREADS": + return changeOneSetting("threads", action.threads); + case "CHANGE_OBJDIRS": + return changeOneSetting("objdirs", action.objdirs); + case "CHANGE_PRESET": + return action.preset + ? { + ...state, + presetName: action.presetName, + interval: action.preset.interval, + entries: action.preset.entries, + features: action.preset.features, + threads: action.preset.threads, + // The client doesn't implement durations yet. See Bug 1587165. + duration: action.preset.duration, + } + : { + ...state, + presetName: action.presetName, // it's probably "custom". + }; + case "UPDATE_SETTINGS_FROM_PREFERENCES": + if (areSettingsEquals(state, action.recordingSettingsFromPreferences)) { + return state; + } + return { ...action.recordingSettingsFromPreferences }; + default: + return state; + } +} + +/** + * These are all the values used to initialize the profiler. They should never + * change once added to the store. + * + * @type {Reducer<InitializedValues | null>} + */ +function initializedValues(state = null, action) { + switch (action.type) { + case "INITIALIZE_STORE": + return { + presets: action.presets, + pageContext: action.pageContext, + supportedFeatures: action.supportedFeatures, + openRemoteDevTools: action.openRemoteDevTools, + }; + default: + return state; + } +} + +/** + * Some features may need a browser restart with an environment flag. Request + * one here. + * + * @type {Reducer<string | null>} + */ +function promptEnvRestart(state = null, action) { + switch (action.type) { + case "CHANGE_FEATURES": + return action.promptEnvRestart; + default: + return state; + } +} + +/** + * The main reducer for the performance-new client. + * @type {Reducer<State>} + */ +module.exports = (state = undefined, action) => { + return { + recordingState: recordingState(state?.recordingState, action), + + // Treat this one specially - it also gets the recordingState. + recordingUnexpectedlyStopped: recordingUnexpectedlyStopped( + state?.recordingState, + state?.recordingUnexpectedlyStopped, + action + ), + + isSupportedPlatform: isSupportedPlatform( + state?.isSupportedPlatform, + action + ), + recordingSettings: recordingSettings(state?.recordingSettings, action), + initializedValues: initializedValues(state?.initializedValues, action), + promptEnvRestart: promptEnvRestart(state?.promptEnvRestart, action), + }; +}; diff --git a/devtools/client/performance-new/store/selectors.js b/devtools/client/performance-new/store/selectors.js new file mode 100644 index 0000000000..91f5bc9b65 --- /dev/null +++ b/devtools/client/performance-new/store/selectors.js @@ -0,0 +1,104 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @ts-check +"use strict"; + +/** + * @typedef {import("../@types/perf").RecordingState} RecordingState + * @typedef {import("../@types/perf").RecordingSettings} RecordingSettings + * @typedef {import("../@types/perf").InitializedValues} InitializedValues + * @typedef {import("../@types/perf").PerfFront} PerfFront + * @typedef {import("../@types/perf").ReceiveProfile} ReceiveProfile + * @typedef {import("../@types/perf").RestartBrowserWithEnvironmentVariable} RestartBrowserWithEnvironmentVariable + * @typedef {import("../@types/perf").PageContext} PageContext + * @typedef {import("../@types/perf").Presets} Presets + */ +/** + * @template S + * @typedef {import("../@types/perf").Selector<S>} Selector<S> + */ + +/** @type {Selector<RecordingState>} */ +const getRecordingState = state => state.recordingState; + +/** @type {Selector<boolean>} */ +const getRecordingUnexpectedlyStopped = state => + state.recordingUnexpectedlyStopped; + +/** @type {Selector<boolean | null>} */ +const getIsSupportedPlatform = state => state.isSupportedPlatform; + +/** @type {Selector<RecordingSettings>} */ +const getRecordingSettings = state => state.recordingSettings; + +/** @type {Selector<number>} */ +const getInterval = state => getRecordingSettings(state).interval; + +/** @type {Selector<number>} */ +const getEntries = state => getRecordingSettings(state).entries; + +/** @type {Selector<string[]>} */ +const getFeatures = state => getRecordingSettings(state).features; + +/** @type {Selector<string[]>} */ +const getThreads = state => getRecordingSettings(state).threads; + +/** @type {Selector<string>} */ +const getThreadsString = state => getThreads(state).join(","); + +/** @type {Selector<string[]>} */ +const getObjdirs = state => getRecordingSettings(state).objdirs; + +/** @type {Selector<Presets>} */ +const getPresets = state => getInitializedValues(state).presets; + +/** @type {Selector<string>} */ +const getPresetName = state => state.recordingSettings.presetName; + +/** + * When remote profiling, there will be a back button to the settings. + * + * @type {Selector<(() => void) | undefined>} + */ +const getOpenRemoteDevTools = state => + getInitializedValues(state).openRemoteDevTools; + +/** @type {Selector<InitializedValues>} */ +const getInitializedValues = state => { + const values = state.initializedValues; + if (!values) { + throw new Error("The store must be initialized before it can be used."); + } + return values; +}; + +/** @type {Selector<PageContext>} */ +const getPageContext = state => getInitializedValues(state).pageContext; + +/** @type {Selector<string[]>} */ +const getSupportedFeatures = state => + getInitializedValues(state).supportedFeatures; + +/** @type {Selector<string | null>} */ +const getPromptEnvRestart = state => state.promptEnvRestart; + +module.exports = { + getRecordingState, + getRecordingUnexpectedlyStopped, + getIsSupportedPlatform, + getInterval, + getEntries, + getFeatures, + getThreads, + getThreadsString, + getObjdirs, + getPresets, + getPresetName, + getOpenRemoteDevTools, + getRecordingSettings, + getInitializedValues, + getPageContext, + getPromptEnvRestart, + getSupportedFeatures, +}; diff --git a/devtools/client/performance-new/test/browser/browser.toml b/devtools/client/performance-new/test/browser/browser.toml new file mode 100644 index 0000000000..3440bb7880 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser.toml @@ -0,0 +1,77 @@ +[DEFAULT] +prefs = ["devtools.performance.recording.ui-base-url='http://example.com'"] # This sets up the WebChannel so that it can be used for our tests. +tags = "devtools devtools-performance" +subsuite = "devtools" +skip-if = [ + "tsan", # Bug 1804081, timeouts and data races in various tests + "http3", # Bug 1829298 + "http2", +] +support-files = [ + "head.js", + "helpers.js", + "fake-frontend.html", + "webchannel.html", +] + +["browser_aboutprofiling-entries.js"] + +["browser_aboutprofiling-env-restart-button.js"] + +["browser_aboutprofiling-features-disabled.js"] + +["browser_aboutprofiling-features.js"] + +["browser_aboutprofiling-interval.js"] + +["browser_aboutprofiling-presets-custom.js"] + +["browser_aboutprofiling-presets.js"] + +["browser_aboutprofiling-rtl.js"] + +["browser_aboutprofiling-threads-behavior.js"] + +["browser_aboutprofiling-threads.js"] + +["browser_devtools-interrupted.js"] + +["browser_devtools-onboarding.js"] + +["browser_devtools-presets.js"] +skip-if = ["a11y_checks"] # Bug 1849028 and 1849179 for causing crashes + +["browser_devtools-previously-started.js"] + +["browser_devtools-record-capture.js"] +https_first_disabled = true +skip-if = ["a11y_checks"] # Bug 1849028 and 1849179 for causing crashes + +["browser_devtools-record-discard.js"] + +["browser_interaction-between-interfaces.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_popup-profiler-states.js"] +https_first_disabled = true + +["browser_popup-record-capture-view.js"] +https_first_disabled = true +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_popup-record-capture.js"] +https_first_disabled = true +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_popup-record-discard.js"] +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_split-toolbar-button.js"] +https_first_disabled = true +fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and/or labeled + +["browser_webchannel-enable-menu-button-preset.js"] +https_first_disabled = true + +["browser_webchannel-enable-menu-button.js"] +https_first_disabled = true diff --git a/devtools/client/performance-new/test/browser/browser_aboutprofiling-entries.js b/devtools/client/performance-new/test/browser/browser_aboutprofiling-entries.js new file mode 100644 index 0000000000..4415387b16 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_aboutprofiling-entries.js @@ -0,0 +1,28 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function test() { + info("Test that about:profiling can modify the sampling interval."); + + await withAboutProfiling(async document => { + is( + getActiveConfiguration().capacity, + 128 * 1024 * 1024, + "The active configuration is set to a specific number initially. If this" + + " test fails here, then the magic numbers here may need to be adjusted." + ); + + info("Change the buffer input to an arbitrarily smaller value."); + const bufferInput = await getNearestInputFromText(document, "Buffer size:"); + setReactFriendlyInputValue(bufferInput, Number(bufferInput.value) * 0.1); + + is( + getActiveConfiguration().capacity, + 256 * 1024, + "The capacity changed to a smaller value." + ); + }); +}); diff --git a/devtools/client/performance-new/test/browser/browser_aboutprofiling-env-restart-button.js b/devtools/client/performance-new/test/browser/browser_aboutprofiling-env-restart-button.js new file mode 100644 index 0000000000..28ea3798a2 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_aboutprofiling-env-restart-button.js @@ -0,0 +1,78 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function test() { + info( + "Test that the popup offers to restart the browser to set an enviroment flag." + ); + + if (!Services.profiler.GetFeatures().includes("jstracer")) { + ok( + true, + "JS tracer is not supported on this platform, or is currently disabled. Skip the rest of the test." + ); + return; + } + + { + info("Ensure that JS Tracer is not currently enabled."); + ok( + !Services.env.get("JS_TRACE_LOGGING"), + "The JS_TRACE_LOGGING is not currently enabled." + ); + } + + ok( + false, + "This test was migrated from the initial popup implementation to " + + "about:profiling, however JS Tracer was disabled at the time. When " + + "re-enabling JS Tracer, please audit that this text works as expected, " + + "especially in the UI." + ); + + await withAboutProfiling(async document => { + { + info( + "Test that there is offer to restart the browser when first loading up the popup." + ); + const noRestartButton = maybeGetElementFromDocumentByText( + document, + "Restart" + ); + ok(!noRestartButton, "There is no button to restart the browser."); + } + + const jsTracerFeature = await getElementFromDocumentByText( + document, + "JSTracer" + ); + + { + info("Toggle the jstracer feature on."); + jsTracerFeature.click(); + + const restartButton = await getElementFromDocumentByText( + document, + "Restart" + ); + ok( + restartButton, + "There is now a button to offer to restart the browser" + ); + } + + { + info("Toggle the jstracer feature back off."); + jsTracerFeature.click(); + + const noRestartButton = maybeGetElementFromDocumentByText( + document, + "Restart" + ); + ok(!noRestartButton, "The offer to restart the browser goes away."); + } + }); +}); diff --git a/devtools/client/performance-new/test/browser/browser_aboutprofiling-features-disabled.js b/devtools/client/performance-new/test/browser/browser_aboutprofiling-features-disabled.js new file mode 100644 index 0000000000..ce91f42167 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_aboutprofiling-features-disabled.js @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function test() { + info( + "Test that features that are disabled on the platform are disabled in about:profiling." + ); + + const supportedFeatures = Services.profiler.GetFeatures(); + const allFeatures = Services.profiler.GetAllFeatures(); + const unsupportedFeatures = allFeatures.filter( + feature => !supportedFeatures.includes(feature) + ); + + if (unsupportedFeatures.length === 0) { + ok(true, "This platform has no unsupported features. Skip this test."); + return; + } + + await withAboutProfiling(async document => { + { + info("Find and click a supported feature to toggle it."); + const [firstSupportedFeature] = supportedFeatures; + const checkbox = getFeatureCheckbox(document, firstSupportedFeature); + const initialValue = checkbox.checked; + info("Click the supported checkbox."); + checkbox.click(); + is( + initialValue, + !checkbox.checked, + "A supported feature can be toggled." + ); + checkbox.click(); + } + + { + info("Find and click an unsupported feature, it should be disabled."); + const [firstUnsupportedFeature] = unsupportedFeatures; + const checkbox = getFeatureCheckbox(document, firstUnsupportedFeature); + is(checkbox.checked, false, "The unsupported feature is not checked."); + + info("Click the unsupported checkbox."); + checkbox.click(); + is(checkbox.checked, false, "After clicking it, it's still not checked."); + } + }); +}); + +/** + * @param {HTMLDocument} document + * @param {string} feature + * @return {HTMLElement} + */ +function getFeatureCheckbox(document, feature) { + const element = document.querySelector(`input[value="${feature}"]`); + if (!element) { + throw new Error("Could not find the checkbox for the feature: " + feature); + } + return element; +} diff --git a/devtools/client/performance-new/test/browser/browser_aboutprofiling-features.js b/devtools/client/performance-new/test/browser/browser_aboutprofiling-features.js new file mode 100644 index 0000000000..2ee07f5ad0 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_aboutprofiling-features.js @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function test() { + info("Test that about:profiling can be loaded, and the features changed."); + + ok( + Services.profiler.GetFeatures().includes("js"), + "This test assumes that the JavaScript feature is available on every platform." + ); + + await withAboutProfiling(async document => { + const jsInput = await getNearestInputFromText(document, "JavaScript"); + + ok( + activeConfigurationHasFeature("js"), + "By default, the JS feature is always enabled." + ); + ok(jsInput.checked, "The JavaScript input is checked when enabled."); + + jsInput.click(); + + ok( + !activeConfigurationHasFeature("js"), + "The JS feature can be toggled off." + ); + ok(!jsInput.checked, "The JS feature's input element is also toggled off."); + }); +}); diff --git a/devtools/client/performance-new/test/browser/browser_aboutprofiling-interval.js b/devtools/client/performance-new/test/browser/browser_aboutprofiling-interval.js new file mode 100644 index 0000000000..6dcc4e1156 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_aboutprofiling-interval.js @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +add_task(async function test() { + info("Test that about:profiling can modify the sampling interval."); + + await withAboutProfiling(async document => { + const intervalInput = await getNearestInputFromText( + document, + "Sampling interval:" + ); + is( + getActiveConfiguration().interval, + 1, + "The active configuration's interval is set to a specific number initially." + ); + is( + intervalInput.getAttribute("aria-valuemin"), + "0.01", + "aria-valuemin has the expected value" + ); + is( + intervalInput.getAttribute("aria-valuemax"), + "100", + "aria-valuemax has the expected value" + ); + is( + intervalInput.getAttribute("aria-valuenow"), + "1", + "aria-valuenow has the expected value" + ); + + info( + "Increase the interval by an arbitrary amount. The input range will " + + "scale that to the final value presented to the profiler." + ); + setReactFriendlyInputValue(intervalInput, Number(intervalInput.value) + 1); + + is( + getActiveConfiguration().interval, + 2, + "The configuration's interval was able to be increased." + ); + is( + intervalInput.getAttribute("aria-valuenow"), + "2", + "aria-valuenow has the expected value" + ); + + intervalInput.focus(); + + info("Increase the interval with the keyboard"); + EventUtils.synthesizeKey("VK_RIGHT"); + await waitUntil(() => getActiveConfiguration().interval === 3); + is( + intervalInput.getAttribute("aria-valuenow"), + "3", + "aria-valuenow has the expected value" + ); + + info("Decrease the interval with the keyboard"); + EventUtils.synthesizeKey("VK_LEFT"); + await waitUntil(() => getActiveConfiguration().interval === 2); + is( + intervalInput.getAttribute("aria-valuenow"), + "2", + "aria-valuenow has the expected value" + ); + }); +}); diff --git a/devtools/client/performance-new/test/browser/browser_aboutprofiling-presets-custom.js b/devtools/client/performance-new/test/browser/browser_aboutprofiling-presets-custom.js new file mode 100644 index 0000000000..07680d7496 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_aboutprofiling-presets-custom.js @@ -0,0 +1,128 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function test() { + info( + "Test that about:profiling presets override the individual settings when changed." + ); + const supportedFeatures = Services.profiler.GetFeatures(); + + if (!supportedFeatures.includes("stackwalk")) { + ok(true, "This platform does not support stackwalking, skip this test."); + return; + } + // This test assumes that the Web Developer preset is set by default, which is + // not the case on Nightly and custom builds. + BackgroundJSM.changePreset( + "aboutprofiling", + "web-developer", + supportedFeatures + ); + + await withAboutProfiling(async document => { + const webdevPreset = await getNearestInputFromText( + document, + "Web Developer" + ); + const customPreset = await getNearestInputFromText(document, "Custom"); + const stackwalkFeature = await getNearestInputFromText( + document, + "Native Stacks" + ); + const geckoMainThread = await getNearestInputFromText( + document, + "GeckoMain" + ); + + { + info("Check the defaults on the about:profiling page."); + ok( + webdevPreset.checked, + "By default the Web Developer preset is checked." + ); + ok(!customPreset.checked, "By default the custom preset is not checked."); + ok( + !stackwalkFeature.checked, + "Stack walking is not enabled for Web Developer." + ); + ok( + !activeConfigurationHasFeature("stackwalk"), + "Stack walking is not in the active configuration." + ); + ok( + geckoMainThread.checked, + "The GeckoMain thread is tracked for the Web Developer preset" + ); + ok( + activeConfigurationHasThread("GeckoMain"), + "The GeckoMain thread is in the active configuration." + ); + } + + { + info("Change some settings, which will move the preset over to Custom."); + + info("Click stack walking."); + stackwalkFeature.click(); + + info("Click the GeckoMain thread."); + geckoMainThread.click(); + } + + { + info("Check that the various settings were actually updated in the UI."); + ok( + !webdevPreset.checked, + "The Web Developer preset is no longer enabled." + ); + ok(customPreset.checked, "The Custom preset is now checked."); + ok(stackwalkFeature.checked, "Stack walking was enabled"); + ok( + activeConfigurationHasFeature("stackwalk"), + "Stack walking is in the active configuration." + ); + ok( + !geckoMainThread.checked, + "GeckoMain was removed from tracked threads." + ); + ok( + !activeConfigurationHasThread("GeckoMain"), + "The GeckoMain thread is not in the active configuration." + ); + } + + { + info( + "Click the Web Developer preset, which should revert the other settings." + ); + webdevPreset.click(); + } + + { + info( + "Now verify that everything was reverted back to the original settings." + ); + ok(webdevPreset.checked, "The Web Developer preset is checked again."); + ok(!customPreset.checked, "The custom preset is not checked."); + ok( + !stackwalkFeature.checked, + "Stack walking is reverted for the Web Developer preset." + ); + ok( + !activeConfigurationHasFeature("stackwalk"), + "Stack walking is not in the active configuration." + ); + ok( + geckoMainThread.checked, + "GeckoMain was added back to the tracked threads." + ); + ok( + activeConfigurationHasThread("GeckoMain"), + "The GeckoMain thread is in the active configuration." + ); + } + }); +}); diff --git a/devtools/client/performance-new/test/browser/browser_aboutprofiling-presets.js b/devtools/client/performance-new/test/browser/browser_aboutprofiling-presets.js new file mode 100644 index 0000000000..66ead70094 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_aboutprofiling-presets.js @@ -0,0 +1,54 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function test() { + info("Test that about:profiling presets configure the profiler"); + + if (!Services.profiler.GetFeatures().includes("stackwalk")) { + ok(true, "This platform does not support stackwalking, skip this test."); + return; + } + // This test assumes that the Web Developer preset is set by default, which is + // not the case on Nightly and custom builds. + BackgroundJSM.changePreset( + "aboutprofiling", + "web-developer", + Services.profiler.GetFeatures() + ); + + await withAboutProfiling(async document => { + const webdev = await getNearestInputFromText(document, "Web Developer"); + ok(webdev.checked, "By default the Web Developer preset is selected."); + + ok( + !activeConfigurationHasFeature("stackwalk"), + "Stackwalking is not enabled for the Web Developer workflow" + ); + + const graphics = await getNearestInputFromText(document, "Graphics"); + + ok(!graphics.checked, "The Graphics preset is not checked."); + graphics.click(); + ok( + graphics.checked, + "After clicking the input, the Graphics preset is now checked." + ); + + ok( + activeConfigurationHasFeature("stackwalk"), + "The graphics preset uses stackwalking." + ); + + const media = await getNearestInputFromText(document, "Media"); + + ok(!media.checked, "The media preset is not checked."); + media.click(); + ok( + media.checked, + "After clicking the input, the Media preset is now checked." + ); + }); +}); diff --git a/devtools/client/performance-new/test/browser/browser_aboutprofiling-rtl.js b/devtools/client/performance-new/test/browser/browser_aboutprofiling-rtl.js new file mode 100644 index 0000000000..dfed3c432b --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_aboutprofiling-rtl.js @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function () { + await withAboutProfiling(async document => { + is(document.dir, "ltr", "About profiling has the expected direction ltr"); + is( + document.documentElement.getAttribute("lang"), + "en-US", + "About profiling has the expected lang" + ); + }); +}); + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["intl.l10n.pseudo", "bidi"]], + }); + + await withAboutProfiling(async document => { + is(document.dir, "rtl", "About profiling has the expected direction rtl"); + is( + document.documentElement.getAttribute("lang"), + "en-US", + "About profiling has the expected lang" + ); + }); +}); diff --git a/devtools/client/performance-new/test/browser/browser_aboutprofiling-threads-behavior.js b/devtools/client/performance-new/test/browser/browser_aboutprofiling-threads-behavior.js new file mode 100644 index 0000000000..c2512dcc05 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_aboutprofiling-threads-behavior.js @@ -0,0 +1,129 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +add_task(async function test() { + info( + "Test the behavior of thread toggling and the text summary works as expected." + ); + + // This test assumes that the Web Developer preset is set by default, which is + // not the case on Nightly and custom builds. + BackgroundJSM.changePreset( + "aboutprofiling", + "web-developer", + Services.profiler.GetFeatures() + ); + + await withAboutProfiling(async document => { + const threadTextEl = await getNearestInputFromText( + document, + "Add custom threads by name:" + ); + + is( + getActiveConfiguration().threads.join(","), + "GeckoMain,Compositor,Renderer,DOM Worker", + "The threads starts out with the default" + ); + is( + threadTextEl.value, + "GeckoMain,Compositor,Renderer,DOM Worker", + "The threads starts out with the default in the thread text input" + ); + + await clickThreadCheckbox(document, "Compositor", "Toggle off"); + + is( + getActiveConfiguration().threads.join(","), + "GeckoMain,Renderer,DOM Worker", + "The threads have been updated" + ); + is( + threadTextEl.value, + "GeckoMain,Renderer,DOM Worker", + "The threads have been updated in the thread text input" + ); + + await clickThreadCheckbox(document, "DNS Resolver", "Toggle on"); + + is( + getActiveConfiguration().threads.join(","), + "GeckoMain,Renderer,DOM Worker,DNS Resolver", + "Another thread was added" + ); + is( + threadTextEl.value, + "GeckoMain,Renderer,DOM Worker,DNS Resolver", + "Another thread was in the thread text input" + ); + + const styleThreadCheckbox = await getNearestInputFromText( + document, + "StyleThread" + ); + ok(!styleThreadCheckbox.checked, "The style thread is not checked."); + + // Set the input box directly + setReactFriendlyInputValue( + threadTextEl, + "GeckoMain,DOM Worker,DNS Resolver,StyleThread" + ); + threadTextEl.dispatchEvent(new Event("blur", { bubbles: true })); + + ok(styleThreadCheckbox.checked, "The style thread is now checked."); + is( + getActiveConfiguration().threads.join(","), + "GeckoMain,DOM Worker,DNS Resolver,StyleThread", + "Another thread was added" + ); + is( + threadTextEl.value, + "GeckoMain,DOM Worker,DNS Resolver,StyleThread", + "Another thread was in the thread text input" + ); + + // The all threads checkbox has nested text elements, so it's not easy to select + // by its label value. Select it by ID. + const allThreadsCheckbox = document.querySelector( + "#perf-settings-thread-checkbox-all-threads" + ); + info(`Turning on "All Threads" by clicking it."`); + allThreadsCheckbox.click(); + + is( + getActiveConfiguration().threads.join(","), + "GeckoMain,DOM Worker,DNS Resolver,StyleThread,*", + "Asterisk was added" + ); + is( + threadTextEl.value, + "GeckoMain,DOM Worker,DNS Resolver,StyleThread,*", + "Asterisk was in the thread text input" + ); + + is(styleThreadCheckbox.disabled, true, "The Style Thread is now disabled."); + + // Remove the asterisk + setReactFriendlyInputValue( + threadTextEl, + "GeckoMain,DOM Worker,DNS Resolver,StyleThread" + ); + threadTextEl.dispatchEvent(new Event("blur", { bubbles: true })); + + ok(!allThreadsCheckbox.checked, "The all threads checkbox is not checked."); + is(styleThreadCheckbox.disabled, false, "The Style Thread is now enabled."); + }); +}); + +/** + * @param {Document} document + * @param {string} threadName + * @param {string} action - This is the intent of the click. + */ +async function clickThreadCheckbox(document, threadName, action) { + info(`${action} "${threadName}" by clicking it.`); + const checkbox = await getNearestInputFromText(document, threadName); + checkbox.click(); +} diff --git a/devtools/client/performance-new/test/browser/browser_aboutprofiling-threads.js b/devtools/client/performance-new/test/browser/browser_aboutprofiling-threads.js new file mode 100644 index 0000000000..69b266207f --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_aboutprofiling-threads.js @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +add_task(async function test() { + info("Test that about:profiling can be loaded, and the threads changed."); + + await withAboutProfiling(async document => { + const geckoMainInput = await getNearestInputFromText(document, "GeckoMain"); + + ok( + geckoMainInput.checked, + "The GeckoMain thread starts checked by default." + ); + + ok( + activeConfigurationHasThread("GeckoMain"), + "The profiler was started with the GeckoMain thread" + ); + + info("Click the GeckoMain checkbox."); + geckoMainInput.click(); + ok(!geckoMainInput.checked, "The GeckoMain thread UI is toggled off."); + + ok( + !activeConfigurationHasThread("GeckoMain"), + "The profiler was not started with the GeckoMain thread." + ); + }); +}); diff --git a/devtools/client/performance-new/test/browser/browser_devtools-interrupted.js b/devtools/client/performance-new/test/browser/browser_devtools-interrupted.js new file mode 100644 index 0000000000..fa38ce15a2 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_devtools-interrupted.js @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +add_task(async function test() { + info("Test what happens when a recording is interrupted by another tool."); + + const { stopProfiler: stopProfilerByAnotherTool } = + ChromeUtils.importESModule( + "resource://devtools/client/performance-new/shared/background.sys.mjs" + ); + + await withDevToolsPanel(async document => { + const getRecordingState = setupGetRecordingState(document); + + const startRecording = await getActiveButtonFromText( + document, + "Start recording" + ); + info("Click to start recording"); + startRecording.click(); + + info("Wait until the profiler UI has updated to show that it is ready."); + await getActiveButtonFromText(document, "Capture recording"); + + info("Stop the profiler by another tool."); + + stopProfilerByAnotherTool(); + + info("Check that the user was notified of this interruption."); + await getElementFromDocumentByText( + document, + "The recording was stopped by another tool." + ); + + is( + getRecordingState(), + "available-to-record", + "The client is ready to record again, even though it was interrupted." + ); + }); +}); diff --git a/devtools/client/performance-new/test/browser/browser_devtools-onboarding.js b/devtools/client/performance-new/test/browser/browser_devtools-onboarding.js new file mode 100644 index 0000000000..ac253b9562 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_devtools-onboarding.js @@ -0,0 +1,95 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const ONBOARDING_PREF = "devtools.performance.new-panel-onboarding"; + +add_task(async function testWithOnboardingPreferenceFalse() { + info("Test that the onboarding message is displayed as expected."); + + info("Test the onboarding message when the preference is false"); + await SpecialPowers.pushPrefEnv({ + set: [[ONBOARDING_PREF, false]], + }); + await withDevToolsPanel(async document => { + { + // Wait for another UI element to be rendered before asserting the + // onboarding message. + await getActiveButtonFromText(document, "Start recording"); + ok( + !isOnboardingDisplayed(document), + "Onboarding message is not displayed" + ); + } + }); +}); + +add_task(async function testWithOnboardingPreferenceTrue() { + info("Test the onboarding message when the preference is true"); + await SpecialPowers.pushPrefEnv({ + set: [[ONBOARDING_PREF, true]], + }); + + await withDevToolsPanel(async document => { + await waitUntil( + () => isOnboardingDisplayed(document), + "Waiting for the onboarding message to be displayed" + ); + ok(true, "Onboarding message is displayed"); + await closeOnboardingMessage(document); + }); + + is( + Services.prefs.getBoolPref(ONBOARDING_PREF), + false, + "onboarding preference should be false after closing the message" + ); +}); + +add_task(async function testWithOnboardingPreferenceNotSet() { + info("Test the onboarding message when the preference is not set"); + await SpecialPowers.pushPrefEnv({ + clear: [[ONBOARDING_PREF]], + }); + + await withDevToolsPanel(async document => { + await waitUntil( + () => isOnboardingDisplayed(document), + "Waiting for the onboarding message to be displayed" + ); + ok(true, "Onboarding message is displayed"); + await closeOnboardingMessage(document); + }); + + is( + Services.prefs.getBoolPref(ONBOARDING_PREF), + false, + "onboarding preference should be false after closing the message" + ); +}); + +/** + * Helper to close the onboarding message by clicking on the close button. + */ +async function closeOnboardingMessage(document) { + const closeButton = await getActiveButtonFromText( + document, + "Close the onboarding message" + ); + info("Click the close button to hide the onboarding message."); + closeButton.click(); + + await waitUntil( + () => !isOnboardingDisplayed(document), + "Waiting for the onboarding message to disappear" + ); +} + +function isOnboardingDisplayed(document) { + return maybeGetElementFromDocumentByText( + document, + "Firefox Profiler is now integrated into Developer Tools" + ); +} diff --git a/devtools/client/performance-new/test/browser/browser_devtools-presets.js b/devtools/client/performance-new/test/browser/browser_devtools-presets.js new file mode 100644 index 0000000000..383ca57088 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_devtools-presets.js @@ -0,0 +1,47 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +add_task(async function test() { + info("Test that about:profiling presets configure the profiler"); + + if (!Services.profiler.GetFeatures().includes("stackwalk")) { + ok(true, "This platform does not support stackwalking, skip this test."); + return; + } + + // This test assumes that the Web Developer preset is set by default, which is + // not the case on Nightly and custom builds. + BackgroundJSM.changePreset( + "aboutprofiling", + "web-developer", + Services.profiler.GetFeatures() + ); + + await withDevToolsPanel(async document => { + { + const presets = await getNearestInputFromText(document, "Settings"); + + is(presets.value, "web-developer", "The presets default to webdev mode."); + ok( + !(await devToolsActiveConfigurationHasFeature(document, "stackwalk")), + "Stack walking is not used in Web Developer mode." + ); + } + + { + const presets = await getNearestInputFromText(document, "Settings"); + setReactFriendlyInputValue(presets, "firefox-platform"); + is( + presets.value, + "firefox-platform", + "The preset was changed to Firefox Platform" + ); + ok( + await devToolsActiveConfigurationHasFeature(document, "stackwalk"), + "Stack walking is used in Firefox Platform mode." + ); + } + }); +}); diff --git a/devtools/client/performance-new/test/browser/browser_devtools-previously-started.js b/devtools/client/performance-new/test/browser/browser_devtools-previously-started.js new file mode 100644 index 0000000000..7235f94846 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_devtools-previously-started.js @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function test() { + info( + "Test what happens if the profiler was previously started by another tool." + ); + + const { startProfiler } = ChromeUtils.importESModule( + "resource://devtools/client/performance-new/shared/background.sys.mjs" + ); + + info("Start the profiler before DevTools is loaded."); + startProfiler("aboutprofiling"); + + await withDevToolsPanel(async document => { + const getRecordingState = setupGetRecordingState(document); + + // The initial state of the profiler UI is racy, as it calls out to the PerfFront + // to get the status of the profiler. This can race with the initialization of + // the test. Most of the the time the result is "not-yet-known", but rarely + // the PerfFront will win this race. Allow for both outcomes of the race in this + // test. + ok( + getRecordingState() === "not-yet-known" || + getRecordingState() === "recording", + "The component starts out in an unknown state or in a recording state." + ); + + const cancelRecording = await getActiveButtonFromText( + document, + "Cancel recording" + ); + + is( + getRecordingState(), + "recording", + "The profiler is reflecting the recording state." + ); + + info("Click the button to cancel the recording"); + cancelRecording.click(); + + is( + getRecordingState(), + "request-to-stop-profiler", + "We can request to stop the profiler." + ); + + await getActiveButtonFromText(document, "Start recording"); + + is( + getRecordingState(), + "available-to-record", + "The profiler is now available to record." + ); + }); +}); diff --git a/devtools/client/performance-new/test/browser/browser_devtools-record-capture.js b/devtools/client/performance-new/test/browser/browser_devtools-record-capture.js new file mode 100644 index 0000000000..4f0accf3eb --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_devtools-record-capture.js @@ -0,0 +1,213 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const FRONTEND_BASE_HOST = "http://example.com"; +const FRONTEND_BASE_PATH = + "/browser/devtools/client/performance-new/test/browser/fake-frontend.html"; +const FRONTEND_BASE_URL = FRONTEND_BASE_HOST + FRONTEND_BASE_PATH; + +add_setup(async function setup() { + // The active tab view isn't enabled in all configurations. Let's make sure + // it's enabled in these tests. + SpecialPowers.pushPrefEnv({ + set: [["devtools.performance.recording.active-tab-view.enabled", true]], + }); +}); + +add_task(async function test() { + info( + "Test that DevTools can capture profiles. This function also unit tests the " + + "internal RecordingState of the client." + ); + + // This test assumes that the Web Developer preset is set by default, which is + // not the case on Nightly and custom builds. + BackgroundJSM.changePreset( + "aboutprofiling", + "web-developer", + Services.profiler.GetFeatures() + ); + + await setProfilerFrontendUrl(FRONTEND_BASE_HOST, FRONTEND_BASE_PATH); + + await withDevToolsPanel(async document => { + const getRecordingState = setupGetRecordingState(document); + + // The initial state of the profiler UI is racy, as it calls out to the PerfFront + // to get the status of the profiler. This can race with the initialization of + // the test. Most of the the time the result is "not-yet-known", but rarely + // the PerfFront will win this race. Allow for both outcomes of the race in this + // test. + ok( + getRecordingState() === "not-yet-known" || + getRecordingState() === "available-to-record", + "The component starts out in an unknown state or is already available to record." + ); + + // First check for "firefox-platform" preset which will have no "view" query + // string because this is where our traditional "full" view opens up. + await setPresetCaptureAndAssertUrl({ + document, + preset: "firefox-platform", + expectedUrl: FRONTEND_BASE_URL, + getRecordingState, + }); + + // Now, let's check for "web-developer" preset. This will open up the frontend + // with "active-tab" view query string. Frontend will understand and open the active tab view for it. + await setPresetCaptureAndAssertUrl({ + document, + preset: "web-developer", + expectedUrl: FRONTEND_BASE_URL + "?view=active-tab&implementation=js", + getRecordingState, + }); + }); +}); + +add_task(async function test_in_private_window() { + info("Test that DevTools can capture profiles in a private window."); + + // This test assumes that the Web Developer preset is set by default, which is + // not the case on Nightly and custom builds. + BackgroundJSM.changePreset( + "aboutprofiling", + "web-developer", + Services.profiler.GetFeatures() + ); + + await setProfilerFrontendUrl(FRONTEND_BASE_HOST, FRONTEND_BASE_PATH); + + info("Open a private window."); + const privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + await withDevToolsPanel(async document => { + const getRecordingState = setupGetRecordingState(document); + + // The initial state of the profiler UI is racy, as it calls out to the PerfFront + // to get the status of the profiler. This can race with the initialization of + // the test. Most of the the time the result is "not-yet-known", but rarely + // the PerfFront will win this race. Allow for both outcomes of the race in this + // test. + ok( + getRecordingState() === "not-yet-known" || + getRecordingState() === "available-to-record", + "The component starts out in an unknown state or is already available to record." + ); + + // First check for "firefox-platform" preset which will have no "view" query + // string because this is where our traditional "full" view opens up. + // Note that this utility will check for a new tab in the main non-private + // window, which is exactly what we want here. + await setPresetCaptureAndAssertUrl({ + document, + preset: "firefox-platform", + expectedUrl: FRONTEND_BASE_URL, + getRecordingState, + }); + + // Now, let's check for "web-developer" preset. This will open up the frontend + // with "active-tab" view query string. Frontend will understand and open the active tab view for it. + await setPresetCaptureAndAssertUrl({ + document, + preset: "web-developer", + expectedUrl: FRONTEND_BASE_URL + "?view=active-tab&implementation=js", + getRecordingState, + }); + }, privateWindow); + + await BrowserTestUtils.closeWindow(privateWindow); +}); + +async function setPresetCaptureAndAssertUrl({ + document, + preset, + expectedUrl, + getRecordingState, +}) { + const presetsInDevtools = await getNearestInputFromText(document, "Settings"); + setReactFriendlyInputValue(presetsInDevtools, preset); + + const startRecording = await getActiveButtonFromText( + document, + "Start recording" + ); + + is( + getRecordingState(), + "available-to-record", + "After talking to the actor, we're ready to record." + ); + + info("Click the button to start recording"); + startRecording.click(); + + is( + getRecordingState(), + "request-to-start-recording", + "Clicking the start recording button sends in a request to start recording." + ); + + is( + document.defaultView.gToolbox.isHighlighted("performance"), + false, + "The Performance panel in not highlighted yet." + ); + + const captureRecording = await getActiveButtonFromText( + document, + "Capture recording" + ); + + is( + getRecordingState(), + "recording", + "Once the Capture recording button is available, the actor has started " + + "its recording" + ); + + is( + document.defaultView.gToolbox.isHighlighted("performance"), + true, + "The Performance Panel in the Devtools Tab is highlighted when the profiler " + + "is recording" + ); + + info("Click the button to capture the recording."); + captureRecording.click(); + + is( + getRecordingState(), + "request-to-get-profile-and-stop-profiler", + "We have requested to stop the profiler." + ); + + await getActiveButtonFromText(document, "Start recording"); + is( + getRecordingState(), + "available-to-record", + "The profiler is available to record again." + ); + + is( + document.defaultView.gToolbox.isHighlighted("performance"), + false, + "The Performance panel in not highlighted anymore when the profiler is stopped" + ); + + info( + "If the DevTools successfully injects a profile into the page, then the " + + "fake frontend will rename the title of the page." + ); + + await waitForTabUrl({ + initialTitle: "Waiting on the profile", + successTitle: "Profile received", + errorTitle: "Error", + expectedUrl, + }); +} diff --git a/devtools/client/performance-new/test/browser/browser_devtools-record-discard.js b/devtools/client/performance-new/test/browser/browser_devtools-record-discard.js new file mode 100644 index 0000000000..a34df5def0 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_devtools-record-discard.js @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function test() { + info("Test that DevTools can discard profiles."); + + await setProfilerFrontendUrl( + "http://example.com", + "/browser/devtools/client/performance-new/test/browser/fake-frontend.html" + ); + + await withDevToolsPanel(async document => { + { + const button = await getActiveButtonFromText(document, "Start recording"); + info("Click the button to start recording"); + button.click(); + } + + { + const button = await getActiveButtonFromText( + document, + "Cancel recording" + ); + info("Click the button to discard to profile."); + button.click(); + } + + { + const button = await getActiveButtonFromText(document, "Start recording"); + ok(Boolean(button), "The start recording button is available again."); + } + }); +}); diff --git a/devtools/client/performance-new/test/browser/browser_interaction-between-interfaces.js b/devtools/client/performance-new/test/browser/browser_interaction-between-interfaces.js new file mode 100644 index 0000000000..1268cf818d --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_interaction-between-interfaces.js @@ -0,0 +1,389 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* eslint-disable max-nested-callbacks */ +"use strict"; + +add_task(async function test_change_in_popup() { + // This test assumes that the Web Developer preset is set by default, which is + // not the case on Nightly and custom builds. + BackgroundJSM.changePreset( + "aboutprofiling", + "web-developer", + Services.profiler.GetFeatures() + ); + + info( + "Test that changing settings in the popup changes settings in the devtools panel and about:profiling too." + ); + + const browserWindow = window; + const browserDocument = document; + + await makeSureProfilerPopupIsEnabled(); + await withDevToolsPanel( + "about:profiling", + async (devtoolsDocument, aboutProfilingDocument) => { + await withPopupOpen(browserWindow, async () => { + const presetsInPopup = browserDocument.getElementById( + "PanelUI-profiler-presets" + ); + + const presetsInDevtools = await getNearestInputFromText( + devtoolsDocument, + "Settings" + ); + + const webdev = await getNearestInputFromText( + aboutProfilingDocument, + "Web Developer" + ); + const graphics = await getNearestInputFromText( + aboutProfilingDocument, + "Graphics" + ); + const media = await getNearestInputFromText( + aboutProfilingDocument, + "Media" + ); + + // Default situation + ok( + webdev.checked, + "By default the Web Developer preset is selected in the about:profiling interface." + ); + is( + presetsInDevtools.value, + "web-developer", + "The presets default to webdev mode in the devtools panel." + ); + is( + presetsInPopup.value, + "web-developer", + "The presets default to webdev mode in the popup." + ); + + // Select "graphics" using the popup + ok(!graphics.checked, "The Graphics preset is not checked."); + + presetsInPopup.menupopup.openPopup(); + presetsInPopup.menupopup.activateItem( + await getElementByLabel(presetsInPopup, "Graphics") + ); + + await TestUtils.waitForCondition( + () => !webdev.checked, + "After selecting the preset in the popup, waiting until the Web Developer preset isn't selected anymore in the about:profiling interface." + ); + await TestUtils.waitForCondition( + () => graphics.checked, + "After selecting the preset in the popup, waiting until the Graphics preset is checked in the about:profiling interface." + ); + await TestUtils.waitForCondition( + () => presetsInDevtools.value === "graphics", + "After selecting the preset in the popup, waiting until the preset is changed to Graphics in the devtools panel too." + ); + await TestUtils.waitForCondition( + () => presetsInPopup.value === "graphics", + "After selecting the preset in the popup, waiting until the preset is changed to Graphics in the popup." + ); + + // Select "firefox frontend" using the popup + ok(!media.checked, "The Media preset is not checked."); + + presetsInPopup.menupopup.openPopup(); + presetsInPopup.menupopup.activateItem( + await getElementByLabel(presetsInPopup, "Media") + ); + + await TestUtils.waitForCondition( + () => !graphics.checked, + "After selecting the preset in the popup, waiting until the Graphics preset is not checked anymore in the about:profiling interface." + ); + await TestUtils.waitForCondition( + () => media.checked, + "After selecting the preset in the popup, waiting until the Media preset is checked in the about:profiling interface." + ); + await TestUtils.waitForCondition( + () => presetsInDevtools.value === "media", + "After selecting the preset in the popup, waiting until the preset is changed to Firefox Front-end in the devtools panel." + ); + await TestUtils.waitForCondition( + () => presetsInPopup.value === "media", + "After selecting the preset in the popup, waiting until the preset is changed to Media in the popup." + ); + }); + } + ); +}); + +// In the following tests we don't look at changes in the popup. Indeed because +// the popup rerenders each time it's open, we don't need to mirror it. +add_task(async function test_change_in_about_profiling() { + // This test assumes that the Web Developer preset is set by default, which is + // not the case on Nightly and custom builds, or after previous tests. + BackgroundJSM.changePreset( + "aboutprofiling", + "web-developer", + Services.profiler.GetFeatures() + ); + + info( + "Test that changing settings in about:profiling changes settings in the devtools panel too." + ); + + await withDevToolsPanel( + "about:profiling", + async (devtoolsDocument, aboutProfilingDocument) => { + const presetsInDevtools = await getNearestInputFromText( + devtoolsDocument, + "Settings" + ); + + const webdev = await getNearestInputFromText( + aboutProfilingDocument, + "Web Developer" + ); + const graphics = await getNearestInputFromText( + aboutProfilingDocument, + "Graphics" + ); + const media = await getNearestInputFromText( + aboutProfilingDocument, + "Media" + ); + const custom = await getNearestInputFromText( + aboutProfilingDocument, + "Custom" + ); + + // Default values + ok( + webdev.checked, + "By default the Web Developer preset is selected in the about:profiling interface." + ); + is( + presetsInDevtools.value, + "web-developer", + "The presets default to webdev mode in the devtools panel." + ); + + // Change the preset in about:profiling, check it changes also in the + // devtools panel. + ok(!graphics.checked, "The Graphics preset is not checked."); + graphics.click(); + ok( + graphics.checked, + "After clicking the input, the Graphics preset is now checked in about:profiling." + ); + await TestUtils.waitForCondition( + () => presetsInDevtools.value === "graphics", + "The preset was changed to Graphics in the devtools panel too." + ); + + ok(!media.checked, "The Media preset is not checked."); + media.click(); + ok( + media.checked, + "After clicking the input, the Media preset is now checked in about:profiling." + ); + await TestUtils.waitForCondition( + () => presetsInDevtools.value === "media", + "The preset was changed to Media in the devtools panel too." + ); + + // Now let's try to change some configuration! + info( + "Increase the interval by an arbitrary amount. The input range will " + + "scale that to the final value presented to the profiler." + ); + const intervalInput = await getNearestInputFromText( + aboutProfilingDocument, + "Sampling interval:" + ); + setReactFriendlyInputValue( + intervalInput, + Number(intervalInput.value) + 1 + ); + ok( + custom.checked, + "After changing the interval, the Custom preset is now checked in about:profiling." + ); + await TestUtils.waitForCondition( + () => presetsInDevtools.value === "custom", + "The preset was changed to Custom in the devtools panel too." + ); + + ok( + getDevtoolsCustomPresetContent(devtoolsDocument).includes( + "Interval: 2 ms" + ), + "The new interval should be in the custom preset description" + ); + + // Let's check some thread as well + info("Change the threads values using the checkboxes"); + + const styleThreadInput = await getNearestInputFromText( + aboutProfilingDocument, + "StyleThread" + ); + ok( + !styleThreadInput.checked, + "The StyleThread thread isn't checked by default." + ); + + info("Click the StyleThread checkbox."); + styleThreadInput.click(); + + // For some reason, it's not possible to directly match "StyleThread". + const threadsLine = ( + await getElementFromDocumentByText(devtoolsDocument, "Threads") + ).parentElement; + await TestUtils.waitForCondition( + () => threadsLine.textContent.includes("StyleThread"), + "Waiting that StyleThread is displayed in the devtools panel." + ); + ok( + getDevtoolsCustomPresetContent(devtoolsDocument).includes( + "StyleThread" + ), + "The StyleThread thread should be listed in the custom preset description" + ); + styleThreadInput.click(); + await TestUtils.waitForCondition( + () => !threadsLine.textContent.includes("StyleThread"), + "Waiting until the StyleThread disappears from the devtools panel." + ); + ok( + !getDevtoolsCustomPresetContent(devtoolsDocument).includes( + "StyleThread" + ), + "The StyleThread thread should no longer be listed in the custom preset description" + ); + + info("Change the threads values using the input."); + const threadInput = await getNearestInputFromText( + aboutProfilingDocument, + "Add custom threads by name" + ); + + function setThreadInputValue(newThreadValue) { + // Actually set the new value. + setReactFriendlyInputValue(threadInput, newThreadValue); + // The settings are actually changed on the blur event. + threadInput.dispatchEvent(new FocusEvent("blur")); + } + + let newThreadValue = "GeckoMain,Foo"; + setThreadInputValue(newThreadValue); + await TestUtils.waitForCondition( + () => threadsLine.textContent.includes("Foo"), + "Waiting for Foo to be displayed in the devtools panel." + ); + + // The code detecting changes to the thread list has a fast path + // to detect that the list of threads has changed if the 2 lists + // have different lengths. Exercise the slower code path by changing + // the list of threads to a list with the same number of threads. + info("Change the thread list again to a list of the same length"); + newThreadValue = "GeckoMain,Dummy"; + is( + threadInput.value.split(",").length, + newThreadValue.split(",").length, + "The new value should have the same count of threads as the old value, please double check the test code." + ); + setThreadInputValue(newThreadValue); + checkDevtoolsCustomPresetContent( + devtoolsDocument, + ` + Interval: 2 ms + Threads: GeckoMain, Dummy + JavaScript + Native Stacks + CPU Utilization + Audio Callback Tracing + IPC Messages + Process CPU Utilization + ` + ); + } + ); +}); + +add_task(async function test_change_in_devtools_panel() { + // This test assumes that the Web Developer preset is set by default, which is + // not the case on Nightly and custom builds, or after previous tests. + BackgroundJSM.changePreset( + "aboutprofiling", + "web-developer", + Services.profiler.GetFeatures() + ); + + info( + "Test that changing settings in the devtools panel changes settings in about:profiling too." + ); + + await withDevToolsPanel( + "about:profiling", + async (devtoolsDocument, aboutProfilingDocument) => { + const presetsInDevtools = await getNearestInputFromText( + devtoolsDocument, + "Settings" + ); + + const webdev = await getNearestInputFromText( + aboutProfilingDocument, + "Web Developer" + ); + const graphics = await getNearestInputFromText( + aboutProfilingDocument, + "Graphics" + ); + const media = await getNearestInputFromText( + aboutProfilingDocument, + "Media" + ); + + // Default values + ok( + webdev.checked, + "By default the Web Developer preset is selected in the about:profiling interface." + ); + is( + presetsInDevtools.value, + "web-developer", + "The presets default to webdev mode in the devtools panel." + ); + + // Change the preset in devtools panel, check it changes also in + // about:profiling. + ok( + !graphics.checked, + "The Graphics preset is not checked in about:profiling." + ); + + setReactFriendlyInputValue(presetsInDevtools, "graphics"); + await TestUtils.waitForCondition( + () => graphics.checked, + "After changing the preset in the devtools panel, the Graphics preset is now checked in about:profiling." + ); + await TestUtils.waitForCondition( + () => presetsInDevtools.value === "graphics", + "The preset was changed to Graphics in the devtools panel too." + ); + + // Change another preset now + ok(!media.checked, "The Media preset is not checked."); + setReactFriendlyInputValue(presetsInDevtools, "media"); + await TestUtils.waitForCondition( + () => media.checked, + "After changing the preset in the devtools panel, the Media preset is now checked in about:profiling." + ); + await TestUtils.waitForCondition( + () => presetsInDevtools.value === "media", + "The preset was changed to Media in the devtools panel too." + ); + } + ); +}); diff --git a/devtools/client/performance-new/test/browser/browser_popup-profiler-states.js b/devtools/client/performance-new/test/browser/browser_popup-profiler-states.js new file mode 100644 index 0000000000..6ad23718e7 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_popup-profiler-states.js @@ -0,0 +1,91 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function test() { + info( + "Test the states of the profiler button, e.g. inactive, active, and capturing." + ); + await setProfilerFrontendUrl( + "http://example.com", + "/browser/devtools/client/performance-new/test/browser/fake-frontend.html" + ); + await makeSureProfilerPopupIsEnabled(); + + const { toggleProfiler, captureProfile } = ChromeUtils.importESModule( + "resource://devtools/client/performance-new/shared/background.sys.mjs" + ); + + const button = document.getElementById("profiler-button-button"); + if (!button) { + throw new Error("Could not find the profiler button."); + } + + info("The profiler button starts out inactive"); + await checkButtonState(button, { + tooltip: "Record a performance profile", + active: false, + paused: false, + }); + + info("Toggling the profiler turns on the active state"); + toggleProfiler("aboutprofiling"); + await checkButtonState(button, { + tooltip: "The profiler is recording a profile", + active: true, + paused: false, + }); + + info("Capturing a profile makes the button paused"); + captureProfile("aboutprofiling"); + + // The state "capturing" can be very quick, so waiting for the tooltip + // translation is racy. Let's only check the button's states. + await checkButtonState(button, { + active: false, + paused: true, + }); + + await waitUntil( + () => !button.classList.contains("profiler-paused"), + "Waiting until the profiler is no longer paused" + ); + + await checkButtonState(button, { + tooltip: "Record a performance profile", + active: false, + paused: false, + }); + + await checkTabLoadedProfile({ + initialTitle: "Waiting on the profile", + successTitle: "Profile received", + errorTitle: "Error", + }); +}); + +/** + * This check dives into the implementation details of the button, mainly + * because it's hard to provide a user-focused interpretation of button + * stylings. + */ +async function checkButtonState(button, { tooltip, active, paused }) { + is( + button.classList.contains("profiler-active"), + active, + `The expected profiler button active state is: ${active}` + ); + is( + button.classList.contains("profiler-paused"), + paused, + `The expected profiler button paused state is: ${paused}` + ); + + if (tooltip) { + // Let's also check the tooltip, but because the translation happens + // asynchronously, we need a waiting mechanism. + await getElementByTooltip(document, tooltip); + } +} diff --git a/devtools/client/performance-new/test/browser/browser_popup-record-capture-view.js b/devtools/client/performance-new/test/browser/browser_popup-record-capture-view.js new file mode 100644 index 0000000000..144e63b63b --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_popup-record-capture-view.js @@ -0,0 +1,143 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const FRONTEND_BASE_HOST = "http://example.com"; +const FRONTEND_BASE_PATH = + "/browser/devtools/client/performance-new/test/browser/fake-frontend.html"; +const FRONTEND_BASE_URL = FRONTEND_BASE_HOST + FRONTEND_BASE_PATH; + +add_setup(async function setup() { + // The active tab view isn't enabled in all configurations. Let's make sure + // it's enabled in these tests. + SpecialPowers.pushPrefEnv({ + set: [["devtools.performance.recording.active-tab-view.enabled", true]], + }); +}); + +add_task(async function test() { + info( + "Test that the profiler pop-up correctly opens the captured profile on the " + + "correct frontend view by adding proper view query string" + ); + + // This test assumes that the Web Developer preset is set by default, which is + // not the case on Nightly and custom builds. + BackgroundJSM.changePreset( + "aboutprofiling", + "web-developer", + Services.profiler.GetFeatures() + ); + + await setProfilerFrontendUrl(FRONTEND_BASE_HOST, FRONTEND_BASE_PATH); + await makeSureProfilerPopupIsEnabled(); + + // First check for the "Media" preset which will have no "view" query + // string because it opens our traditional "full" view. + await openPopupAndAssertUrlForPreset({ + window, + preset: "Media", + expectedUrl: FRONTEND_BASE_URL, + }); + + // Now, let's check for "web-developer" preset. This will open up the frontend + // with "active-tab" view query string. Frontend will understand and open the active tab view for it. + await openPopupAndAssertUrlForPreset({ + window, + preset: "Web Developer", + expectedUrl: FRONTEND_BASE_URL + "?view=active-tab&implementation=js", + }); +}); + +add_task(async function test_in_private_window() { + info( + "Test that the profiler pop-up correctly opens the captured profile on the " + + "correct frontend view by adding proper view query string. This also tests " + + "that a tab is opened on the non-private window even when the popup is used " + + "in the private window." + ); + + // This test assumes that the Web Developer preset is set by default, which is + // not the case on Nightly and custom builds. + BackgroundJSM.changePreset( + "aboutprofiling", + "web-developer", + Services.profiler.GetFeatures() + ); + + await setProfilerFrontendUrl(FRONTEND_BASE_HOST, FRONTEND_BASE_PATH); + await makeSureProfilerPopupIsEnabled(); + + info("Open a private window."); + const privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + // First check for the "Media" preset which will have no "view" query + // string because it opens our traditional "full" view. + // Note that this utility will check for a new tab in the main non-private + // window, which is exactly what we want here. + await openPopupAndAssertUrlForPreset({ + window: privateWindow, + preset: "Media", + expectedUrl: FRONTEND_BASE_URL, + }); + + // Now, let's check for "web-developer" preset. This will open up the frontend + // with "active-tab" view query string. Frontend will understand and open the active tab view for it. + await openPopupAndAssertUrlForPreset({ + window: privateWindow, + preset: "Web Developer", + expectedUrl: FRONTEND_BASE_URL + "?view=active-tab&implementation=js", + }); + + await BrowserTestUtils.closeWindow(privateWindow); +}); + +async function openPopupAndAssertUrlForPreset({ window, preset, expectedUrl }) { + // Let's capture a profile and assert newly created tab's url. + await openPopupAndEnsureCloses(window, async () => { + const { document } = window; + { + // Select the preset in the popup + const presetsInPopup = document.getElementById( + "PanelUI-profiler-presets" + ); + presetsInPopup.menupopup.openPopup(); + presetsInPopup.menupopup.activateItem( + await getElementByLabel(presetsInPopup, preset) + ); + + await TestUtils.waitForCondition( + () => presetsInPopup.label === preset, + `After selecting the preset in the popup, waiting until the preset is changed to ${preset} in the popup.` + ); + } + + { + const button = await getElementByLabel(document, "Start Recording"); + info("Click the button to start recording."); + button.click(); + } + + { + const button = await getElementByLabel(document, "Capture"); + info("Click the button to capture the recording."); + button.click(); + } + + info( + "If the profiler successfully captures a profile, it will create a new " + + "tab with the proper view query string depending on the preset." + ); + + await waitForTabUrl({ + initialTitle: "Waiting on the profile", + successTitle: "Profile received", + errorTitle: "Error", + expectedUrl, + }); + }); +} diff --git a/devtools/client/performance-new/test/browser/browser_popup-record-capture.js b/devtools/client/performance-new/test/browser/browser_popup-record-capture.js new file mode 100644 index 0000000000..94da377614 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_popup-record-capture.js @@ -0,0 +1,41 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function test() { + info( + "Test that the profiler pop-up works end to end with profile recording and " + + "capture using the mouse and hitting buttons." + ); + await setProfilerFrontendUrl( + "http://example.com", + "/browser/devtools/client/performance-new/test/browser/fake-frontend.html" + ); + await makeSureProfilerPopupIsEnabled(); + await openPopupAndEnsureCloses(window, async () => { + { + const button = await getElementByLabel(document, "Start Recording"); + info("Click the button to start recording."); + button.click(); + } + + { + const button = await getElementByLabel(document, "Capture"); + info("Click the button to capture the recording."); + button.click(); + } + + info( + "If the profiler successfully injects a profile into the page, then the " + + "fake frontend will rename the title of the page." + ); + + await checkTabLoadedProfile({ + initialTitle: "Waiting on the profile", + successTitle: "Profile received", + errorTitle: "Error", + }); + }); +}); diff --git a/devtools/client/performance-new/test/browser/browser_popup-record-discard.js b/devtools/client/performance-new/test/browser/browser_popup-record-discard.js new file mode 100644 index 0000000000..e6ab5d38d1 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_popup-record-discard.js @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function test() { + info("Test that the profiler popup recording can be discarded."); + await setProfilerFrontendUrl( + "http://example.com", + "/browser/devtools/client/performance-new/test/browser/fake-frontend.html" + ); + await makeSureProfilerPopupIsEnabled(); + await withPopupOpen(window, async () => { + { + const button = await getElementByLabel(document, "Start Recording"); + info("Click the button to start recording."); + button.click(); + } + + { + const button = await getElementByLabel(document, "Discard"); + info("Click the button to discard the recording."); + button.click(); + } + + { + const button = await getElementByLabel(document, "Start Recording"); + ok( + Boolean(button), + "The popup reverted back to be able to start recording again" + ); + } + }); +}); diff --git a/devtools/client/performance-new/test/browser/browser_split-toolbar-button.js b/devtools/client/performance-new/test/browser/browser_split-toolbar-button.js new file mode 100644 index 0000000000..4caa552910 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_split-toolbar-button.js @@ -0,0 +1,180 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// This is the same value used by CustomizableUI tests. +const kForceOverflowWidthPx = 450; + +function isActive() { + return Services.profiler.IsActive(); +} + +/** + * Force focus to an element that isn't focusable. + * Toolbar buttons aren't focusable because if they were, clicking them would + * focus them, which is undesirable. Therefore, they're only made focusable + * when a user is navigating with the keyboard. This function forces focus as + * is done during toolbar keyboard navigation. + */ +function forceFocus(elem) { + elem.setAttribute("tabindex", "-1"); + elem.focus(); + elem.removeAttribute("tabindex"); +} + +async function waitForProfileAndCloseTab() { + await waitUntil( + () => !button.classList.contains("profiler-paused"), + "Waiting until the profiler is no longer paused" + ); + + await checkTabLoadedProfile({ + initialTitle: "Waiting on the profile", + successTitle: "Profile received", + errorTitle: "Error", + }); +} +var button; +var dropmarker; + +add_setup(async function () { + info( + "Add the profiler button to the toolbar and ensure capturing a profile loads a local url." + ); + await setProfilerFrontendUrl( + "http://example.com", + "/browser/devtools/client/performance-new/test/browser/fake-frontend.html" + ); + await makeSureProfilerPopupIsEnabled(); + button = document.getElementById("profiler-button-button"); + dropmarker = document.getElementById("profiler-button-dropmarker"); +}); + +add_task(async function click_icon() { + info("Test that the profiler icon starts and captures a profile."); + + ok(!dropmarker.hasAttribute("open"), "should start with the panel closed"); + ok(!isActive(), "should start with the profiler inactive"); + + button.click(); + await getElementByTooltip(document, "The profiler is recording a profile"); + ok(isActive(), "should have started the profiler"); + + button.click(); + // We're not testing for the tooltip "capturing a profile" because this might + // be racy. + await waitForProfileAndCloseTab(); + + // Back to the inactive state. + await getElementByTooltip(document, "Record a performance profile"); +}); + +add_task(async function click_dropmarker() { + info("Test that the profiler icon dropmarker opens the panel."); + + ok(!dropmarker.hasAttribute("open"), "should start with the panel closed"); + ok(!isActive(), "should start with the profiler inactive"); + + const popupShownPromise = waitForProfilerPopupEvent(window, "popupshown"); + dropmarker.click(); + await popupShownPromise; + + info("Ensure the panel is open and the profiler still inactive."); + Assert.equal(dropmarker.getAttribute("open"), "true", "panel should be open"); + ok(!isActive(), "profiler should still be inactive"); + await getElementByLabel(document, "Start Recording"); + + info("Press Escape to close the panel."); + const popupHiddenPromise = waitForProfilerPopupEvent(window, "popuphidden"); + EventUtils.synthesizeKey("KEY_Escape"); + await popupHiddenPromise; + ok(!dropmarker.hasAttribute("open"), "panel should be closed"); +}); + +add_task(async function click_overflowed_icon() { + info("Test that the profiler icon opens the panel when overflowed."); + + const overflowMenu = document.getElementById("widget-overflow"); + const profilerPanel = document.getElementById("PanelUI-profiler"); + + ok(!dropmarker.hasAttribute("open"), "should start with the panel closed"); + ok(!isActive(), "should start with the profiler inactive"); + + const navbar = document.getElementById(CustomizableUI.AREA_NAVBAR); + ok( + !navbar.hasAttribute("overflowing"), + "Should start with a non-overflowing toolbar." + ); + + info("Force the toolbar to overflow."); + const originalWindowWidth = window.outerWidth; + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + await TestUtils.waitForCondition(() => navbar.hasAttribute("overflowing")); + ok(navbar.hasAttribute("overflowing"), "Should have an overflowing toolbar."); + + info("Open the overflow menu."); + const chevron = document.getElementById("nav-bar-overflow-button"); + chevron.click(); + await TestUtils.waitForCondition(() => overflowMenu.state == "open"); + + info("Open the profiler panel."); + button.click(); + await TestUtils.waitForCondition(() => + profilerPanel?.hasAttribute("visible") + ); + + info("Ensure the panel is open and the profiler still inactive."); + ok(profilerPanel?.hasAttribute("visible"), "panel should be open"); + ok(!isActive(), "profiler should still be inactive"); + await getElementByLabel(document, "Start Recording"); + + info("Press Escape to close the panel."); + EventUtils.synthesizeKey("KEY_Escape"); + await TestUtils.waitForCondition(() => overflowMenu.state == "closed"); + ok(!dropmarker.hasAttribute("open"), "panel should be closed"); + + info("Undo the forced toolbar overflow."); + window.resizeTo(originalWindowWidth, window.outerHeight); + return TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); +}); + +add_task(async function space_key() { + info("Test that the Space key starts and captures a profile."); + + ok(!dropmarker.hasAttribute("open"), "should start with the panel closed"); + ok(!isActive(), "should start with the profiler inactive"); + forceFocus(button); + + info("Press Space to start the profiler."); + EventUtils.synthesizeKey(" "); + ok(isActive(), "should have started the profiler"); + + info("Press Space again to capture the profile."); + EventUtils.synthesizeKey(" "); + await waitForProfileAndCloseTab(); +}); + +add_task(async function enter_key() { + info("Test that the Enter key starts and captures a profile."); + + ok(!dropmarker.hasAttribute("open"), "should start with the panel closed"); + ok(!isActive(), "should start with the profiler inactive"); + forceFocus(button); + + const isMacOS = Services.appinfo.OS === "Darwin"; + if (isMacOS) { + // On macOS, pressing Enter on a focused toolbarbutton does not fire a + // command event, so we do not expect Enter to start the profiler. + return; + } + + info("Pressing Enter should start the profiler."); + EventUtils.synthesizeKey("KEY_Enter"); + ok(isActive(), "should have started the profiler"); + + info("Pressing Enter again to capture the profile."); + EventUtils.synthesizeKey("KEY_Enter"); + await waitForProfileAndCloseTab(); +}); diff --git a/devtools/client/performance-new/test/browser/browser_webchannel-enable-menu-button-preset.js b/devtools/client/performance-new/test/browser/browser_webchannel-enable-menu-button-preset.js new file mode 100644 index 0000000000..4732f8f037 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_webchannel-enable-menu-button-preset.js @@ -0,0 +1,52 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function test() { + info( + "Test the WebChannel mechanism that it changes the preset to firefox-platform" + ); + await makeSureProfilerPopupIsDisabled(); + const supportedFeatures = Services.profiler.GetFeatures(); + + // This test assumes that the Web Developer preset is set by default, which is + // not the case on Nightly and custom builds. + BackgroundJSM.changePreset( + "aboutprofiling", + "web-developer", + supportedFeatures + ); + + await withAboutProfiling(async document => { + const webdevPreset = document.querySelector("input[value=web-developer]"); + const firefoxPreset = await document.querySelector( + "input[value=firefox-platform]" + ); + + // Check the presets now to make sure web-developer is selected right now. + ok(webdevPreset.checked, "By default the Web Developer preset is checked."); + ok( + !firefoxPreset.checked, + "By default the Firefox Platform preset is not checked." + ); + + // Enable the profiler menu button with web channel. + await withWebChannelTestDocument(async browser => { + await waitForTabTitle("WebChannel Page Ready"); + await waitForProfilerMenuButton(); + ok(true, "The profiler menu button was enabled by the WebChannel."); + }); + + // firefox-platform preset should be selected now. + ok( + !webdevPreset.checked, + "Web Developer preset should not be checked anymore." + ); + ok( + firefoxPreset.checked, + "Firefox Platform preset should now be checked after enabling the popup with web channel." + ); + }); +}); diff --git a/devtools/client/performance-new/test/browser/browser_webchannel-enable-menu-button.js b/devtools/client/performance-new/test/browser/browser_webchannel-enable-menu-button.js new file mode 100644 index 0000000000..a1864c475d --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_webchannel-enable-menu-button.js @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function test() { + info("Test the WebChannel mechanism works for turning on the menu button."); + await makeSureProfilerPopupIsDisabled(); + + await withWebChannelTestDocument(async browser => { + await waitForTabTitle("WebChannel Page Ready"); + await waitForProfilerMenuButton(); + ok(true, "The profiler menu button was enabled by the WebChannel."); + }); +}); diff --git a/devtools/client/performance-new/test/browser/fake-frontend.html b/devtools/client/performance-new/test/browser/fake-frontend.html new file mode 100644 index 0000000000..6742457113 --- /dev/null +++ b/devtools/client/performance-new/test/browser/fake-frontend.html @@ -0,0 +1,126 @@ +<!DOCTYPE html> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<html> + <head> + <meta charset="utf-8"/> + <title></title> + </head> + <body> + <script> + "use strict"; + // This file is used to test the injection of performance profiles into a front-end, + // specifically the mechanism used to inject into profiler.firefox.com. Rather + // than using some kind of complicated message passing scheme to talk to the test + // harness, modify the title of the page. The tests can easily read the window + // title to see if things worked as expected. + + // The following are the titles used to communicate the page's state to the tests. + // Keep these in sync with any tests that read them. + const initialTitle = "Waiting on the profile"; + const successTitle = "Profile received"; + const errorTitle = "Error" + + document.title = initialTitle; + + // A function which requests the profile from the browser using the GET_PROFILE + // WebChannel message. + function getProfile() { + return new Promise((resolve, reject) => { + const requestId = 0; + + function listener(event) { + window.removeEventListener( + "WebChannelMessageToContent", + listener, + true + ); + + const { id, message } = event.detail; + + if (id !== "profiler.firefox.com" || + !message || + typeof message !== "object" + ) { + console.error(message); + reject(new Error("A malformed WebChannel event was received.")); + return; + } + + if (!message.type) { + console.error(message); + reject(new Error("The WebChannel event indicates an error.")); + return; + } + + if (message.requestId === requestId) { + if (message.type === "SUCCESS_RESPONSE") { + resolve(message.response); + } else { + reject(new Error(message.error)); + } + } + } + + window.addEventListener("WebChannelMessageToContent", listener, true); + + window.dispatchEvent( + new CustomEvent("WebChannelMessageToChrome", { + detail: JSON.stringify({ + id: "profiler.firefox.com", + message: { type: "GET_PROFILE", requestId }, + }), + }) + ); + }) + } + + async function runTest() { + try { + // Get the profile. + const profile = await getProfile(); + + // Check that the profile is somewhat reasonable. It should be a gzipped + // profile, so we can only lightly check some properties about it, and check + // that it is an ArrayBuffer. + // + // After the check, modify the title of the document, so the tab title gets + // updated. This is an easy way to pass a message to the test script. + if ( + profile && + typeof profile === 'object' && + ( + // The popup injects the compressed profile as an ArrayBuffer. + (profile instanceof ArrayBuffer) || + // DevTools injects the profile as just the plain object, although + // maybe in the future it could also do it as a compressed profile + // to make this faster (bug 1581963). + Object.keys(profile).includes("threads") + ) + ) { + // The profile looks good! + document.title = successTitle; + } else { + // The profile doesn't look right, surface the error to the terminal. + dump('The gecko profile was malformed in fake-frontend.html\n'); + dump(`Profile: ${JSON.stringify(profile)}\n`); + + // Also to the web console. + console.error(profile); + + // Report the error to the tab title. + document.title = errorTitle; + } + } catch (error) { + // Catch any error and notify the test. + document.title = errorTitle; + dump('An error was caught in fake-frontend.html\n'); + dump(`${error}\n`); + } + } + + runTest(); + </script> + </body> +</html> diff --git a/devtools/client/performance-new/test/browser/head.js b/devtools/client/performance-new/test/browser/head.js new file mode 100644 index 0000000000..498cb4212a --- /dev/null +++ b/devtools/client/performance-new/test/browser/head.js @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const BackgroundJSM = ChromeUtils.importESModule( + "resource://devtools/client/performance-new/shared/background.sys.mjs" +); + +registerCleanupFunction(() => { + BackgroundJSM.revertRecordingSettings(); +}); + +/** + * Allow tests to use "require". + */ +const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); + +{ + if (Services.env.get("MOZ_PROFILER_SHUTDOWN")) { + throw new Error( + "These tests cannot be run with shutdown profiling as they rely on manipulating " + + "the state of the profiler." + ); + } + + if (Services.env.get("MOZ_PROFILER_STARTUP")) { + throw new Error( + "These tests cannot be run with startup profiling as they rely on manipulating " + + "the state of the profiler." + ); + } +} + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/performance-new/test/browser/helpers.js", + this +); diff --git a/devtools/client/performance-new/test/browser/helpers.js b/devtools/client/performance-new/test/browser/helpers.js new file mode 100644 index 0000000000..d5cc3af19e --- /dev/null +++ b/devtools/client/performance-new/test/browser/helpers.js @@ -0,0 +1,836 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/** + * Wait for a single requestAnimationFrame tick. + */ +function tick() { + return new Promise(resolve => requestAnimationFrame(resolve)); +} + +/** + * It can be confusing when waiting for something asynchronously. This function + * logs out a message periodically (every 1 second) in order to create helpful + * log messages. + * @param {string} message + * @returns {Function} + */ +function createPeriodicLogger() { + let startTime = Date.now(); + let lastCount = 0; + let lastMessage = null; + + return message => { + if (lastMessage === message) { + // The messages are the same, check if we should log them. + const now = Date.now(); + const count = Math.floor((now - startTime) / 1000); + if (count !== lastCount) { + info( + `${message} (After ${count} ${count === 1 ? "second" : "seconds"})` + ); + lastCount = count; + } + } else { + // The messages are different, log them now, and reset the waiting time. + info(message); + startTime = Date.now(); + lastCount = 0; + lastMessage = message; + } + }; +} + +/** + * Wait until a condition is fullfilled. + * @param {Function} condition + * @param {string?} logMessage + * @return The truthy result of the condition. + */ +async function waitUntil(condition, message) { + const logPeriodically = createPeriodicLogger(); + + // Loop through the condition. + while (true) { + if (message) { + logPeriodically(message); + } + const result = condition(); + if (result) { + return result; + } + + await tick(); + } +} + +/** + * This function looks inside of a container for some element that has a label. + * It runs in a loop every requestAnimationFrame until it finds the element. If + * it doesn't find the element it throws an error. + * + * @param {Element} container + * @param {string} label + * @returns {Promise<HTMLElement>} + */ +function getElementByLabel(container, label) { + return waitUntil( + () => container.querySelector(`[label="${label}"]`), + `Trying to find the button with the label "${label}".` + ); +} +/* exported getElementByLabel */ + +/** + * This function looks inside of a container for some element that has a tooltip. + * It runs in a loop every requestAnimationFrame until it finds the element. If + * it doesn't find the element it throws an error. + * + * @param {Element} container + * @param {string} tooltip + * @returns {Promise<HTMLElement>} + */ +function getElementByTooltip(container, tooltip) { + return waitUntil( + () => container.querySelector(`[tooltiptext="${tooltip}"]`), + `Trying to find the button with the tooltip "${tooltip}".` + ); +} +/* exported getElementByTooltip */ + +/** + * This function will select a node from the XPath. + * @returns {HTMLElement?} + */ +function getElementByXPath(document, path) { + return document.evaluate( + path, + document, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null + ).singleNodeValue; +} +/* exported getElementByXPath */ + +/** + * This function looks inside of a document for some element that contains + * the given text. It runs in a loop every requestAnimationFrame until it + * finds the element. If it doesn't find the element it throws an error. + * + * @param {HTMLDocument} document + * @param {string} text + * @returns {Promise<HTMLElement>} + */ +async function getElementFromDocumentByText(document, text) { + // Fallback on aria-label if there are no results for the text xpath. + const xpath = `//*[contains(text(), '${text}')] | //*[contains(@aria-label, '${text}')]`; + return waitUntil( + () => getElementByXPath(document, xpath), + `Trying to find the element with the text "${text}".` + ); +} +/* exported getElementFromDocumentByText */ + +/** + * This function is similar to getElementFromDocumentByText, but it immediately + * returns and does not wait for an element to exist. + * @param {HTMLDocument} document + * @param {string} text + * @returns {HTMLElement?} + */ +function maybeGetElementFromDocumentByText(document, text) { + info(`Immediately trying to find the element with the text "${text}".`); + const xpath = `//*[contains(text(), '${text}')]`; + return getElementByXPath(document, xpath); +} +/* exported maybeGetElementFromDocumentByText */ + +/** + * Make sure the profiler popup is enabled. + */ +async function makeSureProfilerPopupIsEnabled() { + info("Make sure the profiler popup is enabled."); + + info("> Load the profiler menu button."); + const { ProfilerMenuButton } = ChromeUtils.importESModule( + "resource://devtools/client/performance-new/popup/menu-button.sys.mjs" + ); + + if (!ProfilerMenuButton.isInNavbar()) { + // Make sure the feature flag is enabled. + SpecialPowers.pushPrefEnv({ + set: [["devtools.performance.popup.feature-flag", true]], + }); + + info("> The menu button is not in the nav bar, add it."); + ProfilerMenuButton.addToNavbar(document); + + await waitUntil( + () => gBrowser.ownerDocument.getElementById("profiler-button"), + "> Waiting until the profiler button is added to the browser." + ); + + await SimpleTest.promiseFocus(gBrowser.ownerGlobal); + + registerCleanupFunction(() => { + info( + "Clean up after the test by disabling the profiler popup menu button." + ); + if (!ProfilerMenuButton.isInNavbar()) { + throw new Error( + "Expected the profiler popup to still be in the navbar during the test cleanup." + ); + } + ProfilerMenuButton.remove(); + }); + } else { + info("> The menu button was already enabled."); + } +} +/* exported makeSureProfilerPopupIsEnabled */ + +/** + * XUL popups will fire the popupshown and popuphidden events. These will fire for + * any type of popup in the browser. This function waits for one of those events, and + * checks that the viewId of the popup is PanelUI-profiler + * + * @param {Window} window + * @param {"popupshown" | "popuphidden"} eventName + * @returns {Promise<void>} + */ +function waitForProfilerPopupEvent(window, eventName) { + return new Promise(resolve => { + function handleEvent(event) { + if (event.target.getAttribute("viewId") === "PanelUI-profiler") { + window.removeEventListener(eventName, handleEvent); + resolve(); + } + } + window.addEventListener(eventName, handleEvent); + }); +} +/* exported waitForProfilerPopupEvent */ + +/** + * Do not use this directly in a test. Prefer withPopupOpen and openPopupAndEnsureCloses. + * + * This function toggles the profiler menu button, and then uses user gestures + * to click it open. It waits a tick to make sure it has a chance to initialize. + * @param {Window} window + * @return {Promise<void>} + */ +async function _toggleOpenProfilerPopup(window) { + info("Toggle open the profiler popup."); + + info("> Find the profiler menu button."); + const profilerDropmarker = window.document.getElementById( + "profiler-button-dropmarker" + ); + if (!profilerDropmarker) { + throw new Error( + "Could not find the profiler button dropmarker in the toolbar." + ); + } + + const popupShown = waitForProfilerPopupEvent(window, "popupshown"); + + info("> Trigger a click on the profiler button dropmarker."); + await EventUtils.synthesizeMouseAtCenter(profilerDropmarker, {}, window); + + if (profilerDropmarker.getAttribute("open") !== "true") { + throw new Error( + "This test assumes that the button will have an open=true attribute after clicking it." + ); + } + + info("> Wait for the popup to be shown."); + await popupShown; + // Also wait a tick in case someone else is subscribing to the "popupshown" event + // and is doing synchronous work with it. + await tick(); +} + +/** + * Do not use this directly in a test. Prefer withPopupOpen. + * + * This function uses a keyboard shortcut to close the profiler popup. + * @param {Window} window + * @return {Promise<void>} + */ +async function _closePopup(window) { + const popupHiddenPromise = waitForProfilerPopupEvent(window, "popuphidden"); + info("> Trigger an escape key to hide the popup"); + EventUtils.synthesizeKey("KEY_Escape"); + + info("> Wait for the popup to be hidden."); + await popupHiddenPromise; + // Also wait a tick in case someone else is subscribing to the "popuphidden" event + // and is doing synchronous work with it. + await tick(); +} + +/** + * Perform some action on the popup, and close it afterwards. + * @param {Window} window + * @param {() => Promise<void>} callback + */ +async function withPopupOpen(window, callback) { + await _toggleOpenProfilerPopup(window); + await callback(); + await _closePopup(window); +} +/* exported withPopupOpen */ + +/** + * This function opens the profiler popup, but also ensures that something else closes + * it before the end of the test. This is useful for tests that trigger the profiler + * popup to close through an implicit action, like opening a tab. + * + * @param {Window} window + * @param {() => Promise<void>} callback + */ +async function openPopupAndEnsureCloses(window, callback) { + await _toggleOpenProfilerPopup(window); + // We want to ensure the popup gets closed by the test, during the callback. + const popupHiddenPromise = waitForProfilerPopupEvent(window, "popuphidden"); + await callback(); + info("> Verifying that the popup was closed by the test."); + await popupHiddenPromise; +} +/* exported openPopupAndEnsureCloses */ + +/** + * This function overwrites the default profiler.firefox.com URL for tests. This + * ensures that the tests do not attempt to access external URLs. + * The origin needs to be on the allowlist in validateProfilerWebChannelUrl, + * otherwise the WebChannel won't work. ("http://example.com" is on that list.) + * + * @param {string} origin - For example: http://example.com + * @param {string} pathname - For example: /my/testing/frontend.html + * @returns {Promise} + */ +function setProfilerFrontendUrl(origin, pathname) { + return SpecialPowers.pushPrefEnv({ + set: [ + // Make sure observer and testing function run in the same process + ["devtools.performance.recording.ui-base-url", origin], + ["devtools.performance.recording.ui-base-url-path", pathname], + ], + }); +} +/* exported setProfilerFrontendUrl */ + +/** + * This function checks the document title of a tab to see what the state is. + * This creates a simple messaging mechanism between the content page and the + * test harness. This function runs in a loop every requestAnimationFrame, and + * checks for a sucess title. In addition, an "initialTitle" and "errorTitle" + * can be specified for nicer test output. + * @param {object} + * { + * initialTitle: string, + * successTitle: string, + * errorTitle: string + * } + */ +async function checkTabLoadedProfile({ + initialTitle, + successTitle, + errorTitle, +}) { + const logPeriodically = createPeriodicLogger(); + + info("Attempting to see if the selected tab can receive a profile."); + + return waitUntil(() => { + switch (gBrowser.selectedTab.label) { + case initialTitle: + logPeriodically(`> Waiting for the profile to be received.`); + return false; + case successTitle: + ok(true, "The profile was successfully injected to the page"); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + return true; + case errorTitle: + throw new Error( + "The fake frontend indicated that there was an error injecting the profile." + ); + default: + logPeriodically(`> Waiting for the fake frontend tab to be loaded.`); + return false; + } + }); +} +/* exported checkTabLoadedProfile */ + +/** + * This function checks the url of a tab so we can assert the frontend's url + * with our expected url. This function runs in a loop every + * requestAnimationFrame, and checks for a initialTitle. Asserts as soon as it + * finds that title. We don't have to look for success title or error title + * since we only care about the url. + * @param {{ + * initialTitle: string, + * successTitle: string, + * errorTitle: string, + * expectedUrl: string + * }} + */ +async function waitForTabUrl({ + initialTitle, + successTitle, + errorTitle, + expectedUrl, +}) { + const logPeriodically = createPeriodicLogger(); + + info(`Waiting for the selected tab to have the url "${expectedUrl}".`); + + return waitUntil(() => { + switch (gBrowser.selectedTab.label) { + case initialTitle: + case successTitle: + if (gBrowser.currentURI.spec === expectedUrl) { + ok(true, `The selected tab has the url ${expectedUrl}`); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + return true; + } + throw new Error( + `Found a different url on the fake frontend: ${gBrowser.currentURI.spec} (expecting ${expectedUrl})` + ); + case errorTitle: + throw new Error( + "The fake frontend indicated that there was an error injecting the profile." + ); + default: + logPeriodically(`> Waiting for the fake frontend tab to be loaded.`); + return false; + } + }); +} +/* exported waitForTabUrl */ + +/** + * This function checks the document title of a tab as an easy way to pass + * messages from a content page to the mochitest. + * @param {string} title + */ +async function waitForTabTitle(title) { + const logPeriodically = createPeriodicLogger(); + + info(`Waiting for the selected tab to have the title "${title}".`); + + return waitUntil(() => { + if (gBrowser.selectedTab.label === title) { + ok(true, `The selected tab has the title ${title}`); + return true; + } + logPeriodically(`> Waiting for the tab title to change.`); + return false; + }); +} +/* exported waitForTabTitle */ + +/** + * Open about:profiling in a new tab, and output helpful log messages. + * + * @template T + * @param {(Document) => T} callback + * @returns {Promise<T>} + */ +function withAboutProfiling(callback) { + info("Begin to open about:profiling in a new tab."); + return BrowserTestUtils.withNewTab( + "about:profiling", + async contentBrowser => { + info("about:profiling is now open in a tab."); + await TestUtils.waitForCondition( + () => + contentBrowser.contentDocument.getElementById("root") + .firstElementChild, + "Document's root has been populated" + ); + return callback(contentBrowser.contentDocument); + } + ); +} +/* exported withAboutProfiling */ + +/** + * Open DevTools and view the performance-new tab. After running the callback, clean + * up the test. + * + * @param {string} [url="about:blank"] url for the new tab + * @param {(Document, Document) => unknown} callback: the first parameter is the + * devtools panel's document, the + * second parameter is the opened tab's + * document. + * @param {Window} [aWindow] The browser's window object we target + * @returns {Promise<void>} + */ +async function withDevToolsPanel(url, callback, aWindow = window) { + if (typeof url === "function") { + aWindow = callback ?? window; + callback = url; + url = "about:blank"; + } + + const { gBrowser } = aWindow; + + const { + gDevTools, + } = require("resource://devtools/client/framework/devtools.js"); + + info(`Create a new tab with url "${url}".`); + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + info("Begin to open the DevTools and the performance-new panel."); + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "performance", + }); + + const { document } = toolbox.getCurrentPanel().panelWin; + + info("The performance-new panel is now open and ready to use."); + await callback(document, tab.linkedBrowser.contentDocument); + + info("About to remove the about:blank tab"); + await toolbox.destroy(); + + // The previous asynchronous functions may resolve within a tick after opening a new tab. + // We shouldn't remove the newly opened tab in the same tick. + // Wait for the next tick here. + await TestUtils.waitForTick(); + + // Take care to register the TabClose event before we call removeTab, to avoid + // race issues. + const waitForClosingPromise = BrowserTestUtils.waitForTabClosing(tab); + BrowserTestUtils.removeTab(tab); + info("Requested closing the about:blank tab, waiting..."); + await waitForClosingPromise; + info("The about:blank tab is now removed."); +} +/* exported withDevToolsPanel */ + +/** + * Start and stop the profiler to get the current active configuration. This is + * done programmtically through the nsIProfiler interface, rather than through click + * interactions, since the about:profiling page does not include buttons to control + * the recording. + * + * @returns {Object} + */ +function getActiveConfiguration() { + const BackgroundJSM = ChromeUtils.importESModule( + "resource://devtools/client/performance-new/shared/background.sys.mjs" + ); + + const { startProfiler, stopProfiler } = BackgroundJSM; + + info("Start the profiler with the current about:profiling configuration."); + startProfiler("aboutprofiling"); + + // Immediately pause the sampling, to make sure the test runs fast. The profiler + // only needs to be started to initialize the configuration. + Services.profiler.Pause(); + + const { activeConfiguration } = Services.profiler; + if (!activeConfiguration) { + throw new Error( + "Expected to find an active configuration for the profile." + ); + } + + info("Stop the profiler after getting the active configuration."); + stopProfiler(); + + return activeConfiguration; +} +/* exported getActiveConfiguration */ + +/** + * Start the profiler programmatically and check that the active configuration has + * a feature enabled + * + * @param {string} feature + * @return {boolean} + */ +function activeConfigurationHasFeature(feature) { + const { features } = getActiveConfiguration(); + return features.includes(feature); +} +/* exported activeConfigurationHasFeature */ + +/** + * Start the profiler programmatically and check that the active configuration is + * tracking a thread. + * + * @param {string} thread + * @return {boolean} + */ +function activeConfigurationHasThread(thread) { + const { threads } = getActiveConfiguration(); + return threads.includes(thread); +} +/* exported activeConfigurationHasThread */ + +/** + * Use user driven events to start the profiler, and then get the active configuration + * of the profiler. This is similar to functions in the head.js file, but is specific + * for the DevTools situation. The UI complains if the profiler stops unexpectedly. + * + * @param {Document} document + * @param {string} feature + * @returns {boolean} + */ +async function devToolsActiveConfigurationHasFeature(document, feature) { + info("Get the active configuration of the profiler via user driven events."); + const start = await getActiveButtonFromText(document, "Start recording"); + info("Click the button to start recording."); + start.click(); + + // Get the cancel button first, so that way we know the profile has actually + // been recorded. + const cancel = await getActiveButtonFromText(document, "Cancel recording"); + + const { activeConfiguration } = Services.profiler; + if (!activeConfiguration) { + throw new Error( + "Expected to find an active configuration for the profile." + ); + } + + info("Click the cancel button to discard the profile.."); + cancel.click(); + + // Wait until the start button is back. + await getActiveButtonFromText(document, "Start recording"); + + return activeConfiguration.features.includes(feature); +} +/* exported devToolsActiveConfigurationHasFeature */ + +/** + * This adapts the expectation using the current build's available profiler + * features. + * @param {string} fixture It can be either already trimmed or untrimmed. + * @returns {string} + */ +function _adaptCustomPresetExpectationToCustomBuild(fixture) { + const supportedFeatures = Services.profiler.GetFeatures(); + info("Supported features are: " + supportedFeatures.join(", ")); + + // Some platforms do not support stack walking, we can adjust the passed + // fixture so that tests are passing in these platforms too. + // Most notably MacOS outside of Nightly and DevEdition. + if (!supportedFeatures.includes("stackwalk")) { + info( + "Supported features do not include stackwalk, let's remove the Native Stacks from the expected output." + ); + fixture = fixture.replace(/^.*Native Stacks.*\n/m, ""); + } + + return fixture; +} + +/** + * Get the content of the preset description. + * @param {Element} devtoolsDocument + * @returns {string} + */ +function getDevtoolsCustomPresetContent(devtoolsDocument) { + return devtoolsDocument.querySelector(".perf-presets-custom").innerText; +} +/* exported getDevtoolsCustomPresetContent */ + +/** + * This checks if the content of the preset description equals the fixture in + * string form. + * @param {Element} devtoolsDocument + * @param {string} fixture + */ +function checkDevtoolsCustomPresetContent(devtoolsDocument, fixture) { + // This removes all indentations and any start or end new line and other space characters. + fixture = fixture.replace(/^\s+/gm, "").trim(); + // This removes unavailable features from the fixture content. + fixture = _adaptCustomPresetExpectationToCustomBuild(fixture); + is(getDevtoolsCustomPresetContent(devtoolsDocument), fixture); +} +/* exported checkDevtoolsCustomPresetContent */ + +/** + * Selects an element with some given text, then it walks up the DOM until it finds + * an input or select element via a call to querySelector. + * + * @param {Document} document + * @param {string} text + * @param {HTMLInputElement} + */ +async function getNearestInputFromText(document, text) { + const textElement = await getElementFromDocumentByText(document, text); + if (textElement.control) { + // This is a label, just grab the input. + return textElement.control; + } + // A non-label node + let next = textElement; + while ((next = next.parentElement)) { + const input = next.querySelector("input, select"); + if (input) { + return input; + } + } + throw new Error("Could not find an input or select near the text element."); +} +/* exported getNearestInputFromText */ + +/** + * Grabs the closest button element from a given snippet of text, and make sure + * the button is not disabled. + * + * @param {Document} document + * @param {string} text + * @param {HTMLButtonElement} + */ +async function getActiveButtonFromText(document, text) { + // This could select a span inside the button, or the button itself. + let button = await getElementFromDocumentByText(document, text); + + while (button.tagName !== "button") { + // Walk up until a button element is found. + button = button.parentElement; + if (!button) { + throw new Error(`Unable to find a button from the text "${text}"`); + } + } + + await waitUntil( + () => !button.disabled, + "Waiting until the button is not disabled." + ); + + return button; +} +/* exported getActiveButtonFromText */ + +/** + * Wait until the profiler menu button is added. + * + * @returns Promise<void> + */ +async function waitForProfilerMenuButton() { + info("Checking if the profiler menu button is enabled."); + await waitUntil( + () => gBrowser.ownerDocument.getElementById("profiler-button"), + "> Waiting until the profiler button is added to the browser." + ); +} +/* exported waitForProfilerMenuButton */ + +/** + * Make sure the profiler popup is disabled for the test. + */ +async function makeSureProfilerPopupIsDisabled() { + info("Make sure the profiler popup is dsiabled."); + + info("> Load the profiler menu button module."); + const { ProfilerMenuButton } = ChromeUtils.importESModule( + "resource://devtools/client/performance-new/popup/menu-button.sys.mjs" + ); + + const isOriginallyInNavBar = ProfilerMenuButton.isInNavbar(); + + if (isOriginallyInNavBar) { + info("> The menu button is in the navbar, remove it for this test."); + ProfilerMenuButton.remove(); + } else { + info("> The menu button was not in the navbar yet."); + } + + registerCleanupFunction(() => { + info("Revert the profiler menu button to be back in its original place"); + if (isOriginallyInNavBar !== ProfilerMenuButton.isInNavbar()) { + ProfilerMenuButton.remove(); + } + }); +} +/* exported makeSureProfilerPopupIsDisabled */ + +/** + * Open the WebChannel test document, that will enable the profiler popup via + * WebChannel. + * @param {Function} callback + */ +function withWebChannelTestDocument(callback) { + return BrowserTestUtils.withNewTab( + { + gBrowser, + url: "http://example.com/browser/devtools/client/performance-new/test/browser/webchannel.html", + }, + callback + ); +} +/* exported withWebChannelTestDocument */ + +// This has been stolen from the great library dom-testing-library. +// See https://github.com/testing-library/dom-testing-library/blob/91b9dc3b6f5deea88028e97aab15b3b9f3289a2a/src/events.js#L104-L123 +// function written after some investigation here: +// https://github.com/facebook/react/issues/10135#issuecomment-401496776 +function setNativeValue(element, value) { + const { set: valueSetter } = + Object.getOwnPropertyDescriptor(element, "value") || {}; + const prototype = Object.getPrototypeOf(element); + const { set: prototypeValueSetter } = + Object.getOwnPropertyDescriptor(prototype, "value") || {}; + if (prototypeValueSetter && valueSetter !== prototypeValueSetter) { + prototypeValueSetter.call(element, value); + } else { + /* istanbul ignore if */ + // eslint-disable-next-line no-lonely-if -- Can't be ignored by istanbul otherwise + if (valueSetter) { + valueSetter.call(element, value); + } else { + throw new Error("The given element does not have a value setter"); + } + } +} +/* exported setNativeValue */ + +/** + * Set a React-friendly input value. Doing this the normal way doesn't work. + * This reuses the previous function setNativeValue stolen from + * dom-testing-library. + * + * See https://github.com/facebook/react/issues/10135 + * + * @param {HTMLInputElement} input + * @param {string} value + */ +function setReactFriendlyInputValue(input, value) { + setNativeValue(input, value); + + // 'change' instead of 'input', see https://github.com/facebook/react/issues/11488#issuecomment-381590324 + input.dispatchEvent(new Event("change", { bubbles: true })); +} +/* exported setReactFriendlyInputValue */ + +/** + * The recording state is the internal state machine that represents the async + * operations that are going on in the profiler. This function sets up a helper + * that will obtain the Redux store and query this internal state. This is useful + * for unit testing purposes. + * + * @param {Document} document + */ +function setupGetRecordingState(document) { + const selectors = require("resource://devtools/client/performance-new/store/selectors.js"); + const store = document.defaultView.gStore; + if (!store) { + throw new Error("Could not find the redux store on the window object."); + } + return () => selectors.getRecordingState(store.getState()); +} +/* exported setupGetRecordingState */ diff --git a/devtools/client/performance-new/test/browser/webchannel.html b/devtools/client/performance-new/test/browser/webchannel.html new file mode 100644 index 0000000000..c1a0872cfe --- /dev/null +++ b/devtools/client/performance-new/test/browser/webchannel.html @@ -0,0 +1,26 @@ +<!DOCTYPE html> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<html> + <head> + <meta charset="utf-8"/> + <title></title> + </head> + <body> + This content page will send a WebChannel message to enable the profiler menu button. + <script> + "use strict"; + document.title = "WebChannel Page Ready"; + + window.dispatchEvent( + new CustomEvent('WebChannelMessageToChrome', { + detail: JSON.stringify({ + id: 'profiler.firefox.com', + message: { type: "ENABLE_MENU_BUTTON", requestId: 0 }, + }), + }) + ); + </script> + </body> +</html> diff --git a/devtools/client/performance-new/test/xpcshell/.eslintrc.js b/devtools/client/performance-new/test/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..b6aacf458f --- /dev/null +++ b/devtools/client/performance-new/test/xpcshell/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the shared list of defined globals for xpcshell tests. + extends: "../../../../.eslintrc.xpcshell.js", +}; diff --git a/devtools/client/performance-new/test/xpcshell/head.js b/devtools/client/performance-new/test/xpcshell/head.js new file mode 100644 index 0000000000..7dbb67ecfd --- /dev/null +++ b/devtools/client/performance-new/test/xpcshell/head.js @@ -0,0 +1,12 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +registerCleanupFunction(() => { + // Always clean up the prefs after every test. + const { revertRecordingSettings } = ChromeUtils.importESModule( + "resource://devtools/client/performance-new/shared/background.sys.mjs" + ); + revertRecordingSettings(); +}); diff --git a/devtools/client/performance-new/test/xpcshell/test_popup_initial_state.js b/devtools/client/performance-new/test/xpcshell/test_popup_initial_state.js new file mode 100644 index 0000000000..a25d134473 --- /dev/null +++ b/devtools/client/performance-new/test/xpcshell/test_popup_initial_state.js @@ -0,0 +1,106 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/** + * Tests the initial state of the background script for the popup. + */ + +function setupBackgroundJsm() { + return ChromeUtils.importESModule( + "resource://devtools/client/performance-new/shared/background.sys.mjs" + ); +} + +add_task(function test() { + info("Test that we get the default preference values from the browser."); + const { getRecordingSettings } = setupBackgroundJsm(); + + const preferences = getRecordingSettings( + "aboutprofiling", + Services.profiler.GetFeatures() + ); + + Assert.notEqual( + preferences.entries, + undefined, + "The initial state has the default entries." + ); + Assert.notEqual( + preferences.interval, + undefined, + "The initial state has the default interval." + ); + Assert.notEqual( + preferences.features, + undefined, + "The initial state has the default features." + ); + Assert.equal( + preferences.features.includes("js"), + true, + "The js feature is initialized to the default." + ); + Assert.notEqual( + preferences.threads, + undefined, + "The initial state has the default threads." + ); + Assert.equal( + preferences.threads.includes("GeckoMain"), + true, + "The GeckoMain thread is initialized to the default." + ); + Assert.notEqual( + preferences.objdirs, + undefined, + "The initial state has the default objdirs." + ); + Assert.notEqual( + preferences.duration, + undefined, + "The duration is initialized to the duration." + ); +}); + +add_task(function test() { + info( + "Test that the state and features are properly validated. This ensures that as " + + "we add and remove features, the stored preferences do not cause the Gecko " + + "Profiler interface to crash with invalid values." + ); + const { getRecordingSettings, setRecordingSettings, changePreset } = + setupBackgroundJsm(); + + const supportedFeatures = Services.profiler.GetFeatures(); + + changePreset("aboutprofiling", "custom", supportedFeatures); + + Assert.ok( + getRecordingSettings("aboutprofiling", supportedFeatures).features.includes( + "js" + ), + "The js preference is present initially." + ); + + const settings = getRecordingSettings("aboutprofiling", supportedFeatures); + settings.features = settings.features.filter(feature => feature !== "js"); + settings.features.push("UNKNOWN_FEATURE_FOR_TESTS"); + setRecordingSettings("aboutprofiling", settings); + + Assert.ok( + !getRecordingSettings( + "aboutprofiling", + supportedFeatures + ).features.includes("UNKNOWN_FEATURE_FOR_TESTS"), + "The unknown feature is removed." + ); + Assert.ok( + !getRecordingSettings( + "aboutprofiling", + supportedFeatures + ).features.includes("js"), + "The js preference is still flipped from the default." + ); +}); diff --git a/devtools/client/performance-new/test/xpcshell/test_remove_common_path_prefix.js b/devtools/client/performance-new/test/xpcshell/test_remove_common_path_prefix.js new file mode 100644 index 0000000000..563f0c43b4 --- /dev/null +++ b/devtools/client/performance-new/test/xpcshell/test_remove_common_path_prefix.js @@ -0,0 +1,121 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); +const { + withCommonPathPrefixRemoved, +} = require("resource://devtools/client/performance-new/shared/utils.js"); + +add_task(function test() { + info( + "withCommonPathPrefixRemoved() removes the common prefix from an array " + + "of paths. This test ensures that the paths are correctly removed." + ); + + if (Services.appinfo.OS === "WINNT") { + info("Check Windows paths"); + + deepEqual(withCommonPathPrefixRemoved([]), [], "Windows empty paths"); + + deepEqual( + withCommonPathPrefixRemoved(["source\\file1.js", "source\\file2.js"]), + ["source\\file1.js", "source\\file2.js"], + "Windows relative paths" + ); + + deepEqual( + withCommonPathPrefixRemoved([ + "C:\\Users\\SomeUser\\Desktop\\source\\file1.js", + "D:\\Users\\SomeUser\\Desktop\\source\\file2.js", + ]), + [ + "C:\\Users\\SomeUser\\Desktop\\source\\file1.js", + "D:\\Users\\SomeUser\\Desktop\\source\\file2.js", + ], + "Windows multiple disks" + ); + + deepEqual( + withCommonPathPrefixRemoved([ + "C:\\Users\\SomeUser\\Desktop\\source\\file1.js", + "C:\\Users\\SomeUser\\Desktop\\source\\file2.js", + ]), + ["file1.js", "file2.js"], + "Windows full path match" + ); + + deepEqual( + withCommonPathPrefixRemoved([ + "C:\\Users\\SomeUser\\Desktop\\source\\file1.js", + "C:\\Users\\SomeUser\\file2.js", + ]), + ["Desktop\\source\\file1.js", "file2.js"], + "Windows path match at level 3" + ); + + deepEqual( + withCommonPathPrefixRemoved([ + "C:\\Users\\SomeUser\\Desktop\\source\\file1.js", + "C:\\Users\\SomeUser\\file2.js", + "C:\\Users\\file3.js", + ]), + ["SomeUser\\Desktop\\source\\file1.js", "SomeUser\\file2.js", "file3.js"], + "Windows path match at level 2" + ); + + deepEqual( + withCommonPathPrefixRemoved(["C:\\dev"]), + ["C:\\dev"], + "Windows path match at level 1" + ); + } else { + info("Check UNIX paths"); + + deepEqual(withCommonPathPrefixRemoved([]), [], "UNIX empty paths"); + + deepEqual( + withCommonPathPrefixRemoved(["source/file1.js", "source/file2.js"]), + ["source/file1.js", "source/file2.js"], + "UNIX relative paths" + ); + + deepEqual( + withCommonPathPrefixRemoved([ + "/home/someuser/Desktop/source/file1.js", + "/home/someuser/Desktop/source/file2.js", + ]), + ["file1.js", "file2.js"], + "UNIX full path match" + ); + + deepEqual( + withCommonPathPrefixRemoved([ + "/home/someuser/Desktop/source/file1.js", + "/home/someuser/file2.js", + ]), + ["Desktop/source/file1.js", "file2.js"], + "UNIX path match at level 3" + ); + + deepEqual( + withCommonPathPrefixRemoved([ + "/home/someuser/Desktop/source/file1.js", + "/home/someuser/file2.js", + "/home/file3.js", + ]), + ["someuser/Desktop/source/file1.js", "someuser/file2.js", "file3.js"], + "UNIX path match at level 2" + ); + + deepEqual( + withCommonPathPrefixRemoved(["/bin"]), + ["/bin"], + "UNIX path match at level 1" + ); + } +}); diff --git a/devtools/client/performance-new/test/xpcshell/test_webchannel-urls.js b/devtools/client/performance-new/test/xpcshell/test_webchannel-urls.js new file mode 100644 index 0000000000..0c1a03eb0e --- /dev/null +++ b/devtools/client/performance-new/test/xpcshell/test_webchannel-urls.js @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { validateProfilerWebChannelUrl } = ChromeUtils.importESModule( + "resource:///modules/DevToolsStartup.sys.mjs" +); + +add_task(function test() { + info( + "Since the WebChannel can communicate with a content page, test that only " + + "trusted URLs can be used with this mechanism." + ); + + const { checkUrlIsValid, checkUrlIsInvalid } = setup(); + + info("Check all of the valid URLs"); + checkUrlIsValid("https://profiler.firefox.com"); + checkUrlIsValid("http://example.com"); + checkUrlIsValid("http://localhost:4242"); + checkUrlIsValid("http://localhost:32343434"); + checkUrlIsValid("http://localhost:4242/"); + checkUrlIsValid("https://deploy-preview-1234--perf-html.netlify.com"); + checkUrlIsValid("https://deploy-preview-1234--perf-html.netlify.com/"); + checkUrlIsValid("https://deploy-preview-1234--perf-html.netlify.app"); + checkUrlIsValid("https://deploy-preview-1234--perf-html.netlify.app/"); + checkUrlIsValid("https://main--perf-html.netlify.app/"); + + info("Check all of the invalid URLs"); + checkUrlIsInvalid("https://profiler.firefox.com/some-other-path"); + checkUrlIsInvalid("http://localhost:4242/some-other-path"); + checkUrlIsInvalid("http://profiler.firefox.com.example.com"); + checkUrlIsInvalid("http://mozilla.com"); + checkUrlIsInvalid("https://deploy-preview-1234--perf-html.netlify.dev"); + checkUrlIsInvalid("https://anything--perf-html.netlify.app/"); +}); + +function setup() { + function checkUrlIsValid(url) { + info(`Check that ${url} is valid`); + equal( + validateProfilerWebChannelUrl(url), + url, + `"${url}" is a valid WebChannel URL.` + ); + } + + function checkUrlIsInvalid(url) { + info(`Check that ${url} is invalid`); + equal( + validateProfilerWebChannelUrl(url), + "https://profiler.firefox.com", + `"${url}" was not valid, and was reset to the base URL.` + ); + } + + return { + checkUrlIsValid, + checkUrlIsInvalid, + }; +} diff --git a/devtools/client/performance-new/test/xpcshell/xpcshell.toml b/devtools/client/performance-new/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..ce161877dc --- /dev/null +++ b/devtools/client/performance-new/test/xpcshell/xpcshell.toml @@ -0,0 +1,10 @@ +[DEFAULT] +tags = "devtools" +head = "head.js" +firefox-appdir = "browser" + +["test_popup_initial_state.js"] + +["test_remove_common_path_prefix.js"] + +["test_webchannel-urls.js"] diff --git a/devtools/client/performance-new/tsconfig.json b/devtools/client/performance-new/tsconfig.json new file mode 100644 index 0000000000..f98b4b33d4 --- /dev/null +++ b/devtools/client/performance-new/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "module": "commonjs", + // Set the baseUrl to the root of the project. + "baseUrl": "../../..", + "paths": { + "resource://devtools/*": ["devtools/*"] + }, + // Make the type checking as strict as possible. + "strict": true, + // TypeScript will check JS files only if they have a @ts-check comment in them. + "allowJs": true, + // Only type check, don't emit files. + "noEmit": true, + // Allow esnext syntax. Otherwise the default is ES5 only. + "target": "esnext", + "lib": ["esnext", "dom"] + }, + "files": ["./@types/gecko.d.ts"], + // Add a @ts-check comment to a JS file to start type checking it. + "include": ["./**/*.js"], + "exclude": [ + // For some reason some test files were still being scanned, and creating + // transient errors. Manually exclude this directory until the tests are + // typed. + "./test" + ] +} diff --git a/devtools/client/performance-new/typescript.md b/devtools/client/performance-new/typescript.md new file mode 100644 index 0000000000..8f9e405299 --- /dev/null +++ b/devtools/client/performance-new/typescript.md @@ -0,0 +1,38 @@ +# TypeScript Experiment + +This folder contains an experiment to add TypeScript to Gecko. The type checking can be run manually via: + +``` +cd devtools/client/performance-new +yarn install +yarn test +``` + +Also, the types should work with editor integration. VS Code works with TypeScript by default, and should pick up the types here. + +The type checking is also included in the DevTools node tests, which can be run manually via: +``` +node devtools/client/bin/devtools-node-test-runner.js --suite=performance +``` + +More importantly the DevTools node tests run on Continuous Integration. They are included in the DevTools presets `devtools` and `devtools-linux`. They can also be found via `mach try fuzzy`, under the name "source-test-node-devtools-tests". To recap, the following try pushes will run the DevTools node tests: + +DevTools node tests are also automatically run for any Phabricator diff which impacts DevTools. If the job fails, a bot will add a comment on the corresponding Phabricator diff. + +## Do not overload require + +Anytime that our code creates the `require` function through a BrowserLoader, it can conflict with the TypeScript type system. For example: + +``` +const { require } = BrowserLoader(...); +``` + +TypeScript treats `require` as a special keyword. If the variable is defined on the page, then it shadow's TypeScript's keyword, and the require machinery will be improperly typed as an `any`. Care needs to be taken to get around this. Here is a solution of hiding the `require` function from TypeScript: + +``` +const browserLoader = BrowserLoader(...); + +/** @type {any} - */ +const scope = this; +scope.require = browserLoader.require; +``` diff --git a/devtools/client/performance-new/yarn.lock b/devtools/client/performance-new/yarn.lock new file mode 100644 index 0000000000..95528876a1 --- /dev/null +++ b/devtools/client/performance-new/yarn.lock @@ -0,0 +1,95 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@types/hoist-non-react-statics@^3.3.0": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" + integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA== + dependencies: + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + +"@types/prop-types@*": + version "15.7.3" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7" + integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw== + +"@types/react-dom-factories@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@types/react-dom-factories/-/react-dom-factories-1.0.2.tgz#85167cf27a92d3b90cf7380a2d28b9ba353e765e" + integrity sha512-TDrw8nuD8DYXsPKc5qA5lpPw0q2Ejq9di4OiW/vrVItzBzImfx8OaeW/RMl11woYFx6IcaOmcsev6ZuvLi8ctg== + dependencies: + "@types/react" "*" + +"@types/react-redux@^7.1.18": + version "7.1.18" + resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.18.tgz#2bf8fd56ebaae679a90ebffe48ff73717c438e04" + integrity sha512-9iwAsPyJ9DLTRH+OFeIrm9cAbIj1i2ANL3sKQFATqnPWRbg+jEFXyZOKHiQK/N86pNRXbb4HRxAxo0SIX1XwzQ== + dependencies: + "@types/hoist-non-react-statics" "^3.3.0" + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + redux "^4.0.0" + +"@types/react@*": + version "16.9.10" + resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.10.tgz#87c2a7cd715d293c42fe73510eec42cba3ee8210" + integrity sha512-J1sinqwBCzazC+DOmbwRc96pNf0KBOVkefV5DVfLX9qiHdltfFH2BUqc36UYcYSU5tIrZtaaVMAx4JwOq5/Q/g== + dependencies: + "@types/prop-types" "*" + csstype "^2.2.0" + +"@types/redux@^3.6.0": + version "3.6.0" + resolved "https://registry.yarnpkg.com/@types/redux/-/redux-3.6.0.tgz#f1ebe1e5411518072e4fdfca5c76e16e74c1399a" + integrity sha1-8evh5UEVGAcuT9/KXHbhbnTBOZo= + dependencies: + redux "*" + +csstype@^2.2.0: + version "2.6.7" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.7.tgz#20b0024c20b6718f4eda3853a1f5a1cce7f5e4a5" + integrity sha512-9Mcn9sFbGBAdmimWb2gLVDtFJzeKtDGIr76TUqmjZrw9LFXBMSU70lcs+C0/7fyCd6iBDqmksUcCOUIkisPHsQ== + +hoist-non-react-statics@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz#b09178f0122184fb95acf525daaecb4d8f45958b" + integrity sha512-0XsbTXxgiaCDYDIWFcwkmerZPSwywfUqYmwT4jzewKTQSWoE6FCMoUVOeBJWK3E/CrWbxRG3m5GzY4lnIwGRBA== + dependencies: + react-is "^16.7.0" + +"js-tokens@^3.0.0 || ^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +loose-envify@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +react-is@^16.7.0: + version "16.11.0" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.11.0.tgz#b85dfecd48ad1ce469ff558a882ca8e8313928fa" + integrity sha512-gbBVYR2p8mnriqAwWx9LbuUrShnAuSCNnuPGyc7GJrMVQtPDAh8iLpv7FRuMPFb56KkaVZIYSz1PrjI9q0QPCw== + +redux@*, redux@^4.0.0: + version "4.0.4" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.4.tgz#4ee1aeb164b63d6a1bcc57ae4aa0b6e6fa7a3796" + integrity sha512-vKv4WdiJxOWKxK0yRoaK3Y4pxxB0ilzVx6dszU2W8wLxlb2yikRph4iV/ymtdJ6ZxpBLFbyrxklnT5yBbQSl3Q== + dependencies: + loose-envify "^1.4.0" + symbol-observable "^1.2.0" + +symbol-observable@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" + integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== + +typescript@^5.3.3: + version "5.3.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37" + integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== |