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:
committed by
Facebook Github Bot
parent
99547168fc
commit
5429d1f235
@@ -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,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
212
src/utils/pluginStateRecorder.tsx
Normal file
212
src/utils/pluginStateRecorder.tsx
Normal 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\`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user