From 91ed4e31c0fccc8e7a234b17b469aab7491ace4c Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Tue, 4 Aug 2020 07:05:57 -0700 Subject: [PATCH] Introduce DevicePlugin APIs Summary: This stack introduces Sandy device plugins, they are quite similar to normal plugins, but, a devicePlugin module is organized as ``` export function supportsDevice(device): boolean export function devicePlugin(devicePluginClient) export function Component ``` Device plugins get access to the device meta data and can subscribe to the `onLogEntry` callback and `onDestroy` lifecycle. They will be able to store state just as normal plugins, but can't send or receive methods, so devicePluginClient is a bit limited. This diff only sets up most of the new data structures, and makes sure everything still compiles and no existing tests fail. To prevent this diff from becoming to big, actually loading, rendering and testing device plugins will be done in next diffs Please take a critical look at the api proposed and the (especially) the public names used :) Reviewed By: passy, nikoant Differential Revision: D22691351 fbshipit-source-id: bdbbd7f86d14b646fc9a693ad19f33583a76f26d --- desktop/app/src/devices/AndroidDevice.tsx | 3 +- desktop/app/src/devices/ArchivedDevice.tsx | 3 +- desktop/app/src/devices/BaseDevice.tsx | 24 +-- desktop/app/src/devices/IOSDevice.tsx | 3 +- desktop/app/src/devices/MetroDevice.tsx | 3 +- .../src/dispatcher/__tests__/plugins.node.tsx | 2 +- desktop/app/src/index.tsx | 6 +- .../reducers/__tests__/sandyplugins.node.tsx | 8 +- .../app/src/utils/crashReporterUtility.tsx | 2 +- desktop/flipper-plugin/src/index.ts | 7 + .../src/plugin/DevicePlugin.tsx | 198 ++++++++++++++++++ desktop/flipper-plugin/src/plugin/Plugin.tsx | 24 ++- .../src/plugin/PluginContext.tsx | 18 +- .../src/plugin/PluginRenderer.tsx | 3 +- .../src/plugin/SandyPluginDefinition.tsx | 59 ++++-- desktop/flipper-plugin/src/state/atom.tsx | 6 +- .../src/test-utils/test-utils.tsx | 126 +++++++++++ 17 files changed, 430 insertions(+), 65 deletions(-) create mode 100644 desktop/flipper-plugin/src/plugin/DevicePlugin.tsx diff --git a/desktop/app/src/devices/AndroidDevice.tsx b/desktop/app/src/devices/AndroidDevice.tsx index 47d90e532..87d796816 100644 --- a/desktop/app/src/devices/AndroidDevice.tsx +++ b/desktop/app/src/devices/AndroidDevice.tsx @@ -7,11 +7,12 @@ * @format */ -import BaseDevice, {DeviceType, LogLevel} from './BaseDevice'; +import BaseDevice, {DeviceType} from './BaseDevice'; import adb, {Client as ADBClient} from 'adbkit'; import {Priority} from 'adbkit-logcat'; import ArchivedDevice from './ArchivedDevice'; import {createWriteStream} from 'fs'; +import {LogLevel} from 'flipper-plugin'; const DEVICE_RECORDING_DIR = '/sdcard/flipper_recorder'; diff --git a/desktop/app/src/devices/ArchivedDevice.tsx b/desktop/app/src/devices/ArchivedDevice.tsx index 0d0d8d31b..ce7d743f1 100644 --- a/desktop/app/src/devices/ArchivedDevice.tsx +++ b/desktop/app/src/devices/ArchivedDevice.tsx @@ -7,8 +7,9 @@ * @format */ +import {DeviceLogEntry} from 'flipper-plugin'; import BaseDevice from './BaseDevice'; -import {DeviceType, OS, DeviceShell, DeviceLogEntry} from './BaseDevice'; +import {DeviceType, OS, DeviceShell} from './BaseDevice'; import {SupportFormRequestDetailsState} from '../reducers/supportForm'; function normalizeArchivedDeviceType(deviceType: DeviceType): DeviceType { diff --git a/desktop/app/src/devices/BaseDevice.tsx b/desktop/app/src/devices/BaseDevice.tsx index 909a00017..827893a21 100644 --- a/desktop/app/src/devices/BaseDevice.tsx +++ b/desktop/app/src/devices/BaseDevice.tsx @@ -8,27 +8,9 @@ */ import stream from 'stream'; -import {FlipperDevicePlugin} from 'flipper'; +import {FlipperDevicePlugin, DeviceLogListener} from 'flipper'; import {sortPluginsByName} from '../utils/pluginUtils'; - -export type LogLevel = - | 'unknown' - | 'verbose' - | 'debug' - | 'info' - | 'warn' - | 'error' - | 'fatal'; - -export type DeviceLogEntry = { - readonly date: Date; - readonly pid: number; - readonly tid: number; - readonly app?: string; - readonly type: LogLevel; - readonly tag: string; - readonly message: string; -}; +import {DeviceLogEntry} from 'flipper-plugin'; export type DeviceShell = { stdout: stream.Readable; @@ -36,8 +18,6 @@ export type DeviceShell = { stdin: stream.Writable; }; -export type DeviceLogListener = (entry: DeviceLogEntry) => void; - export type DeviceType = | 'emulator' | 'physical' diff --git a/desktop/app/src/devices/IOSDevice.tsx b/desktop/app/src/devices/IOSDevice.tsx index 9ce955aa5..e2ba3b459 100644 --- a/desktop/app/src/devices/IOSDevice.tsx +++ b/desktop/app/src/devices/IOSDevice.tsx @@ -7,7 +7,8 @@ * @format */ -import {DeviceType, LogLevel, DeviceLogEntry} from './BaseDevice'; +import {LogLevel, DeviceLogEntry} from 'flipper-plugin'; +import {DeviceType} from './BaseDevice'; import child_process, {ChildProcess} from 'child_process'; import BaseDevice from './BaseDevice'; import JSONStream from 'JSONStream'; diff --git a/desktop/app/src/devices/MetroDevice.tsx b/desktop/app/src/devices/MetroDevice.tsx index 5676b58fb..7d6c68f1e 100644 --- a/desktop/app/src/devices/MetroDevice.tsx +++ b/desktop/app/src/devices/MetroDevice.tsx @@ -7,7 +7,8 @@ * @format */ -import BaseDevice, {LogLevel} from './BaseDevice'; +import {LogLevel} from 'flipper-plugin'; +import BaseDevice from './BaseDevice'; import ArchivedDevice from './ArchivedDevice'; import {EventEmitter} from 'events'; diff --git a/desktop/app/src/dispatcher/__tests__/plugins.node.tsx b/desktop/app/src/dispatcher/__tests__/plugins.node.tsx index 2e0ca0c12..59fbb6a67 100644 --- a/desktop/app/src/dispatcher/__tests__/plugins.node.tsx +++ b/desktop/app/src/dispatcher/__tests__/plugins.node.tsx @@ -267,7 +267,7 @@ test('requirePlugin loads valid Sandy plugin', () => { }); expect(typeof plugin.module.Component).toBe('function'); expect(plugin.module.Component.displayName).toBe('FlipperPlugin(Sample)'); - expect(typeof plugin.module.plugin).toBe('function'); + expect(typeof plugin.asPluginModule().plugin).toBe('function'); }); test('requirePlugin errors on invalid Sandy plugin', () => { diff --git a/desktop/app/src/index.tsx b/desktop/app/src/index.tsx index b2a1dd97f..0b70c5ccd 100644 --- a/desktop/app/src/index.tsx +++ b/desktop/app/src/index.tsx @@ -44,11 +44,7 @@ export {getPluginKey, getPersistedState} from './utils/pluginUtils'; export {Idler} from './utils/Idler'; export {Store, MiddlewareAPI, State as ReduxState} from './reducers/index'; export {default as BaseDevice} from './devices/BaseDevice'; -export { - DeviceLogListener, - DeviceLogEntry, - LogLevel, -} from './devices/BaseDevice'; +export {DeviceLogEntry, LogLevel, DeviceLogListener} from 'flipper-plugin'; export {shouldParseAndroidLog} from './utils/crashReporterUtility'; export {default as isProduction} from './utils/isProduction'; export {createTablePlugin} from './createTablePlugin'; diff --git a/desktop/app/src/reducers/__tests__/sandyplugins.node.tsx b/desktop/app/src/reducers/__tests__/sandyplugins.node.tsx index e31798751..b9355358d 100644 --- a/desktop/app/src/reducers/__tests__/sandyplugins.node.tsx +++ b/desktop/app/src/reducers/__tests__/sandyplugins.node.tsx @@ -101,7 +101,7 @@ test('it should initialize starred sandy plugins', async () => { test('it should cleanup a plugin if disabled', async () => { const {client, store} = await createMockFlipperWithPlugin(TestPlugin); - expect(TestPlugin.module.plugin).toBeCalledTimes(1); + expect(TestPlugin.asPluginModule().plugin).toBeCalledTimes(1); const pluginInstance: PluginApi = client.sandyPluginStates.get(TestPlugin.id)! .instanceApi; expect(pluginInstance.destroyStub).toHaveBeenCalledTimes(0); @@ -150,7 +150,7 @@ test('it should not initialize a sandy plugin if not enabled', async () => { await client.refreshPlugins(); // not yet enabled, so not yet started expect(client.sandyPluginStates.get(Plugin2.id)).toBeUndefined(); - expect(Plugin2.module.plugin).toBeCalledTimes(0); + expect(Plugin2.asPluginModule().plugin).toBeCalledTimes(0); store.dispatch( starPlugin({ @@ -166,8 +166,8 @@ test('it should not initialize a sandy plugin if not enabled', async () => { .instanceApi as PluginApi; expect(client.sandyPluginStates.get(TestPlugin.id)).toBe(pluginState1); // not reinitialized - expect(TestPlugin.module.plugin).toBeCalledTimes(1); - expect(Plugin2.module.plugin).toBeCalledTimes(1); + expect(TestPlugin.asPluginModule().plugin).toBeCalledTimes(1); + expect(Plugin2.asPluginModule().plugin).toBeCalledTimes(1); expect(instance.destroyStub).toHaveBeenCalledTimes(0); // disable plugin again diff --git a/desktop/app/src/utils/crashReporterUtility.tsx b/desktop/app/src/utils/crashReporterUtility.tsx index 88d365d6a..8e963466d 100644 --- a/desktop/app/src/utils/crashReporterUtility.tsx +++ b/desktop/app/src/utils/crashReporterUtility.tsx @@ -8,7 +8,7 @@ * @flow */ -import {DeviceLogEntry} from '../devices/BaseDevice'; +import {DeviceLogEntry} from 'flipper-plugin'; export function shouldParseAndroidLog( entry: DeviceLogEntry, diff --git a/desktop/flipper-plugin/src/index.ts b/desktop/flipper-plugin/src/index.ts index a7b6cf478..203d64c0f 100644 --- a/desktop/flipper-plugin/src/index.ts +++ b/desktop/flipper-plugin/src/index.ts @@ -10,6 +10,13 @@ import * as TestUtilites from './test-utils/test-utils'; export {SandyPluginInstance, FlipperClient} from './plugin/Plugin'; +export { + Device, + DeviceLogEntry, + DeviceLogListener, + DevicePluginClient, + LogLevel, +} from './plugin/DevicePlugin'; export {SandyPluginDefinition} from './plugin/SandyPluginDefinition'; export {SandyPluginRenderer} from './plugin/PluginRenderer'; export {SandyPluginContext, usePlugin} from './plugin/PluginContext'; diff --git a/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx b/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx new file mode 100644 index 000000000..02b87bc3f --- /dev/null +++ b/desktop/flipper-plugin/src/plugin/DevicePlugin.tsx @@ -0,0 +1,198 @@ +/** + * 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 {SandyPluginDefinition} from './SandyPluginDefinition'; +import {EventEmitter} from 'events'; +import {Atom} from '../state/atom'; +import {setCurrentPluginInstance} from './Plugin'; + +export type DeviceLogListener = (entry: DeviceLogEntry) => void; + +export type DeviceLogEntry = { + readonly date: Date; + readonly pid: number; + readonly tid: number; + readonly app?: string; + readonly type: LogLevel; + readonly tag: string; + readonly message: string; +}; + +export type LogLevel = + | 'unknown' + | 'verbose' + | 'debug' + | 'info' + | 'warn' + | 'error' + | 'fatal'; + +export interface Device { + isArchived: boolean; + onLogEntry(cb: DeviceLogListener): () => void; +} + +export type DevicePluginPredicate = (device: Device) => boolean; + +export type DevicePluginFactory = (client: DevicePluginClient) => object; + +export interface DevicePluginClient { + readonly device: Device; + + /** + * the onDestroy event is fired whenever a device is unloaded from Flipper, or a plugin is disabled. + */ + onDestroy(cb: () => void): void; + + /** + * the onActivate event is fired whenever the plugin is actived in the UI + */ + onActivate(cb: () => void): void; + + /** + * The counterpart of the `onActivate` handler. + */ + onDeactivate(cb: () => void): void; +} + +export interface RealFlipperDevice { + isArchived: boolean; + addLogListener(callback: DeviceLogListener): Symbol; + removeLogListener(id: Symbol): void; + addLogEntry(entry: DeviceLogEntry): void; +} + +export class SandyDevicePluginInstance { + static is(thing: any): thing is SandyDevicePluginInstance { + return thing instanceof SandyDevicePluginInstance; + } + + /** client that is bound to this instance */ + client: DevicePluginClient; + /** the original plugin definition */ + definition: SandyPluginDefinition; + /** the plugin instance api as used inside components and such */ + instanceApi: any; + + activated = false; + destroyed = false; + events = new EventEmitter(); + + // temporarily field that is used during deserialization + initialStates?: Record; + // all the atoms that should be serialized when making an export / import + rootStates: Record> = {}; + + constructor( + realDevice: RealFlipperDevice, + definition: SandyPluginDefinition, + initialStates?: Record, + ) { + this.definition = definition; + const device: Device = { + get isArchived() { + return realDevice.isArchived; + }, + onLogEntry(cb) { + const handle = realDevice.addLogListener(cb); + return () => { + realDevice.removeLogListener(handle); + }; + }, + }; + this.client = { + device, + onDestroy: (cb) => { + this.events.on('destroy', cb); + }, + onActivate: (cb) => { + this.events.on('activate', cb); + }, + onDeactivate: (cb) => { + this.events.on('deactivate', cb); + }, + }; + setCurrentPluginInstance(this); + this.initialStates = initialStates; + try { + this.instanceApi = definition + .asDevicePluginModule() + .devicePlugin(this.client); + } finally { + this.initialStates = undefined; + setCurrentPluginInstance(undefined); + } + } + + // the plugin is selected in the UI + activate() { + this.assertNotDestroyed(); + if (!this.activated) { + 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; + this.events.emit('deactivate'); + } + } + + destroy() { + this.assertNotDestroyed(); + // TODO: + // if (this.activated) { + // this.realClient.deinitPlugin(this.definition.id); + // } + this.events.emit('destroy'); + this.destroyed = true; + } + + toJSON() { + return '[SandyDevicePluginInstance]'; + } + + exportState() { + return Object.fromEntries( + Object.entries(this.rootStates).map(([key, atom]) => [key, atom.get()]), + ); + } + + isPersistable(): boolean { + return Object.keys(this.rootStates).length > 0; + } + + private assertNotDestroyed() { + if (this.destroyed) { + throw new Error('Plugin has been destroyed already'); + } + } +} diff --git a/desktop/flipper-plugin/src/plugin/Plugin.tsx b/desktop/flipper-plugin/src/plugin/Plugin.tsx index 4407717e4..3304daf56 100644 --- a/desktop/flipper-plugin/src/plugin/Plugin.tsx +++ b/desktop/flipper-plugin/src/plugin/Plugin.tsx @@ -10,6 +10,7 @@ import {SandyPluginDefinition} from './SandyPluginDefinition'; import {EventEmitter} from 'events'; import {Atom} from '../state/atom'; +import {SandyDevicePluginInstance} from './DevicePlugin'; type EventsContract = Record; type MethodsContract = Record Promise>; @@ -87,14 +88,27 @@ export interface RealFlipperClient { ): Promise; } -export type FlipperPluginFactory< +export type PluginFactory< Events extends EventsContract, Methods extends MethodsContract > = (client: FlipperClient) => object; export type FlipperPluginComponent = React.FC<{}>; -export let currentPluginInstance: SandyPluginInstance | undefined = undefined; +let currentPluginInstance: + | SandyPluginInstance + | SandyDevicePluginInstance + | undefined = undefined; + +export function setCurrentPluginInstance( + instance: typeof currentPluginInstance, +) { + currentPluginInstance = instance; +} + +export function getCurrentPluginInstance(): typeof currentPluginInstance { + return currentPluginInstance; +} export class SandyPluginInstance { static is(thing: any): thing is SandyPluginInstance { @@ -154,13 +168,13 @@ export class SandyPluginInstance { this.events.on('deeplink', callback); }, }; - currentPluginInstance = this; + setCurrentPluginInstance(this); this.initialStates = initialStates; try { - this.instanceApi = definition.module.plugin(this.client); + this.instanceApi = definition.asPluginModule().plugin(this.client); } finally { this.initialStates = undefined; - currentPluginInstance = undefined; + setCurrentPluginInstance(undefined); } } diff --git a/desktop/flipper-plugin/src/plugin/PluginContext.tsx b/desktop/flipper-plugin/src/plugin/PluginContext.tsx index d9bff9c5f..48ec91809 100644 --- a/desktop/flipper-plugin/src/plugin/PluginContext.tsx +++ b/desktop/flipper-plugin/src/plugin/PluginContext.tsx @@ -8,15 +8,16 @@ */ import {createContext, useContext} from 'react'; -import {SandyPluginInstance, FlipperPluginFactory} from './Plugin'; +import {SandyPluginInstance, PluginFactory} from './Plugin'; +import {SandyDevicePluginInstance, DevicePluginFactory} from './DevicePlugin'; export const SandyPluginContext = createContext< - SandyPluginInstance | undefined + SandyPluginInstance | SandyDevicePluginInstance | undefined >(undefined); -export function usePlugin>( - plugin: PluginFactory, -): ReturnType { +export function usePlugin< + Factory extends PluginFactory | DevicePluginFactory +>(plugin: Factory): ReturnType { const pluginInstance = useContext(SandyPluginContext); if (!pluginInstance) { throw new Error('Plugin context not available'); @@ -25,9 +26,12 @@ export function usePlugin>( // return of this function is strongly typed, without the user needing to create it's own // context. // But since we pass it, let's make sure the user did request the proper context - if (pluginInstance.definition.module.plugin !== plugin) { + const pluginFromDefinition = pluginInstance.definition.isDevicePlugin + ? pluginInstance.definition.asDevicePluginModule().devicePlugin + : pluginInstance.definition.asPluginModule().plugin; + if (pluginFromDefinition !== plugin) { throw new Error( - `Plugin context (${pluginInstance.definition.module.plugin}) didn't match the type of the requested plugin (${plugin})`, + `Plugin in context (${pluginFromDefinition}) didn't match the type of the requested plugin (${plugin})`, ); } return pluginInstance.instanceApi; diff --git a/desktop/flipper-plugin/src/plugin/PluginRenderer.tsx b/desktop/flipper-plugin/src/plugin/PluginRenderer.tsx index 2a16d8d17..6d4150f56 100644 --- a/desktop/flipper-plugin/src/plugin/PluginRenderer.tsx +++ b/desktop/flipper-plugin/src/plugin/PluginRenderer.tsx @@ -10,9 +10,10 @@ import React, {memo, useEffect, createElement} from 'react'; import {SandyPluginContext} from './PluginContext'; import {SandyPluginInstance} from './Plugin'; +import {SandyDevicePluginInstance} from './DevicePlugin'; type Props = { - plugin: SandyPluginInstance; + plugin: SandyPluginInstance | SandyDevicePluginInstance; }; /** diff --git a/desktop/flipper-plugin/src/plugin/SandyPluginDefinition.tsx b/desktop/flipper-plugin/src/plugin/SandyPluginDefinition.tsx index 6a8441860..798226c1f 100644 --- a/desktop/flipper-plugin/src/plugin/SandyPluginDefinition.tsx +++ b/desktop/flipper-plugin/src/plugin/SandyPluginDefinition.tsx @@ -8,20 +8,29 @@ */ import {PluginDetails} from 'flipper-plugin-lib'; -import {FlipperPluginFactory, FlipperPluginComponent} from './Plugin'; +import {PluginFactory, FlipperPluginComponent} from './Plugin'; +import {DevicePluginPredicate, DevicePluginFactory} from './DevicePlugin'; /** * FlipperPluginModule describe the exports that are provided by a typical Flipper Desktop plugin */ -export type FlipperPluginModule< - Factory extends FlipperPluginFactory -> = { +export type FlipperDevicePluginModule = { + /** predicate that determines if this plugin applies to the currently selcted device */ + supportsDevice: DevicePluginPredicate; + /** the factory function that initializes a plugin instance */ + devicePlugin: DevicePluginFactory; + /** the component type that can render this plugin */ + Component: FlipperPluginComponent; +}; + +/** + * FlipperPluginModule describe the exports that are provided by a typical Flipper Desktop plugin + */ +export type FlipperPluginModule> = { /** the factory function that initializes a plugin instance */ plugin: Factory; /** the component type that can render this plugin */ Component: FlipperPluginComponent; - // TODO: support device plugins T68738317 - // devicePlugin: FlipperPluginFactory }; /** @@ -32,8 +41,9 @@ export type FlipperPluginModule< */ export class SandyPluginDefinition { id: string; - module: FlipperPluginModule; + module: FlipperPluginModule | FlipperDevicePluginModule; details: PluginDetails; + isDevicePlugin: boolean; // TODO: Implement T68683476 exportPersistedState: @@ -47,13 +57,28 @@ export class SandyPluginDefinition { ) => Promise) | undefined = undefined; - constructor(details: PluginDetails, module: FlipperPluginModule) { + constructor( + details: PluginDetails, + module: FlipperPluginModule | FlipperDevicePluginModule, + ); + constructor(details: PluginDetails, module: any) { this.id = details.id; this.details = details; - if (!module.plugin || typeof module.plugin !== 'function') { - throw new Error( - `Flipper plugin '${this.id}' should export named function called 'plugin'`, - ); + if (module.supportsDevice) { + // device plugin + this.isDevicePlugin = true; + if (!module.devicePlugin || typeof module.devicePlugin !== 'function') { + throw new Error( + `Flipper device plugin '${this.id}' should export named function called 'devicePlugin'`, + ); + } + } else { + this.isDevicePlugin = false; + if (!module.plugin || typeof module.plugin !== 'function') { + throw new Error( + `Flipper plugin '${this.id}' should export named function called 'plugin'`, + ); + } } if (!module.Component || typeof module.Component !== 'function') { throw new Error( @@ -64,6 +89,16 @@ export class SandyPluginDefinition { this.module.Component.displayName = `FlipperPlugin(${this.id})`; } + asDevicePluginModule(): FlipperDevicePluginModule { + if (!this.isDevicePlugin) throw new Error('Not a device plugin'); + return this.module as FlipperDevicePluginModule; + } + + asPluginModule(): FlipperPluginModule { + if (this.isDevicePlugin) throw new Error('Not an application plugin'); + return this.module as FlipperPluginModule; + } + get packageName() { return this.details.name; } diff --git a/desktop/flipper-plugin/src/state/atom.tsx b/desktop/flipper-plugin/src/state/atom.tsx index 4cdc9fb32..d00dc3b50 100644 --- a/desktop/flipper-plugin/src/state/atom.tsx +++ b/desktop/flipper-plugin/src/state/atom.tsx @@ -9,7 +9,7 @@ import {produce} from 'immer'; import {useState, useEffect} from 'react'; -import {currentPluginInstance} from '../plugin/Plugin'; +import {getCurrentPluginInstance} from '../plugin/Plugin'; export type Atom = { get(): T; @@ -70,8 +70,8 @@ export function createState( options: StateOptions = {}, ): Atom { const atom = new AtomValue(initialValue); - if (currentPluginInstance && options.persist) { - const {initialStates, rootStates} = currentPluginInstance; + if (getCurrentPluginInstance() && options.persist) { + const {initialStates, rootStates} = getCurrentPluginInstance()!; if (initialStates) { if (options.persist in initialStates) { atom.set(initialStates[options.persist]); diff --git a/desktop/flipper-plugin/src/test-utils/test-utils.tsx b/desktop/flipper-plugin/src/test-utils/test-utils.tsx index 868d39116..b68bc41b2 100644 --- a/desktop/flipper-plugin/src/test-utils/test-utils.tsx +++ b/desktop/flipper-plugin/src/test-utils/test-utils.tsx @@ -24,14 +24,22 @@ import { import { SandyPluginDefinition, FlipperPluginModule, + FlipperDevicePluginModule, } from '../plugin/SandyPluginDefinition'; import {SandyPluginRenderer} from '../plugin/PluginRenderer'; import {act} from '@testing-library/react'; +import { + DeviceLogEntry, + SandyDevicePluginInstance, + RealFlipperDevice, + DeviceLogListener, +} from '../plugin/DevicePlugin'; type Renderer = RenderResult; interface StartPluginOptions { initialState?: Record; + isArchived?: boolean; } type ExtractClientType> = Parameters< @@ -105,6 +113,37 @@ interface StartPluginResult> { exportState(): any; } +interface StartDevicePluginResult { + /** + * the instantiated plugin for this test + */ + instance: ReturnType; + /** + * module, from which any other exposed methods can be accessed during testing + */ + module: Module; + /** + * Emulates the 'onActivate' event + */ + activate(): void; + /** + * Emulatese the 'onDeactivate' event + */ + deactivate(): void; + /** + * Emulates the 'destroy' event. After calling destroy this plugin instance won't be usable anymore + */ + destroy(): void; + /** + * Emulates sending a log message arriving from the device + */ + sendLogEntry(logEntry: DeviceLogEntry): void; + /** + * Grabs the current (exportable) state + */ + exportState(): any; +} + export function startPlugin>( module: Module, options?: StartPluginOptions, @@ -113,6 +152,11 @@ export function startPlugin>( createMockPluginDetails(), module, ); + if (definition.isDevicePlugin) { + throw new Error( + 'Use `startDevicePlugin` or `renderDevicePlugin` to test device plugins', + ); + } const sendStub = jest.fn(); const fakeFlipper: RealFlipperClient = { @@ -199,6 +243,71 @@ export function renderPlugin>( }; } +export function startDevicePlugin( + module: Module, + options?: StartPluginOptions, +): StartDevicePluginResult { + const definition = new SandyPluginDefinition( + createMockPluginDetails(), + module, + ); + if (definition.isDevicePlugin) { + throw new Error( + 'Use `startPlugin` or `renderPlugin` to test non-device plugins', + ); + } + + const testDevice = createMockDevice(options); + const pluginInstance = new SandyDevicePluginInstance( + testDevice, + definition, + options?.initialState, + ); + // we start connected + pluginInstance.activate(); + + const res: StartDevicePluginResult = { + module, + instance: pluginInstance.instanceApi, + activate: () => pluginInstance.activate(), + deactivate: () => pluginInstance.deactivate(), + destroy: () => pluginInstance.destroy(), + sendLogEntry: (entry) => { + act(() => { + testDevice.addLogEntry(entry); + }); + }, + exportState: () => pluginInstance.exportState(), + }; + // @ts-ignore + res._backingInstance = pluginInstance; + return res; +} + +export function renderDevicePlugin( + module: Module, + options?: StartPluginOptions, +): StartDevicePluginResult & { + renderer: Renderer; + act: (cb: () => void) => void; +} { + const res = startDevicePlugin(module, options); + // @ts-ignore hidden api + const pluginInstance: SandyDevicePluginInstance = res._backingInstance; + + const renderer = render(); + + return { + ...res, + renderer, + act: testingLibAct, + destroy: () => { + renderer.unmount(); + pluginInstance.destroy(); + }, + }; +} + export function createMockPluginDetails( details?: Partial, ): PluginDetails { @@ -216,3 +325,20 @@ export function createMockPluginDetails( ...details, }; } + +function createMockDevice(options?: StartPluginOptions): RealFlipperDevice { + const logListeners: (undefined | DeviceLogListener)[] = []; + return { + isArchived: !!options?.isArchived, + addLogListener(cb) { + logListeners.push(cb); + return (logListeners.length - 1) as any; + }, + removeLogListener(idx) { + logListeners[idx as any] = undefined; + }, + addLogEntry(entry: DeviceLogEntry) { + logListeners.forEach((f) => f?.(entry)); + }, + }; +}