From b65581262a1dc637270b00b1cb81d3cf4e74aeb6 Mon Sep 17 00:00:00 2001 From: Pritesh Nandgaonkar Date: Fri, 8 Mar 2019 10:15:59 -0800 Subject: [PATCH] Enable search on layout2 for archived devices Summary: Enables search on the imported layout data. The way search is implemented is that the Flipper app asks for the search results from the mobile clients. Since mobile client will not exist in the archived case, the search won't work. To solve this problem I added a proxy client which will get all the messages fired by the Layout Inspector and it will accordingly revert back with the responses. In the case of search, it will give back the search result tree for a particular query. Also added extensive tests for the proxy client Reviewed By: danielbuechele Differential Revision: D14281856 fbshipit-source-id: 651436084ebacd57f86e4fe9bb2036e7f666880c --- src/PluginContainer.js | 21 +- src/index.js | 1 + src/plugin.js | 17 +- .../layout/layout2/ProxyArchiveClient.js | 169 +++++++ src/plugins/layout/layout2/Search.js | 6 +- .../__tests__/ProxyArchiveClient.node.js | 414 ++++++++++++++++++ src/plugins/layout/layout2/index.js | 20 +- src/plugins/layout/package.json | 1 + src/plugins/layout/yarn.lock | 5 + 9 files changed, 633 insertions(+), 21 deletions(-) create mode 100644 src/plugins/layout/layout2/ProxyArchiveClient.js create mode 100644 src/plugins/layout/layout2/__tests__/ProxyArchiveClient.node.js diff --git a/src/PluginContainer.js b/src/PluginContainer.js index 7b5f8e82a..2d1ad5a96 100644 --- a/src/PluginContainer.js +++ b/src/PluginContainer.js @@ -17,6 +17,7 @@ import { FlexRow, colors, styled, + ArchivedDevice, } from 'flipper'; import React from 'react'; import {connect} from 'react-redux'; @@ -66,6 +67,7 @@ type Props = {| pluginKey: string, state: Object, }) => void, + isArchivedDevice: boolean, |}; class PluginContainer extends PureComponent { @@ -91,20 +93,21 @@ class PluginContainer extends PureComponent { activePlugin, pluginKey, target, + isArchivedDevice, } = this.props; - if (!activePlugin || !target) { return null; } const props: PluginProps = { key: pluginKey, logger: this.props.logger, - persistedState: activePlugin.defaultPersistedState - ? { - ...activePlugin.defaultPersistedState, - ...pluginState, - } - : pluginState, + persistedState: + !isArchivedDevice && activePlugin.defaultPersistedState + ? { + ...activePlugin.defaultPersistedState, + ...pluginState, + } + : pluginState, setPersistedState: state => setPluginState({pluginKey, state}), target, deepLinkPayload: this.props.deepLinkPayload, @@ -125,8 +128,8 @@ class PluginContainer extends PureComponent { } }, ref: this.refChanged, + isArchivedDevice, }; - return ( @@ -180,6 +183,7 @@ export default connect( pluginKey = getPluginKey(target.id, activePlugin.id); } } + const isArchivedDevice = selectedDevice instanceof ArchivedDevice; return { pluginState: pluginStates[pluginKey], @@ -187,6 +191,7 @@ export default connect( target, deepLinkPayload, pluginKey, + isArchivedDevice, }; }, // $FlowFixMe diff --git a/src/index.js b/src/index.js index dc8084338..f054e260c 100644 --- a/src/index.js +++ b/src/index.js @@ -40,6 +40,7 @@ export {createTablePlugin} from './createTablePlugin.js'; export {default as DetailSidebar} from './chrome/DetailSidebar.js'; export {default as AndroidDevice} from './devices/AndroidDevice.js'; +export {default as ArchivedDevice} from './devices/ArchivedDevice.js'; export {default as Device} from './devices/BaseDevice.js'; export {default as IOSDevice} from './devices/IOSDevice.js'; export type {OS} from './devices/BaseDevice.js'; diff --git a/src/plugin.js b/src/plugin.js index aa062ed53..8513fc858 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -28,12 +28,16 @@ export function callClient( return (method, params) => client.call(id, method, false, params); } -export type PluginClient = {| - send: (method: string, params?: Object) => void, - call: (method: string, params?: Object) => Promise, - subscribe: (method: string, callback: (params: any) => void) => void, - supportsMethod: (method: string) => Promise, -|}; +export interface PluginClient { + // eslint-disable-next-line + send(method: string, params?: Object): void; + // eslint-disable-next-line + call(method: string, params?: Object): Promise; + // eslint-disable-next-line + subscribe(method: string, callback: (params: any) => void): void; + // eslint-disable-next-line + supportsMethod(method: string): Promise; +} type PluginTarget = BaseDevice | Client; @@ -54,6 +58,7 @@ export type Props = { target: PluginTarget, deepLinkPayload: ?string, selectPlugin: (pluginID: string, deepLinkPayload: ?string) => boolean, + isArchivedDevice: boolean, }; export class FlipperBasePlugin< diff --git a/src/plugins/layout/layout2/ProxyArchiveClient.js b/src/plugins/layout/layout2/ProxyArchiveClient.js new file mode 100644 index 000000000..78a95b9ce --- /dev/null +++ b/src/plugins/layout/layout2/ProxyArchiveClient.js @@ -0,0 +1,169 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + */ + +import type {Element, ElementID} from 'flipper'; +import type {PersistedState} from './index'; +import type {SearchResultTree} from './Search'; +import {cloneDeep} from 'lodash'; + +const propsForPersistedState = ( + AXMode: boolean, +): {ROOT: string, ELEMENTS: string, ELEMENT: string} => { + return { + ROOT: AXMode ? 'rootAXElement' : 'rootElement', + ELEMENTS: AXMode ? 'AXelements' : 'elements', + ELEMENT: AXMode ? 'axElement' : 'element', + }; +}; + +function constructSearchResultTree( + node: Element, + isMatch: boolean, + children: Array, + AXMode: boolean, +): SearchResultTree { + let searchResult = { + id: node.id, + isMatch, + hasChildren: children.length > 0, + children: children.length > 0 ? children : null, + element: node, + axElement: null, + }; + searchResult[`${propsForPersistedState(AXMode).ELEMENT}`] = node; + return searchResult; +} + +function isMatch(element: Element, query: string): boolean { + const nameMatch = element.name.toLowerCase().includes(query.toLowerCase()); + return nameMatch || element.id === query; +} + +export function searchNodes( + node: Element, + query: string, + AXMode: boolean, + state: PersistedState, +): ?SearchResultTree { + const elements = state[propsForPersistedState(AXMode).ELEMENTS]; + const children: Array = []; + const match = isMatch(node, query); + + for (const childID of node.children) { + const child = elements[childID]; + const tree = searchNodes(child, query, AXMode, state); + if (tree) { + children.push(tree); + } + } + + if (match || children.length > 0) { + return cloneDeep(constructSearchResultTree(node, match, children, AXMode)); + } + return null; +} + +class ProxyArchiveClient { + constructor(persistedState: PersistedState) { + this.persistedState = cloneDeep(persistedState); + } + persistedState: PersistedState; + subscribe(method: string, callback: (params: any) => void): void { + return; + } + + supportsMethod(method: string): Promise { + return Promise.resolve(false); + } + + send(method: string, params?: Object): void { + return; + } + + call(method: string, params?: Object): Promise { + const paramaters = params; + switch (method) { + case 'getRoot': { + const {rootElement} = this.persistedState; + if (!rootElement) { + return Promise.resolve(null); + } + return Promise.resolve(this.persistedState.elements[rootElement]); + } + case 'getAXRoot': { + const {rootAXElement} = this.persistedState; + if (!rootAXElement) { + return Promise.resolve(null); + } + return Promise.resolve(this.persistedState.AXelements[rootAXElement]); + } + case 'getNodes': { + if (!paramaters) { + return Promise.reject(new Error('Called getNodes with no params')); + } + const {ids} = paramaters; + const arr: Array = []; + for (let id: ElementID of ids) { + arr.push(this.persistedState.elements[id]); + } + return Promise.resolve({elements: arr}); + } + case 'getAXNodes': { + if (!paramaters) { + return Promise.reject(new Error('Called getAXNodes with no params')); + } + const {ids} = paramaters; + const arr: Array = []; + for (let id: ElementID of ids) { + arr.push(this.persistedState.AXelements[id]); + } + return Promise.resolve({elements: arr}); + } + case 'getSearchResults': { + const {rootElement, rootAXElement} = this.persistedState; + + if (!paramaters) { + return Promise.reject( + new Error('Called getSearchResults with no params'), + ); + } + const {query, axEnabled} = paramaters; + if (!query) { + return Promise.reject( + new Error('query is not passed as a params to getSearchResults'), + ); + } + let element = {}; + if (axEnabled) { + if (!rootAXElement) { + return Promise.reject(new Error('rootAXElement is undefined')); + } + element = this.persistedState.AXelements[rootAXElement]; + } else { + if (!rootElement) { + return Promise.reject(new Error('rootElement is undefined')); + } + element = this.persistedState.elements[rootElement]; + } + const output = searchNodes( + element, + query, + axEnabled, + this.persistedState, + ); + return Promise.resolve({results: output, query}); + } + case 'isConsoleEnabled': { + return Promise.resolve(false); + } + default: { + return Promise.resolve(); + } + } + } +} +export default ProxyArchiveClient; diff --git a/src/plugins/layout/layout2/Search.js b/src/plugins/layout/layout2/Search.js index d17518640..7a47c7db6 100644 --- a/src/plugins/layout/layout2/Search.js +++ b/src/plugins/layout/layout2/Search.js @@ -18,13 +18,13 @@ import { } from 'flipper'; import {Component} from 'react'; -type SearchResultTree = {| +export type SearchResultTree = {| id: string, - isMatch: Boolean, + isMatch: boolean, hasChildren: boolean, children: ?Array, element: Element, - axElement: Element, + axElement: ?Element, // Not supported in iOS |}; type Props = { diff --git a/src/plugins/layout/layout2/__tests__/ProxyArchiveClient.node.js b/src/plugins/layout/layout2/__tests__/ProxyArchiveClient.node.js new file mode 100644 index 000000000..cb397f0ec --- /dev/null +++ b/src/plugins/layout/layout2/__tests__/ProxyArchiveClient.node.js @@ -0,0 +1,414 @@ +/** + * Copyright 2018-present Facebook. + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * @format + */ + +import { + default as ProxyArchiveClient, + searchNodes, +} from '../ProxyArchiveClient'; +import type {PersistedState} from '../index'; +import type {ElementID, Element} from 'flipper'; +import type {SearchResultTree} from '../Search'; + +function constructElement( + id: string, + name: string, + children: Array, +): Element { + return { + id, + name, + expanded: false, + children, + attributes: [], + data: {}, + decoration: 'decoration', + extraInfo: {}, + }; +} + +function constructPersistedState(axMode: boolean): PersistedState { + if (!axMode) { + return { + rootElement: 'root', + rootAXElement: null, + elements: {}, + AXelements: {}, + }; + } + return { + rootElement: null, + rootAXElement: 'root', + elements: {}, + AXelements: {}, + }; +} +let state = constructPersistedState(false); + +function populateChildren(state: PersistedState, axMode: boolean) { + let elements = {}; + elements['root'] = constructElement('root', 'root view', [ + 'child0', + 'child1', + ]); + + elements['child0'] = constructElement('child0', 'child0 view', [ + 'child0_child0', + 'child0_child1', + ]); + elements['child1'] = constructElement('child1', 'child1 view', [ + 'child1_child0', + 'child1_child1', + ]); + elements['child0_child0'] = constructElement( + 'child0_child0', + 'child0_child0 view', + [], + ); + elements['child0_child1'] = constructElement( + 'child0_child1', + 'child0_child1 view', + [], + ); + elements['child1_child0'] = constructElement( + 'child1_child0', + 'child1_child0 view', + [], + ); + elements['child1_child1'] = constructElement( + 'child1_child1', + 'child1_child1 view', + [], + ); + if (axMode) { + state.AXelements = elements; + } else { + state.elements = elements; + } +} + +beforeEach(() => { + state = constructPersistedState(false); + populateChildren(state, false); +}); + +test('test the searchNode for root in axMode false', async () => { + let searchResult: ?SearchResultTree = await searchNodes( + state.elements['root'], + 'root', + false, + state, + ); + expect(searchResult).toBeDefined(); + expect(searchResult).toEqual({ + id: 'root', + isMatch: true, + hasChildren: false, + children: null, + element: state.elements['root'], + axElement: null, + }); +}); + +test('test the searchNode for root in axMode true', async () => { + state = constructPersistedState(true); + populateChildren(state, true); + let searchResult: ?SearchResultTree = await searchNodes( + state.AXelements['root'], + 'RoOT', + true, + state, + ); + expect(searchResult).toBeDefined(); + expect(searchResult).toEqual({ + id: 'root', + isMatch: true, + hasChildren: false, + children: null, + element: state.AXelements['root'], // Even though AXElement exists, normal element will exist too + axElement: state.AXelements['root'], + }); +}); + +test('test the searchNode which matches just one child', async () => { + let searchResult: ?SearchResultTree = await searchNodes( + state.elements['root'], + 'child0_child0', + false, + state, + ); + expect(searchResult).toBeDefined(); + expect(searchResult).toEqual({ + id: 'root', + isMatch: false, + hasChildren: true, + children: [ + { + id: 'child0', + isMatch: false, + hasChildren: true, + children: [ + { + id: 'child0_child0', + isMatch: true, + hasChildren: false, + children: null, + element: state.elements['child0_child0'], + axElement: null, + }, + ], + element: state.elements['child0'], + axElement: null, + }, + ], + element: state.elements['root'], + axElement: null, + }); +}); + +test('test the searchNode for which matches multiple child', async () => { + let searchResult: ?SearchResultTree = await searchNodes( + state.elements['root'], + 'child0', + false, + state, + ); + expect(searchResult).toBeDefined(); + let expectedSearchResult = { + id: 'root', + isMatch: false, + hasChildren: true, + children: [ + { + id: 'child0', + isMatch: true, + hasChildren: true, + children: [ + { + id: 'child0_child0', + isMatch: true, + hasChildren: false, + children: null, + element: state.elements['child0_child0'], + axElement: null, + }, + { + id: 'child0_child1', + isMatch: true, + hasChildren: false, + children: null, + element: state.elements['child0_child1'], + axElement: null, + }, + ], + element: state.elements['child0'], + axElement: null, + }, + { + id: 'child1', + isMatch: false, + hasChildren: true, + children: [ + { + id: 'child1_child0', + isMatch: true, + hasChildren: false, + children: null, + element: state.elements['child1_child0'], + axElement: null, + }, + ], + element: state.elements['child1'], + axElement: null, + }, + ], + element: state.elements['root'], + axElement: null, + }; + expect(searchResult).toEqual(expectedSearchResult); +}); + +test('test the searchNode, it should not be case sensitive', async () => { + let searchResult: ?SearchResultTree = await searchNodes( + state.elements['root'], + 'ChIlD0', + false, + state, + ); + expect(searchResult).toBeDefined(); + let expectedSearchResult = { + id: 'root', + isMatch: false, + hasChildren: true, + children: [ + { + id: 'child0', + isMatch: true, + hasChildren: true, + children: [ + { + id: 'child0_child0', + isMatch: true, + hasChildren: false, + children: null, + element: state.elements['child0_child0'], + axElement: null, + }, + { + id: 'child0_child1', + isMatch: true, + hasChildren: false, + children: null, + element: state.elements['child0_child1'], + axElement: null, + }, + ], + element: state.elements['child0'], + axElement: null, + }, + { + id: 'child1', + isMatch: false, + hasChildren: true, + children: [ + { + id: 'child1_child0', + isMatch: true, + hasChildren: false, + children: null, + element: state.elements['child1_child0'], + axElement: null, + }, + ], + element: state.elements['child1'], + axElement: null, + }, + ], + element: state.elements['root'], + axElement: null, + }; + expect(searchResult).toEqual(expectedSearchResult); +}); + +test('test the searchNode for non existent query', async () => { + let searchResult: ?SearchResultTree = await searchNodes( + state.elements['root'], + 'Unknown query', + false, + state, + ); + expect(searchResult).toBeNull(); +}); + +test('test the call method with getRoot', async () => { + let proxyClient = new ProxyArchiveClient(state); + let root: Element = await proxyClient.call('getRoot'); + expect(root).toEqual(state.elements['root']); +}); + +test('test the call method with getAXRoot', async () => { + state = constructPersistedState(true); + populateChildren(state, true); + let proxyClient = new ProxyArchiveClient(state); + let root: Element = await proxyClient.call('getAXRoot'); + expect(root).toEqual(state.AXelements['root']); +}); + +test('test the call method with getNodes', async () => { + let proxyClient = new ProxyArchiveClient(state); + let nodes: Array = await proxyClient.call('getNodes', { + ids: ['child0_child1', 'child1_child0'], + }); + expect(nodes).toEqual({ + elements: [ + { + id: 'child0_child1', + name: 'child0_child1 view', + expanded: false, + children: [], + attributes: [], + data: {}, + decoration: 'decoration', + extraInfo: {}, + }, + { + id: 'child1_child0', + name: 'child1_child0 view', + expanded: false, + children: [], + attributes: [], + data: {}, + decoration: 'decoration', + extraInfo: {}, + }, + ], + }); +}); + +test('test the call method with getAXNodes', async () => { + state = constructPersistedState(true); + populateChildren(state, true); + let proxyClient = new ProxyArchiveClient(state); + let nodes: Array = await proxyClient.call('getAXNodes', { + ids: ['child0_child1', 'child1_child0'], + }); + expect(nodes).toEqual({ + elements: [ + { + id: 'child0_child1', + name: 'child0_child1 view', + expanded: false, + children: [], + attributes: [], + data: {}, + decoration: 'decoration', + extraInfo: {}, + }, + { + id: 'child1_child0', + name: 'child1_child0 view', + expanded: false, + children: [], + attributes: [], + data: {}, + decoration: 'decoration', + extraInfo: {}, + }, + ], + }); +}); + +test('test different methods of calls with no params', async () => { + let proxyClient = new ProxyArchiveClient(state); + await expect(proxyClient.call('getNodes')).rejects.toThrow( + new Error('Called getNodes with no params'), + ); + await expect(proxyClient.call('getAXNodes')).rejects.toThrow( + new Error('Called getAXNodes with no params'), + ); + // let result: Error = await proxyClient.call('getSearchResults'); + await expect(proxyClient.call('getSearchResults')).rejects.toThrow( + new Error('Called getSearchResults with no params'), + ); + await expect( + proxyClient.call('getSearchResults', { + query: 'random', + axEnabled: true, + }), + ).rejects.toThrow(new Error('rootAXElement is undefined')); + await expect( + proxyClient.call('getSearchResults', { + axEnabled: false, + }), + ).rejects.toThrow( + new Error('query is not passed as a params to getSearchResults'), + ); +}); + +test('test call method isConsoleEnabled', () => { + let proxyClient = new ProxyArchiveClient(state); + return expect(proxyClient.call('isConsoleEnabled')).resolves.toBe(false); +}); diff --git a/src/plugins/layout/layout2/index.js b/src/plugins/layout/layout2/index.js index 29f50e8ba..4700a1446 100644 --- a/src/plugins/layout/layout2/index.js +++ b/src/plugins/layout/layout2/index.js @@ -5,7 +5,13 @@ * @format */ -import type {ElementID, Element, ElementSearchResultSet, Store} from 'flipper'; +import type { + ElementID, + Element, + ElementSearchResultSet, + Store, + PluginClient, +} from 'flipper'; import { FlexColumn, @@ -22,6 +28,7 @@ import Inspector from './Inspector'; import ToolbarIcon from './ToolbarIcon'; import InspectorSidebar from './InspectorSidebar'; import Search from './Search'; +import ProxyArchiveClient from './ProxyArchiveClient'; type State = {| init: boolean, @@ -115,6 +122,11 @@ export default class Layout extends FlipperPlugin { this.setState({inAXMode: !this.state.inAXMode}); }; + getClient(): PluginClient { + return this.props.isArchivedDevice + ? new ProxyArchiveClient(this.props.persistedState) + : this.client; + } onToggleAlignmentMode = () => { if (this.state.selectedElement) { this.client.send('setHighlighted', { @@ -139,7 +151,7 @@ export default class Layout extends FlipperPlugin { render() { const inspectorProps = { - client: this.client, + client: this.getClient(), inAlignmentMode: this.state.inAlignmentMode, selectedElement: this.state.selectedElement, selectedAXElement: this.state.selectedAXElement, @@ -192,7 +204,7 @@ export default class Layout extends FlipperPlugin { active={this.state.inAlignmentMode} /> @@ -223,7 +235,7 @@ export default class Layout extends FlipperPlugin {