From 2aa4a82499d4becd2284cdb482213d541b8804dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 28 Apr 2024 16:29:10 +0200 Subject: Adding upstream version 86.0.1. Signed-off-by: Daniel Baumann --- devtools/client/performance-new/.eslintrc.js | 12 + devtools/client/performance-new/@types/README.md | 5 + .../performance-new/@types/frame-script.d.ts | 17 + devtools/client/performance-new/@types/gecko.d.ts | 396 +++++++++++ devtools/client/performance-new/@types/perf.d.ts | 514 +++++++++++++++ devtools/client/performance-new/README.md | 43 ++ .../performance-new/aboutprofiling/README.md | 3 + .../performance-new/aboutprofiling/index.xhtml | 17 + .../performance-new/aboutprofiling/initializer.js | 161 +++++ .../performance-new/aboutprofiling/moz.build | 7 + devtools/client/performance-new/browser.js | 284 ++++++++ .../performance-new/components/AboutProfiling.js | 161 +++++ .../performance-new/components/Description.js | 69 ++ .../performance-new/components/DevToolsPanel.js | 83 +++ .../components/DevToolsPresetSelection.js | 204 ++++++ .../performance-new/components/DirectoryPicker.js | 122 ++++ .../components/OnboardingMessage.js | 144 ++++ .../client/performance-new/components/Presets.js | 163 +++++ .../components/ProfilerEventHandling.js | 269 ++++++++ .../client/performance-new/components/README.md | 3 + .../client/performance-new/components/Range.js | 79 +++ .../performance-new/components/RecordingButton.js | 254 +++++++ .../client/performance-new/components/Settings.js | 620 ++++++++++++++++++ .../client/performance-new/components/moz.build | 18 + devtools/client/performance-new/frame-script.js | 200 ++++++ devtools/client/performance-new/index.xhtml | 24 + devtools/client/performance-new/initializer.js | 167 +++++ devtools/client/performance-new/moz.build | 27 + devtools/client/performance-new/package.json | 16 + devtools/client/performance-new/panel.js | 90 +++ devtools/client/performance-new/popup/README.md | 3 + .../client/performance-new/popup/background.jsm.js | 620 ++++++++++++++++++ .../performance-new/popup/menu-button.jsm.js | 334 ++++++++++ devtools/client/performance-new/popup/moz.build | 13 + devtools/client/performance-new/popup/panel.jsm.js | 386 +++++++++++ .../performance-new/preference-management.js | 58 ++ devtools/client/performance-new/store/README.md | 3 + devtools/client/performance-new/store/actions.js | 247 +++++++ devtools/client/performance-new/store/moz.build | 10 + devtools/client/performance-new/store/reducers.js | 230 +++++++ devtools/client/performance-new/store/selectors.js | 179 +++++ .../client/performance-new/symbolication.jsm.js | 184 ++++++ devtools/client/performance-new/test/.eslintrc.js | 6 + .../performance-new/test/browser/.eslintrc.js | 9 + .../performance-new/test/browser/browser.ini | 43 ++ .../test/browser/browser_aboutprofiling-entries.js | 28 + .../browser_aboutprofiling-env-restart-button.js | 81 +++ .../browser_aboutprofiling-features-disabled.js | 63 ++ .../browser/browser_aboutprofiling-features.js | 32 + .../browser/browser_aboutprofiling-interval.js | 32 + .../browser_aboutprofiling-presets-custom.js | 128 ++++ .../test/browser/browser_aboutprofiling-presets.js | 60 ++ .../browser_aboutprofiling-threads-behavior.js | 126 ++++ .../test/browser/browser_aboutprofiling-threads.js | 38 ++ .../test/browser/browser_devtools-interrupted.js | 42 ++ .../test/browser/browser_devtools-onboarding.js | 95 +++ .../test/browser/browser_devtools-presets.js | 47 ++ .../browser/browser_devtools-previously-started.js | 61 ++ .../browser/browser_devtools-private-window.js | 57 ++ .../browser/browser_devtools-record-capture.js | 90 +++ .../browser/browser_devtools-record-discard.js | 35 + .../test/browser/browser_popup-private-browsing.js | 48 ++ .../test/browser/browser_popup-profiler-states.js | 81 +++ .../browser/browser_popup-record-capture-view.js | 67 ++ .../test/browser/browser_popup-record-capture.js | 40 ++ .../test/browser/browser_popup-record-discard.js | 34 + .../test/browser/browser_split-toolbar-button.js | 123 ++++ .../browser_webchannel-enable-menu-button.js | 16 + .../test/browser/fake-frontend.html | 74 +++ .../client/performance-new/test/browser/head.js | 728 +++++++++++++++++++++ .../performance-new/test/browser/webchannel.html | 27 + .../performance-new/test/xpcshell/.eslintrc.js | 6 + .../client/performance-new/test/xpcshell/head.js | 14 + .../test/xpcshell/test_popup_initial_state.js | 110 ++++ .../test/xpcshell/test_webchannel-urls.js | 63 ++ .../performance-new/test/xpcshell/xpcshell.ini | 7 + devtools/client/performance-new/tsconfig.json | 25 + .../performance-new/typescript-lazy-load.jsm.js | 54 ++ devtools/client/performance-new/typescript.md | 54 ++ devtools/client/performance-new/utils.js | 434 ++++++++++++ devtools/client/performance-new/yarn.lock | 95 +++ 81 files changed, 9612 insertions(+) create mode 100644 devtools/client/performance-new/.eslintrc.js create mode 100644 devtools/client/performance-new/@types/README.md create mode 100644 devtools/client/performance-new/@types/frame-script.d.ts create mode 100644 devtools/client/performance-new/@types/gecko.d.ts create mode 100644 devtools/client/performance-new/@types/perf.d.ts create mode 100644 devtools/client/performance-new/README.md create mode 100644 devtools/client/performance-new/aboutprofiling/README.md create mode 100644 devtools/client/performance-new/aboutprofiling/index.xhtml create mode 100644 devtools/client/performance-new/aboutprofiling/initializer.js create mode 100644 devtools/client/performance-new/aboutprofiling/moz.build create mode 100644 devtools/client/performance-new/browser.js create mode 100644 devtools/client/performance-new/components/AboutProfiling.js create mode 100644 devtools/client/performance-new/components/Description.js create mode 100644 devtools/client/performance-new/components/DevToolsPanel.js create mode 100644 devtools/client/performance-new/components/DevToolsPresetSelection.js create mode 100644 devtools/client/performance-new/components/DirectoryPicker.js create mode 100644 devtools/client/performance-new/components/OnboardingMessage.js create mode 100644 devtools/client/performance-new/components/Presets.js create mode 100644 devtools/client/performance-new/components/ProfilerEventHandling.js create mode 100644 devtools/client/performance-new/components/README.md create mode 100644 devtools/client/performance-new/components/Range.js create mode 100644 devtools/client/performance-new/components/RecordingButton.js create mode 100644 devtools/client/performance-new/components/Settings.js create mode 100644 devtools/client/performance-new/components/moz.build create mode 100644 devtools/client/performance-new/frame-script.js create mode 100644 devtools/client/performance-new/index.xhtml create mode 100644 devtools/client/performance-new/initializer.js create mode 100644 devtools/client/performance-new/moz.build create mode 100644 devtools/client/performance-new/package.json create mode 100644 devtools/client/performance-new/panel.js create mode 100644 devtools/client/performance-new/popup/README.md create mode 100644 devtools/client/performance-new/popup/background.jsm.js create mode 100644 devtools/client/performance-new/popup/menu-button.jsm.js create mode 100644 devtools/client/performance-new/popup/moz.build create mode 100644 devtools/client/performance-new/popup/panel.jsm.js create mode 100644 devtools/client/performance-new/preference-management.js create mode 100644 devtools/client/performance-new/store/README.md create mode 100644 devtools/client/performance-new/store/actions.js create mode 100644 devtools/client/performance-new/store/moz.build create mode 100644 devtools/client/performance-new/store/reducers.js create mode 100644 devtools/client/performance-new/store/selectors.js create mode 100644 devtools/client/performance-new/symbolication.jsm.js create mode 100644 devtools/client/performance-new/test/.eslintrc.js create mode 100644 devtools/client/performance-new/test/browser/.eslintrc.js create mode 100644 devtools/client/performance-new/test/browser/browser.ini create mode 100644 devtools/client/performance-new/test/browser/browser_aboutprofiling-entries.js create mode 100644 devtools/client/performance-new/test/browser/browser_aboutprofiling-env-restart-button.js create mode 100644 devtools/client/performance-new/test/browser/browser_aboutprofiling-features-disabled.js create mode 100644 devtools/client/performance-new/test/browser/browser_aboutprofiling-features.js create mode 100644 devtools/client/performance-new/test/browser/browser_aboutprofiling-interval.js create mode 100644 devtools/client/performance-new/test/browser/browser_aboutprofiling-presets-custom.js create mode 100644 devtools/client/performance-new/test/browser/browser_aboutprofiling-presets.js create mode 100644 devtools/client/performance-new/test/browser/browser_aboutprofiling-threads-behavior.js create mode 100644 devtools/client/performance-new/test/browser/browser_aboutprofiling-threads.js create mode 100644 devtools/client/performance-new/test/browser/browser_devtools-interrupted.js create mode 100644 devtools/client/performance-new/test/browser/browser_devtools-onboarding.js create mode 100644 devtools/client/performance-new/test/browser/browser_devtools-presets.js create mode 100644 devtools/client/performance-new/test/browser/browser_devtools-previously-started.js create mode 100644 devtools/client/performance-new/test/browser/browser_devtools-private-window.js create mode 100644 devtools/client/performance-new/test/browser/browser_devtools-record-capture.js create mode 100644 devtools/client/performance-new/test/browser/browser_devtools-record-discard.js create mode 100644 devtools/client/performance-new/test/browser/browser_popup-private-browsing.js create mode 100644 devtools/client/performance-new/test/browser/browser_popup-profiler-states.js create mode 100644 devtools/client/performance-new/test/browser/browser_popup-record-capture-view.js create mode 100644 devtools/client/performance-new/test/browser/browser_popup-record-capture.js create mode 100644 devtools/client/performance-new/test/browser/browser_popup-record-discard.js create mode 100644 devtools/client/performance-new/test/browser/browser_split-toolbar-button.js create mode 100644 devtools/client/performance-new/test/browser/browser_webchannel-enable-menu-button.js create mode 100644 devtools/client/performance-new/test/browser/fake-frontend.html create mode 100644 devtools/client/performance-new/test/browser/head.js create mode 100644 devtools/client/performance-new/test/browser/webchannel.html create mode 100644 devtools/client/performance-new/test/xpcshell/.eslintrc.js create mode 100644 devtools/client/performance-new/test/xpcshell/head.js create mode 100644 devtools/client/performance-new/test/xpcshell/test_popup_initial_state.js create mode 100644 devtools/client/performance-new/test/xpcshell/test_webchannel-urls.js create mode 100644 devtools/client/performance-new/test/xpcshell/xpcshell.ini create mode 100644 devtools/client/performance-new/tsconfig.json create mode 100644 devtools/client/performance-new/typescript-lazy-load.jsm.js create mode 100644 devtools/client/performance-new/typescript.md create mode 100644 devtools/client/performance-new/utils.js create mode 100644 devtools/client/performance-new/yarn.lock (limited to 'devtools/client/performance-new') 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/frame-script.d.ts b/devtools/client/performance-new/@types/frame-script.d.ts new file mode 100644 index 0000000000..4271a8222e --- /dev/null +++ b/devtools/client/performance-new/@types/frame-script.d.ts @@ -0,0 +1,17 @@ +/* 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 globals for the Gecko Profiler frame script environment. + */ + +interface ContentWindow { + wrappedJSObject: { + connectToGeckoProfiler?: ( + interface: import("./perf").GeckoProfilerFrameScriptInterface + ) => void; + }; +} + +declare var content: ContentWindow; 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..64a09214e3 --- /dev/null +++ b/devtools/client/performance-new/@types/gecko.d.ts @@ -0,0 +1,396 @@ +/* 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 { + "resource://gre/modules/Services.jsm": + typeof import("resource://gre/modules/Services.jsm"); + "Services": + typeof import("Services"); + "chrome": + typeof import("chrome"); + "resource://gre/modules/osfile.jsm": + typeof import("resource://gre/modules/osfile.jsm"); + "resource://gre/modules/AppConstants.jsm": + typeof import("resource://gre/modules/AppConstants.jsm"); + "resource://gre/modules/ProfilerGetSymbols.jsm": + typeof import("resource://gre/modules/ProfilerGetSymbols.jsm"); + "resource:///modules/CustomizableUI.jsm": + typeof import("resource:///modules/CustomizableUI.jsm") + "resource:///modules/CustomizableWidgets.jsm": + typeof import("resource:///modules/CustomizableWidgets.jsm"); + "resource://devtools/shared/Loader.jsm": + typeof import("resource://devtools/shared/Loader.jsm"); + "resource://devtools/client/performance-new/popup/background.jsm.js": + typeof import("resource://devtools/client/performance-new/popup/background.jsm.js"); + "resource://devtools/client/shared/browser-loader.js": any; + "resource://devtools/client/performance-new/popup/menu-button.jsm.js": + typeof import("devtools/client/performance-new/popup/menu-button.jsm.js"); + "resource://devtools/client/performance-new/typescript-lazy-load.jsm.js": + typeof import("devtools/client/performance-new/typescript-lazy-load.jsm.js"); + "resource://devtools/client/performance-new/popup/panel.jsm.js": + typeof import("devtools/client/performance-new/popup/panel.jsm.js"); + "resource://devtools/client/performance-new/symbolication.jsm.js": + typeof import("resource://devtools/client/performance-new/symbolication.jsm.js"); + "resource:///modules/PanelMultiView.jsm": + typeof import("resource:///modules/PanelMultiView.jsm"); + } + + 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: (module: S) => KnownModules[S]; + createObjectIn: (content: ContentWindow) => object; + exportFunction: (fn: Function, scope: object, options?: object) => void; + cloneInto: (value: any, scope: object, options?: object) => void; + defineModuleGetter: (target: any, variable: string, path: string) => 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; + } + + interface ChromeBrowser { + browsingContext?: BrowsingContext; + } + + interface BrowsingContext { + id: number; + } + + type GetPref = (prefName: string, defaultValue?: T) => T; + type SetPref = (prefName: string, value?: T) => T; + + interface nsIURI {} + + type Services = { + prefs: { + clearUserPref: (prefName: string) => void; + getStringPref: GetPref; + setStringPref: SetPref; + getCharPref: GetPref; + setCharPref: SetPref; + getIntPref: GetPref; + setIntPref: SetPref; + getBoolPref: GetPref; + setBoolPref: SetPref; + addObserver: any; + removeObserver: any; + }; + profiler: any; + platform: string; + obs: { + addObserver: (observer: object, type: string) => void; + removeObserver: (observer: object, type: string) => void; + }; + wm: { + getMostRecentWindow: (name: string) => ChromeWindow; + }; + focus: { + activeWindow: ChromeWindow; + }; + io: { + newURI(url: string): nsIURI; + }, + scriptSecurityManager: any; + startup: { + quit: (optionsBitmask: number) => void, + eForceQuit: number, + eRestart: number + }; + }; + + const ServicesJSM: { + Services: Services; + }; + + const EventEmitter: { + decorate: (target: object) => void; + }; + + const ProfilerGetSymbolsJSM: { + ProfilerGetSymbols: { + getSymbolTable: ( + path: string, + debugPath: string, + breakpadId: string + ) => any; + }; + }; + + const AppConstantsJSM: { + AppConstants: { + platform: string; + }; + }; + + const osfileJSM: { + OS: { + Path: { + split: ( + path: string + ) => { + absolute: boolean; + components: string[]; + winDrive?: string; + }; + join: (...pathParts: string[]) => string; + }; + File: { + stat: (path: string) => Promise<{ isDir: boolean }>; + Error: any; + }; + }; + }; + + interface BrowsingContextStub {} + interface PrincipalStub {} + + interface WebChannelTarget { + browsingContext: BrowsingContextStub, + browser: Browser, + eventTarget: null, + principal: PrincipalStub, + } + + const WebChannelJSM: any; + + // TS-TODO + const CustomizableUIJSM: any; + const CustomizableWidgetsJSM: any; + const PanelMultiViewJSM: any; + + const LoaderJSM: { + 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 + } + } + + // This class is needed by the Cc importing mechanism. e.g. + // Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment); + class nsIEnvironment {} + + interface Environment { + get(envName: string): string; + set(envName: string, value: string): void; + } + + const chrome: { + Cc: { + "@mozilla.org/process/environment;1": { + getService(service: nsIEnvironment): Environment + }, + "@mozilla.org/filepicker;1": { + createInstance(instance: nsIFilePicker): FilePicker + } + }, + Ci: { + nsIFilePicker: nsIFilePicker; + nsIEnvironment: nsIEnvironment; + }, + }; +} + + +declare module "devtools/client/shared/vendor/react" { + import * as React from "react"; + export = React; +} + +declare module "devtools/client/shared/vendor/react-dom-factories" { + import * as ReactDomFactories from "react-dom-factories"; + export = ReactDomFactories; +} + +declare module "devtools/client/shared/vendor/redux" { + import * as Redux from "redux"; + export = Redux; +} + +declare module "devtools/client/shared/vendor/react-redux" { + import * as ReactRedux from "react-redux"; + export = ReactRedux; +} + +declare module "devtools/shared/event-emitter2" { + export = MockedExports.EventEmitter; +} + +declare module "resource://gre/modules/Services.jsm" { + export = MockedExports.ServicesJSM; +} + +declare module "Services" { + export = MockedExports.Services; +} + +declare module "chrome" { + export = MockedExports.chrome; +} + +declare module "ChromeUtils" { + export = ChromeUtils; +} + +declare module "resource://gre/modules/osfile.jsm" { + export = MockedExports.osfileJSM; +} + +declare module "resource://gre/modules/AppConstants.jsm" { + export = MockedExports.AppConstantsJSM; +} + +declare module "resource://gre/modules/ProfilerGetSymbols.jsm" { + export = MockedExports.ProfilerGetSymbolsJSM; +} + +declare module "resource://gre/modules/WebChannel.jsm" { + export = MockedExports.WebChannelJSM; +} + +declare module "resource://devtools/client/performance-new/popup/background.jsm.js" { + import * as Background from "devtools/client/performance-new/popup/background.jsm.js"; + export = Background +} + +declare module "resource://devtools/client/performance-new/symbolication.jsm.js" { + import * as PerfSymbolication from "devtools/client/performance-new/symbolication.jsm.js"; + export = PerfSymbolication +} + +declare module "resource:///modules/CustomizableUI.jsm" { + export = MockedExports.CustomizableUIJSM; +} + +declare module "resource:///modules/CustomizableWidgets.jsm" { + export = MockedExports.CustomizableWidgetsJSM; +} + +declare module "resource:///modules/PanelMultiView.jsm" { + export = MockedExports.PanelMultiViewJSM; +} + +declare module "resource://devtools/shared/Loader.jsm" { + export = MockedExports.LoaderJSM; +} + +declare var ChromeUtils: MockedExports.ChromeUtils; +declare var Cu: MockedExports.ChromeUtils; + +/** + * 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 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 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 +} 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..ddd1d963bc --- /dev/null +++ b/devtools/client/performance-new/@types/perf.d.ts @@ -0,0 +1,514 @@ +/* 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 "devtools/client/shared/vendor/redux"; + +export interface PanelWindow { + gToolbox?: any; + gStore?: Store; + gInit(perfFront: PerfFront, pageContext: PageContext): void; + gDestroy(): void; + gReportReady?(): void; + gIsPanelDestroyed?: boolean; +} + +/** + * TS-TODO - Stub. + */ +export interface Target { + // TODO + client: any; +} + +/** + * TS-TODO - Stub. + */ +export interface Toolbox { + target: Target; +} + +/** + * The actor version of the ActorReadyGeckoProfilerInterface returns promises, + * while if it's instantiated directly it will not return promises. + */ +type MaybePromise = Promise | T; + +/** + * TS-TODO - Stub. + * + * Any method here that returns a MaybePromise is because the + * ActorReadyGeckoProfilerInterface returns T while the PerfFront returns Promise. + * Any method here that returns Promise is because both the + * ActorReadyGeckoProfilerInterface and the PerfFront return promises. + */ +export interface PerfFront { + startProfiler: ( + options: RecordingStateFromPreferences + ) => MaybePromise; + getProfileAndStopProfiler: () => Promise; + stopProfilerAndDiscardProfile: () => MaybePromise; + getSymbolTable: ( + path: string, + breakpadId: string + ) => Promise<[number[], number[], number[]]>; + isActive: () => MaybePromise; + isSupportedPlatform: () => MaybePromise; + isLockedForPrivateBrowsing: () => MaybePromise; + on: (type: string, listener: () => void) => void; + off: (type: string, listener: () => void) => void; + destroy: () => void; + getSupportedFeatures: () => MaybePromise; +} + +/** + * TS-TODO - Stub + */ +export interface PreferenceFront { + clearUserPref: (prefName: string) => Promise; + getStringPref: (prefName: string) => Promise; + setStringPref: (prefName: string, value: string) => Promise; + getCharPref: (prefName: string) => Promise; + setCharPref: (prefName: string, value: string) => Promise; + getIntPref: (prefName: string) => Promise; + setIntPref: (prefName: string, value: number) => Promise; +} + +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" + // Profiling is not available when in private browsing mode. + | "locked-by-private-browsing"; + +// 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" + | "aboutprofiling" + | "aboutprofiling-remote"; + +export interface State { + recordingState: RecordingState; + recordingUnexpectedlyStopped: boolean; + isSupportedPlatform: boolean; + interval: number; + entries: number; + features: string[]; + threads: string[]; + objdirs: string[]; + presetName: string; + profilerViewMode: ProfilerViewMode | undefined; + initializedValues: InitializedValues | null; + promptEnvRestart: null | string; +} + +export type Selector = (state: State) => T; + +export type ThunkDispatch = (action: ThunkAction) => 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 = ({ 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: Array<{ debugName: string; breakpadId: string }>; + processes: Array; +} + +export type GetSymbolTableCallback = ( + debugName: string, + breakpadId: string +) => Promise; + +export type ReceiveProfile = ( + geckoProfile: MinimallyTypedGeckoProfile, + profilerViewMode: ProfilerViewMode | undefined, + getSymbolTableCallback: GetSymbolTableCallback +) => void; + +export type SetRecordingPreferences = ( + settings: RecordingStateFromPreferences +) => 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 a function to query the browser for an + * environment variable. Currently only implemented for the popup. + */ +export type GetEnvironmentVariable = (envName: string) => string; + +/** + * This is the type signature for a function to query the browser for the + * ID of BrowsingContext of active tab. + */ +export type GetActiveBrowsingContextID = () => number; + +/** + * This interface is injected into profiler.firefox.com + */ +interface GeckoProfilerFrameScriptInterface { + getProfile: () => Promise; + getSymbolTable: GetSymbolTableCallback; +} + +export interface RecordingStateFromPreferences { + presetName: string; + entries: number; + interval: number; + 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 = (state: S | undefined, action: Action) => S; + +export interface InitializedValues { + // The current Front to the Perf actor. + perfFront: PerfFront; + // A function to receive the profile and open it into a new window. + receiveProfile: ReceiveProfile; + // A function to set the recording settings. + setRecordingPreferences: SetRecordingPreferences; + // The current list of presets, loaded in from a JSM. + presets: Presets; + // Determine the current page context. + pageContext: PageContext; + // The popup and devtools panel use different codepaths for getting symbol tables. + getSymbolTableGetter: ( + profile: MinimallyTypedGeckoProfile + ) => GetSymbolTableCallback; + // The list of profiler features that the current target supports. + supportedFeatures: string[]; + // Allow different devtools contexts to open about:profiling with different methods. + // e.g. via a new tab, or page navigation. + openAboutProfiling?: () => void; + // 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; + +export type Action = + | { + type: "CHANGE_RECORDING_STATE"; + state: RecordingState; + didRecordingUnexpectedlyStopped: boolean; + } + | { + type: "REPORT_PROFILER_READY"; + isSupportedPlatform: boolean; + recordingState: RecordingState; + } + | { + 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"; + perfFront: PerfFront; + receiveProfile: ReceiveProfile; + setRecordingPreferences: SetRecordingPreferences; + presets: Presets; + pageContext: PageContext; + openAboutProfiling?: () => void; + openRemoteDevTools?: () => void; + recordingSettingsFromPreferences: RecordingStateFromPreferences; + getSymbolTableGetter: ( + profile: MinimallyTypedGeckoProfile + ) => GetSymbolTableCallback; + supportedFeatures: string[]; + } + | { + type: "CHANGE_PRESET"; + presetName: string; + preset: PresetDefinition | undefined; + }; + +export interface InitializeStoreValues { + perfFront: PerfFront; + receiveProfile: ReceiveProfile; + setRecordingPreferences: SetRecordingPreferences; + presets: Presets; + pageContext: PageContext; + recordingPreferences: RecordingStateFromPreferences; + supportedFeatures: string[]; + getSymbolTableGetter: ( + profile: MinimallyTypedGeckoProfile + ) => GetSymbolTableCallback; + openAboutProfiling?: () => void; + openRemoteDevTools?: () => void; +} + +export type PopupBackgroundFeatures = { [feature: string]: boolean }; + +/** + * The state of the profiler popup. + */ +export interface PopupBackgroundState { + features: PopupBackgroundFeatures; + buffersize: number; + windowLength: number; + interval: number; + threads: string; +} + +// 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-addon in order to more easily + * test the profile injection mechanism. + */ + UIBaseUrlPathPref: "devtools.performance.recording.ui-base-url-path"; + /** + * 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"; +} + +/** + * This interface represents the global values that are potentially on the window + * object in the popup. Coerce the "window" object into this interface. + */ +export interface PopupWindow extends Window { + gResizePopup?: (height: number) => void; + gIsDarkMode?: boolean; +} + +/** + * 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; +} + +/** + * 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 { + label: string; + description: string; + entries: number; + interval: number; + features: string[]; + threads: string[]; + duration: number; + profilerViewMode?: ProfilerViewMode; +} + +export interface Presets { + [presetName: string]: PresetDefinition; +} + +export type MessageFromFrontend = + | { + type: "STATUS_QUERY"; + requestId: number; + } + | { + type: "ENABLE_MENU_BUTTON"; + requestId: number; + }; + +export type MessageToFrontend = + | { + type: "STATUS_RESPONSE"; + menuButtonIsEnabled: boolean; + requestId: number; + } + | { + type: "ENABLE_MENU_BUTTON_DONE"; + requestId: number; + }; + +/** + * 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.jsm + * 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, + target: MockedExports.WebChannelTarget + ) => void; + listen: ( + handler: ( + idle: string, + message: MessageFromFrontend, + target: MockedExports.WebChannelTarget + ) => void + ) => void; +} + +/** + * 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; +} diff --git a/devtools/client/performance-new/README.md b/devtools/client/performance-new/README.md new file mode 100644 index 0000000000..7dcfcfad4c --- /dev/null +++ b/devtools/client/performance-new/README.md @@ -0,0 +1,43 @@ +# 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/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. + +## 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 injecting the profile into the page through a frame script. See `frame-script.js` for implementation details. Both the DevTools Panel and the Popup use this frame script. + +[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..dadbd2f442 --- /dev/null +++ b/devtools/client/performance-new/aboutprofiling/index.xhtml @@ -0,0 +1,17 @@ + + + + + + + + + + +
+ + + diff --git a/devtools/client/performance-new/aboutprofiling/initializer.js b/devtools/client/performance-new/aboutprofiling/initializer.js new file mode 100644 index 0000000000..ffe896f46b --- /dev/null +++ b/devtools/client/performance-new/aboutprofiling/initializer.js @@ -0,0 +1,161 @@ +/* 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").InitializeStoreValues} InitializeStoreValues + * @typedef {import("../@types/perf").PopupWindow} PopupWindow + * @typedef {import("../@types/perf").RecordingStateFromPreferences} RecordingStateFromPreferences + * @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/client/shared/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.jsm.js 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 { + getRecordingPreferences, + setRecordingPreferences, + getSymbolsFromThisBrowser, + presets, +} = ChromeUtils.import( + "resource://devtools/client/performance-new/popup/background.jsm.js" +); + +const { receiveProfile } = require("devtools/client/performance-new/browser"); + +const ReactDOM = require("devtools/client/shared/vendor/react-dom"); +const React = require("devtools/client/shared/vendor/react"); +const FluentReact = require("devtools/client/shared/vendor/fluent-react"); +const { + FluentL10n, +} = require("devtools/client/shared/fluent-l10n/fluent-l10n"); +const Provider = React.createFactory( + require("devtools/client/shared/vendor/react-redux").Provider +); +const LocalizationProvider = React.createFactory( + FluentReact.LocalizationProvider +); +const AboutProfiling = React.createFactory( + require("devtools/client/performance-new/components/AboutProfiling") +); +const ProfilerEventHandling = React.createFactory( + require("devtools/client/performance-new/components/ProfilerEventHandling") +); +const createStore = require("devtools/client/shared/redux/create-store"); +const reducers = require("devtools/client/performance-new/store/reducers"); +const actions = require("devtools/client/performance-new/store/actions"); +const { + ActorReadyGeckoProfilerInterface, +} = require("devtools/shared/performance-new/gecko-profiler-interface"); + +/** + * 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 {PageContext} pageContext - The context that the UI is being loaded in under. + * @param {(() => void)} [openRemoteDevTools] Optionally provide a way to go back to + * the remote devtools page. + */ +async function gInit(perfFront, pageContext, openRemoteDevTools) { + const store = createStore(reducers); + const supportedFeatures = await perfFront.getSupportedFeatures(); + + const l10n = new FluentL10n(); + await l10n.init(["devtools/client/perftools.ftl"]); + + // Do some initialization, especially with privileged things that are part of the + // the browser. + store.dispatch( + actions.initializeStore({ + perfFront, + receiveProfile, + supportedFeatures, + presets, + // Get the preferences from the current browser + recordingPreferences: getRecordingPreferences( + pageContext, + supportedFeatures + ), + /** + * @param {RecordingStateFromPreferences} newRecordingPreferences + */ + setRecordingPreferences: newRecordingPreferences => + setRecordingPreferences(pageContext, newRecordingPreferences), + + // The popup doesn't need to support remote symbol tables from the debuggee. + // Only get the symbols from this browser. + getSymbolTableGetter: () => { + return (debugName, breakpadId) => + getSymbolsFromThisBrowser(pageContext, debugName, breakpadId); + }, + pageContext, + openRemoteDevTools, + }) + ); + + ReactDOM.render( + Provider( + { store }, + LocalizationProvider( + { bundles: l10n.getBundles() }, + React.createElement( + React.Fragment, + null, + ProfilerEventHandling(), + AboutProfiling() + ) + ) + ), + document.querySelector("#root") + ); + + window.addEventListener("unload", function() { + // Do not destroy the perf front if working remotely, about:debugging will do + // this for us. + if (pageContext !== "aboutprofiling-remote") { + // The perf front interface needs to be unloaded in order to remove event handlers. + // Not doing so leads to leaks. + perfFront.destroy(); + } + }); +} + +// 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", () => { + gInit(new ActorReadyGeckoProfilerInterface(), "aboutprofiling"); + }); +} 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/browser.js b/devtools/client/performance-new/browser.js new file mode 100644 index 0000000000..02dd4a2e3b --- /dev/null +++ b/devtools/client/performance-new/browser.js @@ -0,0 +1,284 @@ +/* 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").GetSymbolTableCallback} GetSymbolTableCallback + * @typedef {import("./@types/perf").PreferenceFront} PreferenceFront + * @typedef {import("./@types/perf").PerformancePref} PerformancePref + * @typedef {import("./@types/perf").RecordingStateFromPreferences} RecordingStateFromPreferences + * @typedef {import("./@types/perf").RestartBrowserWithEnvironmentVariable} RestartBrowserWithEnvironmentVariable + * @typedef {import("./@types/perf").GetEnvironmentVariable} GetEnvironmentVariable + * @typedef {import("./@types/perf").GetActiveBrowsingContextID} GetActiveBrowsingContextID + * @typedef {import("./@types/perf").MinimallyTypedGeckoProfile} MinimallyTypedGeckoProfile + * * @typedef {import("./@types/perf").ProfilerViewMode} ProfilerViewMode + */ + +const ChromeUtils = require("ChromeUtils"); +const { createLazyLoaders } = ChromeUtils.import( + "resource://devtools/client/performance-new/typescript-lazy-load.jsm.js" +); + +const lazy = createLazyLoaders({ + Chrome: () => require("chrome"), + Services: () => require("Services"), + OS: () => ChromeUtils.import("resource://gre/modules/osfile.jsm"), + ProfilerGetSymbols: () => + ChromeUtils.import("resource://gre/modules/ProfilerGetSymbols.jsm"), + PerfSymbolication: () => + ChromeUtils.import( + "resource://devtools/client/performance-new/symbolication.jsm.js" + ), +}); + +const TRANSFER_EVENT = "devtools:perf-html-transfer-profile"; +const SYMBOL_TABLE_REQUEST_EVENT = "devtools:perf-html-request-symbol-table"; +const SYMBOL_TABLE_RESPONSE_EVENT = "devtools:perf-html-reply-symbol-table"; + +/** @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"; + +const UI_BASE_URL_DEFAULT = "https://profiler.firefox.com"; +const UI_BASE_URL_PATH_DEFAULT = "/from-addon"; + +/** + * 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, and injects the profile via a frame script. + * + * @param {MinimallyTypedGeckoProfile} profile - The Gecko profile. + * @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. + * @param {GetSymbolTableCallback} getSymbolTableCallback - A callback function with the signature + * (debugName, breakpadId) => Promise, which will be invoked + * when profiler.firefox.com sends SYMBOL_TABLE_REQUEST_EVENT messages to us. This + * function should obtain a symbol table for the requested binary and resolve the + * returned promise with it. + */ +function receiveProfile(profile, profilerViewMode, getSymbolTableCallback) { + const Services = lazy.Services(); + // Find the most recently used window, as the DevTools client could be in a variety + // of hosts. + const win = Services.wm.getMostRecentWindow("navigator:browser"); + if (!win) { + throw new Error("No browser window"); + } + const browser = win.gBrowser; + win.focus(); + + // 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 + ); + + // 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. + const viewModeQueryString = + profilerViewMode !== undefined && profilerViewMode !== "full" + ? `?view=${profilerViewMode}` + : ""; + + const tab = browser.addWebTab( + `${baseUrl}${baseUrlPath}${viewModeQueryString}`, + { + triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({ + userContextId: browser.contentPrincipal.userContextId, + }), + } + ); + browser.selectedTab = tab; + const mm = tab.linkedBrowser.messageManager; + mm.loadFrameScript( + "chrome://devtools/content/performance-new/frame-script.js", + false + ); + mm.sendAsyncMessage(TRANSFER_EVENT, profile); + mm.addMessageListener(SYMBOL_TABLE_REQUEST_EVENT, e => { + const { debugName, breakpadId } = e.data; + getSymbolTableCallback(debugName, breakpadId).then( + result => { + const [addr, index, buffer] = result; + mm.sendAsyncMessage(SYMBOL_TABLE_RESPONSE_EVENT, { + status: "success", + debugName, + breakpadId, + result: [addr, index, buffer], + }); + }, + error => { + // Re-wrap the error object into an object that is Structured Clone-able. + const { name, message, lineNumber, fileName } = error; + mm.sendAsyncMessage(SYMBOL_TABLE_RESPONSE_EVENT, { + status: "error", + debugName, + breakpadId, + error: { name, message, lineNumber, fileName }, + }); + } + ); + }); +} + +/** + * Returns a function getDebugPathFor(debugName, breakpadId) => Library which + * resolves a (debugName, breakpadId) pair to the library's information, which + * contains the absolute paths on the file system where the binary and its + * optional pdb file are stored. + * + * This is needed for the following reason: + * - In order to obtain a symbol table for a system library, we need to know + * the library's absolute path on the file system. On Windows, we + * additionally need to know the absolute path to the library's PDB file, + * which we call the binary's "debugPath". + * - Symbol tables are requested asynchronously, by the profiler UI, after the + * profile itself has been obtained. + * - When the symbol tables are requested, we don't want the profiler UI to + * pass us arbitrary absolute file paths, as an extra defense against + * potential information leaks. + * - Instead, when the UI requests symbol tables, it identifies the library + * with a (debugName, breakpadId) pair. We need to map that pair back to the + * absolute paths. + * - We get the "trusted" paths from the "libs" sections of the profile. We + * trust these paths because we just obtained the profile directly from + * Gecko. + * - This function builds the (debugName, breakpadId) => Library mapping and + * retains it on the returned closure so that it can be consulted after the + * profile has been passed to the UI. + * + * @param {MinimallyTypedGeckoProfile} profile - The profile JSON object + * @returns {(debugName: string, breakpadId: string) => Library | undefined} + */ +function createLibraryMap(profile) { + const map = new Map(); + + /** + * @param {MinimallyTypedGeckoProfile} processProfile + */ + function fillMapForProcessRecursive(processProfile) { + for (const lib of processProfile.libs) { + const { debugName, breakpadId } = lib; + const key = [debugName, breakpadId].join(":"); + map.set(key, lib); + } + for (const subprocess of processProfile.processes) { + fillMapForProcessRecursive(subprocess); + } + } + + fillMapForProcessRecursive(profile); + return function getLibraryFor(debugName, breakpadId) { + const key = [debugName, breakpadId].join(":"); + return map.get(key); + }; +} + +/** + * Return a function `getSymbolTable` that calls getSymbolTableMultiModal with the + * right arguments. + * + * @param {MinimallyTypedGeckoProfile} profile - The raw profie (not gzipped). + * @param {() => string[]} getObjdirs - A function that returns an array of objdir paths + * on the host machine that should be searched for relevant build artifacts. + * @param {PerfFront} perfFront + * @return {GetSymbolTableCallback} + */ +function createMultiModalGetSymbolTableFn(profile, getObjdirs, perfFront) { + const libraryGetter = createLibraryMap(profile); + + return async function getSymbolTable(debugName, breakpadId) { + const lib = libraryGetter(debugName, breakpadId); + if (!lib) { + throw new Error( + `Could not find the library for "${debugName}", "${breakpadId}".` + ); + } + const objdirs = getObjdirs(); + const { getSymbolTableMultiModal } = lazy.PerfSymbolication(); + return getSymbolTableMultiModal(lib, objdirs, perfFront); + }; +} + +/** + * Restarts the browser with a given environment variable set to a value. + * + * @type {RestartBrowserWithEnvironmentVariable} + */ +function restartBrowserWithEnvironmentVariable(envName, value) { + const Services = lazy.Services(); + const { Cc, Ci } = lazy.Chrome(); + const env = Cc["@mozilla.org/process/environment;1"].getService( + Ci.nsIEnvironment + ); + env.set(envName, value); + + Services.startup.quit( + Services.startup.eForceQuit | Services.startup.eRestart + ); +} + +/** + * Gets an environment variable from the browser. + * + * @type {GetEnvironmentVariable} + */ +function getEnvironmentVariable(envName) { + const { Cc, Ci } = lazy.Chrome(); + const env = Cc["@mozilla.org/process/environment;1"].getService( + Ci.nsIEnvironment + ); + return env.get(envName); +} + +/** + * @param {Window} window + * @param {string[]} objdirs + * @param {(objdirs: string[]) => unknown} changeObjdirs + */ +function openFilePickerForObjdir(window, objdirs, changeObjdirs) { + const { Cc, Ci } = lazy.Chrome(); + 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 = { + receiveProfile, + createMultiModalGetSymbolTableFn, + restartBrowserWithEnvironmentVariable, + getEnvironmentVariable, + openFilePickerForObjdir, +}; diff --git a/devtools/client/performance-new/components/AboutProfiling.js b/devtools/client/performance-new/components/AboutProfiling.js new file mode 100644 index 0000000000..3e8083a335 --- /dev/null +++ b/devtools/client/performance-new/components/AboutProfiling.js @@ -0,0 +1,161 @@ +/* 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

} ResolveThunks

+ */ + +/** + * @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").PanelWindow} PanelWindow + *@typedef {import("../@types/perf").PageContext} PageContext + */ + +"use strict"; + +const { + PureComponent, + createFactory, +} = require("devtools/client/shared/vendor/react"); +const { connect } = require("devtools/client/shared/vendor/react-redux"); +const { + div, + h1, + button, +} = require("devtools/client/shared/vendor/react-dom-factories"); +const Localized = createFactory( + require("devtools/client/shared/vendor/fluent-react").Localized +); +const Settings = createFactory( + require("devtools/client/performance-new/components/Settings.js") +); +const Presets = createFactory( + require("devtools/client/performance-new/components/Presets") +); + +const selectors = require("devtools/client/performance-new/store/selectors"); +const { + restartBrowserWithEnvironmentVariable, +} = require("devtools/client/performance-new/browser"); + +/** + * This is the top level component for the about:profiling page. It shares components + * with the popup and DevTools page. + * + * @extends {React.PureComponent} + */ +class AboutProfiling extends PureComponent { + /** @param {Props} props */ + constructor(props) { + super(props); + this.handleRestart = this.handleRestart.bind(this); + } + + 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/Description.js b/devtools/client/performance-new/components/Description.js new file mode 100644 index 0000000000..49688d23ce --- /dev/null +++ b/devtools/client/performance-new/components/Description.js @@ -0,0 +1,69 @@ +/* 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("devtools/client/shared/vendor/react"); +const { + div, + button, + p, +} = require("devtools/client/shared/vendor/react-dom-factories"); +const Localized = createFactory( + require("devtools/client/shared/vendor/fluent-react").Localized +); + +/** + * This component provides a helpful description for what is going on in the component + * and provides some external links. + * @extends {React.PureComponent} + */ +class Description extends PureComponent { + /** + * @param {Props} props + */ + constructor(props) { + super(props); + this.handleLinkClick = this.handleLinkClick.bind(this); + } + + /** + * @param {React.MouseEvent} event + */ + handleLinkClick(event) { + const { openDocLink } = require("devtools/client/shared/link"); + + /** @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/DevToolsPanel.js b/devtools/client/performance-new/components/DevToolsPanel.js new file mode 100644 index 0000000000..7c36c10682 --- /dev/null +++ b/devtools/client/performance-new/components/DevToolsPanel.js @@ -0,0 +1,83 @@ +/* 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

} ResolveThunks

+ */ + +/** + * @typedef {Object} StateProps + * @property {boolean?} isSupportedPlatform + */ + +/** + * @typedef {StateProps} Props + * @typedef {import("../@types/perf").State} StoreState + * @typedef {import("../@types/perf").PanelWindow} PanelWindow + */ + +"use strict"; + +const { + PureComponent, + createFactory, +} = require("devtools/client/shared/vendor/react"); +const { connect } = require("devtools/client/shared/vendor/react-redux"); +const { + div, + hr, +} = require("devtools/client/shared/vendor/react-dom-factories"); +const RecordingButton = createFactory( + require("devtools/client/performance-new/components/RecordingButton") +); +const Description = createFactory( + require("devtools/client/performance-new/components/Description") +); +const DevToolsPresetSelection = createFactory( + require("devtools/client/performance-new/components/DevToolsPresetSelection") +); +const OnboardingMessage = createFactory( + require("devtools/client/performance-new/components/OnboardingMessage") +); + +const selectors = require("devtools/client/performance-new/store/selectors"); + +/** + * This is the top level component for the DevTools panel. + * + * @extends {React.PureComponent} + */ +class DevToolsPanel extends PureComponent { + render() { + const { isSupportedPlatform } = 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-devtools` }, + OnboardingMessage(), + RecordingButton(), + Description(), + hr({ className: "perf-presets-hr" }), + DevToolsPresetSelection() + ); + } +} + +/** + * @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/DevToolsPresetSelection.js b/devtools/client/performance-new/components/DevToolsPresetSelection.js new file mode 100644 index 0000000000..fccfd6b46a --- /dev/null +++ b/devtools/client/performance-new/components/DevToolsPresetSelection.js @@ -0,0 +1,204 @@ +/* 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

} ResolveThunks

+ */ + +/** + * @typedef {Object} StateProps + * @property {string} presetName + * @property {number} interval + * @property {string[]} threads + * @property {string[]} features + * @property {() => void} openAboutProfiling + * @property {import("../@types/perf").Presets} presets + */ + +/** + * @typedef {Object} ThunkDispatchProps + * @property {typeof actions.changePreset} changePreset + */ + +/** + * @typedef {ResolveThunks} DispatchProps + * @typedef {StateProps & DispatchProps} Props + * @typedef {import("../@types/perf").State} StoreState + * @typedef {import("../@types/perf").FeatureDescription} FeatureDescription + */ + +"use strict"; + +const { + PureComponent, + createFactory, +} = require("devtools/client/shared/vendor/react"); +const { + div, + select, + option, + button, + ul, + li, + span, +} = require("devtools/client/shared/vendor/react-dom-factories"); +const { connect } = require("devtools/client/shared/vendor/react-redux"); +const actions = require("devtools/client/performance-new/store/actions"); +const selectors = require("devtools/client/performance-new/store/selectors"); +const { + featureDescriptions, +} = require("devtools/client/performance-new/utils"); +const Localized = createFactory( + require("devtools/client/shared/vendor/fluent-react").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} + */ +class DevToolsPresetSelection extends PureComponent { + /** @param {Props} props */ + constructor(props) { + super(props); + this.onPresetChange = this.onPresetChange.bind(this); + + /** + * 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} event + */ + onPresetChange(event) { + const { presets } = this.props; + this.props.changePreset(presets, event.target.value); + } + + render() { + const { presetName, presets, openAboutProfiling } = this.props; + + let presetDescription; + const currentPreset = presets[presetName]; + if (currentPreset) { + // Display the current preset's description. + presetDescription = currentPreset.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: "perftools-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 + ); + }) + ), + button( + { className: "perf-external-link", onClick: openAboutProfiling }, + Localized({ id: "perftools-button-edit-settings" }) + ) + ); + } + + 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]) => + option({ key: name, value: name }, preset.label) + ), + option({ value: "custom" }, "Custom") + ) + // The overhead component will go here. + ), + div( + { className: "perf-presets-details-row perf-presets-description" }, + presetDescription + ) + ) + ); + } +} + +/** + * @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), + openAboutProfiling: selectors.getOpenAboutProfiling(state), + }; +} + +/** + * @type {ThunkDispatchProps} + */ +const mapDispatchToProps = { + changePreset: actions.changePreset, +}; + +module.exports = connect( + mapStateToProps, + mapDispatchToProps +)(DevToolsPresetSelection); diff --git a/devtools/client/performance-new/components/DirectoryPicker.js b/devtools/client/performance-new/components/DirectoryPicker.js new file mode 100644 index 0000000000..1bd667353e --- /dev/null +++ b/devtools/client/performance-new/components/DirectoryPicker.js @@ -0,0 +1,122 @@ +/* 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("devtools/client/shared/vendor/react"); +const { + div, + button, + select, + option, +} = require("devtools/client/shared/vendor/react-dom-factories"); +const { + withCommonPathPrefixRemoved, +} = require("devtools/client/performance-new/utils"); +const Localized = createFactory( + require("devtools/client/shared/vendor/fluent-react").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} + */ +class DirectoryPicker extends PureComponent { + /** @param {Props} props */ + constructor(props) { + super(props); + this._listBox = null; + this._takeListBoxRef = this._takeListBoxRef.bind(this); + this._handleAddButtonClick = this._handleAddButtonClick.bind(this); + this._handleRemoveButtonClick = this._handleRemoveButtonClick.bind(this); + } + + /** + * @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/OnboardingMessage.js b/devtools/client/performance-new/components/OnboardingMessage.js new file mode 100644 index 0000000000..740d1e6ff4 --- /dev/null +++ b/devtools/client/performance-new/components/OnboardingMessage.js @@ -0,0 +1,144 @@ +/* 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 } = require("devtools/client/shared/vendor/react"); +const { + b, + button, + div, + p, +} = require("devtools/client/shared/vendor/react-dom-factories"); + +const Services = require("Services"); +const { openDocLink } = require("devtools/client/shared/link"); + +const LEARN_MORE_URL = + "https://developer.mozilla.org/docs/Mozilla/Performance/Profiling_with_the_Built-in_Profiler"; +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} + */ +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, {}); + }; + + handleSettingsClick = () => { + /** @type {any} */ + const anyWindow = window; + /** @type {PanelWindow} - Coerce the window into the PanelWindow. */ + const { gToolbox } = anyWindow; + gToolbox.selectTool("options"); + }; + + /** + * 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; + } + + const learnMoreLink = button( + { + className: "perf-external-link", + onClick: this.handleLearnMoreClick, + }, + "Learn more" + ); + + const settingsLink = button( + { + className: "perf-external-link", + onClick: this.handleSettingsClick, + }, + "Settings > Advanced" + ); + + const closeButton = button({ + "aria-label": "Close the onboarding message", + 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" }, + p( + { className: "perf-onboarding-message-row" }, + b({}, "New"), + ": Firefox Profiler is now integrated into Developer Tools. ", + learnMoreLink, + " about this powerful new tool." + ), + p( + { className: "perf-onboarding-message-row" }, + "(For a limited time, you can access the original Performance panel via ", + settingsLink, + ")" + ) + ), + closeButton + ); + } +} + +module.exports = OnboardingMessage; diff --git a/devtools/client/performance-new/components/Presets.js b/devtools/client/performance-new/components/Presets.js new file mode 100644 index 0000000000..4bb6533a49 --- /dev/null +++ b/devtools/client/performance-new/components/Presets.js @@ -0,0 +1,163 @@ +/* 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

} ResolveThunks

+ */ + +"use strict"; +const { + PureComponent, + createElement, +} = require("devtools/client/shared/vendor/react"); +const { + div, + label, + input, +} = require("devtools/client/shared/vendor/react-dom-factories"); +const selectors = require("devtools/client/performance-new/store/selectors"); +const actions = require("devtools/client/performance-new/store/actions"); +const { connect } = require("devtools/client/shared/vendor/react-redux"); + +/** + * @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} + */ +class Preset extends PureComponent { + /** + * Handle the checkbox change. + * @param {React.ChangeEvent} event + */ + onChange = event => { + this.props.onChange(event.target.value); + }; + + render() { + const { preset, presetName, selected } = this.props; + let labelText, description; + if (preset) { + labelText = preset.label; + description = preset.description; + } else { + labelText = "Custom"; + } + return label( + { className: "perf-presets-label" }, + div( + { className: "perf-presets-input-container" }, + input({ + className: "perf-presets-input", + type: "radio", + name: "presets", + value: presetName, + checked: selected, + onChange: this.onChange, + }) + ), + div( + { className: "perf-presets-text" }, + div({ className: "pref-preset-text-label" }, labelText), + description + ? div({ className: "perf-presets-description" }, description) + : null + ) + ); + } +} + +/** + * @typedef {Object} StateProps + * @property {string} selectedPresetName + * @property {import("../@types/perf").Presets} presets + */ + +/** + * @typedef {Object} ThunkDispatchProps + * @property {typeof actions.changePreset} changePreset + */ + +/** + * @typedef {ResolveThunks} 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} + */ +class Presets extends PureComponent { + /** @param {Props} props */ + constructor(props) { + super(props); + this.onChange = this.onChange.bind(this); + } + + /** + * 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/ProfilerEventHandling.js b/devtools/client/performance-new/components/ProfilerEventHandling.js new file mode 100644 index 0000000000..e3b07ff2bc --- /dev/null +++ b/devtools/client/performance-new/components/ProfilerEventHandling.js @@ -0,0 +1,269 @@ +/* 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

} ResolveThunks

+ */ + +/** + * @typedef {Object} StateProps + * @property {PerfFront} perfFront + * @property {RecordingState} recordingState + * @property {boolean?} isSupportedPlatform + * @property {PageContext} pageContext + * @property {string | null} promptEnvRestart + */ + +/** + * @typedef {Object} ThunkDispatchProps + * @property {typeof actions.changeRecordingState} changeRecordingState + * @property {typeof actions.reportProfilerReady} reportProfilerReady + */ + +/** + * @typedef {ResolveThunks} DispatchProps + * @typedef {StateProps & DispatchProps} Props + * @typedef {import("../@types/perf").PerfFront} PerfFront + * @typedef {import("../@types/perf").RecordingState} RecordingState + * @typedef {import("../@types/perf").State} StoreState + * @typedef {import("../@types/perf").PageContext} PageContext + */ + +/** + * @typedef {import("../@types/perf").PanelWindow} PanelWindow + */ + +"use strict"; + +const { PureComponent } = require("devtools/client/shared/vendor/react"); +const { connect } = require("devtools/client/shared/vendor/react-redux"); +const actions = require("devtools/client/performance-new/store/actions"); +const selectors = require("devtools/client/performance-new/store/selectors"); + +/** + * 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} + */ +class ProfilerEventHandling extends PureComponent { + /** @param {Props} props */ + constructor(props) { + super(props); + this.handleProfilerStarting = this.handleProfilerStarting.bind(this); + this.handleProfilerStopping = this.handleProfilerStopping.bind(this); + this.handlePrivateBrowsingStarting = this.handlePrivateBrowsingStarting.bind( + this + ); + this.handlePrivateBrowsingEnding = this.handlePrivateBrowsingEnding.bind( + this + ); + } + + componentDidMount() { + const { perfFront, reportProfilerReady } = this.props; + + // Ask for the initial state of the profiler. + Promise.all([ + perfFront.isActive(), + perfFront.isSupportedPlatform(), + perfFront.isLockedForPrivateBrowsing(), + ]).then(results => { + const [ + isActive, + isSupportedPlatform, + isLockedForPrivateBrowsing, + ] = results; + + let recordingState = this.props.recordingState; + // It's theoretically possible we got an event that already let us know about + // the current state of the profiler. + if (recordingState === "not-yet-known" && isSupportedPlatform) { + if (isLockedForPrivateBrowsing) { + recordingState = "locked-by-private-browsing"; + } else if (isActive) { + recordingState = "recording"; + } else { + recordingState = "available-to-record"; + } + } + reportProfilerReady(isSupportedPlatform, recordingState); + + // If this component is inside the popup, then report it being ready so that + // it will show. This defers the initial visibility of the popup until the + // React components have fully rendered, and thus there is no annoying "blip" + // to the screen when the page goes from fully blank, to showing the content. + /** @type {any} */ + const anyWindow = window; + /** @type {PanelWindow} - Coerce the window into the PanelWindow. */ + const { gReportReady } = anyWindow; + if (gReportReady) { + gReportReady(); + } + }); + + // Handle when the profiler changes state. It might be us, it might be someone else. + this.props.perfFront.on("profiler-started", this.handleProfilerStarting); + this.props.perfFront.on("profiler-stopped", this.handleProfilerStopping); + this.props.perfFront.on( + "profile-locked-by-private-browsing", + this.handlePrivateBrowsingStarting + ); + this.props.perfFront.on( + "profile-unlocked-from-private-browsing", + this.handlePrivateBrowsingEnding + ); + } + + 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": + case "locked-by-private-browsing": + // 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."); + } + } + + handleProfilerStarting() { + const { changeRecordingState, recordingState } = this.props; + switch (recordingState) { + 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": + changeRecordingState("recording"); + break; + + case "request-to-start-recording": + // Wait for the profiler to tell us that it has started. + changeRecordingState("recording"); + break; + + case "locked-by-private-browsing": + 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: "${recordingState}"` + ); + default: + throw new Error("Unhandled recording state"); + } + } + + handleProfilerStopping() { + const { changeRecordingState, recordingState } = this.props; + switch (recordingState) { + case "not-yet-known": + case "request-to-get-profile-and-stop-profiler": + case "request-to-stop-profiler": + changeRecordingState("available-to-record"); + break; + + case "request-to-start-recording": + // Highly unlikely, but someone stopped the recorder, this is fine. + // Do nothing (fallthrough). + case "locked-by-private-browsing": + // The profiler is already locked, so we know about this already. + break; + + case "recording": + changeRecordingState("available-to-record", { + didRecordingUnexpectedlyStopped: true, + }); + break; + + 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"); + } + } + + handlePrivateBrowsingStarting() { + const { recordingState, changeRecordingState } = this.props; + + switch (recordingState) { + case "request-to-get-profile-and-stop-profiler": + // This one is a tricky case. Go ahead and act like nothing went wrong, maybe + // it will resolve correctly? (fallthrough) + case "request-to-stop-profiler": + case "available-to-record": + case "not-yet-known": + changeRecordingState("locked-by-private-browsing"); + break; + + case "request-to-start-recording": + case "recording": + changeRecordingState("locked-by-private-browsing", { + didRecordingUnexpectedlyStopped: false, + }); + break; + + case "locked-by-private-browsing": + // Do nothing + break; + + default: + throw new Error("Unhandled recording state"); + } + } + + handlePrivateBrowsingEnding() { + // No matter the state, go ahead and set this as ready to record. This should + // be the only logical state to go into. + this.props.changeRecordingState("available-to-record"); + } + + render() { + return null; + } +} + +/** + * @param {StoreState} state + * @returns {StateProps} + */ +function mapStateToProps(state) { + return { + perfFront: selectors.getPerfFront(state), + recordingState: selectors.getRecordingState(state), + isSupportedPlatform: selectors.getIsSupportedPlatform(state), + pageContext: selectors.getPageContext(state), + promptEnvRestart: selectors.getPromptEnvRestart(state), + }; +} + +/** @type {ThunkDispatchProps} */ +const mapDispatchToProps = { + changeRecordingState: actions.changeRecordingState, + reportProfilerReady: actions.reportProfilerReady, +}; + +module.exports = connect( + mapStateToProps, + mapDispatchToProps +)(ProfilerEventHandling); 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/Range.js b/devtools/client/performance-new/components/Range.js new file mode 100644 index 0000000000..026b256eb1 --- /dev/null +++ b/devtools/client/performance-new/components/Range.js @@ -0,0 +1,79 @@ +/* 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("devtools/client/shared/vendor/react"); +const { + div, + input, + label, +} = require("devtools/client/shared/vendor/react-dom-factories"); + +/** + * Provide a numeric range slider UI that works off of custom numeric scales. + * @extends React.PureComponent + */ +class Range extends PureComponent { + /** @param {Props} props */ + constructor(props) { + super(props); + this.handleInput = this.handleInput.bind(this); + } + + /** + * @param {React.ChangeEvent} event + */ + handleInput(event) { + event.preventDefault(); + const { scale, onChange } = this.props; + const frac = Number(event.target.value) / 100; + onChange(scale.fromFractionToSingleDigitValue(frac)); + } + + render() { + const { label: labelText, scale, id, value, display } = this.props; + return div( + { className: "perf-settings-row" }, + label( + { + className: "perf-settings-label", + htmlFor: id, + }, + labelText + ), + div( + { className: "perf-settings-value" }, + div( + { className: "perf-settings-range-input" }, + input({ + type: "range", + className: `perf-settings-range-input-el`, + min: "0", + max: "100", + value: scale.fromValueToFraction(value) * 100, + onChange: this.handleInput, + id, + }) + ), + div({ className: `perf-settings-range-value` }, display(value)) + ) + ); + } +} + +module.exports = Range; diff --git a/devtools/client/performance-new/components/RecordingButton.js b/devtools/client/performance-new/components/RecordingButton.js new file mode 100644 index 0000000000..e6dccc329c --- /dev/null +++ b/devtools/client/performance-new/components/RecordingButton.js @@ -0,0 +1,254 @@ +/* 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

} ResolveThunks

+ */ + +/** + * @typedef {Object} StateProps + * @property {RecordingState} recordingState + * @property {boolean} isSupportedPlatform + * @property {boolean} recordingUnexpectedlyStopped + * @property {PageContext} pageContext + */ + +/** + * @typedef {Object} ThunkDispatchProps + * @property {typeof actions.startRecording} startRecording + * @property {typeof actions.getProfileAndStopProfiler} getProfileAndStopProfiler + * @property {typeof actions.stopProfilerAndDiscardProfile} stopProfilerAndDiscardProfile + + */ + +/** + * @typedef {ResolveThunks} DispatchProps + * @typedef {StateProps & DispatchProps} Props + * @typedef {import("../@types/perf").RecordingState} RecordingState + * @typedef {import("../@types/perf").State} StoreState + * @typedef {import("../@types/perf").PageContext} PageContext + */ + +"use strict"; + +const { PureComponent } = require("devtools/client/shared/vendor/react"); +const { + div, + button, + span, + img, +} = require("devtools/client/shared/vendor/react-dom-factories"); +const { connect } = require("devtools/client/shared/vendor/react-redux"); +const actions = require("devtools/client/performance-new/store/actions"); +const selectors = require("devtools/client/performance-new/store/selectors"); +const React = require("devtools/client/shared/vendor/react"); +const Localized = React.createFactory( + require("devtools/client/shared/vendor/fluent-react").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} + */ +class RecordingButton extends PureComponent { + render() { + const { + startRecording, + stopProfilerAndDiscardProfile, + recordingState, + isSupportedPlatform, + recordingUnexpectedlyStopped, + getProfileAndStopProfiler, + } = 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: startRecording, + 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: getProfileAndStopProfiler, + disabled: recordingState === "request-to-start-recording", + additionalButton: { + label: Localized( + { id: "perftools-button-cancel-recording" }, + "Cancel recording" + ), + onClick: stopProfilerAndDiscardProfile, + }, + }); + + case "locked-by-private-browsing": + return renderButton({ + label: startRecordingLabel(), + isPrimary: true, + disabled: true, + additionalMessage: Localized( + { id: "perftools-status-private-browsing-notice" }, + `The profiler is disabled when Private Browsing is enabled. + Close all Private Windows to re-enable the profiler` + ), + }); + + 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/Settings.js b/devtools/client/performance-new/components/Settings.js new file mode 100644 index 0000000000..96803136a3 --- /dev/null +++ b/devtools/client/performance-new/components/Settings.js @@ -0,0 +1,620 @@ +/* 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} DispatchProps + */ + +/** + * @typedef {Object} State + * @property {null | string} temporaryThreadText + */ + +/** + * @typedef {import("../@types/perf").PopupWindow} PopupWindow + * @typedef {import("../@types/perf").State} StoreState + * @typedef {import("../@types/perf").FeatureDescription} FeatureDescription + * + * @typedef {StateProps & DispatchProps} Props + */ + +/** + * @template P + * @typedef {import("react-redux").ResolveThunks

} ResolveThunks

+ */ + +/** + * @template InjectedProps + * @template NeededProps + * @typedef {import("react-redux") + * .InferableComponentEnhancerWithProps + * } InferableComponentEnhancerWithProps + */ +"use strict"; + +const { + PureComponent, + createFactory, +} = require("devtools/client/shared/vendor/react"); +const { + div, + label, + input, + h1, + h2, + h3, + section, + p, +} = require("devtools/client/shared/vendor/react-dom-factories"); +const Range = createFactory( + require("devtools/client/performance-new/components/Range") +); +const DirectoryPicker = createFactory( + require("devtools/client/performance-new/components/DirectoryPicker") +); +const { + makeExponentialScale, + makePowerOf2Scale, + formatFileSize, + featureDescriptions, +} = require("devtools/client/performance-new/utils"); +const { connect } = require("devtools/client/shared/vendor/react-redux"); +const actions = require("devtools/client/performance-new/store/actions"); +const selectors = require("devtools/client/performance-new/store/selectors"); +const { + openFilePickerForObjdir, +} = require("devtools/client/performance-new/browser"); +const Localized = createFactory( + require("devtools/client/shared/vendor/fluent-react").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} */ +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: "PaintWorker", + id: "paint-worker", + l10nId: "perftools-thread-paint-worker", + }, + { + 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-dns-resolver", + }, + { + name: "DNS Resolver", + id: "dns-resolver", + l10nId: "perftools-thread-dns-resolver", + }, + { + name: "JS Helper", + id: "js-helper", + l10nId: "perftools-thread-js-helper", + }, + ], +]; + +/** + * This component manages the settings for recording a performance profile. + * @extends {React.PureComponent} + */ +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._handleThreadCheckboxChange = this._handleThreadCheckboxChange.bind( + this + ); + this._handleFeaturesCheckboxChange = this._handleFeaturesCheckboxChange.bind( + this + ); + this._handleAddObjdir = this._handleAddObjdir.bind(this); + this._handleRemoveObjdir = this._handleRemoveObjdir.bind(this); + this._setThreadTextFromInput = this._setThreadTextFromInput.bind(this); + this._handleThreadTextCleanup = this._handleThreadTextCleanup.bind(this); + this._renderThreadsColumns = this._renderThreadsColumns.bind(this); + + this._intervalExponentialScale = makeExponentialScale(0.01, 100); + this._entriesExponentialScale = makePowerOf2Scale( + 128 * 1024, + 256 * 1024 * 1024 + ); + } + + /** + * Handle the checkbox change. + * @param {React.ChangeEvent} 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} 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} event + */ + _setThreadTextFromInput(event) { + this.setState({ temporaryThreadText: event.target.value }); + } + + /** + * @param {React.ChangeEvent} 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; + 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", + }, + 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), + onChange: this._handleThreadCheckboxChange, + }), + 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(this._renderThreadsColumns) + ), + div( + { className: "perf-settings-all-threads" }, + label( + { + className: "perf-settings-checkbox-label", + }, + input({ + className: "perf-settings-checkbox", + 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, + }) + ) + ) + ) + ) + ); + } + + /** + * @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-settings-feature-label ${extraClassName}`, + key: value, + }, + div( + { className: "perf-settings-checkbox-and-name" }, + input({ + className: "perf-settings-checkbox", + id: `perf-settings-feature-checkbox-${value}`, + type: "checkbox", + value, + checked: isSupported && this.props.features.includes(value), + onChange: this._handleFeaturesCheckboxChange, + disabled: !isSupported, + }), + div( + { className: "perf-settings-feature-name" }, + !isSupported && featureDescription.experimental + ? // Note when unsupported features are experimental. + `${name} (Experimental)` + : name + ) + ), + div( + { className: "perf-settings-feature-title" }, + 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); +} + +/** + * about:profiling doesn't need to collapse the children into details/summary, + * but the popup and devtools do (for now). + * + * @param {string} id + * @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/moz.build b/devtools/client/performance-new/components/moz.build new file mode 100644 index 0000000000..2287b44a7e --- /dev/null +++ b/devtools/client/performance-new/components/moz.build @@ -0,0 +1,18 @@ +# 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", + "Description.js", + "DevToolsPanel.js", + "DevToolsPresetSelection.js", + "DirectoryPicker.js", + "OnboardingMessage.js", + "Presets.js", + "ProfilerEventHandling.js", + "Range.js", + "RecordingButton.js", + "Settings.js", +) diff --git a/devtools/client/performance-new/frame-script.js b/devtools/client/performance-new/frame-script.js new file mode 100644 index 0000000000..e1bafe12d1 --- /dev/null +++ b/devtools/client/performance-new/frame-script.js @@ -0,0 +1,200 @@ +/* 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 +/// +/* global content */ +"use strict"; + +/** + * @typedef {import("./@types/perf").GetSymbolTableCallback} GetSymbolTableCallback + * @typedef {import("./@types/perf").ContentFrameMessageManager} ContentFrameMessageManager + * @typedef {import("./@types/perf").MinimallyTypedGeckoProfile} MinimallyTypedGeckoProfile + */ + +/** + * This frame script injects itself into profiler.firefox.com and injects the profile + * into the page. It is mostly taken from the Gecko Profiler Addon implementation. + */ + +const TRANSFER_EVENT = "devtools:perf-html-transfer-profile"; +const SYMBOL_TABLE_REQUEST_EVENT = "devtools:perf-html-request-symbol-table"; +const SYMBOL_TABLE_RESPONSE_EVENT = "devtools:perf-html-reply-symbol-table"; + +/** @type {null | MinimallyTypedGeckoProfile} */ +let gProfile = null; +const symbolReplyPromiseMap = new Map(); + +/** + * TypeScript wants to use the DOM library definition, which conflicts with our + * own definitions for the frame message manager. Instead, coerce the `this` + * variable into the proper interface. + * + * @type {ContentFrameMessageManager} + */ +let frameScript; +{ + const any = /** @type {any} */ (this); + frameScript = any; +} + +frameScript.addMessageListener(TRANSFER_EVENT, e => { + gProfile = e.data; + // Eagerly try and see if the framescript was evaluated after perf loaded its scripts. + connectToPage(); + // If not try again at DOMContentLoaded which should be called after the script + // tag was synchronously loaded in. + frameScript.addEventListener("DOMContentLoaded", connectToPage); +}); + +frameScript.addMessageListener(SYMBOL_TABLE_RESPONSE_EVENT, e => { + const { debugName, breakpadId, status, result, error } = e.data; + const promiseKey = [debugName, breakpadId].join(":"); + const { resolve, reject } = symbolReplyPromiseMap.get(promiseKey); + symbolReplyPromiseMap.delete(promiseKey); + + if (status === "success") { + const [addresses, index, buffer] = result; + resolve([addresses, index, buffer]); + } else { + reject(error); + } +}); + +function connectToPage() { + const unsafeWindow = content.wrappedJSObject; + if (unsafeWindow.connectToGeckoProfiler) { + unsafeWindow.connectToGeckoProfiler( + makeAccessibleToPage( + { + getProfile: () => + gProfile + ? Promise.resolve(gProfile) + : Promise.reject( + new Error("No profile was available to inject into the page.") + ), + getSymbolTable: (debugName, breakpadId) => + getSymbolTable(debugName, breakpadId), + }, + unsafeWindow + ) + ); + } +} + +/** @type {GetSymbolTableCallback} */ +function getSymbolTable(debugName, breakpadId) { + return new Promise((resolve, reject) => { + frameScript.sendAsyncMessage(SYMBOL_TABLE_REQUEST_EVENT, { + debugName, + breakpadId, + }); + symbolReplyPromiseMap.set([debugName, breakpadId].join(":"), { + resolve, + reject, + }); + }); +} + +// The following functions handle the security of cloning the object into the page. +// The code was taken from the original Gecko Profiler Add-on to maintain +// compatibility with the existing profile importing mechanism: +// See: https://github.com/firefox-devtools/Gecko-Profiler-Addon/blob/78138190b42565f54ce4022a5b28583406489ed2/data/tab-framescript.js + +/** + * Create a promise that can be used in the page. + * + * @template T + * @param {(resolve: Function, reject: Function) => Promise} fun + * @param {any} contentGlobal + * @returns Promise + */ +function createPromiseInPage(fun, contentGlobal) { + /** + * Use the any type here, as this is pretty dynamic, and probably not worth typing. + * @param {any} resolve + * @param {any} reject + */ + function funThatClonesObjects(resolve, reject) { + return fun( + /** @type {(result: any) => any} */ + result => resolve(Cu.cloneInto(result, contentGlobal)), + /** @type {(result: any) => any} */ + error => { + if (error.name) { + // Turn the JSON error object into a real Error object. + const { name, message, fileName, lineNumber } = error; + const ErrorObjConstructor = + name in contentGlobal && + contentGlobal.Error.isPrototypeOf(contentGlobal[name]) + ? contentGlobal[name] + : contentGlobal.Error; + const e = new ErrorObjConstructor(message, fileName, lineNumber); + e.name = name; + reject(e); + } else { + reject(Cu.cloneInto(error, contentGlobal)); + } + } + ); + } + return new contentGlobal.Promise( + Cu.exportFunction(funThatClonesObjects, contentGlobal) + ); +} + +/** + * Returns a function that calls the original function and tries to make the + * return value available to the page. + * @param {Function} fun + * @param {any} contentGlobal + * @return {Function} + */ +function wrapFunction(fun, contentGlobal) { + return function() { + // @ts-ignore - Ignore the use of `this`. + const result = fun.apply(this, arguments); + if (typeof result === "object") { + if ("then" in result && typeof result.then === "function") { + // fun returned a promise. + return createPromiseInPage( + (resolve, reject) => result.then(resolve, reject), + contentGlobal + ); + } + return Cu.cloneInto(result, contentGlobal); + } + return result; + }; +} + +/** + * Pass a simple object containing values that are objects or functions. + * The objects or functions are wrapped in such a way that they can be + * consumed by the page. + * @template T + * @param {T} obj + * @param {any} contentGlobal + * @return {T} + */ +function makeAccessibleToPage(obj, contentGlobal) { + /** @type {any} - This value is probably too dynamic to type. */ + const result = Cu.createObjectIn(contentGlobal); + for (const field in obj) { + switch (typeof obj[field]) { + case "function": + // @ts-ignore - Ignore the obj[field] call. This code is too dynamic. + Cu.exportFunction(wrapFunction(obj[field], contentGlobal), result, { + defineAs: field, + }); + break; + case "object": + Cu.cloneInto(obj[field], result, { defineAs: field }); + break; + default: + result[field] = obj[field]; + break; + } + } + return result; +} diff --git a/devtools/client/performance-new/index.xhtml b/devtools/client/performance-new/index.xhtml new file mode 100644 index 0000000000..2097d06f5d --- /dev/null +++ b/devtools/client/performance-new/index.xhtml @@ -0,0 +1,24 @@ + + + %htmlDTD; +]> + + + + + + + + +

+ + + + diff --git a/devtools/client/performance-new/initializer.js b/devtools/client/performance-new/initializer.js new file mode 100644 index 0000000000..d575a99af1 --- /dev/null +++ b/devtools/client/performance-new/initializer.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 +/* exported gInit, gDestroy, loader */ + +/** + * @typedef {import("./@types/perf").PerfFront} PerfFront + * @typedef {import("./@types/perf").PreferenceFront} PreferenceFront + * @typedef {import("./@types/perf").RecordingStateFromPreferences} RecordingStateFromPreferences + * @typedef {import("./@types/perf").PageContext} PageContext + * @typedef {import("./@types/perf").PanelWindow} PanelWindow + * @typedef {import("./@types/perf").Store} Store + * @typedef {import("./@types/perf").MinimallyTypedGeckoProfile} MinimallyTypedGeckoProfile + */ +"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/client/shared/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("devtools/client/shared/vendor/react-dom"); +const React = require("devtools/client/shared/vendor/react"); +const FluentReact = require("devtools/client/shared/vendor/fluent-react"); +const { + FluentL10n, +} = require("devtools/client/shared/fluent-l10n/fluent-l10n"); +const Provider = React.createFactory( + require("devtools/client/shared/vendor/react-redux").Provider +); +const LocalizationProvider = React.createFactory( + FluentReact.LocalizationProvider +); +const DevToolsPanel = React.createFactory( + require("devtools/client/performance-new/components/DevToolsPanel") +); +const ProfilerEventHandling = React.createFactory( + require("devtools/client/performance-new/components/ProfilerEventHandling") +); +const createStore = require("devtools/client/shared/redux/create-store"); +const selectors = require("devtools/client/performance-new/store/selectors"); +const reducers = require("devtools/client/performance-new/store/reducers"); +const actions = require("devtools/client/performance-new/store/actions"); +const { + receiveProfile, + createMultiModalGetSymbolTableFn, +} = require("devtools/client/performance-new/browser"); + +const { + setRecordingPreferences, + presets, + getRecordingPreferences, +} = ChromeUtils.import( + "resource://devtools/client/performance-new/popup/background.jsm.js" +); + +/** + * 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 {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, pageContext, openAboutProfiling) { + const store = createStore(reducers); + const supportedFeatures = await perfFront.getSupportedFeatures(); + + if (!openAboutProfiling) { + openAboutProfiling = () => { + const { openTrustedLink } = require("devtools/client/shared/link"); + openTrustedLink("about:profiling", {}); + }; + } + + { + // 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"]); + + // Do some initialization, especially with privileged things that are part of the + // the browser. + store.dispatch( + actions.initializeStore({ + perfFront, + receiveProfile, + recordingPreferences: getRecordingPreferences( + pageContext, + supportedFeatures + ), + presets, + supportedFeatures, + openAboutProfiling, + pageContext: "devtools", + + // Go ahead and hide the implementation details for the component on how the + // preference information is stored + /** + * @param {RecordingStateFromPreferences} newRecordingPreferences + */ + setRecordingPreferences: newRecordingPreferences => + setRecordingPreferences(pageContext, newRecordingPreferences), + + // Configure the getSymbolTable function for the DevTools workflow. + // See createMultiModalGetSymbolTableFn for more information. + getSymbolTableGetter: + /** @type {(profile: MinimallyTypedGeckoProfile) => GetSymbolTableCallback} */ + profile => + createMultiModalGetSymbolTableFn( + profile, + () => selectors.getObjdirs(store.getState()), + selectors.getPerfFront(store.getState()) + ), + }) + ); + + ReactDOM.render( + Provider( + { store }, + LocalizationProvider( + { bundles: l10n.getBundles() }, + React.createElement( + React.Fragment, + null, + ProfilerEventHandling(), + DevToolsPanel() + ) + ) + ), + document.querySelector("#root") + ); +} + +function gDestroy() { + ReactDOM.unmountComponentAtNode(document.querySelector("#root")); +} diff --git a/devtools/client/performance-new/moz.build b/devtools/client/performance-new/moz.build new file mode 100644 index 0000000000..a0538297e1 --- /dev/null +++ b/devtools/client/performance-new/moz.build @@ -0,0 +1,27 @@ +# 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", + "popup", +] + +DevToolsModules( + "browser.js", + "initializer.js", + "panel.js", + "preference-management.js", + "symbolication.jsm.js", + "typescript-lazy-load.jsm.js", + "utils.js", +) + +BROWSER_CHROME_MANIFESTS += ["test/browser/browser.ini"] +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.ini"] + +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..ecfc416661 --- /dev/null +++ b/devtools/client/performance-new/package.json @@ -0,0 +1,16 @@ +{ + "name": "devtools-bin", + "version": "1.0.0", + "scripts": { + "test": "tsc", + "test-ci": "tsc" + }, + "license": "MPL-2.0", + "devDependencies": { + "@types/react": "^16.9.10", + "@types/react-dom-factories": "^1.0.2", + "@types/react-redux": "^7.1.5", + "@types/redux": "^3.6.0", + "typescript": "^3.8.3" + } +} diff --git a/devtools/client/performance-new/panel.js b/devtools/client/performance-new/panel.js new file mode 100644 index 0000000000..2a6394942a --- /dev/null +++ b/devtools/client/performance-new/panel.js @@ -0,0 +1,90 @@ +/* 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 + */ + +class PerformancePanel { + /** + * @param {PanelWindow} iframeWindow + * @param {Toolbox} toolbox + */ + constructor(iframeWindow, toolbox) { + this.panelWin = iframeWindow; + this.toolbox = toolbox; + + const EventEmitter = require("devtools/shared/event-emitter"); + 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} 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 + */ + async _doOpen() { + this.panelWin.gToolbox = this.toolbox; + this.panelWin.gIsPanelDestroyed = false; + + const perfFront = await this.target.client.mainRoot.getFront("perf"); + + this.isReady = true; + this.emit("ready"); + this.panelWin.gInit(perfFront, "devtools"); + return this; + } + + // 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/background.jsm.js b/devtools/client/performance-new/popup/background.jsm.js new file mode 100644 index 0000000000..6ac121c752 --- /dev/null +++ b/devtools/client/performance-new/popup/background.jsm.js @@ -0,0 +1,620 @@ +/* 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 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. + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { createLazyLoaders } = ChromeUtils.import( + "resource://devtools/client/performance-new/typescript-lazy-load.jsm.js" +); +// 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.import( + "resource://gre/modules/AppConstants.jsm" +).AppConstants; + +/** + * @typedef {import("../@types/perf").RecordingStateFromPreferences} RecordingStateFromPreferences + * @typedef {import("../@types/perf").PopupBackgroundFeatures} PopupBackgroundFeatures + * @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").MessageFromFrontend} MessageFromFrontend + * @typedef {import("../@types/perf").PageContext} PageContext + * @typedef {import("../@types/perf").Presets} Presets + * @typedef {import("../@types/perf").ProfilerViewMode} ProfilerViewMode + */ + +/** @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"; + +// Lazily load the require function, when it's needed. +ChromeUtils.defineModuleGetter( + this, + "require", + "resource://devtools/shared/Loader.jsm" +); + +// 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({ + OS: () => ChromeUtils.import("resource://gre/modules/osfile.jsm"), + Utils: () => require("devtools/client/performance-new/utils"), + BrowserModule: () => require("devtools/client/performance-new/browser"), + RecordingUtils: () => + require("devtools/shared/performance-new/recording-utils"), + CustomizableUI: () => + ChromeUtils.import("resource:///modules/CustomizableUI.jsm"), + PerfSymbolication: () => + ChromeUtils.import( + "resource://devtools/client/performance-new/symbolication.jsm.js" + ), + PreferenceManagement: () => + require("devtools/client/performance-new/preference-management"), + ProfilerMenuButton: () => + ChromeUtils.import( + "resource://devtools/client/performance-new/popup/menu-button.jsm.js" + ), +}); + +// TODO - Bug 1681539. The presets still need to be localized. + +/** @type {Presets} */ +const presets = { + "web-developer": { + label: "Web Developer", + description: + "Recommended preset for most web app debugging, with low overhead.", + entries: 128 * 1024 * 1024, + interval: 1, + features: ["screenshots", "js"], + threads: ["GeckoMain", "Compositor", "Renderer", "DOM Worker"], + duration: 0, + profilerViewMode: "active-tab", + }, + "firefox-platform": { + label: "Firefox Platform", + description: "Recommended preset for internal Firefox platform debugging.", + entries: 128 * 1024 * 1024, + interval: 1, + features: ["screenshots", "js", "leaf", "stackwalk", "java"], + threads: ["GeckoMain", "Compositor", "Renderer", "SwComposite"], + duration: 0, + }, + "firefox-front-end": { + label: "Firefox Front-End", + description: "Recommended preset for internal Firefox front-end debugging.", + entries: 128 * 1024 * 1024, + interval: 1, + features: ["screenshots", "js", "leaf", "stackwalk", "java"], + threads: ["GeckoMain", "Compositor", "Renderer", "DOM Worker"], + duration: 0, + }, + graphics: { + label: "Firefox Graphics", + description: + "Recommended preset for Firefox graphics performance investigation.", + entries: 128 * 1024 * 1024, + interval: 1, + features: ["leaf", "stackwalk", "js", "java"], + threads: [ + "GeckoMain", + "Compositor", + "Renderer", + "SwComposite", + "RenderBackend", + "SceneBuilder", + "WrWorker", + ], + duration: 0, + }, + media: { + label: "Media", + description: "Recommended preset for diagnosing audio and video problems.", + entries: 128 * 1024 * 1024, + interval: 1, + features: ["js", "leaf", "stackwalk", "audiocallbacktracing"], + threads: [ + "AsyncCubebTask", + "AudioIPC", + "Compositor", + "GeckoMain", + "GraphRunner", + "MediaDecoderStateMachine", + "MediaPDecoder", + "MediaSupervisor", + "MediaTimer", + "NativeAudioCallback", + "RenderBackend", + "Renderer", + "SwComposite", + ], + duration: 0, + }, +}; + +/** + * This Map caches the symbols from the shared libraries. + * @type {Map} + */ +const symbolCache = new Map(); + +/** + * @param {PageContext} pageContext + * @param {string} debugName + * @param {string} breakpadId + */ +async function getSymbolsFromThisBrowser(pageContext, debugName, breakpadId) { + if (symbolCache.size === 0) { + // Prime the symbols cache. + for (const lib of Services.profiler.sharedLibraries) { + symbolCache.set(`${lib.debugName}/${lib.breakpadId}`, lib); + } + } + + const cachedLib = symbolCache.get(`${debugName}/${breakpadId}`); + if (!cachedLib) { + throw new Error( + `The library ${debugName} ${breakpadId} is not in the ` + + "Services.profiler.sharedLibraries list, so the local path for it is not known " + + "and symbols for it can not be obtained. This usually happens if a content " + + "process uses a library that's not used in the parent process - " + + "Services.profiler.sharedLibraries only knows about libraries in the " + + "parent process." + ); + } + + const lib = cachedLib; + const objdirs = getObjdirPrefValue(pageContext); + const { getSymbolTableMultiModal } = lazy.PerfSymbolication(); + return getSymbolTableMultiModal(lib, objdirs); +} + +/** + * 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} + */ +function getProfilerViewModeForCurrentPreset(pageContext) { + const postfix = getPrefPostfix(pageContext); + const presetName = Services.prefs.getCharPref(PRESET_PREF + postfix); + + 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 directly by devtools/startup/DevToolsStartup.jsm when + * using the shortcut keys to capture a profile. + * @param {PageContext} pageContext + * @return {Promise} + */ +async function captureProfile(pageContext) { + if (!Services.profiler.IsActive()) { + // The profiler is not active, ignore this shortcut. + return; + } + if (Services.profiler.IsPaused()) { + // The profiler is already paused for capture, ignore this shortcut. + 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(); + + const profile = await Services.profiler + .getProfileDataAsGzippedArrayBuffer() + .catch( + /** @type {(e: any) => {}} */ e => { + console.error(e); + return {}; + } + ); + + const profilerViewMode = getProfilerViewModeForCurrentPreset(pageContext); + const receiveProfile = lazy.BrowserModule().receiveProfile; + receiveProfile(profile, profilerViewMode, (debugName, breakpadId) => { + return getSymbolsFromThisBrowser(pageContext, debugName, breakpadId); + }); + + Services.profiler.StopProfiler(); +} + +/** + * This function is only called by devtools/startup/DevToolsStartup.jsm when + * starting the profiler using the shortcut keys, through toggleProfiler below. + * @param {PageContext} pageContext + */ +function startProfiler(pageContext) { + const { translatePreferencesToState } = lazy.PreferenceManagement(); + const { + entries, + interval, + features, + threads, + duration, + } = translatePreferencesToState( + getRecordingPreferences(pageContext, Services.profiler.GetFeatures()) + ); + + // Get the active BrowsingContext ID from browser. + const { getActiveBrowsingContextID } = lazy.RecordingUtils(); + const activeBrowsingContextID = getActiveBrowsingContextID(); + + Services.profiler.StartProfiler( + entries, + interval, + features, + threads, + activeBrowsingContextID, + duration + ); +} + +/** + * This function is called directly by devtools/startup/DevToolsStartup.jsm when + * using the shortcut keys to capture a profile. + * @type {() => void} + */ +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} + */ +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 + */ +function restartProfiler(pageContext) { + stopProfiler(); + startProfiler(pageContext); +} + +/** + * @param {string} prefName + * @return {string[]} + */ +function _getArrayOfStringsPref(prefName) { + const text = Services.prefs.getCharPref(prefName); + return JSON.parse(text); +} + +/** + * @param {string} prefName + * @return {string[]} + */ +function _getArrayOfStringsHostPref(prefName) { + const text = Services.prefs.getStringPref(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 {string} + */ +function getPrefPostfix(pageContext) { + switch (pageContext) { + case "devtools": + case "aboutprofiling": + // 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 {PageContext} pageContext + * @returns {string[]} + */ +function getObjdirPrefValue(pageContext) { + const postfix = getPrefPostfix(pageContext); + return _getArrayOfStringsHostPref(OBJDIRS_PREF + postfix); +} + +/** + * @param {PageContext} pageContext + * @param {string[]} supportedFeatures + * @returns {RecordingStateFromPreferences} + */ +function getRecordingPreferences(pageContext, supportedFeatures) { + const postfix = getPrefPostfix(pageContext); + + // If you add a new preference here, please do not forget to update + // `revertRecordingPreferences` as well. + const objdirs = getObjdirPrefValue(pageContext); + const presetName = Services.prefs.getCharPref(PRESET_PREF + postfix); + + // First try to get the values from a preset. + const recordingPrefs = getRecordingPrefsFromPreset( + presetName, + supportedFeatures, + objdirs + ); + if (recordingPrefs) { + return recordingPrefs; + } + + // Next use the preferences to get the values. + const entries = Services.prefs.getIntPref(ENTRIES_PREF + postfix); + const interval = Services.prefs.getIntPref(INTERVAL_PREF + postfix); + const features = _getArrayOfStringsPref(FEATURES_PREF + postfix); + const threads = _getArrayOfStringsPref(THREADS_PREF + postfix); + const duration = Services.prefs.getIntPref(DURATION_PREF + postfix); + + 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 {string} presetName + * @param {string[]} supportedFeatures + * @param {string[]} objdirs + * @return {RecordingStateFromPreferences | null} + */ +function getRecordingPrefsFromPreset(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, + // The interval is stored in preferences as microseconds, but the preset + // defines it in terms of milliseconds. Make the conversion here. + interval: preset.interval * 1000, + // 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 {PageContext} pageContext + * @param {RecordingStateFromPreferences} prefs + */ +function setRecordingPreferences(pageContext, prefs) { + const postfix = getPrefPostfix(pageContext); + Services.prefs.setCharPref(PRESET_PREF + postfix, prefs.presetName); + Services.prefs.setIntPref(ENTRIES_PREF + postfix, prefs.entries); + // The interval pref stores the value in microseconds for extra precision. + Services.prefs.setIntPref(INTERVAL_PREF + postfix, prefs.interval); + Services.prefs.setCharPref( + FEATURES_PREF + postfix, + JSON.stringify(prefs.features) + ); + Services.prefs.setCharPref( + THREADS_PREF + postfix, + JSON.stringify(prefs.threads) + ); + Services.prefs.setCharPref( + OBJDIRS_PREF + postfix, + JSON.stringify(prefs.objdirs) + ); +} + +const platform = AppConstants.platform; + +/** + * Revert the recording prefs for both local and remote profiling. + * @return {void} + */ +function revertRecordingPreferences() { + for (const postfix of ["", ".remote"]) { + Services.prefs.clearUserPref(PRESET_PREF + postfix); + Services.prefs.clearUserPref(ENTRIES_PREF + postfix); + Services.prefs.clearUserPref(INTERVAL_PREF + postfix); + Services.prefs.clearUserPref(FEATURES_PREF + postfix); + Services.prefs.clearUserPref(THREADS_PREF + postfix); + Services.prefs.clearUserPref(OBJDIRS_PREF + postfix); + Services.prefs.clearUserPref(DURATION_PREF + postfix); + } + 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} + */ +function changePreset(pageContext, presetName, supportedFeatures) { + const postfix = getPrefPostfix(pageContext); + const objdirs = _getArrayOfStringsHostPref(OBJDIRS_PREF + postfix); + let recordingPrefs = getRecordingPrefsFromPreset( + presetName, + supportedFeatures, + objdirs + ); + + if (!recordingPrefs) { + // No recordingPrefs 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 + postfix, presetName); + recordingPrefs = getRecordingPreferences(pageContext, supportedFeatures); + } + + setRecordingPreferences(pageContext, recordingPrefs); +} + +/** + * 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 + */ +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; + switch (messageFromFrontend.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(); + channel.send( + { + type: "STATUS_RESPONSE", + menuButtonIsEnabled: ProfilerMenuButton.isInNavbar(), + requestId, + }, + target + ); + break; + } + case "ENABLE_MENU_BUTTON": { + const { ownerDocument } = target.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); + + // 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); + + // Respond back that we've done it. + channel.send( + { + type: "ENABLE_MENU_BUTTON_DONE", + requestId, + }, + target + ); + break; + } + default: + console.error( + "An unknown message type was received by the profiler's WebChannel handler.", + message + ); + } +} + +// Provide a fake module.exports for the JSM to be properly read by TypeScript. +/** @type {any} */ (this).module = { exports: {} }; + +module.exports = { + presets, + captureProfile, + startProfiler, + stopProfiler, + restartProfiler, + toggleProfiler, + platform, + getSymbolsFromThisBrowser, + getRecordingPreferences, + setRecordingPreferences, + revertRecordingPreferences, + changePreset, + handleWebChannelMessage, +}; + +// 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/popup/menu-button.jsm.js b/devtools/client/performance-new/popup/menu-button.jsm.js new file mode 100644 index 0000000000..0bb7e26e0a --- /dev/null +++ b/devtools/client/performance-new/popup/menu-button.jsm.js @@ -0,0 +1,334 @@ +/* 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 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. + */ + +// Provide an exports object for the JSM to be properly read by TypeScript. +/** @type {any} */ (this).exports = {}; + +const { createLazyLoaders } = ChromeUtils.import( + "resource://devtools/client/performance-new/typescript-lazy-load.jsm.js" +); + +const lazy = createLazyLoaders({ + Services: () => ChromeUtils.import("resource://gre/modules/Services.jsm"), + CustomizableUI: () => + ChromeUtils.import("resource:///modules/CustomizableUI.jsm"), + CustomizableWidgets: () => + ChromeUtils.import("resource:///modules/CustomizableWidgets.jsm"), + PopupPanel: () => + ChromeUtils.import( + "resource://devtools/client/performance-new/popup/panel.jsm.js" + ), + Background: () => + ChromeUtils.import( + "resource://devtools/client/performance-new/popup/background.jsm.js" + ), +}); + +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 { Services } = lazy.Services(); + + 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/panel.jsm.js").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); + + if (Services.profiler.IsActive()) { + Services.profiler.StopProfiler(); + } + } + } + + const item = { + id: WIDGET_ID, + type: "button-and-view", + viewId, + tooltiptext: "profiler-button.tooltiptext", + + onViewShowing: + /** + * @type {(event: { + * target: ChromeHTMLElement | XULElement, + * detail: { + * addBlocker: (blocker: Promise) => 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 { + selectElementsInPanelview, + createViewControllers, + addPopupEventHandlers, + initializePopup, + } = lazy.PopupPanel(); + + const panelElements = selectElementsInPanelview(event.target); + const panelView = createViewControllers(panelState, panelElements); + addPopupEventHandlers(panelState, panelElements, panelView); + initializePopup(panelState, panelElements, panelView); + } 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: HTMLElement) => void} + */ + onCreated: node => { + const window = node.ownerDocument?.defaultView; + if (!window) { + console.error( + "Unable to find 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() { + buttonElement.setAttribute( + "tooltiptext", + "The profiler is recording a profile" + ); + buttonElement.classList.toggle("profiler-active", true); + buttonElement.classList.toggle("profiler-paused", false); + } + function setButtonPaused() { + buttonElement.setAttribute( + "tooltiptext", + "The profiler is capturing a profile" + ); + buttonElement.classList.toggle("profiler-active", false); + buttonElement.classList.toggle("profiler-paused", true); + } + function setButtonInactive() { + buttonElement.setAttribute( + "tooltiptext", + "Record a performance profile" + ); + 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); +} + +const ProfilerMenuButton = { + initialize, + addToNavbar, + isInNavbar, + openPopup, + remove, +}; + +exports.ProfilerMenuButton = ProfilerMenuButton; + +// Object.keys() confuses the linting which expects a static array expression. +// eslint-disable-next-line +var EXPORTED_SYMBOLS = Object.keys(exports); diff --git a/devtools/client/performance-new/popup/moz.build b/devtools/client/performance-new/popup/moz.build new file mode 100644 index 0000000000..857cc3a3c7 --- /dev/null +++ b/devtools/client/performance-new/popup/moz.build @@ -0,0 +1,13 @@ +# 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.jsm.js", + "menu-button.jsm.js", + "panel.jsm.js", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Performance Tools (Profiler/Timeline)") diff --git a/devtools/client/performance-new/popup/panel.jsm.js b/devtools/client/performance-new/popup/panel.jsm.js new file mode 100644 index 0000000000..33e2e6fb4f --- /dev/null +++ b/devtools/client/performance-new/popup/panel.jsm.js @@ -0,0 +1,386 @@ +/* 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 controls the logic of the profiler popup view. + */ + +/** + * @typedef {ReturnType} Elements + * @typedef {ReturnType} 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 + */ + +const { createLazyLoaders } = ChromeUtils.import( + "resource://devtools/client/performance-new/typescript-lazy-load.jsm.js" +); + +const lazy = createLazyLoaders({ + Services: () => ChromeUtils.import("resource://gre/modules/Services.jsm"), + PanelMultiView: () => + ChromeUtils.import("resource:///modules/PanelMultiView.jsm"), + Background: () => + ChromeUtils.import( + "resource://devtools/client/performance-new/popup/background.jsm.js" + ), +}); + +/** + * 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; + } + + return { + document, + panelview, + window: /** @type {ChromeWindow} */ (document.defaultView), + inactive: getElementById("PanelUI-profiler-inactive"), + active: getElementById("PanelUI-profiler-active"), + locked: getElementById("PanelUI-profiler-locked"), + presetDescription: getElementById("PanelUI-profiler-content-description"), + presetCustom: getElementById("PanelUI-profiler-content-custom"), + presetsCustomButton: getElementById( + "PanelUI-profiler-content-custom-button" + ), + 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, panelview } = elements; + header.setAttribute( + "isinfocollapsed", + state.isInfoCollapsed ? "true" : "false" + ); + // @ts-ignore - Bug 1674368 + panelview + .closest("panel") + .setAttribute( + "isinfoexpanded", + state.isInfoCollapsed ? "false" : "true" + ); + // @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 { Services } = lazy.Services(); + const { presets, getRecordingPreferences } = lazy.Background(); + const { presetName } = getRecordingPreferences( + "aboutprofiling", + Services.profiler.GetFeatures() + ); + const preset = presets[presetName]; + if (preset) { + elements.presetDescription.style.display = "block"; + elements.presetCustom.style.display = "none"; + elements.presetDescription.textContent = preset.description; + elements.presetsMenuList.value = presetName; + // This works around XULElement height issues. + const { height } = elements.presetDescription.getBoundingClientRect(); + elements.presetDescription.style.height = `${height}px`; + } else { + elements.presetDescription.style.display = "none"; + elements.presetCustom.style.display = "block"; + elements.presetsMenuList.value = "custom"; + } + const { PanelMultiView } = lazy.PanelMultiView(); + // Update the description height sizing. + PanelMultiView.forNode(elements.panelview).descriptionHeightWorkaround(); + }, + + updateProfilerState() { + const { Services } = lazy.Services(); + /** + * Convert two boolean values into a "profilerState" enum. + * + * @type {"active" | "inactive" | "locked"} + */ + let profilerState = Services.profiler.IsActive() ? "active" : "inactive"; + if (!Services.profiler.CanProfile()) { + // In private browsing mode, the profiler is locked. + profilerState = "locked"; + } + + switch (profilerState) { + case "active": + elements.inactive.setAttribute("hidden", "true"); + elements.active.setAttribute("hidden", "false"); + elements.settingsSection.setAttribute("hidden", "true"); + elements.contentRecording.setAttribute("hidden", "false"); + elements.locked.setAttribute("hidden", "true"); + break; + case "inactive": + elements.inactive.setAttribute("hidden", "false"); + elements.active.setAttribute("hidden", "true"); + elements.settingsSection.setAttribute("hidden", "false"); + elements.contentRecording.setAttribute("hidden", "true"); + elements.locked.setAttribute("hidden", "true"); + break; + case "locked": { + elements.inactive.setAttribute("hidden", "true"); + elements.active.setAttribute("hidden", "true"); + elements.settingsSection.setAttribute("hidden", "true"); + elements.contentRecording.setAttribute("hidden", "true"); + elements.locked.setAttribute("hidden", "false"); + // This works around XULElement height issues. + const { height } = elements.locked.getBoundingClientRect(); + elements.locked.style.height = `${height}px`; + break; + } + default: + throw new Error("Unhandled profiler state."); + } + }, + + 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 { Services } = lazy.Services(); + const { presets } = lazy.Background(); + const currentPreset = Services.prefs.getCharPref( + "devtools.performance.recording.preset" + ); + + const menuitems = Object.entries(presets).map(([id, preset]) => { + const menuitem = elements.document.createXULElement("menuitem"); + menuitem.setAttribute("label", preset.label); + menuitem.setAttribute("value", id); + if (id === currentPreset) { + elements.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."); + } + panel.removeAttribute("isinfoexpanded"); + /** @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 initializePopup(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(); + + // XUL elements don't vertically size correctly, this is + // the workaround for it. + const { PanelMultiView } = lazy.PanelMultiView(); + PanelMultiView.forNode(elements.panelview).descriptionHeightWorkaround(); + + // 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.presetsMenuList, "popuphidden", event => { + // Changing a preset makes the popup autohide, this handler stops the + // propagation of that event, so that only the menulist's popup closes, + // and not the rest of the popup. + event.stopPropagation(); + }); + + addHandler(elements.presetsMenuList, "click", event => { + // Clicking on a preset makes the popup autohide, this preventDefault stops + // the CustomizableUI from closing the popup. + event.preventDefault(); + }); + + addHandler(elements.presetsCustomButton, "click", () => { + elements.window.openTrustedLinkIn("about:profiling", "tab"); + view.hidePopup(); + }); + + // Update the view when the profiler starts/stops. + const { Services } = lazy.Services(); + + // These are all events that can affect the current state of the profiler. + const events = [ + "profiler-started", + "profiler-stopped", + "chrome-document-global-created", // This is potentially a private browser. + "last-pb-context-exited", + ]; + for (const event of events) { + Services.obs.addObserver(view.updateProfilerState, event); + state.cleanup.push(() => { + Services.obs.removeObserver(view.updateProfilerState, event); + }); + } +} + +// Provide an exports object for the JSM to be properly read by TypeScript. +/** @type {any} */ (this).module = {}; + +module.exports = { + selectElementsInPanelview, + createViewControllers, + addPopupEventHandlers, + initializePopup, +}; + +// 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/preference-management.js b/devtools/client/performance-new/preference-management.js new file mode 100644 index 0000000000..50d2f850fb --- /dev/null +++ b/devtools/client/performance-new/preference-management.js @@ -0,0 +1,58 @@ +/* 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").RecordingStateFromPreferences} RecordingStateFromPreferences + */ + +/* + * This file centralizes some functions needed to manage the preferences for the + * profiler UI inside Firefox. + * Especially we store the current settings as defined by the user in + * preferences: interval, features, threads, etc. + * However the format we use in the preferences isn't always the same as the one + * we use in settings. + * Especially the "interval" setting is handled differently. Indeed, because we + * can't store float data in Firefox preferences, we store it as an integer with + * the microseconds (µs) unit. But in the UI we handle it with the milliseconds + * (ms) unit as a float. That's why there are these multiplication and division + * by 1000 for that property in this file. + */ + +/** + * This function does a translation on each property if needed. Indeed the + * stored prefs sometimes have a different shape. + * This function takes the preferences stored in the user profile and returns + * the preferences to be used in the redux state. + * @param {RecordingStateFromPreferences} preferences + * @returns {RecordingStateFromPreferences} + */ +function translatePreferencesToState(preferences) { + return { + ...preferences, + interval: preferences.interval / 1000, // converts from µs to ms + }; +} + +/** + * This function does a translation on each property if needed. Indeed the + * stored prefs sometimes have a different shape. + * This function takes the preferences from the redux state and returns the + * preferences to be stored in the user profile. + * @param {RecordingStateFromPreferences} state + * @returns {RecordingStateFromPreferences} + */ +function translatePreferencesFromState(state) { + return { + ...state, + interval: state.interval * 1000, // converts from ms to µs + }; +} + +module.exports = { + translatePreferencesToState, + translatePreferencesFromState, +}; 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..bac265d159 --- /dev/null +++ b/devtools/client/performance-new/store/actions.js @@ -0,0 +1,247 @@ +/* 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("devtools/client/performance-new/store/selectors"); +const { + translatePreferencesToState, + translatePreferencesFromState, +} = require("devtools/client/performance-new/preference-management"); +const { + getEnvironmentVariable, +} = require("devtools/client/performance-new/browser"); + +/** + * @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").Presets} Presets + * @typedef {import("../@types/perf").PanelWindow} PanelWindow + */ + +/** + * @template T + * @typedef {import("../@types/perf").ThunkAction} ThunkAction + */ + +/** + * The recording state manages the current state of the recording panel. + * @param {RecordingState} state - A valid state in `recordingState`. + * @param {{ didRecordingUnexpectedlyStopped: boolean }} options + * @return {Action} + */ +const changeRecordingState = (exports.changeRecordingState = ( + state, + options = { didRecordingUnexpectedlyStopped: false } +) => ({ + type: "CHANGE_RECORDING_STATE", + state, + didRecordingUnexpectedlyStopped: options.didRecordingUnexpectedlyStopped, +})); + +/** + * This is the result of the initial questions about the state of the profiler. + * + * @param {boolean} isSupportedPlatform - This is a supported platform. + * @param {RecordingState} recordingState - A valid state in `recordingState`. + * @return {Action} + */ +exports.reportProfilerReady = (isSupportedPlatform, recordingState) => ({ + type: "REPORT_PROFILER_READY", + isSupportedPlatform, + recordingState, +}); + +/** + * Dispatch the given action, and then update the recording settings. + * @param {Action} action + * @return {ThunkAction} + */ +function _dispatchAndUpdatePreferences(action) { + return ({ dispatch, getState }) => { + if (typeof action !== "object") { + throw new Error( + "This function assumes that the dispatched action is a simple object and " + + "synchronous." + ); + } + dispatch(action); + const setRecordingPreferences = selectors.getSetRecordingPreferencesFn( + getState() + ); + const recordingSettings = selectors.getRecordingSettings(getState()); + setRecordingPreferences(translatePreferencesFromState(recordingSettings)); + }; +} + +/** + * Updates the recording settings for the interval. + * @param {number} interval + * @return {ThunkAction} + */ +exports.changeInterval = interval => + _dispatchAndUpdatePreferences({ + type: "CHANGE_INTERVAL", + interval, + }); + +/** + * Updates the recording settings for the entries. + * @param {number} entries + * @return {ThunkAction} + */ +exports.changeEntries = entries => + _dispatchAndUpdatePreferences({ + type: "CHANGE_ENTRIES", + entries, + }); + +/** + * Updates the recording settings for the features. + * @param {string[]} features + * @return {ThunkAction} + */ +exports.changeFeatures = features => { + return ({ dispatch, getState }) => { + let promptEnvRestart = null; + if (selectors.getPageContext(getState()) === "aboutprofiling") { + // TODO Bug 1615431 - The popup supported restarting the browser, but + // this hasn't been updated yet for the about:profiling workflow. + if ( + !getEnvironmentVariable("JS_TRACE_LOGGING") && + features.includes("jstracer") + ) { + promptEnvRestart = "JS_TRACE_LOGGING"; + } + } + + dispatch( + _dispatchAndUpdatePreferences({ + type: "CHANGE_FEATURES", + features, + promptEnvRestart, + }) + ); + }; +}; + +/** + * Updates the recording settings for the threads. + * @param {string[]} threads + * @return {ThunkAction} + */ +exports.changeThreads = threads => + _dispatchAndUpdatePreferences({ + type: "CHANGE_THREADS", + threads, + }); + +/** + * Change the preset. + * @param {Presets} presets + * @param {string} presetName + * @return {ThunkAction} + */ +exports.changePreset = (presets, presetName) => + _dispatchAndUpdatePreferences({ + 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 {ThunkAction} + */ +exports.changeObjdirs = objdirs => + _dispatchAndUpdatePreferences({ + 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 => { + const { recordingPreferences, ...initValues } = values; + return { + ...initValues, + type: "INITIALIZE_STORE", + recordingSettingsFromPreferences: translatePreferencesToState( + recordingPreferences + ), + }; +}; + +/** + * Start a new recording with the perfFront and update the internal recording state. + * @return {ThunkAction} + */ +exports.startRecording = () => { + return ({ dispatch, getState }) => { + const recordingSettings = selectors.getRecordingSettings(getState()); + const perfFront = selectors.getPerfFront(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(changeRecordingState("request-to-start-recording")); + perfFront.startProfiler(recordingSettings); + }; +}; + +/** + * Stops the profiler, and opens the profile in a new window. + * @return {ThunkAction} + */ +exports.getProfileAndStopProfiler = () => { + return async ({ dispatch, getState }) => { + const perfFront = selectors.getPerfFront(getState()); + dispatch(changeRecordingState("request-to-get-profile-and-stop-profiler")); + const profile = await perfFront.getProfileAndStopProfiler(); + + const getSymbolTable = selectors.getSymbolTableGetter(getState())(profile); + const receiveProfile = selectors.getReceiveProfileFn(getState()); + const profilerViewMode = selectors.getProfilerViewMode(getState()); + receiveProfile(profile, profilerViewMode, getSymbolTable); + dispatch(changeRecordingState("available-to-record")); + }; +}; + +/** + * Stops the profiler, but does not try to retrieve the profile. + * @return {ThunkAction} + */ +exports.stopProfilerAndDiscardProfile = () => { + return async ({ dispatch, getState }) => { + const perfFront = selectors.getPerfFront(getState()); + dispatch(changeRecordingState("request-to-stop-profiler")); + + 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..40a07ecc86 --- /dev/null +++ b/devtools/client/performance-new/store/reducers.js @@ -0,0 +1,230 @@ +/* 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 { combineReducers } = require("devtools/client/shared/vendor/redux"); + +/** + * @typedef {import("../@types/perf").Action} Action + * @typedef {import("../@types/perf").State} State + * @typedef {import("../@types/perf").RecordingState} RecordingState + * @typedef {import("../@types/perf").InitializedValues} InitializedValues + */ + +/** + * @template S + * @typedef {import("../@types/perf").Reducer} Reducer + */ + +/** + * The current state of the recording. + * @type {Reducer} + */ +function recordingState(state = "not-yet-known", action) { + switch (action.type) { + case "CHANGE_RECORDING_STATE": + return action.state; + case "REPORT_PROFILER_READY": + return action.recordingState; + default: + return state; + } +} + +/** + * Whether or not the recording state unexpectedly stopped. This allows + * the UI to display a helpful message. + * @type {Reducer} + */ +function recordingUnexpectedlyStopped(state = false, action) { + switch (action.type) { + case "CHANGE_RECORDING_STATE": + return action.didRecordingUnexpectedlyStopped; + default: + return state; + } +} + +/** + * The profiler needs to be queried asynchronously on whether or not + * it supports the user's platform. + * @type {Reducer} + */ +function isSupportedPlatform(state = null, action) { + switch (action.type) { + case "REPORT_PROFILER_READY": + return action.isSupportedPlatform; + default: + return state; + } +} + +// Right now all recording setting the defaults are reset every time the panel +// is opened. These should be persisted between sessions. See Bug 1453014. + +/** + * The setting for the recording interval. Defaults to 1ms. + * @type {Reducer} + */ +function interval(state = 1, action) { + switch (action.type) { + case "CHANGE_INTERVAL": + return action.interval; + case "CHANGE_PRESET": + return action.preset ? action.preset.interval : state; + case "INITIALIZE_STORE": + return action.recordingSettingsFromPreferences.interval; + default: + return state; + } +} + +/** + * The number of entries in the profiler's circular buffer. + * @type {Reducer} + */ +function entries(state = 0, action) { + switch (action.type) { + case "CHANGE_ENTRIES": + return action.entries; + case "CHANGE_PRESET": + return action.preset ? action.preset.entries : state; + case "INITIALIZE_STORE": + return action.recordingSettingsFromPreferences.entries; + default: + return state; + } +} + +/** + * The features that are enabled for the profiler. + * @type {Reducer} + */ +function features(state = [], action) { + switch (action.type) { + case "CHANGE_FEATURES": + return action.features; + case "CHANGE_PRESET": + return action.preset ? action.preset.features : state; + case "INITIALIZE_STORE": + return action.recordingSettingsFromPreferences.features; + default: + return state; + } +} + +/** + * The current threads list. + * @type {Reducer} + */ +function threads(state = [], action) { + switch (action.type) { + case "CHANGE_THREADS": + return action.threads; + case "CHANGE_PRESET": + return action.preset ? action.preset.threads : state; + case "INITIALIZE_STORE": + return action.recordingSettingsFromPreferences.threads; + default: + return state; + } +} + +/** + * The current objdirs list. + * @type {Reducer} + */ +function objdirs(state = [], action) { + switch (action.type) { + case "CHANGE_OBJDIRS": + return action.objdirs; + case "INITIALIZE_STORE": + return action.recordingSettingsFromPreferences.objdirs; + default: + return state; + } +} + +/** + * The current preset name, used to select + * @type {Reducer} + */ +function presetName(state = "", action) { + switch (action.type) { + case "INITIALIZE_STORE": + return action.recordingSettingsFromPreferences.presetName; + case "CHANGE_PRESET": + return action.presetName; + case "CHANGE_INTERVAL": + case "CHANGE_ENTRIES": + case "CHANGE_FEATURES": + case "CHANGE_THREADS": + // When updating any values, switch the preset over to "custom". + return "custom"; + default: + return state; + } +} + +/** + * These are all the values used to initialize the profiler. They should never + * change once added to the store. + * + * @type {Reducer} + */ +function initializedValues(state = null, action) { + switch (action.type) { + case "INITIALIZE_STORE": + return { + perfFront: action.perfFront, + receiveProfile: action.receiveProfile, + setRecordingPreferences: action.setRecordingPreferences, + presets: action.presets, + pageContext: action.pageContext, + getSymbolTableGetter: action.getSymbolTableGetter, + supportedFeatures: action.supportedFeatures, + openAboutProfiling: action.openAboutProfiling, + openRemoteDevTools: action.openRemoteDevTools, + }; + default: + return state; + } +} + +/** + * Some features may need a browser restart with an environment flag. Request + * one here. + * + * @type {Reducer} + */ +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} + */ +module.exports = combineReducers({ + // TODO - The object going into `combineReducers` is not currently type checked + // as being correct for. For instance, recordingState here could be removed, or + // not return the right state, and TypeScript will not create an error. + recordingState, + recordingUnexpectedlyStopped, + isSupportedPlatform, + interval, + entries, + features, + threads, + objdirs, + presetName, + initializedValues, + promptEnvRestart, +}); diff --git a/devtools/client/performance-new/store/selectors.js b/devtools/client/performance-new/store/selectors.js new file mode 100644 index 0000000000..d9332ccff3 --- /dev/null +++ b/devtools/client/performance-new/store/selectors.js @@ -0,0 +1,179 @@ +/* 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").RecordingStateFromPreferences} RecordingStateFromPreferences + * @typedef {import("../@types/perf").InitializedValues} InitializedValues + * @typedef {import("../@types/perf").PerfFront} PerfFront + * @typedef {import("../@types/perf").ReceiveProfile} ReceiveProfile + * @typedef {import("../@types/perf").SetRecordingPreferences} SetRecordingPreferences + * @typedef {import("../@types/perf").GetSymbolTableCallback} GetSymbolTableCallback + * @typedef {import("../@types/perf").RestartBrowserWithEnvironmentVariable} RestartBrowserWithEnvironmentVariable + * @typedef {import("../@types/perf").GetEnvironmentVariable} GetEnvironmentVariable + * @typedef {import("../@types/perf").PageContext} PageContext + * @typedef {import("../@types/perf").Presets} Presets + * @typedef {import("../@types/perf").ProfilerViewMode} ProfilerViewMode + */ +/** + * @template S + * @typedef {import("../@types/perf").Selector} Selector + */ + +/** @type {Selector} */ +const getRecordingState = state => state.recordingState; + +/** @type {Selector} */ +const getRecordingUnexpectedlyStopped = state => + state.recordingUnexpectedlyStopped; + +/** @type {Selector} */ +const getIsSupportedPlatform = state => state.isSupportedPlatform; + +/** @type {Selector} */ +const getInterval = state => state.interval; + +/** @type {Selector} */ +const getEntries = state => state.entries; + +/** @type {Selector} */ +const getFeatures = state => state.features; + +/** @type {Selector} */ +const getThreads = state => state.threads; + +/** @type {Selector} */ +const getThreadsString = state => getThreads(state).join(","); + +/** @type {Selector} */ +const getObjdirs = state => state.objdirs; + +/** @type {Selector} */ +const getPresets = state => getInitializedValues(state).presets; + +/** @type {Selector} */ +const getPresetName = state => state.presetName; + +/** @type {Selector} */ +const getProfilerViewMode = state => state.profilerViewMode; + +/** + * When remote profiling, there will be a back button to the settings. + * + * @type {Selector<(() => void) | undefined>} + */ +const getOpenRemoteDevTools = state => + getInitializedValues(state).openRemoteDevTools; + +/** + * Get the functon to open about:profiling. This assumes that the function exists, + * otherwise it will throw an error. + * + * @type {Selector<() => void>} + */ +const getOpenAboutProfiling = state => { + const { openAboutProfiling } = getInitializedValues(state); + if (!openAboutProfiling) { + throw new Error("Expected to get an openAboutProfiling function."); + } + return openAboutProfiling; +}; + +/** + * Warning! This function returns a new object on every run, and so should not + * be used directly as a React prop. + * + * @type {Selector} + */ +const getRecordingSettings = state => { + const presets = getPresets(state); + const presetName = getPresetName(state); + const preset = presets[presetName]; + if (preset) { + // Use the the settings from the preset. + return { + presetName: presetName, + entries: preset.entries, + interval: preset.interval, + features: preset.features, + threads: preset.threads, + objdirs: getObjdirs(state), + // The client doesn't implement durations yet. See Bug 1587165. + duration: preset.duration, + }; + } + + // Use the the custom settings from the panel. + return { + presetName: "custom", + entries: getEntries(state), + interval: getInterval(state), + features: getFeatures(state), + threads: getThreads(state), + objdirs: getObjdirs(state), + // The client doesn't implement durations yet. See Bug 1587165. + duration: 0, + }; +}; + +/** @type {Selector} */ +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} */ +const getPerfFront = state => getInitializedValues(state).perfFront; + +/** @type {Selector} */ +const getReceiveProfileFn = state => getInitializedValues(state).receiveProfile; + +/** @type {Selector} */ +const getSetRecordingPreferencesFn = state => + getInitializedValues(state).setRecordingPreferences; + +/** @type {Selector} */ +const getPageContext = state => getInitializedValues(state).pageContext; + +/** @type {Selector<(profile: MinimallyTypedGeckoProfile) => GetSymbolTableCallback>} */ +const getSymbolTableGetter = state => + getInitializedValues(state).getSymbolTableGetter; + +/** @type {Selector} */ +const getSupportedFeatures = state => + getInitializedValues(state).supportedFeatures; + +/** @type {Selector} */ +const getPromptEnvRestart = state => state.promptEnvRestart; + +module.exports = { + getRecordingState, + getRecordingUnexpectedlyStopped, + getIsSupportedPlatform, + getInterval, + getEntries, + getFeatures, + getThreads, + getThreadsString, + getObjdirs, + getPresets, + getPresetName, + getProfilerViewMode, + getOpenRemoteDevTools, + getOpenAboutProfiling, + getRecordingSettings, + getInitializedValues, + getPerfFront, + getReceiveProfileFn, + getSetRecordingPreferencesFn, + getPageContext, + getSymbolTableGetter, + getPromptEnvRestart, + getSupportedFeatures, +}; diff --git a/devtools/client/performance-new/symbolication.jsm.js b/devtools/client/performance-new/symbolication.jsm.js new file mode 100644 index 0000000000..99a087d713 --- /dev/null +++ b/devtools/client/performance-new/symbolication.jsm.js @@ -0,0 +1,184 @@ +/* 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 { createLazyLoaders } = ChromeUtils.import( + "resource://devtools/client/performance-new/typescript-lazy-load.jsm.js" +); + +/** + * @typedef {import("./@types/perf").Library} Library + * @typedef {import("./@types/perf").PerfFront} PerfFront + * @typedef {import("./@types/perf").SymbolTableAsTuple} SymbolTableAsTuple + */ + +const lazy = createLazyLoaders({ + OS: () => ChromeUtils.import("resource://gre/modules/osfile.jsm"), + ProfilerGetSymbols: () => + ChromeUtils.import("resource://gre/modules/ProfilerGetSymbols.jsm"), +}); + +/** + * @param {PerfFront} perfFront + * @param {string} path + * @param {string} breakpadId + * @returns {Promise} + */ +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), + ]; +} + +/** + * @param {string} path + * @returns {Promise} + */ +async function doesFileExistAtPath(path) { + const { OS } = lazy.OS(); + try { + const result = await OS.File.stat(path); + return !result.isDir; + } catch (e) { + if (e instanceof OS.File.Error && e.becauseNoSuchFile) { + return false; + } + throw e; + } +} + +/** + * Retrieve a symbol table from a binary on the host machine, by looking up + * relevant build artifacts in the 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. + * + * @param {string[]} objdirs An array of objdir paths on the host machine + * that should be searched for relevant build artifacts. + * @param {string} filename The file name of the binary. + * @param {string} breakpadId The breakpad ID of the binary. + * @returns {Promise} The symbol table of the first encountered binary with a + * matching breakpad ID, in SymbolTableAsTuple format. An exception is thrown (the + * promise is rejected) if nothing was found. + */ +async function getSymbolTableFromLocalBinary(objdirs, filename, breakpadId) { + const { OS } = lazy.OS(); + const candidatePaths = []; + for (const objdirPath of objdirs) { + // Binaries are usually expected to exist at objdir/dist/bin/filename. + candidatePaths.push(OS.Path.join(objdirPath, "dist", "bin", filename)); + // 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(OS.Path.join(objdirPath, filename)); + } + + for (const path of candidatePaths) { + if (await doesFileExistAtPath(path)) { + const { ProfilerGetSymbols } = lazy.ProfilerGetSymbols(); + try { + return await ProfilerGetSymbols.getSymbolTable(path, path, breakpadId); + } catch (e) { + // ProfilerGetSymbols.getSymbolTable was unsuccessful. So either the + // file wasn't parseable or its contents didn't match the specified + // breakpadId, or some other error occurred. + // Advance to the next candidate path. + } + } + } + throw new Error("Could not find any matching binary."); +} + +/** + * Profiling through the DevTools remote debugging protocol supports multiple + * different modes. This function 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. + * + * @param {Library} lib - The library to get symbols for. + * @param {string[]} objdirs - An array of objdir paths on the host machine that + * should be searched for relevant build artifacts. + * @param {PerfFront | undefined} perfFront - The perfFront for a remote debugging + * connection, or undefined when profiling this browser. + * @return {Promise} + */ +async function getSymbolTableMultiModal(lib, objdirs, perfFront = undefined) { + const { name, debugName, path, debugPath, breakpadId } = lib; + try { + // First, try to find a binary with a matching file name and breakpadId + // in one of the manually specified objdirs. If the profiled build was + // compiled locally, and matches the build artifacts in the objdir, then + // this gives the best results because the objdir build always has full + // symbol information. + // This only works if the binary is one of the Gecko binaries and not + // a system library. + return await getSymbolTableFromLocalBinary(objdirs, name, breakpadId); + } catch (errorWhenCheckingObjdirs) { + // Couldn't find a matching build in one of the objdirs. Search elsewhere. + if (await doesFileExistAtPath(path)) { + const { ProfilerGetSymbols } = lazy.ProfilerGetSymbols(); + // This profile was probably obtained from this machine, and not from a + // different device (e.g. an Android phone). Dump symbols from the file + // on this machine directly. + return ProfilerGetSymbols.getSymbolTable(path, debugPath, breakpadId); + } + // No file exists at the path on this machine, which probably indicates + // that the profile was obtained on a different machine, i.e. the debuggee + // is truly remote (e.g. on an Android phone). + if (!perfFront) { + // No remote connection - we really needed the file at path. + throw new Error( + `Could not obtain symbols for the library ${debugName} ${breakpadId} ` + + `because there was no file at the given path "${path}". Furthermore, ` + + `looking for symbols in the given objdirs failed: ${errorWhenCheckingObjdirs.message}` + ); + } + // 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.) + return getSymbolTableFromDebuggee(perfFront, path, breakpadId); + } +} + +// Provide an exports object for the JSM to be properly read by TypeScript. +/** @type {any} */ (this).module = {}; + +module.exports = { + getSymbolTableFromDebuggee, + getSymbolTableFromLocalBinary, + getSymbolTableMultiModal, +}; + +// 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/test/.eslintrc.js b/devtools/client/performance-new/test/.eslintrc.js new file mode 100644 index 0000000000..3d0bd99e1b --- /dev/null +++ b/devtools/client/performance-new/test/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the shared list of defined globals for mochitests. + extends: "../../../.eslintrc.mochitests.js", +}; diff --git a/devtools/client/performance-new/test/browser/.eslintrc.js b/devtools/client/performance-new/test/browser/.eslintrc.js new file mode 100644 index 0000000000..8e6d7b4f8a --- /dev/null +++ b/devtools/client/performance-new/test/browser/.eslintrc.js @@ -0,0 +1,9 @@ +/* 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 = { + // Extend from the shared list of defined globals for mochitests. + extends: "../../../../.eslintrc.mochitests.js", +}; diff --git a/devtools/client/performance-new/test/browser/browser.ini b/devtools/client/performance-new/test/browser/browser.ini new file mode 100644 index 0000000000..5b517fa429 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser.ini @@ -0,0 +1,43 @@ +[DEFAULT] +prefs = + # This sets up the WebChannel so that it can be used for our tests. + devtools.performance.recording.ui-base-url='http://example.com' +tags = devtools devtools-performance +subsuite = devtools +support-files = + head.js + fake-frontend.html + webchannel.html + +[browser_aboutprofiling-env-restart-button.js] +[browser_aboutprofiling-entries.js] +[browser_aboutprofiling-features-disabled.js] +[browser_aboutprofiling-features.js] +skip-if = tsan # Frequently times out on TSan +[browser_aboutprofiling-interval.js] +[browser_aboutprofiling-threads.js] +[browser_aboutprofiling-threads-behavior.js] +[browser_aboutprofiling-presets.js] +[browser_aboutprofiling-presets-custom.js] +[browser_devtools-interrupted.js] +[browser_devtools-onboarding.js] +[browser_devtools-presets.js] +[browser_devtools-previously-started.js] +[browser_devtools-private-window.js] +[browser_devtools-record-capture.js] +[browser_devtools-record-discard.js] +[browser_webchannel-enable-menu-button.js] +[browser_popup-profiler-states.js] +[browser_popup-record-capture.js] +[browser_popup-record-capture-view.js] +[browser_popup-record-discard.js] + +# The popupshown and popuphidden events are not firing correctly on linux, as of +# Bug 1625044. It could be because of the opening of a second private browsing +# window. There should be good enough coverage of this feature with it disabled +# on Linux. This bug appears to have been around for awhile see: +# Bug 941073. Also see: 1178420, 1115757, 1401049, 1269392 +[browser_popup-private-browsing.js] +skip-if = os == 'linux' + +[browser_split-toolbar-button.js] 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..95f9b4d9c9 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_aboutprofiling-env-restart-button.js @@ -0,0 +1,81 @@ +/* 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."); + const { + getEnvironmentVariable, + } = require("devtools/client/performance-new/browser"); + ok( + !getEnvironmentVariable("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..2b8cea19c8 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_aboutprofiling-interval.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 modify the sampling interval."); + + await withAboutProfiling(async document => { + is( + getActiveConfiguration().interval, + 1, + "The active configuration's interval is set to a specific number initially." + ); + + 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( + document, + "Sampling interval:" + ); + setReactFriendlyInputValue(intervalInput, Number(intervalInput.value) + 8); + + is( + getActiveConfiguration().interval, + 2, + "The configuration's interval was able to be increased." + ); + }); +}); 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..03bd0cfa27 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_aboutprofiling-presets.js @@ -0,0 +1,60 @@ +/* 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 platform = await getNearestInputFromText( + document, + "Firefox Platform" + ); + + ok(!platform.checked, "The Firefox Platform preset is not checked."); + platform.click(); + ok( + platform.checked, + "After clicking the input, the Firefox Platform preset is now checked." + ); + + ok( + activeConfigurationHasFeature("stackwalk"), + "The Firefox Platform preset uses stackwalking." + ); + + const frontEnd = await getNearestInputFromText( + document, + "Firefox Front-End" + ); + + ok(!frontEnd.checked, "The Firefox front-end preset is not checked."); + frontEnd.click(); + ok( + frontEnd.checked, + "After clicking the input, the Firefox front-end preset is now checked." + ); + }); +}); 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..a42d9465fa --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_aboutprofiling-threads-behavior.js @@ -0,0 +1,126 @@ +/* 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" + ); + + // 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."); + }); +}); + +/** + * @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..c1e8db5c4f --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_aboutprofiling-threads.js @@ -0,0 +1,38 @@ +/* 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 geckoMainLabel = await getElementFromDocumentByText( + document, + "GeckoMain" + ); + const geckoMainInput = geckoMainLabel.querySelector("input"); + if (!geckoMainInput) { + throw new Error("Unable to find the input from the GeckoMain label."); + } + + 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..87394c5ac7 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_devtools-interrupted.js @@ -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/. */ +"use strict"; + +add_task(async function test() { + info("Test what happens when a recording is interrupted by another tool."); + + const { stopProfiler: stopProfilerByAnotherTool } = ChromeUtils.import( + "resource://devtools/client/performance-new/popup/background.jsm.js" + ); + + 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..4428178541 --- /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.import( + "resource://devtools/client/performance-new/popup/background.jsm.js" + ); + + 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-private-window.js b/devtools/client/performance-new/test/browser/browser_devtools-private-window.js new file mode 100644 index 0000000000..01864225b0 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_devtools-private-window.js @@ -0,0 +1,57 @@ +/* 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 opening a private browsing window while the profiler is active."); + + await withDevToolsPanel(async document => { + const getRecordingState = setupGetRecordingState(document); + + const startRecording = await getActiveButtonFromText( + document, + "Start recording" + ); + + ok(!startRecording.disabled, "The start recording button is not disabled."); + is( + getRecordingState(), + "available-to-record", + "The panel is available to record." + ); + + const privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + await getElementFromDocumentByText( + document, + "The profiler is disabled when Private Browsing is enabled" + ); + + ok( + startRecording.disabled, + "The start recording button is disabled when a private browsing window is open." + ); + + is( + getRecordingState(), + "locked-by-private-browsing", + "The client knows about the private window." + ); + + info("Closing the private window"); + await BrowserTestUtils.closeWindow(privateWindow); + + info("Finally wait for the start recording button to become active again."); + await getActiveButtonFromText(document, "Start recording"); + + is( + getRecordingState(), + "available-to-record", + "The panel is available to record again." + ); + }); +}); 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..51ec553e92 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_devtools-record-capture.js @@ -0,0 +1,90 @@ +/* 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 capture profiles. This function also unit tests the " + + "internal RecordingState of the client." + ); + + await setProfilerFrontendUrl( + "http://example.com/browser/devtools/client/performance-new/test/browser/fake-frontend.html" + ); + + 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." + ); + + 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." + ); + + const captureRecording = await getActiveButtonFromText( + document, + "Capture recording" + ); + + is( + getRecordingState(), + "recording", + "Once the Capture recording button is available, the actor has started " + + "its 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." + ); + + info( + "If the DevTools 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_devtools-record-discard.js b/devtools/client/performance-new/test/browser/browser_devtools-record-discard.js new file mode 100644 index 0000000000..e3d3433fd1 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_devtools-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 DevTools can capture 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_popup-private-browsing.js b/devtools/client/performance-new/test/browser/browser_popup-private-browsing.js new file mode 100644 index 0000000000..ad9b84cbcf --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_popup-private-browsing.js @@ -0,0 +1,48 @@ +/* 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 gets disabled when a private browsing window is open." + ); + await makeSureProfilerPopupIsEnabled(); + + const getRecordingButton = () => + getElementByLabel(document, "Start Recording"); + + const getDisabledMessage = () => + getElementFromDocumentByText( + document, + "The profiler is currently disabled" + ); + + await withPopupOpen(window, async () => { + ok(await getRecordingButton(), "The start recording button is available"); + }); + + info("Open a private browsing window."); + const privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + info("Switch back to the main window and open the popup again."); + window.focus(); + await withPopupOpen(window, async () => { + ok(await getDisabledMessage(), "The disabled message is displayed."); + }); + + info("Close the private window"); + await BrowserTestUtils.closeWindow(privateWindow); + + info("Make sure the first window is focused, and open the popup back up."); + window.focus(); + await withPopupOpen(window, async () => { + ok( + await getRecordingButton(), + "The start recording button is available once again." + ); + }); +}); 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..799d1aefa0 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_popup-profiler-states.js @@ -0,0 +1,81 @@ +/* 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.import( + "resource://devtools/client/performance-new/popup/background.jsm.js" + ); + + 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"); + checkButtonState(button, { + tooltip: "Record a performance profile", + active: false, + paused: false, + }); + + info("Toggling the profiler turns on the active state"); + toggleProfiler("aboutprofiling"); + checkButtonState(button, { + tooltip: "The profiler is recording a profile", + active: true, + paused: false, + }); + + info("Capturing a profile makes the button paused"); + captureProfile("aboutprofiling"); + checkButtonState(button, { + tooltip: "The profiler is capturing a profile", + active: false, + paused: true, + }); + + 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", + }); +}); + +/** + * This check dives into the implementation details of the button, mainly + * because it's hard to provide a user-focused interpretation of button + * stylings. + */ +function checkButtonState(button, { tooltip, active, paused }) { + is( + button.getAttribute("tooltiptext"), + tooltip, + `The tooltip for the button is "${tooltip}".` + ); + 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}` + ); +} 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..aabed66563 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_popup-record-capture-view.js @@ -0,0 +1,67 @@ +/* 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_URL = + "http://example.com/browser/devtools/client/performance-new/test/browser/fake-frontend.html"; + +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" + ); + await setProfilerFrontendUrl(FRONTEND_BASE_URL); + await makeSureProfilerPopupIsEnabled(); + + // First check for "firefox-platform" preset which will have no "view" query + // string because this is where our traditional "full" view opens up. + await openPopupAndAssertUrlForPreset({ + preset: "firefox-platform", + 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({ + preset: "web-developer", + expectedUrl: FRONTEND_BASE_URL + "?view=active-tab", + }); +}); + +async function openPopupAndAssertUrlForPreset({ preset, expectedUrl }) { + // First, switch to the preset we want to test. + BackgroundJSM.changePreset( + "aboutprofiling", + preset, + [] // We don't need any features for this test. + ); + + // Let's capture a profile and assert newly created tab's url. + 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 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..8289014770 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_popup-record-capture.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"; + +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..d8f4ec9f47 --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_popup-record-discard.js @@ -0,0 +1,34 @@ +/* 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..a4efd5c74e --- /dev/null +++ b/devtools/client/performance-new/test/browser/browser_split-toolbar-button.js @@ -0,0 +1,123 @@ +/* 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"; + +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_task(async function setup() { + 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(); + ok(isActive(), "should have started the profiler"); + + button.click(); + await waitForProfileAndCloseTab(); +}); + +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("popupshown"); + dropmarker.click(); + await popupShownPromise; + + info("Ensure the panel is open and the profiler still inactive."); + ok(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("popuphidden"); + EventUtils.synthesizeKey("KEY_Escape"); + await popupHiddenPromise; + ok(!dropmarker.hasAttribute("open"), "panel should be closed"); +}); + +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.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..9817b87e5d --- /dev/null +++ b/devtools/client/performance-new/test/browser/fake-frontend.html @@ -0,0 +1,74 @@ + + + + + + + + + + + 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..ab0715367f --- /dev/null +++ b/devtools/client/performance-new/test/browser/head.js @@ -0,0 +1,728 @@ +/* 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.import( + "resource://devtools/client/performance-new/popup/background.jsm.js" +); + +registerCleanupFunction(() => { + BackgroundJSM.revertRecordingPreferences(); +}); + +/** + * Allow tests to use "require". + */ +const { require } = ChromeUtils.import("resource://devtools/shared/Loader.jsm"); + +{ + const { + getEnvironmentVariable, + } = require("devtools/client/performance-new/browser"); + + if (getEnvironmentVariable("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 (getEnvironmentVariable("MOZ_PROFILER_STARTUP")) { + throw new Error( + "These tests cannot be run with startup profiling as they rely on manipulating " + + "the state of the profiler." + ); + } +} + +/** + * 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 document 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 {string} label + * @returns {Promise} + */ +function getElementByLabel(document, label) { + return waitUntil( + () => document.querySelector(`[label="${label}"]`), + `Trying to find the button with the label "${label}".` + ); +} + +/** + * 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; +} + +/** + * 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} + */ +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}".` + ); +} + +/** + * 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); +} + +/** + * 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.import( + "resource://devtools/client/performance-new/popup/menu-button.jsm.js" + ); + + 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."); + } +} + +/** + * 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 {"popupshown" | "popuphidden"} eventName + * @returns {Promise} + */ +function waitForProfilerPopupEvent(eventName) { + return new Promise(resolve => { + function handleEvent(event) { + if (event.target.getAttribute("viewId") === "PanelUI-profiler") { + window.removeEventListener(eventName, handleEvent); + resolve(); + } + } + window.addEventListener(eventName, handleEvent); + }); +} + +/** + * 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. + * @return {Promise} + */ +async function _toggleOpenProfilerPopup(window) { + info("Toggle open the profiler popup."); + + info("> Find the profiler menu button."); + const profilerDropmarker = document.getElementById( + "profiler-button-dropmarker" + ); + if (!profilerDropmarker) { + throw new Error( + "Could not find the profiler button dropmarker in the toolbar." + ); + } + + const popupShown = waitForProfilerPopupEvent("popupshown"); + + info("> Trigger a click on the profiler button dropmarker."); + await EventUtils.synthesizeMouseAtCenter(profilerDropmarker, {}); + + 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. + * @return {Promise} + */ +async function _closePopup(window) { + const popupHiddenPromise = waitForProfilerPopupEvent("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} callback + */ +async function withPopupOpen(window, callback) { + await _toggleOpenProfilerPopup(window); + await callback(); + await _closePopup(window); +} + +/** + * 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} 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("popuphidden"); + await callback(); + info("> Verifying that the popup was closed by the test."); + await popupHiddenPromise; +} + +/** + * This function overwrites the default profiler.firefox.com URL for tests. This + * ensures that the tests do not attempt to access external URLs. + * @param {string} url + * @returns {Promise} + */ +function setProfilerFrontendUrl(url) { + info( + "Setting the profiler URL to the fake frontend. Note that this doesn't currently " + + "support the WebChannels, so expect a few error messages about the WebChannel " + + "URLs not being correct." + ); + return SpecialPowers.pushPrefEnv({ + set: [ + // Make sure observer and testing function run in the same process + ["devtools.performance.recording.ui-base-url", url], + ["devtools.performance.recording.ui-base-url-path", ""], + ], + }); +} + +/** + * 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; + } + }); +} + +/** + * 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}` + ); + 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; + } + }); +} + +/** + * 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; + }); +} + +/** + * Open about:profiling in a new tab, and output helpful log messages. + * + * @template T + * @param {(Document) => T} callback + * @returns {Promise} + */ +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."); + return callback(contentBrowser.contentDocument); + } + ); +} + +/** + * Open DevTools and view the performance-new tab. After running the callback, clean + * up the test. + * + * @template T + * @param {(Document) => T} callback + * @returns {Promise} + */ +async function withDevToolsPanel(callback) { + SpecialPowers.pushPrefEnv({ + set: [["devtools.performance.new-panel-enabled", "true"]], + }); + + const { gDevTools } = require("devtools/client/framework/devtools"); + const { TargetFactory } = require("devtools/client/framework/target"); + + info("Create a new about:blank tab."); + const tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + + info("Begin to open the DevTools and the performance-new panel."); + const target = await TargetFactory.forTab(tab); + const toolbox = await gDevTools.showToolbox(target, "performance"); + + const { document } = toolbox.getCurrentPanel().panelWin; + + info("The performance-new panel is now open and ready to use."); + await callback(document); + + info("About to remove the about:blank tab"); + await toolbox.destroy(); + BrowserTestUtils.removeTab(tab); + info("The about:blank tab is now removed."); + await new Promise(resolve => setTimeout(resolve, 500)); +} + +/** + * 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 { 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; +} + +/** + * 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); +} + +/** + * 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); +} + +/** + * 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); +} + +/** + * 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."); +} + +/** + * 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; +} + +/** + * Wait until the profiler menu button is added. + * + * @returns Promise + */ +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." + ); +} + +/** + * 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.import( + "resource://devtools/client/performance-new/popup/menu-button.jsm.js" + ); + + 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(); + } + }); +} + +/** + * 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 + ); +} + +/** + * Set a React-friendly input value. Doing this the normal way doesn't work. + * + * See https://github.com/facebook/react/issues/10135#issuecomment-500929024 + * + * @param {HTMLInputElement} input + * @param {string} value + */ +function setReactFriendlyInputValue(input, value) { + const previousValue = input.value; + + input.value = value; + + const tracker = input._valueTracker; + if (tracker) { + tracker.setValue(previousValue); + } + + // 'change' instead of 'input', see https://github.com/facebook/react/issues/11488#issuecomment-381590324 + input.dispatchEvent(new Event("change", { bubbles: true })); +} + +/** + * 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("devtools/client/performance-new/store/selectors"); + 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()); +} 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..607c6dce12 --- /dev/null +++ b/devtools/client/performance-new/test/browser/webchannel.html @@ -0,0 +1,27 @@ + + + + + + + + + This content page will send a WebChannel message to enable the profiler menu button. + + + 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..fb1cc73b6a --- /dev/null +++ b/devtools/client/performance-new/test/xpcshell/head.js @@ -0,0 +1,14 @@ +/* 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 { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +registerCleanupFunction(() => { + // Always clean up the prefs after every test. + const { revertRecordingPreferences } = ChromeUtils.import( + "resource://devtools/client/performance-new/popup/background.jsm.js" + ); + revertRecordingPreferences(); +}); 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..bd75fb9c65 --- /dev/null +++ b/devtools/client/performance-new/test/xpcshell/test_popup_initial_state.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/. */ +"use strict"; + +/** + * Tests the initial state of the background script for the popup. + */ + +function setupBackgroundJsm() { + return ChromeUtils.import( + "resource://devtools/client/performance-new/popup/background.jsm.js" + ); +} + +add_task(function test() { + info("Test that we get the default preference values from the browser."); + const { getRecordingPreferences } = setupBackgroundJsm(); + + const preferences = getRecordingPreferences( + "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 { + getRecordingPreferences, + setRecordingPreferences, + changePreset, + } = setupBackgroundJsm(); + + const supportedFeatures = Services.profiler.GetFeatures(); + + changePreset("aboutprofiling", "custom", supportedFeatures); + + Assert.ok( + getRecordingPreferences( + "aboutprofiling", + supportedFeatures + ).features.includes("js"), + "The js preference is present initially." + ); + + const settings = getRecordingPreferences("aboutprofiling", supportedFeatures); + settings.features = settings.features.filter(feature => feature !== "js"); + settings.features.push("UNKNOWN_FEATURE_FOR_TESTS"); + setRecordingPreferences("aboutprofiling", settings); + + Assert.ok( + !getRecordingPreferences( + "aboutprofiling", + supportedFeatures + ).features.includes("UNKNOWN_FEATURE_FOR_TESTS"), + "The unknown feature is removed." + ); + Assert.ok( + !getRecordingPreferences( + "aboutprofiling", + supportedFeatures + ).features.includes("js"), + "The js preference is still flipped from the default." + ); +}); 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..5f9582af0f --- /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.import( + "resource:///modules/DevToolsStartup.jsm" +); + +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.ini b/devtools/client/performance-new/test/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..6aff202a65 --- /dev/null +++ b/devtools/client/performance-new/test/xpcshell/xpcshell.ini @@ -0,0 +1,7 @@ +[DEFAULT] +tags = devtools +head = head.js +firefox-appdir = browser + +[test_popup_initial_state.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..4343312155 --- /dev/null +++ b/devtools/client/performance-new/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "module": "commonjs", + // Set the baseUrl to the root of the project. + "baseUrl": "../../..", + // 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-lazy-load.jsm.js b/devtools/client/performance-new/typescript-lazy-load.jsm.js new file mode 100644 index 0000000000..88882f3a18 --- /dev/null +++ b/devtools/client/performance-new/typescript-lazy-load.jsm.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/. */ +// @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 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} */ (this).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/typescript.md b/devtools/client/performance-new/typescript.md new file mode 100644 index 0000000000..eca6f63683 --- /dev/null +++ b/devtools/client/performance-new/typescript.md @@ -0,0 +1,54 @@ +# 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; +``` + +## Exports from a JSM + +TypeScript does not understand `EXPORTED_SYMBOLS` from the JSM for exports. However, we can get around this by secretly defining and using the `exports` object so that TypeScript reads the file like a CommonJS module. + +```js +// Provide an exports object for the JSM to be properly read by TypeScript. +/** @type {any} */ (this).exports = {}; + +exports.ProfilerMenuButton = ProfilerMenuButton; + +// The following line confuses the linting which expects a static array expression. +// for the exported symboles. +// eslint-disable-next-line +var EXPORTED_SYMBOLS = Object.keys(exports); +``` diff --git a/devtools/client/performance-new/utils.js b/devtools/client/performance-new/utils.js new file mode 100644 index 0000000000..716b3c6068 --- /dev/null +++ b/devtools/client/performance-new/utils.js @@ -0,0 +1,434 @@ +/* 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"; + +// @ts-ignore +const { OS } = require("resource://gre/modules/osfile.jsm"); + +const UNITS = ["B", "kiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]; + +/** + * 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 scale exponentially. + * + * @param {number} rangeStart + * @param {number} rangeEnd + * + * @returns {ScaleFunctions} + */ +function makeExponentialScale(rangeStart, rangeEnd) { + const startExp = Math.log(rangeStart); + const endExp = Math.log(rangeEnd); + + /** @type {NumberScaler} */ + const fromFractionToValue = frac => + Math.exp((1 - frac) * startExp + frac * endExp); + + /** @type {NumberScaler} */ + const fromValueToFraction = value => + (Math.log(value) - startExp) / (endExp - startExp); + + /** @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, + }; +} + +/** + * 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); + + /** @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, + }; +} + +/** + * 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 overheadFromTaskTracer = features.includes("tasktracer") ? 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 + + overheadFromTaskTracer + + 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 splitPaths = pathArray.map(path => OS.Path.split(path)); + if (!splitPaths.every(sp => sp.absolute)) { + // We're expecting all paths to be absolute, so this is an unexpected case, + // return the original array. + return pathArray; + } + const [firstSplitPath, ...otherSplitPaths] = splitPaths; + if ("winDrive" in firstSplitPath) { + const winDrive = firstSplitPath.winDrive; + if (!otherSplitPaths.every(sp => sp.winDrive === winDrive)) { + return pathArray; + } + } else if (otherSplitPaths.some(sp => "winDrive" in sp)) { + // Inconsistent winDrive property presence, bail out. + return pathArray; + } + // At this point we're either not on Windows or all paths are on the same + // winDrive. And all paths are absolute. + // Find the common prefix. Start by assuming the entire path except for the + // last folder is shared. + const prefix = firstSplitPath.components.slice(0, -1); + for (const sp of otherSplitPaths) { + prefix.length = Math.min(prefix.length, sp.components.length - 1); + for (let i = 0; i < prefix.length; i++) { + if (prefix[i] !== sp.components[i]) { + prefix.length = i; + break; + } + } + } + if (prefix.length === 0 || (prefix.length === 1 && 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 OS.Path.split(path) always returns an + // array whose first element is the empty string 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; + } + return splitPaths.map(sp => + OS.Path.join(...sp.components.slice(prefix.length)) + ); +} + +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: "Java", + value: "java", + title: "Profile Java code", + disabledReason: "This feature is only available on Android.", + }, + { + name: "Native Leaf Stack", + value: "leaf", + title: + "Record the native memory address of the leaf-most stack. This could be " + + "useful on platforms that do not support stack walking.", + }, + { + 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 File IO Stack Sampling", + value: "noiostacks", + title: "Do not sample stacks when recording File I/O markers.", + }, + { + name: "Sequential Styling", + value: "seqstyle", + title: "Disable parallel traversal in styling.", + }, + { + name: "TaskTracer", + value: "tasktracer", + title: "Enable TaskTracer", + experimental: true, + disabledReason: + "TaskTracer requires a custom build with the environment variable MOZ_TASK_TRACER set.", + }, + { + 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: "Preference Read", + value: "preferencereads", + title: "Track Preference Reads", + }, + { + 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: "CPU Utilization", + value: "cpu", + title: + "CPU utilization by threads. To view graphs, in about:config set " + + "devtools.performance.recording.ui-base-url to " + + "https://deploy-preview-3098--perf-html.netlify.app", + experimental: true, + }, +]; + +module.exports = { + formatFileSize, + makeExponentialScale, + makePowerOf2Scale, + scaleRangeWithClamping, + calculateOverhead, + withCommonPathPrefixRemoved, + UnhandledCaseError, + featureDescriptions, +}; diff --git a/devtools/client/performance-new/yarn.lock b/devtools/client/performance-new/yarn.lock new file mode 100644 index 0000000000..5f9ef4f4af --- /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.5": + version "7.1.5" + resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.5.tgz#c7a528d538969250347aa53c52241051cf886bd3" + integrity sha512-ZoNGQMDxh5ENY7PzU7MVonxDzS1l/EWiy8nUhDqxFqUZn4ovboCyvk4Djf68x6COb7vhGTKjyjxHxtFdAA5sUA== + dependencies: + "@types/hoist-non-react-statics" "^3.3.0" + "@types/react" "*" + hoist-non-react-statics "^3.3.0" + redux "^4.0.0" + +"@types/react@*", "@types/react@^16.9.10": + 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@^3.8.3: + version "3.8.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.3.tgz#409eb8544ea0335711205869ec458ab109ee1061" + integrity sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w== -- cgit v1.2.3