diff --git a/desktop/flipper-server-core/src/plugins/PluginManager.tsx b/desktop/flipper-server-core/src/plugins/PluginManager.tsx index 5c71fe6ce..2f38a0631 100644 --- a/desktop/flipper-server-core/src/plugins/PluginManager.tsx +++ b/desktop/flipper-server-core/src/plugins/PluginManager.tsx @@ -50,7 +50,7 @@ const isExecuteMessage = (message: object): message is ExecuteMessage => (message as ExecuteMessage).method === 'execute'; export class PluginManager { - private readonly serverAddOns = new Map(); + public readonly serverAddOns = new Map(); constructor(private readonly flipperServer: FlipperServerForServerAddOn) {} diff --git a/desktop/flipper-server-core/src/plugins/__tests__/PluginManager.node.tsx b/desktop/flipper-server-core/src/plugins/__tests__/PluginManager.node.tsx new file mode 100644 index 000000000..fb8b9d11d --- /dev/null +++ b/desktop/flipper-server-core/src/plugins/__tests__/PluginManager.node.tsx @@ -0,0 +1,214 @@ +/** + * Copyright (c) Meta Platforms, Inc. and 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 {ServerAddOnStartDetails} from 'flipper-common'; +import {loadServerAddOn} from '../loadServerAddOn'; +import {PluginManager} from '../PluginManager'; +import {ServerAddOnManager} from '../ServerAddManager'; +import {ServerAddOnModuleToDesktopConnection} from '../ServerAddOnModuleToDesktopConnection'; +import { + createControlledPromise, + detailsBundled, + detailsInstalled, + initialOwner, + pluginName, +} from './utils'; + +jest.mock('../loadServerAddOn'); +const loadServerAddOnMock = loadServerAddOn as jest.Mock; + +describe('PluginManager', () => { + describe('server add-ons', () => { + const startServerAddOn = async (details: ServerAddOnStartDetails) => { + const addOnCleanupMock = jest.fn(); + const addOnMock = jest.fn().mockImplementation(() => addOnCleanupMock); + loadServerAddOnMock.mockImplementation(() => ({ + default: addOnMock, + })); + + const flipperServer = { + connect: jest.fn(), + on: jest.fn(), + off: jest.fn(), + exec: jest.fn(), + close: jest.fn(), + emit: jest.fn(), + }; + + expect(loadServerAddOnMock).toBeCalledTimes(0); + expect(addOnMock).toBeCalledTimes(0); + expect(addOnCleanupMock).toBeCalledTimes(0); + + const pluginManager = new PluginManager(flipperServer); + await pluginManager.startServerAddOn(pluginName, details, initialOwner); + + expect(loadServerAddOnMock).toBeCalledTimes(1); + expect(loadServerAddOnMock).toBeCalledWith(pluginName, details); + expect(addOnMock).toBeCalledTimes(1); + expect(addOnMock).toBeCalledWith( + expect.any(ServerAddOnModuleToDesktopConnection), + { + flipperServer, + }, + ); + expect(pluginManager.serverAddOns.size).toBe(1); + + const serverAddOn = pluginManager.serverAddOns.get(pluginName); + expect(serverAddOn).toBeInstanceOf(ServerAddOnManager); + expect(serverAddOn!.state.is('active')).toBeTruthy(); + + return { + addOnCleanupMock, + addOnMock, + flipperServer, + pluginManager, + }; + }; + + describe.each([ + ['bundled', detailsBundled], + ['installed', detailsInstalled], + ])('%s', (_name, details) => { + test('stops the add-on when the initial owner is removed', async () => { + const {pluginManager, addOnCleanupMock} = await startServerAddOn( + details, + ); + + const controlledP = createControlledPromise(); + addOnCleanupMock.mockImplementation(() => controlledP.promise); + + const stopPromise = pluginManager.stopServerAddOn( + pluginName, + initialOwner, + ); + + expect(pluginManager.serverAddOns.size).toBe(1); + + controlledP.resolve(); + await stopPromise; + + expect(addOnCleanupMock).toBeCalledTimes(1); + expect(pluginManager.serverAddOns.size).toBe(0); + }); + + test('adds a new owner, stops the add-on when all owners are removed', async () => { + const {pluginManager, addOnCleanupMock} = await startServerAddOn( + details, + ); + + const newOwner = 'luke'; + await pluginManager.startServerAddOn(pluginName, details, newOwner); + expect(pluginManager.serverAddOns.size).toBe(1); + + await pluginManager.stopServerAddOn(pluginName, initialOwner); + expect(addOnCleanupMock).toBeCalledTimes(0); + expect(pluginManager.serverAddOns.size).toBe(1); + + const serverAddOn = pluginManager.serverAddOns.get(pluginName)!; + expect(serverAddOn.state.is('active')).toBeTruthy(); + + await pluginManager.stopServerAddOn(pluginName, newOwner); + expect(addOnCleanupMock).toBeCalledTimes(1); + expect(pluginManager.serverAddOns.size).toBe(0); + + await serverAddOn.state.wait('inactive'); + }); + + test('concurrent calls to startServerAddOn start a single add-on', async () => { + const addOnCleanupMock = jest.fn(); + const controlledP = createControlledPromise<() => void>(); + const addOnMock = jest + .fn() + .mockImplementation(() => controlledP.promise); + loadServerAddOnMock.mockImplementation(() => ({ + default: addOnMock, + })); + + const flipperServer = { + connect: jest.fn(), + on: jest.fn(), + off: jest.fn(), + exec: jest.fn(), + close: jest.fn(), + emit: jest.fn(), + }; + + expect(loadServerAddOnMock).toBeCalledTimes(0); + expect(addOnMock).toBeCalledTimes(0); + expect(addOnCleanupMock).toBeCalledTimes(0); + + const pluginManager = new PluginManager(flipperServer); + + const startP1 = pluginManager.startServerAddOn( + pluginName, + details, + initialOwner, + ); + + expect(loadServerAddOnMock).toBeCalledTimes(1); + expect(loadServerAddOnMock).toBeCalledWith(pluginName, details); + expect(addOnMock).toBeCalledTimes(1); + expect(addOnMock).toBeCalledWith( + expect.any(ServerAddOnModuleToDesktopConnection), + { + flipperServer, + }, + ); + + expect(pluginManager.serverAddOns.size).toBe(1); + + const newOwner = 'luke'; + const startP2 = pluginManager.startServerAddOn( + pluginName, + details, + newOwner, + ); + + expect(loadServerAddOnMock).toBeCalledTimes(1); + expect(addOnMock).toBeCalledTimes(1); + expect(pluginManager.serverAddOns.size).toBe(1); + + controlledP.resolve(addOnCleanupMock); + await startP1; + await startP2; + + expect(loadServerAddOnMock).toBeCalledTimes(1); + expect(addOnMock).toBeCalledTimes(1); + expect(pluginManager.serverAddOns.size).toBe(1); + + const serverAddOn = pluginManager.serverAddOns.get(pluginName); + expect(serverAddOn).toBeInstanceOf(ServerAddOnManager); + expect(serverAddOn!.state.is('active')).toBeTruthy(); + }); + + test('concurrent calls to stopServerAddOn stop add-on only once', async () => { + const {pluginManager, addOnCleanupMock} = await startServerAddOn( + details, + ); + + const controlledP = createControlledPromise(); + addOnCleanupMock.mockImplementation(() => controlledP.promise); + + expect(addOnCleanupMock).toBeCalledTimes(0); + + const stopP1 = pluginManager.stopServerAddOn(pluginName, initialOwner); + expect(addOnCleanupMock).toBeCalledTimes(1); + + const stopP2 = pluginManager.stopServerAddOn(pluginName, initialOwner); + expect(addOnCleanupMock).toBeCalledTimes(1); + + controlledP.resolve(); + await stopP1; + await stopP2; + + expect(addOnCleanupMock).toBeCalledTimes(1); + }); + }); + }); +}); diff --git a/desktop/flipper-server-core/src/plugins/__tests__/ServerAddOn.node.tsx b/desktop/flipper-server-core/src/plugins/__tests__/ServerAddOn.node.tsx index 05e8744f7..99cadf0e8 100644 --- a/desktop/flipper-server-core/src/plugins/__tests__/ServerAddOn.node.tsx +++ b/desktop/flipper-server-core/src/plugins/__tests__/ServerAddOn.node.tsx @@ -11,34 +11,18 @@ import {ServerAddOnStartDetails} from 'flipper-common'; import {loadServerAddOn} from '../loadServerAddOn'; import {ServerAddOn} from '../ServerAddOn'; import {ServerAddOnModuleToDesktopConnection} from '../ServerAddOnModuleToDesktopConnection'; +import { + createControlledPromise, + detailsBundled, + detailsInstalled, + initialOwner, + pluginName, +} from './utils'; jest.mock('../loadServerAddOn'); const loadServerAddOnMock = loadServerAddOn as jest.Mock; -const createControlledPromise = () => { - let resolve!: () => void; - let reject!: (reason: unknown) => void; - const promise = new Promise((resolveP, rejectP) => { - resolve = resolveP; - reject = rejectP; - }); - return { - promise, - resolve, - reject, - }; -}; - describe('ServerAddOn', () => { - const pluginName = 'lightSaber'; - const initialOwner = 'yoda'; - const detailsBundled: ServerAddOnStartDetails = { - isBundled: true, - }; - const detailsInstalled: ServerAddOnStartDetails = { - path: '/dagobar/', - }; - const startServerAddOn = async (details: ServerAddOnStartDetails) => { const addOnCleanupMock = jest.fn(); const addOnMock = jest.fn().mockImplementation(() => addOnCleanupMock); @@ -91,7 +75,7 @@ describe('ServerAddOn', () => { test('stops the add-on when the initial owner is removed', async () => { const {serverAddOn, addOnCleanupMock} = await startServerAddOn(details); - const controlledP = createControlledPromise(); + const controlledP = createControlledPromise(); addOnCleanupMock.mockImplementation(() => controlledP.promise); const removeOwnerRes = serverAddOn.removeOwner(initialOwner); @@ -109,7 +93,7 @@ describe('ServerAddOn', () => { const newOwner = 'luke'; serverAddOn.addOwner(newOwner); - const controlledP = createControlledPromise(); + const controlledP = createControlledPromise(); addOnCleanupMock.mockImplementation(() => controlledP.promise); const removeOwnerRes1 = serverAddOn.removeOwner(initialOwner); @@ -138,7 +122,7 @@ describe('ServerAddOn', () => { test('calls stop only once when removeOwner is called twice with the same owner', async () => { const {serverAddOn, addOnCleanupMock} = await startServerAddOn(details); - const controlledP = createControlledPromise(); + const controlledP = createControlledPromise(); addOnCleanupMock.mockImplementation(() => controlledP.promise); const removeOwnerRes1 = serverAddOn.removeOwner(initialOwner); diff --git a/desktop/flipper-server-core/src/plugins/__tests__/utils.tsx b/desktop/flipper-server-core/src/plugins/__tests__/utils.tsx new file mode 100644 index 000000000..4b0c5b0d5 --- /dev/null +++ b/desktop/flipper-server-core/src/plugins/__tests__/utils.tsx @@ -0,0 +1,33 @@ +/** + * Copyright (c) Meta Platforms, Inc. and 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 {ServerAddOnStartDetails} from 'flipper-common'; + +export const pluginName = 'lightSaber'; +export const initialOwner = 'yoda'; +export const detailsBundled: ServerAddOnStartDetails = { + isBundled: true, +}; +export const detailsInstalled: ServerAddOnStartDetails = { + path: '/dagobar/', +}; + +export const createControlledPromise = () => { + let resolve!: (...res: T extends void ? [] : [T]) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((resolveP, rejectP) => { + resolve = resolveP as typeof resolve; + reject = rejectP; + }); + return { + promise, + resolve, + reject, + }; +};