diff --git a/desktop/app/src/electron/initializeElectron.tsx b/desktop/app/src/electron/initializeElectron.tsx index d8ba1cb45..c968afc83 100644 --- a/desktop/app/src/electron/initializeElectron.tsx +++ b/desktop/app/src/electron/initializeElectron.tsx @@ -177,6 +177,9 @@ export function initializeElectron( return flipperServerConfig.gatekeepers[gatekeeper] ?? false; }, flipperServer, + async requirePlugin(path) { + return (window as any).electronRequire(path); + }, } as RenderHost; setupMenuBar(); diff --git a/desktop/flipper-common/src/index.tsx b/desktop/flipper-common/src/index.tsx index df939b8bc..6d13ee85f 100644 --- a/desktop/flipper-common/src/index.tsx +++ b/desktop/flipper-common/src/index.tsx @@ -25,6 +25,7 @@ export { reportPlatformFailures, reportUsage, reportPluginFailures, + tryCatchReportPluginFailuresAsync, tryCatchReportPlatformFailures, tryCatchReportPluginFailures, UnsupportedError, diff --git a/desktop/flipper-common/src/server-types.tsx b/desktop/flipper-common/src/server-types.tsx index 4b8b4a57e..2dd4c306b 100644 --- a/desktop/flipper-common/src/server-types.tsx +++ b/desktop/flipper-common/src/server-types.tsx @@ -169,6 +169,7 @@ export type FlipperServerCommands = { 'plugin-start-download': ( plugin: DownloadablePluginDetails, ) => Promise; + 'plugin-source': (path: string) => Promise; 'plugins-install-from-npm': (name: string) => Promise; 'plugins-install-from-file': ( path: string, diff --git a/desktop/flipper-common/src/utils/metrics.tsx b/desktop/flipper-common/src/utils/metrics.tsx index 31f87e0ab..baa42bd9f 100644 --- a/desktop/flipper-common/src/utils/metrics.tsx +++ b/desktop/flipper-common/src/utils/metrics.tsx @@ -134,6 +134,29 @@ export function tryCatchReportPluginFailures( } } +/* + * Wraps a closure, preserving it's functionality but logging the success or + failure state of it. + */ +export async function tryCatchReportPluginFailuresAsync( + closure: () => Promise, + name: string, + plugin: string, +): Promise { + try { + const result = await closure(); + logPluginSuccessRate(name, plugin, {kind: 'success'}); + return result; + } catch (e) { + logPluginSuccessRate(name, plugin, { + kind: 'failure', + supportedOperation: !(e instanceof UnsupportedError), + error: e, + }); + throw e; + } +} + /** * Track usage of a feature. * @param action Unique name for the action performed. E.g. captureScreenshot diff --git a/desktop/flipper-server-core/src/FlipperServerImpl.tsx b/desktop/flipper-server-core/src/FlipperServerImpl.tsx index 49e49cf77..59439bb4e 100644 --- a/desktop/flipper-server-core/src/FlipperServerImpl.tsx +++ b/desktop/flipper-server-core/src/FlipperServerImpl.tsx @@ -268,6 +268,7 @@ export class FlipperServerImpl implements FlipperServer { this.pluginManager.installPluginFromFile(path), 'plugins-install-from-npm': (name) => this.pluginManager.installPluginFromNpm(name), + 'plugin-source': (path) => this.pluginManager.loadSource(path), }; registerDevice(device: ServerDevice) { diff --git a/desktop/flipper-server-core/src/plugins/PluginManager.tsx b/desktop/flipper-server-core/src/plugins/PluginManager.tsx index c7a8d6dcf..f9f7d7b86 100644 --- a/desktop/flipper-server-core/src/plugins/PluginManager.tsx +++ b/desktop/flipper-server-core/src/plugins/PluginManager.tsx @@ -61,6 +61,10 @@ export class PluginManager { installPluginFromFile = installPluginFromFile; installPluginFromNpm = installPluginFromNpm; + async loadSource(path: string) { + return await fs.readFile(path, 'utf8'); + } + async getBundledPlugins(): Promise> { if (process.env.NODE_ENV === 'test') { return []; diff --git a/desktop/flipper-ui-browser/src/initializeRenderHost.tsx b/desktop/flipper-ui-browser/src/initializeRenderHost.tsx index 69cb70f54..2b0c9f1c1 100644 --- a/desktop/flipper-ui-browser/src/initializeRenderHost.tsx +++ b/desktop/flipper-ui-browser/src/initializeRenderHost.tsx @@ -8,6 +8,7 @@ */ import {FlipperServer, FlipperServerConfig} from 'flipper-common'; +import {getRenderHostInstance} from 'flipper-ui-core'; export function initializeRenderHost( flipperServer: FlipperServer, @@ -62,6 +63,15 @@ export function initializeRenderHost( return flipperServerConfig.gatekeepers[gatekeeper] ?? false; }, flipperServer, + async requirePlugin(path) { + // TODO: use `await import(path)`? + const source = await getRenderHostInstance().flipperServer.exec( + 'plugin-source', + path, + ); + // eslint-disable-next-line no-eval + return eval(source); + }, }; } diff --git a/desktop/flipper-ui-core/src/PluginContainer.tsx b/desktop/flipper-ui-core/src/PluginContainer.tsx index f71033b55..7b6383445 100644 --- a/desktop/flipper-ui-core/src/PluginContainer.tsx +++ b/desktop/flipper-ui-core/src/PluginContainer.tsx @@ -242,6 +242,10 @@ class PluginContainer extends PureComponent { } renderPluginInfo() { + if (isTest()) { + // Plugin info uses Antd animations, generating a gazillion warnings + return 'Stubbed plugin info'; + } return ; } diff --git a/desktop/flipper-ui-core/src/RenderHost.tsx b/desktop/flipper-ui-core/src/RenderHost.tsx index 90ce1cd84..a67b4ec60 100644 --- a/desktop/flipper-ui-core/src/RenderHost.tsx +++ b/desktop/flipper-ui-core/src/RenderHost.tsx @@ -98,6 +98,7 @@ export interface RenderHost { GK(gatekeeper: string): boolean; flipperServer: FlipperServer; serverConfig: FlipperServerConfig; + requirePlugin(path: string): Promise; } export function getRenderHostInstance(): RenderHost { diff --git a/desktop/flipper-ui-core/src/__tests__/PluginContainer.node.tsx b/desktop/flipper-ui-core/src/__tests__/PluginContainer.node.tsx index 1b21db2ee..27e16a506 100644 --- a/desktop/flipper-ui-core/src/__tests__/PluginContainer.node.tsx +++ b/desktop/flipper-ui-core/src/__tests__/PluginContainer.node.tsx @@ -7,7 +7,7 @@ * @format */ -jest.useFakeTimers(); +// jest.useFakeTimers(); import React from 'react'; import produce from 'immer'; @@ -22,10 +22,12 @@ import { DevicePluginClient, DeviceLogEntry, useValue, + sleep, } from 'flipper-plugin'; import {selectPlugin} from '../reducers/connections'; import {updateSettings} from '../reducers/settings'; import {switchPlugin} from '../reducers/pluginManager'; +import {awaitPluginCommandQueueEmpty} from '../dispatcher/pluginManager'; interface PersistedState { count: 1; @@ -57,7 +59,7 @@ class TestPlugin extends FlipperPlugin { render() { return (

- Hello:{' '} + Hello {this.props.persistedState.count}

); @@ -82,8 +84,9 @@ test('Plugin container can render plugin and receive updates', async () => { class="css-1woty6b-Container" >

- Hello: - + + Hello + @@ -348,19 +351,21 @@ test('PluginContainer can render Sandy plugins', async () => { }), ); }); + // note: this is the old pluginInstance, so that one is not reconnected! expect(pluginInstance.connectedStub).toBeCalledTimes(2); expect(pluginInstance.disconnectedStub).toBeCalledTimes(2); expect(pluginInstance.activatedStub).toBeCalledTimes(2); expect(pluginInstance.deactivatedStub).toBeCalledTimes(2); - expect( - client.sandyPluginStates.get('TestPlugin')!.instanceApi.connectedStub, - ).toBeCalledTimes(1); + await awaitPluginCommandQueueEmpty(store); + await sleep(10); + const newPluginInstance = + client.sandyPluginStates.get('TestPlugin')!.instanceApi; + expect(newPluginInstance).not.toBe(pluginInstance); + expect(newPluginInstance.connectedStub).toBeCalledTimes(1); expect(client.rawSend).toBeCalledWith('init', {plugin: 'TestPlugin'}); - expect( - client.sandyPluginStates.get('TestPlugin')!.instanceApi.count.get(), - ).toBe(0); + expect(newPluginInstance.count.get()).toBe(0); }); test('PluginContainer triggers correct lifecycles for background plugin', async () => { @@ -478,6 +483,9 @@ test('PluginContainer triggers correct lifecycles for background plugin', async }), ); }); + + await awaitPluginCommandQueueEmpty(store); + // note: this is the old pluginInstance, so that one is not reconnected! expect(pluginInstance.connectedStub).toBeCalledTimes(1); expect(pluginInstance.disconnectedStub).toBeCalledTimes(1); @@ -533,7 +541,12 @@ test('PluginContainer + Sandy plugin supports deeplink', async () => { Component() { const instance = usePlugin(plugin); const linkState = useValue(instance.linkState); - return

hello {linkState || 'world'}

; + return ( +

+ hello + {linkState || 'world'} +

+ ); }, }, ); @@ -558,8 +571,12 @@ test('PluginContainer + Sandy plugin supports deeplink', async () => { class="css-1woty6b-Container" >

- hello - world + + hello + + + world +

{ ); }); - jest.runAllTimers(); + await sleep(100); expect(linksSeen).toEqual(['universe!']); expect(renderer.baseElement).toMatchInlineSnapshot(` @@ -598,8 +615,12 @@ test('PluginContainer + Sandy plugin supports deeplink', async () => { class="css-1woty6b-Container" >

- hello - universe! + + hello + + + universe! +

{ }), ); }); - jest.runAllTimers(); + await awaitPluginCommandQueueEmpty(store); expect(linksSeen).toEqual(['universe!']); // ...nor does a random other store update that does trigger a plugin container render @@ -645,7 +666,8 @@ test('PluginContainer + Sandy plugin supports deeplink', async () => { }), ); }); - jest.runAllTimers(); + await awaitPluginCommandQueueEmpty(store); + await sleep(10); expect(linksSeen).toEqual(['universe!', 'london!']); // and same link does trigger if something else was selected in the mean time @@ -667,7 +689,8 @@ test('PluginContainer + Sandy plugin supports deeplink', async () => { }), ); }); - jest.runAllTimers(); + await awaitPluginCommandQueueEmpty(store); + await sleep(10); expect(linksSeen).toEqual(['universe!', 'london!', 'london!']); }); @@ -689,7 +712,12 @@ test('PluginContainer can render Sandy device plugins', async () => { }); }).toThrowError(/didn't match the type of the requested plugin/); const lastLogMessage = useValue(sandyApi.lastLogMessage); - return
Hello from Sandy: {lastLogMessage?.message}
; + return ( +
+ Hello from Sandy: + {lastLogMessage?.message} +
+ ); } const devicePlugin = (client: DevicePluginClient) => { @@ -730,7 +758,10 @@ test('PluginContainer can render Sandy device plugins', async () => { class="css-1woty6b-Container" >
- Hello from Sandy: + + Hello from Sandy: + +
{ tag: 'test', }); }); + await sleep(10); // links are handled async + expect(renders).toBe(2); expect(renderer.baseElement).toMatchInlineSnapshot(` @@ -770,8 +803,12 @@ test('PluginContainer can render Sandy device plugins', async () => { class="css-1woty6b-Container" >
- Hello from Sandy: - helleuh + + Hello from Sandy: + + + helleuh +
{ Component() { const instance = usePlugin(devicePlugin); const linkState = useValue(instance.linkState); - return

hello {linkState || 'world'}

; + return ( +

+ hello + {linkState || 'world'} +

+ ); }, }, ); @@ -877,8 +919,12 @@ test('PluginContainer + Sandy device plugin supports deeplink', async () => { class="css-1woty6b-Container" >

- hello - world + + hello + + + world +

{ ); }); - jest.runAllTimers(); + await awaitPluginCommandQueueEmpty(store); + await sleep(10); // links are handled async expect(linksSeen).toEqual([theUniverse]); expect(renderer.baseElement).toMatchInlineSnapshot(` @@ -918,8 +965,12 @@ test('PluginContainer + Sandy device plugin supports deeplink', async () => { class="css-1woty6b-Container" >

- hello - {"thisIs":"theUniverse"} + + hello + + + {"thisIs":"theUniverse"} +

{ }), ); }); - jest.runAllTimers(); + await awaitPluginCommandQueueEmpty(store); expect(linksSeen).toEqual([theUniverse]); // ...nor does a random other store update that does trigger a plugin container render @@ -967,7 +1018,8 @@ test('PluginContainer + Sandy device plugin supports deeplink', async () => { }), ); }); - jest.runAllTimers(); + await awaitPluginCommandQueueEmpty(store); + await sleep(10); expect(linksSeen).toEqual([theUniverse, 'london!']); // and same link does trigger if something else was selected in the mean time @@ -991,7 +1043,8 @@ test('PluginContainer + Sandy device plugin supports deeplink', async () => { }), ); }); - jest.runAllTimers(); + await awaitPluginCommandQueueEmpty(store); + await sleep(10); expect(linksSeen).toEqual([theUniverse, 'london!', 'london!']); }); @@ -1087,12 +1140,13 @@ test('Sandy plugins support isPluginSupported + selectPlugin', async () => { pluginInstance.selectPlugin(definition.id, 'data'); expect(store.getState().connections.selectedPlugin).toBe(definition.id); expect(pluginInstance.activatedStub).toBeCalledTimes(2); - jest.runAllTimers(); + await awaitPluginCommandQueueEmpty(store); expect(renderer.baseElement.querySelector('h1')).toMatchInlineSnapshot(`

Plugin1

`); + await sleep(10); // links are handled async expect(linksSeen).toEqual(['data']); // try to plugin 2 - it should be possible to select it even if it is not enabled @@ -1137,7 +1191,12 @@ test('PluginContainer can render Sandy plugins for archived devices', async () = return {}; }); }).toThrowError(/didn't match the type of the requested plugin/); - return
Hello from Sandy{count}
; + return ( +
+ Hello from Sandy + {count} +
+ ); } type Events = { @@ -1198,8 +1257,12 @@ test('PluginContainer can render Sandy plugins for archived devices', async () = class="css-1woty6b-Container" >
- Hello from Sandy - 0 + + Hello from Sandy + + + 0 +
- Hello from Sandy - 0 + + Hello from Sandy + + + 0 +
{ expect(entries.length).toBe(2); // disable one plugin - flipper.togglePlugin(Plugin.id); + await flipper.togglePlugin(Plugin.id); expect(device.stopLogging).toBeCalledTimes(0); device.addLogEntry(message); expect(entries.length).toBe(3); // disable the other plugin - flipper.togglePlugin(DevicePlugin.id); + await flipper.togglePlugin(DevicePlugin.id); expect(device.stopLogging).toBeCalledTimes(1); device.addLogEntry(message); expect(entries.length).toBe(3); // re-enable plugn - flipper.togglePlugin(Plugin.id); + await flipper.togglePlugin(Plugin.id); expect(device.startLogging).toBeCalledTimes(2); device.addLogEntry(message); expect(entries.length).toBe(4); diff --git a/desktop/flipper-ui-core/src/dispatcher/__tests__/pluginManager.node.tsx b/desktop/flipper-ui-core/src/dispatcher/__tests__/pluginManager.node.tsx index 4b95402f4..5cc630bf3 100644 --- a/desktop/flipper-ui-core/src/dispatcher/__tests__/pluginManager.node.tsx +++ b/desktop/flipper-ui-core/src/dispatcher/__tests__/pluginManager.node.tsx @@ -23,6 +23,7 @@ import MockFlipper from '../../test-utils/MockFlipper'; import Client from '../../Client'; import React from 'react'; import BaseDevice from '../../devices/BaseDevice'; +import {awaitPluginCommandQueueEmpty} from '../pluginManager'; const pluginDetails1 = TestUtils.createMockPluginDetails({ id: 'plugin1', @@ -71,7 +72,7 @@ let mockDevice: BaseDevice; beforeEach(async () => { mockedRequirePlugin.mockImplementation( - (details) => + async (details) => (details === pluginDetails1 ? pluginDefinition1 : details === pluginDetails2 @@ -99,6 +100,8 @@ test('load plugin when no other version loaded', async () => { mockFlipper.dispatch( loadPlugin({plugin: pluginDetails1, enable: false, notifyIfFailed: false}), ); + + await awaitPluginCommandQueueEmpty(mockFlipper.store); expect(mockFlipper.getState().plugins.clientPlugins.get('plugin1')).toBe( pluginDefinition1, ); @@ -119,6 +122,8 @@ test('load plugin when other version loaded', async () => { notifyIfFailed: false, }), ); + + await awaitPluginCommandQueueEmpty(mockFlipper.store); expect(mockFlipper.getState().plugins.clientPlugins.get('plugin1')).toBe( pluginDefinition1V2, ); @@ -132,6 +137,8 @@ test('load and enable Sandy plugin', async () => { mockFlipper.dispatch( loadPlugin({plugin: pluginDetails1, enable: true, notifyIfFailed: false}), ); + + await awaitPluginCommandQueueEmpty(mockFlipper.store); expect(mockFlipper.getState().plugins.clientPlugins.get('plugin1')).toBe( pluginDefinition1, ); @@ -146,6 +153,8 @@ test('uninstall plugin', async () => { loadPlugin({plugin: pluginDetails1, enable: true, notifyIfFailed: false}), ); mockFlipper.dispatch(uninstallPlugin({plugin: pluginDefinition1})); + + await awaitPluginCommandQueueEmpty(mockFlipper.store); expect( mockFlipper.getState().plugins.clientPlugins.has('plugin1'), ).toBeFalsy(); @@ -167,11 +176,13 @@ test('uninstall bundled plugin', async () => { version: '0.43.0', }); const pluginDefinition = new SandyPluginDefinition(pluginDetails, TestPlugin); - mockedRequirePlugin.mockReturnValue(pluginDefinition); + mockedRequirePlugin.mockReturnValue(Promise.resolve(pluginDefinition)); mockFlipper.dispatch( loadPlugin({plugin: pluginDetails, enable: true, notifyIfFailed: false}), ); mockFlipper.dispatch(uninstallPlugin({plugin: pluginDefinition})); + + await awaitPluginCommandQueueEmpty(mockFlipper.store); expect( mockFlipper.getState().plugins.clientPlugins.has('bundled-plugin'), ).toBeFalsy(); @@ -196,6 +207,8 @@ test('star plugin', async () => { selectedApp: mockClient.query.app, }), ); + + await awaitPluginCommandQueueEmpty(mockFlipper.store); expect( mockFlipper.getState().connections.enabledPlugins[mockClient.query.app], ).toContain('plugin1'); @@ -218,6 +231,8 @@ test('disable plugin', async () => { selectedApp: mockClient.query.app, }), ); + + await awaitPluginCommandQueueEmpty(mockFlipper.store); expect( mockFlipper.getState().connections.enabledPlugins[mockClient.query.app], ).not.toContain('plugin1'); @@ -237,6 +252,8 @@ test('star device plugin', async () => { plugin: devicePluginDefinition, }), ); + + await awaitPluginCommandQueueEmpty(mockFlipper.store); expect( mockFlipper.getState().connections.enabledDevicePlugins.has('device'), ).toBeTruthy(); @@ -261,6 +278,8 @@ test('disable device plugin', async () => { plugin: devicePluginDefinition, }), ); + + await awaitPluginCommandQueueEmpty(mockFlipper.store); expect( mockFlipper.getState().connections.enabledDevicePlugins.has('device'), ).toBeFalsy(); diff --git a/desktop/flipper-ui-core/src/dispatcher/__tests__/plugins.node.tsx b/desktop/flipper-ui-core/src/dispatcher/__tests__/plugins.node.tsx index afd999b92..611a56256 100644 --- a/desktop/flipper-ui-core/src/dispatcher/__tests__/plugins.node.tsx +++ b/desktop/flipper-ui-core/src/dispatcher/__tests__/plugins.node.tsx @@ -135,9 +135,9 @@ test('checkGK for failing plugin', () => { expect(gatekeepedPlugins[0].name).toEqual(name); }); -test('requirePlugin returns null for invalid requires', () => { - const requireFn = createRequirePluginFunction([], require); - const plugin = requireFn({ +test('requirePlugin returns null for invalid requires', async () => { + const requireFn = createRequirePluginFunction([]); + const plugin = await requireFn({ ...sampleInstalledPluginDetails, name: 'pluginID', dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-sample', @@ -148,10 +148,10 @@ test('requirePlugin returns null for invalid requires', () => { expect(plugin).toBeNull(); }); -test('requirePlugin loads plugin', () => { +test('requirePlugin loads plugin', async () => { const name = 'pluginID'; - const requireFn = createRequirePluginFunction([], require); - const plugin = requireFn({ + const requireFn = createRequirePluginFunction([]); + const plugin = await requireFn({ ...sampleInstalledPluginDetails, name, dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-sample', @@ -224,10 +224,10 @@ test('newest version of each plugin is used', () => { }); }); -test('requirePlugin loads valid Sandy plugin', () => { +test('requirePlugin loads valid Sandy plugin', async () => { const name = 'pluginID'; - const requireFn = createRequirePluginFunction([], require); - const plugin = requireFn({ + const requireFn = createRequirePluginFunction([]); + const plugin = (await requireFn({ ...sampleInstalledPluginDetails, name, dir: path.join( @@ -240,7 +240,7 @@ test('requirePlugin loads valid Sandy plugin', () => { ), version: '1.0.0', flipperSDKVersion: '0.0.0', - }) as _SandyPluginDefinition; + })) as _SandyPluginDefinition; expect(plugin).not.toBeNull(); expect(plugin).toBeInstanceOf(_SandyPluginDefinition); expect(plugin.id).toBe('Sample'); @@ -261,11 +261,11 @@ test('requirePlugin loads valid Sandy plugin', () => { expect(typeof plugin.asPluginModule().plugin).toBe('function'); }); -test('requirePlugin errors on invalid Sandy plugin', () => { +test('requirePlugin errors on invalid Sandy plugin', async () => { const name = 'pluginID'; const failedPlugins: any[] = []; - const requireFn = createRequirePluginFunction(failedPlugins, require); - requireFn({ + const requireFn = createRequirePluginFunction(failedPlugins); + await requireFn({ ...sampleInstalledPluginDetails, name, // Intentionally the wrong file: @@ -279,10 +279,10 @@ test('requirePlugin errors on invalid Sandy plugin', () => { ); }); -test('requirePlugin loads valid Sandy Device plugin', () => { +test('requirePlugin loads valid Sandy Device plugin', async () => { const name = 'pluginID'; - const requireFn = createRequirePluginFunction([], require); - const plugin = requireFn({ + const requireFn = createRequirePluginFunction([]); + const plugin = (await requireFn({ ...sampleInstalledPluginDetails, pluginType: 'device', name, @@ -296,7 +296,7 @@ test('requirePlugin loads valid Sandy Device plugin', () => { ), version: '1.0.0', flipperSDKVersion: '0.0.0', - }) as _SandyPluginDefinition; + })) as _SandyPluginDefinition; expect(plugin).not.toBeNull(); expect(plugin).toBeInstanceOf(_SandyPluginDefinition); expect(plugin.id).toBe('Sample'); diff --git a/desktop/flipper-ui-core/src/dispatcher/pluginManager.tsx b/desktop/flipper-ui-core/src/dispatcher/pluginManager.tsx index 67bdcc749..ed150cce4 100644 --- a/desktop/flipper-ui-core/src/dispatcher/pluginManager.tsx +++ b/desktop/flipper-ui-core/src/dispatcher/pluginManager.tsx @@ -75,6 +75,7 @@ export default ( }); } + let running = false; const unsubscribeHandlePluginCommands = sideEffect( store, { @@ -85,14 +86,49 @@ export default ( 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, - processPluginCommandsQueue, + async (_queue: PluginCommand[], store: Store) => { + // To make sure all commands are running in order, and not kicking off parallel command + // processing when new commands arrive (sideEffect doesn't await) + // we keep the 'running' flag, and keep running in a loop until the commandQueue is empty, + // to make sure any commands that have arrived during execution are executed + if (running) { + return; // will be picked up in while(true) loop + } + running = true; + try { + while (true) { + const remaining = store.getState().pluginManager.pluginCommandsQueue; + if (!remaining.length) { + return; // done + } + await processPluginCommandsQueue(remaining, store); + store.dispatch(pluginCommandsProcessed(remaining.length)); + } + } finally { + running = false; + } + }, ); return async () => { unsubscribeHandlePluginCommands(); }; }; -export function processPluginCommandsQueue( +export async function awaitPluginCommandQueueEmpty(store: Store) { + if (store.getState().pluginManager.pluginCommandsQueue.length === 0) { + return; + } + return new Promise((resolve) => { + const unsubscribe = store.subscribe(() => { + if (store.getState().pluginManager.pluginCommandsQueue.length === 0) { + unsubscribe(); + resolve(); + } + }); + }); +} + +async function processPluginCommandsQueue( queue: PluginCommand[], store: Store, ) { @@ -100,7 +136,7 @@ export function processPluginCommandsQueue( try { switch (command.type) { case 'LOAD_PLUGIN': - loadPlugin(store, command.payload); + await loadPlugin(store, command.payload); break; case 'UNINSTALL_PLUGIN': uninstallPlugin(store, command.payload); @@ -121,12 +157,11 @@ export function processPluginCommandsQueue( console.error('Failed to process command', command); } } - store.dispatch(pluginCommandsProcessed(queue.length)); } -function loadPlugin(store: Store, payload: LoadPluginActionPayload) { +async function loadPlugin(store: Store, payload: LoadPluginActionPayload) { try { - const plugin = requirePlugin(payload.plugin); + const plugin = await requirePlugin(payload.plugin); const enablePlugin = payload.enable; updatePlugin(store, {plugin, enablePlugin}); } catch (err) { diff --git a/desktop/flipper-ui-core/src/dispatcher/plugins.tsx b/desktop/flipper-ui-core/src/dispatcher/plugins.tsx index 43311dd84..9a73b51fc 100644 --- a/desktop/flipper-ui-core/src/dispatcher/plugins.tsx +++ b/desktop/flipper-ui-core/src/dispatcher/plugins.tsx @@ -8,7 +8,11 @@ */ import type {Store} from '../reducers/index'; -import type {InstalledPluginDetails, Logger} from 'flipper-common'; +import { + InstalledPluginDetails, + Logger, + tryCatchReportPluginFailuresAsync, +} from 'flipper-common'; import {PluginDefinition} from '../plugin'; import React from 'react'; import ReactDOM from 'react-dom'; @@ -31,7 +35,7 @@ import { BundledPluginDetails, ConcretePluginDetails, } from 'flipper-common'; -import {tryCatchReportPluginFailures, reportUsage} from 'flipper-common'; +import {reportUsage} from 'flipper-common'; import * as FlipperPluginSDK from 'flipper-plugin'; import {_SandyPluginDefinition} from 'flipper-plugin'; import * as Immer from 'immer'; @@ -46,6 +50,8 @@ import isPluginCompatible from '../utils/isPluginCompatible'; import isPluginVersionMoreRecent from '../utils/isPluginVersionMoreRecent'; import {createSandyPluginWrapper} from '../utils/createSandyPluginWrapper'; import {getRenderHostInstance} from '../RenderHost'; +import pMap from 'p-map'; + let defaultPluginsIndex: any = null; export default async (store: Store, _logger: Logger) => { @@ -88,12 +94,15 @@ export default async (store: Store, _logger: Logger) => { const loadedPlugins = getLatestCompatibleVersionOfEachPlugin(allLocalVersions); - const initialPlugins: PluginDefinition[] = loadedPlugins + const pluginsToLoad = loadedPlugins .map(reportVersion) .filter(checkDisabled(disabledPlugins)) - .filter(checkGK(gatekeepedPlugins)) - .map(createRequirePluginFunction(failedPlugins)) - .filter(notNull); + .filter(checkGK(gatekeepedPlugins)); + const loader = createRequirePluginFunction(failedPlugins); + + const initialPlugins: PluginDefinition[] = ( + await pMap(pluginsToLoad, loader) + ).filter(notNull); const classicPlugins = initialPlugins.filter( (p) => !isSandyPlugin(p.details), @@ -235,11 +244,12 @@ export const checkDisabled = ( export const createRequirePluginFunction = ( failedPlugins: Array<[ActivatablePluginDetails, string]>, - reqFn: Function = global.electronRequire, ) => { - return (pluginDetails: ActivatablePluginDetails): PluginDefinition | null => { + return async ( + pluginDetails: ActivatablePluginDetails, + ): Promise => { try { - const pluginDefinition = requirePlugin(pluginDetails, reqFn); + const pluginDefinition = await requirePlugin(pluginDetails); if ( pluginDefinition && isDevicePluginDefinition(pluginDefinition) && @@ -260,8 +270,7 @@ export const createRequirePluginFunction = ( export const requirePlugin = ( pluginDetails: ActivatablePluginDetails, - reqFn: Function = global.electronRequire, -): PluginDefinition => { +): Promise => { reportUsage( 'plugin:load', { @@ -269,8 +278,8 @@ export const requirePlugin = ( }, pluginDetails.id, ); - return tryCatchReportPluginFailures( - () => requirePluginInternal(pluginDetails, reqFn), + return tryCatchReportPluginFailuresAsync( + () => requirePluginInternal(pluginDetails), 'plugin:load', pluginDetails.id, ); @@ -280,13 +289,12 @@ const isSandyPlugin = (pluginDetails: ActivatablePluginDetails) => { return !!pluginDetails.flipperSDKVersion; }; -const requirePluginInternal = ( +const requirePluginInternal = async ( pluginDetails: ActivatablePluginDetails, - reqFn: Function = global.electronRequire, -): PluginDefinition => { +): Promise => { let plugin = pluginDetails.isBundled ? defaultPluginsIndex[pluginDetails.name] - : reqFn(pluginDetails.entry); + : await getRenderHostInstance().requirePlugin(pluginDetails.entry); if (isSandyPlugin(pluginDetails)) { // Sandy plugin return new _SandyPluginDefinition(pluginDetails, plugin); diff --git a/desktop/flipper-ui-core/src/reducers/__tests__/sandyplugins.node.tsx b/desktop/flipper-ui-core/src/reducers/__tests__/sandyplugins.node.tsx index 731f36009..93aa9c8f9 100644 --- a/desktop/flipper-ui-core/src/reducers/__tests__/sandyplugins.node.tsx +++ b/desktop/flipper-ui-core/src/reducers/__tests__/sandyplugins.node.tsx @@ -18,6 +18,7 @@ import { TestUtils, } from 'flipper-plugin'; import {switchPlugin} from '../pluginManager'; +import {awaitPluginCommandQueueEmpty} from '../../dispatcher/pluginManager'; const pluginDetails = TestUtils.createMockPluginDetails(); @@ -184,6 +185,8 @@ test('it should not initialize a sandy plugin if not enabled', async () => { }), ); + await awaitPluginCommandQueueEmpty(store); + expect(client.sandyPluginStates.get(Plugin2.id)).toBeUndefined(); expect(instance.connectStub).toHaveBeenCalledTimes(0); // disconnect wasn't called because connect was never called diff --git a/desktop/flipper-ui-core/src/test-utils/TestDevice.tsx b/desktop/flipper-ui-core/src/test-utils/TestDevice.tsx index 6547ca8c9..e219f3f7d 100644 --- a/desktop/flipper-ui-core/src/test-utils/TestDevice.tsx +++ b/desktop/flipper-ui-core/src/test-utils/TestDevice.tsx @@ -28,4 +28,8 @@ export class TestDevice extends BaseDevice { specs, }); } + + async startLogging() { + // noop + } } diff --git a/desktop/flipper-ui-core/src/test-utils/createMockFlipperWithPlugin.tsx b/desktop/flipper-ui-core/src/test-utils/createMockFlipperWithPlugin.tsx index 0d815075b..b266d71e2 100644 --- a/desktop/flipper-ui-core/src/test-utils/createMockFlipperWithPlugin.tsx +++ b/desktop/flipper-ui-core/src/test-utils/createMockFlipperWithPlugin.tsx @@ -37,6 +37,7 @@ import {switchPlugin} from '../reducers/pluginManager'; import {createSandyPluginFromClassicPlugin} from '../dispatcher/plugins'; import {createMockActivatablePluginDetails} from '../utils/testUtils'; import {_SandyPluginDefinition} from 'flipper-plugin'; +import {awaitPluginCommandQueueEmpty} from '../dispatcher/pluginManager'; export type MockFlipperResult = { client: Client; @@ -54,7 +55,7 @@ export type MockFlipperResult = { skipRegister?: boolean, ): Promise; logger: Logger; - togglePlugin(plugin?: string): void; + togglePlugin(plugin?: string): Promise; selectPlugin( id?: string, client?: Client, @@ -168,6 +169,7 @@ export async function createMockFlipperWithPlugin( } }); } + await awaitPluginCommandQueueEmpty(store); return client; }; @@ -233,7 +235,7 @@ export async function createMockFlipperWithPlugin( createClient, logger, pluginKey: getPluginKey(client.id, device, pluginClazz.id), - togglePlugin(id?: string) { + async togglePlugin(id?: string) { const plugin = id ? store.getState().plugins.clientPlugins.get(id) ?? store.getState().plugins.devicePlugins.get(id) @@ -247,6 +249,7 @@ export async function createMockFlipperWithPlugin( selectedApp: client.query.app, }), ); + await awaitPluginCommandQueueEmpty(store); }, }; } diff --git a/desktop/flipper-ui-core/src/utils/__tests__/messageQueueSandy.node.tsx b/desktop/flipper-ui-core/src/utils/__tests__/messageQueueSandy.node.tsx index c4b2e80d2..a8c1812d0 100644 --- a/desktop/flipper-ui-core/src/utils/__tests__/messageQueueSandy.node.tsx +++ b/desktop/flipper-ui-core/src/utils/__tests__/messageQueueSandy.node.tsx @@ -36,6 +36,7 @@ import pluginMessageQueue, { State, queueMessages, } from '../../reducers/pluginMessageQueue'; +import {awaitPluginCommandQueueEmpty} from '../../dispatcher/pluginManager'; type Events = { inc: { @@ -67,13 +68,14 @@ const TestPlugin = new _SandyPluginDefinition( }, ); -function switchTestPlugin(store: Store, client: Client) { +async function switchTestPlugin(store: Store, client: Client) { store.dispatch( switchPlugin({ plugin: TestPlugin, selectedApp: client.query.app, }), ); + await awaitPluginCommandQueueEmpty(store); } function selectDeviceLogs(store: Store) { @@ -190,7 +192,7 @@ test('queue - events are NOT processed immediately if plugin is NOT selected (bu }); // disable. Messages don't arrive anymore - switchTestPlugin(store, client); + await switchTestPlugin(store, client); // weird state... selectTestPlugin(store, client); sendMessage('inc', {delta: 3}); @@ -206,7 +208,7 @@ test('queue - events are NOT processed immediately if plugin is NOT selected (bu expect(store.getState().pluginMessageQueue).toEqual({}); // star again, plugin still not selected, message is queued - switchTestPlugin(store, client); + await switchTestPlugin(store, client); sendMessage('inc', {delta: 5}); client.flushMessageBuffer(); @@ -699,14 +701,14 @@ test('queue - messages that have not yet flushed be lost when disabling the plug `); // disable - switchTestPlugin(store, client); + await switchTestPlugin(store, client); expect(client.messageBuffer).toMatchInlineSnapshot(`Object {}`); expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot( `Object {}`, ); // re-enable, no messages arrive - switchTestPlugin(store, client); + await switchTestPlugin(store, client); client.flushMessageBuffer(); processMessageQueue( client.sandyPluginStates.get(TestPlugin.id)!, diff --git a/desktop/scripts/jest-setup-after.ts b/desktop/scripts/jest-setup-after.ts index 06a3dbdd1..16896d87b 100644 --- a/desktop/scripts/jest-setup-after.ts +++ b/desktop/scripts/jest-setup-after.ts @@ -168,5 +168,8 @@ function createStubRenderHost(): RenderHost { return stubConfig.gatekeepers[gk] ?? false; }, flipperServer: TestUtils.createFlipperServerMock(), + async requirePlugin(path: string) { + return require(path); + }, }; }