head.js (23856B)
1 ChromeUtils.defineESModuleGetters(this, { 2 AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs", 3 ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", 4 ExtensionsUI: "resource:///modules/ExtensionsUI.sys.mjs", 5 }); 6 7 const BASE = getRootDirectory(gTestPath).replace( 8 "chrome://mochitests/content/", 9 "https://example.com/" 10 ); 11 12 ChromeUtils.defineLazyGetter(this, "Management", () => { 13 return ExtensionParent.apiManager; 14 }); 15 16 let { CustomizableUITestUtils } = ChromeUtils.importESModule( 17 "resource://testing-common/CustomizableUITestUtils.sys.mjs" 18 ); 19 let gCUITestUtils = new CustomizableUITestUtils(window); 20 21 const { PermissionTestUtils } = ChromeUtils.importESModule( 22 "resource://testing-common/PermissionTestUtils.sys.mjs" 23 ); 24 25 let extL10n = null; 26 /** 27 * @param {string} id 28 * @param {object} [args] 29 * @returns {string} 30 */ 31 function formatExtValue(id, args) { 32 if (!extL10n) { 33 extL10n = new Localization( 34 [ 35 "toolkit/global/extensions.ftl", 36 "toolkit/global/extensionPermissions.ftl", 37 "branding/brand.ftl", 38 ], 39 true 40 ); 41 } 42 return extL10n.formatValueSync(id, args); 43 } 44 45 /** 46 * Wait for the given PopupNotification to display 47 * 48 * @param {string} name 49 * The name of the notification to wait for. 50 * 51 * @returns {Promise} 52 * Resolves with the notification window. 53 */ 54 function promisePopupNotificationShown(name) { 55 return new Promise(resolve => { 56 function popupshown() { 57 let notification = PopupNotifications.getNotification(name); 58 if (!notification) { 59 return; 60 } 61 62 ok(notification, `${name} notification shown`); 63 ok(PopupNotifications.isPanelOpen, "notification panel open"); 64 65 PopupNotifications.panel.removeEventListener("popupshown", popupshown); 66 resolve(PopupNotifications.panel.firstElementChild); 67 } 68 69 PopupNotifications.panel.addEventListener("popupshown", popupshown); 70 }); 71 } 72 73 function promiseAppMenuNotificationShown(id) { 74 const { AppMenuNotifications } = ChromeUtils.importESModule( 75 "resource://gre/modules/AppMenuNotifications.sys.mjs" 76 ); 77 return new Promise(resolve => { 78 function popupshown() { 79 let notification = AppMenuNotifications.activeNotification; 80 if (!notification) { 81 return; 82 } 83 84 is(notification.id, id, `${id} notification shown`); 85 ok(PanelUI.isNotificationPanelOpen, "notification panel open"); 86 87 PanelUI.notificationPanel.removeEventListener("popupshown", popupshown); 88 89 let popupnotificationID = PanelUI._getPopupId(notification); 90 let popupnotification = document.getElementById(popupnotificationID); 91 92 resolve(popupnotification); 93 } 94 PanelUI.notificationPanel.addEventListener("popupshown", popupshown); 95 }); 96 } 97 98 /** 99 * Wait for a specific install event to fire for a given addon 100 * 101 * @param {AddonWrapper} addon 102 * The addon to watch for an event on 103 * @param {string} 104 * The name of the event to watch for (e.g., onInstallEnded) 105 * 106 * @returns {Promise} 107 * Resolves when the event triggers with the first argument 108 * to the event handler as the resolution value. 109 */ 110 function promiseInstallEvent(addon, event) { 111 return new Promise(resolve => { 112 let listener = {}; 113 listener[event] = (install, arg) => { 114 if (install.addon.id == addon.id) { 115 AddonManager.removeInstallListener(listener); 116 resolve(arg); 117 } 118 }; 119 AddonManager.addInstallListener(listener); 120 }); 121 } 122 123 /** 124 * Install an (xpi packaged) extension 125 * 126 * @param {string} url 127 * URL of the .xpi file to install 128 * @param {object?} installTelemetryInfo 129 * an optional object that contains additional details used by the telemetry events. 130 * 131 * @returns {Promise} 132 * Resolves when the extension has been installed with the Addon 133 * object as the resolution value. 134 */ 135 async function promiseInstallAddon(url, telemetryInfo) { 136 let install = await AddonManager.getInstallForURL(url, { telemetryInfo }); 137 install.install(); 138 139 let addon = await new Promise(resolve => { 140 install.addListener({ 141 onInstallEnded(_install, _addon) { 142 resolve(_addon); 143 }, 144 }); 145 }); 146 147 if (addon.isWebExtension) { 148 await new Promise(resolve => { 149 function listener(event, extension) { 150 if (extension.id == addon.id) { 151 Management.off("ready", listener); 152 resolve(); 153 } 154 } 155 Management.on("ready", listener); 156 }); 157 } 158 159 return addon; 160 } 161 162 /** 163 * Wait for an update to the given webextension to complete. 164 * (This does not actually perform an update, it just watches for 165 * the events that occur as a result of an update.) 166 * 167 * @param {AddonWrapper} addon 168 * The addon to be updated. 169 * 170 * @returns {Promise} 171 * Resolves when the extension has ben updated. 172 */ 173 async function waitForUpdate(addon) { 174 let installPromise = promiseInstallEvent(addon, "onInstallEnded"); 175 let readyPromise = new Promise(resolve => { 176 function listener(event, extension) { 177 if (extension.id == addon.id) { 178 Management.off("ready", listener); 179 resolve(); 180 } 181 } 182 Management.on("ready", listener); 183 }); 184 185 let [newAddon] = await Promise.all([installPromise, readyPromise]); 186 return newAddon; 187 } 188 189 function waitAboutAddonsViewLoaded(doc) { 190 return BrowserTestUtils.waitForEvent(doc, "view-loaded"); 191 } 192 193 /** 194 * Trigger an action from the page options menu. 195 */ 196 function triggerPageOptionsAction(win, action) { 197 win.document.querySelector(`#page-options [action="${action}"]`).click(); 198 } 199 200 function isDefaultIcon(icon) { 201 return icon == "chrome://mozapps/skin/extensions/extensionGeneric.svg"; 202 } 203 204 /** 205 * Check the contents of a permission popup notification 206 * 207 * @param {Window} panel 208 * The popup window. 209 * @param {string|regexp|function} checkIcon 210 * The icon expected to appear in the notification. If this is a 211 * string, it must match the icon url exactly. If it is a 212 * regular expression it is tested against the icon url, and if 213 * it is a function, it is called with the icon url and returns 214 * true if the url is correct. 215 * @param {Array} permissions 216 * The expected entries in the permissions list. Each element 217 * in this array is itself a 2-element array with the string key 218 * for the item (e.g., "webext-perms-description-foo") and an 219 * optional formatting parameter. 220 * @param {boolean} sideloaded 221 * Whether the notification is for a sideloaded extenion. 222 */ 223 function checkNotification(panel, checkIcon, permissions, sideloaded) { 224 let icon = panel.getAttribute("icon"); 225 let learnMoreLink = panel.querySelector(".popup-notification-learnmore-link"); 226 let listRequired = document.getElementById("addon-webext-perm-list-required"); 227 let listOptional = document.getElementById("addon-webext-perm-list-optional"); 228 229 if (checkIcon instanceof RegExp) { 230 ok( 231 checkIcon.test(icon), 232 `Notification icon is correct ${JSON.stringify(icon)} ~= ${checkIcon}` 233 ); 234 } else if (typeof checkIcon == "function") { 235 ok(checkIcon(icon), "Notification icon is correct"); 236 } else { 237 is(icon, checkIcon, "Notification icon is correct"); 238 } 239 240 let description = panel.querySelector( 241 ".popup-notification-description" 242 ).textContent; 243 let descL10nId = "webext-perms-header2"; 244 if (sideloaded) { 245 descL10nId = "webext-perms-sideload-header"; 246 } 247 const exp = formatExtValue(descL10nId, { extension: "<>" }).split("<>"); 248 ok(description.startsWith(exp.at(0)), "Description is the expected one"); 249 ok(description.endsWith(exp.at(-1)), "Description is the expected one"); 250 251 const hasPBCheckbox = !!listOptional.querySelector( 252 "li.webext-perm-privatebrowsing > moz-checkbox" 253 ); 254 255 is( 256 BrowserTestUtils.isHidden(learnMoreLink), 257 !permissions.length && !hasPBCheckbox, 258 "Permissions learn more is hidden if there are no permissions and no private browsing checkbox" 259 ); 260 261 if (!permissions.length && !hasPBCheckbox) { 262 ok(listRequired.hidden, "Required permissions list is hidden"); 263 ok(listOptional.hidden, "Optional permissions list is hidden"); 264 } else if (!permissions.length) { 265 ok(listRequired.hidden, "Required permissions list is hidden"); 266 ok(!listOptional.hidden, "Optional permissions list is visible"); 267 ok(hasPBCheckbox, "Expect a checkbox inside the list of permissions"); 268 is( 269 listOptional.childElementCount, 270 1, 271 "Optional permissions list should have an entry" 272 ); 273 } else if (permissions.length === 1 && hasPBCheckbox) { 274 ok(!listRequired.hidden, "Required permissions list is visible"); 275 is( 276 listRequired.childElementCount, 277 1, 278 "Required permissions list should have an entry" 279 ); 280 ok(!listOptional.hidden, "Optional permissions list is visible"); 281 is( 282 listOptional.childElementCount, 283 1, 284 "Optional permissions list should have an entry" 285 ); 286 is( 287 listRequired.children[0].textContent, 288 formatExtValue(permissions[0]), 289 "First Permission entry is correct" 290 ); 291 const entry = listOptional.firstChild; 292 ok( 293 entry.classList.contains("webext-perm-privatebrowsing"), 294 "Expect last permissions list entry to be the private browsing checkbox" 295 ); 296 ok( 297 entry.querySelector("moz-checkbox"), 298 "Expect a checkbox inside the last permissions list entry" 299 ); 300 } else { 301 ok(!listRequired.hidden, "Required permissions list is visible"); 302 for (let i in permissions) { 303 let [key, param] = permissions[i]; 304 const expected = formatExtValue(key, param); 305 // If the permissions list entry has a label child element then 306 // we expect the permission string to be set as the label element 307 // value (in particular this is the case when the permission dialog 308 // is going to show multiple host permissions as a single permission 309 // entry and a nested ul listing all those domains). 310 const permDescriptionEl = listRequired.children[i].querySelector("label") 311 ? listRequired.children[i].firstElementChild.value 312 : listRequired.children[i].textContent; 313 is(permDescriptionEl, expected, `Permission number ${i + 1} is correct`); 314 } 315 316 if (hasPBCheckbox) { 317 ok(!listOptional.hidden, "Optional permissions list is visible"); 318 const entry = listOptional.firstChild; 319 ok( 320 entry.classList.contains("webext-perm-privatebrowsing"), 321 "Expect last permissions list entry to be the private browsing checkbox" 322 ); 323 } else { 324 ok(listOptional.hidden, "Optional permissions list is hidden"); 325 } 326 } 327 } 328 329 /** 330 * Test that install-time permission prompts work for a given 331 * installation method. 332 * 333 * @param {Function} installFn 334 * Callable that takes the name of an xpi file to install and 335 * starts to install it. Should return a Promise that resolves 336 * when the install is finished or rejects if the install is canceled. 337 * @param {string} telemetryBase 338 * If supplied, the base type for telemetry events that should be 339 * recorded for this install method. 340 * 341 * @returns {Promise} 342 */ 343 async function testInstallMethod(installFn) { 344 const PERMS_XPI = "browser_webext_permissions.xpi"; 345 const NO_PERMS_XPI = "browser_webext_nopermissions.xpi"; 346 const ID = "permissions@test.mozilla.org"; 347 348 await SpecialPowers.pushPrefEnv({ 349 set: [ 350 ["extensions.webapi.testing", true], 351 ["extensions.install.requireBuiltInCerts", false], 352 ], 353 }); 354 355 let testURI = makeURI("https://example.com/"); 356 PermissionTestUtils.add(testURI, "install", Services.perms.ALLOW_ACTION); 357 registerCleanupFunction(() => PermissionTestUtils.remove(testURI, "install")); 358 359 async function runOnce(filename, cancel) { 360 let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); 361 362 let installPromise = new Promise(resolve => { 363 let listener = { 364 onDownloadCancelled() { 365 AddonManager.removeInstallListener(listener); 366 resolve(false); 367 }, 368 369 onDownloadFailed() { 370 AddonManager.removeInstallListener(listener); 371 resolve(false); 372 }, 373 374 onInstallCancelled() { 375 AddonManager.removeInstallListener(listener); 376 resolve(false); 377 }, 378 379 onInstallEnded() { 380 AddonManager.removeInstallListener(listener); 381 resolve(true); 382 }, 383 384 onInstallFailed() { 385 AddonManager.removeInstallListener(listener); 386 resolve(false); 387 }, 388 }; 389 AddonManager.addInstallListener(listener); 390 }); 391 392 let installMethodPromise = installFn(filename); 393 394 let panel = await promisePopupNotificationShown("addon-webext-permissions"); 395 if (filename == PERMS_XPI) { 396 const hostPermissions = [ 397 ["webext-perms-host-description-multiple-domains", { domainCount: 2 }], 398 ]; 399 400 // The icon should come from the extension, don't bother with the precise 401 // path, just make sure we've got a jar url pointing to the right path 402 // inside the jar. 403 checkNotification(panel, /^jar:file:\/\/.*\/icon\.png$/, [ 404 ...hostPermissions, 405 ["webext-perms-description-nativeMessaging"], 406 // The below permissions are deliberately in this order as permissions 407 // are sorted alphabetically by the permission string to match AMO. 408 ["webext-perms-description-history"], 409 ["webext-perms-description-tabs"], 410 ]); 411 } else if (filename == NO_PERMS_XPI) { 412 checkNotification(panel, isDefaultIcon, []); 413 } 414 415 if (cancel) { 416 panel.secondaryButton.click(); 417 try { 418 await installMethodPromise; 419 } catch (err) {} 420 } else { 421 // Look for post-install notification 422 let postInstallPromise = 423 promiseAppMenuNotificationShown("addon-installed"); 424 panel.button.click(); 425 426 // Press OK on the post-install notification 427 panel = await postInstallPromise; 428 panel.button.click(); 429 430 await installMethodPromise; 431 } 432 433 let result = await installPromise; 434 let addon = await AddonManager.getAddonByID(ID); 435 if (cancel) { 436 ok(!result, "Installation was cancelled"); 437 is(addon, null, "Extension is not installed"); 438 } else { 439 ok(result, "Installation completed"); 440 isnot(addon, null, "Extension is installed"); 441 await addon.uninstall(); 442 } 443 444 BrowserTestUtils.removeTab(tab); 445 } 446 447 // A few different tests for each installation method: 448 // 1. Start installation of an extension that requests no permissions, 449 // verify the notification contents, then cancel the install 450 await runOnce(NO_PERMS_XPI, true); 451 452 // 2. Same as #1 but with an extension that requests some permissions. 453 await runOnce(PERMS_XPI, true); 454 455 // 3. Repeat with the same extension from step 2 but this time, 456 // accept the permissions to install the extension. (Then uninstall 457 // the extension to clean up.) 458 await runOnce(PERMS_XPI, false); 459 460 await SpecialPowers.popPrefEnv(); 461 } 462 463 // Helper function to test a specific scenario for interactive updates. 464 // `checkFn` is a callable that triggers a check for updates. 465 // `autoUpdate` specifies whether the test should be run with 466 // updates applied automatically or not. 467 async function interactiveUpdateTest(autoUpdate, checkFn) { 468 AddonTestUtils.initMochitest(this); 469 Services.fog.testResetFOG(); 470 471 const ID = "update2@tests.mozilla.org"; 472 const FAKE_INSTALL_SOURCE = "fake-install-source"; 473 474 await SpecialPowers.pushPrefEnv({ 475 set: [ 476 // We don't have pre-pinned certificates for the local mochitest server 477 ["extensions.install.requireBuiltInCerts", false], 478 ["extensions.update.requireBuiltInCerts", false], 479 480 ["extensions.update.autoUpdateDefault", autoUpdate], 481 482 // Point updates to the local mochitest server 483 ["extensions.update.url", `${BASE}/browser_webext_update.json`], 484 ], 485 }); 486 487 AddonTestUtils.hookAMTelemetryEvents(); 488 489 // Trigger an update check, manually applying the update if we're testing 490 // without auto-update. 491 async function triggerUpdate(win, addon) { 492 let manualUpdatePromise; 493 if (!autoUpdate) { 494 manualUpdatePromise = new Promise(resolve => { 495 let listener = { 496 onNewInstall() { 497 AddonManager.removeInstallListener(listener); 498 resolve(); 499 }, 500 }; 501 AddonManager.addInstallListener(listener); 502 }); 503 } 504 505 let promise = checkFn(win, addon); 506 507 if (manualUpdatePromise) { 508 await manualUpdatePromise; 509 510 let doc = win.document; 511 if (win.gViewController.currentViewId !== "addons://updates/available") { 512 let showUpdatesBtn = doc.querySelector("addon-updates-message").button; 513 await TestUtils.waitForCondition(() => { 514 return !showUpdatesBtn.hidden; 515 }, "Wait for show updates button"); 516 let viewChanged = waitAboutAddonsViewLoaded(doc); 517 showUpdatesBtn.click(); 518 await viewChanged; 519 } 520 let card = await TestUtils.waitForCondition(() => { 521 return doc.querySelector(`addon-card[addon-id="${ID}"]`); 522 }, `Wait addon card for "${ID}"`); 523 let updateBtn = card.querySelector('panel-item[action="install-update"]'); 524 ok(updateBtn, `Found update button for "${ID}"`); 525 updateBtn.click(); 526 } 527 528 return { promise }; 529 } 530 531 // Navigate away from the starting page to force about:addons to load 532 // in a new tab during the tests below. 533 BrowserTestUtils.startLoadingURIString( 534 gBrowser.selectedBrowser, 535 "about:mozilla" 536 ); 537 await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); 538 539 // Install version 1.0 of the test extension 540 let addon = await promiseInstallAddon(`${BASE}/browser_webext_update1.xpi`, { 541 source: FAKE_INSTALL_SOURCE, 542 }); 543 ok(addon, "Addon was installed"); 544 is(addon.version, "1.0", "Version 1 of the addon is installed"); 545 546 let win = await BrowserAddonUI.openAddonsMgr("addons://list/extension"); 547 548 await waitAboutAddonsViewLoaded(win.document); 549 550 // Trigger an update check 551 let popupPromise = promisePopupNotificationShown("addon-webext-permissions"); 552 let { promise: checkPromise } = await triggerUpdate(win, addon); 553 let panel = await popupPromise; 554 555 // Click the cancel button, wait to see the cancel event 556 let cancelPromise = promiseInstallEvent(addon, "onInstallCancelled"); 557 panel.secondaryButton.click(); 558 const cancelledByUser = await cancelPromise; 559 is(cancelledByUser, true, "Install cancelled by user"); 560 561 addon = await AddonManager.getAddonByID(ID); 562 is(addon.version, "1.0", "Should still be running the old version"); 563 564 // Make sure the update check is completely finished. 565 await checkPromise; 566 567 // Trigger a new update check 568 popupPromise = promisePopupNotificationShown("addon-webext-permissions"); 569 checkPromise = (await triggerUpdate(win, addon)).promise; 570 571 // This time, accept the upgrade 572 let updatePromise = waitForUpdate(addon); 573 panel = await popupPromise; 574 panel.button.click(); 575 576 addon = await updatePromise; 577 is(addon.version, "2.0", "Should have upgraded"); 578 579 await checkPromise; 580 581 BrowserTestUtils.removeTab(gBrowser.selectedTab); 582 await addon.uninstall(); 583 await SpecialPowers.popPrefEnv(); 584 585 const collectedUpdateEvents = AddonTestUtils.getAMTelemetryEvents().filter( 586 evt => { 587 return evt.method === "update"; 588 } 589 ); 590 591 const expectedSteps = [ 592 // First update is cancelled on the permission prompt. 593 "started", 594 "download_started", 595 "download_completed", 596 "permissions_prompt", 597 "cancelled", 598 // Second update is expected to be completed. 599 "started", 600 "download_started", 601 "download_completed", 602 "permissions_prompt", 603 "completed", 604 ]; 605 606 Assert.deepEqual( 607 expectedSteps, 608 collectedUpdateEvents.map(evt => evt.extra.step), 609 "Got the expected sequence on update telemetry events" 610 ); 611 612 let gleanEvents = AddonTestUtils.getAMGleanEvents("update"); 613 Services.fog.testResetFOG(); 614 615 Assert.deepEqual( 616 expectedSteps, 617 gleanEvents.map(e => e.step), 618 "Got the expected sequence on update Glean events." 619 ); 620 621 ok( 622 collectedUpdateEvents.every(evt => evt.extra.addon_id === ID), 623 "Every update telemetry event should have the expected addon_id extra var" 624 ); 625 626 ok( 627 collectedUpdateEvents.every( 628 evt => evt.extra.source === FAKE_INSTALL_SOURCE 629 ), 630 "Every update telemetry event should have the expected source extra var" 631 ); 632 633 ok( 634 collectedUpdateEvents.every(evt => evt.extra.updated_from === "user"), 635 "Every update telemetry event should have the update_from extra var 'user'" 636 ); 637 638 for (let e of gleanEvents) { 639 is(e.addon_id, ID, "Glean event has the expected addon_id."); 640 is(e.source, FAKE_INSTALL_SOURCE, "Glean event has the expected source."); 641 is(e.updated_from, "user", "Glean event has the expected updated_from."); 642 643 if (e.step === "permissions_prompt") { 644 Assert.greater(parseInt(e.num_strings), 0, "Expected num_strings."); 645 } 646 if (e.step === "download_completed") { 647 Assert.greater(parseInt(e.download_time), 0, "Valid download_time."); 648 } 649 } 650 651 let hasPermissionsExtras = collectedUpdateEvents 652 .filter(evt => { 653 return evt.extra.step === "permissions_prompt"; 654 }) 655 .every(evt => { 656 return Number.isInteger(parseInt(evt.extra.num_strings, 10)); 657 }); 658 659 ok( 660 hasPermissionsExtras, 661 "Every 'permissions_prompt' update telemetry event should have the permissions extra vars" 662 ); 663 664 let hasDownloadTimeExtras = collectedUpdateEvents 665 .filter(evt => { 666 return evt.extra.step === "download_completed"; 667 }) 668 .every(evt => { 669 const download_time = parseInt(evt.extra.download_time, 10); 670 return !isNaN(download_time) && download_time > 0; 671 }); 672 673 ok( 674 hasDownloadTimeExtras, 675 "Every 'download_completed' update telemetry event should have a download_time extra vars" 676 ); 677 } 678 679 async function getCachedPermissions(extensionId) { 680 const NotFound = Symbol("extension ID not found in permissions cache"); 681 try { 682 return await ExtensionParent.StartupCache.permissions.get( 683 extensionId, 684 () => { 685 // Throw error to prevent the key from being created. 686 throw NotFound; 687 } 688 ); 689 } catch (e) { 690 if (e === NotFound) { 691 return null; 692 } 693 throw e; 694 } 695 } 696 697 // The tests in this directory install a bunch of extensions but they 698 // need to uninstall them before exiting, as a stray leftover extension 699 // after one test can foul up subsequent tests. 700 // So, add a task to run before any tests that grabs a list of all the 701 // add-ons that are pre-installed in the test environment and then checks 702 // the list of installed add-ons at the end of the test to make sure no 703 // new add-ons have been added. 704 // Individual tests can store a cleanup function in the testCleanup global 705 // to ensure it gets called before the final check is performed. 706 let testCleanup; 707 add_setup(async function head_setup() { 708 let addons = await AddonManager.getAllAddons(); 709 let existingAddons = new Set(addons.map(a => a.id)); 710 711 let uuids = Services.prefs.getStringPref("extensions.webextensions.uuids"); 712 713 registerCleanupFunction(async function () { 714 if (testCleanup) { 715 await testCleanup(); 716 testCleanup = null; 717 } 718 719 for (let addon of await AddonManager.getAllAddons()) { 720 if (!existingAddons.has(addon.id)) { 721 ok( 722 false, 723 `Addon ${addon.id} was left installed at the end of the test` 724 ); 725 await addon.uninstall(); 726 } 727 } 728 // Regression test for https://bugzilla.mozilla.org/show_bug.cgi?id=1974419 729 is( 730 Services.prefs.getStringPref("extensions.webextensions.uuids"), 731 uuids, 732 "No unexpected changes to extensions.webextensions.uuid" 733 ); 734 }); 735 });