shared-head.js (87355B)
1 /* Any copyright is dedicated to the Public Domain. 2 * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 /* eslint no-unused-vars: [2, {"vars": "local", caughtErrors: "none"}] */ 4 5 /* import-globals-from ../../inspector/test/shared-head.js */ 6 7 "use strict"; 8 9 // This shared-head.js file is used by most mochitests 10 // and we start using it in xpcshell tests as well. 11 // It contains various common helper functions. 12 13 // Recording already set preferences. 14 const devtoolsPreferences = Services.prefs.getBranch("devtools"); 15 const alreadySetPreferences = new Set(); 16 for (const pref of devtoolsPreferences.getChildList("")) { 17 if (devtoolsPreferences.prefHasUserValue(pref)) { 18 alreadySetPreferences.add(pref); 19 } 20 } 21 22 { 23 const { PromiseTestUtils } = ChromeUtils.importESModule( 24 "resource://testing-common/PromiseTestUtils.sys.mjs" 25 ); 26 PromiseTestUtils.allowMatchingRejectionsGlobally( 27 /REDUX_MIDDLEWARE_IGNORED_REDUX_ACTION/ 28 ); 29 } 30 31 async function resetPreferencesModifiedDuringTest() { 32 if (!isXpcshell) { 33 await SpecialPowers.flushPrefEnv(); 34 } 35 36 // Reset devtools preferences modified by the test. 37 for (const pref of devtoolsPreferences.getChildList("")) { 38 if ( 39 devtoolsPreferences.prefHasUserValue(pref) && 40 !alreadySetPreferences.has(pref) 41 ) { 42 devtoolsPreferences.clearUserPref(pref); 43 } 44 } 45 46 // Cleanup some generic Firefox preferences set indirectly by tests. 47 for (const pref of [ 48 "browser.firefox-view.view-count", 49 "extensions.ui.lastCategory", 50 "sidebar.old-sidebar.has-used", 51 ]) { 52 Services.prefs.clearUserPref(pref); 53 } 54 } 55 56 const isMochitest = "gTestPath" in this; 57 const isXpcshell = !isMochitest; 58 if (isXpcshell) { 59 // gTestPath isn't exposed to xpcshell tests 60 // _TEST_FILE is an array for a unique string 61 /* global _TEST_FILE */ 62 this.gTestPath = _TEST_FILE[0]; 63 } 64 65 const { Constructor: CC } = Components; 66 67 // Print allocation count if DEBUG_DEVTOOLS_ALLOCATIONS is set to "normal", 68 // and allocation sites if DEBUG_DEVTOOLS_ALLOCATIONS is set to "verbose". 69 const DEBUG_ALLOCATIONS = Services.env.get("DEBUG_DEVTOOLS_ALLOCATIONS"); 70 if (DEBUG_ALLOCATIONS) { 71 // Load the allocation tracker from the distinct privileged loader in order 72 // to be able to debug all privileged code (ESMs, XPCOM,...) running in the shared privileged global. 73 const { 74 useDistinctSystemPrincipalLoader, 75 releaseDistinctSystemPrincipalLoader, 76 } = ChromeUtils.importESModule( 77 "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs", 78 { global: "shared" } 79 ); 80 const requester = {}; 81 const loader = useDistinctSystemPrincipalLoader(requester); 82 registerCleanupFunction(() => 83 releaseDistinctSystemPrincipalLoader(requester) 84 ); 85 86 const { allocationTracker } = loader.require( 87 "resource://devtools/shared/test-helpers/allocation-tracker.js" 88 ); 89 const tracker = allocationTracker({ watchAllGlobals: true }); 90 registerCleanupFunction(() => { 91 if (DEBUG_ALLOCATIONS == "normal") { 92 tracker.logCount(); 93 } else if (DEBUG_ALLOCATIONS == "verbose") { 94 tracker.logAllocationSites(); 95 } 96 tracker.stop(); 97 }); 98 } 99 100 // When DEBUG_STEP environment variable is set, 101 // automatically start a tracer which will log all line being executed 102 // in the running test (and nothing else) and also pause its execution 103 // for the given amount of milliseconds. 104 // 105 // Be careful that these pause have significant side effect. 106 // This will pause the test script event loop and allow running the other 107 // tasks queued in the parent process's main thread event loop queue. 108 // 109 // Passing any non-number value, like `DEBUG_STEP=true` will still 110 // log the executed lines without any pause, and without this side effect. 111 // 112 // For now, the tracer can only work once per thread. 113 // So when using this feature you will not be able to use the JS tracer 114 // in any other way on parent process's main thread. 115 const DEBUG_STEP = Services.env.get("DEBUG_STEP"); 116 if (DEBUG_STEP) { 117 // Load the stepper code from the distinct privileged loader in order 118 // to be able to debug all privileged code (ESMs, XPCOM,...) running in the shared privileged global. 119 const { 120 useDistinctSystemPrincipalLoader, 121 releaseDistinctSystemPrincipalLoader, 122 } = ChromeUtils.importESModule( 123 "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs", 124 { global: "shared" } 125 ); 126 const requester = {}; 127 const loader = useDistinctSystemPrincipalLoader(requester); 128 129 const stepper = loader.require( 130 "resource://devtools/shared/test-helpers/test-stepper.js" 131 ); 132 stepper.start(globalThis, gTestPath, DEBUG_STEP); 133 registerCleanupFunction(() => { 134 stepper.stop(); 135 releaseDistinctSystemPrincipalLoader(requester); 136 }); 137 } 138 139 const DEBUG_TRACE_LINE = Services.env.get("DEBUG_TRACE_LINE"); 140 if (DEBUG_TRACE_LINE) { 141 // Load the tracing code from the distinct privileged loader in order 142 // to be able to debug all privileged code (ESMs, XPCOM,...) running in the shared privileged global. 143 const { 144 useDistinctSystemPrincipalLoader, 145 releaseDistinctSystemPrincipalLoader, 146 } = ChromeUtils.importESModule( 147 "resource://devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs" 148 ); 149 const requester = {}; 150 const loader = useDistinctSystemPrincipalLoader(requester); 151 152 const lineTracer = loader.require( 153 "resource://devtools/shared/test-helpers/test-line-tracer.js" 154 ); 155 lineTracer.start(globalThis, gTestPath, DEBUG_TRACE_LINE); 156 registerCleanupFunction(() => { 157 lineTracer.stop(); 158 releaseDistinctSystemPrincipalLoader(requester); 159 }); 160 } 161 162 const { loader, require } = ChromeUtils.importESModule( 163 "resource://devtools/shared/loader/Loader.sys.mjs" 164 ); 165 const { sinon } = ChromeUtils.importESModule( 166 "resource://testing-common/Sinon.sys.mjs" 167 ); 168 169 // When loaded from xpcshell test, this file is loaded via xpcshell.toml's head property 170 // and so it loaded first before anything else and isn't having access to Services global. 171 // Whereas many head.js files from mochitest import this file via loadSubScript 172 // and already expose Services as a global. 173 174 const { 175 gDevTools, 176 } = require("resource://devtools/client/framework/devtools.js"); 177 const { 178 CommandsFactory, 179 } = require("resource://devtools/shared/commands/commands-factory.js"); 180 const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js"); 181 182 const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js"); 183 184 const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); 185 186 loader.lazyRequireGetter( 187 this, 188 "ResponsiveUIManager", 189 "resource://devtools/client/responsive/manager.js" 190 ); 191 loader.lazyRequireGetter( 192 this, 193 "localTypes", 194 "resource://devtools/client/responsive/types.js" 195 ); 196 loader.lazyRequireGetter( 197 this, 198 "ResponsiveMessageHelper", 199 "resource://devtools/client/responsive/utils/message.js" 200 ); 201 202 loader.lazyRequireGetter( 203 this, 204 "FluentReact", 205 "resource://devtools/client/shared/vendor/fluent-react.js" 206 ); 207 208 const TEST_DIR = gTestPath.substr(0, gTestPath.lastIndexOf("/")); 209 const CHROME_URL_ROOT = TEST_DIR + "/"; 210 const URL_ROOT = CHROME_URL_ROOT.replace( 211 "chrome://mochitests/content/", 212 "http://example.com/" 213 ); 214 const URL_ROOT_SSL = CHROME_URL_ROOT.replace( 215 "chrome://mochitests/content/", 216 "https://example.com/" 217 ); 218 219 // Add aliases which make it more explicit that URL_ROOT uses a com TLD. 220 const URL_ROOT_COM = URL_ROOT; 221 const URL_ROOT_COM_SSL = URL_ROOT_SSL; 222 223 // Also expose http://example.org, http://example.net, https://example.org to 224 // test Fission scenarios easily. 225 // Note: example.net is not available for https. 226 const URL_ROOT_ORG = CHROME_URL_ROOT.replace( 227 "chrome://mochitests/content/", 228 "http://example.org/" 229 ); 230 const URL_ROOT_ORG_SSL = CHROME_URL_ROOT.replace( 231 "chrome://mochitests/content/", 232 "https://example.org/" 233 ); 234 const URL_ROOT_NET = CHROME_URL_ROOT.replace( 235 "chrome://mochitests/content/", 236 "http://example.net/" 237 ); 238 const URL_ROOT_NET_SSL = CHROME_URL_ROOT.replace( 239 "chrome://mochitests/content/", 240 "https://example.net/" 241 ); 242 // mochi.test:8888 is the actual primary location where files are served. 243 const URL_ROOT_MOCHI_8888 = CHROME_URL_ROOT.replace( 244 "chrome://mochitests/content/", 245 "http://mochi.test:8888/" 246 ); 247 248 try { 249 if (isMochitest) { 250 Services.scriptloader.loadSubScript( 251 "chrome://mochitests/content/browser/devtools/client/shared/test/telemetry-test-helpers.js", 252 this 253 ); 254 } 255 } catch (e) { 256 ok( 257 false, 258 "MISSING DEPENDENCY ON telemetry-test-helpers.js\n" + 259 "Please add the following line in browser.toml:\n" + 260 " !/devtools/client/shared/test/telemetry-test-helpers.js\n" 261 ); 262 throw e; 263 } 264 265 // Force devtools to be initialized so menu items and keyboard shortcuts get installed 266 require("resource://devtools/client/framework/devtools-browser.js"); 267 268 // All tests are asynchronous 269 if (isMochitest) { 270 waitForExplicitFinish(); 271 } 272 273 var EXPECTED_DTU_ASSERT_FAILURE_COUNT = 0; 274 275 registerCleanupFunction(function () { 276 if ( 277 DevToolsUtils.assertionFailureCount !== EXPECTED_DTU_ASSERT_FAILURE_COUNT 278 ) { 279 ok( 280 false, 281 "Should have had the expected number of DevToolsUtils.assert() failures." + 282 " Expected " + 283 EXPECTED_DTU_ASSERT_FAILURE_COUNT + 284 ", got " + 285 DevToolsUtils.assertionFailureCount 286 ); 287 } 288 }); 289 290 // Uncomment this pref to dump all devtools emitted events to the console. 291 // Services.prefs.setBoolPref("devtools.dump.emit", true); 292 293 /** 294 * Watch console messages for failed propType definitions in React components. 295 */ 296 function onConsoleMessage(subject) { 297 const message = subject.wrappedJSObject.arguments[0]; 298 299 if (message && /Failed propType/.test(message.toString())) { 300 ok(false, message); 301 } 302 } 303 304 const ConsoleAPIStorage = Cc["@mozilla.org/consoleAPI-storage;1"].getService( 305 Ci.nsIConsoleAPIStorage 306 ); 307 308 ConsoleAPIStorage.addLogEventListener( 309 onConsoleMessage, 310 Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal) 311 ); 312 registerCleanupFunction(() => { 313 ConsoleAPIStorage.removeLogEventListener(onConsoleMessage); 314 }); 315 316 // Disable this preference to reduce exceptions related to pending `listWorkers` 317 // requests occuring after a process is created/destroyed. See Bug 1620983. 318 add_setup(async () => { 319 if (!isXpcshell) { 320 await pushPref("dom.ipc.processPrelaunch.enabled", false); 321 } 322 }); 323 324 // On some Linux platforms, prefers-reduced-motion is enabled, which would 325 // trigger the notification to be displayed in the toolbox. Dismiss the message 326 // by default. 327 Services.prefs.setBoolPref( 328 "devtools.inspector.simple-highlighters.message-dismissed", 329 true 330 ); 331 332 // Enable dumping scope variables when a test failure occurs. 333 Services.prefs.setBoolPref("devtools.testing.testScopes", true); 334 335 var { 336 BrowserConsoleManager, 337 } = require("resource://devtools/client/webconsole/browser-console-manager.js"); 338 339 registerCleanupFunction(async function cleanup() { 340 // Closing the browser console if there's one 341 const browserConsole = BrowserConsoleManager.getBrowserConsole(); 342 if (browserConsole) { 343 await safeCloseBrowserConsole({ clearOutput: true }); 344 } 345 346 // Close any tab opened by the test. 347 // There should be only one tab opened by default when firefox starts the test. 348 while (isMochitest && gBrowser.tabs.length > 1) { 349 await closeTabAndToolbox(gBrowser.selectedTab); 350 } 351 352 // Note that this will run before cleanup functions registered by tests or other head.js files. 353 // So all connections must be cleaned up by the test when the test ends, 354 // before the harness starts invoking the cleanup functions 355 await waitForTick(); 356 357 // All connections must be cleaned up by the test when the test ends. 358 const { 359 DevToolsServer, 360 } = require("resource://devtools/server/devtools-server.js"); 361 ok( 362 !DevToolsServer.hasConnection(), 363 "The main process DevToolsServer has no pending connection when the test ends" 364 ); 365 // If there is still open connection, close all of them so that following tests 366 // could pass. 367 if (DevToolsServer.hasConnection()) { 368 for (const conn of Object.values(DevToolsServer._connections)) { 369 conn.close(); 370 } 371 } 372 373 // Reset all preferences AFTER the toolbox is closed. 374 // NOTE: Doing it before toolbox destruction could trigger observers. 375 await resetPreferencesModifiedDuringTest(); 376 }); 377 378 async function safeCloseBrowserConsole({ clearOutput = false } = {}) { 379 const hud = BrowserConsoleManager.getBrowserConsole(); 380 if (!hud) { 381 return; 382 } 383 384 if (clearOutput) { 385 info("Clear the browser console output"); 386 const { ui } = hud; 387 const promises = [ui.once("messages-cleared")]; 388 // If there's an object inspector, we need to wait for the actors to be released. 389 if (ui.outputNode.querySelector(".object-inspector")) { 390 promises.push(ui.once("fronts-released")); 391 } 392 await ui.clearOutput(true); 393 await Promise.all(promises); 394 info("Browser console cleared"); 395 } 396 397 info("Wait for all Browser Console targets to be attached"); 398 // It might happen that waitForAllTargetsToBeAttached does not resolve, so we set a 399 // timeout of 1s before closing 400 await Promise.race([ 401 waitForAllTargetsToBeAttached(hud.commands.targetCommand), 402 wait(1000), 403 ]); 404 405 info("Close the Browser Console"); 406 await BrowserConsoleManager.closeBrowserConsole(); 407 info("Browser Console closed"); 408 } 409 410 /** 411 * Observer code to register the test actor in every DevTools server which 412 * starts registering its own actors. 413 * 414 * We require immediately the highlighter test actor file, because it will force to load and 415 * register the front and the spec for HighlighterTestActor. Normally specs and fronts are 416 * in separate files registered in specs/index.js. But here to simplify the 417 * setup everything is in the same file and we force to load it here. 418 * 419 * DevToolsServer will emit "devtools-server-initialized" after finishing its 420 * initialization. We watch this observable to add our custom actor. 421 * 422 * As a single test may create several DevTools servers, we keep the observer 423 * alive until the test ends. 424 * 425 * To avoid leaks, the observer needs to be removed at the end of each test. 426 * The test cleanup will send the async message "remove-devtools-highlightertestactor-observer", 427 * we listen to this message to cleanup the observer. 428 */ 429 function highlighterTestActorBootstrap() { 430 /* eslint-env mozilla/process-script */ 431 const HIGHLIGHTER_TEST_ACTOR_URL = 432 "chrome://mochitests/content/browser/devtools/client/shared/test/highlighter-test-actor.js"; 433 434 const { require: _require } = ChromeUtils.importESModule( 435 "resource://devtools/shared/loader/Loader.sys.mjs" 436 ); 437 _require(HIGHLIGHTER_TEST_ACTOR_URL); 438 439 const actorRegistryObserver = subject => { 440 const actorRegistry = subject.wrappedJSObject; 441 actorRegistry.registerModule(HIGHLIGHTER_TEST_ACTOR_URL, { 442 prefix: "highlighterTest", 443 constructor: "HighlighterTestActor", 444 type: { target: true }, 445 }); 446 }; 447 Services.obs.addObserver( 448 actorRegistryObserver, 449 "devtools-server-initialized" 450 ); 451 452 const unloadListener = () => { 453 Services.cpmm.removeMessageListener( 454 "remove-devtools-testactor-observer", 455 unloadListener 456 ); 457 Services.obs.removeObserver( 458 actorRegistryObserver, 459 "devtools-server-initialized" 460 ); 461 }; 462 Services.cpmm.addMessageListener( 463 "remove-devtools-testactor-observer", 464 unloadListener 465 ); 466 } 467 468 if (isMochitest) { 469 const highlighterTestActorBootstrapScript = 470 "data:,(" + highlighterTestActorBootstrap + ")()"; 471 Services.ppmm.loadProcessScript( 472 highlighterTestActorBootstrapScript, 473 // Load this script in all processes (created or to be created) 474 true 475 ); 476 477 registerCleanupFunction(() => { 478 Services.ppmm.broadcastAsyncMessage("remove-devtools-testactor-observer"); 479 Services.ppmm.removeDelayedProcessScript( 480 highlighterTestActorBootstrapScript 481 ); 482 }); 483 } 484 485 /** 486 * Spawn an instance of the highlighter test actor for the given toolbox 487 * 488 * @param {Toolbox} toolbox 489 * @param {object} options 490 * @param {Function} options.target: Optional target to get the highlighterTestFront for. 491 * If not provided, the top level target will be used. 492 * @returns {HighlighterTestFront} 493 */ 494 async function getHighlighterTestFront(toolbox, { target } = {}) { 495 // Loading the Inspector panel in order to overwrite the TestActor getter for the 496 // highlighter instance with a method that points to the currently visible 497 // Box Model Highlighter managed by the Inspector panel. 498 const inspector = await toolbox.loadTool("inspector"); 499 500 const highlighterTestFront = await (target || toolbox.target).getFront( 501 "highlighterTest" 502 ); 503 // Override the highligher getter with a method to return the active box model 504 // highlighter. Adaptation for multi-process scenarios where there can be multiple 505 // highlighters, one per process. 506 highlighterTestFront.highlighter = () => { 507 return inspector.highlighters.getActiveHighlighter( 508 inspector.highlighters.TYPES.BOXMODEL 509 ); 510 }; 511 return highlighterTestFront; 512 } 513 514 /** 515 * Spawn an instance of the highlighter test actor for the given tab, when we need the 516 * highlighter test front before opening or without a toolbox. 517 * 518 * @param {Tab} tab 519 * @returns {HighlighterTestFront} 520 */ 521 async function getHighlighterTestFrontWithoutToolbox(tab) { 522 const commands = await CommandsFactory.forTab(tab); 523 // Initialize the TargetCommands which require some async stuff to be done 524 // before being fully ready. This will define the `targetCommand.targetFront` attribute. 525 await commands.targetCommand.startListening(); 526 527 const targetFront = commands.targetCommand.targetFront; 528 return targetFront.getFront("highlighterTest"); 529 } 530 531 /** 532 * Returns a Promise that resolves when all the targets are fully attached. 533 * 534 * @param {TargetCommand} targetCommand 535 */ 536 function waitForAllTargetsToBeAttached(targetCommand) { 537 return Promise.allSettled( 538 targetCommand 539 .getAllTargets(targetCommand.ALL_TYPES) 540 .map(target => target.initialized) 541 ); 542 } 543 544 /** 545 * Add a new test tab in the browser and load the given url. 546 * 547 * @param {string} url The url to be loaded in the new tab 548 * @param {object} options Object with various optional fields: 549 * - {Boolean} background If true, open the tab in background 550 * - {ChromeWindow} window Firefox top level window we should use to open the tab 551 * - {Number} userContextId The userContextId of the tab. 552 * - {String} preferredRemoteType 553 * - {Boolean} waitForLoad Wait for the page in the new tab to load. (Defaults to true.) 554 * @return a promise that resolves to the tab object when the url is loaded 555 */ 556 async function addTab(url, options = {}) { 557 info("Adding a new tab with URL: " + url); 558 559 const { 560 background = false, 561 userContextId, 562 preferredRemoteType, 563 waitForLoad = true, 564 } = options; 565 const { gBrowser } = options.window ? options.window : window; 566 567 const tab = BrowserTestUtils.addTab(gBrowser, url, { 568 userContextId, 569 preferredRemoteType, 570 }); 571 572 if (!background) { 573 gBrowser.selectedTab = tab; 574 } 575 576 if (waitForLoad) { 577 // accept any URL as url arg might not be serialized or redirects might happen 578 await BrowserTestUtils.browserLoaded(tab.linkedBrowser, { 579 wantLoad: () => true, 580 }); 581 // Waiting for presShell helps with test timeouts in webrender platforms. 582 await waitForPresShell(tab.linkedBrowser); 583 info("Tab added and finished loading"); 584 } else { 585 info("Tab added"); 586 } 587 588 return tab; 589 } 590 591 /** 592 * Remove the given tab. 593 * 594 * @param {object} tab The tab to be removed. 595 * @return Promise<undefined> resolved when the tab is successfully removed. 596 */ 597 async function removeTab(tab) { 598 info("Removing tab."); 599 600 const { gBrowser } = tab.ownerDocument.defaultView; 601 const onClose = once(gBrowser.tabContainer, "TabClose"); 602 gBrowser.removeTab(tab); 603 await onClose; 604 605 info("Tab removed and finished closing"); 606 } 607 608 /** 609 * Alias for navigateTo which will reuse the current URI of the provided browser 610 * to trigger a navigation. 611 */ 612 async function reloadBrowser({ 613 browser = gBrowser.selectedBrowser, 614 isErrorPage = false, 615 waitForLoad = true, 616 } = {}) { 617 return navigateTo(browser.currentURI.spec, { 618 browser, 619 isErrorPage, 620 waitForLoad, 621 }); 622 } 623 624 /** 625 * Navigate the currently selected tab to a new URL and wait for it to load. 626 * Also wait for the toolbox to attach to the new target, if we navigated 627 * to a new process. 628 * 629 * @param {string} url The url to be loaded in the current tab. 630 * @param {JSON} options Optional dictionary object with the following keys: 631 * - {XULBrowser} browser 632 * The browser element which should navigate. Defaults to the selected 633 * browser. 634 * - {Boolean} isErrorPage 635 * You may pass `true` if the URL is an error page. Otherwise 636 * BrowserTestUtils.browserLoaded will wait for 'load' event, which 637 * never fires for error pages. 638 * - {Boolean} waitForLoad 639 * You may pass `false` if the page load is expected to be blocked by 640 * a script or a breakpoint. 641 * 642 * @return a promise that resolves when the page has fully loaded. 643 */ 644 async function navigateTo( 645 uri, 646 { 647 browser = gBrowser.selectedBrowser, 648 isErrorPage = false, 649 waitForLoad = true, 650 } = {} 651 ) { 652 const waitForDevToolsReload = await watchForDevToolsReload(browser, { 653 isErrorPage, 654 waitForLoad, 655 }); 656 657 uri = uri.replaceAll("\n", ""); 658 info(`Navigating to "${uri}"`); 659 660 const onBrowserLoaded = BrowserTestUtils.browserLoaded( 661 browser, 662 // includeSubFrames 663 false, 664 // resolve on this specific page to load (if null, it would be any page load) 665 loadedUrl => { 666 // loadedUrl is encoded, while uri might not be. 667 return loadedUrl === uri || decodeURI(loadedUrl) === uri; 668 }, 669 isErrorPage 670 ); 671 672 // if we're navigating to the same page we're already on, use reloadTab instead as the 673 // behavior slightly differs from loadURI (e.g. scroll position isn't keps with the latter). 674 if (uri === browser.currentURI.spec) { 675 gBrowser.reloadTab(gBrowser.getTabForBrowser(browser)); 676 } else { 677 BrowserTestUtils.startLoadingURIString(browser, uri); 678 } 679 680 if (waitForLoad) { 681 info(`Waiting for page to be loaded…`); 682 await onBrowserLoaded; 683 info(`→ page loaded`); 684 } 685 686 await waitForDevToolsReload(); 687 } 688 689 /** 690 * This method should be used to watch for completion of any browser navigation 691 * performed with a DevTools UI. 692 * 693 * It should watch for: 694 * - Toolbox reload 695 * - Toolbox commands reload 696 * - RDM reload 697 * - RDM commands reload 698 * 699 * And it should work both for target switching or old-style navigations. 700 * 701 * This method, similarly to all the other watch* navigation methods in this file, 702 * is async but returns another method which should be called after the navigation 703 * is done. Browser navigation might be monitored differently depending on the 704 * situation, so it's up to the caller to handle it as needed. 705 * 706 * Typically, this would be used as follows: 707 * ``` 708 * async function someNavigationHelper(browser) { 709 * const waitForDevToolsFn = await watchForDevToolsReload(browser); 710 * 711 * // This step should wait for the load to be completed from the browser's 712 * // point of view, so that waitForDevToolsFn can compare pIds, browsing 713 * // contexts etc... and check if we should expect a target switch 714 * await performBrowserNavigation(browser); 715 * 716 * await waitForDevToolsFn(); 717 * } 718 * ``` 719 */ 720 async function watchForDevToolsReload( 721 browser, 722 { isErrorPage = false, waitForLoad = true } = {} 723 ) { 724 const waitForToolboxReload = await _watchForToolboxReload(browser, { 725 isErrorPage, 726 waitForLoad, 727 }); 728 const waitForResponsiveReload = await _watchForResponsiveReload(browser, { 729 isErrorPage, 730 waitForLoad, 731 }); 732 733 return async function () { 734 info("Wait for the toolbox to reload"); 735 await waitForToolboxReload(); 736 737 info("Wait for Responsive UI to reload"); 738 await waitForResponsiveReload(); 739 }; 740 } 741 742 /** 743 * Start watching for the toolbox reload to be completed: 744 * - watch for the toolbox's commands to be fully reloaded 745 * - watch for the toolbox's current panel to be reloaded 746 */ 747 async function _watchForToolboxReload( 748 browser, 749 { isErrorPage, waitForLoad } = {} 750 ) { 751 const tab = gBrowser.getTabForBrowser(browser); 752 753 const toolbox = gDevTools.getToolboxForTab(tab); 754 755 if (!toolbox) { 756 // No toolbox to wait for 757 return function () {}; 758 } 759 760 const waitForCurrentPanelReload = watchForCurrentPanelReload(toolbox); 761 const waitForToolboxCommandsReload = await watchForCommandsReload( 762 toolbox.commands, 763 { isErrorPage, waitForLoad } 764 ); 765 const checkTargetSwitching = await watchForTargetSwitching( 766 toolbox.commands, 767 browser 768 ); 769 770 return async function () { 771 const isTargetSwitching = checkTargetSwitching(); 772 773 info(`Waiting for toolbox commands to be reloaded…`); 774 await waitForToolboxCommandsReload(isTargetSwitching); 775 776 // TODO: We should wait for all loaded panels to reload here, because some 777 // of them might still perform background updates. 778 if (waitForCurrentPanelReload) { 779 info(`Waiting for ${toolbox.currentToolId} to be reloaded…`); 780 await waitForCurrentPanelReload(); 781 info(`→ panel reloaded`); 782 } 783 }; 784 } 785 786 /** 787 * Start watching for Responsive UI (RDM) reload to be completed: 788 * - watch for the Responsive UI's commands to be fully reloaded 789 * - watch for the Responsive UI's target switch to be done 790 */ 791 async function _watchForResponsiveReload( 792 browser, 793 { isErrorPage, waitForLoad } = {} 794 ) { 795 const tab = gBrowser.getTabForBrowser(browser); 796 const ui = ResponsiveUIManager.getResponsiveUIForTab(tab); 797 798 if (!ui) { 799 // No responsive UI to wait for 800 return function () {}; 801 } 802 803 const onResponsiveTargetSwitch = ui.once("responsive-ui-target-switch-done"); 804 const waitForResponsiveCommandsReload = await watchForCommandsReload( 805 ui.commands, 806 { isErrorPage, waitForLoad } 807 ); 808 const checkTargetSwitching = await watchForTargetSwitching( 809 ui.commands, 810 browser 811 ); 812 813 return async function () { 814 const isTargetSwitching = checkTargetSwitching(); 815 816 info(`Waiting for responsive ui commands to be reloaded…`); 817 await waitForResponsiveCommandsReload(isTargetSwitching); 818 819 if (isTargetSwitching) { 820 await onResponsiveTargetSwitch; 821 } 822 }; 823 } 824 825 /** 826 * Watch for the current panel selected in the provided toolbox to be reloaded. 827 * Some panels implement custom events that should be expected for every reload. 828 * 829 * Note about returning a method instead of a promise: 830 * In general this pattern is useful so that we can check if a target switch 831 * occurred or not, and decide which events to listen for. So far no panel is 832 * behaving differently whether there was a target switch or not. But to remain 833 * consistent with other watch* methods we still return a function here. 834 * 835 * @param {Toolbox} 836 * The Toolbox instance which is going to experience a reload 837 * @return {function} An async method to be called and awaited after the reload 838 * started. Will return `null` for panels which don't implement any 839 * specific reload event. 840 */ 841 function watchForCurrentPanelReload(toolbox) { 842 return _watchForPanelReload(toolbox, toolbox.currentToolId); 843 } 844 845 /** 846 * Watch for all the panels loaded in the provided toolbox to be reloaded. 847 * Some panels implement custom events that should be expected for every reload. 848 * 849 * Note about returning a method instead of a promise: 850 * See comment for watchForCurrentPanelReload 851 * 852 * @param {Toolbox} 853 * The Toolbox instance which is going to experience a reload 854 * @return {function} An async method to be called and awaited after the reload 855 * started. 856 */ 857 function watchForLoadedPanelsReload(toolbox) { 858 const waitForPanels = []; 859 for (const [id] of toolbox.getToolPanels()) { 860 // Store a watcher method for each panel already loaded. 861 waitForPanels.push(_watchForPanelReload(toolbox, id)); 862 } 863 864 return function () { 865 return Promise.all( 866 waitForPanels.map(async watchPanel => { 867 // Wait for all panels to be reloaded. 868 if (watchPanel) { 869 await watchPanel(); 870 } 871 }) 872 ); 873 }; 874 } 875 876 function _watchForPanelReload(toolbox, toolId) { 877 const panel = toolbox.getPanel(toolId); 878 879 if (toolId == "inspector") { 880 const markuploaded = panel.once("markuploaded"); 881 const onNewRoot = panel.once("new-root"); 882 const onUpdated = panel.once("inspector-updated"); 883 const onReloaded = panel.once("reloaded"); 884 885 return async function () { 886 info("Waiting for markup view to load after navigation."); 887 await markuploaded; 888 889 info("Waiting for new root."); 890 await onNewRoot; 891 892 info("Waiting for inspector to update after new-root event."); 893 await onUpdated; 894 895 info("Waiting for inspector updates after page reload"); 896 await onReloaded; 897 898 info("Received 'reloaded' event for inspector"); 899 }; 900 } else if ( 901 ["netmonitor", "accessibility", "webconsole", "jsdebugger"].includes(toolId) 902 ) { 903 const onReloaded = panel.once("reloaded"); 904 return async function () { 905 info(`Waiting for ${toolId} updates after page reload`); 906 await onReloaded; 907 908 info(`Received 'reloaded' event for ${toolId}`); 909 }; 910 } 911 return null; 912 } 913 914 /** 915 * Watch for a Commands instance to be reloaded after a navigation. 916 * 917 * As for other navigation watch* methods, this should be called before the 918 * navigation starts, and the function it returns should be called after the 919 * navigation is done from a Browser point of view. 920 * 921 * !!! The wait function expects a `isTargetSwitching` argument to be provided, 922 * which needs to be monitored using watchForTargetSwitching !!! 923 */ 924 async function watchForCommandsReload( 925 commands, 926 { isErrorPage = false, waitForLoad = true } = {} 927 ) { 928 // If we're switching origins, we need to wait for the 'switched-target' 929 // event to make sure everything is ready. 930 // Navigating from/to pages loaded in the parent process, like about:robots, 931 // also spawn new targets. 932 // (If target switching is disabled, the toolbox will reboot) 933 const onTargetSwitched = commands.targetCommand.once("switched-target"); 934 935 // Wait until we received a page load resource: 936 // - dom-complete if we can wait for a full page load 937 // - dom-loading otherwise 938 // This allows to wait for page load for consumers calling directly 939 // waitForDevTools instead of navigateTo/reloadBrowser. 940 // This is also useful as an alternative to target switching, when no target 941 // switch is supposed to happen. 942 const waitForCompleteLoad = waitForLoad && !isErrorPage; 943 const documentEventName = waitForCompleteLoad 944 ? "dom-complete" 945 : "dom-loading"; 946 947 const { onResource: onTopLevelDomEvent } = 948 await commands.resourceCommand.waitForNextResource( 949 commands.resourceCommand.TYPES.DOCUMENT_EVENT, 950 { 951 ignoreExistingResources: true, 952 predicate: resource => 953 resource.targetFront.isTopLevel && 954 resource.name === documentEventName, 955 } 956 ); 957 958 return async function (isTargetSwitching) { 959 if (typeof isTargetSwitching === "undefined") { 960 throw new Error("isTargetSwitching was not provided to the wait method"); 961 } 962 963 if (isTargetSwitching) { 964 info(`Waiting for target switch…`); 965 await onTargetSwitched; 966 info(`→ switched-target emitted`); 967 } 968 969 info(`Waiting for '${documentEventName}' resource…`); 970 await onTopLevelDomEvent; 971 info(`→ '${documentEventName}' resource emitted`); 972 973 return isTargetSwitching; 974 }; 975 } 976 977 /** 978 * Watch if an upcoming navigation will trigger a target switching, for the 979 * provided Commands instance and the provided Browser. 980 * 981 * As for other navigation watch* methods, this should be called before the 982 * navigation starts, and the function it returns should be called after the 983 * navigation is done from a Browser point of view. 984 */ 985 async function watchForTargetSwitching(commands, browser) { 986 browser = browser || gBrowser.selectedBrowser; 987 const currentPID = browser.browsingContext.currentWindowGlobal.osPid; 988 const currentBrowsingContextID = browser.browsingContext.id; 989 990 // If the current top-level target follows the window global lifecycle, a 991 // target switch will occur regardless of process changes. 992 const targetFollowsWindowLifecycle = 993 commands.targetCommand.targetFront.targetForm.followWindowGlobalLifeCycle; 994 995 return function () { 996 // Compare the PIDs (and not the toolbox's targets) as PIDs are updated also immediately, 997 // while target may be updated slightly later. 998 const switchedProcess = 999 currentPID !== browser.browsingContext.currentWindowGlobal.osPid; 1000 const switchedBrowsingContext = 1001 currentBrowsingContextID !== browser.browsingContext.id; 1002 1003 return ( 1004 targetFollowsWindowLifecycle || switchedProcess || switchedBrowsingContext 1005 ); 1006 }; 1007 } 1008 1009 /** 1010 * Create a Target for the provided tab and attach to it before resolving. 1011 * This should only be used for tests which don't involve the frontend or a 1012 * toolbox. Typically, retrieving the target and attaching to it should be 1013 * handled at framework level when a Toolbox is used. 1014 * 1015 * @param {XULTab} tab 1016 * The tab for which a target should be created. 1017 * @return {WindowGlobalTargetFront} The attached target front. 1018 */ 1019 async function createAndAttachTargetForTab(tab) { 1020 info("Creating and attaching to a local tab target"); 1021 1022 const commands = await CommandsFactory.forTab(tab); 1023 1024 // Initialize the TargetCommands which require some async stuff to be done 1025 // before being fully ready. This will define the `targetCommand.targetFront` attribute. 1026 await commands.targetCommand.startListening(); 1027 1028 const target = commands.targetCommand.targetFront; 1029 return target; 1030 } 1031 1032 /** 1033 * Open the inspector in a tab with given URL. 1034 * 1035 * @param {string} url The URL to open. 1036 * @param {string} hostType Optional hostType, as defined in Toolbox.HostType 1037 * @return A promise that is resolved once the tab and inspector have loaded 1038 * with an object: { tab, toolbox, inspector, highlighterTestFront }. 1039 */ 1040 async function openInspectorForURL(url, hostType) { 1041 const tab = await addTab(url); 1042 const { inspector, toolbox, highlighterTestFront } = 1043 await openInspector(hostType); 1044 return { tab, inspector, toolbox, highlighterTestFront }; 1045 } 1046 1047 function getActiveInspector() { 1048 const toolbox = gDevTools.getToolboxForTab(gBrowser.selectedTab); 1049 return toolbox.getPanel("inspector"); 1050 } 1051 1052 /** 1053 * Simulate a key event from an electron key shortcut string: 1054 * https://github.com/electron/electron/blob/master/docs/api/accelerator.md 1055 * 1056 * @param {string} key 1057 * @param {DOMWindow} target 1058 * Optional window where to fire the key event 1059 */ 1060 function synthesizeKeyShortcut(key, target) { 1061 const shortcut = KeyShortcuts.parseElectronKey(key); 1062 const keyEvent = { 1063 altKey: shortcut.alt, 1064 ctrlKey: shortcut.ctrl, 1065 metaKey: shortcut.meta, 1066 shiftKey: shortcut.shift, 1067 }; 1068 if (shortcut.keyCode) { 1069 keyEvent.keyCode = shortcut.keyCode; 1070 } 1071 1072 info("Synthesizing key shortcut: " + key); 1073 EventUtils.synthesizeKey(shortcut.key || "", keyEvent, target); 1074 } 1075 1076 var waitForTime = DevToolsUtils.waitForTime; 1077 1078 /** 1079 * Wait for a tick. 1080 * 1081 * @return {Promise} 1082 */ 1083 function waitForTick() { 1084 return new Promise(resolve => DevToolsUtils.executeSoon(resolve)); 1085 } 1086 1087 /** 1088 * This shouldn't be used in the tests, but is useful when writing new tests or 1089 * debugging existing tests in order to introduce delays in the test steps 1090 * 1091 * @param {number} ms 1092 * The time to wait 1093 * @return A promise that resolves when the time is passed 1094 */ 1095 function wait(ms) { 1096 return new Promise(resolve => { 1097 setTimeout(resolve, ms); 1098 info("Waiting " + ms / 1000 + " seconds."); 1099 }); 1100 } 1101 1102 /** 1103 * Wait for a predicate to return a result. 1104 * 1105 * @param function condition 1106 * Invoked once in a while until it returns a truthy value. This should be an 1107 * idempotent function, since we have to run it a second time after it returns 1108 * true in order to return the value. 1109 * @param string message [optional] 1110 * A message to output if the condition fails. 1111 * @param number interval [optional] 1112 * How often the predicate is invoked, in milliseconds. 1113 * Can be set globally for a test via `waitFor.overrideIntervalForTestFile = someNumber;`. 1114 * @param number maxTries [optional] 1115 * How many times the predicate is invoked before timing out. 1116 * Can be set globally for a test via `waitFor.overrideMaxTriesForTestFile = someNumber;`. 1117 * @param boolean expectTimeout [optional] 1118 * Whether the helper is expected to reach the timeout or not. Consider 1119 * using waitForTimeout instead of passing this to true. 1120 * @return object 1121 * A promise that is resolved with the result of the condition. 1122 */ 1123 async function waitFor( 1124 condition, 1125 message = "", 1126 interval = 10, 1127 maxTries = 500, 1128 expectTimeout = false 1129 ) { 1130 // Update interval & maxTries if overrides are defined on the waitFor object. 1131 interval = 1132 typeof waitFor.overrideIntervalForTestFile !== "undefined" 1133 ? waitFor.overrideIntervalForTestFile 1134 : interval; 1135 maxTries = 1136 typeof waitFor.overrideMaxTriesForTestFile !== "undefined" 1137 ? waitFor.overrideMaxTriesForTestFile 1138 : maxTries; 1139 1140 try { 1141 const value = await BrowserTestUtils.waitForCondition( 1142 condition, 1143 message, 1144 interval, 1145 maxTries 1146 ); 1147 if (expectTimeout) { 1148 // If we expected a timeout, fail the test here. 1149 const errorMessage = `Expected timeout in waitFor(): ${message} \nUnexpected condition: ${condition} \n`; 1150 ok(false, errorMessage); 1151 } 1152 return value; 1153 } catch (e) { 1154 if (expectTimeout) { 1155 // If we expected a timeout, simply return null, the consumer should not 1156 // need any return value. 1157 return null; 1158 } 1159 1160 // If we didn't expect a timeout, fail the test here. 1161 const errorMessage = `Failed waitFor(): ${message} \nFailed condition: ${condition} \nException Message: ${e}`; 1162 // Use both assert ok(false) and throw. 1163 // Assert captures the correct frame to log variables with dump-scope.js. 1164 // Error will make sure the test stops. 1165 ok(false, errorMessage); 1166 throw new Error(errorMessage); 1167 } 1168 } 1169 1170 /** 1171 * Similar to @see waitFor defined above but expect the predicate to never 1172 * be satisfied and therefore to timeout. 1173 * 1174 * Arguments are identical to `waitFor` but the default values for interval and 1175 * maxTries are more conservative since this method expects a timeout. 1176 * 1177 */ 1178 async function waitForTimeout( 1179 condition, 1180 message = "", 1181 interval = 100, 1182 maxTries = 10 1183 ) { 1184 return waitFor(condition, message, interval, maxTries, true); 1185 } 1186 1187 /** 1188 * Wait for eventName on target to be delivered a number of times. 1189 * 1190 * @param {object} target 1191 * An observable object that either supports on/off or 1192 * addEventListener/removeEventListener 1193 * @param {string} eventName 1194 * @param {number} numTimes 1195 * Number of deliveries to wait for. 1196 * @param {boolean} useCapture 1197 * Optional, for addEventListener/removeEventListener 1198 * @return A promise that resolves when the event has been handled 1199 */ 1200 function waitForNEvents(target, eventName, numTimes, useCapture = false) { 1201 info("Waiting for event: '" + eventName + "' on " + target + "."); 1202 1203 let count = 0; 1204 1205 return new Promise(resolve => { 1206 for (const [add, remove] of [ 1207 ["on", "off"], 1208 ["addEventListener", "removeEventListener"], 1209 ["addListener", "removeListener"], 1210 ["addMessageListener", "removeMessageListener"], 1211 ]) { 1212 if (add in target && remove in target) { 1213 target[add]( 1214 eventName, 1215 function onEvent(...args) { 1216 if (typeof info === "function") { 1217 info("Got event: '" + eventName + "' on " + target + "."); 1218 } 1219 1220 if (++count == numTimes) { 1221 target[remove](eventName, onEvent, useCapture); 1222 resolve(...args); 1223 } 1224 }, 1225 useCapture 1226 ); 1227 break; 1228 } 1229 } 1230 }); 1231 } 1232 1233 /** 1234 * Wait for DOM change on target. 1235 * 1236 * @param {object} target 1237 * The Node on which to observe DOM mutations. 1238 * @param {string} selector 1239 * Given a selector to watch whether the expected element is changed 1240 * on target. 1241 * @param {number} expectedLength 1242 * Optional, default set to 1 1243 * There may be more than one element match an array match the selector, 1244 * give an expected length to wait for more elements. 1245 * @return A promise that resolves when the event has been handled 1246 */ 1247 function waitForDOM(target, selector, expectedLength = 1) { 1248 return new Promise(resolve => { 1249 const observer = new MutationObserver(mutations => { 1250 mutations.forEach(mutation => { 1251 const elements = mutation.target.querySelectorAll(selector); 1252 1253 if (elements.length === expectedLength) { 1254 observer.disconnect(); 1255 resolve(elements); 1256 } 1257 }); 1258 }); 1259 1260 observer.observe(target, { 1261 attributes: true, 1262 childList: true, 1263 subtree: true, 1264 }); 1265 }); 1266 } 1267 1268 /** 1269 * Wait for eventName on target. 1270 * 1271 * @param {object} target 1272 * An observable object that either supports on/off or 1273 * addEventListener/removeEventListener 1274 * @param {string} eventName 1275 * @param {boolean} useCapture 1276 * Optional, for addEventListener/removeEventListener 1277 * @return A promise that resolves when the event has been handled 1278 */ 1279 function once(target, eventName, useCapture = false) { 1280 return waitForNEvents(target, eventName, 1, useCapture); 1281 } 1282 1283 /** 1284 * Some tests may need to import one or more of the test helper scripts. 1285 * A test helper script is simply a js file that contains common test code that 1286 * is either not common-enough to be in head.js, or that is located in a 1287 * separate directory. 1288 * The script will be loaded synchronously and in the test's scope. 1289 * 1290 * @param {string} filePath The file path, relative to the current directory. 1291 * Examples: 1292 * - "helper_attributes_test_runner.js" 1293 */ 1294 function loadHelperScript(filePath) { 1295 const testDir = gTestPath.substr(0, gTestPath.lastIndexOf("/")); 1296 Services.scriptloader.loadSubScript(testDir + "/" + filePath, this); 1297 } 1298 1299 /** 1300 * Open the toolbox in a given tab. 1301 * 1302 * @param {XULNode} tab The tab the toolbox should be opened in. 1303 * @param {string} toolId Optional. The ID of the tool to be selected. 1304 * @param {string} hostType Optional. The type of toolbox host to be used. 1305 * @return {Promise} Resolves with the toolbox, when it has been opened. 1306 */ 1307 async function openToolboxForTab(tab, toolId, hostType) { 1308 info("Opening the toolbox"); 1309 1310 // Check if the toolbox is already loaded. 1311 let toolbox = gDevTools.getToolboxForTab(tab); 1312 if (toolbox) { 1313 if (!toolId || (toolId && toolbox.getPanel(toolId))) { 1314 info("Toolbox is already opened"); 1315 return toolbox; 1316 } 1317 } 1318 1319 // If not, load it now. 1320 toolbox = await gDevTools.showToolboxForTab(tab, { toolId, hostType }); 1321 1322 // Make sure that the toolbox frame is focused. 1323 await new Promise(resolve => waitForFocus(resolve, toolbox.win)); 1324 1325 info("Toolbox opened and focused"); 1326 1327 return toolbox; 1328 } 1329 1330 /** 1331 * Add a new tab and open the toolbox in it. 1332 * 1333 * @param {string} url The URL for the tab to be opened. 1334 * @param {string} toolId Optional. The ID of the tool to be selected. 1335 * @param {string} hostType Optional. The type of toolbox host to be used. 1336 * @return {Promise} Resolves when the tab has been added, loaded and the 1337 * toolbox has been opened. Resolves to the toolbox. 1338 */ 1339 async function openNewTabAndToolbox(url, toolId, hostType) { 1340 const tab = await addTab(url); 1341 return openToolboxForTab(tab, toolId, hostType); 1342 } 1343 1344 /** 1345 * Close a tab and if necessary, the toolbox that belongs to it 1346 * 1347 * @param {Tab} tab The tab to close. 1348 * @return {Promise} Resolves when the toolbox and tab have been destroyed and 1349 * closed. 1350 */ 1351 async function closeTabAndToolbox(tab = gBrowser.selectedTab) { 1352 if (gDevTools.hasToolboxForTab(tab)) { 1353 await gDevTools.closeToolboxForTab(tab); 1354 } 1355 1356 await removeTab(tab); 1357 1358 await new Promise(resolve => setTimeout(resolve, 0)); 1359 } 1360 1361 /** 1362 * Close a toolbox and the current tab. 1363 * 1364 * @param {Toolbox} toolbox The toolbox to close. 1365 * @return {Promise} Resolves when the toolbox and tab have been destroyed and 1366 * closed. 1367 */ 1368 async function closeToolboxAndTab(toolbox) { 1369 await toolbox.destroy(); 1370 await removeTab(gBrowser.selectedTab); 1371 } 1372 1373 /** 1374 * Waits until a predicate returns true. 1375 * 1376 * @param function predicate 1377 * Invoked once in a while until it returns true. 1378 * @param number interval [optional] 1379 * How often the predicate is invoked, in milliseconds. 1380 */ 1381 function waitUntil(predicate, interval = 10) { 1382 if (predicate()) { 1383 return Promise.resolve(true); 1384 } 1385 return new Promise(resolve => { 1386 setTimeout(function () { 1387 waitUntil(predicate, interval).then(() => resolve(true)); 1388 }, interval); 1389 }); 1390 } 1391 1392 /** 1393 * Variant of waitUntil that accepts a predicate returning a promise. 1394 */ 1395 async function asyncWaitUntil(predicate, interval = 10) { 1396 let success = await predicate(); 1397 while (!success) { 1398 // Wait for X milliseconds. 1399 await new Promise(resolve => setTimeout(resolve, interval)); 1400 // Test the predicate again. 1401 success = await predicate(); 1402 } 1403 } 1404 1405 /** 1406 * Wait for a context menu popup to open. 1407 * 1408 * @param Element popup 1409 * The XUL popup you expect to open. 1410 * @param Element button 1411 * The button/element that receives the contextmenu event. This is 1412 * expected to open the popup. 1413 * @param function onShown 1414 * Function to invoke on popupshown event. 1415 * @param function onHidden 1416 * Function to invoke on popuphidden event. 1417 * @return object 1418 * A Promise object that is resolved after the popuphidden event 1419 * callback is invoked. 1420 */ 1421 function waitForContextMenu(popup, button, onShown, onHidden) { 1422 return new Promise(resolve => { 1423 function onPopupShown() { 1424 info("onPopupShown"); 1425 popup.removeEventListener("popupshown", onPopupShown); 1426 1427 onShown && onShown(); 1428 1429 // Use executeSoon() to get out of the popupshown event. 1430 popup.addEventListener("popuphidden", onPopupHidden); 1431 DevToolsUtils.executeSoon(() => popup.hidePopup()); 1432 } 1433 function onPopupHidden() { 1434 info("onPopupHidden"); 1435 popup.removeEventListener("popuphidden", onPopupHidden); 1436 1437 onHidden && onHidden(); 1438 1439 resolve(popup); 1440 } 1441 1442 popup.addEventListener("popupshown", onPopupShown); 1443 1444 info("wait for the context menu to open"); 1445 synthesizeContextMenuEvent(button); 1446 }); 1447 } 1448 1449 function synthesizeContextMenuEvent(el) { 1450 el.scrollIntoView(); 1451 const eventDetails = { type: "contextmenu", button: 2 }; 1452 EventUtils.synthesizeMouse( 1453 el, 1454 5, 1455 2, 1456 eventDetails, 1457 el.ownerDocument.defaultView 1458 ); 1459 } 1460 1461 /** 1462 * Promise wrapper around SimpleTest.waitForClipboard 1463 */ 1464 function waitForClipboardPromise(setup, expected) { 1465 return new Promise((resolve, reject) => { 1466 SimpleTest.waitForClipboard(expected, setup, resolve, reject); 1467 }); 1468 } 1469 1470 /** 1471 * Simple helper to push a temporary preference. Wrapper on SpecialPowers 1472 * pushPrefEnv that returns a promise resolving when the preferences have been 1473 * updated. 1474 * 1475 * @param {string} preferenceName 1476 * The name of the preference to updated 1477 * @param {} value 1478 * The preference value, type can vary 1479 * @return {Promise} resolves when the preferences have been updated 1480 */ 1481 function pushPref(preferenceName, value) { 1482 const options = { set: [[preferenceName, value]] }; 1483 return SpecialPowers.pushPrefEnv(options); 1484 } 1485 1486 /** 1487 * Close the toolbox for the selected tab if needed. 1488 */ 1489 async function closeToolboxIfOpen() { 1490 // `closeToolboxForTab` will be a noop if the selected tab does not have any 1491 // toolbox. 1492 await gDevTools.closeToolboxForTab(gBrowser.selectedTab); 1493 } 1494 1495 /** 1496 * Clean the logical clipboard content. This method only clears the OS clipboard on 1497 * Windows (see Bug 666254). 1498 */ 1499 function emptyClipboard() { 1500 const clipboard = Services.clipboard; 1501 clipboard.emptyClipboard(clipboard.kGlobalClipboard); 1502 } 1503 1504 /** 1505 * Check if the current operating system is Windows. 1506 */ 1507 function isWindows() { 1508 return Services.appinfo.OS === "WINNT"; 1509 } 1510 1511 /** 1512 * Create an HTTP server that can be used to simulate custom requests within 1513 * a test. It is automatically cleaned up when the test ends, so no need to 1514 * call `destroy`. 1515 * 1516 * See https://developer.mozilla.org/en-US/docs/Httpd.js/HTTP_server_for_unit_tests 1517 * for more information about how to register handlers. 1518 * 1519 * The server can be accessed like: 1520 * 1521 * ```js 1522 * const server = createTestHTTPServer(); 1523 * let url = "http://localhost: " + server.identity.primaryPort + "/path"; 1524 * ``` 1525 * 1526 * @returns {HttpServer} 1527 */ 1528 function createTestHTTPServer() { 1529 const { HttpServer } = ChromeUtils.importESModule( 1530 "resource://testing-common/httpd.sys.mjs" 1531 ); 1532 const server = new HttpServer(); 1533 1534 registerCleanupFunction(async function cleanup() { 1535 await new Promise(resolve => server.stop(resolve)); 1536 }); 1537 1538 server.start(-1); 1539 return server; 1540 } 1541 1542 /** 1543 * Register an actor in the content process of the current tab. 1544 * 1545 * Calling ActorRegistry.registerModule only registers the actor in the current process. 1546 * As all test scripts are ran in the parent process, it is only registered here. 1547 * This function helps register them in the content process used for the current tab. 1548 * 1549 * @param {string} url 1550 * Actor module URL or absolute require path 1551 * @param {json} options 1552 * Arguments to be passed to DevToolsServer.registerModule 1553 */ 1554 async function registerActorInContentProcess(url, options) { 1555 function convertChromeToFile(uri) { 1556 return Cc["@mozilla.org/chrome/chrome-registry;1"] 1557 .getService(Ci.nsIChromeRegistry) 1558 .convertChromeURL(Services.io.newURI(uri)).spec; 1559 } 1560 // chrome://mochitests URI is registered only in the parent process, so convert these 1561 // URLs to file:// one in order to work in the content processes 1562 url = url.startsWith("chrome://mochitests") ? convertChromeToFile(url) : url; 1563 return SpecialPowers.spawn( 1564 gBrowser.selectedBrowser, 1565 [{ url, options }], 1566 args => { 1567 // eslint-disable-next-line no-shadow 1568 const { require } = ChromeUtils.importESModule( 1569 "resource://devtools/shared/loader/Loader.sys.mjs" 1570 ); 1571 const { 1572 ActorRegistry, 1573 } = require("resource://devtools/server/actors/utils/actor-registry.js"); 1574 ActorRegistry.registerModule(args.url, args.options); 1575 } 1576 ); 1577 } 1578 1579 /** 1580 * Move the provided Window to the provided left, top coordinates and wait for 1581 * the window position to be updated. 1582 */ 1583 async function moveWindowTo(win, left, top) { 1584 // Check that the expected coordinates are within the window available area. 1585 left = Math.max(win.screen.availLeft, left); 1586 left = Math.min(win.screen.width, left); 1587 top = Math.max(win.screen.availTop, top); 1588 top = Math.min(win.screen.height, top); 1589 1590 info(`Moving window to {${left}, ${top}}`); 1591 win.moveTo(left, top); 1592 1593 // Bug 1600809: window move/resize can be async on Linux sometimes. 1594 // Wait so that the anchor's position is correctly measured. 1595 return waitUntil(() => { 1596 info( 1597 `Wait for window screenLeft and screenTop to be updated: (${win.screenLeft}, ${win.screenTop})` 1598 ); 1599 return win.screenLeft === left && win.screenTop === top; 1600 }); 1601 } 1602 1603 function getCurrentTestFilePath() { 1604 return gTestPath.replace("chrome://mochitests/content/browser/", ""); 1605 } 1606 1607 /** 1608 * Unregister all registered service workers. 1609 * 1610 * @param {DevToolsClient} client 1611 */ 1612 async function unregisterAllServiceWorkers(client) { 1613 info("Wait until all workers have a valid registrationFront"); 1614 let workers; 1615 await asyncWaitUntil(async function () { 1616 workers = await client.mainRoot.listAllWorkers(); 1617 const allWorkersRegistered = workers.service.every( 1618 worker => !!worker.registrationFront 1619 ); 1620 return allWorkersRegistered; 1621 }); 1622 1623 info("Unregister all service workers"); 1624 const promises = []; 1625 for (const worker of workers.service) { 1626 promises.push(worker.registrationFront.unregister()); 1627 } 1628 await Promise.all(promises); 1629 } 1630 1631 /********************** 1632 * Screenshot helpers * 1633 **********************/ 1634 1635 /** 1636 * Returns an object containing the r,g and b colors of the provided image at 1637 * the passed position 1638 * 1639 * @param {Image} image 1640 * @param {Int} x 1641 * @param {Int} y 1642 * @returns Object with the following properties: 1643 * - {Int} r: The red component of the pixel 1644 * - {Int} g: The green component of the pixel 1645 * - {Int} b: The blue component of the pixel 1646 */ 1647 function colorAt(image, x, y) { 1648 // Create a test canvas element. 1649 const HTML_NS = "http://www.w3.org/1999/xhtml"; 1650 const canvas = document.createElementNS(HTML_NS, "canvas"); 1651 canvas.width = image.width; 1652 canvas.height = image.height; 1653 1654 // Draw the image in the canvas 1655 const context = canvas.getContext("2d"); 1656 context.drawImage(image, 0, 0, image.width, image.height); 1657 1658 // Return the color found at the provided x,y coordinates as a "r, g, b" string. 1659 const [r, g, b] = context.getImageData(x, y, 1, 1).data; 1660 return { r, g, b }; 1661 } 1662 1663 let allDownloads = []; 1664 /** 1665 * Returns a Promise that resolves when a new file (e.g. screenshot, JSON, …) is available 1666 * in the download folder. 1667 * 1668 * @param {object} [options] 1669 * @param {boolean} options.isWindowPrivate: Set to true if the window from which the file 1670 * is downloaded is a private window. This will ensure that we check that the 1671 * file appears in the private window, not the non-private one (See Bug 1783373) 1672 */ 1673 async function waitUntilDownload({ isWindowPrivate = false } = {}) { 1674 const { Downloads } = ChromeUtils.importESModule( 1675 "resource://gre/modules/Downloads.sys.mjs" 1676 ); 1677 const list = await Downloads.getList(Downloads.ALL); 1678 1679 return new Promise(function (resolve) { 1680 const view = { 1681 onDownloadAdded: async download => { 1682 await download.whenSucceeded(); 1683 if (allDownloads.includes(download)) { 1684 return; 1685 } 1686 1687 is( 1688 !!download.source.isPrivate, 1689 isWindowPrivate, 1690 `The download occured in the expected${ 1691 isWindowPrivate ? " private" : "" 1692 } window` 1693 ); 1694 1695 allDownloads.push(download); 1696 resolve(download.target.path); 1697 list.removeView(view); 1698 }, 1699 }; 1700 1701 list.addView(view); 1702 }); 1703 } 1704 1705 /** 1706 * Clear all the download references. 1707 */ 1708 async function resetDownloads() { 1709 info("Reset downloads"); 1710 const { Downloads } = ChromeUtils.importESModule( 1711 "resource://gre/modules/Downloads.sys.mjs" 1712 ); 1713 const downloadList = await Downloads.getList(Downloads.ALL); 1714 const downloads = await downloadList.getAll(); 1715 for (const download of downloads) { 1716 downloadList.remove(download); 1717 await download.finalize(true); 1718 } 1719 allDownloads = []; 1720 } 1721 1722 /** 1723 * Return a screenshot of the currently selected node in the inspector (using the internal 1724 * Inspector#screenshotNode method). 1725 * 1726 * @param {Inspector} inspector 1727 * @returns {Image} 1728 */ 1729 async function takeNodeScreenshot(inspector) { 1730 // Cleanup all downloads at the end of the test. 1731 registerCleanupFunction(resetDownloads); 1732 1733 info( 1734 "Call screenshotNode() and wait until the screenshot is found in the Downloads" 1735 ); 1736 const whenScreenshotSucceeded = waitUntilDownload(); 1737 inspector.screenshotNode(); 1738 const filePath = await whenScreenshotSucceeded; 1739 1740 info("Create an image using the downloaded fileas source"); 1741 const image = new Image(); 1742 const onImageLoad = once(image, "load"); 1743 image.src = PathUtils.toFileURI(filePath); 1744 await onImageLoad; 1745 1746 info("Remove the downloaded screenshot file"); 1747 await IOUtils.remove(filePath); 1748 1749 // See intermittent Bug 1508435. Even after removing the file, tests still manage to 1750 // reuse files from the previous test if they have the same name. Since our file name 1751 // is based on a timestamp that has "second" precision, wait for one second to make sure 1752 // screenshots will have different names. 1753 info( 1754 "Wait for one second to make sure future screenshots will use a different name" 1755 ); 1756 await new Promise(r => setTimeout(r, 1000)); 1757 1758 return image; 1759 } 1760 1761 /** 1762 * Check that the provided image has the expected width, height, and color. 1763 * NOTE: This test assumes that the image is only made of a single color and will only 1764 * check one pixel. 1765 */ 1766 async function assertSingleColorScreenshotImage( 1767 image, 1768 width, 1769 height, 1770 { r, g, b } 1771 ) { 1772 info(`Assert ${image.src} content`); 1773 const ratio = await SpecialPowers.spawn( 1774 gBrowser.selectedBrowser, 1775 [], 1776 () => content.wrappedJSObject.devicePixelRatio 1777 ); 1778 1779 is( 1780 image.width, 1781 ratio * width, 1782 `node screenshot has the expected width (dpr = ${ratio})` 1783 ); 1784 is( 1785 image.height, 1786 height * ratio, 1787 `node screenshot has the expected height (dpr = ${ratio})` 1788 ); 1789 1790 const color = colorAt(image, 0, 0); 1791 is(color.r, r, "node screenshot has the expected red component"); 1792 is(color.g, g, "node screenshot has the expected green component"); 1793 is(color.b, b, "node screenshot has the expected blue component"); 1794 } 1795 1796 /** 1797 * Check that the provided image has the expected color at a given position 1798 */ 1799 function checkImageColorAt({ image, x = 0, y, expectedColor, label }) { 1800 const color = colorAt(image, x, y); 1801 is(`rgb(${Object.values(color).join(", ")})`, expectedColor, label); 1802 } 1803 1804 /** 1805 * Wait until the store has reached a state that matches the predicate. 1806 * 1807 * @param Store store 1808 * The Redux store being used. 1809 * @param function predicate 1810 * A function that returns true when the store has reached the expected 1811 * state. 1812 * @return Promise 1813 * Resolved once the store reaches the expected state. 1814 */ 1815 function waitUntilState(store, predicate) { 1816 return new Promise(resolve => { 1817 const unsubscribe = store.subscribe(check); 1818 1819 info(`Waiting for state predicate "${predicate}"`); 1820 function check() { 1821 if (predicate(store.getState())) { 1822 info(`Found state predicate "${predicate}"`); 1823 unsubscribe(); 1824 resolve(); 1825 } 1826 } 1827 1828 // Fire the check immediately in case the action has already occurred 1829 check(); 1830 }); 1831 } 1832 1833 /** 1834 * Wait for a specific action type to be dispatched. 1835 * 1836 * If the action is async and defines a `status` property, this helper will wait 1837 * for the status to reach either "error" or "done". 1838 * 1839 * @param {object} store 1840 * Redux store where the action should be dispatched. 1841 * @param {string} actionType 1842 * The actionType to wait for. 1843 * @param {number} repeat 1844 * Optional, number of time the action is expected to be dispatched. 1845 * Defaults to 1 1846 * @return {Promise} 1847 */ 1848 function waitForDispatch(store, actionType, repeat = 1) { 1849 let count = 0; 1850 return new Promise(resolve => { 1851 store.dispatch({ 1852 type: "@@service/waitUntil", 1853 predicate: action => { 1854 const isDone = 1855 !action.status || 1856 action.status === "done" || 1857 action.status === "error"; 1858 1859 if (action.type === actionType && isDone && ++count == repeat) { 1860 return true; 1861 } 1862 1863 return false; 1864 }, 1865 run: (dispatch, getState, action) => { 1866 resolve(action); 1867 }, 1868 }); 1869 }); 1870 } 1871 1872 /** 1873 * Retrieve a browsing context in nested frames. 1874 * 1875 * @param {BrowsingContext|XULBrowser} browsingContext 1876 * The topmost browsing context under which we should search for the 1877 * browsing context. 1878 * @param {Array<string>} selectors 1879 * Array of CSS selectors that form a path to a specific nested frame. 1880 * @return {BrowsingContext} The nested browsing context. 1881 */ 1882 async function getBrowsingContextInFrames(browsingContext, selectors) { 1883 let context = browsingContext; 1884 1885 if (!Array.isArray(selectors)) { 1886 throw new Error( 1887 "getBrowsingContextInFrames called with an invalid selectors argument" 1888 ); 1889 } 1890 1891 if (selectors.length === 0) { 1892 throw new Error( 1893 "getBrowsingContextInFrames called with an empty selectors array" 1894 ); 1895 } 1896 1897 const clonedSelectors = [...selectors]; 1898 while (clonedSelectors.length) { 1899 const selector = clonedSelectors.shift(); 1900 context = await SpecialPowers.spawn(context, [selector], _selector => { 1901 return content.document.querySelector(_selector).browsingContext; 1902 }); 1903 } 1904 1905 return context; 1906 } 1907 1908 /** 1909 * Synthesize a mouse event on an element, after ensuring that it is visible 1910 * in the viewport. 1911 * 1912 * @param {string | Array} selector: The node selector to get the node target for the event. 1913 * To target an element in a specific iframe, pass an array of CSS selectors 1914 * (e.g. ["iframe", ".el-in-iframe"]) 1915 * @param {number} x 1916 * @param {number} y 1917 * @param {object} options: Options that will be passed to BrowserTestUtils.synthesizeMouse 1918 */ 1919 async function safeSynthesizeMouseEventInContentPage( 1920 selector, 1921 x, 1922 y, 1923 options = {} 1924 ) { 1925 let context = gBrowser.selectedBrowser.browsingContext; 1926 1927 // If an array of selector is passed, we need to retrieve the context in which the node 1928 // lives in. 1929 if (Array.isArray(selector)) { 1930 if (selector.length === 1) { 1931 selector = selector[0]; 1932 } else { 1933 context = await getBrowsingContextInFrames( 1934 context, 1935 // only pass the iframe path 1936 selector.slice(0, -1) 1937 ); 1938 // retrieve the last item of the selector, which should be the one for the node we want. 1939 selector = selector.at(-1); 1940 } 1941 } 1942 1943 await scrollContentPageNodeIntoView(context, selector); 1944 BrowserTestUtils.synthesizeMouse(selector, x, y, options, context); 1945 } 1946 1947 /** 1948 * Synthesize a mouse event at the center of an element, after ensuring that it is visible 1949 * in the viewport. 1950 * 1951 * @param {string | Array} selector: The node selector to get the node target for the event. 1952 * To target an element in a specific iframe, pass an array of CSS selectors 1953 * (e.g. ["iframe", ".el-in-iframe"]) 1954 * @param {object} options: Options that will be passed to BrowserTestUtils.synthesizeMouse 1955 */ 1956 async function safeSynthesizeMouseEventAtCenterInContentPage( 1957 selector, 1958 options = {} 1959 ) { 1960 let context = gBrowser.selectedBrowser.browsingContext; 1961 1962 // If an array of selector is passed, we need to retrieve the context in which the node 1963 // lives in. 1964 if (Array.isArray(selector)) { 1965 if (selector.length === 1) { 1966 selector = selector[0]; 1967 } else { 1968 context = await getBrowsingContextInFrames( 1969 context, 1970 // only pass the iframe path 1971 selector.slice(0, -1) 1972 ); 1973 // retrieve the last item of the selector, which should be the one for the node we want. 1974 selector = selector.at(-1); 1975 } 1976 } 1977 1978 await scrollContentPageNodeIntoView(context, selector); 1979 BrowserTestUtils.synthesizeMouseAtCenter(selector, options, context); 1980 } 1981 1982 /** 1983 * Scroll into view an element in the content page matching the passed selector 1984 * 1985 * @param {BrowsingContext} browsingContext: The browsing context the element lives in. 1986 * @param {string} selector: The node selector to get the node to scroll into view 1987 * @returns {Promise} 1988 */ 1989 function scrollContentPageNodeIntoView(browsingContext, selector) { 1990 return SpecialPowers.spawn( 1991 browsingContext, 1992 [selector], 1993 function (innerSelector) { 1994 const node = 1995 content.wrappedJSObject.document.querySelector(innerSelector); 1996 node.scrollIntoView(); 1997 } 1998 ); 1999 } 2000 2001 /** 2002 * Change the zoom level of the selected page. 2003 * 2004 * @param {number} zoomLevel 2005 */ 2006 function setContentPageZoomLevel(zoomLevel) { 2007 gBrowser.selectedBrowser.fullZoom = zoomLevel; 2008 } 2009 2010 /** 2011 * Wait for the next DOCUMENT_EVENT dom-complete resource on a top-level target 2012 * 2013 * @param {object} commands 2014 * @return {Promise<object>} 2015 * Return a promise which resolves once we fully settle the resource listener. 2016 * You should await for its resolution before doing the action which may fire 2017 * your resource. 2018 * This promise will resolve with an object containing a `onDomCompleteResource` property, 2019 * which is also a promise, that will resolve once a "top-level" DOCUMENT_EVENT dom-complete 2020 * is received. 2021 */ 2022 async function waitForNextTopLevelDomCompleteResource(commands) { 2023 const { onResource: onDomCompleteResource } = 2024 await commands.resourceCommand.waitForNextResource( 2025 commands.resourceCommand.TYPES.DOCUMENT_EVENT, 2026 { 2027 ignoreExistingResources: true, 2028 predicate: resource => 2029 resource.name === "dom-complete" && resource.targetFront.isTopLevel, 2030 } 2031 ); 2032 return { onDomCompleteResource }; 2033 } 2034 2035 /** 2036 * Wait for the provided context to have a valid presShell. This can be useful 2037 * for tests which try to create popup panels or interact with the document very 2038 * early. 2039 * 2040 * @param {BrowsingContext} context 2041 */ 2042 function waitForPresShell(context) { 2043 return SpecialPowers.spawn(context, [], async () => { 2044 const winUtils = SpecialPowers.getDOMWindowUtils(content); 2045 await ContentTaskUtils.waitForCondition(() => { 2046 try { 2047 return !!winUtils.getPresShellId(); 2048 } catch (e) { 2049 return false; 2050 } 2051 }, "Waiting for a valid presShell"); 2052 }); 2053 } 2054 2055 /** 2056 * In tests using Fluent localization, it is preferable to match DOM elements using 2057 * a message ID rather than the raw string as: 2058 * 2059 * 1. It allows testing infrastructure to be multilingual if needed. 2060 * 2. It isolates the tests from localization changes. 2061 * 2062 * @param {Array<string>} resourceIds A list of .ftl files to load. 2063 * @returns {(id: string, args?: Record<string, FluentVariable>) => string} 2064 */ 2065 async function getFluentStringHelper(resourceIds) { 2066 const locales = Services.locale.appLocalesAsBCP47; 2067 const generator = L10nRegistry.getInstance().generateBundles( 2068 locales, 2069 resourceIds 2070 ); 2071 2072 const bundles = []; 2073 for await (const bundle of generator) { 2074 bundles.push(bundle); 2075 } 2076 2077 const reactLocalization = new FluentReact.ReactLocalization(bundles); 2078 2079 /** 2080 * Get the string from a message id. It throws when the message is not found. 2081 * 2082 * @param {string} id 2083 * @param {string} attributeName: attribute name if you need to access a specific attribute 2084 * defined in the fluent string, e.g. setting "title" for this param 2085 * will retrieve the `title` string in 2086 * compatibility-issue-browsers-list = 2087 * .title = This is the title 2088 * @param {Record<string, FluentVariable>} [args] optional 2089 * @returns {string} 2090 */ 2091 return (id, attributeName, args) => { 2092 let string; 2093 2094 if (!attributeName) { 2095 string = reactLocalization.getString(id, args); 2096 } else { 2097 for (const bundle of reactLocalization.bundles) { 2098 const msg = bundle.getMessage(id); 2099 if (msg?.attributes[attributeName]) { 2100 string = bundle.formatPattern( 2101 msg.attributes[attributeName], 2102 args, 2103 [] 2104 ); 2105 break; 2106 } 2107 } 2108 } 2109 2110 if (!string) { 2111 throw new Error( 2112 `Could not find a string for "${id}"${ 2113 attributeName ? ` and attribute "${attributeName}")` : "" 2114 }. Was the correct resource bundle loaded?` 2115 ); 2116 } 2117 return string; 2118 }; 2119 } 2120 2121 /** 2122 * Open responsive design mode for the given tab. 2123 */ 2124 async function openRDM(tab, { waitForDeviceList = true } = {}) { 2125 info("Opening responsive design mode"); 2126 const manager = ResponsiveUIManager; 2127 const ui = await manager.openIfNeeded(tab.ownerGlobal, tab, { 2128 trigger: "test", 2129 }); 2130 info("Responsive design mode opened"); 2131 2132 await ResponsiveMessageHelper.wait(ui.toolWindow, "post-init"); 2133 info("Responsive design initialized"); 2134 2135 await waitForRDMLoaded(ui, { waitForDeviceList }); 2136 2137 return { ui, manager }; 2138 } 2139 2140 async function waitForRDMLoaded(ui, { waitForDeviceList = true } = {}) { 2141 // Always wait for the viewport to be added. 2142 const { store } = ui.toolWindow; 2143 await waitUntilState(store, state => state.viewports.length == 1); 2144 2145 if (waitForDeviceList) { 2146 // Wait until the device list has been loaded. 2147 await waitUntilState( 2148 store, 2149 state => state.devices.listState == localTypes.loadableState.LOADED 2150 ); 2151 } 2152 } 2153 2154 /** 2155 * Close responsive design mode for the given tab. 2156 */ 2157 async function closeRDM(tab, options) { 2158 info("Closing responsive design mode"); 2159 const manager = ResponsiveUIManager; 2160 await manager.closeIfNeeded(tab.ownerGlobal, tab, options); 2161 info("Responsive design mode closed"); 2162 } 2163 2164 function getInputStream(data) { 2165 const BufferStream = Components.Constructor( 2166 "@mozilla.org/io/arraybuffer-input-stream;1", 2167 "nsIArrayBufferInputStream", 2168 "setData" 2169 ); 2170 const buffer = new TextEncoder().encode(data).buffer; 2171 return new BufferStream(buffer, 0, buffer.byteLength); 2172 } 2173 2174 /** 2175 * Wait for a specific target to have been fully processed by targetCommand. 2176 * 2177 * @param {Commands} commands 2178 * The commands instance 2179 * @param {Function} isExpectedTargetFn 2180 * Predicate which will be called with a target front argument. Should 2181 * return true if the target front is the expected one, false otherwise. 2182 * @return {Promise} 2183 * Promise which resolves when a target matching `isExpectedTargetFn` 2184 * has been processed by targetCommand. 2185 */ 2186 function waitForTargetProcessed(commands, isExpectedTargetFn) { 2187 return new Promise(resolve => { 2188 const onProcessed = targetFront => { 2189 try { 2190 if (isExpectedTargetFn(targetFront)) { 2191 commands.targetCommand.off("processed-available-target", onProcessed); 2192 resolve(); 2193 } 2194 } catch { 2195 // Ignore errors from isExpectedTargetFn. 2196 } 2197 }; 2198 2199 commands.targetCommand.on("processed-available-target", onProcessed); 2200 }); 2201 } 2202 2203 /** 2204 * Instantiate a HTTP Server that serves files from a given test folder. 2205 * The test folder should be made of multiple sub folder named: v1, v2, v3,... 2206 * We will serve the content from one of these sub folder 2207 * and switch to the next one, each time `httpServer.switchToNextVersion()` 2208 * is called. 2209 * 2210 * @return Object Test server with two functions: 2211 * - urlFor(path) 2212 * Returns the absolute url for a given file. 2213 * - switchToNextVersion() 2214 * Start serving files from the next available sub folder. 2215 * - backToFirstVersion() 2216 * When running more than one test, helps restart from the first folder. 2217 */ 2218 function createVersionizedHttpTestServer(testFolderName) { 2219 const httpServer = createTestHTTPServer(); 2220 2221 let currentVersion = 1; 2222 2223 httpServer.registerPrefixHandler("/", async (request, response) => { 2224 response.processAsync(); 2225 response.setStatusLine(request.httpVersion, 200, "OK"); 2226 if (request.path.endsWith(".js")) { 2227 response.setHeader("Content-Type", "application/javascript"); 2228 } else if (request.path.endsWith(".js.map")) { 2229 response.setHeader("Content-Type", "application/json"); 2230 } 2231 if (request.path == "/" || request.path.endsWith(".html")) { 2232 response.setHeader("Content-Type", "text/html"); 2233 } 2234 // If a query string is passed, lookup with a matching file, if available 2235 // The '?' is replaced by '.' 2236 let fetchResponse; 2237 2238 if (request.queryString) { 2239 const url = `${URL_ROOT_SSL}${testFolderName}/v${currentVersion}${request.path}.${request.queryString}`; 2240 try { 2241 fetchResponse = await fetch(url); 2242 // Log this only if the request succeed 2243 info(`[test-http-server] serving: ${url}`); 2244 } catch (e) { 2245 // Ignore any error and proceed without the query string 2246 fetchResponse = null; 2247 } 2248 } 2249 2250 if (!fetchResponse) { 2251 const url = `${URL_ROOT_SSL}${testFolderName}/v${currentVersion}${request.path}`; 2252 info(`[test-http-server] serving: ${url}`); 2253 fetchResponse = await fetch(url); 2254 } 2255 2256 // Ensure forwarding the response headers generated by the other http server 2257 // (this can be especially useful when query .sjs files) 2258 for (const [name, value] of fetchResponse.headers.entries()) { 2259 response.setHeader(name, value); 2260 } 2261 2262 // Override cache settings so that versionized requests are never cached 2263 // and we get brand new content for any request. 2264 response.setHeader("Cache-Control", "no-store"); 2265 2266 const text = await fetchResponse.text(); 2267 response.write(text); 2268 response.finish(); 2269 }); 2270 2271 return { 2272 switchToNextVersion() { 2273 currentVersion++; 2274 }, 2275 backToFirstVersion() { 2276 currentVersion = 1; 2277 }, 2278 urlFor(path) { 2279 const port = httpServer.identity.primaryPort; 2280 return `http://localhost:${port}/${path}`; 2281 }, 2282 }; 2283 } 2284 2285 /** 2286 * Fake clicking a link and return the URL we would have navigated to. 2287 * This function should be used to check external links since we can't access 2288 * network in tests. 2289 * This can also be used to test that a click will not be fired. 2290 * 2291 * @param ElementNode element 2292 * The <a> element we want to simulate click on. 2293 * @returns Promise 2294 * A Promise that is resolved when the link click simulation occured or 2295 * when the click is not dispatched. 2296 * The promise resolves with an object that holds the following properties 2297 * - link: url of the link or null(if event not fired) 2298 * - where: "tab" if tab is active or "tabshifted" if tab is inactive 2299 * or null(if event not fired) 2300 */ 2301 function simulateLinkClick(element) { 2302 const browserWindow = Services.wm.getMostRecentWindow( 2303 gDevTools.chromeWindowType 2304 ); 2305 2306 const onOpenLink = new Promise(resolve => { 2307 const openLinkIn = (link, where) => resolve({ link, where }); 2308 sinon.replace(browserWindow, "openTrustedLinkIn", openLinkIn); 2309 sinon.replace(browserWindow, "openWebLinkIn", openLinkIn); 2310 }); 2311 2312 element.click(); 2313 2314 // Declare a timeout Promise that we can use to make sure spied methods were not called. 2315 const onTimeout = new Promise(function (resolve) { 2316 setTimeout(() => { 2317 resolve({ link: null, where: null }); 2318 }, 1000); 2319 }); 2320 2321 const raceResult = Promise.race([onOpenLink, onTimeout]); 2322 sinon.restore(); 2323 return raceResult; 2324 } 2325 2326 /** 2327 * Since the MDN data is updated frequently, it might happen that the properties used in 2328 * this test are not in the dataset anymore/now have URLs. 2329 * This function will return properties in the dataset that don't have MDN url so you 2330 * can easily find a replacement. 2331 */ 2332 function logCssCompatDataPropertiesWithoutMDNUrl() { 2333 const cssPropertiesCompatData = require("resource://devtools/shared/compatibility/dataset/css-properties.json"); 2334 2335 function walk(node) { 2336 for (const propertyName in node) { 2337 const property = node[propertyName]; 2338 if (property.__compat) { 2339 if (!property.__compat.mdn_url) { 2340 dump( 2341 `"${propertyName}" - MDN URL: ${ 2342 property.__compat.mdn_url || "❌" 2343 } - Spec URL: ${property.__compat.spec_url || "❌"}\n` 2344 ); 2345 } 2346 } else if (typeof property == "object") { 2347 walk(property); 2348 } 2349 } 2350 } 2351 walk(cssPropertiesCompatData); 2352 } 2353 2354 /** 2355 * Craft a CssProperties instance without involving RDP for tests 2356 * manually spawning OutputParser, CssCompleter, Editor... 2357 * 2358 * Otherwise this should instead be fetched from CssPropertiesFront. 2359 * 2360 * @return {CssProperties} 2361 */ 2362 function getClientCssProperties() { 2363 const { 2364 generateCssProperties, 2365 } = require("resource://devtools/server/actors/css-properties.js"); 2366 const { 2367 CssProperties, 2368 normalizeCssData, 2369 } = require("resource://devtools/client/fronts/css-properties.js"); 2370 return new CssProperties( 2371 normalizeCssData({ properties: generateCssProperties(document) }) 2372 ); 2373 } 2374 2375 /** 2376 * Helper method to stop a Service Worker promptly. 2377 * 2378 * @param {string} workerUrl 2379 * Absolute Worker URL to stop. 2380 */ 2381 async function stopServiceWorker(workerUrl) { 2382 info(`Stop Service Worker: ${workerUrl}\n`); 2383 2384 // Help the SW to be immediately destroyed after unregistering it. 2385 Services.prefs.setIntPref("dom.serviceWorkers.idle_timeout", 0); 2386 2387 const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( 2388 Ci.nsIServiceWorkerManager 2389 ); 2390 // Unfortunately we can't use swm.getRegistrationByPrincipal, as it requires a "scope", which doesn't seem to be the worker URL. 2391 // So let's use getAllRegistrations to find the nsIServiceWorkerInfo in order to: 2392 // - retrieve its active worker, 2393 // - call attach+detachDebugger, 2394 // - reset the idle timeout. 2395 // This way, the unregister instruction is immediate, thanks to the 0 dom.serviceWorkers.idle_timeout we set at the beginning of the function 2396 const registrations = swm.getAllRegistrations(); 2397 let matchedInfo; 2398 for (let i = 0; i < registrations.length; i++) { 2399 const info = registrations.queryElementAt( 2400 i, 2401 Ci.nsIServiceWorkerRegistrationInfo 2402 ); 2403 // Lookup for an exact URL match. 2404 if (info.scriptSpec === workerUrl) { 2405 matchedInfo = info; 2406 break; 2407 } 2408 } 2409 ok(!!matchedInfo, "Found the service worker info"); 2410 2411 info("Wait for the worker to be active"); 2412 await waitFor(() => matchedInfo.activeWorker, "Wait for the SW to be active"); 2413 2414 // We need to attach+detach the debugger in order to reset the idle timeout. 2415 // Otherwise the worker would still be waiting for a previously registered timeout 2416 // which would be the 0ms one we set by tweaking the preference. 2417 function resetWorkerTimeout(worker) { 2418 worker.attachDebugger(); 2419 worker.detachDebugger(); 2420 } 2421 resetWorkerTimeout(matchedInfo.activeWorker); 2422 // Also reset all the other possible worker instances 2423 if (matchedInfo.evaluatingWorker) { 2424 resetWorkerTimeout(matchedInfo.evaluatingWorker); 2425 } 2426 if (matchedInfo.installingWorker) { 2427 resetWorkerTimeout(matchedInfo.installingWorker); 2428 } 2429 if (matchedInfo.waitingWorker) { 2430 resetWorkerTimeout(matchedInfo.waitingWorker); 2431 } 2432 // Reset this preference in order to ensure other SW are not immediately destroyed. 2433 Services.prefs.clearUserPref("dom.serviceWorkers.idle_timeout"); 2434 2435 // Spin the event loop to ensure the worker had time to really be shut down. 2436 await wait(0); 2437 2438 return matchedInfo; 2439 } 2440 2441 /** 2442 * Helper method to stop and unregister a Service Worker promptly. 2443 * 2444 * @param {string} workerUrl 2445 * Absolute Worker URL to unregister. 2446 */ 2447 async function unregisterServiceWorker(workerUrl) { 2448 const swInfo = await stopServiceWorker(workerUrl); 2449 2450 info(`Unregister Service Worker: ${workerUrl}\n`); 2451 // Now call unregister on that worker so that it can be destroyed immediately 2452 const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( 2453 Ci.nsIServiceWorkerManager 2454 ); 2455 const unregisterSuccess = await new Promise(resolve => { 2456 swm.unregister( 2457 swInfo.principal, 2458 { 2459 unregisterSucceeded(success) { 2460 resolve(success); 2461 }, 2462 }, 2463 swInfo.scope 2464 ); 2465 }); 2466 ok(unregisterSuccess, "Service worker successfully unregistered"); 2467 } 2468 2469 /** 2470 * Toggle the JavavaScript tracer via its toolbox toolbar button. 2471 */ 2472 async function toggleJsTracer(toolbox) { 2473 const { tracerCommand } = toolbox.commands; 2474 const { isTracingEnabled } = tracerCommand; 2475 const { logMethod, traceOnNextInteraction, traceOnNextLoad } = 2476 toolbox.commands.tracerCommand.getTracingOptions(); 2477 2478 // When the tracer is waiting for user interaction or page load, it won't be made active 2479 // right away. The test should manually wait for its activation. 2480 const shouldWaitForToggle = !traceOnNextInteraction && !traceOnNextLoad; 2481 let onTracingToggled; 2482 if (shouldWaitForToggle) { 2483 onTracingToggled = new Promise(resolve => { 2484 tracerCommand.on("toggle", async function listener() { 2485 // Ignore the event, if we are still in the same state as before the click 2486 if (tracerCommand.isTracingActive == isTracingEnabled) { 2487 return; 2488 } 2489 tracerCommand.off("toggle", listener); 2490 resolve(); 2491 }); 2492 }); 2493 } 2494 2495 const toolbarButton = toolbox.doc.getElementById("command-button-jstracer"); 2496 toolbarButton.click(); 2497 2498 if (shouldWaitForToggle) { 2499 info("Waiting for the tracer to be active"); 2500 await onTracingToggled; 2501 } 2502 2503 const { 2504 TRACER_LOG_METHODS, 2505 } = require("resource://devtools/shared/specs/tracer.js"); 2506 if (logMethod != TRACER_LOG_METHODS.CONSOLE) { 2507 return; 2508 } 2509 2510 // We were tracing and just requested to stop it. 2511 // Wait for the stop message to appear in the console before clearing its content. 2512 // This simplifies writting tests toggling the tracer ON multiple times and checking 2513 // for the display of traces in the console. 2514 if (isTracingEnabled) { 2515 const { hud } = await toolbox.getPanel("webconsole"); 2516 info("Wait for tracing to be disabled"); 2517 await waitFor(() => 2518 [...hud.ui.outputNode.querySelectorAll(".message")].some(msg => 2519 msg.textContent.includes("Stopped tracing") 2520 ) 2521 ); 2522 2523 hud.ui.clearOutput(); 2524 await waitFor( 2525 () => hud.ui.outputNode.querySelectorAll(".message").length === 0 2526 ); 2527 } else { 2528 // We are enabling the tracing to the console, and the console may not be opened just yet. 2529 const { hud } = await toolbox.getPanelWhenReady("webconsole"); 2530 if (!traceOnNextInteraction && !traceOnNextLoad) { 2531 await waitFor(() => 2532 [...hud.ui.outputNode.querySelectorAll(".message")].some(msg => 2533 msg.textContent.includes("Started tracing to Web Console") 2534 ) 2535 ); 2536 } 2537 } 2538 } 2539 2540 /** 2541 * Retrieve the context menu element corresponding to the provided id, for the 2542 * provided netmonitor instance. 2543 * 2544 * @param {object} monitor 2545 * The network monitor object 2546 * @param {string} id 2547 * The id of the context menu item 2548 */ 2549 function getNetmonitorContextMenuItem(monitor, id) { 2550 const Menu = require("resource://devtools/client/framework/menu.js"); 2551 return Menu.getMenuElementById(id, monitor.panelWin.document); 2552 } 2553 2554 /** 2555 * Selects and clicks the context menu item of the netmonitor, it should 2556 * also wait for the popup to close. 2557 * 2558 * @param {object} monitor 2559 * The network monitor object 2560 * @param {string} id 2561 * The id of the context menu item 2562 */ 2563 async function selectNetmonitorContextMenuItem(monitor, id) { 2564 const contextMenuItem = getNetmonitorContextMenuItem(monitor, id); 2565 2566 const popup = contextMenuItem.parentNode; 2567 await _maybeOpenAncestorMenu(contextMenuItem); 2568 const hidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); 2569 popup.activateItem(contextMenuItem); 2570 await hidden; 2571 } 2572 2573 async function _maybeOpenAncestorMenu(menuItem) { 2574 const parentPopup = menuItem.parentNode; 2575 if (parentPopup.state == "open") { 2576 return; 2577 } 2578 const shown = BrowserTestUtils.waitForEvent(parentPopup, "popupshown"); 2579 if (parentPopup.state == "showing") { 2580 await shown; 2581 return; 2582 } 2583 const parentMenu = parentPopup.parentNode; 2584 await _maybeOpenAncestorMenu(parentMenu); 2585 parentMenu.openMenu(true); 2586 await shown; 2587 } 2588 2589 /** 2590 * Returns the list of console messages DOM Element display in the Web Console 2591 * which contains a given string. 2592 * 2593 * @param {Toolbox} toolbox 2594 * @param {string} query 2595 * @return {Array<DOMElement>} 2596 */ 2597 async function findConsoleMessages(toolbox, query) { 2598 const webConsole = await toolbox.getPanel("webconsole"); 2599 const win = webConsole._frameWindow; 2600 return Array.prototype.filter.call( 2601 win.document.querySelectorAll(".message"), 2602 e => e.innerText.includes(query) 2603 ); 2604 } 2605 2606 /** 2607 * Wait for a console message to appear with a given text and a given link to 2608 * a specific location in a JS source. 2609 * Returns the DOM Element in the Web Console for the link to the JS Source. 2610 * 2611 * @param {Toolbox} toolbox 2612 * @param {string} messageText 2613 * @param {string} linkText 2614 * @return {DOMElement} 2615 */ 2616 async function waitForConsoleMessageLink(toolbox, messageText, linkText) { 2617 await toolbox.selectTool("webconsole"); 2618 2619 return waitFor(async () => { 2620 // Wait until the message updates. 2621 const [message] = await findConsoleMessages(toolbox, messageText); 2622 if (!message) { 2623 return false; 2624 } 2625 const linkEl = message.querySelector(".frame-link-source"); 2626 if (!linkEl || linkEl.textContent !== linkText) { 2627 return false; 2628 } 2629 2630 return linkEl; 2631 }); 2632 } 2633 2634 /** 2635 * Click on a Frame component link and ensure it opens the debugger on the expected location 2636 * 2637 * @param {Toolbox} toolbox 2638 * @param {DOMElement} frameLinkNode 2639 * @param {Object] options 2640 * @param {string | null} options.url 2641 * @param {number | null} options.line 2642 * @param {number | null} options.column 2643 * @param {string | undefined} logPointExpr 2644 */ 2645 async function clickAndAssertFrameLinkNode( 2646 toolbox, 2647 frameLinkNode, 2648 { url, line, column }, 2649 logPointExpr 2650 ) { 2651 info("checking click on node location"); 2652 2653 // If the debugger hasn't fully loaded yet and breakpoints are still being 2654 // added when we click on the logpoint link, the logpoint panel might not 2655 // render. Work around this for now, see bug 1592854. 2656 if (logPointExpr) { 2657 await waitForTime(1000); 2658 } 2659 2660 const onSourceOpened = toolbox.once("source-opened-in-debugger"); 2661 2662 EventUtils.sendMouseEvent( 2663 { type: "click" }, 2664 // The frame DOM Element may be coming from a Debugger Frame component, or a shared compoentn Frame component 2665 // and the link would be at a distinct selector. 2666 frameLinkNode.querySelector(".frame-link-filename") || 2667 frameLinkNode.querySelector(".location") 2668 ); 2669 2670 // Wait for the source to finish loading, if it is pending. 2671 await onSourceOpened; 2672 2673 // Wait for the debugger to have fully processed the opened source 2674 const dbg = toolbox.getPanel("jsdebugger"); 2675 2676 const selectedLocation = await waitFor(() => 2677 dbg._selectors.getSelectedLocation(dbg._getState()) 2678 ); 2679 2680 if (typeof url == "string") { 2681 const frameUrl = frameLinkNode.getAttribute("data-url"); 2682 is(frameUrl, url, "Frame link url is correct"); 2683 2684 is(selectedLocation.source.url, url, "debugger opened url is correct"); 2685 } 2686 if (typeof line == "number") { 2687 const frameLine = frameLinkNode.getAttribute("data-line"); 2688 is(parseInt(frameLine, 10), line, "Frame link line is correct"); 2689 2690 is(selectedLocation.line, line, "debugger opened line is correct"); 2691 } 2692 if (typeof column == "number") { 2693 // Note that debugger's Frame component doesn't show the column 2694 const frameColumn = frameLinkNode.getAttribute("data-column"); 2695 is(parseInt(frameColumn, 10), column, "Frame link column is correct"); 2696 2697 // Redux location object uses 0-based column, while we display a 1-based one. 2698 is( 2699 selectedLocation.column + 1, 2700 column, 2701 "debugger opened column is correct" 2702 ); 2703 } 2704 2705 if (logPointExpr !== undefined && logPointExpr !== "") { 2706 const inputEl = dbg.panelWin.document.activeElement; 2707 2708 const isPanelFocused = 2709 inputEl.classList.contains("cm-content") && 2710 inputEl.closest(".conditional-breakpoint-panel.log-point"); 2711 2712 ok(isPanelFocused, "The textarea of logpoint panel is focused"); 2713 2714 const inputValue = inputEl.parentElement.parentElement.innerText.trim(); 2715 is( 2716 inputValue, 2717 logPointExpr, 2718 "The input in the open logpoint panel matches the logpoint expression" 2719 ); 2720 } 2721 }