Command processing (2/n): testing

Summary:
*Stack summary*: this stack refactors plugin management actions to perform them in a dispatcher rather than in the root reducer (store.tsx) as all of these actions has side effects. To do that, we store requested plugin management actions (install/update/uninstall, star/unstar) in a queue which is then handled by pluginManager dispatcher. This dispatcher then dispatches all required state updates.

*Diff summary*: refactored Flipper mocking helpers to allow testing of plugin commands, and wrote some tests for pluginManager.

Reviewed By: mweststrate

Differential Revision: D26450344

fbshipit-source-id: 0e8414517cc1ad353781dffd7ffb4a5f9a815d38
This commit is contained in:
Anton Nikolaev
2021-02-16 10:46:11 -08:00
committed by Facebook GitHub Bot
parent 8efdde08c4
commit 24aed8fd45
8 changed files with 551 additions and 108 deletions

View File

@@ -0,0 +1,108 @@
/**
* 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
*/
jest.mock('../plugins');
jest.mock('../../utils/electronModuleCache');
import {loadPlugin} from '../../reducers/pluginManager';
import {requirePlugin} from '../plugins';
import {mocked} from 'ts-jest/utils';
import {TestUtils} from 'flipper-plugin';
import * as TestPlugin from '../../test-utils/TestPlugin';
import {_SandyPluginDefinition as SandyPluginDefinition} from 'flipper-plugin';
import MockFlipper from '../../test-utils/MockFlipper';
const pluginDetails1 = TestUtils.createMockPluginDetails({
id: 'plugin1',
version: '0.0.1',
});
const pluginDefinition1 = new SandyPluginDefinition(pluginDetails1, TestPlugin);
const pluginDetails1V2 = TestUtils.createMockPluginDetails({
id: 'plugin1',
version: '0.0.2',
});
const pluginDefinition1V2 = new SandyPluginDefinition(
pluginDetails1V2,
TestPlugin,
);
const pluginDetails2 = TestUtils.createMockPluginDetails({id: 'plugin2'});
const pluginDefinition2 = new SandyPluginDefinition(pluginDetails2, TestPlugin);
const mockedRequirePlugin = mocked(requirePlugin);
let mockFlipper: MockFlipper;
beforeEach(async () => {
mockedRequirePlugin.mockImplementation(
(details) =>
(details === pluginDetails1
? pluginDefinition1
: details === pluginDetails2
? pluginDefinition2
: details === pluginDetails1V2
? pluginDefinition1V2
: undefined)!,
);
mockFlipper = new MockFlipper();
await mockFlipper.initWithDeviceAndClient({
clientOptions: {supportedPlugins: ['plugin1', 'plugin2']},
});
});
afterEach(async () => {
mockedRequirePlugin.mockReset();
await mockFlipper.destroy();
});
test('load plugin when no other version loaded', async () => {
mockFlipper.dispatch(
loadPlugin({plugin: pluginDetails1, enable: false, notifyIfFailed: false}),
);
expect(mockFlipper.getState().plugins.clientPlugins.get('plugin1')).toBe(
pluginDefinition1,
);
expect(mockFlipper.getState().plugins.loadedPlugins.get('plugin1')).toBe(
pluginDetails1,
);
expect(mockFlipper.clients[0].sandyPluginStates.has('plugin1')).toBeFalsy();
});
test('load plugin when other version loaded', async () => {
mockFlipper.dispatch(
loadPlugin({plugin: pluginDetails1, enable: false, notifyIfFailed: false}),
);
mockFlipper.dispatch(
loadPlugin({
plugin: pluginDetails1V2,
enable: false,
notifyIfFailed: false,
}),
);
expect(mockFlipper.getState().plugins.clientPlugins.get('plugin1')).toBe(
pluginDefinition1V2,
);
expect(mockFlipper.getState().plugins.loadedPlugins.get('plugin1')).toBe(
pluginDetails1V2,
);
expect(mockFlipper.clients[0].sandyPluginStates.has('plugin1')).toBeFalsy();
});
test('load and enable Sandy plugin', async () => {
mockFlipper.dispatch(
loadPlugin({plugin: pluginDetails1, enable: true, notifyIfFailed: false}),
);
expect(mockFlipper.getState().plugins.clientPlugins.get('plugin1')).toBe(
pluginDefinition1,
);
expect(mockFlipper.getState().plugins.loadedPlugins.get('plugin1')).toBe(
pluginDetails1,
);
expect(mockFlipper.clients[0].sandyPluginStates.has('plugin1')).toBeTruthy();
});

View File

@@ -35,15 +35,29 @@ function refreshInstalledPlugins(store: Store) {
.then((plugins) => store.dispatch(registerInstalledPlugins(plugins)));
}
export default (store: Store, _logger: Logger) => {
export default (
store: Store,
_logger: Logger,
{runSideEffectsSynchronously}: {runSideEffectsSynchronously: boolean} = {
runSideEffectsSynchronously: false,
},
) => {
// This needn't happen immediately and is (light) I/O work.
if (window.requestIdleCallback) {
window.requestIdleCallback(() => {
refreshInstalledPlugins(store);
});
}
sideEffect(
const unsubscribeHandlePluginCommands = sideEffect(
store,
{name: 'handlePluginActivation', throttleMs: 1000, fireImmediately: true},
{
name: 'handlePluginCommands',
throttleMs: 0,
fireImmediately: true,
runSynchronously: runSideEffectsSynchronously, // Used to simplify writing tests, if "true" passed, the all side effects will be called synchronously and immediately after changes
noTimeBudgetWarns: true, // These side effects are critical, so we're doing them with zero throttling and want to avoid unnecessary warns
},
(state) => state.pluginManager.pluginCommandsQueue,
(queue, store) => {
for (const command of queue) {
@@ -59,6 +73,9 @@ export default (store: Store, _logger: Logger) => {
store.dispatch(pluginCommandsProcessed(queue.length));
},
);
return async () => {
unsubscribeHandlePluginCommands();
};
};
function loadPlugin(store: Store, payload: LoadPluginActionPayload) {

View File

@@ -250,7 +250,9 @@ test('it can send messages from sandy clients', async () => {
});
test('it should initialize "Navigation" plugin if not enabled', async () => {
const {client, store} = await createMockFlipperWithPlugin(TestPlugin);
const {client, store} = await createMockFlipperWithPlugin(TestPlugin, {
supportedPlugins: ['Navigation'],
});
const Plugin2 = new _SandyPluginDefinition(
TestUtils.createMockPluginDetails({

View File

@@ -0,0 +1,64 @@
/**
* 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,
usePlugin,
createState,
useValue,
} from 'flipper-plugin';
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 <h1>Hi from test plugin {count}</h1>;
}

View File

@@ -0,0 +1,213 @@
/**
* 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 {createStore} from 'redux';
import BaseDevice from '../devices/BaseDevice';
import {rootReducer} from '../store';
import {Store} from '../reducers/index';
import Client, {ClientQuery} from '../Client';
import {buildClientId} from '../utils/clientUtils';
import {Logger} from '../fb-interfaces/Logger';
import {PluginDefinition} from '../plugin';
import {registerPlugins} from '../reducers/plugins';
import {getInstance} from '../fb-stubs/Logger';
import {initializeFlipperLibImplementation} from '../utils/flipperLibImplementation';
import pluginManager from '../dispatcher/pluginManager';
export interface AppOptions {
plugins?: PluginDefinition[];
}
export interface ClientOptions {
name?: string;
supportedPlugins?: string[];
backgroundPlugins?: string[];
onSend?: (pluginId: string, method: string, params?: object) => any;
query?: ClientQuery;
skipRegister?: boolean;
}
export interface DeviceOptions {
serial?: string;
}
export default class MockFlipper {
private unsubscribePluginManager!: () => Promise<void>;
private _store!: Store;
private _logger!: Logger;
private _devices: BaseDevice[] = [];
private _clients: Client[] = [];
private _deviceCounter: number = 0;
private _clientCounter: number = 0;
public get store(): Store {
return this._store;
}
public get logger(): Logger {
return this._logger;
}
public get devices(): ReadonlyArray<BaseDevice> {
return this._devices;
}
public get clients(): ReadonlyArray<Client> {
return this._clients;
}
public get dispatch() {
return this._store.dispatch;
}
public getState() {
return this._store.getState();
}
public async init({plugins}: AppOptions = {}) {
this._store = createStore(rootReducer);
this._logger = getInstance();
this.unsubscribePluginManager = pluginManager(this._store, this._logger, {
runSideEffectsSynchronously: true,
});
initializeFlipperLibImplementation(this._store, this._logger);
this._store.dispatch(registerPlugins(plugins ?? []));
}
public async initWithDeviceAndClient(
{
appOptions = {},
deviceOptions = {},
clientOptions = {},
}: {
appOptions?: AppOptions;
deviceOptions?: DeviceOptions;
clientOptions?: ClientOptions;
} = {appOptions: {}, deviceOptions: {}, clientOptions: {}},
): Promise<{flipper: MockFlipper; device: BaseDevice; client: Client}> {
await this.init(appOptions);
const device = this.createDevice(deviceOptions);
const client = await this.createClient(device, clientOptions);
return {
flipper: this,
device,
client,
};
}
public async destroy() {
this.unsubscribePluginManager && this.unsubscribePluginManager();
}
public createDevice({serial}: DeviceOptions = {}): BaseDevice {
const device = new BaseDevice(
serial ?? `serial_${++this._deviceCounter}`,
'physical',
'MockAndroidDevice',
'Android',
);
this._store.dispatch({
type: 'REGISTER_DEVICE',
payload: device,
});
device.loadDevicePlugins(this._store.getState().plugins.devicePlugins);
this._devices.push(device);
return device;
}
public async createClient(
device: BaseDevice,
{
name,
supportedPlugins,
backgroundPlugins,
onSend,
skipRegister,
query,
}: ClientOptions = {},
): Promise<Client> {
if (!this._devices.includes(device)) {
throw new Error('The provided device does not exist');
}
query = query ?? {
app: name ?? `serial_${++this._clientCounter}`,
os: 'Android',
device: device.title,
device_id: device.serial,
sdk_version: 4,
};
const id = buildClientId({
app: query.app,
os: query.os,
device: query.device,
device_id: query.device_id,
});
supportedPlugins =
supportedPlugins ??
[...this._store.getState().plugins.clientPlugins.values()].map(
(p) => p.id,
);
const client = new Client(
id,
query,
null, // create a stub connection to avoid this plugin to be archived?
this._logger,
this._store,
supportedPlugins,
device,
);
// yikes
client.device = {
then() {
return device;
},
} as any;
client.rawCall = async (
method: string,
_fromPlugin: boolean,
params: any,
): Promise<any> => {
const intercepted = onSend?.(method, params);
if (intercepted !== undefined) {
return intercepted;
}
switch (method) {
case 'getPlugins':
// assuming this plugin supports all plugins for now
return {
plugins: supportedPlugins,
};
case 'getBackgroundPlugins':
return {
plugins: backgroundPlugins ?? [],
};
default:
throw new Error(
`Test client doesn't support rawCall method '${method}'`,
);
}
};
client.rawSend = jest.fn();
await client.init();
// As convenience, by default we select the new client, star the plugin, and select it
if (!skipRegister) {
this._store.dispatch({
type: 'NEW_CLIENT',
payload: client,
});
}
this._clients.push(client);
return client;
}
}

View File

@@ -0,0 +1,98 @@
/**
* 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 {PluginClient, usePlugin, createState, useValue} from 'flipper-plugin';
type Events = {
inc: {
delta: number;
};
};
type Methods = {
currentState(params: {since: number}): Promise<number>;
};
export function plugin(client: PluginClient<Events, Methods>) {
const connectStub = jest.fn();
const disconnectStub = jest.fn();
const activateStub = jest.fn();
const deactivateStub = jest.fn();
const destroyStub = jest.fn();
const state = createState(
{
count: 0,
},
{
persist: 'counter',
},
);
const unhandledMessages = createState<any[]>([]);
client.onConnect(connectStub);
client.onDisconnect(disconnectStub);
client.onActivate(activateStub);
client.onDeactivate(deactivateStub);
client.onDestroy(destroyStub);
client.onMessage('inc', ({delta}) => {
state.update((draft) => {
draft.count += delta;
});
});
client.onUnhandledMessage((event, params) => {
unhandledMessages.update((draft) => {
draft.push({event, params});
});
});
function _unused_JustTypeChecks() {
// @ts-expect-error Argument of type '"bla"' is not assignable
client.send('bla', {});
// @ts-expect-error Argument of type '{ stuff: string; }' is not assignable to parameter of type
client.send('currentState', {stuff: 'nope'});
// @ts-expect-error
client.onMessage('stuff', (_params) => {
// noop
});
client.onMessage('inc', (params) => {
// @ts-expect-error
params.bla;
});
}
async function getCurrentState() {
return client.send('currentState', {since: 0});
}
expect(client.device).not.toBeNull();
return {
activateStub,
deactivateStub,
connectStub,
destroyStub,
disconnectStub,
getCurrentState,
state,
unhandledMessages,
appId: client.appId,
appName: client.appName,
};
}
export function Component() {
const api = usePlugin(plugin);
const count = useValue(api.state).count;
// @ts-expect-error
api.bla;
return <h1>Hi from test plugin {count}</h1>;
}

View File

@@ -8,7 +8,6 @@
*/
import React from 'react';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import {
render,
@@ -25,18 +24,14 @@ import {
} from '../reducers/connections';
import BaseDevice from '../devices/BaseDevice';
import {rootReducer} from '../store';
import {Store} from '../reducers/index';
import Client, {ClientQuery} from '../Client';
import {buildClientId} from '../utils/clientUtils';
import {Logger} from '../fb-interfaces/Logger';
import {PluginDefinition} from '../plugin';
import {registerPlugins} from '../reducers/plugins';
import PluginContainer from '../PluginContainer';
import {getPluginKey, isDevicePluginDefinition} from '../utils/pluginUtils';
import {getInstance} from '../fb-stubs/Logger';
import {initializeFlipperLibImplementation} from '../utils/flipperLibImplementation';
import MockFlipper from './MockFlipper';
export type MockFlipperResult = {
client: Client;
@@ -64,94 +59,35 @@ type MockOptions = Partial<{
additionalPlugins?: PluginDefinition[];
dontEnableAdditionalPlugins?: true;
asBackgroundPlugin?: true;
supportedPlugins?: string[];
}>;
export async function createMockFlipperWithPlugin(
pluginClazz: PluginDefinition,
options?: MockOptions,
): Promise<MockFlipperResult> {
const store = createStore(rootReducer);
const logger = getInstance();
initializeFlipperLibImplementation(store, logger);
store.dispatch(
registerPlugins([pluginClazz, ...(options?.additionalPlugins ?? [])]),
);
function createDevice(serial: string): BaseDevice {
const device = new BaseDevice(
serial,
'physical',
'MockAndroidDevice',
'Android',
);
store.dispatch({
type: 'REGISTER_DEVICE',
payload: device,
const mockFlipper = new MockFlipper();
await mockFlipper.init({
plugins: [pluginClazz, ...(options?.additionalPlugins ?? [])],
});
device.loadDevicePlugins(store.getState().plugins.devicePlugins);
return device;
}
const logger = mockFlipper.logger;
const store = mockFlipper.store;
async function createClient(
const createDevice = (serial: string) => mockFlipper.createDevice({serial});
const createClient = async (
device: BaseDevice,
name: string,
query?: ClientQuery,
skipRegister?: boolean,
): Promise<Client> {
query = query ?? {
app: name,
os: 'Android',
device: device.title,
device_id: device.serial,
sdk_version: 4,
};
const id = buildClientId({
app: query.app,
os: query.os,
device: query.device,
device_id: query.device_id,
});
const client = new Client(
id,
) => {
const client = await mockFlipper.createClient(device, {
name,
query,
null, // create a stub connection to avoid this plugin to be archived?
logger,
store,
[
...(isDevicePluginDefinition(pluginClazz) ? [] : [pluginClazz.id]),
...(options?.dontEnableAdditionalPlugins
? []
: options?.additionalPlugins?.map((p) => p.id) ?? []),
],
device,
);
client.rawCall = async (
method: string,
_fromPlugin: boolean,
params: any,
): Promise<any> => {
const intercepted = options?.onSend?.(method, params);
if (intercepted !== undefined) {
return intercepted;
}
switch (method) {
case 'getPlugins':
// assuming this plugin supports all plugins for now
return {
plugins: [...store.getState().plugins.clientPlugins.keys()],
};
case 'getBackgroundPlugins':
return {plugins: options?.asBackgroundPlugin ? [pluginClazz.id] : []};
default:
throw new Error(
`Test client doesn't support rawCall method '${method}'`,
);
}
};
client.rawSend = jest.fn();
skipRegister,
onSend: options?.onSend,
supportedPlugins: options?.supportedPlugins,
backgroundPlugins: options?.asBackgroundPlugin ? [pluginClazz.id] : [],
});
// enable the plugin
if (
!isDevicePluginDefinition(pluginClazz) &&
@@ -180,17 +116,8 @@ export async function createMockFlipperWithPlugin(
}
});
}
await client.init();
// As convenience, by default we select the new client, star the plugin, and select it
if (!skipRegister) {
store.dispatch({
type: 'NEW_CLIENT',
payload: client,
});
}
return client;
}
};
const device = createDevice('serial');
const client = await createClient(device, 'TestApp');

View File

@@ -28,7 +28,13 @@ export function sideEffect<
State = Store extends ReduxStore<infer S, any> ? S : never
>(
store: Store,
options: {name: string; throttleMs: number; fireImmediately?: boolean},
options: {
name: string;
throttleMs: number;
fireImmediately?: boolean;
noTimeBudgetWarns?: boolean;
runSynchronously?: boolean;
},
selector: (state: State) => V,
effect: (selectedState: V, store: Store) => void,
): () => void {
@@ -52,7 +58,11 @@ export function sideEffect<
}
lastRun = performance.now();
const duration = lastRun - start;
if (duration > 15 && duration > options.throttleMs / 10) {
if (
!options.noTimeBudgetWarns &&
duration > 15 &&
duration > options.throttleMs / 10
) {
console.warn(
`Side effect '${options.name}' took ${Math.round(
duration,
@@ -75,6 +85,9 @@ export function sideEffect<
return; // no new value, no need to schedule
}
scheduled = true;
if (options.runSynchronously) {
run();
} else {
timeout = setTimeout(
run,
// Run ASAP (but async) or, if we recently did run, delay until at least 'throttle' time has expired
@@ -82,6 +95,7 @@ export function sideEffect<
? 1
: Math.max(1, lastRun + options.throttleMs - performance.now()),
);
}
});
if (options.fireImmediately) {