文件
mynode/packages/lib/services/plugins/PluginService.ts
chen 301194963b
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

781 行
24 KiB
TypeScript

import Plugin from './Plugin';
import manifestFromObject from './utils/manifestFromObject';
import Global from './api/Global';
import BasePluginRunner from './BasePluginRunner';
import BaseService from '../BaseService';
import shim from '../../shim';
import { filename, dirname, rtrimSlashes } from '../../path-utils';
import Setting from '../../models/Setting';
import Logger from '@joplin/utils/Logger';
import RepositoryApi from './RepositoryApi';
import { produce } from 'immer';
import { PluginManifest } from './utils/types';
import isCompatible from './utils/isCompatible';
import { AppType } from './api/types';
import minVersionForPlatform from './utils/isCompatible/minVersionForPlatform';
import { _ } from '../../locale';
import ViewController from './ViewController';
const uslug = require('@joplin/fork-uslug');
const logger = Logger.create('PluginService');
interface PluginExtractionState {
size: number;
timestamp: number;
}
// Plugin data is split into two:
//
// - First there's the service `plugins` property, which contains the
// plugin static data, as loaded from the plugin file or directory. For
// example, the plugin ID, the manifest, the script files, etc.
//
// - Secondly, there's the `PluginSettings` data, which is dynamic and is
// used for example to enable or disable a plugin. Its state is saved to
// the user's settings.
export interface Plugins {
[key: string]: Plugin;
}
export interface SettingAndValue {
[settingName: string]: string|number|boolean;
}
export interface DefaultPluginSettings {
settings?: SettingAndValue;
enabled?: boolean;
}
export interface DefaultPluginsInfo {
[pluginId: string]: DefaultPluginSettings;
}
export interface PluginSetting {
enabled: boolean;
deleted: boolean;
// After a plugin has been updated, the user needs to restart the app before
// loading the new version. In the meantime, we set this property to `true`
// so that we know the plugin has been updated. It is used for example to
// disable the Update button.
hasBeenUpdated: boolean;
}
export function defaultPluginSetting(): PluginSetting {
return {
enabled: true,
deleted: false,
hasBeenUpdated: false,
};
}
export interface PluginSettings {
[pluginId: string]: PluginSetting;
}
export type SerializedPluginSettings = Record<string, Partial<PluginSetting>>;
interface PluginLoadOptions {
devMode: boolean;
builtIn: boolean;
}
function makePluginId(source: string): string {
// https://www.npmjs.com/package/slug#options
return uslug(source).substr(0, 32);
}
type LoadedPluginsChangeListener = ()=> void;
export default class PluginService extends BaseService {
private static instance_: PluginService = null;
public static instance(): PluginService {
if (!this.instance_) {
this.instance_ = new PluginService();
}
return this.instance_;
}
private appVersion_: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Test fixtures across app-cli/app-mobile pass partial { dispatch, getState } stores here, so Store<unknown> would be too strict
private store_: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Test fixtures across app-cli/app-mobile pass `{ joplin: {} }` here, so BasePlatformImplementation would be too strict
private platformImplementation_: any = null;
private plugins_: Plugins = {};
private runner_: BasePluginRunner = null;
private startedPlugins_: Record<string, boolean> = {};
private isSafeMode_ = false;
private pluginsChangeListeners_: LoadedPluginsChangeListener[] = [];
private extractionStates_: Record<string, PluginExtractionState> = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- See platformImplementation_ and store_ above
public initialize(appVersion: string, platformImplementation: any, runner: BasePluginRunner, store: any) {
this.appVersion_ = appVersion;
this.store_ = store;
this.runner_ = runner;
this.platformImplementation_ = platformImplementation;
}
public get plugins(): Plugins {
return this.plugins_;
}
public enabledPlugins(pluginSettings: PluginSettings): Plugins {
const enabledPlugins = Object.fromEntries(Object.entries(this.plugins_).filter((p) => this.pluginEnabled(pluginSettings, p[0])));
return enabledPlugins;
}
public isPluginLoaded(pluginId: string) {
return !!this.plugins_[pluginId];
}
public get pluginIds(): string[] {
return Object.keys(this.plugins_);
}
public get isSafeMode(): boolean {
return this.isSafeMode_;
}
public get appVersion(): string {
return this.appVersion_;
}
public set isSafeMode(v: boolean) {
this.isSafeMode_ = v;
}
public addLoadedPluginsChangeListener(listener: ()=> void) {
this.pluginsChangeListeners_.push(listener);
return {
remove: () => {
this.pluginsChangeListeners_ = this.pluginsChangeListeners_.filter(l => (l !== listener));
},
};
}
private dispatchPluginsChangeListeners() {
for (const listener of this.pluginsChangeListeners_) {
listener();
}
}
private setPluginAt(pluginId: string, plugin: Plugin) {
this.plugins_ = {
...this.plugins_,
[pluginId]: plugin,
};
this.dispatchPluginsChangeListeners();
}
private deletePluginAt(pluginId: string) {
if (!this.plugins_[pluginId]) return;
this.plugins_ = { ...this.plugins_ };
delete this.plugins_[pluginId];
this.dispatchPluginsChangeListeners();
}
public async unloadPlugin(pluginId: string) {
const plugin = this.plugins_[pluginId];
if (plugin) {
this.logger().info(`Unloading plugin ${pluginId}`);
plugin.onUnload();
await this.runner_.stop(plugin);
plugin.running = false;
this.deletePluginAt(pluginId);
this.startedPlugins_ = { ...this.startedPlugins_ };
delete this.startedPlugins_[pluginId];
} else {
this.logger().info(`Unable to unload plugin ${pluginId} -- already unloaded`);
}
}
private async deletePluginFiles(plugin: Plugin) {
await shim.fsDriver().remove(plugin.baseDir);
}
private extractionStatePath(): string {
return `${Setting.value('cacheDir')}/plugin-extraction-state.json`;
}
private async loadExtractionStates(): Promise<Record<string, PluginExtractionState>> {
if (!this.extractionStates_) {
try {
const text = await shim.fsDriver().readFile(this.extractionStatePath(), 'utf8');
this.extractionStates_ = JSON.parse(text);
} catch {
this.extractionStates_ = {};
}
}
return this.extractionStates_;
}
private async saveExtractionStates(states: Record<string, PluginExtractionState>): Promise<void> {
this.extractionStates_ = states;
try {
await shim.fsDriver().writeFile(this.extractionStatePath(), JSON.stringify(states), 'utf8');
} catch (error) {
logger.error('Failed to save extraction states:', error);
}
}
public pluginById(id: string): Plugin {
if (!this.plugins_[id]) throw new Error(`Plugin not found: ${id}`);
return this.plugins_[id];
}
public safePluginNameById(id: string) {
if (!this.plugins_[id]) {
return id;
}
return this.pluginById(id).manifest?.name ?? 'Unknown';
}
public viewControllerByViewId(id: string): ViewController|null {
for (const [, plugin] of Object.entries(this.plugins_)) {
if (plugin.hasViewController(id)) return plugin.viewController(id);
}
return null;
}
public unserializePluginSettings(settings: SerializedPluginSettings): PluginSettings {
const output = { ...settings };
for (const pluginId in output) {
output[pluginId] = {
...defaultPluginSetting(),
...output[pluginId],
};
}
return output as PluginSettings;
}
public serializePluginSettings(settings: PluginSettings) {
return settings;
}
public pluginIdByContentScriptId(contentScriptId: string): string {
for (const pluginId in this.plugins_) {
const plugin = this.plugins_[pluginId];
const contentScript = plugin.contentScriptById(contentScriptId);
if (contentScript) return pluginId;
}
return null;
}
private async parsePluginJsBundle(jsBundleString: string) {
const scriptText = jsBundleString;
const lines = scriptText.split('\n');
const manifestText: string[] = [];
const StateStarted = 1;
const StateInManifest = 2;
let state: number = StateStarted;
for (let line of lines) {
line = line.trim();
if (state !== StateInManifest) {
if (line === '/* joplin-manifest:') {
state = StateInManifest;
}
continue;
}
if (state === StateInManifest) {
if (line.indexOf('*/') === 0) {
break;
} else {
manifestText.push(line);
}
}
}
if (!manifestText.length) throw new Error('Could not find manifest');
return {
scriptText: scriptText,
manifestText: manifestText.join('\n'),
};
}
public async loadPluginFromJsBundle(baseDir: string, jsBundleString: string, pluginIdIfNotSpecified = ''): Promise<Plugin> {
baseDir = rtrimSlashes(baseDir);
const r = await this.parsePluginJsBundle(jsBundleString);
return this.loadPlugin(baseDir, r.manifestText, r.scriptText, pluginIdIfNotSpecified);
}
public async loadPluginFromPackage(baseDir: string, path: string, manifestOnly = false): Promise<Plugin> {
baseDir = rtrimSlashes(baseDir);
const fname = filename(path);
const unpackDir = `${Setting.value('cacheDir')}/${fname}`;
const manifestFilePath = `${unpackDir}/manifest.json`;
// Use file size + mtime to check if the .jpl has changed, to
// avoid computing an MD5 hash of the full file on every startup.
const stat = await shim.fsDriver().stat(path);
const extractionStates = await this.loadExtractionStates();
const extractionState = extractionStates[fname];
const scriptFilePath = `${unpackDir}/index.js`;
const extractionValid = extractionState
&& extractionState.size === stat.size
&& extractionState.timestamp === stat.mtime.getTime()
&& await shim.fsDriver().exists(manifestFilePath)
&& (manifestOnly || await shim.fsDriver().exists(scriptFilePath));
if (manifestOnly && extractionValid) {
// When loading only the manifest (e.g. for disabled plugins), use
// the already-extracted manifest from cache to avoid the tar
// extraction.
const manifest = await this.loadManifestToObject(manifestFilePath);
if (manifest) {
return this.loadPlugin(unpackDir, JSON.stringify(manifest), '', makePluginId(fname));
}
}
if (!extractionValid) {
logger.info(`Extracting plugin: ${fname}`);
await shim.fsDriver().remove(unpackDir);
await shim.fsDriver().mkdir(unpackDir);
await shim.fsDriver().tarExtract({
strict: true,
portable: true,
file: path,
cwd: unpackDir,
});
const manifest = await this.loadManifestToObject(manifestFilePath);
if (!manifest) throw new Error(`Missing manifest file at: ${manifestFilePath}`);
extractionStates[fname] = {
size: stat.size,
timestamp: stat.mtime.getTime(),
};
await this.saveExtractionStates(extractionStates);
} else {
logger.info(`Using already extracted plugin: ${fname}`);
}
return this.loadPluginFromPath(unpackDir, manifestOnly);
}
// Loads the manifest as a simple object with no validation. Used only
// when unpacking a package.
private async loadManifestToObject(path: string): Promise<Record<string, unknown>> {
try {
const manifestText = await shim.fsDriver().readFile(path, 'utf8');
return JSON.parse(manifestText);
} catch (error) {
return null;
}
}
public async loadPluginFromPath(path: string, manifestOnly = false): Promise<Plugin> {
path = rtrimSlashes(path);
const fsDriver = shim.fsDriver();
if (path.toLowerCase().endsWith('.js')) {
return this.loadPluginFromJsBundle(dirname(path), await fsDriver.readFile(path), filename(path));
} else if (path.toLowerCase().endsWith('.jpl')) {
return this.loadPluginFromPackage(dirname(path), path, manifestOnly);
} else {
let distPath = path;
if (!(await fsDriver.exists(`${distPath}/manifest.json`))) {
distPath = `${path}/dist`;
}
logger.info(`Loading plugin from ${path}`);
const manifestText = await fsDriver.readFile(`${distPath}/manifest.json`);
// On mobile, plugin scripts are loaded directly by the WebView
// from the filesystem, so we don't need to read them here.
const indexPath = `${distPath}/index.js`;
const loadMainScript = !shim.mobilePlatform() || shim.mobilePlatform() === 'web';
if (loadMainScript) {
if (!(await fsDriver.exists(indexPath))) {
throw new Error(`Plugin bundle not found at: ${indexPath}`);
}
}
const scriptText = (manifestOnly || !loadMainScript) ? '' : await fsDriver.readFile(indexPath);
const pluginId = makePluginId(filename(path));
return this.loadPlugin(distPath, manifestText, scriptText, pluginId);
}
}
private async loadPlugin(baseDir: string, manifestText: string, scriptText: string, pluginIdIfNotSpecified: string): Promise<Plugin> {
baseDir = rtrimSlashes(baseDir);
const manifestObj = JSON.parse(manifestText);
interface DeprecationNotice {
goneInVersion: string;
message: string;
isError: boolean;
}
const deprecationNotices: DeprecationNotice[] = [];
if (!manifestObj.app_min_version) {
manifestObj.app_min_version = '1.4';
deprecationNotices.push({
message: 'The manifest must contain an "app_min_version" key, which should be the minimum version of the app you support.',
goneInVersion: '1.4',
isError: true,
});
}
if (!manifestObj.id) {
manifestObj.id = pluginIdIfNotSpecified;
deprecationNotices.push({
message: 'The manifest must contain an "id" key, which should be a globally unique ID for your plugin, such as "com.example.MyPlugin" or a UUID.',
goneInVersion: '1.4',
isError: true,
});
}
const manifest = manifestFromObject(manifestObj);
const dataDir = `${Setting.value('pluginDataDir')}/${manifest.id}`;
const plugin = new Plugin(baseDir, manifest, scriptText, (action: { type: string; [key: string]: unknown }) => this.store_.dispatch(action), dataDir);
for (const notice of deprecationNotices) {
plugin.deprecationNotice(notice.goneInVersion, notice.message, notice.isError);
}
// Sanity check, although at that point the plugin ID should have
// been set, either automatically, or because it was defined in the
// manifest.
if (!plugin.id) throw new Error('Could not load plugin: ID is not set');
return plugin;
}
private pluginEnabled(settings: PluginSettings, pluginId: string): boolean {
if (!settings[pluginId]) return true;
return settings[pluginId].enabled !== false && settings[pluginId].deleted !== true;
}
public callStatsSummary(pluginId: string, duration: number) {
return this.runner_.callStatsSummary(pluginId, duration);
}
public async loadAndRunPlugins(
pluginDirOrPaths: string | string[], settings: PluginSettings, options?: PluginLoadOptions,
) {
options ??= {
builtIn: false,
devMode: false,
};
let pluginPaths = [];
if (Array.isArray(pluginDirOrPaths)) {
pluginPaths = pluginDirOrPaths;
} else {
pluginPaths = (await shim.fsDriver().readDirStats(pluginDirOrPaths))
.filter(stat => {
if (stat.isDirectory()) return true;
if (stat.path.toLowerCase().endsWith('.js')) return true;
if (stat.path.toLowerCase().endsWith('.jpl')) return true;
return false;
})
.map(stat => `${pluginDirOrPaths}/${stat.path}`);
}
for (const pluginPath of pluginPaths) {
if (filename(pluginPath).indexOf('_') === 0) {
logger.info(`Plugin name starts with "_" and has not been loaded: ${pluginPath}`);
continue;
}
try {
// Load only the manifest first to check if the plugin is
// enabled before doing the expensive full load.
let plugin = await this.loadPluginFromPath(pluginPath, true);
const enabled = this.pluginEnabled(settings, plugin.id);
if (enabled) {
logger.info(`Loading full plugin: ${plugin.id}`);
plugin = await this.loadPluginFromPath(pluginPath, false);
} else {
logger.info(`Loading manifest only for disabled plugin: ${plugin.id}`);
}
const existingPlugin = this.plugins_[plugin.id];
if (existingPlugin) {
const isSamePlugin = existingPlugin.baseDir === plugin.baseDir;
// On mobile, plugins can reload without restarting the app. If a plugin is currently
// running and hasn't changed, it doesn't need to be reloaded.
if (isSamePlugin) {
const isSameVersion =
existingPlugin.manifest.version === plugin.manifest.version
&& existingPlugin.manifest._package_hash === plugin.manifest._package_hash;
if (isSameVersion && existingPlugin.running === enabled) {
logger.debug('Not reloading same-version plugin', plugin.id);
continue;
} else {
logger.info('Reloading plugin with ID', plugin.id);
await this.unloadPlugin(plugin.id);
}
} else {
// After transforming the plugin path to an ID, multiple plugins might end up with the same ID. For
// example "MyPlugin" and "myplugin" would have the same ID. Technically it's possible to have two
// such folders but to keep things sane we disallow it.
throw new Error(`There is already a plugin with this ID: ${plugin.id}`);
}
}
// We mark the plugin as built-in even if not enabled (being built-in affects
// update UI).
plugin.builtIn = options.builtIn;
this.setPluginAt(plugin.id, plugin);
if (!enabled) {
logger.info(`Not running disabled plugin: "${plugin.id}"`);
continue;
}
plugin.devMode = options.devMode;
await this.runPlugin(plugin);
} catch (error) {
logger.error(`Could not load plugin: ${pluginPath}`, error);
}
}
}
public async loadAndRunDevPlugins(settings: PluginSettings) {
const devPluginOptions = { devMode: true, builtIn: false };
if (Setting.value('plugins.devPluginPaths')) {
const paths = Setting.value('plugins.devPluginPaths').split(',').map((p: string) => p.trim());
await this.loadAndRunPlugins(paths, settings, devPluginOptions);
}
// Also load dev plugins that have passed via command line arguments
if (Setting.value('startupDevPlugins')) {
await this.loadAndRunPlugins(Setting.value('startupDevPlugins'), settings, devPluginOptions);
}
}
private get appType_() {
return shim.mobilePlatform() ? AppType.Mobile : AppType.Desktop;
}
public isCompatible(manifest: PluginManifest): boolean {
return isCompatible(this.appVersion_, this.appType_, manifest);
}
private validateManifest(manifest: unknown): void {
manifestFromObject(manifest as Record<string, unknown>);
}
public describeIncompatibility(manifest: PluginManifest) {
try {
this.validateManifest(manifest);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return _('Invalid plugin manifest: %s', message);
}
if (this.isCompatible(manifest)) return null;
const minVersion = minVersionForPlatform(this.appType_, manifest);
if (minVersion) {
return _('Please upgrade Joplin to version %s or later to use this plugin.', minVersion);
} else {
let platformDescription = 'Unknown';
if (this.appType_ === AppType.Mobile) {
platformDescription = _('Joplin Mobile');
} else if (this.appType_ === AppType.Desktop) {
platformDescription = _('Joplin Desktop');
}
return _('This plugin doesn\'t support %s.', platformDescription);
}
}
public get allPluginsStarted(): boolean {
for (const pluginId of Object.keys(this.startedPlugins_)) {
if (!this.startedPlugins_[pluginId]) return false;
}
return true;
}
public async runPlugin(plugin: Plugin) {
if (this.isSafeMode) throw new Error(`Plugin was not started due to safe mode: ${plugin.manifest.id}`);
if (!this.isCompatible(plugin.manifest)) {
throw new Error(`Plugin "${plugin.id}" was disabled: ${this.describeIncompatibility(plugin.manifest)}`);
} else {
this.store_.dispatch({
type: 'PLUGIN_ADD',
plugin: {
id: plugin.id,
views: {},
contentScripts: {},
},
});
}
this.startedPlugins_[plugin.id] = false;
const onStarted = () => {
this.startedPlugins_[plugin.id] = true;
plugin.off('started', onStarted);
};
plugin.on('started', onStarted);
plugin.running = true;
const pluginApi = new Global(this.platformImplementation_, plugin, this.store_);
try {
await this.runner_.run(plugin, pluginApi);
} catch (error) {
logger.error(`Plugin "${plugin.id}" failed to start:`, error);
// Mark as started so this failed plugin doesn't block
// allPluginsStarted and prevent other plugins from working.
// See: https://github.com/laurent22/joplin/issues/12793
this.startedPlugins_[plugin.id] = true;
plugin.off('started', onStarted);
plugin.running = false;
}
}
public async installPluginFromRepo(repoApi: RepositoryApi, pluginId: string): Promise<Plugin> {
const pluginPath = await repoApi.downloadPlugin(pluginId);
const plugin = await this.installPlugin(pluginPath);
await shim.fsDriver().remove(pluginPath);
return plugin;
}
public async updatePluginFromRepo(repoApi: RepositoryApi, pluginId: string): Promise<Plugin> {
return this.installPluginFromRepo(repoApi, pluginId);
}
public async installPlugin(jplPath: string, loadPlugin = true): Promise<Plugin | null> {
logger.info(`Installing plugin: "${jplPath}"`);
// Before moving the plugin to the profile directory, we load it
// from where it is now to check that it is valid and to retrieve
// the plugin ID.
const preloadedPlugin = await this.loadPluginFromPath(jplPath);
try {
await this.deletePluginFiles(preloadedPlugin);
} catch (error) {
// Deleting the plugin appears to occasionally fail on Windows (maybe because the files
// are still loaded?), and it prevents the plugin from being installed. Because of this
// we just ignore the error - it means that there will be unnecessary files in the cache
// directory, which is not a big issue.
//
// Ref: https://discourse.joplinapp.org/t/math-mode-plugin-no-longer-works-in-windows-v3-1-23/41853
logger.warn('Could not delete plugin temp directory:', error);
}
// On mobile, it's necessary to create the plugin directory before we can copy
// into it.
if (!(await shim.fsDriver().exists(Setting.value('pluginDir')))) {
logger.info(`Creating plugin directory: ${Setting.value('pluginDir')}`);
await shim.fsDriver().mkdir(Setting.value('pluginDir'));
}
const destPath = `${Setting.value('pluginDir')}/${preloadedPlugin.id}.jpl`;
await shim.fsDriver().copy(jplPath, destPath);
// Now load it from the profile directory
if (loadPlugin) {
const plugin = await this.loadPluginFromPath(destPath);
if (!this.plugins_[plugin.id]) this.setPluginAt(plugin.id, plugin);
return plugin;
} else { return null; }
}
private async pluginPath(pluginId: string) {
const stats = await shim.fsDriver().readDirStats(Setting.value('pluginDir'), { recursive: false });
for (const stat of stats) {
if (filename(stat.path) === pluginId) {
return `${Setting.value('pluginDir')}/${stat.path}`;
}
}
return null;
}
public async uninstallPlugin(pluginId: string) {
logger.info(`Uninstalling plugin: "${pluginId}"`);
const path = await this.pluginPath(pluginId);
if (!path) {
// Plugin might have already been deleted
logger.error(`Could not find plugin path to uninstall - nothing will be done: ${pluginId}`);
} else {
await shim.fsDriver().remove(path);
}
this.deletePluginAt(pluginId);
}
public async uninstallPlugins(settings: PluginSettings): Promise<PluginSettings> {
let newSettings = settings;
for (const pluginId in settings) {
if (settings[pluginId].deleted) {
await this.uninstallPlugin(pluginId);
newSettings = { ...newSettings };
delete newSettings[pluginId];
}
}
return newSettings;
}
// On startup the "hasBeenUpdated" prop can be cleared since the new version
// of the plugin has now been loaded.
public clearUpdateState(settings: PluginSettings): PluginSettings {
return produce(settings, (draft: PluginSettings) => {
for (const pluginId in draft) {
if (draft[pluginId].hasBeenUpdated) draft[pluginId].hasBeenUpdated = false;
}
});
}
public async destroy() {
await this.runner_.waitForSandboxCalls();
}
// Test-only: resets the singleton's transient state between tests that share the
// same PluginService instance. Without this, a late callback from an in-flight
// install in the previous test can re-populate `plugins_` and dispatch into the
// new test's components, triggering "already exists" errors and "not wrapped in
// act(...)" warnings.
public resetForTesting() {
this.plugins_ = {};
this.startedPlugins_ = {};
this.pluginsChangeListeners_ = [];
}
}