diff --git a/desktop/app/src/dispatcher/__tests__/pluginManager.node.tsx b/desktop/app/src/dispatcher/__tests__/pluginManager.node.tsx
new file mode 100644
index 000000000..bed1e4a26
--- /dev/null
+++ b/desktop/app/src/dispatcher/__tests__/pluginManager.node.tsx
@@ -0,0 +1,108 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @format
+ */
+
+jest.mock('../plugins');
+jest.mock('../../utils/electronModuleCache');
+import {loadPlugin} from '../../reducers/pluginManager';
+import {requirePlugin} from '../plugins';
+import {mocked} from 'ts-jest/utils';
+import {TestUtils} from 'flipper-plugin';
+import * as TestPlugin from '../../test-utils/TestPlugin';
+import {_SandyPluginDefinition as SandyPluginDefinition} from 'flipper-plugin';
+import MockFlipper from '../../test-utils/MockFlipper';
+
+const pluginDetails1 = TestUtils.createMockPluginDetails({
+ id: 'plugin1',
+ version: '0.0.1',
+});
+const pluginDefinition1 = new SandyPluginDefinition(pluginDetails1, TestPlugin);
+
+const pluginDetails1V2 = TestUtils.createMockPluginDetails({
+ id: 'plugin1',
+ version: '0.0.2',
+});
+const pluginDefinition1V2 = new SandyPluginDefinition(
+ pluginDetails1V2,
+ TestPlugin,
+);
+
+const pluginDetails2 = TestUtils.createMockPluginDetails({id: 'plugin2'});
+const pluginDefinition2 = new SandyPluginDefinition(pluginDetails2, TestPlugin);
+
+const mockedRequirePlugin = mocked(requirePlugin);
+
+let mockFlipper: MockFlipper;
+
+beforeEach(async () => {
+ mockedRequirePlugin.mockImplementation(
+ (details) =>
+ (details === pluginDetails1
+ ? pluginDefinition1
+ : details === pluginDetails2
+ ? pluginDefinition2
+ : details === pluginDetails1V2
+ ? pluginDefinition1V2
+ : undefined)!,
+ );
+ mockFlipper = new MockFlipper();
+ await mockFlipper.initWithDeviceAndClient({
+ clientOptions: {supportedPlugins: ['plugin1', 'plugin2']},
+ });
+});
+
+afterEach(async () => {
+ mockedRequirePlugin.mockReset();
+ await mockFlipper.destroy();
+});
+
+test('load plugin when no other version loaded', async () => {
+ mockFlipper.dispatch(
+ loadPlugin({plugin: pluginDetails1, enable: false, notifyIfFailed: false}),
+ );
+ expect(mockFlipper.getState().plugins.clientPlugins.get('plugin1')).toBe(
+ pluginDefinition1,
+ );
+ expect(mockFlipper.getState().plugins.loadedPlugins.get('plugin1')).toBe(
+ pluginDetails1,
+ );
+ expect(mockFlipper.clients[0].sandyPluginStates.has('plugin1')).toBeFalsy();
+});
+
+test('load plugin when other version loaded', async () => {
+ mockFlipper.dispatch(
+ loadPlugin({plugin: pluginDetails1, enable: false, notifyIfFailed: false}),
+ );
+ mockFlipper.dispatch(
+ loadPlugin({
+ plugin: pluginDetails1V2,
+ enable: false,
+ notifyIfFailed: false,
+ }),
+ );
+ expect(mockFlipper.getState().plugins.clientPlugins.get('plugin1')).toBe(
+ pluginDefinition1V2,
+ );
+ expect(mockFlipper.getState().plugins.loadedPlugins.get('plugin1')).toBe(
+ pluginDetails1V2,
+ );
+ expect(mockFlipper.clients[0].sandyPluginStates.has('plugin1')).toBeFalsy();
+});
+
+test('load and enable Sandy plugin', async () => {
+ mockFlipper.dispatch(
+ loadPlugin({plugin: pluginDetails1, enable: true, notifyIfFailed: false}),
+ );
+ expect(mockFlipper.getState().plugins.clientPlugins.get('plugin1')).toBe(
+ pluginDefinition1,
+ );
+ expect(mockFlipper.getState().plugins.loadedPlugins.get('plugin1')).toBe(
+ pluginDetails1,
+ );
+ expect(mockFlipper.clients[0].sandyPluginStates.has('plugin1')).toBeTruthy();
+});
diff --git a/desktop/app/src/dispatcher/pluginManager.tsx b/desktop/app/src/dispatcher/pluginManager.tsx
index e2c0144be..5b386a810 100644
--- a/desktop/app/src/dispatcher/pluginManager.tsx
+++ b/desktop/app/src/dispatcher/pluginManager.tsx
@@ -35,15 +35,29 @@ function refreshInstalledPlugins(store: Store) {
.then((plugins) => store.dispatch(registerInstalledPlugins(plugins)));
}
-export default (store: Store, _logger: Logger) => {
+export default (
+ store: Store,
+ _logger: Logger,
+ {runSideEffectsSynchronously}: {runSideEffectsSynchronously: boolean} = {
+ runSideEffectsSynchronously: false,
+ },
+) => {
// This needn't happen immediately and is (light) I/O work.
- window.requestIdleCallback(() => {
- refreshInstalledPlugins(store);
- });
+ if (window.requestIdleCallback) {
+ window.requestIdleCallback(() => {
+ refreshInstalledPlugins(store);
+ });
+ }
- sideEffect(
+ const unsubscribeHandlePluginCommands = sideEffect(
store,
- {name: 'handlePluginActivation', throttleMs: 1000, fireImmediately: true},
+ {
+ name: 'handlePluginCommands',
+ throttleMs: 0,
+ fireImmediately: true,
+ runSynchronously: runSideEffectsSynchronously, // Used to simplify writing tests, if "true" passed, the all side effects will be called synchronously and immediately after changes
+ noTimeBudgetWarns: true, // These side effects are critical, so we're doing them with zero throttling and want to avoid unnecessary warns
+ },
(state) => state.pluginManager.pluginCommandsQueue,
(queue, store) => {
for (const command of queue) {
@@ -59,6 +73,9 @@ export default (store: Store, _logger: Logger) => {
store.dispatch(pluginCommandsProcessed(queue.length));
},
);
+ return async () => {
+ unsubscribeHandlePluginCommands();
+ };
};
function loadPlugin(store: Store, payload: LoadPluginActionPayload) {
diff --git a/desktop/app/src/reducers/__tests__/sandyplugins.node.tsx b/desktop/app/src/reducers/__tests__/sandyplugins.node.tsx
index 4c1620469..59fd4e7be 100644
--- a/desktop/app/src/reducers/__tests__/sandyplugins.node.tsx
+++ b/desktop/app/src/reducers/__tests__/sandyplugins.node.tsx
@@ -250,7 +250,9 @@ test('it can send messages from sandy clients', async () => {
});
test('it should initialize "Navigation" plugin if not enabled', async () => {
- const {client, store} = await createMockFlipperWithPlugin(TestPlugin);
+ const {client, store} = await createMockFlipperWithPlugin(TestPlugin, {
+ supportedPlugins: ['Navigation'],
+ });
const Plugin2 = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails({
diff --git a/desktop/app/src/test-utils/DeviceTestPlugin.tsx b/desktop/app/src/test-utils/DeviceTestPlugin.tsx
new file mode 100644
index 000000000..56ae32b5d
--- /dev/null
+++ b/desktop/app/src/test-utils/DeviceTestPlugin.tsx
@@ -0,0 +1,64 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @format
+ */
+
+import * as React from 'react';
+import {
+ DevicePluginClient,
+ Device,
+ usePlugin,
+ createState,
+ useValue,
+} from 'flipper-plugin';
+
+export function supportsDevice(_device: Device) {
+ return true;
+}
+
+export function devicePlugin(client: DevicePluginClient) {
+ const logStub = jest.fn();
+ const activateStub = jest.fn();
+ const deactivateStub = jest.fn();
+ const destroyStub = jest.fn();
+ const state = createState(
+ {
+ count: 0,
+ },
+ {
+ persist: 'counter',
+ },
+ );
+
+ client.device.onLogEntry((entry) => {
+ state.update((d) => {
+ d.count++;
+ });
+ logStub(entry);
+ });
+ client.onActivate(activateStub);
+ client.onDeactivate(deactivateStub);
+ client.onDestroy(destroyStub);
+
+ return {
+ logStub,
+ activateStub,
+ deactivateStub,
+ destroyStub,
+ state,
+ };
+}
+
+export function Component() {
+ const api = usePlugin(devicePlugin);
+ const count = useValue(api.state).count;
+
+ // @ts-expect-error
+ api.bla;
+
+ return
Hi from test plugin {count}
;
+}
diff --git a/desktop/app/src/test-utils/MockFlipper.tsx b/desktop/app/src/test-utils/MockFlipper.tsx
new file mode 100644
index 000000000..5bd91649e
--- /dev/null
+++ b/desktop/app/src/test-utils/MockFlipper.tsx
@@ -0,0 +1,213 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @format
+ */
+
+import {createStore} from 'redux';
+import BaseDevice from '../devices/BaseDevice';
+import {rootReducer} from '../store';
+import {Store} from '../reducers/index';
+import Client, {ClientQuery} from '../Client';
+import {buildClientId} from '../utils/clientUtils';
+import {Logger} from '../fb-interfaces/Logger';
+import {PluginDefinition} from '../plugin';
+import {registerPlugins} from '../reducers/plugins';
+import {getInstance} from '../fb-stubs/Logger';
+import {initializeFlipperLibImplementation} from '../utils/flipperLibImplementation';
+import pluginManager from '../dispatcher/pluginManager';
+
+export interface AppOptions {
+ plugins?: PluginDefinition[];
+}
+
+export interface ClientOptions {
+ name?: string;
+ supportedPlugins?: string[];
+ backgroundPlugins?: string[];
+ onSend?: (pluginId: string, method: string, params?: object) => any;
+ query?: ClientQuery;
+ skipRegister?: boolean;
+}
+
+export interface DeviceOptions {
+ serial?: string;
+}
+
+export default class MockFlipper {
+ private unsubscribePluginManager!: () => Promise;
+ private _store!: Store;
+ private _logger!: Logger;
+ private _devices: BaseDevice[] = [];
+ private _clients: Client[] = [];
+ private _deviceCounter: number = 0;
+ private _clientCounter: number = 0;
+
+ public get store(): Store {
+ return this._store;
+ }
+
+ public get logger(): Logger {
+ return this._logger;
+ }
+
+ public get devices(): ReadonlyArray {
+ return this._devices;
+ }
+
+ public get clients(): ReadonlyArray {
+ return this._clients;
+ }
+
+ public get dispatch() {
+ return this._store.dispatch;
+ }
+
+ public getState() {
+ return this._store.getState();
+ }
+
+ public async init({plugins}: AppOptions = {}) {
+ this._store = createStore(rootReducer);
+ this._logger = getInstance();
+ this.unsubscribePluginManager = pluginManager(this._store, this._logger, {
+ runSideEffectsSynchronously: true,
+ });
+ initializeFlipperLibImplementation(this._store, this._logger);
+ this._store.dispatch(registerPlugins(plugins ?? []));
+ }
+
+ public async initWithDeviceAndClient(
+ {
+ appOptions = {},
+ deviceOptions = {},
+ clientOptions = {},
+ }: {
+ appOptions?: AppOptions;
+ deviceOptions?: DeviceOptions;
+ clientOptions?: ClientOptions;
+ } = {appOptions: {}, deviceOptions: {}, clientOptions: {}},
+ ): Promise<{flipper: MockFlipper; device: BaseDevice; client: Client}> {
+ await this.init(appOptions);
+ const device = this.createDevice(deviceOptions);
+ const client = await this.createClient(device, clientOptions);
+ return {
+ flipper: this,
+ device,
+ client,
+ };
+ }
+
+ public async destroy() {
+ this.unsubscribePluginManager && this.unsubscribePluginManager();
+ }
+
+ public createDevice({serial}: DeviceOptions = {}): BaseDevice {
+ const device = new BaseDevice(
+ serial ?? `serial_${++this._deviceCounter}`,
+ 'physical',
+ 'MockAndroidDevice',
+ 'Android',
+ );
+ this._store.dispatch({
+ type: 'REGISTER_DEVICE',
+ payload: device,
+ });
+ device.loadDevicePlugins(this._store.getState().plugins.devicePlugins);
+ this._devices.push(device);
+ return device;
+ }
+
+ public async createClient(
+ device: BaseDevice,
+ {
+ name,
+ supportedPlugins,
+ backgroundPlugins,
+ onSend,
+ skipRegister,
+ query,
+ }: ClientOptions = {},
+ ): Promise {
+ if (!this._devices.includes(device)) {
+ throw new Error('The provided device does not exist');
+ }
+ query = query ?? {
+ app: name ?? `serial_${++this._clientCounter}`,
+ os: 'Android',
+ device: device.title,
+ device_id: device.serial,
+ sdk_version: 4,
+ };
+ const id = buildClientId({
+ app: query.app,
+ os: query.os,
+ device: query.device,
+ device_id: query.device_id,
+ });
+ supportedPlugins =
+ supportedPlugins ??
+ [...this._store.getState().plugins.clientPlugins.values()].map(
+ (p) => p.id,
+ );
+ const client = new Client(
+ id,
+ query,
+ null, // create a stub connection to avoid this plugin to be archived?
+ this._logger,
+ this._store,
+ supportedPlugins,
+ device,
+ );
+
+ // yikes
+ client.device = {
+ then() {
+ return device;
+ },
+ } as any;
+ client.rawCall = async (
+ method: string,
+ _fromPlugin: boolean,
+ params: any,
+ ): Promise => {
+ const intercepted = onSend?.(method, params);
+ if (intercepted !== undefined) {
+ return intercepted;
+ }
+ switch (method) {
+ case 'getPlugins':
+ // assuming this plugin supports all plugins for now
+ return {
+ plugins: supportedPlugins,
+ };
+ case 'getBackgroundPlugins':
+ return {
+ plugins: backgroundPlugins ?? [],
+ };
+ default:
+ throw new Error(
+ `Test client doesn't support rawCall method '${method}'`,
+ );
+ }
+ };
+ client.rawSend = jest.fn();
+
+ await client.init();
+
+ // As convenience, by default we select the new client, star the plugin, and select it
+ if (!skipRegister) {
+ this._store.dispatch({
+ type: 'NEW_CLIENT',
+ payload: client,
+ });
+ }
+
+ this._clients.push(client);
+
+ return client;
+ }
+}
diff --git a/desktop/app/src/test-utils/TestPlugin.tsx b/desktop/app/src/test-utils/TestPlugin.tsx
new file mode 100644
index 000000000..ba7686802
--- /dev/null
+++ b/desktop/app/src/test-utils/TestPlugin.tsx
@@ -0,0 +1,98 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @format
+ */
+
+import * as React from 'react';
+import {PluginClient, usePlugin, createState, useValue} from 'flipper-plugin';
+
+type Events = {
+ inc: {
+ delta: number;
+ };
+};
+
+type Methods = {
+ currentState(params: {since: number}): Promise;
+};
+
+export function plugin(client: PluginClient) {
+ const connectStub = jest.fn();
+ const disconnectStub = jest.fn();
+ const activateStub = jest.fn();
+ const deactivateStub = jest.fn();
+ const destroyStub = jest.fn();
+ const state = createState(
+ {
+ count: 0,
+ },
+ {
+ persist: 'counter',
+ },
+ );
+ const unhandledMessages = createState([]);
+
+ client.onConnect(connectStub);
+ client.onDisconnect(disconnectStub);
+ client.onActivate(activateStub);
+ client.onDeactivate(deactivateStub);
+ client.onDestroy(destroyStub);
+ client.onMessage('inc', ({delta}) => {
+ state.update((draft) => {
+ draft.count += delta;
+ });
+ });
+ client.onUnhandledMessage((event, params) => {
+ unhandledMessages.update((draft) => {
+ draft.push({event, params});
+ });
+ });
+
+ function _unused_JustTypeChecks() {
+ // @ts-expect-error Argument of type '"bla"' is not assignable
+ client.send('bla', {});
+ // @ts-expect-error Argument of type '{ stuff: string; }' is not assignable to parameter of type
+ client.send('currentState', {stuff: 'nope'});
+ // @ts-expect-error
+ client.onMessage('stuff', (_params) => {
+ // noop
+ });
+ client.onMessage('inc', (params) => {
+ // @ts-expect-error
+ params.bla;
+ });
+ }
+
+ async function getCurrentState() {
+ return client.send('currentState', {since: 0});
+ }
+
+ expect(client.device).not.toBeNull();
+
+ return {
+ activateStub,
+ deactivateStub,
+ connectStub,
+ destroyStub,
+ disconnectStub,
+ getCurrentState,
+ state,
+ unhandledMessages,
+ appId: client.appId,
+ appName: client.appName,
+ };
+}
+
+export function Component() {
+ const api = usePlugin(plugin);
+ const count = useValue(api.state).count;
+
+ // @ts-expect-error
+ api.bla;
+
+ return Hi from test plugin {count}
;
+}
diff --git a/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx b/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx
index 00591a7e0..11dfdfd01 100644
--- a/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx
+++ b/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx
@@ -8,7 +8,6 @@
*/
import React from 'react';
-import {createStore} from 'redux';
import {Provider} from 'react-redux';
import {
render,
@@ -25,18 +24,14 @@ import {
} from '../reducers/connections';
import BaseDevice from '../devices/BaseDevice';
-import {rootReducer} from '../store';
import {Store} from '../reducers/index';
import Client, {ClientQuery} from '../Client';
-import {buildClientId} from '../utils/clientUtils';
import {Logger} from '../fb-interfaces/Logger';
import {PluginDefinition} from '../plugin';
-import {registerPlugins} from '../reducers/plugins';
import PluginContainer from '../PluginContainer';
import {getPluginKey, isDevicePluginDefinition} from '../utils/pluginUtils';
-import {getInstance} from '../fb-stubs/Logger';
-import {initializeFlipperLibImplementation} from '../utils/flipperLibImplementation';
+import MockFlipper from './MockFlipper';
export type MockFlipperResult = {
client: Client;
@@ -64,94 +59,35 @@ type MockOptions = Partial<{
additionalPlugins?: PluginDefinition[];
dontEnableAdditionalPlugins?: true;
asBackgroundPlugin?: true;
+ supportedPlugins?: string[];
}>;
export async function createMockFlipperWithPlugin(
pluginClazz: PluginDefinition,
options?: MockOptions,
): Promise {
- const store = createStore(rootReducer);
- const logger = getInstance();
- initializeFlipperLibImplementation(store, logger);
- store.dispatch(
- registerPlugins([pluginClazz, ...(options?.additionalPlugins ?? [])]),
- );
+ const mockFlipper = new MockFlipper();
+ await mockFlipper.init({
+ plugins: [pluginClazz, ...(options?.additionalPlugins ?? [])],
+ });
+ const logger = mockFlipper.logger;
+ const store = mockFlipper.store;
- function createDevice(serial: string): BaseDevice {
- const device = new BaseDevice(
- serial,
- 'physical',
- 'MockAndroidDevice',
- 'Android',
- );
- store.dispatch({
- type: 'REGISTER_DEVICE',
- payload: device,
- });
- device.loadDevicePlugins(store.getState().plugins.devicePlugins);
- return device;
- }
-
- async function createClient(
+ const createDevice = (serial: string) => mockFlipper.createDevice({serial});
+ const createClient = async (
device: BaseDevice,
name: string,
query?: ClientQuery,
skipRegister?: boolean,
- ): Promise {
- query = query ?? {
- app: name,
- os: 'Android',
- device: device.title,
- device_id: device.serial,
- sdk_version: 4,
- };
- const id = buildClientId({
- app: query.app,
- os: query.os,
- device: query.device,
- device_id: query.device_id,
- });
-
- const client = new Client(
- id,
+ ) => {
+ const client = await mockFlipper.createClient(device, {
+ name,
query,
- null, // create a stub connection to avoid this plugin to be archived?
- logger,
- store,
- [
- ...(isDevicePluginDefinition(pluginClazz) ? [] : [pluginClazz.id]),
- ...(options?.dontEnableAdditionalPlugins
- ? []
- : options?.additionalPlugins?.map((p) => p.id) ?? []),
- ],
- device,
- );
-
- client.rawCall = async (
- method: string,
- _fromPlugin: boolean,
- params: any,
- ): Promise => {
- const intercepted = options?.onSend?.(method, params);
- if (intercepted !== undefined) {
- return intercepted;
- }
- switch (method) {
- case 'getPlugins':
- // assuming this plugin supports all plugins for now
- return {
- plugins: [...store.getState().plugins.clientPlugins.keys()],
- };
- case 'getBackgroundPlugins':
- return {plugins: options?.asBackgroundPlugin ? [pluginClazz.id] : []};
- default:
- throw new Error(
- `Test client doesn't support rawCall method '${method}'`,
- );
- }
- };
- client.rawSend = jest.fn();
-
+ skipRegister,
+ onSend: options?.onSend,
+ supportedPlugins: options?.supportedPlugins,
+ backgroundPlugins: options?.asBackgroundPlugin ? [pluginClazz.id] : [],
+ });
// enable the plugin
if (
!isDevicePluginDefinition(pluginClazz) &&
@@ -180,17 +116,8 @@ export async function createMockFlipperWithPlugin(
}
});
}
- await client.init();
-
- // As convenience, by default we select the new client, star the plugin, and select it
- if (!skipRegister) {
- store.dispatch({
- type: 'NEW_CLIENT',
- payload: client,
- });
- }
return client;
- }
+ };
const device = createDevice('serial');
const client = await createClient(device, 'TestApp');
diff --git a/desktop/app/src/utils/sideEffect.tsx b/desktop/app/src/utils/sideEffect.tsx
index b9097be5b..46d0f9821 100644
--- a/desktop/app/src/utils/sideEffect.tsx
+++ b/desktop/app/src/utils/sideEffect.tsx
@@ -28,7 +28,13 @@ export function sideEffect<
State = Store extends ReduxStore ? S : never
>(
store: Store,
- options: {name: string; throttleMs: number; fireImmediately?: boolean},
+ options: {
+ name: string;
+ throttleMs: number;
+ fireImmediately?: boolean;
+ noTimeBudgetWarns?: boolean;
+ runSynchronously?: boolean;
+ },
selector: (state: State) => V,
effect: (selectedState: V, store: Store) => void,
): () => void {
@@ -52,7 +58,11 @@ export function sideEffect<
}
lastRun = performance.now();
const duration = lastRun - start;
- if (duration > 15 && duration > options.throttleMs / 10) {
+ if (
+ !options.noTimeBudgetWarns &&
+ duration > 15 &&
+ duration > options.throttleMs / 10
+ ) {
console.warn(
`Side effect '${options.name}' took ${Math.round(
duration,
@@ -75,13 +85,17 @@ export function sideEffect<
return; // no new value, no need to schedule
}
scheduled = true;
- timeout = setTimeout(
- run,
- // Run ASAP (but async) or, if we recently did run, delay until at least 'throttle' time has expired
- lastRun === -1
- ? 1
- : Math.max(1, lastRun + options.throttleMs - performance.now()),
- );
+ if (options.runSynchronously) {
+ run();
+ } else {
+ timeout = setTimeout(
+ run,
+ // Run ASAP (but async) or, if we recently did run, delay until at least 'throttle' time has expired
+ lastRun === -1
+ ? 1
+ : Math.max(1, lastRun + options.throttleMs - performance.now()),
+ );
+ }
});
if (options.fireImmediately) {