inspector-command.js (17182B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 loader.lazyRequireGetter( 8 this, 9 "getTargetBrowsers", 10 "resource://devtools/shared/compatibility/compatibility-user-settings.js", 11 true 12 ); 13 loader.lazyRequireGetter( 14 this, 15 "TARGET_BROWSER_PREF", 16 "resource://devtools/shared/compatibility/constants.js", 17 true 18 ); 19 20 const { getSystemInfo } = require("resource://devtools/shared/system.js"); 21 22 class InspectorCommand { 23 constructor({ commands }) { 24 this.commands = commands; 25 } 26 27 #cssDeclarationBlockIssuesQueuedDomRulesDeclarations = []; 28 #cssDeclarationBlockIssuesPendingTimeoutPromise; 29 #cssDeclarationBlockIssuesTargetBrowsersPromise; 30 31 /** 32 * Return the list of all current target's inspector fronts 33 * 34 * @return {Promise<Array<InspectorFront>>} 35 */ 36 async getAllInspectorFronts() { 37 return this.commands.targetCommand.getAllFronts( 38 [this.commands.targetCommand.TYPES.FRAME], 39 "inspector" 40 ); 41 } 42 43 /** 44 * Search the document for the given string and return all the results. 45 * 46 * @param {object} walkerFront 47 * @param {string} query 48 * The string to search for. 49 * @param {object} options 50 * {Boolean} options.reverse - search backwards 51 * @returns {Array} The list of search results 52 */ 53 async walkerSearch(walkerFront, query, options = {}) { 54 const result = await walkerFront.search(query, options); 55 return result.list.items(); 56 } 57 58 /** 59 * Incrementally search the top-level document and sub frames for a given string. 60 * Only one result is sent back at a time. Calling the 61 * method again with the same query will send the next result. 62 * If a new query which does not match the current one all is reset and new search 63 * is kicked off. 64 * 65 * @param {string} query 66 * The string / selector searched for 67 * @param {object} options 68 * {Boolean} reverse - determines if the search is done backwards 69 * @returns {object} res 70 * {String} res.type 71 * {String} res.query - The string / selector searched for 72 * {Object} res.node - the current node 73 * {Number} res.resultsIndex - The index of the current node 74 * {Number} res.resultsLength - The total number of results found. 75 */ 76 async findNextNode(query, { reverse } = {}) { 77 const inspectors = await this.getAllInspectorFronts(); 78 const nodes = await Promise.all( 79 inspectors.map(({ walker }) => 80 this.walkerSearch(walker, query, { reverse }) 81 ) 82 ); 83 const results = nodes.flat(); 84 85 // If the search query changes 86 if (this._searchQuery !== query) { 87 this._searchQuery = query; 88 this._currentIndex = -1; 89 } 90 91 if (!results.length) { 92 return null; 93 } 94 95 this._currentIndex = reverse 96 ? this._currentIndex - 1 97 : this._currentIndex + 1; 98 99 if (this._currentIndex >= results.length) { 100 this._currentIndex = 0; 101 } 102 if (this._currentIndex < 0) { 103 this._currentIndex = results.length - 1; 104 } 105 106 return { 107 node: results[this._currentIndex], 108 resultsIndex: this._currentIndex, 109 resultsLength: results.length, 110 }; 111 } 112 113 /** 114 * Returns a list of matching results for CSS selector autocompletion. 115 * 116 * @param {string} query 117 * The selector query being completed 118 * @param {string} firstPart 119 * The exact token being completed out of the query 120 * @param {string} state 121 * One of "pseudo", "id", "tag", "class", "null" 122 * @return {Array<string>} suggestions 123 * The list of suggested CSS selectors 124 */ 125 async getSuggestionsForQuery(query, firstPart, state) { 126 // Get all inspectors where we want suggestions from. 127 const inspectors = await this.getAllInspectorFronts(); 128 129 const mergedSuggestions = []; 130 // Get all of the suggestions. 131 await Promise.all( 132 inspectors.map(async ({ walker }) => { 133 const { suggestions } = await walker.getSuggestionsForQuery( 134 query, 135 firstPart, 136 state 137 ); 138 139 for (const suggestion of suggestions) { 140 const [value, type] = suggestion; 141 142 // Only add suggestions to final array if it doesn't exist yet. 143 const existing = mergedSuggestions.some( 144 ([s, t]) => s == value && t == type 145 ); 146 if (!existing) { 147 mergedSuggestions.push([value, type]); 148 } 149 } 150 }) 151 ); 152 153 return sortSuggestions(mergedSuggestions); 154 } 155 156 /** 157 * Find a nodeFront from an array of selectors. The last item of the array is the selector 158 * for the element in its owner document, and the previous items are selectors to iframes 159 * that lead to the frame where the searched node lives in. 160 * 161 * For example, with the following markup 162 * <html> 163 * <iframe id="level-1" src="…"> 164 * <iframe id="level-2" src="…"> 165 * <h1>Waldo</h1> 166 * </iframe> 167 * </iframe> 168 * 169 * If you want to retrieve the `<h1>` nodeFront, `selectors` would be: 170 * [ 171 * "#level-1", 172 * "#level-2", 173 * "h1", 174 * ] 175 * 176 * @param {Array} selectors 177 * An array of CSS selectors to find the target accessible object. 178 * Several selectors can be needed if the element is nested in frames 179 * and not directly in the root document. 180 * @param {Integer} timeoutInMs 181 * The maximum number of ms the function should run (defaults to 1000). 182 * If it exceeds this, the returned promise will resolve with `null`. 183 * @return {Promise<NodeFront|null>} a promise that resolves when the node front is found 184 * for selection using inspector tools. It resolves with the deepest frame document 185 * that could be retrieved when the "final" nodeFront couldn't be found in the page. 186 * It resolves with `null` when the function runs for more than timeoutInMs. 187 */ 188 async findNodeFrontFromSelectors(nodeSelectors, timeoutInMs = 1000) { 189 if ( 190 !nodeSelectors || 191 !Array.isArray(nodeSelectors) || 192 nodeSelectors.length === 0 193 ) { 194 console.warn( 195 "findNodeFrontFromSelectors expect a non-empty array but got", 196 nodeSelectors 197 ); 198 return null; 199 } 200 201 const { walker } = 202 await this.commands.targetCommand.targetFront.getFront("inspector"); 203 // Copy the array as we will mutate it 204 nodeSelectors = [...nodeSelectors]; 205 const querySelectors = async nodeFront => { 206 const selector = nodeSelectors.shift(); 207 if (!selector) { 208 return nodeFront; 209 } 210 nodeFront = await nodeFront.walkerFront.querySelector( 211 nodeFront, 212 selector 213 ); 214 // It's possible the containing iframe isn't available by the time 215 // walkerFront.querySelector is called, which causes the re-selected node to be 216 // unavailable. There also isn't a way for us to know when all iframes on the page 217 // have been created after a reload. Because of this, we should should bail here. 218 if (!nodeFront) { 219 return null; 220 } 221 222 if (nodeSelectors.length) { 223 if (!nodeFront.isShadowHost) { 224 await this.#waitForFrameLoad(nodeFront); 225 } 226 227 const { nodes } = await walker.children(nodeFront); 228 229 // If there are remaining selectors to process, they will target a document or a 230 // document-fragment under the current node. Whether the element is a frame or 231 // a web component, it can only contain one document/document-fragment, so just 232 // select the first one available. 233 nodeFront = nodes.find(node => { 234 const { nodeType } = node; 235 return ( 236 nodeType === Node.DOCUMENT_FRAGMENT_NODE || 237 nodeType === Node.DOCUMENT_NODE 238 ); 239 }); 240 241 // The iframe selector might have matched an element which is not an 242 // iframe in the new page (or an iframe with no document?). In this 243 // case, bail out and fallback to the root body element. 244 if (!nodeFront) { 245 return null; 246 } 247 } 248 const childrenNodeFront = await querySelectors(nodeFront); 249 return childrenNodeFront || nodeFront; 250 }; 251 const rootNodeFront = await walker.getRootNode(); 252 253 // Since this is only used for re-setting a selection after a page reloads, we can 254 // put a timeout, in case there's an iframe that would take too much time to load, 255 // and prevent the markup view to be populated. 256 const onTimeout = new Promise(res => 257 setTimeout(res, timeoutInMs * getSystemInfo().timeoutMultiplier) 258 ).then(() => null); 259 const onQuerySelectors = querySelectors(rootNodeFront); 260 return Promise.race([onTimeout, onQuerySelectors]); 261 } 262 263 /** 264 * Wait for the given NodeFront child document to be loaded. 265 * 266 * @param {NodeFront} A nodeFront representing a frame 267 */ 268 async #waitForFrameLoad(nodeFront) { 269 const domLoadingPromises = []; 270 271 // if the flag isn't true, we don't know for sure if the iframe will be remote 272 // or not; when the nodeFront was created, the iframe might still have been loading 273 // and in such case, its associated window can be an initial document. 274 // Luckily, once EFT is enabled everywhere we can remove this call and only wait 275 // for the associated target. 276 if (!nodeFront.useChildTargetToFetchChildren) { 277 domLoadingPromises.push(nodeFront.waitForFrameLoad()); 278 } 279 280 const { onResource: onDomInteractiveResource } = 281 await this.commands.resourceCommand.waitForNextResource( 282 this.commands.resourceCommand.TYPES.DOCUMENT_EVENT, 283 { 284 // We might be in a case where the children document is already loaded (i.e. we 285 // would already have received the dom-interactive resource), so it's important 286 // to _not_ ignore existing resource. 287 predicate: resource => 288 resource.name == "dom-interactive" && 289 resource.targetFront !== nodeFront.targetFront && 290 resource.targetFront.browsingContextID == 291 nodeFront.browsingContextID, 292 } 293 ); 294 const newTargetResolveValue = Symbol(); 295 domLoadingPromises.push( 296 onDomInteractiveResource.then(() => newTargetResolveValue) 297 ); 298 299 // Here we wait for any promise to resolve first. `waitForFrameLoad` might throw 300 // (if the iframe does end up being remote), so we don't want to use `Promise.race`. 301 const loadResult = await Promise.any(domLoadingPromises); 302 303 // The Node may have `useChildTargetToFetchChildren` set to false because the 304 // child document was still loading when fetching its form. But it may happen that 305 // the Node ends up being a remote iframe. 306 // When this happen we will try to call `waitForFrameLoad` which will throw, but 307 // we will be notified about the new target. 308 // This is the special edge case we are trying to handle here. 309 // We want WalkerFront.children to consider this as an iframe with a dedicated target. 310 if (loadResult == newTargetResolveValue) { 311 nodeFront._form.useChildTargetToFetchChildren = true; 312 } 313 } 314 315 /** 316 * Get the full array of selectors from the topmost document, going through 317 * iframes. 318 * For example, given the following markup: 319 * 320 * <html> 321 * <body> 322 * <iframe src="..."> 323 * <html> 324 * <body> 325 * <h1 id="sub-document-title">Title of sub document</h1> 326 * </body> 327 * </html> 328 * </iframe> 329 * </body> 330 * </html> 331 * 332 * If this function is called with the NodeFront for the h1#sub-document-title element, 333 * it will return something like: ["body > iframe", "#sub-document-title"] 334 * 335 * @param {NodeFront} nodeFront: The nodefront to get the selectors for 336 * @returns {Promise<Array<string>>} A promise that resolves with an array of selectors (strings) 337 */ 338 async getNodeFrontSelectorsFromTopDocument(nodeFront) { 339 const selectors = []; 340 341 let currentNode = nodeFront; 342 while (currentNode) { 343 // Get the selector for the node inside its document 344 const selector = await currentNode.getUniqueSelector(); 345 selectors.unshift(selector); 346 347 // Retrieve the node's document/shadowRoot nodeFront so we can get its parent 348 // (so if we're in an iframe, we'll get the <iframe> node front, and if we're in a 349 // shadow dom document, we'll get the host). 350 const rootNode = currentNode.getOwnerRootNodeFront(); 351 currentNode = rootNode?.parentOrHost(); 352 } 353 354 return selectors; 355 } 356 357 #updateTargetBrowsersCache = async () => { 358 this.#cssDeclarationBlockIssuesTargetBrowsersPromise = getTargetBrowsers(); 359 }; 360 361 /** 362 * Get compatibility issues for given domRule declarations 363 * 364 * @param {Array<object>} domRuleDeclarations 365 * @param {string} domRuleDeclarations[].name: Declaration name 366 * @param {string} domRuleDeclarations[].value: Declaration value 367 * @returns {Promise<Array<object>>} 368 */ 369 async getCSSDeclarationBlockIssues(domRuleDeclarations) { 370 // Filter out custom property declarations as we can't have issue with those and 371 // they're already ignored on the server. 372 const nonCustomPropertyDeclarations = domRuleDeclarations.filter( 373 decl => !decl.isCustomProperty 374 ); 375 const resultIndex = 376 this.#cssDeclarationBlockIssuesQueuedDomRulesDeclarations.length; 377 this.#cssDeclarationBlockIssuesQueuedDomRulesDeclarations.push( 378 nonCustomPropertyDeclarations 379 ); 380 381 // We're getting the target browsers from RemoteSettings, which can take some time. 382 // We cache the target browsers to avoid bad performance. 383 if (!this.#cssDeclarationBlockIssuesTargetBrowsersPromise) { 384 this.#updateTargetBrowsersCache(); 385 // Update the target browsers cache when the pref in which we store the compat 386 // panel settings is updated. 387 Services.prefs.addObserver( 388 TARGET_BROWSER_PREF, 389 this.#updateTargetBrowsersCache 390 ); 391 } 392 393 // This can be a hot path if the rules view has a lot of rules displayed. 394 // Here we wait before sending the RDP request so we can collect all the domRule declarations 395 // of "concurrent" calls, and only send a single RDP request. 396 if (!this.#cssDeclarationBlockIssuesPendingTimeoutPromise) { 397 // Wait before sending the RDP request so all "concurrent" calls can be handle 398 // in a single RDP request. 399 this.#cssDeclarationBlockIssuesPendingTimeoutPromise = new Promise( 400 resolve => { 401 setTimeout(() => { 402 this.#cssDeclarationBlockIssuesPendingTimeoutPromise = null; 403 this.#batchedGetCSSDeclarationBlockIssues().then(data => 404 resolve(data) 405 ); 406 }, 50); 407 } 408 ); 409 } 410 411 const results = await this.#cssDeclarationBlockIssuesPendingTimeoutPromise; 412 return results?.[resultIndex] || []; 413 } 414 415 /** 416 * Get compatibility issues for all queued domRules declarations 417 * 418 * @returns {Promise<Array<Array<object>>>} 419 */ 420 #batchedGetCSSDeclarationBlockIssues = async () => { 421 const declarations = 422 this.#cssDeclarationBlockIssuesQueuedDomRulesDeclarations; 423 this.#cssDeclarationBlockIssuesQueuedDomRulesDeclarations = []; 424 425 const { targetFront } = this.commands.targetCommand; 426 try { 427 // The server method isn't dependent on the target (it computes the values from the 428 // declarations we send, which are just property names and values), so we can always 429 // use the top-level target front. 430 const inspectorFront = await targetFront.getFront("inspector"); 431 432 const [compatibilityFront, targetBrowsers] = await Promise.all([ 433 inspectorFront.getCompatibilityFront(), 434 this.#cssDeclarationBlockIssuesTargetBrowsersPromise, 435 ]); 436 437 const data = await compatibilityFront.getCSSDeclarationBlockIssues( 438 declarations, 439 targetBrowsers 440 ); 441 return data; 442 } catch (e) { 443 if (this.destroyed || targetFront.isDestroyed()) { 444 return []; 445 } 446 throw e; 447 } 448 }; 449 450 destroy() { 451 Services.prefs.removeObserver( 452 TARGET_BROWSER_PREF, 453 this.#updateTargetBrowsersCache 454 ); 455 this.destroyed = true; 456 } 457 } 458 459 // This is a fork of the server sort: 460 // https://searchfox.org/mozilla-central/rev/46a67b8656ac12b5c180e47bc4055f713d73983b/devtools/server/actors/inspector/walker.js#1447 461 function sortSuggestions(suggestions) { 462 const sorted = suggestions.sort((a, b) => { 463 // Prefixing ids, classes and tags, to group results 464 const firstA = a[0].substring(0, 1); 465 const firstB = b[0].substring(0, 1); 466 467 const getSortKeyPrefix = firstLetter => { 468 if (firstLetter === "#") { 469 return "2"; 470 } 471 if (firstLetter === ".") { 472 return "1"; 473 } 474 return "0"; 475 }; 476 477 const sortA = getSortKeyPrefix(firstA) + a[0]; 478 const sortB = getSortKeyPrefix(firstB) + b[0]; 479 480 // String compare 481 return sortA.localeCompare(sortB); 482 }); 483 return sorted.slice(0, 25); 484 } 485 486 module.exports = InspectorCommand;