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,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 ?? [])]),
);
const mockFlipper = new MockFlipper();
await mockFlipper.init({
plugins: [pluginClazz, ...(options?.additionalPlugins ?? [])],
});
const logger = mockFlipper.logger;
const store = mockFlipper.store;
function createDevice(serial: string): BaseDevice {
const device = new BaseDevice(
serial,
'physical',
'MockAndroidDevice',
'Android',
);
store.dispatch({
type: 'REGISTER_DEVICE',
payload: device,
});
device.loadDevicePlugins(store.getState().plugins.devicePlugins);
return device;
}
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');