diff options
Diffstat (limited to 'devtools/shared/commands/inspector')
8 files changed, 1010 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..1f92b4d9d0 --- /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.toml"] diff --git a/devtools/shared/commands/inspector/tests/browser.toml b/devtools/shared/commands/inspector/tests/browser.toml new file mode 100644 index 0000000000..d068db90ac --- /dev/null +++ b/devtools/shared/commands/inspector/tests/browser.toml @@ -0,0 +1,22 @@ +[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 + "http2", +] + +["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 +); |