diff --git a/desktop/flipper-server-core/src/plugins/ServerAddOn.tsx b/desktop/flipper-server-core/src/plugins/ServerAddOn.tsx index 37e207dda..bb03c8f30 100644 --- a/desktop/flipper-server-core/src/plugins/ServerAddOn.tsx +++ b/desktop/flipper-server-core/src/plugins/ServerAddOn.tsx @@ -12,42 +12,11 @@ import {assertNotNull} from '../comms/Utilities'; import { FlipperServerForServerAddOn, ServerAddOnCleanup, - ServerAddOn as ServerAddOnFn, ServerAddOnStartDetails, } from 'flipper-common'; import {ServerAddOnDesktopToModuleConnection} from './ServerAddOnDesktopToModuleConnection'; import {ServerAddOnModuleToDesktopConnection} from './ServerAddOnModuleToDesktopConnection'; -// @ts-ignore -import defaultPlugins from '../defaultPlugins'; - -interface ServerAddOnModule { - default: ServerAddOnFn; -} - -const loadPlugin = ( - pluginName: string, - details: ServerAddOnStartDetails, -): ServerAddOnModule => { - console.debug('loadPlugin', pluginName, details); - - if (details.isBundled) { - const bundledPlugin = defaultPlugins[pluginName]; - assertNotNull( - bundledPlugin, - `loadPlugin (isBundled = true) -> plugin ${pluginName} not found.`, - ); - return bundledPlugin; - } - - assertNotNull( - details.path, - `loadPlugin (isBundled = false) -> server add-on path is empty plugin ${pluginName}.`, - ); - - // eslint-disable-next-line no-eval - const serverAddOnModule = eval(`require("${details.path}")`); - return serverAddOnModule; -}; +import {loadServerAddOn} from './loadServerAddOn'; export class ServerAddOn { private owners: Set; @@ -69,7 +38,7 @@ export class ServerAddOn { ): Promise { console.info('ServerAddOn.start', pluginName, details); - const {default: serverAddOn} = loadPlugin(pluginName, details); + const {default: serverAddOn} = loadServerAddOn(pluginName, details); assertNotNull(serverAddOn); assert( typeof serverAddOn === 'function', diff --git a/desktop/flipper-server-core/src/plugins/__tests__/ServerAddOn.node.tsx b/desktop/flipper-server-core/src/plugins/__tests__/ServerAddOn.node.tsx new file mode 100644 index 000000000..05e8744f7 --- /dev/null +++ b/desktop/flipper-server-core/src/plugins/__tests__/ServerAddOn.node.tsx @@ -0,0 +1,157 @@ +/** + * 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 {ServerAddOn} from '../ServerAddOn'; +import {ServerAddOnModuleToDesktopConnection} from '../ServerAddOnModuleToDesktopConnection'; + +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); + 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 serverAddOn = await ServerAddOn.start( + pluginName, + details, + initialOwner, + flipperServer, + ); + + expect(loadServerAddOnMock).toBeCalledTimes(1); + expect(loadServerAddOnMock).toBeCalledWith(pluginName, details); + expect(addOnMock).toBeCalledTimes(1); + expect(addOnMock).toBeCalledWith( + expect.any(ServerAddOnModuleToDesktopConnection), + { + flipperServer, + }, + ); + + return { + addOnCleanupMock, + addOnMock, + flipperServer, + serverAddOn, + }; + }; + + describe.each([ + ['bundled', detailsBundled], + ['installed', detailsInstalled], + ])('%s', (_name, details) => { + test('stops the add-on when the initial owner is removed', async () => { + const {serverAddOn, addOnCleanupMock} = await startServerAddOn(details); + + const controlledP = createControlledPromise(); + addOnCleanupMock.mockImplementation(() => controlledP.promise); + + const removeOwnerRes = serverAddOn.removeOwner(initialOwner); + + expect(removeOwnerRes).toBeInstanceOf(Promise); + expect(addOnCleanupMock).toBeCalledTimes(1); + + controlledP.resolve(); + await removeOwnerRes; + }); + + test('adds a new owner, stops the add-on when all owners are removed', async () => { + const {serverAddOn, addOnCleanupMock} = await startServerAddOn(details); + + const newOwner = 'luke'; + serverAddOn.addOwner(newOwner); + + const controlledP = createControlledPromise(); + addOnCleanupMock.mockImplementation(() => controlledP.promise); + + const removeOwnerRes1 = serverAddOn.removeOwner(initialOwner); + expect(removeOwnerRes1).toBeUndefined(); + expect(addOnCleanupMock).toBeCalledTimes(0); + + const removeOwnerRes2 = serverAddOn.removeOwner(newOwner); + + expect(removeOwnerRes2).toBeInstanceOf(Promise); + expect(addOnCleanupMock).toBeCalledTimes(1); + + controlledP.resolve(); + await removeOwnerRes2; + }); + + test('does nothing when removeOwner is called with a missing owner', async () => { + const {serverAddOn, addOnCleanupMock} = await startServerAddOn(details); + + const missingOwner = 'luke'; + + const removeOwnerRes = serverAddOn.removeOwner(missingOwner); + expect(addOnCleanupMock).toBeCalledTimes(0); + expect(removeOwnerRes).toBeUndefined(); + }); + + test('calls stop only once when removeOwner is called twice with the same owner', async () => { + const {serverAddOn, addOnCleanupMock} = await startServerAddOn(details); + + const controlledP = createControlledPromise(); + addOnCleanupMock.mockImplementation(() => controlledP.promise); + + const removeOwnerRes1 = serverAddOn.removeOwner(initialOwner); + + expect(removeOwnerRes1).toBeInstanceOf(Promise); + expect(addOnCleanupMock).toBeCalledTimes(1); + + const removeOwnerRes2 = serverAddOn.removeOwner(initialOwner); + expect(removeOwnerRes2).toBeUndefined(); + expect(addOnCleanupMock).toBeCalledTimes(1); + + controlledP.resolve(); + await removeOwnerRes1; + }); + }); +}); diff --git a/desktop/flipper-server-core/src/plugins/loadServerAddOn.tsx b/desktop/flipper-server-core/src/plugins/loadServerAddOn.tsx new file mode 100644 index 000000000..62f27f35e --- /dev/null +++ b/desktop/flipper-server-core/src/plugins/loadServerAddOn.tsx @@ -0,0 +1,46 @@ +/** + * 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 { + ServerAddOn as ServerAddOnFn, + ServerAddOnStartDetails, +} from 'flipper-common'; +import {assertNotNull} from '../comms/Utilities'; +// The file is generated automatically by "prepareDefaultPlugins" in "scripts" +// @ts-ignore +import defaultPlugins from '../defaultPlugins'; + +interface ServerAddOnModule { + default: ServerAddOnFn; +} + +export const loadServerAddOn = ( + pluginName: string, + details: ServerAddOnStartDetails, +): ServerAddOnModule => { + console.debug('loadPlugin', pluginName, details); + + if (details.isBundled) { + const bundledPlugin = defaultPlugins[pluginName]; + assertNotNull( + bundledPlugin, + `loadPlugin (isBundled = true) -> plugin ${pluginName} not found.`, + ); + return bundledPlugin; + } + + assertNotNull( + details.path, + `loadPlugin (isBundled = false) -> server add-on path is empty plugin ${pluginName}.`, + ); + + // eslint-disable-next-line no-eval + const serverAddOnModule = eval(`require("${details.path}")`); + return serverAddOnModule; +};