summaryrefslogtreecommitdiffstats
path: root/devtools/shared/commands/inspector
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/shared/commands/inspector')
-rw-r--r--devtools/shared/commands/inspector/inspector-command.js483
-rw-r--r--devtools/shared/commands/inspector/moz.build10
-rw-r--r--devtools/shared/commands/inspector/tests/browser.ini15
-rw-r--r--devtools/shared/commands/inspector/tests/browser_inspector_command_findNodeFrontFromSelectors.js140
-rw-r--r--devtools/shared/commands/inspector/tests/browser_inspector_command_getNodeFrontSelectorsFromTopDocument.js119
-rw-r--r--devtools/shared/commands/inspector/tests/browser_inspector_command_getSuggestionsForQuery.js124
-rw-r--r--devtools/shared/commands/inspector/tests/browser_inspector_command_search.js98
-rw-r--r--devtools/shared/commands/inspector/tests/head.js14
8 files changed, 1003 insertions, 0 deletions
diff --git a/devtools/shared/commands/inspector/inspector-command.js b/devtools/shared/commands/inspector/inspector-command.js
new file mode 100644
index 0000000000..a8c4edd6c1
--- /dev/null
+++ b/devtools/shared/commands/inspector/inspector-command.js
@@ -0,0 +1,483 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+loader.lazyRequireGetter(
+ this,
+ "getTargetBrowsers",
+ "resource://devtools/shared/compatibility/compatibility-user-settings.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "TARGET_BROWSER_PREF",
+ "resource://devtools/shared/compatibility/constants.js",
+ true
+);
+
+class InspectorCommand {
+ constructor({ commands }) {
+ this.commands = commands;
+ }
+
+ #cssDeclarationBlockIssuesQueuedDomRulesDeclarations = [];
+ #cssDeclarationBlockIssuesPendingTimeoutPromise;
+ #cssDeclarationBlockIssuesTargetBrowsersPromise;
+
+ /**
+ * Return the list of all current target's inspector fronts
+ *
+ * @return {Promise<Array<InspectorFront>>}
+ */
+ async getAllInspectorFronts() {
+ return this.commands.targetCommand.getAllFronts(
+ [this.commands.targetCommand.TYPES.FRAME],
+ "inspector"
+ );
+ }
+
+ /**
+ * Search the document for the given string and return all the results.
+ *
+ * @param {Object} walkerFront
+ * @param {String} query
+ * The string to search for.
+ * @param {Object} options
+ * {Boolean} options.reverse - search backwards
+ * @returns {Array} The list of search results
+ */
+ async walkerSearch(walkerFront, query, options = {}) {
+ const result = await walkerFront.search(query, options);
+ return result.list.items();
+ }
+
+ /**
+ * Incrementally search the top-level document and sub frames for a given string.
+ * Only one result is sent back at a time. Calling the
+ * method again with the same query will send the next result.
+ * If a new query which does not match the current one all is reset and new search
+ * is kicked off.
+ *
+ * @param {String} query
+ * The string / selector searched for
+ * @param {Object} options
+ * {Boolean} reverse - determines if the search is done backwards
+ * @returns {Object} res
+ * {String} res.type
+ * {String} res.query - The string / selector searched for
+ * {Object} res.node - the current node
+ * {Number} res.resultsIndex - The index of the current node
+ * {Number} res.resultsLength - The total number of results found.
+ */
+ async findNextNode(query, { reverse } = {}) {
+ const inspectors = await this.getAllInspectorFronts();
+ const nodes = await Promise.all(
+ inspectors.map(({ walker }) =>
+ this.walkerSearch(walker, query, { reverse })
+ )
+ );
+ const results = nodes.flat();
+
+ // If the search query changes
+ if (this._searchQuery !== query) {
+ this._searchQuery = query;
+ this._currentIndex = -1;
+ }
+
+ if (!results.length) {
+ return null;
+ }
+
+ this._currentIndex = reverse
+ ? this._currentIndex - 1
+ : this._currentIndex + 1;
+
+ if (this._currentIndex >= results.length) {
+ this._currentIndex = 0;
+ }
+ if (this._currentIndex < 0) {
+ this._currentIndex = results.length - 1;
+ }
+
+ return {
+ node: results[this._currentIndex],
+ resultsIndex: this._currentIndex,
+ resultsLength: results.length,
+ };
+ }
+
+ /**
+ * Returns a list of matching results for CSS selector autocompletion.
+ *
+ * @param {String} query
+ * The selector query being completed
+ * @param {String} firstPart
+ * The exact token being completed out of the query
+ * @param {String} state
+ * One of "pseudo", "id", "tag", "class", "null"
+ * @return {Array<string>} suggestions
+ * The list of suggested CSS selectors
+ */
+ async getSuggestionsForQuery(query, firstPart, state) {
+ // Get all inspectors where we want suggestions from.
+ const inspectors = await this.getAllInspectorFronts();
+
+ const mergedSuggestions = [];
+ // Get all of the suggestions.
+ await Promise.all(
+ inspectors.map(async ({ walker }) => {
+ const { suggestions } = await walker.getSuggestionsForQuery(
+ query,
+ firstPart,
+ state
+ );
+ for (const [suggestion, count, type] of suggestions) {
+ // Merge any already existing suggestion with the new one, by incrementing the count
+ // which is the second element of the array.
+ const existing = mergedSuggestions.find(
+ ([s, , t]) => s == suggestion && t == type
+ );
+ if (existing) {
+ existing[1] += count;
+ } else {
+ mergedSuggestions.push([suggestion, count, type]);
+ }
+ }
+ })
+ );
+
+ // Descending sort the list by count, i.e. second element of the arrays
+ return sortSuggestions(mergedSuggestions);
+ }
+
+ /**
+ * Find a nodeFront from an array of selectors. The last item of the array is the selector
+ * for the element in its owner document, and the previous items are selectors to iframes
+ * that lead to the frame where the searched node lives in.
+ *
+ * For example, with the following markup
+ * <html>
+ * <iframe id="level-1" src="…">
+ * <iframe id="level-2" src="…">
+ * <h1>Waldo</h1>
+ * </iframe>
+ * </iframe>
+ *
+ * If you want to retrieve the `<h1>` nodeFront, `selectors` would be:
+ * [
+ * "#level-1",
+ * "#level-2",
+ * "h1",
+ * ]
+ *
+ * @param {Array} selectors
+ * An array of CSS selectors to find the target accessible object.
+ * Several selectors can be needed if the element is nested in frames
+ * and not directly in the root document.
+ * @param {Integer} timeoutInMs
+ * The maximum number of ms the function should run (defaults to 5000).
+ * If it exceeds this, the returned promise will resolve with `null`.
+ * @return {Promise<NodeFront|null>} a promise that resolves when the node front is found
+ * for selection using inspector tools. It resolves with the deepest frame document
+ * that could be retrieved when the "final" nodeFront couldn't be found in the page.
+ * It resolves with `null` when the function runs for more than timeoutInMs.
+ */
+ async findNodeFrontFromSelectors(nodeSelectors, timeoutInMs = 5000) {
+ if (
+ !nodeSelectors ||
+ !Array.isArray(nodeSelectors) ||
+ nodeSelectors.length === 0
+ ) {
+ console.warn(
+ "findNodeFrontFromSelectors expect a non-empty array but got",
+ nodeSelectors
+ );
+ return null;
+ }
+
+ const { walker } = await this.commands.targetCommand.targetFront.getFront(
+ "inspector"
+ );
+ const querySelectors = async nodeFront => {
+ const selector = nodeSelectors.shift();
+ if (!selector) {
+ return nodeFront;
+ }
+ nodeFront = await nodeFront.walkerFront.querySelector(
+ nodeFront,
+ selector
+ );
+ // It's possible the containing iframe isn't available by the time
+ // walkerFront.querySelector is called, which causes the re-selected node to be
+ // unavailable. There also isn't a way for us to know when all iframes on the page
+ // have been created after a reload. Because of this, we should should bail here.
+ if (!nodeFront) {
+ return null;
+ }
+
+ if (nodeSelectors.length) {
+ if (!nodeFront.isShadowHost) {
+ await this.#waitForFrameLoad(nodeFront);
+ }
+
+ const { nodes } = await walker.children(nodeFront);
+
+ // If there are remaining selectors to process, they will target a document or a
+ // document-fragment under the current node. Whether the element is a frame or
+ // a web component, it can only contain one document/document-fragment, so just
+ // select the first one available.
+ nodeFront = nodes.find(node => {
+ const { nodeType } = node;
+ return (
+ nodeType === Node.DOCUMENT_FRAGMENT_NODE ||
+ nodeType === Node.DOCUMENT_NODE
+ );
+ });
+
+ // The iframe selector might have matched an element which is not an
+ // iframe in the new page (or an iframe with no document?). In this
+ // case, bail out and fallback to the root body element.
+ if (!nodeFront) {
+ return null;
+ }
+ }
+ const childrenNodeFront = await querySelectors(nodeFront);
+ return childrenNodeFront || nodeFront;
+ };
+ const rootNodeFront = await walker.getRootNode();
+
+ // Since this is only used for re-setting a selection after a page reloads, we can
+ // put a timeout, in case there's an iframe that would take too much time to load,
+ // and prevent the markup view to be populated.
+ const onTimeout = new Promise(res => setTimeout(res, timeoutInMs)).then(
+ () => null
+ );
+ const onQuerySelectors = querySelectors(rootNodeFront);
+ return Promise.race([onTimeout, onQuerySelectors]);
+ }
+
+ /**
+ * Wait for the given NodeFront child document to be loaded.
+ *
+ * @param {NodeFront} A nodeFront representing a frame
+ */
+ async #waitForFrameLoad(nodeFront) {
+ const domLoadingPromises = [];
+
+ // if the flag isn't true, we don't know for sure if the iframe will be remote
+ // or not; when the nodeFront was created, the iframe might still have been loading
+ // and in such case, its associated window can be an initial document.
+ // Luckily, once EFT is enabled everywhere we can remove this call and only wait
+ // for the associated target.
+ if (!nodeFront.useChildTargetToFetchChildren) {
+ domLoadingPromises.push(nodeFront.waitForFrameLoad());
+ }
+
+ const { onResource: onDomInteractiveResource } =
+ await this.commands.resourceCommand.waitForNextResource(
+ this.commands.resourceCommand.TYPES.DOCUMENT_EVENT,
+ {
+ // We might be in a case where the children document is already loaded (i.e. we
+ // would already have received the dom-interactive resource), so it's important
+ // to _not_ ignore existing resource.
+ predicate: resource =>
+ resource.name == "dom-interactive" &&
+ resource.targetFront !== nodeFront.targetFront &&
+ resource.targetFront.browsingContextID ==
+ nodeFront.browsingContextID,
+ }
+ );
+ const newTargetResolveValue = Symbol();
+ domLoadingPromises.push(
+ onDomInteractiveResource.then(() => newTargetResolveValue)
+ );
+
+ // Here we wait for any promise to resolve first. `waitForFrameLoad` might throw
+ // (if the iframe does end up being remote), so we don't want to use `Promise.race`.
+ const loadResult = await Promise.any(domLoadingPromises);
+
+ // The Node may have `useChildTargetToFetchChildren` set to false because the
+ // child document was still loading when fetching its form. But it may happen that
+ // the Node ends up being a remote iframe.
+ // When this happen we will try to call `waitForFrameLoad` which will throw, but
+ // we will be notified about the new target.
+ // This is the special edge case we are trying to handle here.
+ // We want WalkerFront.children to consider this as an iframe with a dedicated target.
+ if (loadResult == newTargetResolveValue) {
+ nodeFront._form.useChildTargetToFetchChildren = true;
+ }
+ }
+
+ /**
+ * Get the full array of selectors from the topmost document, going through
+ * iframes.
+ * For example, given the following markup:
+ *
+ * <html>
+ * <body>
+ * <iframe src="...">
+ * <html>
+ * <body>
+ * <h1 id="sub-document-title">Title of sub document</h1>
+ * </body>
+ * </html>
+ * </iframe>
+ * </body>
+ * </html>
+ *
+ * If this function is called with the NodeFront for the h1#sub-document-title element,
+ * it will return something like: ["body > iframe", "#sub-document-title"]
+ *
+ * @param {NodeFront} nodeFront: The nodefront to get the selectors for
+ * @returns {Promise<Array<String>>} A promise that resolves with an array of selectors (strings)
+ */
+ async getNodeFrontSelectorsFromTopDocument(nodeFront) {
+ const selectors = [];
+
+ let currentNode = nodeFront;
+ while (currentNode) {
+ // Get the selector for the node inside its document
+ const selector = await currentNode.getUniqueSelector();
+ selectors.unshift(selector);
+
+ // Retrieve the node's document/shadowRoot nodeFront so we can get its parent
+ // (so if we're in an iframe, we'll get the <iframe> node front, and if we're in a
+ // shadow dom document, we'll get the host).
+ const rootNode = currentNode.getOwnerRootNodeFront();
+ currentNode = rootNode?.parentOrHost();
+ }
+
+ return selectors;
+ }
+
+ #updateTargetBrowsersCache = async () => {
+ this.#cssDeclarationBlockIssuesTargetBrowsersPromise = getTargetBrowsers();
+ };
+
+ /**
+ * Get compatibility issues for given domRule declarations
+ *
+ * @param {Array<Object>} domRuleDeclarations
+ * @param {string} domRuleDeclarations[].name: Declaration name
+ * @param {string} domRuleDeclarations[].value: Declaration value
+ * @returns {Promise<Array<Object>>}
+ */
+ async getCSSDeclarationBlockIssues(domRuleDeclarations) {
+ const resultIndex =
+ this.#cssDeclarationBlockIssuesQueuedDomRulesDeclarations.length;
+ this.#cssDeclarationBlockIssuesQueuedDomRulesDeclarations.push(
+ domRuleDeclarations
+ );
+
+ // We're getting the target browsers from RemoteSettings, which can take some time.
+ // We cache the target browsers to avoid bad performance.
+ if (!this.#cssDeclarationBlockIssuesTargetBrowsersPromise) {
+ this.#updateTargetBrowsersCache();
+ // Update the target browsers cache when the pref in which we store the compat
+ // panel settings is updated.
+ Services.prefs.addObserver(
+ TARGET_BROWSER_PREF,
+ this.#updateTargetBrowsersCache
+ );
+ }
+
+ // This can be a hot path if the rules view has a lot of rules displayed.
+ // Here we wait before sending the RDP request so we can collect all the domRule declarations
+ // of "concurrent" calls, and only send a single RDP request.
+ if (!this.#cssDeclarationBlockIssuesPendingTimeoutPromise) {
+ // Wait before sending the RDP request so all "concurrent" calls can be handle
+ // in a single RDP request.
+ this.#cssDeclarationBlockIssuesPendingTimeoutPromise = new Promise(
+ resolve => {
+ setTimeout(() => {
+ this.#cssDeclarationBlockIssuesPendingTimeoutPromise = null;
+ this.#batchedGetCSSDeclarationBlockIssues().then(data =>
+ resolve(data)
+ );
+ }, 50);
+ }
+ );
+ }
+
+ const results = await this.#cssDeclarationBlockIssuesPendingTimeoutPromise;
+ return results?.[resultIndex] || [];
+ }
+
+ /**
+ * Get compatibility issues for all queued domRules declarations
+ * @returns {Promise<Array<Array<Object>>>}
+ */
+ #batchedGetCSSDeclarationBlockIssues = async () => {
+ const declarations = Array.from(
+ this.#cssDeclarationBlockIssuesQueuedDomRulesDeclarations
+ );
+ this.#cssDeclarationBlockIssuesQueuedDomRulesDeclarations = [];
+
+ const { targetFront } = this.commands.targetCommand;
+ try {
+ // The server method isn't dependent on the target (it computes the values from the
+ // declarations we send, which are just property names and values), so we can always
+ // use the top-level target front.
+ const inspectorFront = await targetFront.getFront("inspector");
+
+ const [compatibilityFront, targetBrowsers] = await Promise.all([
+ inspectorFront.getCompatibilityFront(),
+ this.#cssDeclarationBlockIssuesTargetBrowsersPromise,
+ ]);
+
+ const data = await compatibilityFront.getCSSDeclarationBlockIssues(
+ declarations,
+ targetBrowsers
+ );
+ return data;
+ } catch (e) {
+ if (this.destroyed || targetFront.isDestroyed()) {
+ return [];
+ }
+ throw e;
+ }
+ };
+
+ destroy() {
+ Services.prefs.removeObserver(
+ TARGET_BROWSER_PREF,
+ this.#updateTargetBrowsersCache
+ );
+ this.destroyed = true;
+ }
+}
+
+// This is a fork of the server sort:
+// https://searchfox.org/mozilla-central/rev/46a67b8656ac12b5c180e47bc4055f713d73983b/devtools/server/actors/inspector/walker.js#1447
+function sortSuggestions(suggestions) {
+ const sorted = suggestions.sort((a, b) => {
+ // Computed a sortable string with first the inverted count, then the name
+ let sortA = 10000 - a[1] + a[0];
+ let sortB = 10000 - b[1] + b[0];
+
+ // Prefixing ids, classes and tags, to group results
+ const firstA = a[0].substring(0, 1);
+ const firstB = b[0].substring(0, 1);
+
+ const getSortKeyPrefix = firstLetter => {
+ if (firstLetter === "#") {
+ return "2";
+ }
+ if (firstLetter === ".") {
+ return "1";
+ }
+ return "0";
+ };
+
+ sortA = getSortKeyPrefix(firstA) + sortA;
+ sortB = getSortKeyPrefix(firstB) + sortB;
+
+ // String compare
+ return sortA.localeCompare(sortB);
+ });
+ return sorted.slice(0, 25);
+}
+
+module.exports = InspectorCommand;
diff --git a/devtools/shared/commands/inspector/moz.build b/devtools/shared/commands/inspector/moz.build
new file mode 100644
index 0000000000..d9ef593b8d
--- /dev/null
+++ b/devtools/shared/commands/inspector/moz.build
@@ -0,0 +1,10 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DevToolsModules(
+ "inspector-command.js",
+)
+
+if CONFIG["MOZ_BUILD_APP"] != "mobile/android":
+ BROWSER_CHROME_MANIFESTS += ["tests/browser.ini"]
diff --git a/devtools/shared/commands/inspector/tests/browser.ini b/devtools/shared/commands/inspector/tests/browser.ini
new file mode 100644
index 0000000000..cd3e3117cf
--- /dev/null
+++ b/devtools/shared/commands/inspector/tests/browser.ini
@@ -0,0 +1,15 @@
+[DEFAULT]
+tags = devtools
+subsuite = devtools
+support-files =
+ !/devtools/client/inspector/test/shared-head.js
+ !/devtools/client/shared/test/shared-head.js
+ !/devtools/client/shared/test/telemetry-test-helpers.js
+ !/devtools/client/shared/test/highlighter-test-actor.js
+ head.js
+
+[browser_inspector_command_findNodeFrontFromSelectors.js]
+skip-if = http3 # Bug 1829298
+[browser_inspector_command_getNodeFrontSelectorsFromTopDocument.js]
+[browser_inspector_command_getSuggestionsForQuery.js]
+[browser_inspector_command_search.js]
diff --git a/devtools/shared/commands/inspector/tests/browser_inspector_command_findNodeFrontFromSelectors.js b/devtools/shared/commands/inspector/tests/browser_inspector_command_findNodeFrontFromSelectors.js
new file mode 100644
index 0000000000..7991421c8d
--- /dev/null
+++ b/devtools/shared/commands/inspector/tests/browser_inspector_command_findNodeFrontFromSelectors.js
@@ -0,0 +1,140 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async () => {
+ // Build a simple test page with a remote iframe, using two distinct origins .com and .org
+ const iframeOrgHtml = encodeURIComponent(
+ `<h2 id="in-iframe">in org - same origin</h2>`
+ );
+ const iframeComHtml = encodeURIComponent(`<h3>in com - remote</h3>`);
+ const html = encodeURIComponent(
+ `<main class="foo bar">
+ <button id="child">Click</button>
+ </main>
+ <!-- adding delay to both iframe so we can check we handle loading document has expected -->
+ <iframe id="iframe-org" src="https://example.org/document-builder.sjs?delay=3000&html=${iframeOrgHtml}"></iframe>
+ <iframe id="iframe-com" src="https://example.com/document-builder.sjs?delay=6000&html=${iframeComHtml}"></iframe>`
+ );
+ const tab = await addTab(
+ "https://example.org/document-builder.sjs?html=" + html,
+ { waitForLoad: false }
+ );
+
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+
+ info("Check that it returns null when no params are passed");
+ let nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors();
+ is(
+ nodeFront,
+ null,
+ `findNodeFrontFromSelectors returns null when no param is passed`
+ );
+
+ info("Check that it returns null when a string is passed");
+ nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors(
+ "body main"
+ );
+ is(
+ nodeFront,
+ null,
+ `findNodeFrontFromSelectors returns null when passed a string`
+ );
+
+ info("Check it returns null when an empty array is passed");
+ nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([]);
+ is(
+ nodeFront,
+ null,
+ `findNodeFrontFromSelectors returns null when passed an empty array`
+ );
+
+ info("Check that passing a selector for a non-matching element returns null");
+ nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([
+ "h1",
+ ]);
+ is(
+ nodeFront,
+ null,
+ "findNodeFrontFromSelectors returns null as there's no <h1> element in the page"
+ );
+
+ info("Check passing a selector for an element in the top document");
+ nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([
+ "button",
+ ]);
+ is(
+ nodeFront.typeName,
+ "domnode",
+ "findNodeFrontFromSelectors returns a nodeFront"
+ );
+ is(
+ nodeFront.displayName,
+ "button",
+ "findNodeFrontFromSelectors returned the appropriate nodeFront"
+ );
+
+ info("Check passing a selector for an element in a same origin iframe");
+ nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([
+ "#iframe-org",
+ "#in-iframe",
+ ]);
+ is(
+ nodeFront.displayName,
+ "h2",
+ "findNodeFrontFromSelectors returned the appropriate nodeFront"
+ );
+
+ info("Check passing a selector for an element in a cross origin iframe");
+ nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([
+ "#iframe-com",
+ "h3",
+ ]);
+ is(
+ nodeFront.displayName,
+ "h3",
+ "findNodeFrontFromSelectors returned the appropriate nodeFront"
+ );
+
+ info(
+ "Check passing a selector for an non-existing element in an existing iframe"
+ );
+ nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([
+ "iframe",
+ "#non-existant-id",
+ ]);
+ is(
+ nodeFront.displayName,
+ "#document",
+ "findNodeFrontFromSelectors returned the last matching iframe document if the children selector isn't found"
+ );
+ is(
+ nodeFront.parentNode().displayName,
+ "iframe",
+ "findNodeFrontFromSelectors returned the last matching iframe document if the children selector isn't found"
+ );
+
+ info("Check that timeout does work");
+ // Reload the page so we'll have the iframe loading (for 3s) and we can check that
+ // putting a smaller timeout will result in the function returning null.
+ // we need to wait until it's fully processed to avoid pending promises.
+ const onNewTargetProcessed = commands.targetCommand.once(
+ "processed-available-target"
+ );
+ await reloadBrowser({ waitForLoad: false });
+ await onNewTargetProcessed;
+ nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors(
+ ["#iframe-org", "#in-iframe"],
+ // timeout in ms (smaller than 3s)
+ 100
+ );
+ is(
+ nodeFront,
+ null,
+ "findNodeFrontFromSelectors timed out and returned null, as expected"
+ );
+
+ await commands.destroy();
+});
diff --git a/devtools/shared/commands/inspector/tests/browser_inspector_command_getNodeFrontSelectorsFromTopDocument.js b/devtools/shared/commands/inspector/tests/browser_inspector_command_getNodeFrontSelectorsFromTopDocument.js
new file mode 100644
index 0000000000..3e5abcddd0
--- /dev/null
+++ b/devtools/shared/commands/inspector/tests/browser_inspector_command_getNodeFrontSelectorsFromTopDocument.js
@@ -0,0 +1,119 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Testing getNodeFrontSelectorsFromTopDocument
+
+add_task(async () => {
+ const html = `
+ <html>
+ <head>
+ <meta charset="utf8">
+ <title>Test</title>
+ </head>
+ <body>
+ <header>
+ <span>hello</span>
+ <span>world</span>
+ </header>
+ <main>
+ <iframe src="data:text/html,${encodeURIComponent(
+ "<html><body><h2 class='frame-child'>foo</h2></body></html>"
+ )}"></iframe>
+ </main>
+ <footer></footer>
+
+ <test-component>
+ <div slot="slot1" id="el1">content</div>
+ </test-component>
+ <script>
+ 'use strict';
+
+ customElements.define('test-component', class extends HTMLElement {
+ constructor() {
+ super();
+ const shadowRoot = this.attachShadow({mode: 'open'});
+ shadowRoot.innerHTML = '<slot class="slot-class" name="slot1"></slot>';
+ }
+ });
+ </script>
+ </body>
+ </html>`;
+
+ const tab = await addTab("data:text/html," + encodeURIComponent(html));
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+
+ const walker = (
+ await commands.targetCommand.targetFront.getFront("inspector")
+ ).walker;
+
+ const checkSelectors = (...args) =>
+ checkSelectorsFromTopDocumentForNode(commands, ...args);
+
+ let node = await getNodeFrontInFrames(["meta"], { walker });
+ await checkSelectors(
+ node,
+ ["head > meta:nth-child(1)"],
+ "Got expected selectors for the top-level meta node"
+ );
+
+ node = await getNodeFrontInFrames(["body"], { walker });
+ await checkSelectors(
+ node,
+ ["body"],
+ "Got expected selectors for the top-level body node"
+ );
+
+ node = await getNodeFrontInFrames(["header > span"], { walker });
+ await checkSelectors(
+ node,
+ ["body > header:nth-child(1) > span:nth-child(1)"],
+ "Got expected selectors for the top-level span node"
+ );
+
+ node = await getNodeFrontInFrames(["iframe"], { walker });
+ await checkSelectors(
+ node,
+ ["body > main:nth-child(2) > iframe:nth-child(1)"],
+ "Got expected selectors for the iframe node"
+ );
+
+ node = await getNodeFrontInFrames(["iframe", "body"], { walker });
+ await checkSelectors(
+ node,
+ ["body > main:nth-child(2) > iframe:nth-child(1)", "body"],
+ "Got expected selectors for the iframe body node"
+ );
+
+ const hostFront = await getNodeFront("test-component", { walker });
+ const { nodes } = await walker.children(hostFront);
+ const shadowRoot = nodes.find(hostNode => hostNode.isShadowRoot);
+ node = await walker.querySelector(shadowRoot, ".slot-class");
+
+ await checkSelectors(
+ node,
+ ["body > test-component:nth-child(4)", ".slot-class"],
+ "Got expected selectors for the shadow dom node"
+ );
+
+ await commands.destroy();
+});
+
+async function checkSelectorsFromTopDocumentForNode(
+ commands,
+ nodeFront,
+ expectedSelectors,
+ assertionText
+) {
+ const selectors =
+ await commands.inspectorCommand.getNodeFrontSelectorsFromTopDocument(
+ nodeFront
+ );
+ is(
+ JSON.stringify(selectors),
+ JSON.stringify(expectedSelectors),
+ assertionText
+ );
+}
diff --git a/devtools/shared/commands/inspector/tests/browser_inspector_command_getSuggestionsForQuery.js b/devtools/shared/commands/inspector/tests/browser_inspector_command_getSuggestionsForQuery.js
new file mode 100644
index 0000000000..e7b765b1d0
--- /dev/null
+++ b/devtools/shared/commands/inspector/tests/browser_inspector_command_getSuggestionsForQuery.js
@@ -0,0 +1,124 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async () => {
+ // Build a test page with a remote iframe, using two distinct origins .com and .org
+ const iframeHtml = encodeURIComponent(`<div id="iframe"></div>`);
+ const html = encodeURIComponent(
+ `<div class="foo bar">
+ <div id="child"></div>
+ </div>
+ <iframe src="https://example.org/document-builder.sjs?html=${iframeHtml}"></iframe>`
+ );
+ const tab = await addTab(
+ "https://example.com/document-builder.sjs?html=" + html
+ );
+
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+
+ info(
+ "Suggestions for 'di' with tag search, will match the two <div> elements in top document and the one in the iframe"
+ );
+ await assertSuggestion(
+ commands,
+ { query: "", firstPart: "di", state: "tag" },
+ [
+ {
+ suggestion: "div",
+ count: 3, // Matches the two <div> in the top document and the one in the iframe
+ type: "tag",
+ },
+ ]
+ );
+
+ info(
+ "Suggestions for 'ifram' with id search, will only match the <div> within the iframe"
+ );
+ await assertSuggestion(
+ commands,
+ { query: "", firstPart: "ifram", state: "id" },
+ [
+ {
+ suggestion: "#iframe",
+ count: 1,
+ type: "id",
+ },
+ ]
+ );
+
+ info(
+ "Suggestions for 'fo' with tag search, will match the class of the top <div> element"
+ );
+ await assertSuggestion(
+ commands,
+ { query: "", firstPart: "fo", state: "tag" },
+ [
+ {
+ suggestion: ".foo",
+ count: 1,
+ type: "class",
+ },
+ ]
+ );
+
+ info(
+ "Suggestions for classes, based on div elements, will match the two classes of top <div> element"
+ );
+ await assertSuggestion(
+ commands,
+ { query: "div", firstPart: "", state: "class" },
+ [
+ {
+ suggestion: ".bar",
+ count: 1,
+ type: "class",
+ },
+ {
+ suggestion: ".foo",
+ count: 1,
+ type: "class",
+ },
+ ]
+ );
+
+ info("Suggestion for non-existent tag names will return no suggestion");
+ await assertSuggestion(
+ commands,
+ { query: "", firstPart: "marquee", state: "tag" },
+ []
+ );
+
+ await commands.destroy();
+});
+
+async function assertSuggestion(
+ commands,
+ { query, firstPart, state },
+ expectedSuggestions
+) {
+ const suggestions = await commands.inspectorCommand.getSuggestionsForQuery(
+ query,
+ firstPart,
+ state
+ );
+ is(
+ suggestions.length,
+ expectedSuggestions.length,
+ "Got the expected number of suggestions"
+ );
+ for (let i = 0; i < expectedSuggestions.length; i++) {
+ info(` ## Asserting suggestion #${i}:`);
+ const expectedSuggestion = expectedSuggestions[i];
+ const [suggestion, count, type] = suggestions[i];
+ is(
+ suggestion,
+ expectedSuggestion.suggestion,
+ "The suggested string is valid"
+ );
+ is(count, expectedSuggestion.count, "The number of matches is valid");
+ is(type, expectedSuggestion.type, "The type of match is valid");
+ }
+}
diff --git a/devtools/shared/commands/inspector/tests/browser_inspector_command_search.js b/devtools/shared/commands/inspector/tests/browser_inspector_command_search.js
new file mode 100644
index 0000000000..d7d25d3ce6
--- /dev/null
+++ b/devtools/shared/commands/inspector/tests/browser_inspector_command_search.js
@@ -0,0 +1,98 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Testing basic inspector search
+
+add_task(async () => {
+ const html = `<div>
+ <div>
+ <p>This is the paragraph node down in the tree</p>
+ </div>
+ <div class="child"></div>
+ <div class="child"></div>
+ <iframe src="data:text/html,${encodeURIComponent(
+ "<html><body><div class='frame-child'>foo</div></body></html>"
+ )}">
+ </iframe>
+ </div>`;
+
+ const tab = await addTab("data:text/html," + encodeURIComponent(html));
+
+ const commands = await CommandsFactory.forTab(tab);
+ await commands.targetCommand.startListening();
+
+ info("Search using text");
+ await searchAndAssert(
+ commands,
+ { query: "paragraph", reverse: false },
+ { resultsLength: 1, resultsIndex: 0 }
+ );
+
+ info("Search using class selector");
+ info(" > Get first result ");
+ await searchAndAssert(
+ commands,
+ { query: ".child", reverse: false },
+ { resultsLength: 2, resultsIndex: 0 }
+ );
+
+ info(" > Get next result ");
+ await searchAndAssert(
+ commands,
+ { query: ".child", reverse: false },
+ { resultsLength: 2, resultsIndex: 1 }
+ );
+
+ info("Search using el selector with reverse option");
+ info(" > Get first result ");
+ await searchAndAssert(
+ commands,
+ { query: "div", reverse: true },
+ { resultsLength: 6, resultsIndex: 5 }
+ );
+
+ info(" > Get next result ");
+ await searchAndAssert(
+ commands,
+ { query: "div", reverse: true },
+ { resultsLength: 6, resultsIndex: 4 }
+ );
+
+ info("Search for foo in remote frame");
+ await searchAndAssert(
+ commands,
+ { query: ".frame-child", reverse: false },
+ { resultsLength: 1, resultsIndex: 0 }
+ );
+
+ await commands.destroy();
+});
+/**
+ * Does an inspector search to find the next node and assert the results
+ *
+ * @param {Object} commands
+ * @param {Object} options
+ * options.query - search query
+ * options.reverse - search in reverse
+ * @param {Object} expected
+ * Holds the expected values
+ */
+async function searchAndAssert(commands, { query, reverse }, expected) {
+ const response = await commands.inspectorCommand.findNextNode(query, {
+ reverse,
+ });
+
+ is(
+ response.resultsLength,
+ expected.resultsLength,
+ "Got the expected no of results"
+ );
+
+ is(
+ response.resultsIndex,
+ expected.resultsIndex,
+ "Got the expected currently selected node index"
+ );
+}
diff --git a/devtools/shared/commands/inspector/tests/head.js b/devtools/shared/commands/inspector/tests/head.js
new file mode 100644
index 0000000000..73d9798446
--- /dev/null
+++ b/devtools/shared/commands/inspector/tests/head.js
@@ -0,0 +1,14 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js",
+ this
+);
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js",
+ this
+);