tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

head.js (26992B)


      1 /* Any copyright is dedicated to the Public Domain.
      2 * http://creativecommons.org/publicdomain/zero/1.0/ */
      3 
      4 const { NimbusTestUtils } = ChromeUtils.importESModule(
      5  "resource://testing-common/NimbusTestUtils.sys.mjs"
      6 );
      7 const { PermissionTestUtils } = ChromeUtils.importESModule(
      8  "resource://testing-common/PermissionTestUtils.sys.mjs"
      9 );
     10 const { PromptTestUtils } = ChromeUtils.importESModule(
     11  "resource://testing-common/PromptTestUtils.sys.mjs"
     12 );
     13 
     14 ChromeUtils.defineLazyGetter(this, "QuickSuggestTestUtils", () => {
     15  const { QuickSuggestTestUtils: module } = ChromeUtils.importESModule(
     16    "resource://testing-common/QuickSuggestTestUtils.sys.mjs"
     17  );
     18  module.init(this);
     19  return module;
     20 });
     21 
     22 ChromeUtils.defineESModuleGetters(this, {
     23  ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
     24  QuickSuggest: "moz-src:///browser/components/urlbar/QuickSuggest.sys.mjs",
     25 });
     26 
     27 NimbusTestUtils.init(this);
     28 
     29 const kDefaultWait = 2000;
     30 
     31 function is_element_visible(aElement, aMsg) {
     32  isnot(aElement, null, "Element should not be null, when checking visibility");
     33  ok(!BrowserTestUtils.isHidden(aElement), aMsg);
     34 }
     35 
     36 function is_element_hidden(aElement, aMsg) {
     37  isnot(aElement, null, "Element should not be null, when checking visibility");
     38  ok(BrowserTestUtils.isHidden(aElement), aMsg);
     39 }
     40 
     41 function open_preferences(aCallback) {
     42  gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:preferences");
     43  let newTabBrowser = gBrowser.getBrowserForTab(gBrowser.selectedTab);
     44  newTabBrowser.addEventListener(
     45    "Initialized",
     46    function () {
     47      aCallback(gBrowser.contentWindow);
     48    },
     49    { capture: true, once: true }
     50  );
     51 }
     52 
     53 function openAndLoadSubDialog(
     54  aURL,
     55  aFeatures = null,
     56  aParams = null,
     57  aClosingCallback = null
     58 ) {
     59  let promise = promiseLoadSubDialog(aURL);
     60  content.gSubDialog.open(
     61    aURL,
     62    { features: aFeatures, closingCallback: aClosingCallback },
     63    aParams
     64  );
     65  return promise;
     66 }
     67 
     68 function promiseLoadSubDialog(aURL) {
     69  return new Promise(resolve => {
     70    content.gSubDialog._dialogStack.addEventListener(
     71      "dialogopen",
     72      function dialogopen(aEvent) {
     73        if (
     74          aEvent.detail.dialog._frame.contentWindow.location == "about:blank"
     75        ) {
     76          return;
     77        }
     78        content.gSubDialog._dialogStack.removeEventListener(
     79          "dialogopen",
     80          dialogopen
     81        );
     82 
     83        is(
     84          aEvent.detail.dialog._frame.contentWindow.location.toString(),
     85          aURL,
     86          "Check the proper URL is loaded"
     87        );
     88 
     89        // Check visibility
     90        is_element_visible(aEvent.detail.dialog._overlay, "Overlay is visible");
     91 
     92        // Check that stylesheets were injected
     93        let expectedStyleSheetURLs =
     94          aEvent.detail.dialog._injectedStyleSheets.slice(0);
     95        for (let styleSheet of aEvent.detail.dialog._frame.contentDocument
     96          .styleSheets) {
     97          let i = expectedStyleSheetURLs.indexOf(styleSheet.href);
     98          if (i >= 0) {
     99            info("found " + styleSheet.href);
    100            expectedStyleSheetURLs.splice(i, 1);
    101          }
    102        }
    103        is(
    104          expectedStyleSheetURLs.length,
    105          0,
    106          "All expectedStyleSheetURLs should have been found"
    107        );
    108 
    109        // Wait for the next event tick to make sure the remaining part of the
    110        // testcase runs after the dialog gets ready for input.
    111        executeSoon(() => resolve(aEvent.detail.dialog._frame.contentWindow));
    112      }
    113    );
    114  });
    115 }
    116 
    117 async function openPreferencesViaOpenPreferencesAPI(aPane, aOptions) {
    118  let finalPaneEvent = Services.prefs.getBoolPref("identity.fxaccounts.enabled")
    119    ? "sync-pane-loaded"
    120    : "privacy-pane-loaded";
    121  let finalPrefPaneLoaded = TestUtils.topicObserved(finalPaneEvent, () => true);
    122  gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank", {
    123    allowInheritPrincipal: true,
    124  });
    125  openPreferences(aPane, aOptions);
    126  let newTabBrowser = gBrowser.selectedBrowser;
    127 
    128  if (!newTabBrowser.contentWindow) {
    129    await BrowserTestUtils.waitForEvent(newTabBrowser, "Initialized", true);
    130    if (newTabBrowser.contentDocument.readyState != "complete") {
    131      await BrowserTestUtils.waitForEvent(newTabBrowser.contentWindow, "load");
    132    }
    133    await finalPrefPaneLoaded;
    134  }
    135 
    136  let win = gBrowser.contentWindow;
    137  let selectedPane = win.history.state;
    138  if (!aOptions || !aOptions.leaveOpen) {
    139    gBrowser.removeCurrentTab();
    140  }
    141  return { selectedPane };
    142 }
    143 
    144 async function runSearchInput(input) {
    145  let searchInput = gBrowser.contentDocument.getElementById("searchInput");
    146  searchInput.focus();
    147  let searchCompletedPromise = BrowserTestUtils.waitForEvent(
    148    gBrowser.contentWindow,
    149    "PreferencesSearchCompleted",
    150    evt => evt.detail == input
    151  );
    152  EventUtils.sendString(input);
    153  await searchCompletedPromise;
    154 }
    155 
    156 async function evaluateSearchResults(
    157  keyword,
    158  searchResults,
    159  includeExperiments = false
    160 ) {
    161  searchResults = Array.isArray(searchResults)
    162    ? searchResults
    163    : [searchResults];
    164  searchResults.push("header-searchResults");
    165 
    166  await runSearchInput(keyword);
    167 
    168  let mainPrefTag = gBrowser.contentDocument.getElementById("mainPrefPane");
    169  for (let i = 0; i < mainPrefTag.childElementCount; i++) {
    170    let child = mainPrefTag.children[i];
    171    if (!includeExperiments && child.id?.startsWith("pane-experimental")) {
    172      continue;
    173    }
    174    if (searchResults.includes(child.id)) {
    175      is_element_visible(child, `${child.id} should be in search results`);
    176    } else if (child.id) {
    177      is_element_hidden(child, `${child.id} should not be in search results`);
    178    }
    179  }
    180 }
    181 
    182 function waitForMutation(target, opts, cb) {
    183  return new Promise(resolve => {
    184    let observer = new MutationObserver(() => {
    185      if (!cb || cb(target)) {
    186        observer.disconnect();
    187        resolve();
    188      }
    189    });
    190    observer.observe(target, opts);
    191  });
    192 }
    193 
    194 /**
    195 * Creates observer that waits for and then compares all perm-changes with the observances in order.
    196 *
    197 * @param {Array} observances permission changes to observe (order is important)
    198 * @returns {Promise} Promise object that resolves once all permission changes have been observed
    199 */
    200 function createObserveAllPromise(observances) {
    201  // Create new promise that resolves once all items
    202  // in observances array have been observed.
    203  return new Promise(resolve => {
    204    let permObserver = {
    205      observe(aSubject, aTopic, aData) {
    206        if (aTopic != "perm-changed") {
    207          return;
    208        }
    209 
    210        if (!observances.length) {
    211          // See bug 1063410
    212          return;
    213        }
    214 
    215        let permission = aSubject.QueryInterface(Ci.nsIPermission);
    216        let expected = observances.shift();
    217 
    218        info(
    219          `observed perm-changed for ${permission.principal.origin} (remaining ${observances.length})`
    220        );
    221 
    222        is(aData, expected.data, "type of message should be the same");
    223        for (let prop of ["type", "capability", "expireType"]) {
    224          if (expected[prop]) {
    225            is(
    226              permission[prop],
    227              expected[prop],
    228              `property: "${prop}" should be equal (${permission.principal.origin})`
    229            );
    230          }
    231        }
    232 
    233        if (expected.origin) {
    234          is(
    235            permission.principal.origin,
    236            expected.origin,
    237            `property: "origin" should be equal (${permission.principal.origin})`
    238          );
    239        }
    240 
    241        if (!observances.length) {
    242          Services.obs.removeObserver(permObserver, "perm-changed");
    243          executeSoon(resolve);
    244        }
    245      },
    246    };
    247    Services.obs.addObserver(permObserver, "perm-changed");
    248  });
    249 }
    250 
    251 /**
    252 * Waits for preference to be set and asserts the value.
    253 *
    254 * @param {string} pref - Preference key.
    255 * @param {*} expectedValue - Expected value of the preference.
    256 * @param {string} message - Assertion message.
    257 */
    258 async function waitForAndAssertPrefState(pref, expectedValue, message) {
    259  await TestUtils.waitForPrefChange(pref, value => {
    260    if (value != expectedValue) {
    261      return false;
    262    }
    263    is(value, expectedValue, message);
    264    return true;
    265  });
    266 }
    267 
    268 /**
    269 * The Relay promo is not shown for distributions with a custom FxA instance,
    270 * since Relay requires an account on our own server. These prefs are set to a
    271 * dummy address by the test harness, filling the prefs with a "user value."
    272 * This temporarily sets the default value equal to the dummy value, so that
    273 * Firefox thinks we've configured the correct FxA server.
    274 *
    275 * @returns {Promise<MockFxAUtilityFunctions>} { mock, unmock }
    276 */
    277 async function mockDefaultFxAInstance() {
    278  /**
    279   * @typedef {object} MockFxAUtilityFunctions
    280   * @property {function():void} mock - Makes the dummy values default, creating
    281   *                             the illusion of a production FxA instance.
    282   * @property {function():void} unmock - Restores the true defaults, creating
    283   *                             the illusion of a custom FxA instance.
    284   */
    285 
    286  const defaultPrefs = Services.prefs.getDefaultBranch("");
    287  const userPrefs = Services.prefs.getBranch("");
    288  const realAuth = defaultPrefs.getCharPref("identity.fxaccounts.auth.uri");
    289  const realRoot = defaultPrefs.getCharPref("identity.fxaccounts.remote.root");
    290  const mockAuth = userPrefs.getCharPref("identity.fxaccounts.auth.uri");
    291  const mockRoot = userPrefs.getCharPref("identity.fxaccounts.remote.root");
    292  const mock = () => {
    293    defaultPrefs.setCharPref("identity.fxaccounts.auth.uri", mockAuth);
    294    defaultPrefs.setCharPref("identity.fxaccounts.remote.root", mockRoot);
    295    userPrefs.clearUserPref("identity.fxaccounts.auth.uri");
    296    userPrefs.clearUserPref("identity.fxaccounts.remote.root");
    297  };
    298  const unmock = () => {
    299    defaultPrefs.setCharPref("identity.fxaccounts.auth.uri", realAuth);
    300    defaultPrefs.setCharPref("identity.fxaccounts.remote.root", realRoot);
    301    userPrefs.setCharPref("identity.fxaccounts.auth.uri", mockAuth);
    302    userPrefs.setCharPref("identity.fxaccounts.remote.root", mockRoot);
    303  };
    304 
    305  mock();
    306  registerCleanupFunction(unmock);
    307 
    308  return { mock, unmock };
    309 }
    310 
    311 /**
    312 * Runs a test that checks the visibility of the Firefox Suggest preferences UI.
    313 * An initial Suggest enabled status is set and visibility is checked. Then a
    314 * Nimbus experiment is installed that enables or disables Suggest and
    315 * visibility is checked again. Finally the page is reopened and visibility is
    316 * checked again.
    317 *
    318 * @param {boolean} initialSuggestEnabled
    319 *   Whether Suggest should be enabled initially.
    320 * @param {object} initialExpected
    321 *   The expected visibility after setting the initial enabled status. It should
    322 *   be an object that can be passed to `assertSuggestVisibility()`.
    323 * @param {object} nimbusVariables
    324 *   An object mapping Nimbus variable names to values.
    325 * @param {object} newExpected
    326 *   The expected visibility after installing the Nimbus experiment. It should
    327 *   be an object that can be passed to `assertSuggestVisibility()`.
    328 * @param {string} pane
    329 *   The pref pane to open.
    330 */
    331 async function doSuggestVisibilityTest({
    332  initialSuggestEnabled,
    333  initialExpected,
    334  nimbusVariables,
    335  newExpected = initialExpected,
    336  pane = "search",
    337 }) {
    338  info(
    339    "Running Suggest visibility test: " +
    340      JSON.stringify(
    341        {
    342          initialSuggestEnabled,
    343          initialExpected,
    344          nimbusVariables,
    345          newExpected,
    346        },
    347        null,
    348        2
    349      )
    350  );
    351 
    352  // Set the initial enabled status.
    353  await SpecialPowers.pushPrefEnv({
    354    set: [["browser.urlbar.quicksuggest.enabled", initialSuggestEnabled]],
    355  });
    356 
    357  // Open prefs and check the initial visibility.
    358  await openPreferencesViaOpenPreferencesAPI(pane, { leaveOpen: true });
    359  await assertSuggestVisibility(initialExpected);
    360 
    361  // Install a Nimbus experiment.
    362  await QuickSuggestTestUtils.withExperiment({
    363    valueOverrides: nimbusVariables,
    364    callback: async () => {
    365      // Check visibility again.
    366      await assertSuggestVisibility(newExpected);
    367 
    368      // To make sure visibility is properly updated on load, close the tab,
    369      // open the prefs again, and check visibility.
    370      gBrowser.removeCurrentTab();
    371      await openPreferencesViaOpenPreferencesAPI(pane, { leaveOpen: true });
    372      await assertSuggestVisibility(newExpected);
    373    },
    374  });
    375 
    376  gBrowser.removeCurrentTab();
    377  await SpecialPowers.popPrefEnv();
    378 }
    379 
    380 /**
    381 * Checks the visibility of the Suggest UI.
    382 *
    383 * @param {object} expectedByElementId
    384 *   An object that maps IDs of elements in the current tab to objects with the
    385 *   following properties:
    386 *
    387 *   {bool} isVisible
    388 *     Whether the element is expected to be visible.
    389 *   {string} l10nId
    390 *     The expected l10n ID of the element. Optional.
    391 */
    392 async function assertSuggestVisibility(expectedByElementId) {
    393  let doc = gBrowser.selectedBrowser.contentDocument;
    394  for (let [elementId, { isVisible, l10nId }] of Object.entries(
    395    expectedByElementId
    396  )) {
    397    let element = doc.getElementById(elementId);
    398    await TestUtils.waitForCondition(
    399      () => BrowserTestUtils.isVisible(element) == isVisible,
    400      "Waiting for element visbility: " +
    401        JSON.stringify({ elementId, isVisible })
    402    );
    403    Assert.strictEqual(
    404      BrowserTestUtils.isVisible(element),
    405      isVisible,
    406      "Element should have expected visibility: " + elementId
    407    );
    408    if (l10nId) {
    409      Assert.equal(
    410        element.dataset.l10nId,
    411        l10nId,
    412        "The l10n ID should be correct for element: " + elementId
    413      );
    414    }
    415  }
    416 }
    417 
    418 const DEFAULT_LABS_RECIPES = [
    419  NimbusTestUtils.factories.recipe("nimbus-qa-1", {
    420    targeting: "true",
    421    isRollout: true,
    422    isFirefoxLabsOptIn: true,
    423    firefoxLabsTitle: "experimental-features-ime-search",
    424    firefoxLabsDescription: "experimental-features-ime-search-description",
    425    firefoxLabsDescriptionLinks: null,
    426    firefoxLabsGroup: "experimental-features-group-customize-browsing",
    427    requiresRestart: false,
    428    branches: [
    429      {
    430        slug: "control",
    431        ratio: 1,
    432        features: [
    433          {
    434            featureId: "nimbus-qa-1",
    435            value: {
    436              value: "recipe-value-1",
    437            },
    438          },
    439        ],
    440      },
    441    ],
    442  }),
    443 
    444  NimbusTestUtils.factories.recipe("nimbus-qa-2", {
    445    targeting: "true",
    446    isRollout: true,
    447    isFirefoxLabsOptIn: true,
    448    firefoxLabsTitle: "experimental-features-media-jxl",
    449    firefoxLabsDescription: "experimental-features-media-jxl-description",
    450    firefoxLabsDescriptionLinks: {
    451      bugzilla: "https://example.com",
    452    },
    453    firefoxLabsGroup: "experimental-features-group-webpage-display",
    454    branches: [
    455      {
    456        slug: "control",
    457        ratio: 1,
    458        features: [
    459          {
    460            featureId: "nimbus-qa-2",
    461            value: {
    462              value: "recipe-value-2",
    463            },
    464          },
    465        ],
    466      },
    467    ],
    468  }),
    469 
    470  NimbusTestUtils.factories.recipe("targeting-false", {
    471    targeting: "false",
    472    isRollout: true,
    473    isFirefoxLabsOptIn: true,
    474    firefoxLabsTitle: "experimental-features-ime-search",
    475    firefoxLabsDescription: "experimental-features-ime-search-description",
    476    firefoxLabsDescriptionLinks: null,
    477    firefoxLabsGroup: "experimental-features-group-developer-tools",
    478    requiresRestart: false,
    479  }),
    480 
    481  NimbusTestUtils.factories.recipe("bucketing-false", {
    482    bucketConfig: {
    483      ...NimbusTestUtils.factories.recipe.bucketConfig,
    484      count: 0,
    485    },
    486    isRollout: true,
    487    targeting: "true",
    488    isFirefoxLabsOptIn: true,
    489    firefoxLabsTitle: "experimental-features-ime-search",
    490    firefoxLabsDescription: "experimental-features-ime-search-description",
    491    firefoxLabsDescriptionLinks: null,
    492    firefoxLabsGroup: "experimental-features-group-developer-tools",
    493    requiresRestart: false,
    494  }),
    495 ];
    496 
    497 async function setupLabsTest(recipes) {
    498  await SpecialPowers.pushPrefEnv({
    499    set: [
    500      ["app.normandy.run_interval_seconds", 0],
    501      ["app.shield.optoutstudies.enabled", true],
    502      ["datareporting.healthreport.uploadEnabled", true],
    503      ["messaging-system.log", "debug"],
    504    ],
    505    clear: [
    506      ["browser.preferences.experimental"],
    507      ["browser.preferences.experimental.hidden"],
    508    ],
    509  });
    510  // Initialize Nimbus and wait for the RemoteSettingsExperimentLoader to finish
    511  // updating (with no recipes).
    512  await ExperimentAPI.ready();
    513  await ExperimentAPI._rsLoader.finishedUpdating();
    514 
    515  // Inject some recipes into the Remote Settings client and call
    516  // updateRecipes() so that we have available opt-ins.
    517  await ExperimentAPI._rsLoader.remoteSettingsClients.experiments.db.importChanges(
    518    {},
    519    Date.now(),
    520    recipes ?? DEFAULT_LABS_RECIPES,
    521    { clear: true }
    522  );
    523  await ExperimentAPI._rsLoader.remoteSettingsClients.secureExperiments.db.importChanges(
    524    {},
    525    Date.now(),
    526    [],
    527    { clear: true }
    528  );
    529 
    530  await ExperimentAPI._rsLoader.updateRecipes("test");
    531 
    532  return async function cleanup() {
    533    await NimbusTestUtils.removeStore(ExperimentAPI.manager.store);
    534    await SpecialPowers.popPrefEnv();
    535  };
    536 }
    537 
    538 function promiseNimbusStoreUpdate(wantedSlug, wantedActive) {
    539  const deferred = Promise.withResolvers();
    540  const listener = (_event, { slug, active }) => {
    541    info(
    542      `promiseNimbusStoreUpdate: received update for ${slug} active=${active}`
    543    );
    544    if (slug === wantedSlug && active === wantedActive) {
    545      ExperimentAPI._manager.store.off("update", listener);
    546      deferred.resolve();
    547    }
    548  };
    549 
    550  ExperimentAPI._manager.store.on("update", listener);
    551  return deferred.promise;
    552 }
    553 
    554 function enrollByClick(el, wantedActive) {
    555  const slug = el.dataset.nimbusSlug;
    556 
    557  info(`Enrolling in ${slug}:${el.dataset.nimbusBranchSlug}...`);
    558 
    559  const promise = promiseNimbusStoreUpdate(slug, wantedActive);
    560  EventUtils.synthesizeMouseAtCenter(el.inputEl, {}, gBrowser.contentWindow);
    561  return promise;
    562 }
    563 
    564 /**
    565 * Clicks a checkbox and waits for the associated preference to change to the expected value.
    566 *
    567 * @param {Document} doc - The content document.
    568 * @param {string} checkboxId - The checkbox element id.
    569 * @param {string} prefName - The preference name.
    570 * @param {boolean} expectedValue - The expected value after click.
    571 * @returns {Promise<HTMLInputElement>}
    572 */
    573 async function clickCheckboxAndWaitForPrefChange(
    574  doc,
    575  checkboxId,
    576  prefName,
    577  expectedValue
    578 ) {
    579  let checkbox = doc.getElementById(checkboxId);
    580  let prefChange = waitForAndAssertPrefState(prefName, expectedValue);
    581 
    582  checkbox.click();
    583 
    584  await prefChange;
    585  is(
    586    checkbox.checked,
    587    expectedValue,
    588    `The checkbox #${checkboxId} should be in the expected state after being clicked.`
    589  );
    590  return checkbox;
    591 }
    592 
    593 /**
    594 * Clicks a checkbox that triggers a confirmation dialog and handles the dialog response.
    595 *
    596 * @param {Document} doc - The document containing the checkbox.
    597 * @param {string} checkboxId - The ID of the checkbox to click.
    598 * @param {string} prefName - The name of the preference that should change.
    599 * @param {boolean} expectedValue - The expected value after handling the dialog.
    600 * @param {number} buttonNumClick - The button to click in the dialog (0 = cancel, 1 = OK).
    601 * @returns {Promise<HTMLInputElement>}
    602 */
    603 async function clickCheckboxWithConfirmDialog(
    604  doc,
    605  checkboxId,
    606  prefName,
    607  expectedValue,
    608  buttonNumClick
    609 ) {
    610  let checkbox = doc.getElementById(checkboxId);
    611 
    612  let promptPromise = PromptTestUtils.handleNextPrompt(
    613    gBrowser.selectedBrowser,
    614    { modalType: Services.prompt.MODAL_TYPE_CONTENT },
    615    { buttonNumClick }
    616  );
    617 
    618  let prefChangePromise = null;
    619  if (buttonNumClick === 1) {
    620    // Only wait for the final preference change to the expected value
    621    // The baseline checkbox handler sets the checkbox state directly and
    622    // the preference binding handles the actual preference change
    623    prefChangePromise = waitForAndAssertPrefState(prefName, expectedValue);
    624  }
    625 
    626  checkbox.click();
    627 
    628  await promptPromise;
    629 
    630  if (prefChangePromise) {
    631    await prefChangePromise;
    632  }
    633 
    634  is(
    635    checkbox.checked,
    636    expectedValue,
    637    `The checkbox #${checkboxId} should be in the expected state after dialog interaction.`
    638  );
    639 
    640  return checkbox;
    641 }
    642 
    643 /**
    644 * Select the given history mode via dropdown in the privacy pane.
    645 *
    646 * @param {Window} win - The preferences window which contains the
    647 * dropdown.
    648 * @param {string} value - The history mode to select.
    649 */
    650 async function selectHistoryMode(win, value) {
    651  let historyMode = win.document.getElementById("historyMode").inputEl;
    652 
    653  // Find the index of the option with the given value. Do this before the first
    654  // click so we can bail out early if the option does not exist.
    655  let optionIndexStr = Array.from(historyMode.children)
    656    .findIndex(option => option.value == value)
    657    ?.toString();
    658  if (optionIndexStr == null) {
    659    throw new Error(
    660      "Could not find history mode option item for value: " + value
    661    );
    662  }
    663 
    664  // Scroll into view for click to succeed.
    665  historyMode.scrollIntoView();
    666 
    667  let popupShownPromise = BrowserTestUtils.waitForSelectPopupShown(window);
    668 
    669  await EventUtils.synthesizeMouseAtCenter(
    670    historyMode,
    671    {},
    672    historyMode.ownerGlobal
    673  );
    674 
    675  let popup = await popupShownPromise;
    676  let popupItems = Array.from(popup.children);
    677 
    678  let targetItem = popupItems.find(item => item.value == optionIndexStr);
    679 
    680  if (!targetItem) {
    681    throw new Error(
    682      "Could not find history mode popup item for value: " + value
    683    );
    684  }
    685 
    686  let popupHiddenPromise = BrowserTestUtils.waitForPopupEvent(popup, "hidden");
    687 
    688  EventUtils.synthesizeMouseAtCenter(targetItem, {}, targetItem.ownerGlobal);
    689 
    690  await popupHiddenPromise;
    691 }
    692 
    693 /**
    694 * Select the given history mode in the redesigned privacy pane.
    695 *
    696 * @param {Window} win - The preferences window which contains the
    697 * dropdown.
    698 * @param {string} value - The history mode to select.
    699 */
    700 async function selectRedesignedHistoryMode(win, value) {
    701  let historyMode = win.document.querySelector(
    702    "setting-group[groupid='history2'] #historyMode"
    703  );
    704  let updated = waitForSettingControlChange(historyMode);
    705 
    706  let optionItems = Array.from(historyMode.children);
    707  let targetItem = optionItems.find(option => option.value == value);
    708  if (!targetItem) {
    709    throw new Error(
    710      "Could not find history mode popup item for value: " + value
    711    );
    712  }
    713 
    714  if (historyMode.value == value) {
    715    return;
    716  }
    717 
    718  targetItem.click();
    719  await updated;
    720 }
    721 
    722 async function updateCheckBoxElement(checkbox, value) {
    723  ok(checkbox, "the " + checkbox.id + " checkbox should exist");
    724  is_element_visible(
    725    checkbox,
    726    "the " + checkbox.id + " checkbox should be visible"
    727  );
    728 
    729  // No need to click if we're already in the desired state.
    730  if (checkbox.checked === value) {
    731    return;
    732  }
    733 
    734  // Scroll into view for click to succeed.
    735  checkbox.scrollIntoView();
    736 
    737  // Toggle the state.
    738  await EventUtils.synthesizeMouseAtCenter(checkbox, {}, checkbox.ownerGlobal);
    739 }
    740 
    741 async function updateCheckBox(win, id, value) {
    742  let checkbox = win.document.getElementById(id);
    743  ok(checkbox, "the " + id + " checkbox should exist");
    744  is_element_visible(checkbox, "the " + id + " checkbox should be visible");
    745 
    746  // No need to click if we're already in the desired state.
    747  if (checkbox.checked === value) {
    748    return;
    749  }
    750 
    751  // Scroll into view for click to succeed.
    752  checkbox.scrollIntoView();
    753 
    754  // Toggle the state.
    755  await EventUtils.synthesizeMouseAtCenter(checkbox, {}, checkbox.ownerGlobal);
    756 }
    757 
    758 function waitForSettingChange(setting) {
    759  return new Promise(resolve => {
    760    setting.on("change", function handler() {
    761      setting.off("change", handler);
    762      resolve();
    763    });
    764  });
    765 }
    766 
    767 async function waitForSettingControlChange(control) {
    768  await waitForSettingChange(control.setting);
    769  await new Promise(resolve => requestAnimationFrame(resolve));
    770 }
    771 
    772 /**
    773 * Wait for the current setting pane to change.
    774 *
    775 * @param {string} paneId
    776 */
    777 async function waitForPaneChange(paneId) {
    778  let doc = gBrowser.selectedBrowser.contentDocument;
    779  let event = await BrowserTestUtils.waitForEvent(doc, "paneshown");
    780  let expectId = paneId.startsWith("pane")
    781    ? paneId
    782    : `pane${paneId[0].toUpperCase()}${paneId.substring(1)}`;
    783  is(event.detail.category, expectId, "Loaded the correct pane");
    784 }
    785 
    786 function getControl(doc, id) {
    787  let control = doc.getElementById(id);
    788  ok(control, `Control ${id} exists`);
    789  return control;
    790 }
    791 
    792 function synthesizeClick(el) {
    793  let target = el.buttonEl ?? el.inputEl ?? el;
    794  target.scrollIntoView({ block: "center" });
    795  EventUtils.synthesizeMouseAtCenter(target, {}, target.ownerGlobal);
    796 }
    797 
    798 function getControlWrapper(doc, id) {
    799  return getControl(doc, id).closest("setting-control");
    800 }
    801 
    802 async function openEtpPage() {
    803  await openPreferencesViaOpenPreferencesAPI("etp", { leaveOpen: true });
    804  let doc = gBrowser.contentDocument;
    805  await BrowserTestUtils.waitForCondition(
    806    () => doc.getElementById("contentBlockingCategoryRadioGroup"),
    807    "Wait for the ETP advanced radio group to render"
    808  );
    809  return {
    810    win: gBrowser.contentWindow,
    811    doc,
    812    tab: gBrowser.selectedTab,
    813  };
    814 }
    815 
    816 async function openEtpCustomizePage() {
    817  await openPreferencesViaOpenPreferencesAPI("etpCustomize", {
    818    leaveOpen: true,
    819  });
    820  let doc = gBrowser.contentDocument;
    821  await BrowserTestUtils.waitForCondition(
    822    () => doc.getElementById("etpAllowListBaselineEnabledCustom"),
    823    "Wait for the ETP customize controls to render"
    824  );
    825  return {
    826    win: gBrowser.contentWindow,
    827    doc,
    828  };
    829 }
    830 
    831 async function changeMozSelectValue(selectEl, value) {
    832  let control = selectEl.control;
    833  let changePromise = waitForSettingControlChange(control);
    834  selectEl.value = value;
    835  selectEl.dispatchEvent(new Event("change", { bubbles: true }));
    836  await changePromise;
    837 }
    838 
    839 async function clickEtpBaselineCheckboxWithConfirm(
    840  doc,
    841  controlId,
    842  prefName,
    843  expectedValue,
    844  buttonNumClick
    845 ) {
    846  let checkbox = getControl(doc, controlId);
    847 
    848  let promptPromise = PromptTestUtils.handleNextPrompt(
    849    gBrowser.selectedBrowser,
    850    { modalType: Services.prompt.MODAL_TYPE_CONTENT },
    851    { buttonNumClick }
    852  );
    853 
    854  let prefChangePromise = null;
    855  if (buttonNumClick === 1) {
    856    prefChangePromise = waitForAndAssertPrefState(
    857      prefName,
    858      expectedValue,
    859      `${prefName} updated`
    860    );
    861  }
    862 
    863  synthesizeClick(checkbox);
    864 
    865  await promptPromise;
    866 
    867  if (prefChangePromise) {
    868    await prefChangePromise;
    869  }
    870 
    871  is(
    872    checkbox.checked,
    873    expectedValue,
    874    `Checkbox ${controlId} should be ${expectedValue}`
    875  );
    876 
    877  return checkbox;
    878 }
    879 
    880 // Ensure each test leaves the sidebar in its initial state when it completes
    881 const initialSidebarState = { ...SidebarController.getUIState(), command: "" };
    882 registerCleanupFunction(async function () {
    883  const { ObjectUtils } = ChromeUtils.importESModule(
    884    "resource://gre/modules/ObjectUtils.sys.mjs"
    885  );
    886  if (
    887    !ObjectUtils.deepEqual(SidebarController.getUIState(), initialSidebarState)
    888  ) {
    889    info("Restoring to initial sidebar state");
    890    await SidebarController.initializeUIState(initialSidebarState);
    891  }
    892 });