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 <Methods> generic of FlipperClient.

Reviewed By: nikoant

Differential Revision: D22256972

fbshipit-source-id: 549523a402949b3eb6bb4b4ca160dedb5c5e722d
This commit is contained in:
Michel Weststrate
2020-07-01 08:58:40 -07:00
committed by Facebook GitHub Bot
parent 8b2d8498e6
commit ec85dd5b01
9 changed files with 161 additions and 10 deletions

View File

@@ -30,7 +30,7 @@ beforeEach(() => {
initialized = false;
});
function plugin(client: FlipperClient) {
function plugin(client: FlipperClient<any, any>) {
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

View File

@@ -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<any> => {
client.rawCall = async (
method: string,
_fromPlugin: boolean,
params: any,
): Promise<any> => {
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}'`,
);
}
};

View File

@@ -12,6 +12,7 @@
"@testing-library/react": "^10.4.3"
},
"devDependencies": {
"@types/jest": "^26.0.3",
"typescript": "^3.9.5"
},
"scripts": {

View File

@@ -17,7 +17,7 @@ type Events = {
};
type Methods = {
currentState(): Promise<number>;
currentState(params: {since: number}): Promise<number>;
};
export function plugin(client: FlipperClient<Events, Methods>) {
@@ -32,10 +32,22 @@ export function plugin(client: FlipperClient<Events, Methods>) {
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,
};
}

View File

@@ -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();
});

View File

@@ -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 extends keyof Methods>(
method: Method,
params: Parameters<Methods[Method]>[0],
): ReturnType<Methods[Method]>;
}
/**
@@ -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<Object>;
}
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');
}
}
}

View File

@@ -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<Module extends FlipperPluginModule<any>> = Parameters<
Module['plugin']
>[0];
type ExtractMethodsType<
Module extends FlipperPluginModule<any>
> = ExtractClientType<Module> extends FlipperClient<any, infer Methods>
? Methods
: never;
interface StartPluginResult<Module extends FlipperPluginModule<any>> {
/**
* the instantiated plugin for this test
@@ -50,6 +63,16 @@ interface StartPluginResult<Module extends FlipperPluginModule<any>> {
* 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 extends keyof ExtractMethodsType<Module>>(
method: Method,
params: Parameters<ExtractMethodsType<Module>[Method]>[0],
) => ReturnType<ExtractMethodsType<Module>[Method]>
>;
}
export function startPlugin<Module extends FlipperPluginModule<any>>(
@@ -61,6 +84,7 @@ export function startPlugin<Module extends FlipperPluginModule<any>>(
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<Module extends FlipperPluginModule<any>>(
},
initPlugin(_pluginId: string) {},
deinitPlugin(_pluginId: string) {},
call(
api: string,
method: string,
fromPlugin: boolean,
params?: Object,
): Promise<Object> {
return sendStub(method, params);
},
};
const pluginInstance = new SandyPluginInstance(fakeFlipper, definition);
@@ -81,6 +113,7 @@ export function startPlugin<Module extends FlipperPluginModule<any>>(
connect: () => pluginInstance.connect(),
disconnect: () => pluginInstance.disconnect(),
destroy: () => pluginInstance.destroy(),
onSend: sendStub,
// @ts-ignore
_backingInstance: pluginInstance,
};

View File

@@ -6,5 +6,5 @@
},
"references": [{"path": "../plugin-lib"}],
"include": ["src"],
"exclude": ["node_modules", "**/__tests__/*"]
"exclude": ["node_modules"]
}

View File

@@ -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"