/* Any copyright is dedicated to the Public Domain.
   http://creativecommons.org/publicdomain/zero/1.0/ */

"use strict";

Services.scriptloader.loadSubScript(
  "chrome://mochitests/content/browser/toolkit/components/translations/tests/browser/shared-head.js",
  this
);

/**
 * Opens a new tab in the foreground.
 *
 * @param {string} url
 */
async function addTab(url) {
  logAction(url);
  const tab = await BrowserTestUtils.openNewForegroundTab(
    gBrowser,
    url,
    true // Wait for load
  );
  return {
    tab,
    removeTab() {
      BrowserTestUtils.removeTab(tab);
    },
  };
}

/**
 * Simulates clicking an element with the mouse.
 *
 * @param {element} element - The element to click.
 * @param {string} [message] - A message to log to info.
 */
function click(element, message) {
  logAction(message);
  return new Promise(resolve => {
    element.addEventListener(
      "click",
      function () {
        resolve();
      },
      { once: true }
    );

    EventUtils.synthesizeMouseAtCenter(element, {
      type: "mousedown",
      isSynthesized: false,
    });
    EventUtils.synthesizeMouseAtCenter(element, {
      type: "mouseup",
      isSynthesized: false,
    });
  });
}

/**
 * Get all elements that match the l10n id.
 *
 * @param {string} l10nId
 * @param {Document} doc
 * @returns {Element}
 */
function getAllByL10nId(l10nId, doc = document) {
  const elements = doc.querySelectorAll(`[data-l10n-id="${l10nId}"]`);
  if (elements.length === 0) {
    throw new Error("Could not find the element by l10n id: " + l10nId);
  }
  return elements;
}

/**
 * Retrieves an element by its Id.
 *
 * @param {string} id
 * @param {Document} [doc]
 * @returns {Element}
 * @throws Throws if the element is not visible in the DOM.
 */
function getById(id, doc = document) {
  const element = maybeGetById(id, /* ensureIsVisible */ true, doc);
  if (!element) {
    throw new Error("The element is not visible in the DOM: #" + id);
  }
  return element;
}

/**
 * Get an element by its l10n id, as this is a user-visible way to find an element.
 * The `l10nId` represents the text that a user would actually see.
 *
 * @param {string} l10nId
 * @param {Document} doc
 * @returns {Element}
 */
function getByL10nId(l10nId, doc = document) {
  const elements = doc.querySelectorAll(`[data-l10n-id="${l10nId}"]`);
  if (elements.length === 0) {
    throw new Error("Could not find the element by l10n id: " + l10nId);
  }
  for (const element of elements) {
    if (BrowserTestUtils.isVisible(element)) {
      return element;
    }
  }
  throw new Error("The element is not visible in the DOM: " + l10nId);
}

/**
 * Returns the intl display name of a given language tag.
 *
 * @param {string} langTag - A BCP-47 language tag.
 */
const getIntlDisplayName = (() => {
  let displayNames = null;

  return langTag => {
    if (!displayNames) {
      displayNames = new Services.intl.DisplayNames(undefined, {
        type: "language",
        fallback: "none",
      });
    }
    return displayNames.of(langTag);
  };
})();

/**
 * Attempts to retrieve an element by its Id.
 *
 * @param {string} id - The Id of the element to retrieve.
 * @param {boolean} [ensureIsVisible=true] - If set to true, the function will return null when the element is not visible.
 * @param {Document} [doc=document] - The document from which to retrieve the element.
 * @returns {Element | null} - The retrieved element.
 * @throws Throws if no element was found by the given Id.
 */
function maybeGetById(id, ensureIsVisible = true, doc = document) {
  const element = doc.getElementById(id);
  if (!element) {
    throw new Error("Could not find the element by id: #" + id);
  }

  if (!ensureIsVisible) {
    return element;
  }

  if (BrowserTestUtils.isVisible(element)) {
    return element;
  }

  return null;
}

/**
 * A non-throwing version of `getByL10nId`.
 *
 * @param {string} l10nId
 * @returns {Element | null}
 */
function maybeGetByL10nId(l10nId, doc = document) {
  const selector = `[data-l10n-id="${l10nId}"]`;
  const elements = doc.querySelectorAll(selector);
  for (const element of elements) {
    if (BrowserTestUtils.isVisible(element)) {
      return element;
    }
  }
  return null;
}

/**
 * Provide a uniform way to log actions. This abuses the Error stack to get the callers
 * of the action. This should help in test debugging.
 */
function logAction(...params) {
  const error = new Error();
  const stackLines = error.stack.split("\n");
  const actionName = stackLines[1]?.split("@")[0] ?? "";
  const taskFileLocation = stackLines[2]?.split("@")[1] ?? "";
  if (taskFileLocation.includes("head.js")) {
    // Only log actions that were done at the test level.
    return;
  }

  info(`Action: ${actionName}(${params.join(", ")})`);
  info(
    `Source: ${taskFileLocation.replace(
      "chrome://mochitests/content/browser/",
      ""
    )}`
  );
}

/**
 * Navigate to a URL and indicate a message as to why.
 */
async function navigate(
  message,
  { url, onOpenPanel = null, downloadHandler = null, pivotTranslation = false }
) {
  logAction();
  // When the translations panel is open from the app menu,
  // it doesn't close on navigate the way that it does when it's
  // open from the translations button, so ensure that we always
  // close it when we navigate to a new page.
  await closeAllOpenPanelsAndMenus();

  info(message);

  // Load a blank page first to ensure that tests don't hang.
  // I don't know why this is needed, but it appears to be necessary.
  BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, BLANK_PAGE);
  await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);

  const loadTargetPage = async () => {
    BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, url);
    await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);

    if (downloadHandler) {
      await FullPageTranslationsTestUtils.assertTranslationsButton(
        { button: true, circleArrows: true, locale: false, icon: true },
        "The icon presents the loading indicator."
      );
      await downloadHandler(pivotTranslation ? 2 : 1);
    }
  };

  info(`Loading url: "${url}"`);
  if (onOpenPanel) {
    await FullPageTranslationsTestUtils.waitForPanelPopupEvent(
      "popupshown",
      loadTargetPage,
      onOpenPanel
    );
  } else {
    await loadTargetPage();
  }
}

/**
 * Switches to a given tab.
 *
 * @param {object} tab - The tab to switch to
 * @param {string} name
 */
async function switchTab(tab, name) {
  logAction("tab", name);
  gBrowser.selectedTab = tab;
  await new Promise(resolve => setTimeout(resolve, 0));
}

/**
 * Click the reader-mode button if the reader-mode button is available.
 * Fails if the reader-mode button is hidden.
 */
async function toggleReaderMode() {
  logAction();
  const readerButton = document.getElementById("reader-mode-button");
  await waitForCondition(() => readerButton.hidden === false);

  readerButton.getAttribute("readeractive")
    ? info("Exiting reader mode")
    : info("Entering reader mode");

  const readyPromise = readerButton.getAttribute("readeractive")
    ? waitForCondition(() => !readerButton.getAttribute("readeractive"))
    : BrowserTestUtils.waitForContentEvent(
        gBrowser.selectedBrowser,
        "AboutReaderContentReady"
      );

  click(readerButton, "Clicking the reader-mode button");
  await readyPromise;
}

/**
 * A collection of shared functionality utilized by
 * FullPageTranslationsTestUtils and SelectTranslationsTestUtils.
 *
 * Using functions from the aforementioned classes is preferred over
 * using functions from this class directly.
 */
class SharedTranslationsTestUtils {
  /**
   * Asserts that the specified element currently has focus.
   *
   * @param {Element} element - The element to check for focus.
   */
  static _assertHasFocus(element) {
    is(
      document.activeElement,
      element,
      `The element '${element.id}' should have focus.`
    );
  }

  /**
   * Asserts that the given element has the expected L10nId.
   *
   * @param {Element} element - The element to assert against.
   * @param {string} l10nId - The expected localization id.
   */
  static _assertL10nId(element, l10nId) {
    is(
      element.getAttribute("data-l10n-id"),
      l10nId,
      `The element ${element.id} should have L10n Id ${l10nId}.`
    );
  }

  /**
   * Asserts that the mainViewId of the panel matches the given string.
   *
   * @param {FullPageTranslationsPanel | SelectTranslationsPanel} panel
   * @param {string} expectedId - The expected id that mainViewId is set to.
   */
  static _assertPanelMainViewId(panel, expectedId) {
    const mainViewId = panel.elements.multiview.getAttribute("mainViewId");
    is(
      mainViewId,
      expectedId,
      "The mainViewId should match its expected value"
    );
  }

  /**
   * Asserts that the selected language in the menu matches the langTag or l10nId.
   *
   * @param {Element} menuList - The menu list element to check.
   * @param {object} options - Options containing 'langTag' and 'l10nId' to assert against.
   * @param {string} [options.langTag] - The BCP-47 language tag to match.
   * @param {string} [options.l10nId] - The localization Id to match.
   */
  static _assertSelectedLanguage(menuList, { langTag, l10nId }) {
    ok(
      menuList.label,
      `The label for the menulist ${menuList.id} should not be empty.`
    );
    if (langTag) {
      is(
        menuList.value,
        langTag,
        `Expected ${menuList.id} selection to match '${langTag}'`
      );
    }
    if (l10nId) {
      is(
        menuList.getAttribute("data-l10n-id"),
        l10nId,
        `Expected ${menuList.id} l10nId to match '${l10nId}'`
      );
    }
  }

  /**
   * Asserts the visibility of the given elements based on the given expectations.
   *
   * @param {object} elements - An object containing the elements to be checked for visibility.
   * @param {object} expectations - An object where each property corresponds to a property in elements,
   *                                and its value is a boolean indicating whether the element should
   *                                be visible (true) or hidden (false).
   * @throws Throws if elements does not contain a property for each property in expectations.
   */
  static _assertPanelElementVisibility(elements, expectations) {
    const hidden = {};
    const visible = {};

    for (const propertyName in expectations) {
      ok(
        elements.hasOwnProperty(propertyName),
        `Expected panel elements to have property ${propertyName}`
      );
      if (expectations[propertyName]) {
        visible[propertyName] = elements[propertyName];
      } else {
        hidden[propertyName] = elements[propertyName];
      }
    }

    assertVisibility({ hidden, visible });
  }

  /**
   * Asserts that the given elements are focusable in order
   * via the tab key, starting with the first element already
   * focused and ending back on that same first element.
   *
   * @param {Element[]} elements - The focusable elements.
   */
  static _assertTabIndexOrder(elements) {
    const activeElementAtStart = document.activeElement;

    if (elements.length) {
      elements[0].focus();
      elements.push(elements[0]);
    }
    for (const element of elements) {
      SharedTranslationsTestUtils._assertHasFocus(element);
      EventUtils.synthesizeKey("KEY_Tab");
    }

    activeElementAtStart.focus();
  }

  /**
   * Executes the provided callback before waiting for the event and then waits for the given event
   * to be fired for the element corresponding to the provided elementId.
   *
   * Optionally executes a postEventAssertion function once the event occurs.
   *
   * @param {string} elementId - The Id of the element to wait for the event on.
   * @param {string} eventName - The name of the event to wait for.
   * @param {Function} callback - A callback function to execute immediately before waiting for the event.
   *                              This is often used to trigger the event on the expected element.
   * @param {Function|null} [postEventAssertion=null] - An optional callback function to execute after
   *                                                    the event has occurred.
   * @param {ChromeWindow} [win]
   * @throws Throws if the element with the specified `elementId` does not exist.
   * @returns {Promise<void>}
   */
  static async _waitForPopupEvent(
    elementId,
    eventName,
    callback,
    postEventAssertion = null,
    win = window
  ) {
    const element = win.document.getElementById(elementId);
    if (!element) {
      throw new Error(
        `Unable to find the ${elementId} element in the document.`
      );
    }
    const promise = BrowserTestUtils.waitForEvent(element, eventName);
    await callback();
    info(`Waiting for the ${elementId} ${eventName} event`);
    await promise;
    if (postEventAssertion) {
      await postEventAssertion();
    }
    // Wait a single tick on the event loop.
    await new Promise(resolve => setTimeout(resolve, 0));
  }
}

/**
 * A class containing test utility functions specific to testing full-page translations.
 */
class FullPageTranslationsTestUtils {
  /**
   * A collection of element visibility expectations for the default panel view.
   */
  static #defaultViewVisibilityExpectations = {
    cancelButton: true,
    fromMenuList: true,
    fromLabel: true,
    header: true,
    langSelection: true,
    toMenuList: true,
    toLabel: true,
    translateButton: true,
  };

  /**
   * Asserts that the state of a checkbox with a given dataL10nId is
   * checked or not, based on the value of expected being true or false.
   *
   * @param {string} dataL10nId - The data-l10n-id of the checkbox.
   * @param {object} expectations
   * @param {string} expectations.langTag - A BCP-47 language tag.
   * @param {boolean} expectations.checked - Whether the checkbox is expected to be checked.
   * @param {boolean} expectations.disabled - Whether the menuitem is expected to be disabled.
   */
  static async #assertCheckboxState(
    dataL10nId,
    { langTag = null, checked = true, disabled = false }
  ) {
    const menuItems = getAllByL10nId(dataL10nId);
    for (const menuItem of menuItems) {
      if (langTag) {
        const {
          args: { language },
        } = document.l10n.getAttributes(menuItem);
        is(
          language,
          getIntlDisplayName(langTag),
          `Should match expected language display name for ${dataL10nId}`
        );
      }
      is(
        menuItem.disabled,
        disabled,
        `Should match expected disabled state for ${dataL10nId}`
      );
      await waitForCondition(
        () => menuItem.getAttribute("checked") === (checked ? "true" : "false"),
        "Waiting for checkbox state"
      );
      is(
        menuItem.getAttribute("checked"),
        checked ? "true" : "false",
        `Should match expected checkbox state for ${dataL10nId}`
      );
    }
  }

  /**
   * Asserts that the always-offer-translations checkbox matches the expected checked state.
   *
   * @param {boolean} checked
   */
  static async assertIsAlwaysOfferTranslationsEnabled(checked) {
    info(
      `Checking that always-offer-translations is ${
        checked ? "enabled" : "disabled"
      }`
    );
    await FullPageTranslationsTestUtils.#assertCheckboxState(
      "translations-panel-settings-always-offer-translation",
      { checked }
    );
  }

  /**
   * Asserts that the always-translate-language checkbox matches the expected checked state.
   *
   * @param {string} langTag - A BCP-47 language tag
   * @param {object} expectations
   * @param {boolean} expectations.checked - Whether the checkbox is expected to be checked.
   * @param {boolean} expectations.disabled - Whether the menuitem is expected to be disabled.
   */
  static async assertIsAlwaysTranslateLanguage(
    langTag,
    { checked = true, disabled = false }
  ) {
    info(
      `Checking that always-translate is ${
        checked ? "enabled" : "disabled"
      } for "${langTag}"`
    );
    await FullPageTranslationsTestUtils.#assertCheckboxState(
      "translations-panel-settings-always-translate-language",
      { langTag, checked, disabled }
    );
  }

  /**
   * Asserts that the never-translate-language checkbox matches the expected checked state.
   *
   * @param {string} langTag - A BCP-47 language tag
   * @param {object} expectations
   * @param {boolean} expectations.checked - Whether the checkbox is expected to be checked.
   * @param {boolean} expectations.disabled - Whether the menuitem is expected to be disabled.
   */
  static async assertIsNeverTranslateLanguage(
    langTag,
    { checked = true, disabled = false }
  ) {
    info(
      `Checking that never-translate is ${
        checked ? "enabled" : "disabled"
      } for "${langTag}"`
    );
    await FullPageTranslationsTestUtils.#assertCheckboxState(
      "translations-panel-settings-never-translate-language",
      { langTag, checked, disabled }
    );
  }

  /**
   * Asserts that the never-translate-site checkbox matches the expected checked state.
   *
   * @param {string} url - The url of a website
   * @param {object} expectations
   * @param {boolean} expectations.checked - Whether the checkbox is expected to be checked.
   * @param {boolean} expectations.disabled - Whether the menuitem is expected to be disabled.
   */
  static async assertIsNeverTranslateSite(
    url,
    { checked = true, disabled = false }
  ) {
    info(
      `Checking that never-translate is ${
        checked ? "enabled" : "disabled"
      } for "${url}"`
    );
    await FullPageTranslationsTestUtils.#assertCheckboxState(
      "translations-panel-settings-never-translate-site",
      { checked, disabled }
    );
  }

  /**
   * Asserts that the proper language tags are shown on the translations button.
   *
   * @param {string} fromLanguage - The BCP-47 language tag being translated from.
   * @param {string} toLanguage - The BCP-47 language tag being translated into.
   * @param {ChromeWindow} win
   */
  static async assertLangTagIsShownOnTranslationsButton(
    fromLanguage,
    toLanguage,
    win = window
  ) {
    info(
      `Ensuring that the translations button displays the language tag "${toLanguage}"`
    );
    const { button, locale } =
      await FullPageTranslationsTestUtils.assertTranslationsButton(
        { button: true, circleArrows: false, locale: true, icon: true },
        "The icon presents the locale.",
        win
      );
    is(
      locale.innerText,
      toLanguage,
      `The expected language tag "${toLanguage}" is shown.`
    );
    is(
      button.getAttribute("data-l10n-id"),
      "urlbar-translations-button-translated"
    );
    const fromLangDisplay = getIntlDisplayName(fromLanguage);
    const toLangDisplay = getIntlDisplayName(toLanguage);
    is(
      button.getAttribute("data-l10n-args"),
      `{"fromLanguage":"${fromLangDisplay}","toLanguage":"${toLangDisplay}"}`
    );
  }
  /**
   * Asserts that the Spanish test page has been translated by checking
   * that the H1 element has been modified from its original form.
   *
   * @param {string} fromLanguage - The BCP-47 language tag being translated from.
   * @param {string} toLanguage - The BCP-47 language tag being translated into.
   * @param {Function} runInPage - Allows running a closure in the content page.
   * @param {string} message - An optional message to log to info.
   * @param {ChromeWindow} [win]
   */
  static async assertPageIsTranslated(
    fromLanguage,
    toLanguage,
    runInPage,
    message = null,
    win = window
  ) {
    if (message) {
      info(message);
    }
    info("Checking that the page is translated");
    const callback = async (TranslationsTest, { fromLang, toLang }) => {
      const { getH1 } = TranslationsTest.getSelectors();
      await TranslationsTest.assertTranslationResult(
        "The page's H1 is translated.",
        getH1,
        `DON QUIJOTE DE LA MANCHA [${fromLang} to ${toLang}, html]`
      );
    };
    await runInPage(callback, { fromLang: fromLanguage, toLang: toLanguage });
    await FullPageTranslationsTestUtils.assertLangTagIsShownOnTranslationsButton(
      fromLanguage,
      toLanguage,
      win
    );
  }

  /**
   * Asserts that the Spanish test page is untranslated by checking
   * that the H1 element is still in its original Spanish form.
   *
   * @param {Function} runInPage - Allows running a closure in the content page.
   * @param {string} message - An optional message to log to info.
   */
  static async assertPageIsUntranslated(runInPage, message = null) {
    if (message) {
      info(message);
    }
    info("Checking that the page is untranslated");
    await runInPage(async TranslationsTest => {
      const { getH1 } = TranslationsTest.getSelectors();
      await TranslationsTest.assertTranslationResult(
        "The page's H1 is untranslated and in the original Spanish.",
        getH1,
        "Don Quijote de La Mancha"
      );
    });
  }

  /**
   * Asserts that for each provided expectation, the visible state of the corresponding
   * element in FullPageTranslationsPanel.elements both exists and matches the visibility expectation.
   *
   * @param {object} expectations
   *   A list of expectations for the visibility of any subset of FullPageTranslationsPanel.elements
   */
  static #assertPanelElementVisibility(expectations = {}) {
    SharedTranslationsTestUtils._assertPanelElementVisibility(
      FullPageTranslationsPanel.elements,
      {
        cancelButton: false,
        changeSourceLanguageButton: false,
        dismissErrorButton: false,
        error: false,
        errorMessage: false,
        errorMessageHint: false,
        errorHintAction: false,
        fromMenuList: false,
        fromLabel: false,
        header: false,
        intro: false,
        introLearnMoreLink: false,
        langSelection: false,
        restoreButton: false,
        toLabel: false,
        toMenuList: false,
        translateButton: false,
        unsupportedHeader: false,
        unsupportedHint: false,
        unsupportedLearnMoreLink: false,
        // Overwrite any of the above defaults with the passed in expectations.
        ...expectations,
      }
    );
  }

  /**
   * Asserts that the FullPageTranslationsPanel header has the expected l10nId.
   *
   * @param {string} l10nId - The expected data-l10n-id of the header.
   */
  static #assertPanelHeaderL10nId(l10nId) {
    const { header } = FullPageTranslationsPanel.elements;
    SharedTranslationsTestUtils._assertL10nId(header, l10nId);
  }

  /**
   * Asserts that the FullPageTranslationsPanel error has the expected l10nId.
   *
   * @param {string} l10nId - The expected data-l10n-id of the error.
   */
  static #assertPanelErrorL10nId(l10nId) {
    const { errorMessage } = FullPageTranslationsPanel.elements;
    SharedTranslationsTestUtils._assertL10nId(errorMessage, l10nId);
  }

  /**
   * Asserts that the mainViewId of the panel matches the given string.
   *
   * @param {string} expectedId
   */
  static #assertPanelMainViewId(expectedId) {
    SharedTranslationsTestUtils._assertPanelMainViewId(
      FullPageTranslationsPanel,
      expectedId
    );
  }

  /**
   * Asserts that panel element visibility matches the default panel view.
   */
  static assertPanelViewDefault() {
    info("Checking that the panel shows the default view");
    FullPageTranslationsTestUtils.#assertPanelMainViewId(
      "full-page-translations-panel-view-default"
    );
    FullPageTranslationsTestUtils.#assertPanelElementVisibility({
      ...FullPageTranslationsTestUtils.#defaultViewVisibilityExpectations,
    });
    FullPageTranslationsTestUtils.#assertPanelHeaderL10nId(
      "translations-panel-header"
    );
  }

  /**
   * Asserts that panel element visibility matches the initialization-failure view.
   */
  static assertPanelViewInitFailure() {
    info("Checking that the panel shows the default view");
    const { translateButton } = FullPageTranslationsPanel.elements;
    FullPageTranslationsTestUtils.#assertPanelMainViewId(
      "full-page-translations-panel-view-default"
    );
    FullPageTranslationsTestUtils.#assertPanelElementVisibility({
      cancelButton: true,
      error: true,
      errorMessage: true,
      errorMessageHint: true,
      errorHintAction: true,
      header: true,
      translateButton: true,
    });
    is(
      translateButton.disabled,
      true,
      "The translate button should be disabled."
    );
    FullPageTranslationsTestUtils.#assertPanelHeaderL10nId(
      "translations-panel-header"
    );
  }

  /**
   * Asserts that panel element visibility matches the panel error view.
   */
  static assertPanelViewError() {
    info("Checking that the panel shows the error view");
    FullPageTranslationsTestUtils.#assertPanelMainViewId(
      "full-page-translations-panel-view-default"
    );
    FullPageTranslationsTestUtils.#assertPanelElementVisibility({
      error: true,
      errorMessage: true,
      ...FullPageTranslationsTestUtils.#defaultViewVisibilityExpectations,
    });
    FullPageTranslationsTestUtils.#assertPanelHeaderL10nId(
      "translations-panel-header"
    );
    FullPageTranslationsTestUtils.#assertPanelErrorL10nId(
      "translations-panel-error-translating"
    );
  }

  /**
   * Asserts that the panel element visibility matches the panel loading view.
   */
  static assertPanelViewLoading() {
    info("Checking that the panel shows the loading view");
    FullPageTranslationsTestUtils.assertPanelViewDefault();
    const loadingButton = getByL10nId(
      "translations-panel-translate-button-loading"
    );
    ok(loadingButton, "The loading button is present");
    ok(loadingButton.disabled, "The loading button is disabled");
  }

  /**
   * Asserts that panel element visibility matches the panel first-show view.
   */
  static assertPanelViewFirstShow() {
    info("Checking that the panel shows the first-show view");
    FullPageTranslationsTestUtils.#assertPanelMainViewId(
      "full-page-translations-panel-view-default"
    );
    FullPageTranslationsTestUtils.#assertPanelElementVisibility({
      intro: true,
      introLearnMoreLink: true,
      ...FullPageTranslationsTestUtils.#defaultViewVisibilityExpectations,
    });
    FullPageTranslationsTestUtils.#assertPanelHeaderL10nId(
      "translations-panel-intro-header"
    );
  }

  /**
   * Asserts that panel element visibility matches the panel first-show error view.
   */
  static assertPanelViewFirstShowError() {
    info("Checking that the panel shows the first-show error view");
    FullPageTranslationsTestUtils.#assertPanelMainViewId(
      "full-page-translations-panel-view-default"
    );
    FullPageTranslationsTestUtils.#assertPanelElementVisibility({
      error: true,
      intro: true,
      introLearnMoreLink: true,
      ...FullPageTranslationsTestUtils.#defaultViewVisibilityExpectations,
    });
    FullPageTranslationsTestUtils.#assertPanelHeaderL10nId(
      "translations-panel-intro-header"
    );
  }

  /**
   * Asserts that panel element visibility matches the panel revisit view.
   */
  static assertPanelViewRevisit() {
    info("Checking that the panel shows the revisit view");
    FullPageTranslationsTestUtils.#assertPanelMainViewId(
      "full-page-translations-panel-view-default"
    );
    FullPageTranslationsTestUtils.#assertPanelElementVisibility({
      header: true,
      langSelection: true,
      restoreButton: true,
      toLabel: true,
      toMenuList: true,
      translateButton: true,
    });
    FullPageTranslationsTestUtils.#assertPanelHeaderL10nId(
      "translations-panel-revisit-header"
    );
  }

  /**
   * Asserts that panel element visibility matches the panel unsupported language view.
   */
  static assertPanelViewUnsupportedLanguage() {
    info("Checking that the panel shows the unsupported-language view");
    FullPageTranslationsTestUtils.#assertPanelMainViewId(
      "full-page-translations-panel-view-unsupported-language"
    );
    FullPageTranslationsTestUtils.#assertPanelElementVisibility({
      changeSourceLanguageButton: true,
      dismissErrorButton: true,
      unsupportedHeader: true,
      unsupportedHint: true,
      unsupportedLearnMoreLink: true,
    });
  }

  /**
   * Asserts that the selected from-language matches the provided language tag.
   *
   * @param {object} options - Options containing 'langTag' and 'l10nId' to assert against.
   * @param {string} [options.langTag] - The BCP-47 language tag to match.
   * @param {string} [options.l10nId] - The localization Id to match.
   */
  static assertSelectedFromLanguage({ langTag, l10nId }) {
    const { fromMenuList } = FullPageTranslationsPanel.elements;
    SharedTranslationsTestUtils._assertSelectedLanguage(fromMenuList, {
      langTag,
      l10nId,
    });
  }

  /**
   * Asserts that the selected to-language matches the provided language tag.
   *
   * @param {object} options - Options containing 'langTag' and 'l10nId' to assert against.
   * @param {string} [options.langTag] - The BCP-47 language tag to match.
   * @param {string} [options.l10nId] - The localization Id to match.
   */
  static assertSelectedToLanguage({ langTag, l10nId }) {
    const { toMenuList } = FullPageTranslationsPanel.elements;
    SharedTranslationsTestUtils._assertSelectedLanguage(toMenuList, {
      langTag,
      l10nId,
    });
  }

  /**
   * Assert some property about the translations button.
   *
   * @param {Record<string, boolean>} visibleAssertions
   * @param {string} message The message for the assertion.
   * @param {ChromeWindow} [win]
   * @returns {HTMLElement}
   */
  static async assertTranslationsButton(
    visibleAssertions,
    message,
    win = window
  ) {
    const elements = {
      button: win.document.getElementById("translations-button"),
      icon: win.document.getElementById("translations-button-icon"),
      circleArrows: win.document.getElementById(
        "translations-button-circle-arrows"
      ),
      locale: win.document.getElementById("translations-button-locale"),
    };

    for (const [name, element] of Object.entries(elements)) {
      if (!element) {
        throw new Error("Could not find the " + name);
      }
    }

    try {
      // Test that the visibilities match.
      await waitForCondition(() => {
        for (const [name, visible] of Object.entries(visibleAssertions)) {
          if (elements[name].hidden === visible) {
            return false;
          }
        }
        return true;
      }, message);
    } catch (error) {
      // On a mismatch, report it.
      for (const [name, expected] of Object.entries(visibleAssertions)) {
        is(!elements[name].hidden, expected, `Visibility for "${name}"`);
      }
    }

    ok(true, message);

    return elements;
  }

  /**
   * Simulates the effect of clicking the always-offer-translations menuitem.
   * Requires that the settings menu of the translations panel is open,
   * otherwise the test will fail.
   */
  static async clickAlwaysOfferTranslations() {
    logAction();
    await FullPageTranslationsTestUtils.#clickSettingsMenuItemByL10nId(
      "translations-panel-settings-always-offer-translation"
    );
  }

  /**
   * Simulates the effect of clicking the always-translate-language menuitem.
   * Requires that the settings menu of the translations panel is open,
   * otherwise the test will fail.
   */
  static async clickAlwaysTranslateLanguage({
    downloadHandler = null,
    pivotTranslation = false,
  } = {}) {
    logAction();
    await FullPageTranslationsTestUtils.#clickSettingsMenuItemByL10nId(
      "translations-panel-settings-always-translate-language"
    );
    if (downloadHandler) {
      await FullPageTranslationsTestUtils.assertTranslationsButton(
        { button: true, circleArrows: true, locale: false, icon: true },
        "The icon presents the loading indicator."
      );
      await downloadHandler(pivotTranslation ? 2 : 1);
    }
  }

  /**
   * Simulates clicking the cancel button.
   */
  static async clickCancelButton() {
    logAction();
    const { cancelButton } = FullPageTranslationsPanel.elements;
    assertVisibility({ visible: { cancelButton } });
    await FullPageTranslationsTestUtils.waitForPanelPopupEvent(
      "popuphidden",
      () => {
        click(cancelButton, "Clicking the cancel button");
      }
    );
  }

  /**
   * Simulates clicking the change-source-language button.
   *
   * @param {object} config
   * @param {boolean} config.firstShow
   *  - True if the first-show view should be expected
   *    False if the default view should be expected
   */
  static async clickChangeSourceLanguageButton({ firstShow = false } = {}) {
    logAction();
    const { changeSourceLanguageButton } = FullPageTranslationsPanel.elements;
    assertVisibility({ visible: { changeSourceLanguageButton } });
    await FullPageTranslationsTestUtils.waitForPanelPopupEvent(
      "popupshown",
      () => {
        click(
          changeSourceLanguageButton,
          "Click the change-source-language button"
        );
      },
      firstShow
        ? FullPageTranslationsTestUtils.assertPanelViewFirstShow
        : FullPageTranslationsTestUtils.assertPanelViewDefault
    );
  }

  /**
   * Simulates clicking the dismiss-error button.
   */
  static async clickDismissErrorButton() {
    logAction();
    const { dismissErrorButton } = FullPageTranslationsPanel.elements;
    assertVisibility({ visible: { dismissErrorButton } });
    await FullPageTranslationsTestUtils.waitForPanelPopupEvent(
      "popuphidden",
      () => {
        click(dismissErrorButton, "Click the dismiss-error button");
      }
    );
  }

  /**
   * Simulates the effect of clicking the manage-languages menuitem.
   * Requires that the settings menu of the translations panel is open,
   * otherwise the test will fail.
   */
  static async clickManageLanguages() {
    logAction();
    await FullPageTranslationsTestUtils.#clickSettingsMenuItemByL10nId(
      "translations-panel-settings-manage-languages"
    );
  }

  /**
   * Simulates the effect of clicking the never-translate-language menuitem.
   * Requires that the settings menu of the translations panel is open,
   * otherwise the test will fail.
   */
  static async clickNeverTranslateLanguage() {
    logAction();
    await FullPageTranslationsTestUtils.#clickSettingsMenuItemByL10nId(
      "translations-panel-settings-never-translate-language"
    );
  }

  /**
   * Simulates the effect of clicking the never-translate-site menuitem.
   * Requires that the settings menu of the translations panel is open,
   * otherwise the test will fail.
   */
  static async clickNeverTranslateSite() {
    logAction();
    await FullPageTranslationsTestUtils.#clickSettingsMenuItemByL10nId(
      "translations-panel-settings-never-translate-site"
    );
  }

  /**
   * Simulates clicking the restore-page button.
   */
  static async clickRestoreButton() {
    logAction();
    const { restoreButton } = FullPageTranslationsPanel.elements;
    assertVisibility({ visible: { restoreButton } });
    await FullPageTranslationsTestUtils.waitForPanelPopupEvent(
      "popuphidden",
      () => {
        click(restoreButton, "Click the restore-page button");
      }
    );
  }

  /*
   * Simulates the effect of toggling a menu item in the translations panel
   * settings menu. Requires that the settings menu is currently open,
   * otherwise the test will fail.
   */
  static async #clickSettingsMenuItemByL10nId(l10nId) {
    info(`Toggling the "${l10nId}" settings menu item.`);
    click(getByL10nId(l10nId), `Clicking the "${l10nId}" settings menu item.`);
    await closeFullPagePanelSettingsMenuIfOpen();
  }

  /**
   * Simulates clicking the translate button.
   *
   * @param {object} config
   * @param {Function} config.downloadHandler
   *  - The function handle expected downloads, resolveDownloads() or rejectDownloads()
   *    Leave as null to test more granularly, such as testing opening the loading view,
   *    or allowing for the automatic downloading of files.
   * @param {boolean} config.pivotTranslation
   *  - True if the expected translation is a pivot translation, otherwise false.
   *    Affects the number of expected downloads.
   * @param {ChromeWindow} [config.win]
   *  - An optional ChromeWindow, for multi-window tests.
   */
  static async clickTranslateButton({
    downloadHandler = null,
    pivotTranslation = false,
    win = window,
  } = {}) {
    logAction();
    const { translateButton } = win.FullPageTranslationsPanel.elements;
    assertVisibility({ visible: { translateButton } });
    await FullPageTranslationsTestUtils.waitForPanelPopupEvent(
      "popuphidden",
      () => {
        click(translateButton);
      },
      null /* postEventAssertion */,
      win
    );

    if (downloadHandler) {
      await FullPageTranslationsTestUtils.assertTranslationsButton(
        { button: true, circleArrows: true, locale: false, icon: true },
        "The icon presents the loading indicator.",
        win
      );
      await downloadHandler(pivotTranslation ? 2 : 1);
    }
  }

  /**
   * Opens the translations panel.
   *
   * @param {object} config
   * @param {Function} config.onOpenPanel
   *  - A function to run as soon as the panel opens.
   * @param {boolean} config.openFromAppMenu
   *  - Open the panel from the app menu. If false, uses the translations button.
   * @param {boolean} config.openWithKeyboard
   *  - Open the panel by synthesizing the keyboard. If false, synthesizes the mouse.
   * @param {ChromeWindow} [config.win]
   *  - An optional window for multi-window tests.
   */
  static async openPanel({
    onOpenPanel = null,
    openFromAppMenu = false,
    openWithKeyboard = false,
    win = window,
  }) {
    logAction();
    await closeAllOpenPanelsAndMenus(win);
    if (openFromAppMenu) {
      await FullPageTranslationsTestUtils.#openPanelViaAppMenu({
        win,
        onOpenPanel,
        openWithKeyboard,
      });
    } else {
      await FullPageTranslationsTestUtils.#openPanelViaTranslationsButton({
        win,
        onOpenPanel,
        openWithKeyboard,
      });
    }
  }

  /**
   * Opens the translations panel via the app menu.
   *
   * @param {object} config
   * @param {Function} config.onOpenPanel
   *  - A function to run as soon as the panel opens.
   * @param {boolean} config.openWithKeyboard
   *  - Open the panel by synthesizing the keyboard. If false, synthesizes the mouse.
   * @param {ChromeWindow} [config.win]
   */
  static async #openPanelViaAppMenu({
    onOpenPanel = null,
    openWithKeyboard = false,
    win = window,
  }) {
    logAction();
    const appMenuButton = getById("PanelUI-menu-button", win.document);
    if (openWithKeyboard) {
      hitEnterKey(appMenuButton, "Opening the app-menu button with keyboard");
    } else {
      click(appMenuButton, "Opening the app-menu button");
    }
    await BrowserTestUtils.waitForEvent(win.PanelUI.mainView, "ViewShown");

    const translateSiteButton = getById(
      "appMenu-translate-button",
      win.document
    );

    is(
      translateSiteButton.disabled,
      false,
      "The app-menu translate button should be enabled"
    );

    await FullPageTranslationsTestUtils.waitForPanelPopupEvent(
      "popupshown",
      () => {
        if (openWithKeyboard) {
          hitEnterKey(translateSiteButton, "Opening the popup with keyboard");
        } else {
          click(translateSiteButton, "Opening the popup");
        }
      },
      onOpenPanel
    );
  }

  /**
   * Opens the translations panel via the translations button.
   *
   * @param {object} config
   * @param {Function} config.onOpenPanel
   *  - A function to run as soon as the panel opens.
   * @param {boolean} config.openWithKeyboard
   *  - Open the panel by synthesizing the keyboard. If false, synthesizes the mouse.
   * @param {ChromeWindow} [config.win]
   */
  static async #openPanelViaTranslationsButton({
    onOpenPanel = null,
    openWithKeyboard = false,
    win = window,
  }) {
    logAction();
    const { button } =
      await FullPageTranslationsTestUtils.assertTranslationsButton(
        { button: true },
        "The translations button is visible.",
        win
      );
    await FullPageTranslationsTestUtils.waitForPanelPopupEvent(
      "popupshown",
      () => {
        if (openWithKeyboard) {
          hitEnterKey(button, "Opening the popup with keyboard");
        } else {
          click(button, "Opening the popup");
        }
      },
      onOpenPanel,
      win
    );
  }

  /**
   * Opens the translations panel settings menu.
   * Requires that the translations panel is already open.
   */
  static async openTranslationsSettingsMenu() {
    logAction();
    const gearIcons = getAllByL10nId("translations-panel-settings-button");
    for (const gearIcon of gearIcons) {
      if (BrowserTestUtils.isHidden(gearIcon)) {
        continue;
      }
      click(gearIcon, "Open the settings menu");
      info("Waiting for settings menu to open.");
      const manageLanguages = await waitForCondition(() =>
        maybeGetByL10nId("translations-panel-settings-manage-languages")
      );
      ok(
        manageLanguages,
        "The manage languages item should be visible in the settings menu."
      );
      return;
    }
  }

  /**
   * Switches the selected from-language to the provided language tag.
   *
   * @param {string} langTag - A BCP-47 language tag.
   */
  static changeSelectedFromLanguage(langTag) {
    logAction(langTag);
    const { fromMenuList } = FullPageTranslationsPanel.elements;
    fromMenuList.value = langTag;
    fromMenuList.dispatchEvent(new Event("command"));
  }

  /**
   * Switches the selected to-language to the provided language tag.
   *
   * @param {string} langTag - A BCP-47 language tag.
   */
  static changeSelectedToLanguage(langTag) {
    logAction(langTag);
    const { toMenuList } = FullPageTranslationsPanel.elements;
    toMenuList.value = langTag;
    toMenuList.dispatchEvent(new Event("command"));
  }

  /**
   * XUL popups will fire the popupshown and popuphidden events. These will fire for
   * any type of popup in the browser. This function waits for one of those events, and
   * checks that the viewId of the popup is PanelUI-profiler
   *
   * @param {"popupshown" | "popuphidden"} eventName
   * @param {Function} callback
   * @param {Function} postEventAssertion
   *   An optional assertion to be made immediately after the event occurs.
   * @param {ChromeWindow} [win]
   * @returns {Promise<void>}
   */
  static async waitForPanelPopupEvent(
    eventName,
    callback,
    postEventAssertion = null,
    win = window
  ) {
    // De-lazify the panel elements.
    win.FullPageTranslationsPanel.elements;
    await SharedTranslationsTestUtils._waitForPopupEvent(
      "full-page-translations-panel",
      eventName,
      callback,
      postEventAssertion,
      win
    );
  }
}

/**
 * A class containing test utility functions specific to testing select translations.
 */
class SelectTranslationsTestUtils {
  /**
   * Opens the context menu then asserts properties of the translate-selection item in the context menu.
   *
   * @param {Function} runInPage - A content-exposed function to run within the context of the page.
   * @param {object} options - Options for how to open the context menu and what properties to assert about the translate-selection item.
   *
   * @param {boolean} options.expectMenuItemVisible - Whether the select-translations menu item should be present in the context menu.
   * @param {boolean} options.expectedTargetLanguage - The expected target language to be shown in the context menu.
   *
   * The following options will work on all test pages that have an <h1> element.
   *
   * @param {boolean} options.selectH1 - Selects the first H1 element of the page.
   * @param {boolean} options.openAtH1 - Opens the context menu at the first H1 element of the page.
   *
   * The following options will work only in the PDF_TEST_PAGE_URL.
   *
   * @param {boolean} options.selectPdfSpan - Selects the first span of text on the first page of a pdf.
   * @param {boolean} options.openAtPdfSpan - Opens the context menu at the first span of text on the first page of a pdf.
   *
   * The following options will only work when testing SELECT_TEST_PAGE_URL.
   *
   * @param {boolean} options.selectFrenchSection - Selects the section of French text.
   * @param {boolean} options.selectEnglishSection - Selects the section of English text.
   * @param {boolean} options.selectSpanishSection - Selects the section of Spanish text.
   * @param {boolean} options.selectFrenchSentence - Selects a French sentence.
   * @param {boolean} options.selectEnglishSentence - Selects an English sentence.
   * @param {boolean} options.selectSpanishSentence - Selects a Spanish sentence.
   * @param {boolean} options.openAtFrenchSection - Opens the context menu at the section of French text.
   * @param {boolean} options.openAtEnglishSection - Opens the context menu at the section of English text.
   * @param {boolean} options.openAtSpanishSection - Opens the context menu at the section of Spanish text.
   * @param {boolean} options.openAtFrenchSentence - Opens the context menu at a French sentence.
   * @param {boolean} options.openAtEnglishSentence - Opens the context menu at an English sentence.
   * @param {boolean} options.openAtSpanishSentence - Opens the context menu at a Spanish sentence.
   * @param {boolean} options.openAtFrenchHyperlink - Opens the context menu at a hyperlinked French text.
   * @param {boolean} options.openAtEnglishHyperlink - Opens the context menu at an hyperlinked English text.
   * @param {boolean} options.openAtSpanishHyperlink - Opens the context menu at a hyperlinked Spanish text.
   * @param {string} [message] - A message to log to info.
   * @throws Throws an error if the properties of the translate-selection item do not match the expected options.
   */
  static async assertContextMenuTranslateSelectionItem(
    runInPage,
    {
      expectMenuItemVisible,
      expectedTargetLanguage,
      selectH1,
      selectPdfSpan,
      selectFrenchSection,
      selectEnglishSection,
      selectSpanishSection,
      selectFrenchSentence,
      selectEnglishSentence,
      selectSpanishSentence,
      openAtH1,
      openAtPdfSpan,
      openAtFrenchSection,
      openAtEnglishSection,
      openAtSpanishSection,
      openAtFrenchSentence,
      openAtEnglishSentence,
      openAtSpanishSentence,
      openAtFrenchHyperlink,
      openAtEnglishHyperlink,
      openAtSpanishHyperlink,
    },
    message
  ) {
    logAction();

    if (message) {
      info(message);
    }

    await closeAllOpenPanelsAndMenus();

    await SelectTranslationsTestUtils.openContextMenu(runInPage, {
      expectMenuItemVisible,
      expectedTargetLanguage,
      selectH1,
      selectPdfSpan,
      selectFrenchSection,
      selectEnglishSection,
      selectSpanishSection,
      selectFrenchSentence,
      selectEnglishSentence,
      selectSpanishSentence,
      openAtH1,
      openAtPdfSpan,
      openAtFrenchSection,
      openAtEnglishSection,
      openAtSpanishSection,
      openAtFrenchSentence,
      openAtEnglishSentence,
      openAtSpanishSentence,
      openAtFrenchHyperlink,
      openAtEnglishHyperlink,
      openAtSpanishHyperlink,
    });

    const menuItem = maybeGetById(
      "context-translate-selection",
      /* ensureIsVisible */ false
    );

    if (expectMenuItemVisible !== undefined) {
      const visibility = expectMenuItemVisible ? "visible" : "hidden";
      assertVisibility({ [visibility]: { menuItem } });
    }

    if (expectMenuItemVisible === true) {
      if (expectedTargetLanguage) {
        // Target language expected, check for the data-l10n-id with a `{$language}` argument.
        const expectedL10nId =
          selectFrenchSection ||
          selectEnglishSection ||
          selectSpanishSection ||
          selectFrenchSentence ||
          selectEnglishSentence ||
          selectSpanishSentence
            ? "main-context-menu-translate-selection-to-language"
            : "main-context-menu-translate-link-text-to-language";
        await waitForCondition(
          () => menuItem.getAttribute("data-l10n-id") === expectedL10nId,
          `Waiting for translate-selection context menu item to localize with target language ${expectedTargetLanguage}`
        );

        is(
          menuItem.getAttribute("data-l10n-id"),
          expectedL10nId,
          "Expected the translate-selection context menu item to be localized with a target language."
        );

        const l10nArgs = JSON.parse(menuItem.getAttribute("data-l10n-args"));
        is(
          l10nArgs.language,
          getIntlDisplayName(expectedTargetLanguage),
          `Expected the translate-selection context menu item to have the target language '${expectedTargetLanguage}'.`
        );
      } else {
        // No target language expected, check for the data-l10n-id that has no `{$language}` argument.
        const expectedL10nId =
          selectFrenchSection ||
          selectEnglishSection ||
          selectSpanishSection ||
          selectFrenchSentence ||
          selectEnglishSentence ||
          selectSpanishSentence
            ? "main-context-menu-translate-selection"
            : "main-context-menu-translate-link-text";
        await waitForCondition(
          () => menuItem.getAttribute("data-l10n-id") === expectedL10nId,
          "Waiting for translate-selection context menu item to localize without target language."
        );

        is(
          menuItem.getAttribute("data-l10n-id"),
          expectedL10nId,
          "Expected the translate-selection context menu item to be localized without a target language."
        );
      }
    }
  }

  /**
   * Asserts that for each provided expectation, the visible state of the corresponding
   * element in FullPageTranslationsPanel.elements both exists and matches the visibility expectation.
   *
   * @param {object} expectations
   *   A list of expectations for the visibility of any subset of SelectTranslationsPanel.elements
   */
  static #assertPanelElementVisibility(expectations = {}) {
    SharedTranslationsTestUtils._assertPanelElementVisibility(
      SelectTranslationsPanel.elements,
      {
        betaIcon: false,
        cancelButton: false,
        copyButton: false,
        doneButtonPrimary: false,
        doneButtonSecondary: false,
        fromLabel: false,
        fromMenuList: false,
        fromMenuPopup: false,
        header: false,
        initFailureContent: false,
        initFailureMessageBar: false,
        mainContent: false,
        settingsButton: false,
        textArea: false,
        toLabel: false,
        toMenuList: false,
        toMenuPopup: false,
        translateButton: false,
        translateFullPageButton: false,
        translationFailureMessageBar: false,
        tryAgainButton: false,
        tryAnotherSourceMenuList: false,
        tryAnotherSourceMenuPopup: false,
        unsupportedLanguageContent: false,
        unsupportedLanguageMessageBar: false,
        // Overwrite any of the above defaults with the passed in expectations.
        ...expectations,
      }
    );
  }

  /**
   * Waits for the panel's translation state to reach the given phase,
   * if it is not currently in that phase already.
   *
   * @param {string} phase - The phase of the panel's translation state to wait for.
   */
  static async waitForPanelState(phase) {
    const currentPhase = SelectTranslationsPanel.phase();
    if (currentPhase !== phase) {
      info(
        `Waiting for SelectTranslationsPanel to change state from "${currentPhase}" to "${phase}"`
      );
      await BrowserTestUtils.waitForEvent(
        document,
        "SelectTranslationsPanelStateChanged",
        event => event.detail.phase === phase
      );
    }
  }

  /**
   * Asserts that the SelectTranslationsPanel UI matches the expected
   * state when the panel has completed its translation.
   */
  static async assertPanelViewTranslated() {
    const {
      copyButton,
      doneButtonPrimary,
      fromMenuList,
      settingsButton,
      textArea,
      toMenuList,
      translateFullPageButton,
    } = SelectTranslationsPanel.elements;
    const sameLanguageSelected = fromMenuList.value === toMenuList.value;
    await SelectTranslationsTestUtils.waitForPanelState("translated");
    ok(
      !textArea.classList.contains("translating"),
      "The textarea should not have the translating class."
    );
    const isFullPageTranslationsRestrictedForPage =
      TranslationsParent.isFullPageTranslationsRestrictedForPage(gBrowser);
    SelectTranslationsTestUtils.#assertPanelElementVisibility({
      betaIcon: true,
      copyButton: true,
      doneButtonPrimary: true,
      fromLabel: true,
      fromMenuList: true,
      header: true,
      mainContent: true,
      settingsButton: true,
      textArea: true,
      toLabel: true,
      toMenuList: true,
      translateFullPageButton: !isFullPageTranslationsRestrictedForPage,
    });
    SelectTranslationsTestUtils.#assertConditionalUIEnabled({
      copyButton: true,
      doneButtonPrimary: true,
      textArea: true,
      translateFullPageButton:
        !sameLanguageSelected && !isFullPageTranslationsRestrictedForPage,
    });

    await waitForCondition(
      () =>
        !copyButton.classList.contains("copied") &&
        copyButton.getAttribute("data-l10n-id") ===
          "select-translations-panel-copy-button",
      "Waiting for copy button to match the not-copied state."
    );

    SelectTranslationsTestUtils.#assertPanelHasTranslatedText();
    SelectTranslationsTestUtils.#assertPanelTextAreaHeight();
    await SelectTranslationsTestUtils.#assertPanelTextAreaOverflow();

    let footerButtons;
    if (sameLanguageSelected || isFullPageTranslationsRestrictedForPage) {
      footerButtons = [copyButton, doneButtonPrimary];
    } else {
      footerButtons =
        AppConstants.platform === "win"
          ? [copyButton, doneButtonPrimary, translateFullPageButton]
          : [copyButton, translateFullPageButton, doneButtonPrimary];
    }

    SharedTranslationsTestUtils._assertTabIndexOrder([
      settingsButton,
      fromMenuList,
      toMenuList,
      textArea,
      ...footerButtons,
    ]);
  }

  /**
   * Asserts that the SelectTranslationsPanel UI matches the expected
   * state when the language lists fail to initialize upon opening the panel.
   */
  static async assertPanelViewInitFailure() {
    const { cancelButton, settingsButton, tryAgainButton } =
      SelectTranslationsPanel.elements;
    await SelectTranslationsTestUtils.waitForPanelState("init-failure");
    SelectTranslationsTestUtils.#assertPanelElementVisibility({
      header: true,
      betaIcon: true,
      cancelButton: true,
      initFailureContent: true,
      initFailureMessageBar: true,
      settingsButton: true,
      tryAgainButton: true,
    });
    SharedTranslationsTestUtils._assertTabIndexOrder([
      settingsButton,
      ...(AppConstants.platform === "win"
        ? [tryAgainButton, cancelButton]
        : [cancelButton, tryAgainButton]),
    ]);
    SharedTranslationsTestUtils._assertHasFocus(tryAgainButton);
  }

  /**
   * Asserts that the SelectTranslationsPanel UI matches the expected
   * state when a translation has failed to complete.
   */
  static async assertPanelViewTranslationFailure() {
    const {
      cancelButton,
      fromMenuList,
      settingsButton,
      toMenuList,
      translationFailureMessageBar,
      tryAgainButton,
    } = SelectTranslationsPanel.elements;
    await SelectTranslationsTestUtils.waitForPanelState("translation-failure");
    SelectTranslationsTestUtils.#assertPanelElementVisibility({
      header: true,
      betaIcon: true,
      cancelButton: true,
      fromLabel: true,
      fromMenuList: true,
      mainContent: true,
      settingsButton: true,
      toLabel: true,
      toMenuList: true,
      translationFailureMessageBar: true,
      tryAgainButton: true,
    });
    is(
      document.activeElement,
      tryAgainButton,
      "The try-again button should have focus."
    );
    is(
      translationFailureMessageBar.getAttribute("role"),
      "alert",
      "The translation failure message bar is an alert."
    );
    SharedTranslationsTestUtils._assertTabIndexOrder([
      settingsButton,
      fromMenuList,
      toMenuList,
      ...(AppConstants.platform === "win"
        ? [tryAgainButton, cancelButton]
        : [cancelButton, tryAgainButton]),
    ]);
    SharedTranslationsTestUtils._assertHasFocus(tryAgainButton);
  }

  static #assertPanelTextAreaDirection(langTag = null) {
    const expectedTextDirection = langTag
      ? Services.intl.getScriptDirection(langTag)
      : null;
    const { textArea } = SelectTranslationsPanel.elements;
    const actualTextDirection = textArea.getAttribute("dir");

    is(
      actualTextDirection,
      expectedTextDirection,
      `The text direction should be ${expectedTextDirection}`
    );
  }

  /**
   * Asserts that the SelectTranslationsPanel UI matches the expected
   * state when the panel has completed its translation.
   */
  static async assertPanelViewUnsupportedLanguage() {
    await SelectTranslationsTestUtils.waitForPanelState("unsupported");
    const {
      doneButtonSecondary,
      settingsButton,
      translateButton,
      tryAnotherSourceMenuList,
      unsupportedLanguageMessageBar,
    } = SelectTranslationsPanel.elements;
    SelectTranslationsTestUtils.#assertPanelElementVisibility({
      betaIcon: true,
      doneButtonSecondary: true,
      header: true,
      settingsButton: true,
      translateButton: true,
      tryAnotherSourceMenuList: true,
      unsupportedLanguageContent: true,
      unsupportedLanguageMessageBar: true,
    });
    SelectTranslationsTestUtils.#assertConditionalUIEnabled({
      doneButtonSecondary: true,
      translateButton: false,
    });
    ok(
      translateButton.disabled,
      "The translate button should be disabled when first shown."
    );
    SharedTranslationsTestUtils._assertL10nId(
      unsupportedLanguageMessageBar,
      "select-translations-panel-unsupported-language-message-known"
    );
    SharedTranslationsTestUtils._assertHasFocus(tryAnotherSourceMenuList);
    SharedTranslationsTestUtils._assertTabIndexOrder([
      settingsButton,
      tryAnotherSourceMenuList,
      doneButtonSecondary,
    ]);
  }

  /**
   * Asserts that the SelectTranslationsPanel translated text area is
   * both scrollable and scrolled to the top.
   */
  static async #assertPanelTextAreaOverflow() {
    const { textArea } = SelectTranslationsPanel.elements;
    if (textArea.style.overflow !== "auto") {
      await BrowserTestUtils.waitForMutationCondition(
        textArea,
        { attributes: true, attributeFilter: ["style"] },
        () => textArea.style.overflow === "auto"
      );
    }
    if (textArea.scrollHeight > textArea.clientHeight) {
      info("Ensuring that the textarea is scrolled to the top.");
      await waitForCondition(() => textArea.scrollTop === 0);
    }
  }

  /**
   * Asserts that the SelectTranslationsPanel translated text area is
   * the correct height for the length of the translated text.
   */
  static #assertPanelTextAreaHeight() {
    const { textArea } = SelectTranslationsPanel.elements;

    if (
      SelectTranslationsPanel.getSourceText().length <
      SelectTranslationsPanel.textLengthThreshold
    ) {
      is(
        textArea.style.height,
        SelectTranslationsPanel.shortTextHeight,
        "The panel text area should have the short-text height"
      );
    } else {
      is(
        textArea.style.height,
        SelectTranslationsPanel.longTextHeight,
        "The panel text area should have the long-text height"
      );
    }
  }

  /**
   * Asserts that the SelectTranslationsPanel UI matches the expected
   * state when the panel is actively translating text.
   */
  static async assertPanelViewActivelyTranslating() {
    const { textArea } = SelectTranslationsPanel.elements;
    const isFullPageTranslationsRestrictedForPage =
      TranslationsParent.isFullPageTranslationsRestrictedForPage(gBrowser);
    await SelectTranslationsTestUtils.waitForPanelState("translating");
    ok(
      textArea.classList.contains("translating"),
      "The textarea should have the translating class."
    );
    SelectTranslationsTestUtils.#assertPanelElementVisibility({
      betaIcon: true,
      copyButton: true,
      doneButtonPrimary: true,
      fromLabel: true,
      fromMenuList: true,
      header: true,
      mainContent: true,
      settingsButton: true,
      textArea: true,
      toLabel: true,
      toMenuList: true,
      translateFullPageButton: !isFullPageTranslationsRestrictedForPage,
    });
    SelectTranslationsTestUtils.#assertPanelHasTranslatingPlaceholder();
  }

  /**
   * Asserts that the SelectTranslationsPanel UI contains the
   * translating placeholder text.
   */
  static async #assertPanelHasTranslatingPlaceholder() {
    const { textArea, fromMenuList, toMenuList } =
      SelectTranslationsPanel.elements;
    const expected = await document.l10n.formatValue(
      "select-translations-panel-translating-placeholder-text"
    );
    const isFullPageTranslationsRestrictedForPage =
      TranslationsParent.isFullPageTranslationsRestrictedForPage(gBrowser);
    is(
      textArea.value,
      expected,
      "Active translation text area should have the translating placeholder."
    );
    SelectTranslationsTestUtils.#assertPanelTextAreaDirection();
    SelectTranslationsTestUtils.#assertConditionalUIEnabled({
      textArea: true,
      copyButton: false,
      doneButtonPrimary: true,
      translateFullPageButton:
        fromMenuList.value !== toMenuList.value &&
        !isFullPageTranslationsRestrictedForPage,
    });
  }

  /**
   * Asserts that the SelectTranslationsPanel UI contains the
   * translated text.
   */
  static #assertPanelHasTranslatedText() {
    const { textArea, fromMenuList, toMenuList } =
      SelectTranslationsPanel.elements;
    const fromLanguage = fromMenuList.value;
    const toLanguage = toMenuList.value;
    const isFullPageTranslationsRestrictedForPage =
      TranslationsParent.isFullPageTranslationsRestrictedForPage(gBrowser);

    SelectTranslationsTestUtils.#assertPanelTextAreaDirection(toLanguage);
    SelectTranslationsTestUtils.#assertConditionalUIEnabled({
      textArea: true,
      copyButton: true,
      doneButtonPrimary: true,
      translateFullPageButton:
        fromLanguage !== toLanguage && !isFullPageTranslationsRestrictedForPage,
    });

    if (fromLanguage === toLanguage) {
      is(
        SelectTranslationsPanel.getSourceText(),
        SelectTranslationsPanel.getTranslatedText(),
        "The source text should passthrough as the translated text."
      );
      return;
    }

    const translatedSuffix = ` [${fromLanguage} to ${toLanguage}]`;
    ok(
      textArea.value.endsWith(translatedSuffix),
      `Translated text should match ${fromLanguage} to ${toLanguage}`
    );
    is(
      SelectTranslationsPanel.getSourceText().length,
      SelectTranslationsPanel.getTranslatedText().length -
        translatedSuffix.length,
      "Expected translated text length to correspond to the source text length."
    );
  }

  /**
   * Asserts the enabled state of action buttons in the SelectTranslationsPanel.
   *
   * @param {Record<string, boolean>} enabledStates
   *  - An object that maps whether each element should be enabled (true) or disabled (false).
   */
  static #assertConditionalUIEnabled(enabledStates) {
    const elements = SelectTranslationsPanel.elements;

    for (const [elementName, expectEnabled] of Object.entries(enabledStates)) {
      const element = elements[elementName];
      if (!element) {
        throw new Error(
          `SelectTranslationsPanel element '${elementName}' not found.`
        );
      }
      is(
        element.disabled,
        !expectEnabled,
        `The element '${elementName} should be ${
          expectEnabled ? "enabled" : "disabled"
        }.`
      );
    }
  }

  /**
   * Asserts that the selected from-language matches the provided language tag.
   *
   * @param {string} langTag - A BCP-47 language tag.
   */
  static assertSelectedFromLanguage(langTag = null) {
    const { fromMenuList } = SelectTranslationsPanel.elements;
    SelectTranslationsTestUtils.#assertSelectedLanguage(fromMenuList, langTag);
  }

  /**
   * Asserts that the selected to-language matches the provided language tag.
   *
   * @param {string} langTag - A BCP-47 language tag.
   */
  static assertSelectedToLanguage(langTag = null) {
    const { toMenuList } = SelectTranslationsPanel.elements;
    SelectTranslationsTestUtils.#assertSelectedLanguage(toMenuList, langTag);
  }

  /**
   * Asserts the selected language in the given  menu list if a langTag is provided.
   * If no langTag is given, asserts that the menulist displays the localized placeholder.
   *
   * @param {object} menuList - The menu list object to check.
   * @param {string} [langTag] - The optional language tag to assert against.
   */
  static #assertSelectedLanguage(menuList, langTag) {
    if (langTag) {
      SharedTranslationsTestUtils._assertSelectedLanguage(menuList, {
        langTag,
      });
    } else {
      SharedTranslationsTestUtils._assertSelectedLanguage(menuList, {
        l10nId: "translations-panel-choose-language",
      });
      SharedTranslationsTestUtils._assertHasFocus(menuList);
    }
  }

  /**
   * Simulates clicking the done button and waits for the panel to close.
   */
  static async clickDoneButton() {
    logAction();
    const { doneButtonPrimary, doneButtonSecondary } =
      SelectTranslationsPanel.elements;
    let visibleDoneButton;
    let hiddenDoneButton;
    if (BrowserTestUtils.isVisible(doneButtonPrimary)) {
      visibleDoneButton = doneButtonPrimary;
      hiddenDoneButton = doneButtonSecondary;
    } else if (BrowserTestUtils.isVisible(doneButtonSecondary)) {
      visibleDoneButton = doneButtonSecondary;
      hiddenDoneButton = doneButtonPrimary;
    } else {
      throw new Error(
        "Expected either the primary or secondary done button to be visible."
      );
    }
    assertVisibility({
      visible: { visibleDoneButton },
      hidden: { hiddenDoneButton },
    });
    await SelectTranslationsTestUtils.waitForPanelPopupEvent(
      "popuphidden",
      () => {
        click(visibleDoneButton, "Clicking the done button");
      }
    );
  }

  /**
   * Simulates clicking the cancel button and waits for the panel to close.
   */
  static async clickCancelButton() {
    logAction();
    const { cancelButton } = SelectTranslationsPanel.elements;
    assertVisibility({ visible: { cancelButton } });
    await SelectTranslationsTestUtils.waitForPanelPopupEvent(
      "popuphidden",
      () => {
        click(cancelButton, "Clicking the cancel button");
      }
    );
  }

  /**
   * Simulates clicking the copy button and asserts that all relevant states are correctly updated.
   */
  static async clickCopyButton() {
    logAction();
    const { copyButton } = SelectTranslationsPanel.elements;

    assertVisibility({ visible: { copyButton } });
    is(
      SelectTranslationsPanel.phase(),
      "translated",
      'The copy button should only be clickable in the "translated" phase'
    );

    click(copyButton, "Clicking the copy button");
    await waitForCondition(
      () =>
        copyButton.classList.contains("copied") &&
        copyButton.getAttribute("data-l10n-id") ===
          "select-translations-panel-copy-button-copied",
      "Waiting for copy button to match the copied state."
    );

    const copiedText = SpecialPowers.getClipboardData("text/plain");
    is(
      // Because of differences in the clipboard code on Windows, we are going
      // to explicitly sanitize carriage returns here when checking equality.
      copiedText.replaceAll("\r", ""),
      SelectTranslationsPanel.getTranslatedText().replaceAll("\r", ""),
      "The clipboard should contain the translated text."
    );
  }

  /**
   * Simulates clicking the Translate button in the SelectTranslationsPanel,
   * then waits for any pending translation effects, based on the provided options.
   *
   * @param {object} config
   * @param {Function} [config.downloadHandler]
   *  - The function handle expected downloads, resolveDownloads() or rejectDownloads()
   *    Leave as null to test more granularly, such as testing opening the loading view,
   *    or allowing for the automatic downloading of files.
   * @param {boolean} [config.pivotTranslation]
   *  - True if the expected translation is a pivot translation, otherwise false.
   *    Affects the number of expected downloads.
   * @param {Function} [config.viewAssertion]
   *  - An optional callback function to execute for asserting the panel UI state.
   */
  static async clickTranslateButton({
    downloadHandler,
    pivotTranslation,
    viewAssertion,
  }) {
    logAction();
    const {
      doneButtonSecondary,
      settingsButton,
      translateButton,
      tryAnotherSourceMenuList,
    } = SelectTranslationsPanel.elements;
    assertVisibility({ visible: { doneButtonPrimary: translateButton } });

    ok(!translateButton.disabled, "The translate button should be enabled.");
    SharedTranslationsTestUtils._assertTabIndexOrder([
      settingsButton,
      tryAnotherSourceMenuList,
      ...(AppConstants.platform === "win"
        ? [translateButton, doneButtonSecondary]
        : [doneButtonSecondary, translateButton]),
    ]);

    click(translateButton);
    await SelectTranslationsTestUtils.waitForPanelState("translatable");
    if (downloadHandler) {
      await this.handleDownloads({ downloadHandler, pivotTranslation });
    }
    if (viewAssertion) {
      await viewAssertion();
    }
  }

  /**
   * Simulates clicking the translate-full-page button.
   */
  static async clickTranslateFullPageButton() {
    logAction();
    const { translateFullPageButton } = SelectTranslationsPanel.elements;
    assertVisibility({ visible: { translateFullPageButton } });
    click(translateFullPageButton);
    await FullPageTranslationsTestUtils.assertTranslationsButton(
      { button: true, circleArrows: true, locale: false, icon: true },
      "The icon presents the loading indicator."
    );
  }

  /**
   * Simulates clicking the try-again button.
   *
   * @param {object} config
   * @param {Function} [config.downloadHandler]
   *  - The function handle expected downloads, resolveDownloads() or rejectDownloads()
   *    Leave as null to test more granularly, such as testing opening the loading view,
   *    or allowing for the automatic downloading of files.
   * @param {boolean} [config.pivotTranslation]
   *  - True if the expected translation is a pivot translation, otherwise false.
   *    Affects the number of expected downloads.
   * @param {Function} [config.viewAssertion]
   *  - An optional callback function to execute for asserting the panel UI state.
   */
  static async clickTryAgainButton({
    downloadHandler,
    pivotTranslation,
    viewAssertion,
  } = {}) {
    logAction();
    const { tryAgainButton } = SelectTranslationsPanel.elements;
    assertVisibility({ visible: { tryAgainButton } });
    click(tryAgainButton, "Clicking the try-again button");
    await SelectTranslationsTestUtils.waitForPanelState("translatable");
    if (downloadHandler) {
      await this.handleDownloads({ downloadHandler, pivotTranslation });
    }
    if (viewAssertion) {
      await viewAssertion();
    }
  }

  /**
   * Opens the SelectTranslationsPanel settings menu.
   * Requires that the translations panel is already open.
   */
  static async openPanelSettingsMenu() {
    logAction();
    const { settingsButton } = SelectTranslationsPanel.elements;
    assertVisibility({ visible: { settingsButton } });
    await SharedTranslationsTestUtils._waitForPopupEvent(
      "select-translations-panel-settings-menupopup",
      "popupshown",
      () => click(settingsButton, "Opening the settings menu")
    );
    const settingsPageMenuItem = document.getElementById(
      "select-translations-panel-open-settings-page-menuitem"
    );
    const aboutTranslationsMenuItem = document.getElementById(
      "select-translations-panel-about-translations-menuitem"
    );

    assertVisibility({
      visible: {
        settingsPageMenuItem,
        aboutTranslationsMenuItem,
      },
    });
  }

  /**
   * Clicks the SelectTranslationsPanel settings menu item
   * that leads to the Translations Settings in about:preferences.
   */
  static clickTranslationsSettingsPageMenuItem() {
    logAction();
    const settingsPageMenuItem = document.getElementById(
      "select-translations-panel-open-settings-page-menuitem"
    );
    assertVisibility({ visible: { settingsPageMenuItem } });
    click(settingsPageMenuItem);
  }

  /**
   * Opens the context menu at a specified element on the page, based on the provided options.
   *
   * @param {Function} runInPage - A content-exposed function to run within the context of the page.
   * @param {object} options - Options for opening the context menu.
   *
   * @param {boolean} options.expectMenuItemVisible - Whether the select-translations menu item should be present in the context menu.
   * @param {boolean} options.expectedTargetLanguage - The expected target language to be shown in the context menu.
   *
   * The following options will work on all test pages that have an <h1> element.
   *
   * @param {boolean} options.selectH1 - Selects the first H1 element of the page.
   * @param {boolean} options.openAtH1 - Opens the context menu at the first H1 element of the page.
   *
   * The following options will work only in the PDF_TEST_PAGE_URL.
   *
   * @param {boolean} options.selectPdfSpan - Selects the first span of text on the first page of a pdf.
   * @param {boolean} options.openAtPdfSpan - Opens the context menu at the first span of text on the first page of a pdf.
   *
   * The following options will only work when testing SELECT_TEST_PAGE_URL.
   *
   * @param {boolean} options.selectFrenchSection - Selects the section of French text.
   * @param {boolean} options.selectEnglishSection - Selects the section of English text.
   * @param {boolean} options.selectSpanishSection - Selects the section of Spanish text.
   * @param {boolean} options.selectFrenchSentence - Selects a French sentence.
   * @param {boolean} options.selectEnglishSentence - Selects an English sentence.
   * @param {boolean} options.selectSpanishSentence - Selects a Spanish sentence.
   * @param {boolean} options.openAtFrenchSection - Opens the context menu at the section of French text.
   * @param {boolean} options.openAtEnglishSection - Opens the context menu at the section of English text.
   * @param {boolean} options.openAtSpanishSection - Opens the context menu at the section of Spanish text.
   * @param {boolean} options.openAtFrenchSentence - Opens the context menu at a French sentence.
   * @param {boolean} options.openAtEnglishSentence - Opens the context menu at an English sentence.
   * @param {boolean} options.openAtSpanishSentence - Opens the context menu at a Spanish sentence.
   * @param {boolean} options.openAtFrenchHyperlink - Opens the context menu at a hyperlinked French text.
   * @param {boolean} options.openAtEnglishHyperlink - Opens the context menu at an hyperlinked English text.
   * @param {boolean} options.openAtSpanishHyperlink - Opens the context menu at a hyperlinked Spanish text.
   * @throws Throws an error if no valid option was provided for opening the menu.
   */
  static async openContextMenu(runInPage, options) {
    logAction();

    const maybeSelectContentFrom = async keyword => {
      const conditionVariableName = `select${keyword}`;
      const selectorFunctionName = `get${keyword}`;

      if (options[conditionVariableName]) {
        await runInPage(
          async (TranslationsTest, data) => {
            const selectorFunction =
              TranslationsTest.getSelectors()[data.selectorFunctionName];
            if (typeof selectorFunction === "function") {
              const element = await selectorFunction();
              TranslationsTest.selectContentElement(element);
            }
          },
          { selectorFunctionName }
        );
      }
    };

    await maybeSelectContentFrom("H1");
    await maybeSelectContentFrom("PdfSpan");
    await maybeSelectContentFrom("FrenchSection");
    await maybeSelectContentFrom("EnglishSection");
    await maybeSelectContentFrom("SpanishSection");
    await maybeSelectContentFrom("FrenchSentence");
    await maybeSelectContentFrom("EnglishSentence");
    await maybeSelectContentFrom("SpanishSentence");

    const maybeOpenContextMenuAt = async keyword => {
      const optionVariableName = `openAt${keyword}`;
      const selectorFunctionName = `get${keyword}`;

      if (options[optionVariableName]) {
        await SharedTranslationsTestUtils._waitForPopupEvent(
          "contentAreaContextMenu",
          "popupshown",
          async () => {
            await runInPage(
              async (TranslationsTest, data) => {
                const selectorFunction =
                  TranslationsTest.getSelectors()[data.selectorFunctionName];
                if (typeof selectorFunction === "function") {
                  const element = await selectorFunction();
                  await TranslationsTest.rightClickContentElement(element);
                }
              },
              { selectorFunctionName }
            );
          }
        );
      }
    };

    await maybeOpenContextMenuAt("H1");
    await maybeOpenContextMenuAt("PdfSpan");
    await maybeOpenContextMenuAt("FrenchSection");
    await maybeOpenContextMenuAt("EnglishSection");
    await maybeOpenContextMenuAt("SpanishSection");
    await maybeOpenContextMenuAt("FrenchSentence");
    await maybeOpenContextMenuAt("EnglishSentence");
    await maybeOpenContextMenuAt("SpanishSentence");
    await maybeOpenContextMenuAt("FrenchHyperlink");
    await maybeOpenContextMenuAt("EnglishHyperlink");
    await maybeOpenContextMenuAt("SpanishHyperlink");
  }

  /**
   * Handles language-model downloads for the SelectTranslationsPanel, ensuring that expected
   * UI states match based on the resolved download state.
   *
   * @param {object} options - Configuration options for downloads.
   * @param {function(number): Promise<void>} options.downloadHandler - The function to resolve or reject the downloads.
   * @param {boolean} [options.pivotTranslation] - Whether to expect a pivot translation.
   *
   * @returns {Promise<void>}
   */
  static async handleDownloads({ downloadHandler, pivotTranslation }) {
    if (downloadHandler) {
      await SelectTranslationsTestUtils.assertPanelViewActivelyTranslating();
      await downloadHandler(pivotTranslation ? 2 : 1);
    }
  }

  /**
   * Switches the selected from-language to the provided language tags
   *
   * @param {string[]} langTags - An array of BCP-47 language tags.
   * @param {object} options - Configuration options for the language change.
   * @param {boolean} options.openDropdownMenu - Determines whether the language change should be made via a dropdown menu or directly.
   *
   * @returns {Promise<void>}
   */
  static async changeSelectedFromLanguage(langTags, options) {
    logAction(langTags);
    const { fromMenuList, fromMenuPopup } = SelectTranslationsPanel.elements;
    const { openDropdownMenu } = options;

    const switchFn = openDropdownMenu
      ? SelectTranslationsTestUtils.#changeSelectedLanguageViaDropdownMenu
      : SelectTranslationsTestUtils.#changeSelectedLanguageDirectly;

    await switchFn(
      langTags,
      { menuList: fromMenuList, menuPopup: fromMenuPopup },
      options
    );
  }

  /**
   * Change the selected language in the try-another-source-language dropdown.
   *
   * @param {string} langTag - A BCP-47 language tag.
   */
  static async changeSelectedTryAnotherSourceLanguage(langTag) {
    logAction(langTag);
    const { tryAnotherSourceMenuList, translateButton } =
      SelectTranslationsPanel.elements;
    await SelectTranslationsTestUtils.#changeSelectedLanguageDirectly(
      [langTag],
      { menuList: tryAnotherSourceMenuList },
      {
        onChangeLanguage: () =>
          ok(
            !translateButton.disabled,
            "The translate button should be enabled after selecting a language."
          ),
      }
    );
  }

  /**
   * Switches the selected to-language to the provided language tag.
   *
   * @param {string[]} langTags - An array of BCP-47 language tags.
   * @param {object} options - Options for selecting paragraphs and opening the context menu.
   * @param {boolean} options.openDropdownMenu - Determines whether the language change should be made via a dropdown menu or directly.
   * @param {Function} options.downloadHandler - Handler for initiating downloads post language change, if applicable.
   * @param {Function} options.onChangeLanguage - Callback function to be executed after the language change.
   *
   * @returns {Promise<void>}
   */
  static async changeSelectedToLanguage(langTags, options) {
    logAction(langTags);
    const { toMenuList, toMenuPopup } = SelectTranslationsPanel.elements;
    const { openDropdownMenu } = options;

    const switchFn = openDropdownMenu
      ? SelectTranslationsTestUtils.#changeSelectedLanguageViaDropdownMenu
      : SelectTranslationsTestUtils.#changeSelectedLanguageDirectly;

    await switchFn(
      langTags,
      { menuList: toMenuList, menuPopup: toMenuPopup },
      options
    );
  }

  /**
   * Directly changes the selected language to each provided language tag without using a dropdown menu.
   *
   * @param {string[]} langTags - An array of BCP-47 language tags for direct selection.
   * @param {object} elements - Elements required for changing the selected language.
   * @param {Element} elements.menuList - The menu list element where languages are directly changed.
   * @param {object} options - Configuration options for language change and additional actions.
   * @param {Function} options.downloadHandler - Handler for initiating downloads post language change, if applicable.
   * @param {Function} options.onChangeLanguage - Callback function to be executed after the language change.
   *
   * @returns {Promise<void>}
   */
  static async #changeSelectedLanguageDirectly(langTags, elements, options) {
    const { menuList } = elements;
    const { textArea } = SelectTranslationsPanel.elements;
    const { onChangeLanguage, downloadHandler } = options;

    for (const langTag of langTags) {
      const menuListUpdated = BrowserTestUtils.waitForMutationCondition(
        menuList,
        { attributes: true, attributeFilter: ["value"] },
        () => menuList.value === langTag
      );

      menuList.focus();
      menuList.value = langTag;
      menuList.dispatchEvent(new Event("command"));
      await menuListUpdated;
    }

    // Either of these events should trigger a translation after the selected
    // language has been changed directly.
    if (Math.random() < 0.5) {
      info("Attempting to trigger translation via text-area focus.");
      textArea.focus();
    } else {
      info("Attempting to trigger translation via pressing Enter.");
      EventUtils.synthesizeKey("KEY_Enter");
    }

    if (downloadHandler) {
      await SelectTranslationsTestUtils.handleDownloads(options);
    }

    if (onChangeLanguage) {
      await onChangeLanguage();
    }
  }

  /**
   * Changes the selected language by opening the dropdown menu for each provided language tag.
   *
   * @param {string[]} langTags - An array of BCP-47 language tags for selection via dropdown.
   * @param {object} elements - Elements involved in the dropdown language selection process.
   * @param {Element} elements.menuList - The element that triggers the dropdown menu.
   * @param {Element} elements.menuPopup - The dropdown menu element containing selectable languages.
   * @param {object} options - Configuration options for language change and additional actions.
   * @param {Function} options.downloadHandler - Handler for initiating downloads post language change, if applicable.
   * @param {Function} options.onChangeLanguage - Callback function to be executed after the language change.
   *
   * @returns {Promise<void>}
   */
  static async #changeSelectedLanguageViaDropdownMenu(
    langTags,
    elements,
    options
  ) {
    const { menuList, menuPopup } = elements;
    const { onChangeLanguage } = options;
    for (const langTag of langTags) {
      await SelectTranslationsTestUtils.waitForPanelPopupEvent(
        "popupshown",
        () => click(menuList)
      );

      const menuItem = menuPopup.querySelector(`[value="${langTag}"]`);
      await SelectTranslationsTestUtils.waitForPanelPopupEvent(
        "popuphidden",
        () => {
          click(menuItem);
          // Synthesizing a click on the menuitem isn't closing the popup
          // as a click normally would, so this tab keypress is added to
          // ensure the popup closes.
          EventUtils.synthesizeKey("KEY_Tab");
        }
      );

      await SelectTranslationsTestUtils.handleDownloads(options);
      if (onChangeLanguage) {
        await onChangeLanguage();
      }
    }
  }

  /**
   * Opens the Select Translations panel via the context menu based on specified options.
   *
   * @param {Function} runInPage - A content-exposed function to run within the context of the page.
   * @param {object} options - Options for selecting paragraphs and opening the context menu.
   *
   * The following options will only work when testing SELECT_TEST_PAGE_URL.
   *
   * @param {string}  options.expectedFromLanguage - The expected from-language tag.
   * @param {string}  options.expectedToLanguage - The expected to-language tag.
   * @param {boolean} options.selectFrenchSection - Selects the section of French text.
   * @param {boolean} options.selectEnglishSection - Selects the section of English text.
   * @param {boolean} options.selectSpanishSection - Selects the section of Spanish text.
   * @param {boolean} options.selectFrenchSentence - Selects a French sentence.
   * @param {boolean} options.selectEnglishSentence - Selects an English sentence.
   * @param {boolean} options.selectSpanishSentence - Selects a Spanish sentence.
   * @param {boolean} options.openAtFrenchSection - Opens the context menu at the section of French text.
   * @param {boolean} options.openAtEnglishSection - Opens the context menu at the section of English text.
   * @param {boolean} options.openAtSpanishSection - Opens the context menu at the section of Spanish text.
   * @param {boolean} options.openAtFrenchSentence - Opens the context menu at a French sentence.
   * @param {boolean} options.openAtEnglishSentence - Opens the context menu at an English sentence.
   * @param {boolean} options.openAtSpanishSentence - Opens the context menu at a Spanish sentence.
   * @param {boolean} options.openAtFrenchHyperlink - Opens the context menu at a hyperlinked French text.
   * @param {boolean} options.openAtEnglishHyperlink - Opens the context menu at an hyperlinked English text.
   * @param {boolean} options.openAtSpanishHyperlink - Opens the context menu at a hyperlinked Spanish text.
   * @param {Function} [options.onOpenPanel] - An optional callback function to execute after the panel opens.
   * @param {string|null} [message] - An optional message to log to info.
   * @throws Throws an error if the context menu could not be opened with the provided options.
   * @returns {Promise<void>}
   */
  static async openPanel(runInPage, options, message) {
    logAction();

    if (message) {
      info(message);
    }

    await SelectTranslationsTestUtils.assertContextMenuTranslateSelectionItem(
      runInPage,
      options,
      message
    );

    const menuItem = getById("context-translate-selection");

    await SelectTranslationsTestUtils.waitForPanelPopupEvent(
      "popupshown",
      async () => {
        click(menuItem);
        await closeContextMenuIfOpen();
      },
      async () => {
        const { onOpenPanel } = options;
        await SelectTranslationsTestUtils.handleDownloads(options);
        if (onOpenPanel) {
          await onOpenPanel();
        }
      }
    );

    const { expectedFromLanguage, expectedToLanguage } = options;
    if (expectedFromLanguage !== undefined) {
      SelectTranslationsTestUtils.assertSelectedFromLanguage(
        expectedFromLanguage
      );
    }
    if (expectedToLanguage !== undefined) {
      SelectTranslationsTestUtils.assertSelectedToLanguage(expectedToLanguage);
    }
  }

  /**
   * XUL popups will fire the popupshown and popuphidden events. These will fire for
   * any type of popup in the browser. This function waits for one of those events, and
   * checks that the viewId of the popup is PanelUI-profiler
   *
   * @param {"popupshown" | "popuphidden"} eventName
   * @param {Function} callback
   * @param {Function} postEventAssertion
   *   An optional assertion to be made immediately after the event occurs.
   * @returns {Promise<void>}
   */
  static async waitForPanelPopupEvent(
    eventName,
    callback,
    postEventAssertion = null
  ) {
    // De-lazify the panel elements.
    SelectTranslationsPanel.elements;
    await SharedTranslationsTestUtils._waitForPopupEvent(
      "select-translations-panel",
      eventName,
      callback,
      postEventAssertion
    );
  }
}

class TranslationsSettingsTestUtils {
  /**
   * Opens the Translation Settings page by clicking the settings button sent in the argument.
   *
   * @param  {HTMLElement} settingsButton
   * @returns {Element}
   */
  static async openAboutPreferencesTranslationsSettingsPane(settingsButton) {
    const document = gBrowser.selectedBrowser.contentDocument;

    const promise = BrowserTestUtils.waitForEvent(
      document,
      "paneshown",
      false,
      event => event.detail.category === "paneTranslations"
    );

    click(settingsButton, "Click settings button");
    await promise;

    const elements = {
      backButton: document.getElementById("translations-settings-back-button"),
      header: document.getElementById("translations-settings-header"),
      translationsSettingsDescription: document.getElementById(
        "translations-settings-description"
      ),
      translateAlwaysHeader: document.getElementById(
        "translations-settings-always-translate"
      ),
      translateNeverHeader: document.getElementById(
        "translations-settings-never-translate"
      ),
      translateAlwaysMenuList: document.getElementById(
        "translations-settings-always-translate-list"
      ),
      translateNeverMenuList: document.getElementById(
        "translations-settings-never-translate-list"
      ),
      translateNeverSiteHeader: document.getElementById(
        "translations-settings-never-sites-header"
      ),
      translateNeverSiteDesc: document.getElementById(
        "translations-settings-never-sites"
      ),
      translateDownloadLanguagesHeader: document
        .getElementById("translations-settings-download-section")
        .querySelector("h2"),
      translateDownloadLanguagesLearnMore: document.getElementById(
        "download-languages-learn-more"
      ),
      translateDownloadLanguagesList: document.getElementById(
        "translations-settings-download-section"
      ),
    };

    return elements;
  }
}
