diff --git a/desktop/app/src/dispatcher/__tests__/plugins.node.tsx b/desktop/app/src/dispatcher/__tests__/plugins.node.tsx index 59fbb6a67..6ca8795a8 100644 --- a/desktop/app/src/dispatcher/__tests__/plugins.node.tsx +++ b/desktop/app/src/dispatcher/__tests__/plugins.node.tsx @@ -265,6 +265,7 @@ test('requirePlugin loads valid Sandy plugin', () => { title: 'Sample', version: '1.0.0', }); + expect(plugin.isDevicePlugin).toBe(false); expect(typeof plugin.module.Component).toBe('function'); expect(plugin.module.Component.displayName).toBe('FlipperPlugin(Sample)'); expect(typeof plugin.asPluginModule().plugin).toBe('function'); @@ -286,3 +287,38 @@ test('requirePlugin errors on invalid Sandy plugin', () => { `"Flipper plugin 'Sample' should export named function called 'plugin'"`, ); }); + +test('requirePlugin loads valid Sandy Device plugin', () => { + const name = 'pluginID'; + const requireFn = requirePlugin([], {}, require); + const plugin = requireFn({ + ...samplePluginDetails, + name, + entry: path.join( + __dirname, + '../../../../flipper-plugin/src/__tests__/DeviceTestPlugin', + ), + version: '1.0.0', + flipperSDKVersion: '0.0.0', + }) as SandyPluginDefinition; + expect(plugin).not.toBeNull(); + // @ts-ignore + expect(plugin).toBeInstanceOf(SandyPluginDefinition); + expect(plugin.id).toBe('Sample'); + expect(plugin.details).toMatchObject({ + flipperSDKVersion: '0.0.0', + id: 'Sample', + isDefault: false, + main: 'dist/bundle.js', + name: 'pluginID', + source: 'src/index.js', + specVersion: 2, + title: 'Sample', + version: '1.0.0', + }); + expect(plugin.isDevicePlugin).toBe(true); + expect(typeof plugin.module.Component).toBe('function'); + expect(plugin.module.Component.displayName).toBe('FlipperPlugin(Sample)'); + expect(typeof plugin.asDevicePluginModule().devicePlugin).toBe('function'); + expect(typeof plugin.asDevicePluginModule().supportsDevice).toBe('function'); +}); diff --git a/desktop/flipper-plugin/src/__tests__/DeviceTestPlugin.tsx b/desktop/flipper-plugin/src/__tests__/DeviceTestPlugin.tsx new file mode 100644 index 000000000..8e6cb32cb --- /dev/null +++ b/desktop/flipper-plugin/src/__tests__/DeviceTestPlugin.tsx @@ -0,0 +1,60 @@ +/** + * 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 * as React from 'react'; +import {DevicePluginClient, Device} from '../plugin/DevicePlugin'; +import {usePlugin} from '../plugin/PluginContext'; +import {createState, useValue} from '../state/atom'; + +export function supportsDevice(_device: Device) { + return true; +} + +export function devicePlugin(client: DevicePluginClient) { + const logStub = jest.fn(); + const activateStub = jest.fn(); + const deactivateStub = jest.fn(); + const destroyStub = jest.fn(); + const state = createState( + { + count: 0, + }, + { + persist: 'counter', + }, + ); + + client.device.onLogEntry((entry) => { + state.update((d) => { + d.count++; + }); + logStub(entry); + }); + client.onActivate(activateStub); + client.onDeactivate(deactivateStub); + client.onDestroy(destroyStub); + + return { + logStub, + activateStub, + deactivateStub, + destroyStub, + state, + }; +} + +export function Component() { + const api = usePlugin(devicePlugin); + const count = useValue(api.state).count; + + // @ts-expect-error + api.bla; + + return

Hi from test plugin {count}

; +} diff --git a/desktop/flipper-plugin/src/__tests__/test-utils-device.node.tsx b/desktop/flipper-plugin/src/__tests__/test-utils-device.node.tsx new file mode 100644 index 000000000..c51eae2b7 --- /dev/null +++ b/desktop/flipper-plugin/src/__tests__/test-utils-device.node.tsx @@ -0,0 +1,151 @@ +/** + * 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 * as TestUtils from '../test-utils/test-utils'; +import * as testPlugin from './DeviceTestPlugin'; +import {createState} from '../state/atom'; + +const testLogMessage = { + date: new Date(), + message: 'test', + pid: 0, + tid: 0, + tag: 'bla', + type: 'warn', + app: 'TestApp', +} as const; + +test('it can start a device plugin and listen to lifecycle events', () => { + const {instance, ...p} = TestUtils.startDevicePlugin(testPlugin); + + // @ts-expect-error + p.bla; + // @ts-expect-error + instance.bla; + + // startPlugin starts activated + expect(instance.activateStub).toBeCalledTimes(1); + expect(instance.deactivateStub).toBeCalledTimes(0); + expect(instance.destroyStub).toBeCalledTimes(0); + + // calling activate is a noop + p.activate(); + expect(instance.activateStub).toBeCalledTimes(1); + expect(instance.deactivateStub).toBeCalledTimes(0); + expect(instance.destroyStub).toBeCalledTimes(0); + + p.sendLogEntry(testLogMessage); + expect(instance.logStub).toBeCalledWith(testLogMessage); + expect(instance.state.get().count).toBe(1); + + expect(instance.activateStub).toBeCalledTimes(1); + expect(instance.deactivateStub).toBeCalledTimes(0); + expect(instance.destroyStub).toBeCalledTimes(0); + + p.deactivate(); + p.activate(); + + expect(instance.activateStub).toBeCalledTimes(2); + expect(instance.deactivateStub).toBeCalledTimes(1); + expect(instance.destroyStub).toBeCalledTimes(0); + + p.destroy(); + expect(instance.activateStub).toBeCalledTimes(2); + expect(instance.deactivateStub).toBeCalledTimes(2); + expect(instance.destroyStub).toBeCalledTimes(1); + + // cannot interact with destroyed plugin + expect(() => { + p.activate(); + }).toThrowErrorMatchingInlineSnapshot(`"Plugin has been destroyed already"`); +}); + +test('it can render a device plugin', () => { + const {renderer, instance, sendLogEntry} = TestUtils.renderDevicePlugin( + testPlugin, + ); + + expect(renderer.baseElement).toMatchInlineSnapshot(` + +
+

+ Hi from test plugin + 0 +

+
+ + `); + + sendLogEntry(testLogMessage); + + expect(renderer.baseElement).toMatchInlineSnapshot(` + +
+

+ Hi from test plugin + 1 +

+
+ + `); + + // @ts-ignore + expect(instance.state.listeners.length).toBe(1); + renderer.unmount(); + // @ts-ignore + expect(instance.state.listeners.length).toBe(0); +}); + +test('device plugins support non-serializable state', async () => { + const {exportState} = TestUtils.startPlugin({ + plugin() { + const field1 = createState(true); + const field2 = createState( + { + test: 3, + }, + { + persist: 'field2', + }, + ); + return { + field1, + field2, + }; + }, + Component() { + return null; + }, + }); + // states are serialized in creation order + expect(exportState()).toEqual({field2: {test: 3}}); +}); + +test('device plugins support restoring state', async () => { + const {exportState} = TestUtils.startPlugin( + { + plugin() { + const field1 = createState(1, {persist: 'field1'}); + const field2 = createState(2); + const field3 = createState(3, {persist: 'field3'}); + expect(field1.get()).toBe('a'); + expect(field2.get()).toBe(2); + expect(field3.get()).toBe('b'); + return {}; + }, + Component() { + return null; + }, + }, + { + initialState: {field1: 'a', field3: 'b'}, + }, + ); + expect(exportState()).toEqual({field1: 'a', field3: 'b'}); +}); diff --git a/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx b/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx index 02b87bc3f..0e379e0d3 100644 --- a/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx +++ b/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx @@ -137,28 +137,9 @@ export class SandyDevicePluginInstance { this.activated = true; this.events.emit('activate'); } - // TODO: - // const pluginId = this.definition.id; - // if (!this.realClient.isBackgroundPlugin(pluginId)) { - // this.realClient.initPlugin(pluginId); // will call connect() if needed - // } } - // the plugin is deselected in the UI deactivate() { - // TODO: - // if (this.destroyed) { - // // this can happen if the plugin is disabled while active in the UI. - // // In that case deinit & destroy is already triggered from the STAR_PLUGIN action - // return; - // } - // const pluginId = this.definition.id; - // if (!this.realClient.isBackgroundPlugin(pluginId)) { - // this.realClient.deinitPlugin(pluginId); - // } - } - - disconnect() { this.assertNotDestroyed(); if (this.activated) { this.activated = false; @@ -168,10 +149,7 @@ export class SandyDevicePluginInstance { destroy() { this.assertNotDestroyed(); - // TODO: - // if (this.activated) { - // this.realClient.deinitPlugin(this.definition.id); - // } + this.deactivate(); this.events.emit('destroy'); this.destroyed = true; } diff --git a/desktop/flipper-plugin/src/test-utils/test-utils.tsx b/desktop/flipper-plugin/src/test-utils/test-utils.tsx index b68bc41b2..47256ece2 100644 --- a/desktop/flipper-plugin/src/test-utils/test-utils.tsx +++ b/desktop/flipper-plugin/src/test-utils/test-utils.tsx @@ -251,7 +251,7 @@ export function startDevicePlugin( createMockPluginDetails(), module, ); - if (definition.isDevicePlugin) { + if (!definition.isDevicePlugin) { throw new Error( 'Use `startPlugin` or `renderPlugin` to test non-device plugins', );