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 {