diff --git a/desktop/app/src/Client.tsx b/desktop/app/src/Client.tsx index 6883c3925..6d768ba7c 100644 --- a/desktop/app/src/Client.tsx +++ b/desktop/app/src/Client.tsx @@ -11,6 +11,8 @@ import { PluginDefinition, ClientPluginDefinition, isSandyPlugin, + FlipperPlugin, + FlipperDevicePlugin, } from './plugin'; import BaseDevice, {OS} from './devices/BaseDevice'; import {App} from './App'; @@ -135,7 +137,10 @@ export default class Client extends EventEmitter { messageBuffer: Record< string /*pluginKey*/, { - plugin: PluginDefinition; + plugin: + | typeof FlipperPlugin + | typeof FlipperDevicePlugin + | SandyPluginInstance; messages: Params[]; } > = {}; @@ -456,11 +461,11 @@ export default class Client extends EventEmitter { this.store.getState().plugins.devicePlugins.get(params.api); let handled = false; // This is just for analysis - // TODO: support Sandy plugins T68683442 if ( persistingPlugin && - !isSandyPlugin(persistingPlugin) && - persistingPlugin.persistedStateReducer + ((persistingPlugin as any).persistedStateReducer || + // only send messages to enabled sandy plugins + this.sandyPluginStates.has(params.api)) ) { handled = true; const pluginKey = getPluginKey( @@ -470,7 +475,8 @@ export default class Client extends EventEmitter { ); if (!this.messageBuffer[pluginKey]) { this.messageBuffer[pluginKey] = { - plugin: persistingPlugin, + plugin: (this.sandyPluginStates.get(params.api) ?? + persistingPlugin) as any, messages: [params], }; } else { diff --git a/desktop/app/src/utils/__tests__/messageQueueSandy.node.tsx b/desktop/app/src/utils/__tests__/messageQueueSandy.node.tsx new file mode 100644 index 000000000..bcedee7a4 --- /dev/null +++ b/desktop/app/src/utils/__tests__/messageQueueSandy.node.tsx @@ -0,0 +1,639 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 {FlipperDevicePlugin} from '../../plugin'; +import {createMockFlipperWithPlugin} from '../../test-utils/createMockFlipperWithPlugin'; +import {Store, Client, sleep} from '../../'; +import { + selectPlugin, + starPlugin, + selectClient, + selectDevice, +} from '../../reducers/connections'; +import {processMessageQueue} from '../messageQueue'; +import {getPluginKey} from '../pluginUtils'; +import {TestIdler} from '../Idler'; +import {registerPlugins} from '../../reducers/plugins'; +import { + SandyPluginDefinition, + TestUtils, + FlipperClient, + SandyPluginInstance, +} from 'flipper-plugin'; + +type Events = { + inc: { + delta?: number; + }; +}; + +function plugin(client: FlipperClient) { + const state = { + count: 0, + }; + + client.onMessage('inc', (params) => { + state.count += params.delta || 1; + }); + + return { + state, + }; +} + +const TestPlugin = new SandyPluginDefinition( + TestUtils.createMockPluginDetails(), + { + plugin, + Component() { + return null; + }, + }, +); + +function starTestPlugin(store: Store, client: Client) { + store.dispatch( + starPlugin({ + plugin: TestPlugin, + selectedApp: client.query.app, + }), + ); +} + +function selectDeviceLogs(store: Store) { + store.dispatch( + selectPlugin({ + selectedPlugin: 'DeviceLogs', + selectedApp: null, + deepLinkPayload: null, + selectedDevice: store.getState().connections.selectedDevice!, + }), + ); +} + +function selectTestPlugin(store: Store, client: Client) { + store.dispatch( + selectPlugin({ + selectedPlugin: TestPlugin.id, + selectedApp: client.query.app, + deepLinkPayload: null, + selectedDevice: store.getState().connections.selectedDevice!, + }), + ); +} + +function getTestPluginState( + client: Client, +): ReturnType['state'] { + return client.sandyPluginStates.get(TestPlugin.id)!.instanceApi.state; +} + +test('queue - events are processed immediately if plugin is selected', async () => { + const {store, client, sendMessage} = await createMockFlipperWithPlugin( + TestPlugin, + ); + expect(store.getState().connections.selectedPlugin).toBe('TestPlugin'); + sendMessage('noop', {}); + sendMessage('noop', {}); + sendMessage('inc', {}); + sendMessage('inc', {delta: 4}); + sendMessage('noop', {}); + client.flushMessageBuffer(); + expect(getTestPluginState(client)).toMatchInlineSnapshot(` + Object { + "count": 5, + } + `); + expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot( + `Object {}`, + ); +}); + +test('queue - events are NOT processed immediately if plugin is NOT selected (but enabled)', async () => { + const { + store, + client, + sendMessage, + device, + } = await createMockFlipperWithPlugin(TestPlugin); + selectDeviceLogs(store); + expect(store.getState().connections.selectedPlugin).not.toBe('TestPlugin'); + + sendMessage('inc', {}); + sendMessage('inc', {delta: 2}); + sendMessage('inc', {delta: 3}); + expect(store.getState().pluginStates).toMatchInlineSnapshot(`Object {}`); + expect(getTestPluginState(client).count).toBe(0); + // the first message is already visible cause of the leading debounce + expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(` + Object { + "TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [ + Object { + "api": "TestPlugin", + "method": "inc", + "params": Object {}, + }, + ], + } + `); + client.flushMessageBuffer(); + expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(` + Object { + "TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [ + Object { + "api": "TestPlugin", + "method": "inc", + "params": Object {}, + }, + Object { + "api": "TestPlugin", + "method": "inc", + "params": Object { + "delta": 2, + }, + }, + Object { + "api": "TestPlugin", + "method": "inc", + "params": Object { + "delta": 3, + }, + }, + ], + } + `); + + // process the message + const pluginKey = getPluginKey(client.id, device, TestPlugin.id); + await processMessageQueue( + client.sandyPluginStates.get(TestPlugin.id)!, + pluginKey, + store, + ); + expect(getTestPluginState(client)).toEqual({ + count: 6, + }); + + expect(store.getState().pluginMessageQueue).toEqual({ + [pluginKey]: [], + }); + + // unstar. Messages don't arrive anymore + starTestPlugin(store, client); + // weird state... + selectTestPlugin(store, client); + sendMessage('inc', {delta: 3}); + client.flushMessageBuffer(); + // active, immediately processed + expect(client.sandyPluginStates.has(TestPlugin.id)).toBe(false); + + // different plugin, and not starred, message will never arrive + selectDeviceLogs(store); + sendMessage('inc', {delta: 4}); + client.flushMessageBuffer(); + expect(store.getState().pluginMessageQueue).toEqual({ + [pluginKey]: [], + }); + + // star again, plugin still not selected, message is queued + starTestPlugin(store, client); + sendMessage('inc', {delta: 5}); + client.flushMessageBuffer(); + + expect(store.getState().pluginMessageQueue).toEqual({ + [pluginKey]: [{api: 'TestPlugin', method: 'inc', params: {delta: 5}}], + }); +}); + +test('queue - events are queued for plugins that are favorite when app is not selected', async () => { + const { + client, + device, + store, + sendMessage, + createClient, + } = await createMockFlipperWithPlugin(TestPlugin); + selectDeviceLogs(store); + expect(store.getState().connections.selectedPlugin).not.toBe('TestPlugin'); + + const client2 = await createClient(device, 'TestApp2'); + store.dispatch(selectClient(client2.id)); + + // Now we send a message to the second client, it should arrive, + // as the plugin was enabled already on the first client as well + sendMessage('inc', {delta: 2}); + expect(getTestPluginState(client)).toEqual({count: 0}); + expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(` + Object { + "TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [ + Object { + "api": "TestPlugin", + "method": "inc", + "params": Object { + "delta": 2, + }, + }, + ], + } + `); +}); + +test('queue - events are queued for plugins that are favorite when app is selected on different device', async () => { + const { + client, + store, + sendMessage, + createDevice, + createClient, + } = await createMockFlipperWithPlugin(TestPlugin); + selectDeviceLogs(store); + expect(store.getState().connections.selectedPlugin).not.toBe('TestPlugin'); + + const device2 = createDevice('serial2'); + const client2 = await createClient(device2, client.query.app); // same app id + store.dispatch(selectDevice(device2)); + store.dispatch(selectClient(client2.id)); + + // Now we send a message to the first and second client, it should arrive, + // as the plugin was enabled already on the first client as well + sendMessage('inc', {delta: 2}); + sendMessage('inc', {delta: 3}, client2); + client.flushMessageBuffer(); + client2.flushMessageBuffer(); + expect(getTestPluginState(client)).toEqual({count: 0}); + expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(` + Object { + "TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [ + Object { + "api": "TestPlugin", + "method": "inc", + "params": Object { + "delta": 2, + }, + }, + ], + "TestApp#Android#MockAndroidDevice#serial2#TestPlugin": Array [ + Object { + "api": "TestPlugin", + "method": "inc", + "params": Object { + "delta": 3, + }, + }, + ], + } + `); +}); + +test('queue - events processing will be paused', async () => { + const { + client, + device, + store, + sendMessage, + } = await createMockFlipperWithPlugin(TestPlugin); + selectDeviceLogs(store); + + sendMessage('inc', {}); + sendMessage('inc', {delta: 3}); + sendMessage('inc', {delta: 5}); + client.flushMessageBuffer(); + + // process the message + const pluginKey = getPluginKey(client.id, device, TestPlugin.id); + + // controlled idler will signal and and off that idling is needed + const idler = new TestIdler(); + + const p = processMessageQueue( + client.sandyPluginStates.get(TestPlugin.id)!, + pluginKey, + store, + undefined, + idler, + ); + + expect(getTestPluginState(client)).toEqual({ + count: 4, + }); + + expect(store.getState().pluginMessageQueue).toEqual({ + [pluginKey]: [{api: 'TestPlugin', method: 'inc', params: {delta: 5}}], + }); + + await idler.next(); + expect(getTestPluginState(client)).toEqual({ + count: 9, + }); + + expect(store.getState().pluginMessageQueue).toEqual({ + [pluginKey]: [], + }); + + // don't idle anymore + idler.run(); + await p; +}); + +test('queue - messages that arrive during processing will be queued', async () => { + const { + client, + device, + store, + sendMessage, + } = await createMockFlipperWithPlugin(TestPlugin); + selectDeviceLogs(store); + + sendMessage('inc', {}); + sendMessage('inc', {delta: 2}); + sendMessage('inc', {delta: 3}); + client.flushMessageBuffer(); + + // process the message + const pluginKey = getPluginKey(client.id, device, TestPlugin.id); + + const idler = new TestIdler(); + + const p = processMessageQueue( + client.sandyPluginStates.get(TestPlugin.id)!, + pluginKey, + store, + undefined, + idler, + ); + + // first message is consumed + expect(store.getState().pluginMessageQueue[pluginKey].length).toBe(1); + expect(getTestPluginState(client).count).toBe(3); + + // Select the current plugin as active, still, messages should end up in the queue + store.dispatch( + selectPlugin({ + selectedPlugin: TestPlugin.id, + selectedApp: client.id, + deepLinkPayload: null, + selectedDevice: device, + }), + ); + expect(store.getState().connections.selectedPlugin).toBe('TestPlugin'); + + sendMessage('inc', {delta: 4}); + client.flushMessageBuffer(); + // should not be processed yet + expect(store.getState().pluginMessageQueue[pluginKey].length).toBe(2); + expect(getTestPluginState(client).count).toBe(3); + + await idler.next(); + expect(store.getState().pluginMessageQueue[pluginKey].length).toBe(0); + expect(getTestPluginState(client).count).toBe(10); + + idler.run(); + await p; +}); + +test('queue - processing can be cancelled', async () => { + const { + client, + device, + store, + sendMessage, + } = await createMockFlipperWithPlugin(TestPlugin); + selectDeviceLogs(store); + + sendMessage('inc', {}); + sendMessage('inc', {delta: 2}); + sendMessage('inc', {delta: 3}); + sendMessage('inc', {delta: 4}); + sendMessage('inc', {delta: 5}); + client.flushMessageBuffer(); + + // process the message + const pluginKey = getPluginKey(client.id, device, TestPlugin.id); + + const idler = new TestIdler(); + + const p = processMessageQueue( + client.sandyPluginStates.get(TestPlugin.id)!, + pluginKey, + store, + undefined, + idler, + ); + + // first message is consumed + await idler.next(); + expect(store.getState().pluginMessageQueue[pluginKey].length).toBe(1); + expect(getTestPluginState(client).count).toBe(10); + + idler.cancel(); + + expect(store.getState().pluginMessageQueue[pluginKey].length).toBe(1); + expect(getTestPluginState(client).count).toBe(10); + await p; +}); + +test('queue - make sure resetting plugin state clears the message queue', async () => { + const { + client, + device, + store, + sendMessage, + } = await createMockFlipperWithPlugin(TestPlugin); + selectDeviceLogs(store); + + sendMessage('inc', {}); + sendMessage('inc', {delta: 2}); + client.flushMessageBuffer(); + + const pluginKey = getPluginKey(client.id, device, TestPlugin.id); + + expect(store.getState().pluginMessageQueue[pluginKey].length).toBe(2); + + store.dispatch({ + type: 'CLEAR_PLUGIN_STATE', + payload: {clientId: client.id, devicePlugins: new Set()}, + }); + + expect(store.getState().pluginMessageQueue[pluginKey]).toBe(undefined); +}); + +test('client - incoming messages are buffered and flushed together', async () => { + class StubDeviceLogs extends FlipperDevicePlugin { + static id = 'DevicePlugin'; + + static supportsDevice() { + return true; + } + + static persistedStateReducer = jest.fn(); + } + + const { + client, + store, + device, + sendMessage, + pluginKey, + } = await createMockFlipperWithPlugin(TestPlugin); + selectDeviceLogs(store); + + store.dispatch(registerPlugins([StubDeviceLogs])); + sendMessage('inc', {}); + sendMessage('inc', {delta: 2}); + sendMessage('inc', {delta: 3}); + + // send a message to device logs + client.onMessage( + JSON.stringify({ + method: 'execute', + params: { + api: 'DevicePlugin', + method: 'log', + params: {line: 'suff'}, + }, + }), + ); + + expect(store.getState().pluginStates).toMatchInlineSnapshot(`Object {}`); + expect(getTestPluginState(client).count).toBe(0); + // the first message is already visible cause of the leading debounce + expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(` + Object { + "TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [ + Object { + "api": "TestPlugin", + "method": "inc", + "params": Object {}, + }, + ], + } + `); + expect(client.messageBuffer).toMatchInlineSnapshot(` + Object { + "TestApp#Android#MockAndroidDevice#serial#DevicePlugin": Object { + "messages": Array [ + Object { + "api": "DevicePlugin", + "method": "log", + "params": Object { + "line": "suff", + }, + }, + ], + "plugin": [Function], + }, + "TestApp#Android#MockAndroidDevice#serial#TestPlugin": Object { + "messages": Array [ + Object { + "api": "TestPlugin", + "method": "inc", + "params": Object { + "delta": 2, + }, + }, + Object { + "api": "TestPlugin", + "method": "inc", + "params": Object { + "delta": 3, + }, + }, + ], + "plugin": undefined, + }, + } + `); + expect(client.messageBuffer[pluginKey].plugin).toBeInstanceOf( + SandyPluginInstance, + ); + + await sleep(500); + expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(` + Object { + "TestApp#Android#MockAndroidDevice#serial#DevicePlugin": Array [ + Object { + "api": "DevicePlugin", + "method": "log", + "params": Object { + "line": "suff", + }, + }, + ], + "TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [ + Object { + "api": "TestPlugin", + "method": "inc", + "params": Object {}, + }, + Object { + "api": "TestPlugin", + "method": "inc", + "params": Object { + "delta": 2, + }, + }, + Object { + "api": "TestPlugin", + "method": "inc", + "params": Object { + "delta": 3, + }, + }, + ], + } + `); + expect(client.messageBuffer).toMatchInlineSnapshot(`Object {}`); + expect(StubDeviceLogs.persistedStateReducer.mock.calls).toMatchInlineSnapshot( + `Array []`, + ); + + // tigger processing the queue + const pluginKeyDevice = getPluginKey(client.id, device, StubDeviceLogs.id); + await processMessageQueue(StubDeviceLogs, pluginKeyDevice, store); + + expect(StubDeviceLogs.persistedStateReducer.mock.calls) + .toMatchInlineSnapshot(` + Array [ + Array [ + Object {}, + "log", + Object { + "line": "suff", + }, + ], + ] + `); + + expect(store.getState().pluginMessageQueue).toMatchInlineSnapshot(` + Object { + "TestApp#Android#MockAndroidDevice#serial#DevicePlugin": Array [], + "TestApp#Android#MockAndroidDevice#serial#TestPlugin": Array [ + Object { + "api": "TestPlugin", + "method": "inc", + "params": Object {}, + }, + Object { + "api": "TestPlugin", + "method": "inc", + "params": Object { + "delta": 2, + }, + }, + Object { + "api": "TestPlugin", + "method": "inc", + "params": Object { + "delta": 3, + }, + }, + ], + } + `); +}); diff --git a/desktop/app/src/utils/messageQueue.tsx b/desktop/app/src/utils/messageQueue.tsx index fa9f6a52a..bbe0dee14 100644 --- a/desktop/app/src/utils/messageQueue.tsx +++ b/desktop/app/src/utils/messageQueue.tsx @@ -7,27 +7,27 @@ * @format */ -import { - PersistedStateReducer, - FlipperDevicePlugin, - isSandyPlugin, -} from '../plugin'; +import {PersistedStateReducer, FlipperDevicePlugin} from '../plugin'; import {State, MiddlewareAPI} from '../reducers/index'; import {setPluginState} from '../reducers/pluginStates'; -import {flipperRecorderAddEvent} from './pluginStateRecorder'; +import { + flipperRecorderAddEvent, + isRecordingEvents, +} from './pluginStateRecorder'; import { clearMessageQueue, queueMessages, Message, + DEFAULT_MAX_QUEUE_SIZE, } from '../reducers/pluginMessageQueue'; import {Idler, BaseIdler} from './Idler'; import {pluginIsStarred, getSelectedPluginKey} from '../reducers/connections'; import {deconstructPluginKey} from './clientUtils'; import {defaultEnabledBackgroundPlugins} from './pluginUtils'; -import {SandyPluginDefinition} from 'flipper-plugin'; +import {SandyPluginInstance} from 'flipper-plugin'; import {addBackgroundStat} from './pluginStats'; -function processMessage( +function processMessageClassic( state: State, pluginKey: string, plugin: { @@ -52,28 +52,57 @@ function processMessage( } } +function processMessagesSandy( + pluginKey: string, + plugin: SandyPluginInstance, + messages: Message[], +) { + const reducerStartTime = Date.now(); + if (isRecordingEvents(pluginKey)) { + messages.forEach((message) => { + flipperRecorderAddEvent(pluginKey, message.method, message.params); + }); + } + try { + plugin.receiveMessages(messages); + addBackgroundStat(plugin.definition.id, Date.now() - reducerStartTime); + } catch (e) { + console.error( + `Failed to process event for plugin ${plugin.definition.id}`, + e, + ); + } +} + export function processMessagesImmediately( store: MiddlewareAPI, pluginKey: string, - plugin: { - defaultPersistedState: any; - id: string; - persistedStateReducer: PersistedStateReducer | null; - }, + plugin: + | { + defaultPersistedState: any; + id: string; + persistedStateReducer: PersistedStateReducer | null; + } + | SandyPluginInstance, messages: Message[], ) { - const persistedState = getCurrentPluginState(store, plugin, pluginKey); - const newPluginState = messages.reduce( - (state, message) => processMessage(state, pluginKey, plugin, message), - persistedState, - ); - if (persistedState !== newPluginState) { - store.dispatch( - setPluginState({ - pluginKey, - state: newPluginState, - }), + if (plugin instanceof SandyPluginInstance) { + processMessagesSandy(pluginKey, plugin, messages); + } else { + const persistedState = getCurrentPluginState(store, plugin, pluginKey); + const newPluginState = messages.reduce( + (state, message) => + processMessageClassic(state, pluginKey, plugin, message), + persistedState, ); + if (persistedState !== newPluginState) { + store.dispatch( + setPluginState({ + pluginKey, + state: newPluginState, + }), + ); + } } } @@ -87,54 +116,61 @@ export function processMessagesLater( persistedStateReducer: PersistedStateReducer | null; maxQueueSize?: number; } - | SandyPluginDefinition, + | SandyPluginInstance, messages: Message[], ) { - // @ts-ignore - if (isSandyPlugin(plugin)) { - // TODO: - throw new Error( - 'Receiving messages is not yet supported for Sandy plugins', - ); - } + const pluginId = + plugin instanceof SandyPluginInstance ? plugin.definition.id : plugin.id; const isSelected = pluginKey === getSelectedPluginKey(store.getState().connections); switch (true) { - case plugin.id === 'Navigation': // Navigation events are always processed, to make sure the navbar stays up to date + case pluginId === 'Navigation': // Navigation events are always processed, to make sure the navbar stays up to date case isSelected && getPendingMessages(store, pluginKey).length === 0: processMessagesImmediately(store, pluginKey, plugin, messages); break; + // TODO: support SandyDevicePlugin T68738317 case isSelected: + case plugin instanceof SandyPluginInstance: case plugin instanceof FlipperDevicePlugin: case (plugin as any).prototype instanceof FlipperDevicePlugin: case pluginIsStarred( store.getState().connections.userStarredPlugins, deconstructPluginKey(pluginKey).client, - plugin.id, + pluginId, ): - store.dispatch(queueMessages(pluginKey, messages, plugin.maxQueueSize)); + store.dispatch( + queueMessages( + pluginKey, + messages, + plugin instanceof SandyPluginInstance + ? DEFAULT_MAX_QUEUE_SIZE + : plugin.maxQueueSize, + ), + ); break; default: // In all other cases, messages will be dropped... - if (!defaultEnabledBackgroundPlugins.includes(plugin.id)) + if (!defaultEnabledBackgroundPlugins.includes(pluginId)) console.warn( - `Received message for disabled plugin ${plugin.id}, dropping..`, + `Received message for disabled plugin ${pluginId}, dropping..`, ); } } export async function processMessageQueue( - plugin: { - defaultPersistedState: any; - id: string; - persistedStateReducer: PersistedStateReducer | null; - }, + plugin: + | { + defaultPersistedState: any; + id: string; + persistedStateReducer: PersistedStateReducer | null; + } + | SandyPluginInstance, pluginKey: string, store: MiddlewareAPI, progressCallback?: (progress: {current: number; total: number}) => void, idler: BaseIdler = new Idler(), ): Promise { - if (!plugin.persistedStateReducer) { + if (!SandyPluginInstance.is(plugin) && !plugin.persistedStateReducer) { return true; } const total = getPendingMessages(store, pluginKey).length; @@ -145,16 +181,24 @@ export async function processMessageQueue( break; } // there are messages to process! lets do so until we have to idle - const persistedState = getCurrentPluginState(store, plugin, pluginKey); + // persistedState is irrelevant for SandyPlugins, as they store state locally + const persistedState = SandyPluginInstance.is(plugin) + ? undefined + : getCurrentPluginState(store, plugin, pluginKey); let offset = 0; let newPluginState = persistedState; do { - newPluginState = processMessage( - newPluginState, - pluginKey, - plugin, - messages[offset], - ); + if (SandyPluginInstance.is(plugin)) { + // Optimization: we could send a batch of messages here + processMessagesSandy(pluginKey, plugin, [messages[offset]]); + } else { + newPluginState = processMessageClassic( + newPluginState, + pluginKey, + plugin, + messages[offset], + ); + } offset++; progress++; @@ -168,7 +212,7 @@ export async function processMessageQueue( // resistent to kicking off this process twice; grabbing, processing messages, saving state is done synchronosly // until the idler has to break store.dispatch(clearMessageQueue(pluginKey, offset)); - if (newPluginState !== persistedState) { + if (!SandyPluginInstance.is(plugin) && newPluginState !== persistedState) { store.dispatch( setPluginState({ pluginKey, diff --git a/desktop/app/src/utils/pluginStateRecorder.tsx b/desktop/app/src/utils/pluginStateRecorder.tsx index 1c57bd819..3b95068c7 100644 --- a/desktop/app/src/utils/pluginStateRecorder.tsx +++ b/desktop/app/src/utils/pluginStateRecorder.tsx @@ -32,6 +32,10 @@ function initialRecordingState(): typeof pluginRecordingState { }; } +export function isRecordingEvents(pluginKey: string) { + return pluginRecordingState.recording === pluginKey; +} + export function flipperRecorderAddEvent( pluginKey: string, method: string, diff --git a/desktop/flipper-plugin/src/__tests__/TestPlugin.tsx b/desktop/flipper-plugin/src/__tests__/TestPlugin.tsx index 82f4592b8..4ef442af8 100644 --- a/desktop/flipper-plugin/src/__tests__/TestPlugin.tsx +++ b/desktop/flipper-plugin/src/__tests__/TestPlugin.tsx @@ -24,6 +24,9 @@ export function plugin(client: FlipperClient) { const connectStub = jest.fn(); const disconnectStub = jest.fn(); const destroyStub = jest.fn(); + const state = { + count: 0, + }; // TODO: add tests for sending and receiving data T68683442 // including typescript assertions @@ -31,12 +34,23 @@ export function plugin(client: FlipperClient) { client.onConnect(connectStub); client.onDisconnect(disconnectStub); client.onDestroy(destroyStub); + client.onMessage('inc', ({delta}) => { + state.count += delta; + }); 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'}); + // @ts-expect-error + client.onMessage('stuff', (_params) => { + // noop + }); + client.onMessage('inc', (params) => { + // @ts-expect-error + params.bla; + }); } async function getCurrentState() { @@ -48,6 +62,7 @@ export function plugin(client: FlipperClient) { destroyStub, disconnectStub, getCurrentState, + state, }; } diff --git a/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx b/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx index e6990b1a1..705144680 100644 --- a/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx +++ b/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx @@ -93,3 +93,11 @@ test('a plugin cannot send messages after being disconnected', async () => { } expect(threw).toBeTruthy(); }); + +test('a plugin can receive messages', async () => { + const {instance, sendEvent} = TestUtils.startPlugin(testPlugin); + expect(instance.state.count).toBe(0); + + sendEvent('inc', {delta: 2}); + expect(instance.state.count).toBe(2); +}); diff --git a/desktop/flipper-plugin/src/plugin/Plugin.tsx b/desktop/flipper-plugin/src/plugin/Plugin.tsx index 70ebb0641..2c0c29183 100644 --- a/desktop/flipper-plugin/src/plugin/Plugin.tsx +++ b/desktop/flipper-plugin/src/plugin/Plugin.tsx @@ -13,6 +13,11 @@ import {EventEmitter} from 'events'; type EventsContract = Record; type MethodsContract = Record Promise>; +type Message = { + method: string; + params?: any; +}; + /** * API available to a plugin factory */ @@ -47,6 +52,17 @@ export interface FlipperClient< method: Method, params: Parameters[0], ): ReturnType; + + /** + * Subscribe to a specific event arriving from the device. + * + * Messages can only arrive if the plugin is enabled and connected. + * For background plugins messages will be batched and arrive the next time the plugin is connected. + */ + onMessage( + event: Event, + callback: (params: Events[Event]) => void, + ): void; } /** @@ -73,6 +89,10 @@ export type FlipperPluginFactory< export type FlipperPluginComponent = React.FC<{}>; export class SandyPluginInstance { + static is(thing: any): thing is SandyPluginInstance { + return thing instanceof SandyPluginInstance; + } + /** base client provided by Flipper */ realClient: RealFlipperClient; /** client that is bound to this instance */ @@ -111,6 +131,9 @@ export class SandyPluginInstance { params as any, ); }, + onMessage: (event, callback) => { + this.events.on('event-' + event, callback); + }, }; this.instanceApi = definition.module.plugin(this.client); } @@ -156,6 +179,12 @@ export class SandyPluginInstance { this.destroyed = true; } + receiveMessages(messages: Message[]) { + messages.forEach((message) => { + this.events.emit('event-' + message.method, message.params); + }); + } + toJSON() { this.assertNotDestroyed(); // TODO: T68683449 diff --git a/desktop/flipper-plugin/src/test-utils/test-utils.tsx b/desktop/flipper-plugin/src/test-utils/test-utils.tsx index 1af58a907..9620fb3a9 100644 --- a/desktop/flipper-plugin/src/test-utils/test-utils.tsx +++ b/desktop/flipper-plugin/src/test-utils/test-utils.tsx @@ -36,12 +36,19 @@ interface StartPluginOptions { type ExtractClientType> = Parameters< Module['plugin'] >[0]; + type ExtractMethodsType< Module extends FlipperPluginModule > = ExtractClientType extends FlipperClient ? Methods : never; +type ExtractEventsType< + Module extends FlipperPluginModule +> = ExtractClientType extends FlipperClient + ? Events + : never; + interface StartPluginResult> { /** * the instantiated plugin for this test @@ -73,6 +80,24 @@ interface StartPluginResult> { params: Parameters[Method]>[0], ) => ReturnType[Method]> >; + /** + * Send event to the plugin + */ + sendEvent>( + event: Event, + params: ExtractEventsType[Event], + ): void; + /** + * Send events to the plugin + * The structure used here reflects events that can be recorded + * with the pluginRecorder + */ + sendEvents( + events: { + method: keyof ExtractEventsType; + params: any; // afaik we can't type this :-( + }[], + ): void; } export function startPlugin>( @@ -107,16 +132,28 @@ export function startPlugin>( // we start connected pluginInstance.connect(); - return { + const res: StartPluginResult = { module, instance: pluginInstance.instanceApi, connect: () => pluginInstance.connect(), disconnect: () => pluginInstance.disconnect(), destroy: () => pluginInstance.destroy(), onSend: sendStub, - // @ts-ignore - _backingInstance: pluginInstance, + sendEvent: (event, params) => { + res.sendEvents([ + { + method: event, + params, + }, + ]); + }, + sendEvents: (messages) => { + pluginInstance.receiveMessages(messages as any); + }, }; + // @ts-ignore + res._backingInstance = pluginInstance; + return res; } export function renderPlugin>(