From 5429d1f2356712caa6c444c701ae605de0743b0d Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Wed, 11 Dec 2019 05:50:02 -0800 Subject: [PATCH] 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 --- src/Client.tsx | 2 + src/index.tsx | 1 + src/init.tsx | 2 + src/plugin.tsx | 2 +- src/utils/pluginStateRecorder.tsx | 212 ++++++++++++++++++++++++++++++ 5 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 src/utils/pluginStateRecorder.tsx diff --git a/src/Client.tsx b/src/Client.tsx index cfb95197a..f4d094799 100644 --- a/src/Client.tsx +++ b/src/Client.tsx @@ -23,6 +23,7 @@ import {registerPlugins} from './reducers/plugins'; import createTableNativePlugin from './plugins/TableNativePlugin'; import EventEmitter from 'events'; import invariant from 'invariant'; +import {flipperRecorderAddEvent} from './utils/pluginStateRecorder'; import {getPluginKey} from './utils/pluginUtils'; const MAX_BACKGROUND_TASK_TIME = 25; @@ -390,6 +391,7 @@ export default class Client extends EventEmitter { ...this.store.getState().pluginStates[pluginKey], }; const reducerStartTime = Date.now(); + flipperRecorderAddEvent(pluginKey, params.method, params.params); const newPluginState = persistingPlugin.persistedStateReducer( persistedState, params.method, diff --git a/src/index.tsx b/src/index.tsx index 96582ba82..ff07556a4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -10,6 +10,7 @@ export {default as styled, keyframes} from 'react-emotion'; export * from './ui/index'; export {getStringFromErrorLike, textContent} from './utils/index'; +export {serialize, deserialize} from './utils/serialization'; export * from './utils/jsonTypes'; export {default as GK} from './fb-stubs/GK'; export {default as createPaste} from './fb-stubs/createPaste'; diff --git a/src/init.tsx b/src/init.tsx index 77e385e45..b65cc86a9 100644 --- a/src/init.tsx +++ b/src/init.tsx @@ -30,6 +30,7 @@ import {setPersistor} from './utils/persistor'; import React from 'react'; import path from 'path'; import {store} from './store'; +import {registerRecordingHooks} from './utils/pluginStateRecorder'; const logger = initLogger(store); const bugReporter = new BugReporter(logger, store); @@ -90,6 +91,7 @@ function init() { initLauncherHooks(config(), store); const sessionId = store.getState().application.sessionId; initCrashReporter(sessionId || ''); + registerRecordingHooks(store); window.flipperGlobalStoreDispatch = store.dispatch; } diff --git a/src/plugin.tsx b/src/plugin.tsx index 690d3b435..41c71e967 100644 --- a/src/plugin.tsx +++ b/src/plugin.tsx @@ -11,7 +11,7 @@ import {KeyboardActions} from './MenuBar'; import {App} from './App'; import {Logger} from './fb-interfaces/Logger'; import Client from './Client'; -import {Store, MiddlewareAPI} from './reducers/index'; +import {Store} from './reducers/index'; import {MetricType} from './utils/exportMetrics'; import {ReactNode, Component} from 'react'; import BaseDevice from './devices/BaseDevice'; diff --git a/src/utils/pluginStateRecorder.tsx b/src/utils/pluginStateRecorder.tsx new file mode 100644 index 000000000..95e2e3534 --- /dev/null +++ b/src/utils/pluginStateRecorder.tsx @@ -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\`, + ); +}); + +`; +}