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) {