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',
);