From ec85dd5b0110848b094d61ee93aa8bbdb6dee1a5 Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Wed, 1 Jul 2020 08:58:40 -0700 Subject: [PATCH] Allow plugins to send messages Summary: Sandy plugins can now send messages to plugins. The methods and params are strongly typed in implementation and unit tests, based on the generic of FlipperClient. Reviewed By: nikoant Differential Revision: D22256972 fbshipit-source-id: 549523a402949b3eb6bb4b4ca160dedb5c5e722d --- .../reducers/__tests__/sandyplugins.node.tsx | 29 +++++++++++++- .../createMockFlipperWithPlugin.tsx | 12 ++++-- desktop/flipper-plugin/package.json | 1 + .../src/__tests__/TestPlugin.tsx | 14 ++++++- .../src/__tests__/test-utils.node.tsx | 40 +++++++++++++++++-- desktop/flipper-plugin/src/plugin/Plugin.tsx | 30 ++++++++++++++ .../src/test-utils/test-utils.tsx | 35 +++++++++++++++- desktop/flipper-plugin/tsconfig.json | 2 +- desktop/yarn.lock | 8 ++++ 9 files changed, 161 insertions(+), 10 deletions(-) diff --git a/desktop/app/src/reducers/__tests__/sandyplugins.node.tsx b/desktop/app/src/reducers/__tests__/sandyplugins.node.tsx index e0bcba23f..e31798751 100644 --- a/desktop/app/src/reducers/__tests__/sandyplugins.node.tsx +++ b/desktop/app/src/reducers/__tests__/sandyplugins.node.tsx @@ -30,7 +30,7 @@ beforeEach(() => { initialized = false; }); -function plugin(client: FlipperClient) { +function plugin(client: FlipperClient) { const connectStub = jest.fn(); const disconnectStub = jest.fn(); const destroyStub = jest.fn(); @@ -45,6 +45,7 @@ function plugin(client: FlipperClient) { connectStub, disconnectStub, destroyStub, + send: client.send, }; } const TestPlugin = new SandyPluginDefinition(pluginDetails, { @@ -207,4 +208,30 @@ test('it trigger hooks for background plugins', async () => { expect(pluginInstance.disconnectStub).toHaveBeenCalledTimes(1); }); +test('it can send messages from sandy clients', async () => { + let testMethodCalledWith: any = undefined; + const {client} = await createMockFlipperWithPlugin(TestPlugin, { + onSend(method, params) { + if (method === 'execute') { + testMethodCalledWith = params; + return {}; + } + }, + }); + const pluginInstance: PluginApi = client.sandyPluginStates.get(TestPlugin.id)! + .instanceApi; + // without rendering, non-bg plugins won't connect automatically, + client.initPlugin(TestPlugin.id); + await pluginInstance.send('test', {test: 3}); + expect(testMethodCalledWith).toMatchInlineSnapshot(` + Object { + "api": "TestPlugin", + "method": "test", + "params": Object { + "test": 3, + }, + } + `); +}); + // TODO: T68683449 state is persisted if a plugin connects and reconnects diff --git a/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx b/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx index f22be1d7f..9a1253d17 100644 --- a/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx +++ b/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx @@ -51,7 +51,7 @@ type MockOptions = Partial<{ * can be used to intercept outgoing calls. If it returns undefined * the base implementation will be used */ - onSend(method: string, params?: object): object | undefined; + onSend(pluginId: string, method: string, params?: object): any; }>; export async function createMockFlipperWithPlugin( @@ -110,7 +110,11 @@ export async function createMockFlipperWithPlugin( return device; }, } as any; - client.rawCall = async (method, _fromPlugin, params): Promise => { + client.rawCall = async ( + method: string, + _fromPlugin: boolean, + params: any, + ): Promise => { const intercepted = options?.onSend?.(method, params); if (intercepted !== undefined) { return intercepted; @@ -127,7 +131,9 @@ export async function createMockFlipperWithPlugin( case 'getBackgroundPlugins': return {plugins: []}; default: - throw new Error(`Test client doesn't support rawCall to ${method}`); + throw new Error( + `Test client doesn't support rawCall method '${method}'`, + ); } }; diff --git a/desktop/flipper-plugin/package.json b/desktop/flipper-plugin/package.json index 648079193..2037956b2 100644 --- a/desktop/flipper-plugin/package.json +++ b/desktop/flipper-plugin/package.json @@ -12,6 +12,7 @@ "@testing-library/react": "^10.4.3" }, "devDependencies": { + "@types/jest": "^26.0.3", "typescript": "^3.9.5" }, "scripts": { diff --git a/desktop/flipper-plugin/src/__tests__/TestPlugin.tsx b/desktop/flipper-plugin/src/__tests__/TestPlugin.tsx index 5b74d7f38..82f4592b8 100644 --- a/desktop/flipper-plugin/src/__tests__/TestPlugin.tsx +++ b/desktop/flipper-plugin/src/__tests__/TestPlugin.tsx @@ -17,7 +17,7 @@ type Events = { }; type Methods = { - currentState(): Promise; + currentState(params: {since: number}): Promise; }; export function plugin(client: FlipperClient) { @@ -32,10 +32,22 @@ export function plugin(client: FlipperClient) { client.onDisconnect(disconnectStub); client.onDestroy(destroyStub); + 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'}); + } + + async function getCurrentState() { + return client.send('currentState', {since: 0}); + } + return { connectStub, destroyStub, disconnectStub, + getCurrentState, }; } diff --git a/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx b/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx index 8076bb42a..e6990b1a1 100644 --- a/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx +++ b/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx @@ -8,14 +8,15 @@ */ import * as TestUtils from '../test-utils/test-utils'; - import * as testPlugin from './TestPlugin'; test('it can start a plugin and lifecycle events', () => { const {instance, ...p} = TestUtils.startPlugin(testPlugin); - // TODO T69105011 @ts-expect-error - // p.bla; + // @ts-expect-error + p.bla; + // @ts-expect-error + instance.bla; // startPlugin starts connected expect(instance.connectStub).toBeCalledTimes(1); @@ -59,3 +60,36 @@ test('it can render a plugin', () => { `); // TODO: test sending updates T68683442 }); + +test('a plugin can send messages', async () => { + const {instance, onSend} = TestUtils.startPlugin(testPlugin); + + // By default send is stubbed + expect(await instance.getCurrentState()).toBeUndefined(); + expect(onSend).toHaveBeenCalledWith('currentState', {since: 0}); + + // @ts-expect-error + onSend('bla'); + + // ... But we can intercept! + onSend.mockImplementationOnce(async (method, params) => { + expect(method).toEqual('currentState'); + expect(params).toEqual({since: 0}); + return 3; + }); + expect(await instance.getCurrentState()).toEqual(3); +}); + +test('a plugin cannot send messages after being disconnected', async () => { + const {instance, disconnect} = TestUtils.startPlugin(testPlugin); + + disconnect(); + let threw = false; + try { + await instance.getCurrentState(); + } catch (e) { + threw = true; // for some weird reason expect(async () => instance.getCurrentState()).toThrow(...) doesn't work today... + expect(e).toMatchInlineSnapshot(`[Error: Plugin is not connected]`); + } + expect(threw).toBeTruthy(); +}); diff --git a/desktop/flipper-plugin/src/plugin/Plugin.tsx b/desktop/flipper-plugin/src/plugin/Plugin.tsx index 714659a17..70ebb0641 100644 --- a/desktop/flipper-plugin/src/plugin/Plugin.tsx +++ b/desktop/flipper-plugin/src/plugin/Plugin.tsx @@ -39,6 +39,14 @@ export interface FlipperClient< * - when the plugin is disabled */ onDisconnect(cb: () => void): void; + + /** + * Send a message to the connected client + */ + send( + method: Method, + params: Parameters[0], + ): ReturnType; } /** @@ -49,6 +57,12 @@ export interface RealFlipperClient { isBackgroundPlugin(pluginId: string): boolean; initPlugin(pluginId: string): void; deinitPlugin(pluginId: string): void; + call( + api: string, + method: string, + fromPlugin: boolean, + params?: Object, + ): Promise; } export type FlipperPluginFactory< @@ -88,6 +102,15 @@ export class SandyPluginInstance { onDisconnect: (cb) => { this.events.on('disconnect', cb); }, + send: async (method, params) => { + this.assertConnected(); + return await realClient.call( + this.definition.id, + method as any, + true, + params as any, + ); + }, }; this.instanceApi = definition.module.plugin(this.client); } @@ -143,4 +166,11 @@ export class SandyPluginInstance { throw new Error('Plugin has been destroyed already'); } } + + private assertConnected() { + this.assertNotDestroyed(); + if (!this.connected) { + throw new Error('Plugin is not connected'); + } + } } diff --git a/desktop/flipper-plugin/src/test-utils/test-utils.tsx b/desktop/flipper-plugin/src/test-utils/test-utils.tsx index 929fe0d46..1af58a907 100644 --- a/desktop/flipper-plugin/src/test-utils/test-utils.tsx +++ b/desktop/flipper-plugin/src/test-utils/test-utils.tsx @@ -15,7 +15,11 @@ import { } from '@testing-library/react'; import {PluginDetails} from 'flipper-plugin-lib'; -import {RealFlipperClient, SandyPluginInstance} from '../plugin/Plugin'; +import { + RealFlipperClient, + SandyPluginInstance, + FlipperClient, +} from '../plugin/Plugin'; import { SandyPluginDefinition, FlipperPluginModule, @@ -29,6 +33,15 @@ interface StartPluginOptions { // TODO: support initial state T68683449 (and type correctly) } +type ExtractClientType> = Parameters< + Module['plugin'] +>[0]; +type ExtractMethodsType< + Module extends FlipperPluginModule +> = ExtractClientType extends FlipperClient + ? Methods + : never; + interface StartPluginResult> { /** * the instantiated plugin for this test @@ -50,6 +63,16 @@ interface StartPluginResult> { * Emulates the 'destroy' event. After calling destroy this plugin instance won't be usable anymore */ destroy(): void; + /** + * Jest Stub that is called whenever client.send() is called by the plugin. + * Use send.mockImplementation(function) to intercept the calls. + */ + onSend: jest.MockedFunction< + >( + method: Method, + params: Parameters[Method]>[0], + ) => ReturnType[Method]> + >; } export function startPlugin>( @@ -61,6 +84,7 @@ export function startPlugin>( module, ); + const sendStub = jest.fn(); const fakeFlipper: RealFlipperClient = { isBackgroundPlugin(_pluginId: string) { // we only reason about non-background plugins, @@ -69,6 +93,14 @@ export function startPlugin>( }, initPlugin(_pluginId: string) {}, deinitPlugin(_pluginId: string) {}, + call( + api: string, + method: string, + fromPlugin: boolean, + params?: Object, + ): Promise { + return sendStub(method, params); + }, }; const pluginInstance = new SandyPluginInstance(fakeFlipper, definition); @@ -81,6 +113,7 @@ export function startPlugin>( connect: () => pluginInstance.connect(), disconnect: () => pluginInstance.disconnect(), destroy: () => pluginInstance.destroy(), + onSend: sendStub, // @ts-ignore _backingInstance: pluginInstance, }; diff --git a/desktop/flipper-plugin/tsconfig.json b/desktop/flipper-plugin/tsconfig.json index 9c303bc5a..ae65393b7 100644 --- a/desktop/flipper-plugin/tsconfig.json +++ b/desktop/flipper-plugin/tsconfig.json @@ -6,5 +6,5 @@ }, "references": [{"path": "../plugin-lib"}], "include": ["src"], - "exclude": ["node_modules", "**/__tests__/*"] + "exclude": ["node_modules"] } diff --git a/desktop/yarn.lock b/desktop/yarn.lock index 648a7394b..a77fb0385 100644 --- a/desktop/yarn.lock +++ b/desktop/yarn.lock @@ -2032,6 +2032,14 @@ jest-diff "^25.2.1" pretty-format "^25.2.1" +"@types/jest@^26.0.3": + version "26.0.3" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.3.tgz#79534e0e94857171c0edc596db0ebe7cb7863251" + integrity sha512-v89ga1clpVL/Y1+YI0eIu1VMW+KU7Xl8PhylVtDKVWaSUHBHYPLXMQGBdrpHewaKoTvlXkksbYqPgz8b4cmRZg== + dependencies: + jest-diff "^25.2.1" + pretty-format "^25.2.1" + "@types/json-schema@^7.0.3": version "7.0.3" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.3.tgz#bdfd69d61e464dcc81b25159c270d75a73c1a636"