Extensions: add Recorder extensions API
This CL adds the extension API for Recorder panel.
Bug: 1325751
Change-Id: Ibe4f13a2fd868530c72eab7562efe9ab33bbddd7
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/3657448
Reviewed-by: Andrey Kosyakov <caseq@chromium.org>
Commit-Queue: Alex Rudenko <alexrudenko@chromium.org>
Reviewed-by: Philip Pfaffe <pfaffe@chromium.org>
diff --git a/config/gni/devtools_grd_files.gni b/config/gni/devtools_grd_files.gni
index 1ebb358..4cb9d72 100644
--- a/config/gni/devtools_grd_files.gni
+++ b/config/gni/devtools_grd_files.gni
@@ -714,11 +714,14 @@
"front_end/models/emulation/DeviceModeModel.js",
"front_end/models/emulation/EmulatedDevices.js",
"front_end/models/extensions/ExtensionAPI.js",
+ "front_end/models/extensions/ExtensionEndpoint.js",
"front_end/models/extensions/ExtensionPanel.js",
"front_end/models/extensions/ExtensionServer.js",
"front_end/models/extensions/ExtensionTraceProvider.js",
"front_end/models/extensions/ExtensionView.js",
"front_end/models/extensions/LanguageExtensionEndpoint.js",
+ "front_end/models/extensions/RecorderExtensionEndpoint.js",
+ "front_end/models/extensions/RecorderPluginManager.js",
"front_end/models/formatter/FormatterWorkerPool.js",
"front_end/models/formatter/ScriptFormatter.js",
"front_end/models/formatter/SourceFormatter.js",
diff --git a/extension-api/ExtensionAPI.d.ts b/extension-api/ExtensionAPI.d.ts
index 1c4b50b..ba9d736 100644
--- a/extension-api/ExtensionAPI.d.ts
+++ b/extension-api/ExtensionAPI.d.ts
@@ -94,6 +94,7 @@
panels: Panels;
inspectedWindow: InspectedWindow;
languageServices: LanguageExtensions;
+ recorder: RecorderExtensions;
}
export interface ExperimentalDevToolsAPI {
@@ -170,6 +171,10 @@
payload: unknown;
}
+ export interface RecorderExtensionPlugin {
+ stringify(obj: Record<string, any>): Promise<string>;
+ }
+
export interface LanguageExtensionPlugin {
/**
* A new raw module has been loaded. If the raw wasm module references an external debug info module, its URL will be
@@ -272,6 +277,11 @@
unregisterLanguageExtensionPlugin(plugin: LanguageExtensionPlugin): Promise<void>;
}
+ export interface RecorderExtensions {
+ registerRecorderExtensionPlugin(plugin: RecorderExtensionPlugin, pluginName: string): Promise<void>;
+ unregisterRecorderExtensionPlugin(plugin: RecorderExtensionPlugin): Promise<void>;
+ }
+
export interface Chrome {
devtools: DevToolsAPI;
experimental: {devtools: ExperimentalDevToolsAPI};
diff --git a/front_end/models/extensions/BUILD.gn b/front_end/models/extensions/BUILD.gn
index 4263f23..0ef4620 100644
--- a/front_end/models/extensions/BUILD.gn
+++ b/front_end/models/extensions/BUILD.gn
@@ -9,11 +9,14 @@
devtools_module("extensions") {
sources = [
"ExtensionAPI.ts",
+ "ExtensionEndpoint.ts",
"ExtensionPanel.ts",
"ExtensionServer.ts",
"ExtensionTraceProvider.ts",
"ExtensionView.ts",
"LanguageExtensionEndpoint.ts",
+ "RecorderExtensionEndpoint.ts",
+ "RecorderPluginManager.ts",
]
deps = [
@@ -47,6 +50,7 @@
"../../panels/elements/*",
"../../panels/sources/*",
"../../panels/timeline/*",
+ "//test/unittests/front_end/models/extensions:extensions",
]
visibility += devtools_models_visibility
diff --git a/front_end/models/extensions/ExtensionAPI.ts b/front_end/models/extensions/ExtensionAPI.ts
index 3452865..7f76fcc 100644
--- a/front_end/models/extensions/ExtensionAPI.ts
+++ b/front_end/models/extensions/ExtensionAPI.ts
@@ -86,6 +86,7 @@
Unsubscribe = 'unsubscribe',
UpdateButton = 'updateButton',
RegisterLanguageExtensionPlugin = 'registerLanguageExtensionPlugin',
+ RegisterRecorderExtensionPlugin = 'registerRecorderExtensionPlugin',
}
export const enum LanguageExtensionPluginCommands {
@@ -108,6 +109,14 @@
UnregisteredLanguageExtensionPlugin = 'unregisteredLanguageExtensionPlugin',
}
+ export const enum RecorderExtensionPluginCommands {
+ Stringify = 'stringify',
+ }
+
+ export const enum RecorderExtensionPluginEvents {
+ UnregisteredRecorderExtensionPlugin = 'unregisteredRecorderExtensionPlugin',
+ }
+
export interface EvaluateOptions {
frameURL?: string;
useContentScriptContext?: boolean;
@@ -120,6 +129,11 @@
port: MessagePort,
supportedScriptTypes: PublicAPI.Chrome.DevTools.SupportedScriptTypes,
};
+ type RegisterRecorderExtensionPluginRequest = {
+ command: Commands.RegisterRecorderExtensionPlugin,
+ pluginName: string,
+ port: MessagePort,
+ };
type SubscribeRequest = {command: Commands.Subscribe, type: string};
type UnsubscribeRequest = {command: Commands.Unsubscribe, type: string};
type AddRequestHeadersRequest = {
@@ -182,13 +196,13 @@
type GetHARRequest = {command: Commands.GetHAR};
type GetPageResourcesRequest = {command: Commands.GetPageResources};
- export type ServerRequests = RegisterLanguageExtensionPluginRequest|SubscribeRequest|UnsubscribeRequest|
- AddRequestHeadersRequest|ApplyStyleSheetRequest|CreatePanelRequest|ShowPanelRequest|CreateToolbarButtonRequest|
- UpdateButtonRequest|CompleteTraceSessionRequest|CreateSidebarPaneRequest|SetSidebarHeightRequest|
- SetSidebarContentRequest|SetSidebarPageRequest|OpenResourceRequest|SetOpenResourceHandlerRequest|
- SetThemeChangeHandlerRequest|ReloadRequest|EvaluateOnInspectedPageRequest|GetRequestContentRequest|
- GetResourceContentRequest|SetResourceContentRequest|AddTraceProviderRequest|ForwardKeyboardEventRequest|
- GetHARRequest|GetPageResourcesRequest;
+ export type ServerRequests = RegisterRecorderExtensionPluginRequest|RegisterLanguageExtensionPluginRequest|
+ SubscribeRequest|UnsubscribeRequest|AddRequestHeadersRequest|ApplyStyleSheetRequest|CreatePanelRequest|
+ ShowPanelRequest|CreateToolbarButtonRequest|UpdateButtonRequest|CompleteTraceSessionRequest|
+ CreateSidebarPaneRequest|SetSidebarHeightRequest|SetSidebarContentRequest|SetSidebarPageRequest|
+ OpenResourceRequest|SetOpenResourceHandlerRequest|SetThemeChangeHandlerRequest|ReloadRequest|
+ EvaluateOnInspectedPageRequest|GetRequestContentRequest|GetResourceContentRequest|SetResourceContentRequest|
+ AddTraceProviderRequest|ForwardKeyboardEventRequest|GetHARRequest|GetPageResourcesRequest;
export type ExtensionServerRequestMessage = PrivateAPI.ServerRequests&{requestId?: number};
type AddRawModuleRequest = {
@@ -256,6 +270,13 @@
RawLocationToSourceLocationRequest|GetScopeInfoRequest|ListVariablesInScopeRequest|RemoveRawModuleRequest|
GetTypeInfoRequest|GetFormatterRequest|GetInspectableAddressRequest|GetFunctionInfoRequest|
GetInlinedFunctionRangesRequest|GetInlinedCalleesRangesRequest|GetMappedLinesRequest;
+
+ type StringifyRequest = {
+ method: RecorderExtensionPluginCommands.Stringify,
+ parameters: {recording: Record<string, unknown>},
+ };
+
+ export type RecorderExtensionRequests = StringifyRequest;
}
declare global {
@@ -264,7 +285,7 @@
(extensionInfo: ExtensionDescriptor, inspectedTabId: string, themeName: string, keysToForward: number[],
testHook:
(extensionServer: APIImpl.ExtensionServerClient, extensionAPI: APIImpl.InspectorExtensionAPI) => unknown,
- injectedScriptId: number) => void;
+ injectedScriptId: number, targetWindow?: Window) => void;
buildExtensionAPIInjectedScript(
extensionInfo: ExtensionDescriptor, inspectedTabId: string, themeName: string, keysToForward: number[],
testHook: undefined|((extensionServer: unknown, extensionAPI: unknown) => unknown)): string;
@@ -283,6 +304,7 @@
namespace APIImpl {
export interface InspectorExtensionAPI {
languageServices: PublicAPI.Chrome.DevTools.LanguageExtensions;
+ recorder: PublicAPI.Chrome.DevTools.RecorderExtensions;
timeline: Timeline;
network: PublicAPI.Chrome.DevTools.Network;
panels: PublicAPI.Chrome.DevTools.Panels;
@@ -357,6 +379,10 @@
_plugins: Map<PublicAPI.Chrome.DevTools.LanguageExtensionPlugin, MessagePort>;
}
+ export interface RecorderExtensions extends PublicAPI.Chrome.DevTools.RecorderExtensions {
+ _plugins: Map<PublicAPI.Chrome.DevTools.RecorderExtensionPlugin, MessagePort>;
+ }
+
export interface ExtensionPanel extends ExtensionView, PublicAPI.Chrome.DevTools.ExtensionPanel {
show(): void;
}
@@ -392,7 +418,7 @@
self.injectedExtensionAPI = function(
extensionInfo: ExtensionDescriptor, inspectedTabId: string, themeName: string, keysToForward: number[],
testHook: (extensionServer: APIImpl.ExtensionServerClient, extensionAPI: APIImpl.InspectorExtensionAPI) => unknown,
- injectedScriptId: number): void {
+ injectedScriptId: number, targetWindowForTest?: Window): void {
const keysToForwardSet = new Set<number>(keysToForward);
const chrome = window.chrome || {};
@@ -473,6 +499,7 @@
this.network = new (Constructor(Network))();
this.timeline = new (Constructor(Timeline))();
this.languageServices = new (Constructor(LanguageServicesAPI))();
+ this.recorder = new (Constructor(RecorderServicesAPI))();
defineDeprecatedProperty(this, 'webInspector', 'resources', 'network');
}
@@ -671,6 +698,60 @@
__proto__: ExtensionViewImpl.prototype,
};
+ function RecorderServicesAPIImpl(this: APIImpl.RecorderExtensions): void {
+ this._plugins = new Map();
+ }
+
+ (RecorderServicesAPIImpl.prototype as
+ Pick<APIImpl.RecorderExtensions, 'registerRecorderExtensionPlugin'|'unregisterRecorderExtensionPlugin'>) = {
+ registerRecorderExtensionPlugin: async function(
+ this: APIImpl.RecorderExtensions, plugin: PublicAPI.Chrome.DevTools.RecorderExtensionPlugin,
+ pluginName: string): Promise<void> {
+ if (this._plugins.has(plugin)) {
+ throw new Error(`Tried to register plugin '${pluginName}' twice`);
+ }
+ const channel = new MessageChannel();
+ const port = channel.port1;
+ this._plugins.set(plugin, port);
+ port. MessageEvent<{requestId: number}&PrivateAPI.RecorderExtensionRequests>): void => {
+ const {requestId} = data;
+ dispatchMethodCall(data)
+ .then(result => port.postMessage({requestId, result}))
+ .catch(error => port.postMessage({requestId, error: {message: error.message}}));
+ };
+
+ function dispatchMethodCall(request: PrivateAPI.RecorderExtensionRequests): Promise<unknown> {
+ switch (request.method) {
+ case PrivateAPI.RecorderExtensionPluginCommands.Stringify:
+ return plugin.stringify(request.parameters.recording);
+ default:
+ throw new Error(`'${request.method}' is not recognized`);
+ }
+ }
+
+ await new Promise<void>(resolve => {
+ extensionServer.sendRequest(
+ {
+ command: PrivateAPI.Commands.RegisterRecorderExtensionPlugin,
+ pluginName,
+ port: channel.port2,
+ },
+ () => resolve(), [channel.port2]);
+ });
+ },
+
+ unregisterRecorderExtensionPlugin: async function(
+ this: APIImpl.RecorderExtensions, plugin: PublicAPI.Chrome.DevTools.RecorderExtensionPlugin): Promise<void> {
+ const port = this._plugins.get(plugin);
+ if (!port) {
+ throw new Error('Tried to unregister a plugin that was not previously registered');
+ }
+ this._plugins.delete(plugin);
+ port.postMessage({event: PrivateAPI.RecorderExtensionPluginEvents.UnregisteredRecorderExtensionPlugin});
+ port.close();
+ },
+ };
+
function LanguageServicesAPIImpl(this: APIImpl.LanguageExtensions): void {
this._plugins = new Map();
}
@@ -787,6 +868,7 @@
}
const LanguageServicesAPI = declareInterfaceClass(LanguageServicesAPIImpl);
+ const RecorderServicesAPI = declareInterfaceClass(RecorderServicesAPIImpl);
const Button = declareInterfaceClass(ButtonImpl);
const EventSink = declareInterfaceClass(EventSinkImpl);
const ExtensionPanel = declareInterfaceClass(ExtensionPanelImpl);
@@ -1128,7 +1210,7 @@
document.addEventListener('keydown', forwardKeyboardEvent, false);
- function ExtensionServerClient(this: APIImpl.ExtensionServerClient): void {
+ function ExtensionServerClient(this: APIImpl.ExtensionServerClient, targetWindow: Window): void {
this._callbacks = {};
this._handlers = {};
this._lastRequestId = 0;
@@ -1141,7 +1223,7 @@
this._port.addEventListener('message', this._onMessage.bind(this), false);
this._port.start();
- window.parent.postMessage('registerExtension', '*', [channel.port2]);
+ targetWindow.postMessage('registerExtension', '*', [channel.port2]);
}
(ExtensionServerClient.prototype as Pick<
@@ -1225,7 +1307,7 @@
}
}
- const extensionServer = new (Constructor(ExtensionServerClient))();
+ const extensionServer = new (Constructor(ExtensionServerClient))(targetWindowForTest || window.parent);
const coreAPI = new (Constructor(InspectorExtensionAPI))();
@@ -1241,6 +1323,7 @@
chrome.devtools!.panels = coreAPI.panels;
chrome.devtools!.panels.themeName = themeName;
chrome.devtools!.languageServices = coreAPI.languageServices;
+ chrome.devtools!.recorder = coreAPI.recorder;
// default to expose experimental APIs for now.
if (extensionInfo.exposeExperimentalAPIs !== false) {
diff --git a/front_end/models/extensions/ExtensionEndpoint.ts b/front_end/models/extensions/ExtensionEndpoint.ts
new file mode 100644
index 0000000..43fbb23
--- /dev/null
+++ b/front_end/models/extensions/ExtensionEndpoint.ts
@@ -0,0 +1,69 @@
+// Copyright 2022 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+type Response = {
+ requestId: number,
+ result: unknown,
+ error: Error|null,
+};
+
+type Event = {
+ event: string,
+};
+
+type Message = MessageEvent<Response|Event>;
+
+export class ExtensionEndpoint {
+ private readonly port: MessagePort;
+ private nextRequestId: number = 0;
+ private pendingRequests: Map<number, {
+ resolve: (arg: unknown) => void,
+ reject: (error: Error) => void,
+ }>;
+
+ constructor(port: MessagePort) {
+ this.port = port;
+ this.port.>
+ this.pendingRequests = new Map();
+ }
+
+ sendRequest<ReturnType>(method: string, parameters: unknown): Promise<ReturnType> {
+ return new Promise((resolve, reject) => {
+ const requestId = this.nextRequestId++;
+ this.pendingRequests.set(requestId, {resolve: resolve as (arg: unknown) => void, reject});
+ this.port.postMessage({requestId, method, parameters});
+ });
+ }
+
+ protected disconnect(): void {
+ for (const {reject} of this.pendingRequests.values()) {
+ reject(new Error('Extension endpoint disconnected'));
+ }
+ this.pendingRequests.clear();
+ this.port.close();
+ }
+
+ private onResponse({data}: Message): void {
+ if ('event' in data) {
+ this.handleEvent(data);
+ return;
+ }
+ const {requestId, result, error} = data;
+ const pendingRequest = this.pendingRequests.get(requestId);
+ if (!pendingRequest) {
+ console.error(`No pending request ${requestId}`);
+ return;
+ }
+ this.pendingRequests.delete(requestId);
+ if (error) {
+ pendingRequest.reject(new Error(error.message));
+ } else {
+ pendingRequest.resolve(result);
+ }
+ }
+
+ protected handleEvent(_event: Event): void {
+ throw new Error('handleEvent is not implemented');
+ }
+}
diff --git a/front_end/models/extensions/ExtensionServer.ts b/front_end/models/extensions/ExtensionServer.ts
index 9895a1e..7c71e57 100644
--- a/front_end/models/extensions/ExtensionServer.ts
+++ b/front_end/models/extensions/ExtensionServer.ts
@@ -52,7 +52,9 @@
import type {TracingSession} from './ExtensionTraceProvider.js';
import {ExtensionTraceProvider} from './ExtensionTraceProvider.js';
import {LanguageExtensionEndpoint} from './LanguageExtensionEndpoint.js';
+import {RecorderExtensionEndpoint} from './RecorderExtensionEndpoint.js';
import {PrivateAPI} from './ExtensionAPI.js';
+import {RecorderPluginManager} from './RecorderPluginManager.js';
const extensionOrigins: WeakMap<MessagePort, Platform.DevToolsPath.UrlString> = new WeakMap();
@@ -138,6 +140,8 @@
this.registerHandler(PrivateAPI.Commands.UpdateButton, this.onUpdateButton.bind(this));
this.registerHandler(
PrivateAPI.Commands.RegisterLanguageExtensionPlugin, this.registerLanguageExtensionEndpoint.bind(this));
+ this.registerHandler(
+ PrivateAPI.Commands.RegisterRecorderExtensionPlugin, this.registerRecorderExtensionEndpoint.bind(this));
window.addEventListener('message', this.onWindowMessage.bind(this), false); // Only for main window.
const existingTabId =
@@ -215,6 +219,16 @@
return this.status.OK();
}
+ private registerRecorderExtensionEndpoint(
+ message: PrivateAPI.ExtensionServerRequestMessage, _shared_port: MessagePort): Record {
+ if (message.command !== PrivateAPI.Commands.RegisterRecorderExtensionPlugin) {
+ return this.status.E_BADARG('command', `expected ${PrivateAPI.Commands.Subscribe}`);
+ }
+ const {pluginName, port} = message;
+ RecorderPluginManager.instance().addPlugin(new RecorderExtensionEndpoint(pluginName, port));
+ return this.status.OK();
+ }
+
private inspectedURLChanged(event: Common.EventTarget.EventTargetEvent<SDK.Target.Target>): void {
if (!this.canInspectURL(event.data.inspectedURL())) {
this.disableExtensions();
@@ -856,6 +870,13 @@
}
}
+ addExtensionForTest(extensionInfo: Host.InspectorFrontendHostAPI.ExtensionDescriptor, origin: string): boolean
+ |undefined {
+ const name = extensionInfo.name || `Extension ${origin}`;
+ this.registeredExtensions.set(origin, {name});
+ return true;
+ }
+
private addExtension(extensionInfo: Host.InspectorFrontendHostAPI.ExtensionDescriptor): boolean|undefined {
const startPage = extensionInfo.startPage;
diff --git a/front_end/models/extensions/LanguageExtensionEndpoint.ts b/front_end/models/extensions/LanguageExtensionEndpoint.ts
index b13592a..f191458 100644
--- a/front_end/models/extensions/LanguageExtensionEndpoint.ts
+++ b/front_end/models/extensions/LanguageExtensionEndpoint.ts
@@ -5,9 +5,30 @@
import type * as SDK from '../../core/sdk/sdk.js';
import * as Bindings from '../bindings/bindings.js';
import type {Chrome} from '../../../extension-api/ExtensionAPI.js'; // eslint-disable-line rulesdir/es_modules_import
+import {ExtensionEndpoint} from './ExtensionEndpoint.js';
import {PrivateAPI} from './ExtensionAPI.js';
+class LanguageExtensionEndpointImpl extends ExtensionEndpoint {
+ private plugin: LanguageExtensionEndpoint;
+ constructor(plugin: LanguageExtensionEndpoint, port: MessagePort) {
+ super(port);
+ this.plugin = plugin;
+ }
+ protected handleEvent({event}: {event: string}): void {
+ switch (event) {
+ case PrivateAPI.LanguageExtensionPluginEvents.UnregisteredLanguageExtensionPlugin: {
+ this.disconnect();
+ const {pluginManager} = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance();
+ if (pluginManager) {
+ pluginManager.removePlugin(this.plugin);
+ }
+ break;
+ }
+ }
+ }
+}
+
export class LanguageExtensionEndpoint extends Bindings.DebuggerLanguagePlugins.DebuggerLanguagePlugin {
private readonly supportedScriptTypes: {
language: string,
@@ -15,13 +36,7 @@
// eslint-disable-next-line @typescript-eslint/naming-convention
symbol_types: Array<string>,
};
- private readonly port: MessagePort;
- private nextRequestId: number;
- // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- private pendingRequests: Map<any, any>;
+ private endpoint: LanguageExtensionEndpointImpl;
constructor(
name: string, supportedScriptTypes: {
language: string,
@@ -32,63 +47,7 @@
port: MessagePort) {
super(name);
this.supportedScriptTypes = supportedScriptTypes;
- this.port = port;
- this.port.>
- this.nextRequestId = 0;
- this.pendingRequests = new Map();
- }
-
- // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- private sendRequest(method: string, parameters: any): Promise<any> {
- return new Promise((resolve, reject) => {
- const requestId = this.nextRequestId++;
- this.pendingRequests.set(requestId, {resolve, reject});
- this.port.postMessage({requestId, method, parameters});
- });
- }
-
- private onResponse({data}: MessageEvent<{
- requestId: number,
- // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- result: any,
- error: Error|null,
- }|{
- event: string,
- }>): void {
- if ('event' in data) {
- const {event} = data;
- switch (event) {
- case PrivateAPI.LanguageExtensionPluginEvents.UnregisteredLanguageExtensionPlugin: {
- for (const {reject} of this.pendingRequests.values()) {
- reject(new Error('Language extension endpoint disconnected'));
- }
- this.pendingRequests.clear();
- this.port.close();
- const {pluginManager} = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance();
- if (pluginManager) {
- pluginManager.removePlugin(this);
- }
- break;
- }
- }
- return;
- }
- const {requestId, result, error} = data;
- if (!this.pendingRequests.has(requestId)) {
- console.error(`No pending request ${requestId}`);
- return;
- }
- const {resolve, reject} = this.pendingRequests.get(requestId);
- this.pendingRequests.delete(requestId);
- if (error) {
- reject(new Error(error.message));
- } else {
- resolve(result);
- }
+ this.endpoint = new LanguageExtensionEndpointImpl(this, port);
}
handleScript(script: SDK.Script.Script): boolean {
@@ -100,7 +59,7 @@
/** Notify the plugin about a new script
*/
addRawModule(rawModuleId: string, symbolsURL: string, rawModule: Chrome.DevTools.RawModule): Promise<string[]> {
- return this.sendRequest(
+ return this.endpoint.sendRequest(
PrivateAPI.LanguageExtensionPluginCommands.AddRawModule, {rawModuleId, symbolsURL, rawModule}) as
Promise<string[]>;
}
@@ -109,33 +68,36 @@
* Notifies the plugin that a script is removed.
*/
removeRawModule(rawModuleId: string): Promise<void> {
- return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.RemoveRawModule, {rawModuleId}) as Promise<void>;
+ return this.endpoint.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.RemoveRawModule, {rawModuleId}) as
+ Promise<void>;
}
/** Find locations in raw modules from a location in a source file
*/
sourceLocationToRawLocation(sourceLocation: Chrome.DevTools.SourceLocation):
Promise<Chrome.DevTools.RawLocationRange[]> {
- return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.SourceLocationToRawLocation, {sourceLocation}) as
+ return this.endpoint.sendRequest(
+ PrivateAPI.LanguageExtensionPluginCommands.SourceLocationToRawLocation, {sourceLocation}) as
Promise<Chrome.DevTools.RawLocationRange[]>;
}
/** Find locations in source files from a location in a raw module
*/
rawLocationToSourceLocation(rawLocation: Chrome.DevTools.RawLocation): Promise<Chrome.DevTools.SourceLocation[]> {
- return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.RawLocationToSourceLocation, {rawLocation}) as
+ return this.endpoint.sendRequest(
+ PrivateAPI.LanguageExtensionPluginCommands.RawLocationToSourceLocation, {rawLocation}) as
Promise<Chrome.DevTools.SourceLocation[]>;
}
getScopeInfo(type: string): Promise<Chrome.DevTools.ScopeInfo> {
- return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetScopeInfo, {type}) as
+ return this.endpoint.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetScopeInfo, {type}) as
Promise<Chrome.DevTools.ScopeInfo>;
}
/** List all variables in lexical scope at a given location in a raw module
*/
listVariablesInScope(rawLocation: Chrome.DevTools.RawLocation): Promise<Chrome.DevTools.Variable[]> {
- return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.ListVariablesInScope, {rawLocation}) as
+ return this.endpoint.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.ListVariablesInScope, {rawLocation}) as
Promise<Chrome.DevTools.Variable[]>;
}
@@ -144,7 +106,8 @@
getFunctionInfo(rawLocation: Chrome.DevTools.RawLocation): Promise<{
frames: Array<Chrome.DevTools.FunctionInfo>,
}> {
- return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetFunctionInfo, {rawLocation}) as Promise<{
+ return this.endpoint.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetFunctionInfo, {rawLocation}) as
+ Promise<{
frames: Array<Chrome.DevTools.FunctionInfo>,
}>;
}
@@ -153,7 +116,8 @@
* that rawLocation is in.
*/
getInlinedFunctionRanges(rawLocation: Chrome.DevTools.RawLocation): Promise<Chrome.DevTools.RawLocationRange[]> {
- return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetInlinedFunctionRanges, {rawLocation}) as
+ return this.endpoint.sendRequest(
+ PrivateAPI.LanguageExtensionPluginCommands.GetInlinedFunctionRanges, {rawLocation}) as
Promise<Chrome.DevTools.RawLocationRange[]>;
}
@@ -161,7 +125,8 @@
* called by the function or inline frame that rawLocation is in.
*/
getInlinedCalleesRanges(rawLocation: Chrome.DevTools.RawLocation): Promise<Chrome.DevTools.RawLocationRange[]> {
- return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetInlinedCalleesRanges, {rawLocation}) as
+ return this.endpoint.sendRequest(
+ PrivateAPI.LanguageExtensionPluginCommands.GetInlinedCalleesRanges, {rawLocation}) as
Promise<Chrome.DevTools.RawLocationRange[]>;
}
@@ -169,7 +134,8 @@
typeInfos: Array<Chrome.DevTools.TypeInfo>,
base: Chrome.DevTools.EvalBase,
}|null> {
- return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetTypeInfo, {expression, context}) as Promise<{
+ return this.endpoint.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetTypeInfo, {expression, context}) as
+ Promise<{
typeInfos: Array<Chrome.DevTools.TypeInfo>,
base: Chrome.DevTools.EvalBase,
}|null>;
@@ -183,8 +149,8 @@
context: Chrome.DevTools.RawLocation): Promise<{
js: string,
}> {
- return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetFormatter, {expressionOrField, context}) as
- Promise<{
+ return this.endpoint.sendRequest(
+ PrivateAPI.LanguageExtensionPluginCommands.GetFormatter, {expressionOrField, context}) as Promise<{
js: string,
}>;
}
@@ -195,13 +161,15 @@
}): Promise<{
js: string,
}> {
- return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetInspectableAddress, {field}) as Promise<{
+ return this.endpoint.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetInspectableAddress, {field}) as
+ Promise<{
js: string,
}>;
}
async getMappedLines(rawModuleId: string, sourceFileURL: string): Promise<number[]|undefined> {
- return this.sendRequest(PrivateAPI.LanguageExtensionPluginCommands.GetMappedLines, {rawModuleId, sourceFileURL});
+ return this.endpoint.sendRequest(
+ PrivateAPI.LanguageExtensionPluginCommands.GetMappedLines, {rawModuleId, sourceFileURL});
}
dispose(): void {
diff --git a/front_end/models/extensions/RecorderExtensionEndpoint.ts b/front_end/models/extensions/RecorderExtensionEndpoint.ts
new file mode 100644
index 0000000..945b869
--- /dev/null
+++ b/front_end/models/extensions/RecorderExtensionEndpoint.ts
@@ -0,0 +1,43 @@
+// Copyright 2022 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {PrivateAPI} from './ExtensionAPI.js';
+import {ExtensionEndpoint} from './ExtensionEndpoint.js';
+import {RecorderPluginManager} from './RecorderPluginManager.js';
+
+export class RecorderExtensionEndpoint extends ExtensionEndpoint {
+ private readonly name: string;
+
+ constructor(name: string, port: MessagePort) {
+ super(port);
+ this.name = name;
+ }
+
+ getName(): string {
+ return this.name;
+ }
+
+ protected handleEvent({event}: {event: string}): void {
+ switch (event) {
+ case PrivateAPI.RecorderExtensionPluginEvents.UnregisteredRecorderExtensionPlugin: {
+ this.disconnect();
+ RecorderPluginManager.instance().removePlugin(this);
+ break;
+ }
+ default:
+ throw new Error(`Unrecognized Recorder extension endpoint event: ${event}`);
+ }
+ }
+
+ /**
+ * In practice, `recording` is a UserFlow[1], but we avoid defining this type on the
+ * API in order to prevent dependencies between Chrome and puppeteer. Extensions
+ * are responsible for working out potential compatibility issues.
+ *
+ * [1]: https://github.com/puppeteer/replay/blob/main/src/Schema.ts#L245
+ */
+ stringify(recording: Object): Promise<string> {
+ return this.sendRequest(PrivateAPI.RecorderExtensionPluginCommands.Stringify, {recording});
+ }
+}
diff --git a/front_end/models/extensions/RecorderPluginManager.ts b/front_end/models/extensions/RecorderPluginManager.ts
new file mode 100644
index 0000000..93e53ce
--- /dev/null
+++ b/front_end/models/extensions/RecorderPluginManager.ts
@@ -0,0 +1,30 @@
+// Copyright 2022 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import type {RecorderExtensionEndpoint} from './RecorderExtensionEndpoint.js';
+
+let instance: RecorderPluginManager|null = null;
+
+export class RecorderPluginManager {
+ #plugins: Set<RecorderExtensionEndpoint> = new Set();
+
+ static instance(): RecorderPluginManager {
+ if (!instance) {
+ instance = new RecorderPluginManager();
+ }
+ return instance;
+ }
+
+ addPlugin(plugin: RecorderExtensionEndpoint): void {
+ this.#plugins.add(plugin);
+ }
+
+ removePlugin(plugin: RecorderExtensionEndpoint): void {
+ this.#plugins.delete(plugin);
+ }
+
+ plugins(): RecorderExtensionEndpoint[] {
+ return Array.from(this.#plugins.values());
+ }
+}
diff --git a/front_end/models/extensions/extensions.ts b/front_end/models/extensions/extensions.ts
index 37d0289..a250ad2 100644
--- a/front_end/models/extensions/extensions.ts
+++ b/front_end/models/extensions/extensions.ts
@@ -7,6 +7,7 @@
import * as ExtensionServer from './ExtensionServer.js';
import * as ExtensionTraceProvider from './ExtensionTraceProvider.js';
import * as ExtensionView from './ExtensionView.js';
+import * as RecorderPluginManager from './RecorderPluginManager.js';
export {
ExtensionAPI,
@@ -14,4 +15,5 @@
ExtensionServer,
ExtensionTraceProvider,
ExtensionView,
+ RecorderPluginManager,
};
diff --git a/test/unittests/front_end/BUILD.gn b/test/unittests/front_end/BUILD.gn
index aa48d7c..7bc8148 100644
--- a/test/unittests/front_end/BUILD.gn
+++ b/test/unittests/front_end/BUILD.gn
@@ -18,6 +18,7 @@
"entrypoints/missing_entrypoints",
"models/bindings",
"models/emulation",
+ "models/extensions",
"models/formatter",
"models/har",
"models/issues_manager",
diff --git a/test/unittests/front_end/models/extensions/BUILD.gn b/test/unittests/front_end/models/extensions/BUILD.gn
new file mode 100644
index 0000000..b9fdbc4
--- /dev/null
+++ b/test/unittests/front_end/models/extensions/BUILD.gn
@@ -0,0 +1,12 @@
+# Copyright 2022 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("../../../../../third_party/typescript/typescript.gni")
+
+ts_library("extensions") {
+ testonly = true
+ sources = [ "ExtensionServer_test.ts" ]
+
+ deps = [ "../../../../../front_end/models/extensions:bundle" ]
+}
diff --git a/test/unittests/front_end/models/extensions/ExtensionServer_test.ts b/test/unittests/front_end/models/extensions/ExtensionServer_test.ts
new file mode 100644
index 0000000..823bd72
--- /dev/null
+++ b/test/unittests/front_end/models/extensions/ExtensionServer_test.ts
@@ -0,0 +1,50 @@
+// Copyright 2022 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import * as Extensions from '../../../../../front_end/models/extensions/extensions.js';
+import type {Chrome} from '../../../../../extension-api/ExtensionAPI.js';
+
+const {assert} = chai;
+
+describe('Extensions', () => {
+ function cleanup() {
+ delete (window as {chrome?: Chrome.DevTools.Chrome}).chrome;
+ }
+
+ beforeEach(cleanup);
+ afterEach(cleanup);
+
+ it('can register a recorder extension', async () => {
+ const server = Extensions.ExtensionServer.ExtensionServer.instance({forceNew: true});
+ const extensionDescriptor = {
+ startPage: 'blank.html',
+ name: 'TestExtension',
+ exposeExperimentalAPIs: true,
+ };
+ server.addExtensionForTest(extensionDescriptor, window.location.origin);
+ const chrome: Partial<Chrome.DevTools.Chrome> = {};
+ (window as {chrome?: Partial<Chrome.DevTools.Chrome>}).chrome = chrome;
+
+ self.injectedExtensionAPI(extensionDescriptor, 'main', 'dark', [], () => {}, 1, window);
+
+ class RecorderPlugin {
+ async stringify(recording: object) {
+ return JSON.stringify(recording);
+ }
+ }
+ await chrome.devtools?.recorder.registerRecorderExtensionPlugin(new RecorderPlugin(), 'Test');
+
+ const manager = Extensions.RecorderPluginManager.RecorderPluginManager.instance();
+ assert.strictEqual(manager.plugins().length, 1);
+ const plugin = manager.plugins()[0];
+
+ const result = await plugin.stringify({
+ name: 'test',
+ steps: [],
+ });
+
+ assert.strictEqual(manager.plugins().length, 1);
+ assert.deepStrictEqual(result, '{"name":"test","steps":[]}');
+ });
+});