Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add functionality to search through app source tree #1494

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/common/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -283,5 +283,6 @@
"invalidCapType": "Invalid capability type: {{type}}",
"whitespaceDetected": "Text Starts and/or Ends With Whitespace",
"pcloudyCredentialsRequired": "pCloudy username and api key are required!",
"duplicateCapabilityNameError": "A capability set with the same name already exists"
"duplicateCapabilityNameError": "A capability set with the same name already exists",
"searchInPageSource": "Search in page source"
}
7 changes: 7 additions & 0 deletions app/common/renderer/actions/Inspector.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const SET_EXPANDED_PATHS = 'SET_EXPANDED_PATHS';
export const SET_OPTIMAL_LOCATORS = 'SET_OPTIMAL_LOCATORS';
export const SELECT_HOVERED_ELEMENT = 'SELECT_HOVERED_ELEMENT';
export const UNSELECT_HOVERED_ELEMENT = 'UNSELECT_HOVERED_ELEMENT';
export const SET_PAGE_SOURCE_SEARCH_TEXT = 'SET_PAGE_SOURCE_SEARCH_TEXT';

export const SELECT_HOVERED_CENTROID = 'SELECT_HOVERED_CENTROID';
export const UNSELECT_HOVERED_CENTROID = 'UNSELECT_HOVERED_CENTROID';
Expand Down Expand Up @@ -292,6 +293,12 @@ export function addAssignedVarCache(varName) {
};
}

export function setPageSourceSearchText(text) {
return (dispatch) => {
dispatch({type: SET_PAGE_SOURCE_SEARCH_TEXT, text});
};
}

export function setExpandedPaths(paths) {
return (dispatch) => {
dispatch({type: SET_EXPANDED_PATHS, paths});
Expand Down
17 changes: 14 additions & 3 deletions app/common/renderer/components/Inspector/Inspector.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import {
SelectOutlined,
TagOutlined,
ThunderboltOutlined,
SearchOutlined,
} from '@ant-design/icons';
import {Button, Card, Modal, Space, Spin, Switch, Tabs, Tooltip} from 'antd';
import {Button, Card, Modal, Row, Space, Spin, Switch, Tabs, Tooltip, Input} from 'antd';
import {debounce} from 'lodash';
import React, {useEffect, useRef, useState} from 'react';
import {useNavigate} from 'react-router-dom';
Expand Down Expand Up @@ -87,6 +88,7 @@ const Inspector = (props) => {
toggleShowAttributes,
isSourceRefreshOn,
windowSize,
setPageSourceSearchText,
t,
} = props;

Expand Down Expand Up @@ -322,7 +324,16 @@ const Inspector = (props) => {
</span>
}
extra={
<span>
<Row wrap={false}>
<div className={InspectorStyles['inspector-source-tree-search-input']}>
<Input
size="middle"
allowClear
placeholder={t('searchInPageSource')}
onChange={(e) => setPageSourceSearchText(e.target.value)}
prefix={<SearchOutlined />}
/>
</div>
<Tooltip title={t('Toggle Attributes')}>
<Button
type="text"
Expand All @@ -347,7 +358,7 @@ const Inspector = (props) => {
onClick={() => downloadXML(sourceXML)}
/>
</Tooltip>
</span>
</Row>
}
>
<Source {...props} />
Expand Down
9 changes: 9 additions & 0 deletions app/common/renderer/components/Inspector/Inspector.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -739,3 +739,12 @@
.option-inpt {
text-align: center;
}

.search-word-highlighted {
color: #272625;
background: #b6d932;
}

.inspector-source-tree-search-input {
max-width: 200px;
}
83 changes: 79 additions & 4 deletions app/common/renderer/components/Inspector/Source.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,42 @@
import {Spin, Tree} from 'antd';
import React from 'react';
import React, {useEffect} from 'react';
import {renderToString} from 'react-dom/server';

import {IMPORTANT_SOURCE_ATTRS} from '../../constants/source';
import {findNodeMatchingSearchTerm} from '../../utils/source-parsing';
import InspectorStyles from './Inspector.module.css';
import LocatorTestModal from './LocatorTestModal.jsx';
import SiriCommandModal from './SiriCommandModal.jsx';
import {uniq} from 'lodash';

/**
* Highlights the part of the node text in source tree that matches the search term.
* If HTML element contains a part of search value, then the content will be updated
* with a highlighted span. This span will have a class name 'tree-search-value' and
* will have a data attribute 'match' which will hold the search value. If no match found
* then the original node text will be returned.
*
* @param {string} nodeText - The text content of the node
* @param {string} searchText - The search term to highlight
* @returns {ReactNode} - The node text with highlighted search term
*
*/
const highlightNodeMatchingSearchTerm = (nodeText, searchText) => {
const searchResults = findNodeMatchingSearchTerm(nodeText, searchText);
if (!searchResults) {
return nodeText;
}
const {prefix, matchedWord, suffix} = searchResults;
return (
<>
{prefix}
<span className={InspectorStyles['search-word-highlighted']} data-match={searchText}>
{matchedWord}
</span>
{suffix}
</>
);
};

/**
* Shows the 'source' of the app as a Tree
Expand All @@ -20,6 +52,7 @@ const Source = (props) => {
methodCallInProgress,
mjpegScreenshotUrl,
isSourceRefreshOn,
pageSourceSearchText,
t,
} = props;

Expand All @@ -29,18 +62,25 @@ const Source = (props) => {

for (let attr of Object.keys(attributes)) {
if ((IMPORTANT_SOURCE_ATTRS.includes(attr) && attributes[attr]) || showAllAttrs) {
const keyNode = highlightNodeMatchingSearchTerm(attr, pageSourceSearchText);
const valueNode = highlightNodeMatchingSearchTerm(attributes[attr], pageSourceSearchText);

attrs.push(
<span key={attr}>
&nbsp;
<i className={InspectorStyles.sourceAttrName}>{attr}</i>=
<span className={InspectorStyles.sourceAttrValue}>&quot;{attributes[attr]}&quot;</span>
<i className={InspectorStyles.sourceAttrName}>{keyNode}</i>=
<span className={InspectorStyles.sourceAttrValue}>&quot;{valueNode}&quot;</span>
</span>,
);
}
}

return (
<span>
&lt;<b className={InspectorStyles.sourceTag}>{tagName}</b>
&lt;
<b className={InspectorStyles.sourceTag}>
{highlightNodeMatchingSearchTerm(tagName, pageSourceSearchText)}
</b>
{attrs}&gt;
</span>
);
Expand Down Expand Up @@ -75,6 +115,41 @@ const Source = (props) => {

const treeData = sourceJSON && recursive(sourceJSON);

useEffect(() => {
if (!treeData || !pageSourceSearchText) {
return;
}

const nodesMatchingSearchTerm = [];

/**
* If any search text is entered, we will try to find matching nodes in the tree.
* and expand their parents to make the nodes visible that matches the
* search text.
*
* hierarchy is an array of node keys representing the path from the root to the
* current node.
*/
const findNodesToExpand = (node, hierarchy) => {
/* Node title will an object representing a react element.
* renderToString method will construct a HTML DOM string
* which can be used to match against the search text.
*
* If any node that matches the search text is found, we will add all its
* parents to the 'nodesMatchingSearchTerm' array to make them automatically expand.
*/
const nodeText = renderToString(node.title).toLowerCase();
if (nodeText.includes(pageSourceSearchText.toLowerCase())) {
nodesMatchingSearchTerm.push(...hierarchy);
}
if (node.children) {
node.children.forEach((c) => findNodesToExpand(c, [...hierarchy, node.key]));
}
};
treeData.forEach((node) => findNodesToExpand(node, [node.key]));
setExpandedPaths(uniq(nodesMatchingSearchTerm));
}, [pageSourceSearchText]);

return (
<div id="sourceContainer" className={InspectorStyles['tree-container']} tabIndex="0">
{!sourceJSON && !sourceError && <i>{t('Gathering initial app source…')}</i>}
Expand Down
8 changes: 7 additions & 1 deletion app/common/renderer/reducers/Inspector.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
SET_COORD_END,
SET_COORD_START,
SET_EXPANDED_PATHS,
SET_PAGE_SOURCE_SEARCH_TEXT,
SET_OPTIMAL_LOCATORS,
SET_GESTURE_TAP_COORDS_MODE,
SET_INTERACTIONS_NOT_AVAILABLE,
Expand Down Expand Up @@ -127,6 +128,7 @@ const INITIAL_STATE = {
visibleCommandMethod: null,
isAwaitingMjpegStream: true,
showSourceAttrs: false,
pageSourceSearchText: '',
};

let nextState;
Expand Down Expand Up @@ -258,7 +260,11 @@ export default function inspector(state = INITIAL_STATE, action) {
expandedPaths: action.paths,
findElementsExecutionTimes: [],
};

case SET_PAGE_SOURCE_SEARCH_TEXT:
return {
...state,
pageSourceSearchText: action.text,
};
case START_RECORDING:
return {
...state,
Expand Down
24 changes: 24 additions & 0 deletions app/common/renderer/utils/source-parsing.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,27 @@ export function xmlToJSON(sourceXML) {

return firstChild ? translateRecursively(firstChild) : {};
}

/**
* Finds the text that matches the search term for higlighting
*
* @param {string} nodeText
* @param {string} searchText
* @returns {null|Object} details of the highlited information
*/

export function findNodeMatchingSearchTerm(nodeText, searchText) {
if (!searchText || !nodeText) {
return null;
}

const index = nodeText.toLowerCase().indexOf(searchText.toLowerCase());
if (index < 0) {
return null;
}
const prefix = nodeText.substring(0, index);
const suffix = nodeText.slice(index + searchText.length);
// Matched word will be wrapped in a separate span for custom highlighting
const matchedWord = nodeText.slice(index, index + searchText.length);
return {prefix, matchedWord, suffix};
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/session-inspector/assets/images/source/app-source.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/session-inspector/assets/images/source/source-tab.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions docs/session-inspector/source.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ behavior. While the default source refresh behavior in MJPEG mode stays the same
[automatic source refresh button](./header.md#toggle-automatic-source-refresh) in the application
header, which allows to disable automatic refreshing.

### Searching the Source

![Search Page Source Input](./assets/images/source/search-page-source.png)

Search page source functionality enables quick and easy navigation through the page source XML tree by searching for nodes based on element tag names or XML attributes. Matches are automatically highlighted and expanded, allowing for immediate identification and access to relevant nodes.

![Search Page Source Shown](./assets/images/source/search-page-source-highlighted.png)

### Toggle Attributes Button

![Toggle Attributes Button](./assets/images/source/toggle-attributes-button.png)
Expand Down
52 changes: 52 additions & 0 deletions test/unit/utils-source-parsing.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
findDOMNodeByPath,
findJSONElementByPath,
xmlToJSON,
findNodeMatchingSearchTerm,
} from '../../app/common/renderer/utils/source-parsing';

describe('utils/source-parsing.js', function () {
Expand Down Expand Up @@ -514,4 +515,55 @@ describe('utils/source-parsing.js', function () {
});
});
});

describe('#findNodeMatchingSearchTerm', function () {
it('should return the null when search value is empty', function () {
const matcher = findNodeMatchingSearchTerm('android.widget.FrameLayout', '');
expect(matcher).toEqual(null);
});

it('should return null when search value is undefined', function () {
const matcher = findNodeMatchingSearchTerm('android.widget.FrameLayout');
expect(matcher).toEqual(null);
});

it('should return null when the value is undefined', function () {
const matcher = findNodeMatchingSearchTerm(undefined, 'widget');
expect(matcher).toEqual(null);
});

it('should return null when the value is empty', function () {
const matcher = findNodeMatchingSearchTerm('', 'widget');
expect(matcher).toEqual(null);
});

it('should return null when search value is not matched', function () {
const matcher = findNodeMatchingSearchTerm('android.widget.FrameLayout', 'login');
expect(matcher).toEqual(null);
});

it('should return valid prefix, suffix and matched if a part of text matches the search value in lowercase', function () {
const matcher = findNodeMatchingSearchTerm('android.Widget.FrameLayout', 'widget');
expect(matcher.prefix).toEqual('android.');
expect(matcher.matchedWord).toEqual('Widget');
expect(matcher.suffix).toEqual('.FrameLayout');
});

it('should return valid prefix, suffix and matched if a part of text matches the search value in uppercase', function () {
const matcher = findNodeMatchingSearchTerm('android.Widget.FrameLayout', 'WIDGET');
expect(matcher.prefix).toEqual('android.');
expect(matcher.matchedWord).toEqual('Widget');
expect(matcher.suffix).toEqual('.FrameLayout');
});

it('should return valid prefix, suffix and matched if a part of text matches the search value exact matches', function () {
const matcher = findNodeMatchingSearchTerm(
'android.Widget.FrameLayout',
'android.Widget.FrameLayout',
);
expect(matcher.prefix).toEqual('');
expect(matcher.matchedWord).toEqual('android.Widget.FrameLayout');
expect(matcher.suffix).toEqual('');
});
});
});