Export and import all the nodes

Summary:
This diff does the following

- Support to export the entire view hierarchy for iOS
- Android is not supported yet
- This diff adds a call `getAllNodes` to the client side of iOS, which returns the entire view hierarchy
- Currently the search doesn't work on the imported layout plugin data. Also the imported layout plugin data doesn't expand the way it does when component is mounted, reason being the client passed to the plugin is not functional for the archived case

I will work on fixing the last points in the next diffs stacked on the current one.

For Android:
- Currently the export function will export whatever is currently displayed on the Flipper app, not the entire view hierarchy

Support for Android will also come up in later diffs.

Reviewed By: jknoxville

Differential Revision: D14209157

fbshipit-source-id: 3ad3e39edfd994913dc19cc239bfbbe011a9757c
This commit is contained in:
Pritesh Nandgaonkar
2019-02-28 09:38:03 -08:00
committed by Facebook Github Bot
parent 99ea11b8e6
commit af317eed2b
9 changed files with 146 additions and 41 deletions

View File

@@ -97,10 +97,8 @@ function startFlipper({
const logger = initLogger(store, {isHeadless: true}); const logger = initLogger(store, {isHeadless: true});
dispatcher(store, logger); dispatcher(store, logger);
process.on('SIGINT', () => { process.on('SIGINT', async () => {
originalConsole.log( originalConsole.log(JSON.stringify(await serializeStore(store), null, 2));
JSON.stringify(serializeStore(store.getState()), null, 2),
);
process.exit(); process.exit();
}); });
} }

View File

@@ -86,6 +86,10 @@
[connection receive:@"getRoot" withBlock:^(NSDictionary *params, id<FlipperResponder> responder) { [connection receive:@"getRoot" withBlock:^(NSDictionary *params, id<FlipperResponder> responder) {
FlipperPerformBlockOnMainThread(^{ [weakSelf onCallGetRoot: responder]; }, responder); FlipperPerformBlockOnMainThread(^{ [weakSelf onCallGetRoot: responder]; }, responder);
}]; }];
[connection receive:@"getAllNodes" withBlock:^(NSDictionary *params, id<FlipperResponder> responder) {
FlipperPerformBlockOnMainThread(^{ [weakSelf onCallGetAllNodesWithResponder: responder]; }, responder);
}];
[connection receive:@"getNodes" withBlock:^(NSDictionary *params, id<FlipperResponder> responder) { [connection receive:@"getNodes" withBlock:^(NSDictionary *params, id<FlipperResponder> responder) {
FlipperPerformBlockOnMainThread(^{ [weakSelf onCallGetNodes: params[@"ids"] withResponder: responder]; }, responder); FlipperPerformBlockOnMainThread(^{ [weakSelf onCallGetNodes: params[@"ids"] withResponder: responder]; }, responder);
@@ -134,6 +138,29 @@
[responder success: rootNode]; [responder success: rootNode];
} }
- (void)populateAllNodesFromNode:(nonnull NSString *)identifier inDictionary:(nonnull NSMutableDictionary<NSString*, NSDictionary*> *)mutableDict {
NSDictionary *nodeDict = [self getNode:identifier];
mutableDict[identifier] = nodeDict;
NSArray *arr = nodeDict[@"children"];
for (NSString *childIdentifier in arr) {
[self populateAllNodesFromNode:childIdentifier inDictionary:mutableDict];
}
return;
}
- (void)onCallGetAllNodesWithResponder:(nonnull id<FlipperResponder>)responder {
NSMutableArray<NSDictionary*> *allNodes = @[].mutableCopy;
NSString *identifier = [self trackObject: _rootNode];
NSDictionary *rootNode = [self getNode: identifier];
if (!rootNode) {
return [responder error:@{@"error": [NSString stringWithFormat:@"getNode returned nil for the rootNode %@, while getting all the nodes", identifier]}];
}
[allNodes addObject:rootNode];
NSMutableDictionary *allNodesDict = @{}.mutableCopy;
[self populateAllNodesFromNode:identifier inDictionary:allNodesDict];
[responder success:@{@"allNodes": @{@"rootElement": identifier, @"elements": allNodesDict}}];
}
- (void)onCallGetNodes:(NSArray<NSDictionary *> *)nodeIds withResponder:(id<FlipperResponder>)responder { - (void)onCallGetNodes:(NSArray<NSDictionary *> *)nodeIds withResponder:(id<FlipperResponder>)responder {
NSMutableArray<NSDictionary *> *elements = [NSMutableArray new]; NSMutableArray<NSDictionary *> *elements = [NSMutableArray new];
@@ -417,6 +444,10 @@
return objectIdentifier; return objectIdentifier;
} }
- (BOOL)runInBackground {
return true;
}
@end @end
#endif #endif

View File

@@ -171,7 +171,9 @@ export default class Client extends EventEmitter {
// get the supported plugins // get the supported plugins
async getPlugins(): Promise<Plugins> { async getPlugins(): Promise<Plugins> {
const plugins = await this.rawCall('getPlugins').then(data => data.plugins); const plugins = await this.rawCall('getPlugins', false).then(
data => data.plugins,
);
this.plugins = plugins; this.plugins = plugins;
return plugins; return plugins;
} }
@@ -345,7 +347,11 @@ export default class Client extends EventEmitter {
methodCallbacks.delete(callback); methodCallbacks.delete(callback);
} }
rawCall(method: string, params?: Object): Promise<Object> { rawCall(
method: string,
fromPlugin: boolean,
params?: Object,
): Promise<Object> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const id = this.messageIdCounter++; const id = this.messageIdCounter++;
const metadata: RequestMetadata = { const metadata: RequestMetadata = {
@@ -378,13 +384,13 @@ export default class Client extends EventEmitter {
const mark = this.getPerformanceMark(metadata); const mark = this.getPerformanceMark(metadata);
performance.mark(mark); performance.mark(mark);
if (this.isAcceptingMessagesFromPlugin(plugin)) { if (!fromPlugin || this.isAcceptingMessagesFromPlugin(plugin)) {
this.connection && this.connection &&
this.connection this.connection
.requestResponse({data: JSON.stringify(data)}) .requestResponse({data: JSON.stringify(data)})
.subscribe({ .subscribe({
onComplete: payload => { onComplete: payload => {
if (this.isAcceptingMessagesFromPlugin(plugin)) { if (!fromPlugin || this.isAcceptingMessagesFromPlugin(plugin)) {
const logEventName = this.getLogEventName(data); const logEventName = this.getLogEventName(data);
this.logger.trackTimeSince(mark, logEventName); this.logger.trackTimeSince(mark, logEventName);
const response: {| const response: {|
@@ -452,9 +458,14 @@ export default class Client extends EventEmitter {
} }
} }
call(api: string, method: string, params?: Object): Promise<Object> { call(
api: string,
method: string,
fromPlugin: boolean,
params?: Object,
): Promise<Object> {
return reportPluginFailures( return reportPluginFailures(
this.rawCall('execute', {api, method, params}), this.rawCall('execute', fromPlugin, {api, method, params}),
`Call-${method}`, `Call-${method}`,
api, api,
); );
@@ -474,7 +485,7 @@ export default class Client extends EventEmitter {
if (this.sdkVersion < 2) { if (this.sdkVersion < 2) {
return Promise.resolve(false); return Promise.resolve(false);
} }
return this.rawCall('isMethodSupported', {api, method}).then( return this.rawCall('isMethodSupported', true, {api, method}).then(
response => response.isSupported, response => response.isSupported,
); );
} }

View File

@@ -8,7 +8,7 @@ import type {FlipperPlugin, FlipperDevicePlugin} from './plugin.js';
import type {Logger} from './fb-interfaces/Logger'; import type {Logger} from './fb-interfaces/Logger';
import BaseDevice from './devices/BaseDevice.js'; import BaseDevice from './devices/BaseDevice.js';
import type {Props as PluginProps} from './plugin'; import type {Props as PluginProps} from './plugin';
import {pluginKey as getPluginKey} from './reducers/pluginStates';
import Client from './Client.js'; import Client from './Client.js';
import { import {
ErrorBoundary, ErrorBoundary,
@@ -168,7 +168,7 @@ export default connect<Props, OwnProps, _, _, _, _>(
} }
target = selectedDevice; target = selectedDevice;
if (activePlugin) { if (activePlugin) {
pluginKey = `${selectedDevice.serial}#${activePlugin.id}`; pluginKey = getPluginKey(selectedDevice.serial, activePlugin.id);
} else { } else {
target = clients.find((client: Client) => client.id === selectedApp); target = clients.find((client: Client) => client.id === selectedApp);
activePlugin = clientPlugins.get(selectedPlugin); activePlugin = clientPlugins.get(selectedPlugin);
@@ -177,7 +177,7 @@ export default connect<Props, OwnProps, _, _, _, _>(
`Plugin "${selectedPlugin || ''}" could not be found.`, `Plugin "${selectedPlugin || ''}" could not be found.`,
); );
} }
pluginKey = `${target.id}#${activePlugin.id}`; pluginKey = getPluginKey(target.id, activePlugin.id);
} }
} }

View File

@@ -14,8 +14,10 @@ export {
FlipperBasePlugin, FlipperBasePlugin,
FlipperPlugin, FlipperPlugin,
FlipperDevicePlugin, FlipperDevicePlugin,
callClient,
} from './plugin.js'; } from './plugin.js';
export type {PluginClient} from './plugin.js'; export type {PluginClient} from './plugin.js';
export {default as Client} from './Client.js';
export {clipboard} from 'electron'; export {clipboard} from 'electron';
export * from './fb-stubs/constants.js'; export * from './fb-stubs/constants.js';
export * from './fb-stubs/createPaste.js'; export * from './fb-stubs/createPaste.js';

View File

@@ -19,6 +19,15 @@ import IOSDevice from './devices/IOSDevice';
const invariant = require('invariant'); const invariant = require('invariant');
// This function is intended to be called from outside of the plugin.
// If you want to `call` from the plugin use, this.client.call
export function callClient(
client: Client,
id: string,
): (string, ?Object) => Promise<Object> {
return (method, params) => client.call(id, method, false, params);
}
export type PluginClient = {| export type PluginClient = {|
send: (method: string, params?: Object) => void, send: (method: string, params?: Object) => void,
call: (method: string, params?: Object) => Promise<any>, call: (method: string, params?: Object) => Promise<any>,
@@ -69,6 +78,11 @@ export class FlipperBasePlugin<
method: string, method: string,
data: Object, data: Object,
) => $Shape<PersistedState>; ) => $Shape<PersistedState>;
static exportPersistedState: ?(
callClient: (string, ?Object) => Promise<Object>,
persistedState: ?PersistedState,
store: ?Store,
) => Promise<?PersistedState>;
static getActiveNotifications: ?( static getActiveNotifications: ?(
persistedState: PersistedState, persistedState: PersistedState,
) => Array<Notification>; ) => Array<Notification>;
@@ -160,7 +174,7 @@ export class FlipperPlugin<S = *, A = *, P = *> extends FlipperBasePlugin<
// $FlowFixMe props.target will be instance of Client // $FlowFixMe props.target will be instance of Client
this.realClient = props.target; this.realClient = props.target;
this.client = { this.client = {
call: (method, params) => this.realClient.call(id, method, params), call: (method, params) => this.realClient.call(id, method, true, params),
send: (method, params) => this.realClient.send(id, method, params), send: (method, params) => this.realClient.send(id, method, params),
subscribe: (method, callback) => { subscribe: (method, callback) => {
this.subscriptions.push({ this.subscriptions.push({

View File

@@ -5,7 +5,7 @@
* @format * @format
*/ */
import type {ElementID, Element, ElementSearchResultSet} from 'flipper'; import type {ElementID, Element, ElementSearchResultSet, Store} from 'flipper';
import { import {
FlexColumn, FlexColumn,
@@ -51,6 +51,22 @@ const BetaBar = styled(Toolbar)({
}); });
export default class Layout extends FlipperPlugin<State, void, PersistedState> { export default class Layout extends FlipperPlugin<State, void, PersistedState> {
static exportPersistedState = (
callClient: (string, ?Object) => Promise<Object>,
persistedState: ?PersistedState,
store: ?Store,
): Promise<?PersistedState> => {
const defaultPromise = Promise.resolve(persistedState);
if (!store) {
return defaultPromise;
}
const selectedDevice = store.getState().connections.selectedDevice;
if (selectedDevice && selectedDevice.os === 'iOS') {
return callClient('getAllNodes').then(({allNodes}) => allNodes);
}
return defaultPromise;
};
static defaultPersistedState = { static defaultPersistedState = {
rootElement: null, rootElement: null,
rootAXElement: null, rootAXElement: null,
@@ -68,11 +84,6 @@ export default class Layout extends FlipperPlugin<State, void, PersistedState> {
searchResults: null, searchResults: null,
}; };
componentDidMount() {
// reset the data, as it might be outdated
this.props.setPersistedState(Layout.defaultPersistedState);
}
init() { init() {
// persist searchActive state when moving between plugins to prevent multiple // persist searchActive state when moving between plugins to prevent multiple
// TouchOverlayViews since we can't edit the view heirarchy in onDisconnect // TouchOverlayViews since we can't edit the view heirarchy in onDisconnect

View File

@@ -9,6 +9,10 @@ export type State = {
[pluginKey: string]: Object, [pluginKey: string]: Object,
}; };
export const pluginKey = (serial: string, pluginName: string): string => {
return `${serial}#${pluginName}`;
};
export type Action = export type Action =
| { | {
type: 'SET_PLUGIN_STATE', type: 'SET_PLUGIN_STATE',

View File

@@ -10,8 +10,8 @@ import type {State as PluginStates} from '../reducers/pluginStates';
import type {PluginNotification} from '../reducers/notifications.js'; import type {PluginNotification} from '../reducers/notifications.js';
import type {ClientExport} from '../Client.js'; import type {ClientExport} from '../Client.js';
import type {State as PluginStatesState} from '../reducers/pluginStates'; import type {State as PluginStatesState} from '../reducers/pluginStates';
import type {State} from '../reducers/index'; import {pluginKey} from '../reducers/pluginStates';
import {FlipperDevicePlugin} from '../plugin.js'; import {FlipperDevicePlugin, FlipperPlugin, callClient} from '../plugin.js';
import {default as BaseDevice} from '../devices/BaseDevice'; import {default as BaseDevice} from '../devices/BaseDevice';
import {default as ArchivedDevice} from '../devices/ArchivedDevice'; import {default as ArchivedDevice} from '../devices/ArchivedDevice';
import {default as Client} from '../Client'; import {default as Client} from '../Client';
@@ -174,17 +174,51 @@ export const processStore = (
return null; return null;
}; };
export function serializeStore(state: State): ?ExportType { export async function serializeStore(store: Store): Promise<?ExportType> {
const {activeNotifications} = state.notifications; const state = store.getState();
const {selectedDevice, clients} = state.connections; const {clients} = state.connections;
const {pluginStates} = state; const {pluginStates} = state;
const {devicePlugins} = state.plugins; const {plugins} = state;
const newPluginState = {...pluginStates};
// TODO: T39612653 Make Client mockable. Currently rsocket logic is tightly coupled. // TODO: T39612653 Make Client mockable. Currently rsocket logic is tightly coupled.
// Not passing the entire state as currently Client is not mockable. // Not passing the entire state as currently Client is not mockable.
const pluginsMap: Map<
string,
Class<FlipperDevicePlugin<> | FlipperPlugin<>>,
> = new Map([]);
plugins.clientPlugins.forEach((val, key) => {
pluginsMap.set(key, val);
});
plugins.devicePlugins.forEach((val, key) => {
pluginsMap.set(key, val);
});
for (let client of clients) {
for (let plugin of client.plugins) {
const pluginClass: ?Class<
FlipperDevicePlugin<> | FlipperPlugin<>,
> = plugin ? pluginsMap.get(plugin) : null;
const exportState = pluginClass ? pluginClass.exportPersistedState : null;
if (exportState) {
const key = pluginKey(client.id, plugin);
const data = await exportState(
callClient(client, plugin),
newPluginState[key],
store,
);
newPluginState[key] = data;
}
}
}
const {activeNotifications} = store.getState().notifications;
const {selectedDevice} = store.getState().connections;
const {devicePlugins} = store.getState().plugins;
return processStore( return processStore(
activeNotifications, activeNotifications,
selectedDevice, selectedDevice,
pluginStates, newPluginState,
clients.map(client => client.toJSON()), clients.map(client => client.toJSON()),
devicePlugins, devicePlugins,
uuid.v4(), uuid.v4(),
@@ -193,21 +227,21 @@ export function serializeStore(state: State): ?ExportType {
export const exportStoreToFile = ( export const exportStoreToFile = (
exportFilePath: string, exportFilePath: string,
data: Store, store: Store,
): Promise<void> => { ): Promise<void> => {
const json = serializeStore(data.getState()); return new Promise(async (resolve, reject) => {
if (json) { const json = await serializeStore(store);
return new Promise((resolve, reject) => { if (!json) {
fs.writeFile(exportFilePath, serialize(json), err => { console.error('Make sure a device is connected');
if (err) { reject('No device is selected');
reject(err); }
} fs.writeFile(exportFilePath, serialize(json), err => {
resolve(); if (err) {
}); reject(err);
}
resolve();
}); });
} });
console.error('Make sure a device is connected');
return new Promise.reject(new Error('No device is selected'));
}; };
export const importFileToStore = (file: string, store: Store) => { export const importFileToStore = (file: string, store: Store) => {