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)); + }, + }; +}