Make it possible to generate regression tests for Flipper plugins

Summary:
This Pull request makes it possible to automatically generate regression tests for plugins. The idea here is to record all incoming states for a specific plugin, the start state of the plugin, and the enstate of the plugin.

By replaying the same events in a test, the same plugin should result in the same end state. This will make it easy to test regressions and refactorings on real life scenarios. Execution time is recorded as well.

The API's are exposed as

- `flipperStartPluginRecording()`
- `flipperStopPluginRecording()`

This process generates both a data snapshot and unit test.

Reviewed By: passy

Differential Revision: D18907455

fbshipit-source-id: 923f814f534ccfa6aa2ff2bfa2f80bee41a1c182
This commit is contained in:
Michel Weststrate
2019-12-11 05:50:02 -08:00
committed by Facebook Github Bot
parent 99547168fc
commit 5429d1f235
5 changed files with 218 additions and 1 deletions

View File

@@ -23,6 +23,7 @@ import {registerPlugins} from './reducers/plugins';
import createTableNativePlugin from './plugins/TableNativePlugin'; import createTableNativePlugin from './plugins/TableNativePlugin';
import EventEmitter from 'events'; import EventEmitter from 'events';
import invariant from 'invariant'; import invariant from 'invariant';
import {flipperRecorderAddEvent} from './utils/pluginStateRecorder';
import {getPluginKey} from './utils/pluginUtils'; import {getPluginKey} from './utils/pluginUtils';
const MAX_BACKGROUND_TASK_TIME = 25; const MAX_BACKGROUND_TASK_TIME = 25;
@@ -390,6 +391,7 @@ export default class Client extends EventEmitter {
...this.store.getState().pluginStates[pluginKey], ...this.store.getState().pluginStates[pluginKey],
}; };
const reducerStartTime = Date.now(); const reducerStartTime = Date.now();
flipperRecorderAddEvent(pluginKey, params.method, params.params);
const newPluginState = persistingPlugin.persistedStateReducer( const newPluginState = persistingPlugin.persistedStateReducer(
persistedState, persistedState,
params.method, params.method,

View File

@@ -10,6 +10,7 @@
export {default as styled, keyframes} from 'react-emotion'; export {default as styled, keyframes} from 'react-emotion';
export * from './ui/index'; export * from './ui/index';
export {getStringFromErrorLike, textContent} from './utils/index'; export {getStringFromErrorLike, textContent} from './utils/index';
export {serialize, deserialize} from './utils/serialization';
export * from './utils/jsonTypes'; export * from './utils/jsonTypes';
export {default as GK} from './fb-stubs/GK'; export {default as GK} from './fb-stubs/GK';
export {default as createPaste} from './fb-stubs/createPaste'; export {default as createPaste} from './fb-stubs/createPaste';

View File

@@ -30,6 +30,7 @@ import {setPersistor} from './utils/persistor';
import React from 'react'; import React from 'react';
import path from 'path'; import path from 'path';
import {store} from './store'; import {store} from './store';
import {registerRecordingHooks} from './utils/pluginStateRecorder';
const logger = initLogger(store); const logger = initLogger(store);
const bugReporter = new BugReporter(logger, store); const bugReporter = new BugReporter(logger, store);
@@ -90,6 +91,7 @@ function init() {
initLauncherHooks(config(), store); initLauncherHooks(config(), store);
const sessionId = store.getState().application.sessionId; const sessionId = store.getState().application.sessionId;
initCrashReporter(sessionId || ''); initCrashReporter(sessionId || '');
registerRecordingHooks(store);
window.flipperGlobalStoreDispatch = store.dispatch; window.flipperGlobalStoreDispatch = store.dispatch;
} }

View File

@@ -11,7 +11,7 @@ import {KeyboardActions} from './MenuBar';
import {App} from './App'; import {App} from './App';
import {Logger} from './fb-interfaces/Logger'; import {Logger} from './fb-interfaces/Logger';
import Client from './Client'; import Client from './Client';
import {Store, MiddlewareAPI} from './reducers/index'; import {Store} from './reducers/index';
import {MetricType} from './utils/exportMetrics'; import {MetricType} from './utils/exportMetrics';
import {ReactNode, Component} from 'react'; import {ReactNode, Component} from 'react';
import BaseDevice from './devices/BaseDevice'; import BaseDevice from './devices/BaseDevice';

View File

@@ -0,0 +1,212 @@
/**
* 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 path from 'path';
import fs from 'fs';
import {Store, State} from '../reducers';
import {getPluginKey} from './pluginUtils';
import {serialize} from './serialization';
let pluginRecordingState: {
recording: string;
pluginName: string;
startState: any;
events: [string, any][];
endState: any;
} = initialRecordingState();
function initialRecordingState(): typeof pluginRecordingState {
return {
recording: '',
startState: undefined,
events: [],
endState: undefined,
pluginName: '',
};
}
export function flipperRecorderAddEvent(
pluginKey: string,
method: string,
params: any,
) {
if (pluginRecordingState.recording === pluginKey) {
pluginRecordingState.events.push([method, params]);
}
}
async function flipperStartPluginRecording(state: State) {
if (pluginRecordingState.recording) {
throw new Error('A plugin recording is already running');
}
const app = state.connections.selectedApp;
const client = state.connections.clients.find(client => client.id === app);
if (!app || !client) {
throw new Error('Can only record plugin states if a device is selected');
}
const selectedPlugin = state.connections.selectedPlugin;
const pluginKey = getPluginKey(client.id, null, selectedPlugin!);
const plugin = state.plugins.clientPlugins.get(selectedPlugin!);
if (!selectedPlugin || !plugin) {
throw new Error('Can only record plugin states if a plugin is selected');
}
pluginRecordingState = {
recording: pluginKey,
startState: undefined,
events: [],
endState: undefined,
pluginName: selectedPlugin,
};
// Note that we don't use the plugin's own serializeState, as that might interact with the
// device state, and is used for exporting Flipper traces.
pluginRecordingState.startState = await serialize(
state.pluginStates[pluginKey] || plugin.defaultPersistedState,
);
console.log(
`Started recordig the states of plugin ${selectedPlugin}..... Use window.flipperStopPluginRecording() to finish this process`,
);
}
async function flipperStopPluginRecording(state: State) {
if (!pluginRecordingState.recording) {
throw new Error('No plugin recording is running. ');
}
if (!pluginRecordingState.events.length) {
console.warn('No events were captured, cancelling recording');
pluginRecordingState = initialRecordingState();
return;
}
pluginRecordingState.endState = await serialize(
state.pluginStates[pluginRecordingState.recording],
);
const pluginName = pluginRecordingState.pluginName;
const snapShotFileContents = JSON.stringify(pluginRecordingState);
const snapShotFileName = `${pluginName}.pluginSnapshot.json`;
const testFileName = `${pluginName}EventsRunner.tsx`;
const outDir = getOutputDir(pluginName);
const testFileContents = generateTestSuite(pluginName, snapShotFileName);
await fs.promises.writeFile(
path.join(outDir, snapShotFileName),
snapShotFileContents,
'utf8',
);
await fs.promises.writeFile(
path.join(outDir, testFileName),
testFileContents,
'utf8',
);
console.log(
`Finished recording ${pluginRecordingState.events.length} for plugin ${
pluginRecordingState.recording
}. Generated files ${path.join(outDir, testFileName)} and ${path.join(
outDir,
snapShotFileName,
)}. Move them to the '__tests__ folder of your plugin to incorporate them`,
);
pluginRecordingState = initialRecordingState();
}
export function registerRecordingHooks(store: Store) {
Object.assign(window, {
flipperStartPluginRecording() {
flipperStartPluginRecording(store.getState());
},
flipperStopPluginRecording() {
flipperStopPluginRecording(store.getState());
},
});
}
function getOutputDir(pluginName: string) {
const outDir = path.join(process.cwd(), '..');
const fbPluginDir = path.join(
outDir,
'src',
'fb',
'plugins',
pluginName.toLowerCase(),
'__tests__',
);
const defaultPluginDir = path.join(
outDir,
'src',
'plugins',
pluginName.toLowerCase(),
'__tests__',
);
if (fs.existsSync(fbPluginDir)) {
return fbPluginDir;
} else if (fs.existsSync(defaultPluginDir)) {
return defaultPluginDir;
}
return outDir;
}
function generateTestSuite(pluginName: string, snapShotFileName: string) {
return `\
/**
* 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
*/
// This file was initially generated by using \`flipperStartPluginRecording()\` in Flipper console
import fs from 'fs';
import path from 'path';
import {deserialize} from 'flipper';
import Plugin from '../';
test('Verify events produce a consistent end state for plugin ${pluginName}', async () => {
const snapshotData: {
startState: string;
endState: string;
events: [string, any][];
} = JSON.parse(
await fs.promises.readFile(
path.join(__dirname, '${snapShotFileName}'),
'utf8',
),
);
const startState: typeof Plugin.defaultPersistedState = deserialize(
snapshotData.startState,
);
const endState: typeof Plugin.defaultPersistedState = deserialize(
snapshotData.endState,
);
const startTime = Date.now();
const generatedEndState = snapshotData.events.reduce(
(store, [method, params]) =>
Plugin.persistedStateReducer(store, method, params),
startState,
);
const totalTime = Date.now() - startTime;
expect(generatedEndState).toEqual(endState);
console.log(
\`Reducer took $\{totalTime\}ms. to process $\{snapshotData.events.length\} events\`,
);
});
`;
}