-
+
}
>
diff --git a/app/common/renderer/components/Inspector/Inspector.module.css b/app/common/renderer/components/Inspector/Inspector.module.css
index 6fe013883c..ba82353bd9 100644
--- a/app/common/renderer/components/Inspector/Inspector.module.css
+++ b/app/common/renderer/components/Inspector/Inspector.module.css
@@ -739,3 +739,12 @@
.option-inpt {
text-align: center;
}
+
+.search-word-highlighted {
+ color: #272625;
+ background: #b6d932;
+}
+
+.inspector-source-tree-search-input {
+ max-width: 200px;
+}
diff --git a/app/common/renderer/components/Inspector/Source.jsx b/app/common/renderer/components/Inspector/Source.jsx
index 63abfad14a..ed596bc5ba 100644
--- a/app/common/renderer/components/Inspector/Source.jsx
+++ b/app/common/renderer/components/Inspector/Source.jsx
@@ -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}
+
+ {matchedWord}
+
+ {suffix}
+ >
+ );
+};
/**
* Shows the 'source' of the app as a Tree
@@ -20,6 +52,7 @@ const Source = (props) => {
methodCallInProgress,
mjpegScreenshotUrl,
isSourceRefreshOn,
+ pageSourceSearchText,
t,
} = props;
@@ -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(
- {attr}=
- "{attributes[attr]}"
+ {keyNode}=
+ "{valueNode}",
);
}
}
+
return (
- <{tagName}
+ <
+
+ {highlightNodeMatchingSearchTerm(tagName, pageSourceSearchText)}
+
{attrs}>
);
@@ -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 (
{!sourceJSON && !sourceError && {t('Gathering initial app sourceā¦')}}
diff --git a/app/common/renderer/reducers/Inspector.js b/app/common/renderer/reducers/Inspector.js
index bd646a779d..63042b8c43 100644
--- a/app/common/renderer/reducers/Inspector.js
+++ b/app/common/renderer/reducers/Inspector.js
@@ -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,
@@ -127,6 +128,7 @@ const INITIAL_STATE = {
visibleCommandMethod: null,
isAwaitingMjpegStream: true,
showSourceAttrs: false,
+ pageSourceSearchText: '',
};
let nextState;
@@ -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,
diff --git a/app/common/renderer/utils/source-parsing.js b/app/common/renderer/utils/source-parsing.js
index e5f08395e8..34402d1f3c 100644
--- a/app/common/renderer/utils/source-parsing.js
+++ b/app/common/renderer/utils/source-parsing.js
@@ -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};
+}
diff --git a/docs/session-inspector/assets/images/source/app-source-expanded.png b/docs/session-inspector/assets/images/source/app-source-expanded.png
index c39513cef3..62a991faab 100644
Binary files a/docs/session-inspector/assets/images/source/app-source-expanded.png and b/docs/session-inspector/assets/images/source/app-source-expanded.png differ
diff --git a/docs/session-inspector/assets/images/source/app-source.png b/docs/session-inspector/assets/images/source/app-source.png
index 07c9c45d93..23ab444d13 100644
Binary files a/docs/session-inspector/assets/images/source/app-source.png and b/docs/session-inspector/assets/images/source/app-source.png differ
diff --git a/docs/session-inspector/assets/images/source/search-page-source-highlighted.png b/docs/session-inspector/assets/images/source/search-page-source-highlighted.png
new file mode 100644
index 0000000000..2d93d552b6
Binary files /dev/null and b/docs/session-inspector/assets/images/source/search-page-source-highlighted.png differ
diff --git a/docs/session-inspector/assets/images/source/search-page-source.png b/docs/session-inspector/assets/images/source/search-page-source.png
new file mode 100644
index 0000000000..93daff37e0
Binary files /dev/null and b/docs/session-inspector/assets/images/source/search-page-source.png differ
diff --git a/docs/session-inspector/assets/images/source/source-tab.png b/docs/session-inspector/assets/images/source/source-tab.png
index e13f677306..93481195cf 100644
Binary files a/docs/session-inspector/assets/images/source/source-tab.png and b/docs/session-inspector/assets/images/source/source-tab.png differ
diff --git a/docs/session-inspector/source.md b/docs/session-inspector/source.md
index 62c301a308..fcfa1b6ef4 100644
--- a/docs/session-inspector/source.md
+++ b/docs/session-inspector/source.md
@@ -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)
diff --git a/test/unit/utils-source-parsing.spec.js b/test/unit/utils-source-parsing.spec.js
index 22ce1a6023..7868727fd0 100644
--- a/test/unit/utils-source-parsing.spec.js
+++ b/test/unit/utils-source-parsing.spec.js
@@ -5,6 +5,7 @@ import {
findDOMNodeByPath,
findJSONElementByPath,
xmlToJSON,
+ findNodeMatchingSearchTerm,
} from '../../app/common/renderer/utils/source-parsing';
describe('utils/source-parsing.js', function () {
@@ -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('');
+ });
+ });
});