/** * 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 React from 'react'; import produce from 'immer'; import {FlipperPlugin} from '../plugin'; import {renderMockFlipperWithPlugin} from '../test-utils/createMockFlipperWithPlugin'; import { _SandyPluginDefinition, PluginClient, TestUtils, usePlugin, createState, DevicePluginClient, DeviceLogEntry, useValue, } from 'flipper-plugin'; import {selectPlugin, starPlugin} from '../reducers/connections'; import {updateSettings} from '../reducers/settings'; interface PersistedState { count: 1; } class TestPlugin extends FlipperPlugin { static id = 'TestPlugin'; static defaultPersistedState = { count: 0, }; static persistedStateReducer( persistedState: PersistedState, method: string, payload: {delta?: number}, ) { return produce(persistedState, (draft) => { if (method === 'inc') { draft.count += payload?.delta || 1; } }); } render() { return (

Hello:{' '} {this.props.persistedState.count}

); } } test('Plugin container can render plugin and receive updates', async () => { const {renderer, sendMessage, act} = await renderMockFlipperWithPlugin( TestPlugin, ); expect(renderer.baseElement).toMatchInlineSnapshot(`

Hello: 0

`); act(() => { sendMessage('inc', {delta: 2}); }); expect((await renderer.findByTestId('counter')).textContent).toBe('2'); }); test('PluginContainer can render Sandy plugins', async () => { let renders = 0; function MySandyPlugin() { renders++; const sandyApi = usePlugin(plugin); const count = useValue(sandyApi.count); expect(Object.keys(sandyApi)).toEqual([ 'connectedStub', 'disconnectedStub', 'activatedStub', 'deactivatedStub', 'count', ]); expect(() => { // eslint-disable-next-line usePlugin(function bla() { return {}; }); }).toThrowError(/didn't match the type of the requested plugin/); return
Hello from Sandy{count}
; } type Events = { inc: {delta: number}; }; const plugin = (client: PluginClient) => { const count = createState(0); const connectedStub = jest.fn(); const disconnectedStub = jest.fn(); const activatedStub = jest.fn(); const deactivatedStub = jest.fn(); client.onConnect(connectedStub); client.onDisconnect(disconnectedStub); client.onActivate(activatedStub); client.onDeactivate(deactivatedStub); client.onMessage('inc', ({delta}) => { count.set(count.get() + delta); }); return { connectedStub, disconnectedStub, activatedStub, deactivatedStub, count, }; }; const definition = new _SandyPluginDefinition( TestUtils.createMockPluginDetails(), { plugin, Component: MySandyPlugin, }, ); const { renderer, act, sendMessage, client, store, } = await renderMockFlipperWithPlugin(definition); expect(client.rawSend).toBeCalledWith('init', {plugin: 'TestPlugin'}); expect(renderer.baseElement).toMatchInlineSnapshot(`
Hello from Sandy 0
`); expect(renders).toBe(1); // sending irrelevant message does not cause a re-render act(() => { sendMessage('oops', {delta: 2}); }); expect(renders).toBe(1); // sending a new message cause a re-render act(() => { sendMessage('inc', {delta: 2}); }); expect(renders).toBe(2); expect(renderer.baseElement).toMatchInlineSnapshot(`
Hello from Sandy 2
`); // make sure the plugin gets connected const pluginInstance: ReturnType< typeof plugin > = client.sandyPluginStates.get(definition.id)!.instanceApi; expect(pluginInstance.connectedStub).toBeCalledTimes(1); expect(pluginInstance.disconnectedStub).toBeCalledTimes(0); expect(pluginInstance.activatedStub).toBeCalledTimes(1); expect(pluginInstance.deactivatedStub).toBeCalledTimes(0); // select non existing plugin act(() => { store.dispatch( selectPlugin({ selectedPlugin: 'Logs', deepLinkPayload: null, }), ); }); expect(client.rawSend).toBeCalledWith('deinit', {plugin: 'TestPlugin'}); expect(renderer.baseElement).toMatchInlineSnapshot(`
`); expect(pluginInstance.connectedStub).toBeCalledTimes(1); expect(pluginInstance.disconnectedStub).toBeCalledTimes(1); expect(pluginInstance.activatedStub).toBeCalledTimes(1); expect(pluginInstance.deactivatedStub).toBeCalledTimes(1); // send some messages while in BG act(() => { sendMessage('inc', {delta: 3}); sendMessage('inc', {delta: 4}); }); expect(renders).toBe(2); expect(pluginInstance.count.get()).toBe(2); // go back act(() => { store.dispatch( selectPlugin({ selectedPlugin: definition.id, deepLinkPayload: null, }), ); }); // Might be needed, but seems to work reliable without: await sleep(1000); expect(renderer.baseElement).toMatchInlineSnapshot(`
Hello from Sandy 9
`); expect(pluginInstance.count.get()).toBe(9); expect(pluginInstance.connectedStub).toBeCalledTimes(2); expect(pluginInstance.disconnectedStub).toBeCalledTimes(1); expect(pluginInstance.activatedStub).toBeCalledTimes(2); expect(pluginInstance.deactivatedStub).toBeCalledTimes(1); expect(client.rawSend).toBeCalledWith('init', {plugin: 'TestPlugin'}); // disable act(() => { store.dispatch( starPlugin({ plugin: definition, selectedApp: client.query.app, }), ); }); expect(pluginInstance.connectedStub).toBeCalledTimes(2); expect(pluginInstance.disconnectedStub).toBeCalledTimes(2); expect(pluginInstance.activatedStub).toBeCalledTimes(2); expect(pluginInstance.deactivatedStub).toBeCalledTimes(2); expect(client.rawSend).toBeCalledWith('deinit', {plugin: 'TestPlugin'}); // re-enable act(() => { store.dispatch( starPlugin({ plugin: definition, selectedApp: client.query.app, }), ); }); // note: this is the old pluginInstance, so that one is not reconnected! expect(pluginInstance.connectedStub).toBeCalledTimes(2); expect(pluginInstance.disconnectedStub).toBeCalledTimes(2); expect(pluginInstance.activatedStub).toBeCalledTimes(2); expect(pluginInstance.deactivatedStub).toBeCalledTimes(2); expect( client.sandyPluginStates.get('TestPlugin')!.instanceApi.connectedStub, ).toBeCalledTimes(1); expect(client.rawSend).toBeCalledWith('init', {plugin: 'TestPlugin'}); expect( client.sandyPluginStates.get('TestPlugin')!.instanceApi.count.get(), ).toBe(0); }); test('PluginContainer triggers correct lifecycles for background plugin', async () => { function MySandyPlugin() { return
Hello from Sandy
; } const plugin = (client: PluginClient) => { const connectedStub = jest.fn(); const disconnectedStub = jest.fn(); const activatedStub = jest.fn(); const deactivatedStub = jest.fn(); client.onConnect(connectedStub); client.onDisconnect(disconnectedStub); client.onActivate(activatedStub); client.onDeactivate(deactivatedStub); return {connectedStub, disconnectedStub, activatedStub, deactivatedStub}; }; const definition = new _SandyPluginDefinition( TestUtils.createMockPluginDetails(), { plugin, Component: MySandyPlugin, }, ); const {act, client, store} = await renderMockFlipperWithPlugin(definition, { onSend(method) { if (method === 'getBackgroundPlugins') { return {plugins: [definition.id]}; } }, }); expect(client.rawSend).toBeCalledWith('init', {plugin: 'TestPlugin'}); (client.rawSend as jest.Mock).mockClear(); // make sure the plugin gets connected const pluginInstance: ReturnType< typeof plugin > = client.sandyPluginStates.get(definition.id)!.instanceApi; expect(pluginInstance.connectedStub).toBeCalledTimes(1); expect(pluginInstance.disconnectedStub).toBeCalledTimes(0); expect(pluginInstance.activatedStub).toBeCalledTimes(1); expect(pluginInstance.deactivatedStub).toBeCalledTimes(0); // select non existing plugin act(() => { store.dispatch( selectPlugin({ selectedPlugin: 'Logs', deepLinkPayload: null, }), ); }); // bg plugin! expect(client.rawSend).not.toBeCalled(); (client.rawSend as jest.Mock).mockClear(); expect(pluginInstance.connectedStub).toBeCalledTimes(1); expect(pluginInstance.disconnectedStub).toBeCalledTimes(0); expect(pluginInstance.activatedStub).toBeCalledTimes(1); expect(pluginInstance.deactivatedStub).toBeCalledTimes(1); // go back act(() => { store.dispatch( selectPlugin({ selectedPlugin: definition.id, deepLinkPayload: null, }), ); }); expect(pluginInstance.connectedStub).toBeCalledTimes(1); expect(pluginInstance.disconnectedStub).toBeCalledTimes(0); expect(pluginInstance.activatedStub).toBeCalledTimes(2); expect(pluginInstance.deactivatedStub).toBeCalledTimes(1); expect(client.rawSend).not.toBeCalled(); (client.rawSend as jest.Mock).mockClear(); // disable act(() => { store.dispatch( starPlugin({ plugin: definition, selectedApp: client.query.app, }), ); }); expect(pluginInstance.connectedStub).toBeCalledTimes(1); expect(pluginInstance.disconnectedStub).toBeCalledTimes(1); expect(pluginInstance.activatedStub).toBeCalledTimes(2); expect(pluginInstance.deactivatedStub).toBeCalledTimes(2); expect(client.rawSend).toBeCalledWith('deinit', {plugin: 'TestPlugin'}); (client.rawSend as jest.Mock).mockClear(); // select something else act(() => { store.dispatch( selectPlugin({ selectedPlugin: 'Logs', deepLinkPayload: null, }), ); }); // re-enable act(() => { store.dispatch( starPlugin({ plugin: definition, selectedApp: client.query.app, }), ); }); // note: this is the old pluginInstance, so that one is not reconnected! expect(pluginInstance.connectedStub).toBeCalledTimes(1); expect(pluginInstance.disconnectedStub).toBeCalledTimes(1); expect(pluginInstance.activatedStub).toBeCalledTimes(2); expect(pluginInstance.deactivatedStub).toBeCalledTimes(2); const newPluginInstance: ReturnType< typeof plugin > = client.sandyPluginStates.get('TestPlugin')!.instanceApi; expect(newPluginInstance.connectedStub).toBeCalledTimes(1); expect(newPluginInstance.disconnectedStub).toBeCalledTimes(0); expect(newPluginInstance.activatedStub).toBeCalledTimes(0); expect(newPluginInstance.deactivatedStub).toBeCalledTimes(0); expect(client.rawSend).toBeCalledWith('init', {plugin: 'TestPlugin'}); (client.rawSend as jest.Mock).mockClear(); // select new plugin act(() => { store.dispatch( selectPlugin({ selectedPlugin: definition.id, deepLinkPayload: null, }), ); }); expect(newPluginInstance.connectedStub).toBeCalledTimes(1); expect(newPluginInstance.disconnectedStub).toBeCalledTimes(0); expect(newPluginInstance.activatedStub).toBeCalledTimes(1); expect(newPluginInstance.deactivatedStub).toBeCalledTimes(0); expect(client.rawSend).not.toBeCalled(); (client.rawSend as jest.Mock).mockClear(); }); test('PluginContainer + Sandy plugin supports deeplink', async () => { const linksSeen: any[] = []; const plugin = (client: PluginClient) => { const linkState = createState(''); client.onDeepLink((link) => { linksSeen.push(link); linkState.set(String(link)); }); return { linkState, }; }; const definition = new _SandyPluginDefinition( TestUtils.createMockPluginDetails(), { plugin, Component() { const instance = usePlugin(plugin); const linkState = useValue(instance.linkState); return

hello {linkState || 'world'}

; }, }, ); const {renderer, act, client, store} = await renderMockFlipperWithPlugin( definition, ); expect(client.rawSend).toBeCalledWith('init', {plugin: 'TestPlugin'}); expect(linksSeen).toEqual([]); expect(renderer.baseElement).toMatchInlineSnapshot(`

hello world

`); act(() => { store.dispatch( selectPlugin({ selectedPlugin: definition.id, deepLinkPayload: 'universe!', selectedApp: client.query.app, }), ); }); expect(linksSeen).toEqual(['universe!']); expect(renderer.baseElement).toMatchInlineSnapshot(`

hello universe!

`); // Sending same link doesn't trigger again act(() => { store.dispatch( selectPlugin({ selectedPlugin: definition.id, deepLinkPayload: 'universe!', selectedApp: client.query.app, }), ); }); expect(linksSeen).toEqual(['universe!']); // ...nor does a random other store update that does trigger a plugin container render act(() => { store.dispatch( updateSettings({ ...store.getState().settingsState, }), ); }); expect(linksSeen).toEqual(['universe!']); // Different link does trigger again act(() => { store.dispatch( selectPlugin({ selectedPlugin: definition.id, deepLinkPayload: 'london!', selectedApp: client.query.app, }), ); }); expect(linksSeen).toEqual(['universe!', 'london!']); // and same link does trigger if something else was selected in the mean time act(() => { store.dispatch( selectPlugin({ selectedPlugin: 'Logs', deepLinkPayload: 'london!', selectedApp: client.query.app, }), ); }); act(() => { store.dispatch( selectPlugin({ selectedPlugin: definition.id, deepLinkPayload: 'london!', selectedApp: client.query.app, }), ); }); expect(linksSeen).toEqual(['universe!', 'london!', 'london!']); }); test('PluginContainer can render Sandy device plugins', async () => { let renders = 0; function MySandyPlugin() { renders++; const sandyApi = usePlugin(devicePlugin); expect(Object.keys(sandyApi)).toEqual([ 'activatedStub', 'deactivatedStub', 'lastLogMessage', ]); expect(() => { // eslint-disable-next-line usePlugin(function bla() { return {}; }); }).toThrowError(/didn't match the type of the requested plugin/); const lastLogMessage = useValue(sandyApi.lastLogMessage); return
Hello from Sandy: {lastLogMessage?.message}
; } const devicePlugin = (client: DevicePluginClient) => { const lastLogMessage = createState(undefined); const activatedStub = jest.fn(); const deactivatedStub = jest.fn(); client.onActivate(activatedStub); client.onDeactivate(deactivatedStub); client.device.onLogEntry((e) => { lastLogMessage.set(e); }); return {activatedStub, deactivatedStub, lastLogMessage}; }; const definition = new _SandyPluginDefinition( TestUtils.createMockPluginDetails(), { supportsDevice: () => true, devicePlugin, Component: MySandyPlugin, }, ); const {renderer, act, store, device} = await renderMockFlipperWithPlugin( definition, ); expect(renderer.baseElement).toMatchInlineSnapshot(`
Hello from Sandy:
`); expect(renders).toBe(1); act(() => { device.addLogEntry({ date: new Date(), message: 'helleuh', pid: 0, tid: 0, type: 'info', tag: 'test', }); }); expect(renders).toBe(2); expect(renderer.baseElement).toMatchInlineSnapshot(`
Hello from Sandy: helleuh
`); // make sure the plugin gets connected const pluginInstance: ReturnType< typeof devicePlugin > = device.sandyPluginStates.get(definition.id)!.instanceApi; expect(pluginInstance.activatedStub).toBeCalledTimes(1); expect(pluginInstance.deactivatedStub).toBeCalledTimes(0); // select non existing plugin act(() => { store.dispatch( selectPlugin({ selectedPlugin: 'Logs', deepLinkPayload: null, }), ); }); expect(renderer.baseElement).toMatchInlineSnapshot(`
`); expect(pluginInstance.activatedStub).toBeCalledTimes(1); expect(pluginInstance.deactivatedStub).toBeCalledTimes(1); // go back act(() => { store.dispatch( selectPlugin({ selectedPlugin: definition.id, deepLinkPayload: null, }), ); }); expect(pluginInstance.activatedStub).toBeCalledTimes(2); expect(pluginInstance.deactivatedStub).toBeCalledTimes(1); }); test('PluginContainer + Sandy device plugin supports deeplink', async () => { const linksSeen: any[] = []; const devicePlugin = (client: DevicePluginClient) => { const linkState = createState(''); client.onDeepLink((link) => { linksSeen.push(link); linkState.set(String(link)); }); return { linkState, }; }; const definition = new _SandyPluginDefinition( TestUtils.createMockPluginDetails(), { devicePlugin, supportsDevice: () => true, Component() { const instance = usePlugin(devicePlugin); const linkState = useValue(instance.linkState); return

hello {linkState || 'world'}

; }, }, ); const {renderer, act, store} = await renderMockFlipperWithPlugin(definition); const theUniverse = { thisIs: 'theUniverse', toString() { return JSON.stringify({...this}); }, }; expect(linksSeen).toEqual([]); expect(renderer.baseElement).toMatchInlineSnapshot(`

hello world

`); act(() => { store.dispatch( selectPlugin({ selectedPlugin: definition.id, deepLinkPayload: theUniverse, selectedApp: null, }), ); }); expect(linksSeen).toEqual([theUniverse]); expect(renderer.baseElement).toMatchInlineSnapshot(`

hello {"thisIs":"theUniverse"}

`); // Sending same link doesn't trigger again act(() => { store.dispatch( selectPlugin({ selectedPlugin: definition.id, deepLinkPayload: theUniverse, selectedApp: null, }), ); }); expect(linksSeen).toEqual([theUniverse]); // ...nor does a random other store update that does trigger a plugin container render act(() => { store.dispatch( updateSettings({ ...store.getState().settingsState, }), ); }); expect(linksSeen).toEqual([theUniverse]); // Different link does trigger again act(() => { store.dispatch( selectPlugin({ selectedPlugin: definition.id, deepLinkPayload: 'london!', selectedApp: null, }), ); }); expect(linksSeen).toEqual([theUniverse, 'london!']); // and same link does trigger if something else was selected in the mean time act(() => { store.dispatch( selectPlugin({ selectedPlugin: 'Logs', deepLinkPayload: 'london!', selectedApp: null, }), ); }); act(() => { store.dispatch( selectPlugin({ selectedPlugin: definition.id, deepLinkPayload: 'london!', selectedApp: null, }), ); }); expect(linksSeen).toEqual([theUniverse, 'london!', 'london!']); }); test('Sandy plugins support isPluginSupported + selectPlugin', async () => { let renders = 0; const linksSeen: any[] = []; function MySandyPlugin() { renders++; return

Plugin1

; } const plugin = (client: PluginClient) => { const activatedStub = jest.fn(); const deactivatedStub = jest.fn(); client.onDeepLink((link) => { linksSeen.push(link); }); client.onActivate(activatedStub); client.onDeactivate(deactivatedStub); return { activatedStub, deactivatedStub, isPluginAvailable: client.isPluginAvailable, selectPlugin: client.selectPlugin, }; }; const definition = new _SandyPluginDefinition( TestUtils.createMockPluginDetails({id: 'base'}), { plugin, Component: MySandyPlugin, }, ); const definition2 = new _SandyPluginDefinition( TestUtils.createMockPluginDetails({id: 'other'}), { plugin() { return {}; }, Component() { return

Plugin2

; }, }, ); const definition3 = new _SandyPluginDefinition( TestUtils.createMockPluginDetails({id: 'device'}), { supportsDevice() { return true; }, devicePlugin() { return {}; }, Component() { return

Plugin3

; }, }, ); const {renderer, client, store} = await renderMockFlipperWithPlugin( definition, { additionalPlugins: [definition2, definition3], dontEnableAdditionalPlugins: true, }, ); expect(renderer.baseElement.querySelector('h1')).toMatchInlineSnapshot(`

Plugin1

`); expect(renders).toBe(1); const pluginInstance: ReturnType< typeof plugin > = client.sandyPluginStates.get(definition.id)!.instanceApi; expect(pluginInstance.isPluginAvailable(definition.id)).toBeTruthy(); expect(pluginInstance.isPluginAvailable('nonsense')).toBeFalsy(); expect(pluginInstance.isPluginAvailable(definition2.id)).toBeFalsy(); // not enabled yet expect(pluginInstance.isPluginAvailable(definition3.id)).toBeTruthy(); expect(pluginInstance.activatedStub).toBeCalledTimes(1); expect(pluginInstance.deactivatedStub).toBeCalledTimes(0); expect(linksSeen).toEqual([]); // open a device plugin pluginInstance.selectPlugin(definition3.id); expect(store.getState().connections.selectedPlugin).toBe(definition3.id); expect(renderer.baseElement.querySelector('h1')).toMatchInlineSnapshot(`

Plugin3

`); expect(pluginInstance.deactivatedStub).toBeCalledTimes(1); // go back by opening own plugin again (funny, but why not) pluginInstance.selectPlugin(definition.id, 'data'); expect(store.getState().connections.selectedPlugin).toBe(definition.id); expect(pluginInstance.activatedStub).toBeCalledTimes(2); expect(renderer.baseElement.querySelector('h1')).toMatchInlineSnapshot(`

Plugin1

`); expect(linksSeen).toEqual(['data']); // try to go to plugin 2, fails (not starred, so no-op) pluginInstance.selectPlugin(definition2.id); expect(store.getState().connections.selectedPlugin).toBe(definition.id); // star plugin 2 and navigate to plugin 2 store.dispatch( starPlugin({ plugin: definition2, selectedApp: client.query.app, }), ); pluginInstance.selectPlugin(definition2.id); expect(store.getState().connections.selectedPlugin).toBe(definition2.id); expect(pluginInstance.deactivatedStub).toBeCalledTimes(2); expect(renderer.baseElement.querySelector('h1')).toMatchInlineSnapshot(`

Plugin2

`); expect(renders).toBe(2); });