diff --git a/desktop/app/src/Client.tsx b/desktop/app/src/Client.tsx index dfccf9126..6883c3925 100644 --- a/desktop/app/src/Client.tsx +++ b/desktop/app/src/Client.tsx @@ -274,7 +274,7 @@ export default class Client extends EventEmitter { async init() { this.setMatchingDevice(); await this.loadPlugins(); - // this starts all sandy enabled plugin + // this starts all sandy enabled plugins this.plugins.forEach((pluginId) => this.startPluginIfNeeded(this.getPlugin(pluginId)), ); diff --git a/desktop/app/src/__tests__/PluginContainer.node.tsx b/desktop/app/src/__tests__/PluginContainer.node.tsx index 4317ea481..8a63baa94 100644 --- a/desktop/app/src/__tests__/PluginContainer.node.tsx +++ b/desktop/app/src/__tests__/PluginContainer.node.tsx @@ -10,14 +10,12 @@ import React, {useContext} from 'react'; import produce from 'immer'; import {FlipperPlugin} from '../plugin'; -import { - renderMockFlipperWithPlugin, - createMockPluginDetails, -} from '../test-utils/createMockFlipperWithPlugin'; +import {renderMockFlipperWithPlugin} from '../test-utils/createMockFlipperWithPlugin'; import { SandyPluginContext, SandyPluginDefinition, FlipperClient, + TestUtils, } from 'flipper-plugin'; import {selectPlugin} from '../reducers/connections'; @@ -107,10 +105,13 @@ test('PluginContainer can render Sandy plugins', async () => { return {connectedStub, disconnectedStub}; }; - const definition = new SandyPluginDefinition(createMockPluginDetails(), { - plugin, - Component: MySandyPlugin, - }); + const definition = new SandyPluginDefinition( + TestUtils.createMockPluginDetails(), + { + plugin, + Component: MySandyPlugin, + }, + ); // any cast because this plugin is not enriched with the meta data that the plugin loader // normally adds. Our further sandy plugin test infra won't need this, but // for this test we do need to act a s a loaded plugin, to make sure PluginContainer itself can handle it diff --git a/desktop/app/src/dispatcher/__tests__/SandyTestPlugin.tsx b/desktop/app/src/dispatcher/__tests__/SandyTestPlugin.tsx deleted file mode 100644 index ec286894e..000000000 --- a/desktop/app/src/dispatcher/__tests__/SandyTestPlugin.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/** - * 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 {FlipperClient} from 'flipper-plugin'; - -type Events = { - inc: {delta: number}; -}; - -export function plugin(_client: FlipperClient) { - return {}; -} - -export function Component() { - return

Sandy high fives Flipper

; -} diff --git a/desktop/app/src/dispatcher/__tests__/plugins.node.tsx b/desktop/app/src/dispatcher/__tests__/plugins.node.tsx index 7e08f099a..2e0ca0c12 100644 --- a/desktop/app/src/dispatcher/__tests__/plugins.node.tsx +++ b/desktop/app/src/dispatcher/__tests__/plugins.node.tsx @@ -243,7 +243,10 @@ test('requirePlugin loads valid Sandy plugin', () => { const plugin = requireFn({ ...samplePluginDetails, name, - entry: path.join(__dirname, 'SandyTestPlugin'), + entry: path.join( + __dirname, + '../../../../flipper-plugin/src/__tests__/TestPlugin', + ), version: '1.0.0', flipperSDKVersion: '0.0.0', }) as SandyPluginDefinition; diff --git a/desktop/app/src/reducers/__tests__/sandyplugins.node.tsx b/desktop/app/src/reducers/__tests__/sandyplugins.node.tsx index f6ed69feb..e0bcba23f 100644 --- a/desktop/app/src/reducers/__tests__/sandyplugins.node.tsx +++ b/desktop/app/src/reducers/__tests__/sandyplugins.node.tsx @@ -7,10 +7,7 @@ * @format */ -import { - createMockFlipperWithPlugin, - createMockPluginDetails, -} from '../../test-utils/createMockFlipperWithPlugin'; +import {createMockFlipperWithPlugin} from '../../test-utils/createMockFlipperWithPlugin'; import {Store, Client} from '../../'; import {selectPlugin, starPlugin} from '../../reducers/connections'; import {registerPlugins} from '../../reducers/plugins'; @@ -18,13 +15,14 @@ import { SandyPluginDefinition, SandyPluginInstance, FlipperClient, + TestUtils, } from 'flipper-plugin'; interface PersistedState { count: 1; } -const pluginDetails = createMockPluginDetails(); +const pluginDetails = TestUtils.createMockPluginDetails(); let initialized = false; @@ -133,7 +131,7 @@ test('it should not initialize a sandy plugin if not enabled', async () => { const {client, store} = await createMockFlipperWithPlugin(TestPlugin); const Plugin2 = new SandyPluginDefinition( - createMockPluginDetails({ + TestUtils.createMockPluginDetails({ name: 'Plugin2', id: 'Plugin2', }), diff --git a/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx b/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx index ecbe07d11..f22be1d7f 100644 --- a/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx +++ b/desktop/app/src/test-utils/createMockFlipperWithPlugin.tsx @@ -34,7 +34,6 @@ import {registerPlugins} from '../reducers/plugins'; import PluginContainer from '../PluginContainer'; import {getPluginKey} from '../utils/pluginUtils'; import {getInstance} from '../fb-stubs/Logger'; -import {PluginDetails} from 'flipper-plugin-lib'; type MockFlipperResult = { client: Client; @@ -235,21 +234,3 @@ export async function renderMockFlipperWithPlugin( }, }; } - -export function createMockPluginDetails( - details?: Partial, -): PluginDetails { - return { - id: 'TestPlugin', - dir: '', - name: 'TestPlugin', - specVersion: 0, - entry: '', - isDefault: false, - main: '', - source: '', - title: 'Testing Plugin', - version: '', - ...details, - }; -} diff --git a/desktop/flipper-plugin/package.json b/desktop/flipper-plugin/package.json index 4d1e75af4..e2f1038e5 100644 --- a/desktop/flipper-plugin/package.json +++ b/desktop/flipper-plugin/package.json @@ -8,7 +8,9 @@ "types": "lib/index.d.ts", "license": "MIT", "bugs": "https://github.com/facebook/flipper/issues", - "dependencies": {}, + "dependencies": { + "@testing-library/react": "^10.4.3" + }, "devDependencies": { "typescript": "^3.9.2" }, diff --git a/desktop/flipper-plugin/src/__tests__/TestPlugin.tsx b/desktop/flipper-plugin/src/__tests__/TestPlugin.tsx new file mode 100644 index 000000000..5b74d7f38 --- /dev/null +++ b/desktop/flipper-plugin/src/__tests__/TestPlugin.tsx @@ -0,0 +1,45 @@ +/** + * 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 {FlipperClient} from '../plugin/Plugin'; + +type Events = { + inc: { + delta: number; + }; +}; + +type Methods = { + currentState(): Promise; +}; + +export function plugin(client: FlipperClient) { + const connectStub = jest.fn(); + const disconnectStub = jest.fn(); + const destroyStub = jest.fn(); + + // TODO: add tests for sending and receiving data T68683442 + // including typescript assertions + + client.onConnect(connectStub); + client.onDisconnect(disconnectStub); + client.onDestroy(destroyStub); + + return { + connectStub, + destroyStub, + disconnectStub, + }; +} + +export function Component() { + // TODO T69105011 add test for usePlugin including type assertions + return

Hi from test plugin

; +} diff --git a/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx b/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx new file mode 100644 index 000000000..8076bb42a --- /dev/null +++ b/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx @@ -0,0 +1,61 @@ +/** + * 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 './TestPlugin'; + +test('it can start a plugin and lifecycle events', () => { + const {instance, ...p} = TestUtils.startPlugin(testPlugin); + + // TODO T69105011 @ts-expect-error + // p.bla; + + // startPlugin starts connected + expect(instance.connectStub).toBeCalledTimes(1); + expect(instance.disconnectStub).toBeCalledTimes(0); + expect(instance.destroyStub).toBeCalledTimes(0); + + p.connect(); // noop + expect(instance.connectStub).toBeCalledTimes(1); + expect(instance.disconnectStub).toBeCalledTimes(0); + expect(instance.destroyStub).toBeCalledTimes(0); + + p.disconnect(); + p.connect(); + + expect(instance.connectStub).toBeCalledTimes(2); + expect(instance.disconnectStub).toBeCalledTimes(1); + expect(instance.destroyStub).toBeCalledTimes(0); + + p.destroy(); + expect(instance.connectStub).toBeCalledTimes(2); + expect(instance.disconnectStub).toBeCalledTimes(2); + expect(instance.destroyStub).toBeCalledTimes(1); + + // cannot interact with destroyed plugin + expect(() => { + p.connect(); + }).toThrowErrorMatchingInlineSnapshot(`"Plugin has been destroyed already"`); +}); + +test('it can render a plugin', () => { + const {renderer} = TestUtils.renderPlugin(testPlugin); + + expect(renderer.baseElement).toMatchInlineSnapshot(` + +
+

+ Hi from test plugin +

+
+ + `); + // TODO: test sending updates T68683442 +}); diff --git a/desktop/flipper-plugin/src/index.ts b/desktop/flipper-plugin/src/index.ts index c1d1a1d46..d6d6ad9c9 100644 --- a/desktop/flipper-plugin/src/index.ts +++ b/desktop/flipper-plugin/src/index.ts @@ -7,7 +7,15 @@ * @format */ -export * from './plugin/Plugin'; -export * from './plugin/SandyPluginDefinition'; -export * from './plugin/PluginRenderer'; -export * from './plugin/PluginContext'; +import * as TestUtilites from './test-utils/test-utils'; + +export {SandyPluginInstance, FlipperClient} from './plugin/Plugin'; +export {SandyPluginDefinition} from './plugin/SandyPluginDefinition'; +export {SandyPluginRenderer} from './plugin/PluginRenderer'; +export {SandyPluginContext} from './plugin/PluginContext'; + +// It's not ideal that this exists in flipper-plugin sources directly, +// but is the least pain for plugin authors. +// Probably we should make sure that testing-library doesn't end up in our final Flipper bundle (which packages flipper-plugin) +// T69106962 +export const TestUtils = TestUtilites; diff --git a/desktop/flipper-plugin/src/plugin/Plugin.tsx b/desktop/flipper-plugin/src/plugin/Plugin.tsx index e1e5de4d7..714659a17 100644 --- a/desktop/flipper-plugin/src/plugin/Plugin.tsx +++ b/desktop/flipper-plugin/src/plugin/Plugin.tsx @@ -45,7 +45,7 @@ export interface FlipperClient< * Internal API exposed by Flipper, and wrapped by FlipperPluginInstance to be passed to the * Plugin Factory. For internal purposes only */ -interface RealFlipperClient { +export interface RealFlipperClient { isBackgroundPlugin(pluginId: string): boolean; initPlugin(pluginId: string): void; deinitPlugin(pluginId: string): void; @@ -69,6 +69,7 @@ export class SandyPluginInstance { instanceApi: any; connected = false; + destroyed = false; events = new EventEmitter(); constructor( @@ -93,6 +94,7 @@ export class SandyPluginInstance { // the plugin is selected in the UI activate() { + this.assertNotDestroyed(); const pluginId = this.definition.id; if (!this.realClient.isBackgroundPlugin(pluginId)) { this.realClient.initPlugin(pluginId); // will call connect() if needed @@ -101,6 +103,7 @@ export class SandyPluginInstance { // the plugin is deselected in the UI deactivate() { + this.assertNotDestroyed(); const pluginId = this.definition.id; if (!this.realClient.isBackgroundPlugin(pluginId)) { this.realClient.deinitPlugin(pluginId); @@ -108,6 +111,7 @@ export class SandyPluginInstance { } connect() { + this.assertNotDestroyed(); if (!this.connected) { this.connected = true; this.events.emit('connect'); @@ -115,6 +119,7 @@ export class SandyPluginInstance { } disconnect() { + this.assertNotDestroyed(); if (this.connected) { this.connected = false; this.events.emit('disconnect'); @@ -122,11 +127,20 @@ export class SandyPluginInstance { } destroy() { + this.assertNotDestroyed(); this.disconnect(); this.events.emit('destroy'); + this.destroyed = true; } toJSON() { + this.assertNotDestroyed(); // TODO: T68683449 } + + private assertNotDestroyed() { + if (this.destroyed) { + throw new Error('Plugin has been destroyed already'); + } + } } diff --git a/desktop/flipper-plugin/src/plugin/SandyPluginDefinition.tsx b/desktop/flipper-plugin/src/plugin/SandyPluginDefinition.tsx index 42819ca86..1b9956db1 100644 --- a/desktop/flipper-plugin/src/plugin/SandyPluginDefinition.tsx +++ b/desktop/flipper-plugin/src/plugin/SandyPluginDefinition.tsx @@ -13,9 +13,11 @@ import {FlipperPluginFactory, FlipperPluginComponent} from './Plugin'; /** * FlipperPluginModule describe the exports that are provided by a typical Flipper Desktop plugin */ -export type FlipperPluginModule = { +export type FlipperPluginModule< + Factory extends FlipperPluginFactory +> = { /** the factory function that initializes a plugin instance */ - plugin: FlipperPluginFactory; + plugin: Factory; /** the component type that can render this plugin */ Component: FlipperPluginComponent; // TODO: support device plugins T68738317 @@ -23,14 +25,14 @@ export type FlipperPluginModule = { }; /** - * A sandy plugin definitions represents a loaded plugin definition, storing two things: + * A sandy plugin definition represents a loaded plugin definition, storing two things: * the loaded JS module, and the meta data (typically coming from package.json). * * Also delegates some of the standard plugin functionality to have a similar public static api as FlipperPlugin */ export class SandyPluginDefinition { id: string; - module: FlipperPluginModule; + module: FlipperPluginModule; details: PluginDetails; // TODO: Implement T68683449 @@ -45,7 +47,7 @@ export class SandyPluginDefinition { ) => Promise) | undefined = undefined; - constructor(details: PluginDetails, module: FlipperPluginModule) { + constructor(details: PluginDetails, module: FlipperPluginModule) { this.id = details.id; this.details = details; if (!module.plugin || typeof module.plugin !== 'function') { diff --git a/desktop/flipper-plugin/src/test-utils/test-utils.tsx b/desktop/flipper-plugin/src/test-utils/test-utils.tsx new file mode 100644 index 000000000..929fe0d46 --- /dev/null +++ b/desktop/flipper-plugin/src/test-utils/test-utils.tsx @@ -0,0 +1,129 @@ +/** + * 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 { + render, + RenderResult, + act as testingLibAct, +} from '@testing-library/react'; +import {PluginDetails} from 'flipper-plugin-lib'; + +import {RealFlipperClient, SandyPluginInstance} from '../plugin/Plugin'; +import { + SandyPluginDefinition, + FlipperPluginModule, +} from '../plugin/SandyPluginDefinition'; +import {SandyPluginRenderer} from '../plugin/PluginRenderer'; + +type Renderer = RenderResult; + +interface StartPluginOptions { + // TODO: support initial events T68683442 (and type correctly) + // TODO: support initial state T68683449 (and type correctly) +} + +interface StartPluginResult> { + /** + * the instantiated plugin for this test + */ + instance: ReturnType; + /** + * module, from which any other exposed methods can be accessed during testing + */ + module: Module; + /** + * Emulates the 'onConnect' event + */ + connect(): void; + /** + * Emulatese the 'onDisconnect' event + */ + disconnect(): void; + /** + * Emulates the 'destroy' event. After calling destroy this plugin instance won't be usable anymore + */ + destroy(): void; +} + +export function startPlugin>( + module: Module, + _options?: StartPluginOptions, +): StartPluginResult { + const definition = new SandyPluginDefinition( + createMockPluginDetails(), + module, + ); + + const fakeFlipper: RealFlipperClient = { + isBackgroundPlugin(_pluginId: string) { + // we only reason about non-background plugins, + // as from testing perspective the difference shouldn't matter + return false; + }, + initPlugin(_pluginId: string) {}, + deinitPlugin(_pluginId: string) {}, + }; + + const pluginInstance = new SandyPluginInstance(fakeFlipper, definition); + // we start connected + pluginInstance.connect(); + + return { + module, + instance: pluginInstance.instanceApi, + connect: () => pluginInstance.connect(), + disconnect: () => pluginInstance.disconnect(), + destroy: () => pluginInstance.destroy(), + // @ts-ignore + _backingInstance: pluginInstance, + }; +} + +export function renderPlugin>( + module: Module, + options?: StartPluginOptions, +): StartPluginResult & { + renderer: Renderer; + act: (cb: () => void) => void; +} { + const res = startPlugin(module, options); + // @ts-ignore hidden api + const pluginInstance: SandyPluginInstance = res._backingInstance; + + const renderer = render(); + + return { + ...res, + renderer, + act: testingLibAct, + destroy: () => { + renderer.unmount(); + pluginInstance.destroy(); + }, + }; +} + +export function createMockPluginDetails( + details?: Partial, +): PluginDetails { + return { + id: 'TestPlugin', + dir: '', + name: 'TestPlugin', + specVersion: 0, + entry: '', + isDefault: false, + main: '', + source: '', + title: 'Testing Plugin', + version: '', + ...details, + }; +} diff --git a/desktop/yarn.lock b/desktop/yarn.lock index e3d458537..fc3c5a7bb 100644 --- a/desktop/yarn.lock +++ b/desktop/yarn.lock @@ -1033,6 +1033,14 @@ pirates "^4.0.0" source-map-support "^0.5.9" +"@babel/runtime-corejs3@^7.10.2": + version "7.10.3" + resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.10.3.tgz#931ed6941d3954924a7aa967ee440e60c507b91a" + integrity sha512-HA7RPj5xvJxQl429r5Cxr2trJwOfPjKiqhCXcdQPSqO2G0RHPZpXu4fkYmBaTKCp2c/jRaMK9GB/lN+7zvvFPw== + dependencies: + core-js-pure "^3.0.0" + regenerator-runtime "^0.13.4" + "@babel/runtime-corejs3@^7.7.4", "@babel/runtime-corejs3@^7.8.3": version "7.9.6" resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.9.6.tgz#67aded13fffbbc2cb93247388cf84d77a4be9a71" @@ -1048,6 +1056,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3": + version "7.10.3" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.10.3.tgz#670d002655a7c366540c67f6fd3342cd09500364" + integrity sha512-RzGO0RLSdokm9Ipe/YD+7ww8X2Ro79qiXZF3HU9ljrM+qnJmH1Vqth+hbiQZy761LnMJTMitHDuKVYTk3k4dLw== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.0.0", "@babel/template@^7.3.3", "@babel/template@^7.7.4", "@babel/template@^7.8.3", "@babel/template@^7.8.6": version "7.8.6" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b" @@ -1765,6 +1780,16 @@ dom-accessibility-api "^0.4.2" pretty-format "^25.1.0" +"@testing-library/dom@^7.17.1": + version "7.18.1" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.18.1.tgz#c49530410fb184522b3b59c4f9cd6397dc5b462d" + integrity sha512-tGq4KAFjaI7j375sMM1RRVleWA0viJWs/w69B+nyDkqYLNkhdTHdV6mGkspJlkn3PUfyBDi3rERDv4PA/LrpVA== + dependencies: + "@babel/runtime" "^7.10.3" + aria-query "^4.2.2" + dom-accessibility-api "^0.4.5" + pretty-format "^25.5.0" + "@testing-library/react@^10.0.2": version "10.0.2" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-10.0.2.tgz#8eca7aa52d810cf7150048a2829fdc487162006d" @@ -1774,6 +1799,14 @@ "@testing-library/dom" "^7.1.0" "@types/testing-library__react" "^10.0.0" +"@testing-library/react@^10.4.3": + version "10.4.3" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-10.4.3.tgz#c6f356688cffc51f6b35385583d664bb11a161f4" + integrity sha512-A/ydYXcwAcfY7vkPrfUkUTf9HQLL3/GtixTefcu3OyGQtAYQ7XBQj1S9FWbLEhfWa0BLwFwTBFS3Ao1O0tbMJg== + dependencies: + "@babel/runtime" "^7.10.3" + "@testing-library/dom" "^7.17.1" + "@types/algoliasearch@^3.30.19": version "3.34.5" resolved "https://registry.yarnpkg.com/@types/algoliasearch/-/algoliasearch-3.34.5.tgz#c40e346a6c5526f9b27af7863117d1200456e7d2" @@ -2806,6 +2839,14 @@ aria-query@^4.0.2: "@babel/runtime" "^7.7.4" "@babel/runtime-corejs3" "^7.7.4" +aria-query@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-4.2.2.tgz#0d2ca6c9aceb56b8977e9fed6aed7e15bbd2f83b" + integrity sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA== + dependencies: + "@babel/runtime" "^7.10.2" + "@babel/runtime-corejs3" "^7.10.2" + arr-diff@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" @@ -4568,6 +4609,11 @@ dom-accessibility-api@^0.4.2: resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.4.3.tgz#93ca9002eb222fd5a343b6e5e6b9cf5929411c4c" integrity sha512-JZ8iPuEHDQzq6q0k7PKMGbrIdsgBB7TRrtVOUm4nSMCExlg5qQG4KXWTH2k90yggjM4tTumRGwTKJSldMzKyLA== +dom-accessibility-api@^0.4.5: + version "0.4.5" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.4.5.tgz#d9c1cefa89f509d8cf132ab5d250004d755e76e3" + integrity sha512-HcPDilI95nKztbVikaN2vzwvmv0sE8Y2ZJFODy/m15n7mGXLeOKGiys9qWVbFbh+aq/KYj2lqMLybBOkYAEXqg== + dom-helpers@^3.2.0, dom-helpers@^3.4.0: version "3.4.0" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8"