From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- .../shared/commands/inspector/inspector-command.js | 483 +++++++++++++++++++++ devtools/shared/commands/inspector/moz.build | 10 + .../shared/commands/inspector/tests/browser.ini | 15 + ...inspector_command_findNodeFrontFromSelectors.js | 140 ++++++ ...command_getNodeFrontSelectorsFromTopDocument.js | 119 +++++ ...ser_inspector_command_getSuggestionsForQuery.js | 124 ++++++ .../tests/browser_inspector_command_search.js | 98 +++++ devtools/shared/commands/inspector/tests/head.js | 14 + 8 files changed, 1003 insertions(+) create mode 100644 devtools/shared/commands/inspector/inspector-command.js create mode 100644 devtools/shared/commands/inspector/moz.build create mode 100644 devtools/shared/commands/inspector/tests/browser.ini create mode 100644 devtools/shared/commands/inspector/tests/browser_inspector_command_findNodeFrontFromSelectors.js create mode 100644 devtools/shared/commands/inspector/tests/browser_inspector_command_getNodeFrontSelectorsFromTopDocument.js create mode 100644 devtools/shared/commands/inspector/tests/browser_inspector_command_getSuggestionsForQuery.js create mode 100644 devtools/shared/commands/inspector/tests/browser_inspector_command_search.js create mode 100644 devtools/shared/commands/inspector/tests/head.js (limited to 'devtools/shared/commands/inspector') 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>} + */ + 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} 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 + * + * + * + * + * If you want to retrieve the `

` 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} 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: + * + * + * + * + * + * + * + * 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>} 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 + ` + ); + 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

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 = ` + + + + Test + + +
+ hello + world +
+
+ +
+
+ + +
content
+
+ + + `; + + 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(`
`); + const html = encodeURIComponent( + `
+
+
+ ` + ); + 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
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
in the top document and the one in the iframe + type: "tag", + }, + ] + ); + + info( + "Suggestions for 'ifram' with id search, will only match the
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
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
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 = `
+
+

This is the paragraph node down in the tree

+
+
+
+ +
`; + + 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 +); -- cgit v1.2.3