/** * 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'; import {createState} from '../state/atom'; import {PluginClient} from '../plugin/Plugin'; import {DevicePluginClient} from '../plugin/DevicePlugin'; import mockConsole from 'jest-mock-console'; import {sleep} from '../utils/sleep'; import {createDataSource} from '../state/DataSource'; test('it can start a plugin and lifecycle events', () => { const {instance, ...p} = TestUtils.startPlugin(testPlugin); // @ts-expect-error p.bla; // @ts-expect-error instance.bla; // startPlugin starts connected expect(instance.connectStub).toBeCalledTimes(1); expect(instance.disconnectStub).toBeCalledTimes(0); expect(instance.activateStub).toBeCalledTimes(1); expect(instance.deactivateStub).toBeCalledTimes(0); expect(instance.destroyStub).toBeCalledTimes(0); p.connect(); // noop expect(instance.connectStub).toBeCalledTimes(1); expect(instance.disconnectStub).toBeCalledTimes(0); expect(instance.activateStub).toBeCalledTimes(1); expect(instance.deactivateStub).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.deactivate(); // also disconnects p.activate(); expect(instance.connectStub).toBeCalledTimes(3); expect(instance.disconnectStub).toBeCalledTimes(2); expect(instance.activateStub).toBeCalledTimes(2); expect(instance.deactivateStub).toBeCalledTimes(1); p.destroy(); expect(instance.connectStub).toBeCalledTimes(3); expect(instance.disconnectStub).toBeCalledTimes(3); expect(instance.activateStub).toBeCalledTimes(2); expect(instance.deactivateStub).toBeCalledTimes(2); expect(instance.destroyStub).toBeCalledTimes(1); expect(instance.appName).toBe('TestApplication'); expect(instance.appId).toBe('TestApplication#Android#TestDevice#serial-000'); // cannot interact with destroyed plugin expect(() => { p.connect(); }).toThrowErrorMatchingInlineSnapshot(`"Plugin has been destroyed already"`); expect(() => { p.activate(); }).toThrowErrorMatchingInlineSnapshot(`"Plugin has been destroyed already"`); }); test('it can start a plugin and lifecycle events for background plugins', () => { const {instance, ...p} = TestUtils.startPlugin(testPlugin, { isBackgroundPlugin: true, }); // @ts-expect-error p.bla; // @ts-expect-error instance.bla; // startPlugin starts connected expect(instance.connectStub).toBeCalledTimes(1); expect(instance.disconnectStub).toBeCalledTimes(0); expect(instance.activateStub).toBeCalledTimes(1); expect(instance.deactivateStub).toBeCalledTimes(0); expect(instance.destroyStub).toBeCalledTimes(0); p.deactivate(); // bg, no disconnection p.activate(); expect(instance.connectStub).toBeCalledTimes(1); expect(instance.disconnectStub).toBeCalledTimes(0); expect(instance.activateStub).toBeCalledTimes(2); expect(instance.deactivateStub).toBeCalledTimes(1); p.destroy(); expect(instance.connectStub).toBeCalledTimes(1); expect(instance.disconnectStub).toBeCalledTimes(1); expect(instance.activateStub).toBeCalledTimes(2); expect(instance.deactivateStub).toBeCalledTimes(2); expect(instance.destroyStub).toBeCalledTimes(1); }); test('it can render a plugin', () => { const {renderer, sendEvent, instance} = TestUtils.renderPlugin(testPlugin); expect(renderer.baseElement).toMatchInlineSnapshot(`

Hi from test plugin 0

`); sendEvent('inc', {delta: 3}); expect(renderer.baseElement).toMatchInlineSnapshot(`

Hi from test plugin 3

`); // @ts-ignore expect(instance.state.listeners.length).toBe(1); renderer.unmount(); // @ts-ignore expect(instance.state.listeners.length).toBe(0); }); test('a plugin can send messages', async () => { const {instance, onSend} = TestUtils.startPlugin(testPlugin); // By default send is stubbed expect(await instance.getCurrentState()).toBeUndefined(); expect(onSend).toHaveBeenCalledWith('currentState', {since: 0}); // @ts-expect-error onSend('bla'); // ... But we can intercept! onSend.mockImplementationOnce(async (method, params) => { expect(method).toEqual('currentState'); expect(params).toEqual({since: 0}); return 3; }); expect(await instance.getCurrentState()).toEqual(3); }); test('a plugin cannot send messages after being disconnected', async () => { const {instance, disconnect} = TestUtils.startPlugin(testPlugin); disconnect(); let threw = false; try { await instance.getCurrentState(); } catch (e) { threw = true; // for some weird reason expect(async () => instance.getCurrentState()).toThrow(...) doesn't work today... expect(e).toMatchInlineSnapshot(`[Error: Plugin is not connected]`); } expect(threw).toBeTruthy(); }); test('a plugin can receive messages', async () => { const {instance, sendEvent, exportState} = TestUtils.startPlugin(testPlugin); expect(instance.state.get().count).toBe(0); sendEvent('inc', {delta: 2}); expect(instance.state.get().count).toBe(2); expect(exportState()).toMatchInlineSnapshot(` Object { "counter": Object { "count": 2, }, } `); expect(instance.unhandledMessages.get().length).toBe(0); sendEvent('unhandled' as any, {hello: 'world'}); expect(instance.unhandledMessages.get()).toEqual([ { event: 'unhandled', params: {hello: 'world'}, }, ]); }); test('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('plugins support restoring state', async () => { const {exportState, instance} = TestUtils.startPlugin( { plugin() { const field1 = createState(1, {persist: 'field1'}); const field2 = createState(2); const field3 = createState(3, {persist: 'field3'}); return { field1, field2, field3, }; }, Component() { return null; }, }, { initialState: {field1: 'a', field3: 'b'}, }, ); const {field1, field2, field3} = instance; expect(field1.get()).toBe('a'); expect(field2.get()).toBe(2); expect(field3.get()).toBe('b'); expect(exportState()).toEqual({field1: 'a', field3: 'b'}); }); test('plugins cannot use a persist key twice', async () => { expect(() => { TestUtils.startPlugin({ plugin() { const field1 = createState(1, {persist: 'test'}); const field2 = createState(2, {persist: 'test'}); return {field1, field2}; }, Component() { return null; }, }); }).toThrowErrorMatchingInlineSnapshot( `"Some other state is already persisting with key \\"test\\""`, ); }); test('plugins can have custom import handler', () => { const {instance} = TestUtils.startPlugin( { plugin(client: PluginClient) { const field1 = createState(0); const field2 = createState(0); client.onImport((data) => { field1.set(data.a); field2.set(data.b); }); return {field1, field2}; }, Component() { return null; }, }, { initialState: { a: 1, b: 2, }, }, ); expect(instance.field1.get()).toBe(1); expect(instance.field2.get()).toBe(2); }); test('plugins cannot combine import handler with persist option', async () => { expect(() => { TestUtils.startPlugin({ plugin(client: PluginClient) { const field1 = createState(1, {persist: 'f1'}); const field2 = createState(1, {persist: 'f2'}); client.onImport(() => {}); return {field1, field2}; }, Component() { return null; }, }); }).toThrowErrorMatchingInlineSnapshot( `"A custom onImport handler was defined for plugin 'TestPlugin', the 'persist' option of states f1, f2 should not be set."`, ); }); test('plugins can handle import errors', async () => { const restoreConsole = mockConsole(); let instance: any; try { instance = TestUtils.startPlugin( { plugin(client: PluginClient) { const field1 = createState(0); const field2 = createState(0); client.onImport(() => { throw new Error('Oops'); }); return {field1, field2}; }, Component() { return null; }, }, { initialState: { a: 1, b: 2, }, }, ).instance; // @ts-ignore expect(console.error.mock.calls).toMatchInlineSnapshot(` Array [ Array [ "Error occurred when importing date for plugin 'TestPlugin': 'Error: Oops", [Error: Oops], ], ] `); } finally { restoreConsole(); } expect(instance.field1.get()).toBe(0); expect(instance.field2.get()).toBe(0); }); test('plugins can have custom export handler', async () => { const {exportStateAsync} = TestUtils.startPlugin( { plugin(client: PluginClient) { const field1 = createState(0, {persist: 'field1'}); client.onExport(async () => { await sleep(10); return { b: 3, }; }); return {field1}; }, Component() { return null; }, }, { initialState: { a: 1, b: 2, }, }, ); expect(await exportStateAsync()).toEqual({b: 3}); }); test('plugins can receive deeplinks', async () => { const plugin = TestUtils.startPlugin({ plugin(client: PluginClient) { client.onDeepLink((deepLink) => { if (typeof deepLink === 'string') { field1.set(deepLink); } }); const field1 = createState('', {persist: 'test'}); return {field1}; }, Component() { return null; }, }); expect(plugin.instance.field1.get()).toBe(''); plugin.triggerDeepLink('test'); expect(plugin.instance.field1.get()).toBe('test'); }); test('device plugins can receive deeplinks', async () => { const plugin = TestUtils.startDevicePlugin({ devicePlugin(client: DevicePluginClient) { client.onDeepLink((deepLink) => { if (typeof deepLink === 'string') { field1.set(deepLink); } }); const field1 = createState('', {persist: 'test'}); return {field1}; }, supportsDevice: () => true, Component() { return null; }, }); expect(plugin.instance.field1.get()).toBe(''); plugin.triggerDeepLink('test'); expect(plugin.instance.field1.get()).toBe('test'); }); test('plugins can register menu entries', async () => { const plugin = TestUtils.startPlugin({ plugin(client: PluginClient) { const counter = createState(0); client.addMenuEntry( { action: 'createPaste', handler() { counter.set(counter.get() + 1); }, }, { label: 'Custom Action', topLevelMenu: 'Edit', handler() { counter.set(counter.get() + 3); }, }, ); return {counter}; }, Component() { return null; }, }); expect(plugin.instance.counter.get()).toBe(0); plugin.triggerDeepLink('test'); plugin.triggerMenuEntry('createPaste'); plugin.triggerMenuEntry('Custom Action'); expect(plugin.instance.counter.get()).toBe(4); expect(plugin.flipperLib.enableMenuEntries).toBeCalledTimes(1); plugin.deactivate(); expect(() => { plugin.triggerMenuEntry('Non Existing'); }).toThrowErrorMatchingInlineSnapshot( `"No menu entry found with action: Non Existing"`, ); }); test('plugins can create pastes', async () => { const plugin = TestUtils.startPlugin({ plugin(client: PluginClient) { return { doIt() { client.createPaste('test'); }, }; }, Component() { return null; }, }); plugin.instance.doIt(); expect(plugin.flipperLib.createPaste).toBeCalledWith('test'); }); test('plugins support all methods by default', async () => { type Methods = { doit(): Promise; }; const plugin = TestUtils.startPlugin({ plugin(client: PluginClient<{}, Methods>) { return { async checkEnabled() { return client.supportsMethod('doit'); }, }; }, Component() { return null; }, }); expect(await plugin.instance.checkEnabled()).toBeTruthy(); }); test('available methods can be overridden', async () => { type Methods = { doit(): Promise; }; const plugin = TestUtils.startPlugin( { plugin(client: PluginClient<{}, Methods>) { return { async checkEnabled() { return client.supportsMethod('doit'); }, }; }, Component() { return null; }, }, { unsupportedMethods: ['doit'], }, ); expect(await plugin.instance.checkEnabled()).toBeFalsy(); }); test('GKs are supported', () => { const pluginModule = { plugin(client: PluginClient<{}, {}>) { return { isTest() { return client.GK('bla'); }, }; }, Component() { return null; }, }; { const plugin = TestUtils.startPlugin(pluginModule); expect(plugin.instance.isTest()).toBe(false); } { const plugin = TestUtils.startPlugin(pluginModule, {GKs: ['bla']}); expect(plugin.instance.isTest()).toBe(true); } { const plugin = TestUtils.startPlugin(pluginModule, {GKs: ['x']}); expect(plugin.instance.isTest()).toBe(false); } }); test('plugins can serialize dataSources', () => { const {instance, exportState} = TestUtils.startPlugin( { plugin(_client: PluginClient) { const ds = createDataSource([1, 2, 3], {persist: 'ds'}); return {ds}; }, Component() { return null; }, }, { initialState: { ds: [4, 5], }, }, ); expect(instance.ds.records).toEqual([4, 5]); instance.ds.shift(1); instance.ds.append(6); expect(exportState()).toEqual({ ds: [5, 6], }); });