commit 57d3feae5d6f71d9b7aa0e05b9f2e516ce662d2c
parent 580540cbec4f4dc49864ea96d575f9c89092e4b1
Author: Erik Nordin <enordin@mozilla.com>
Date: Tue, 23 Dec 2025 16:05:28 +0000
Bug 1992232 - Part 6/6: Implement Copy Button Functionality r=translations-reviewers,fluent-reviewers,bolsson,gregtatum
This commit implements the logic for clicking the target-section copy
button on the about:translations page. Invoking the copy button will
copy the translated text to the clipboard, and visually show a "copied"
state on the button for a duration of time before resetting.
Differential Revision: https://phabricator.services.mozilla.com/D276138
Diffstat:
6 files changed, 712 insertions(+), 11 deletions(-)
diff --git a/toolkit/components/translations/actors/AboutTranslationsChild.sys.mjs b/toolkit/components/translations/actors/AboutTranslationsChild.sys.mjs
@@ -137,6 +137,7 @@ export class AboutTranslationsChild extends JSWindowActorChild {
"AT_getSupportedLanguages",
"AT_isTranslationEngineSupported",
"AT_isHtmlTranslation",
+ "AT_isInAutomation",
"AT_createTranslationsPort",
"AT_identifyLanguage",
"AT_getDisplayName",
@@ -230,6 +231,15 @@ export class AboutTranslationsChild extends JSWindowActorChild {
}
/**
+ * Returns true if we are running tests in automation, otherwise false.
+ *
+ * @returns {boolean}
+ */
+ AT_isInAutomation() {
+ return Cu.isInAutomation;
+ }
+
+ /**
* Requests a port to the TranslationsEngine process. An engine will be created on
* the fly for translation requests through this port. This port is unique to its
* language pair. In order to translate a different language pair, a new port must be
diff --git a/toolkit/components/translations/content/about-translations.mjs b/toolkit/components/translations/content/about-translations.mjs
@@ -8,7 +8,8 @@
/* global AT_getAppLocale, AT_getSupportedLanguages, AT_log, AT_getScriptDirection,
AT_getDisplayName, AT_logError, AT_createTranslationsPort, AT_isHtmlTranslation,
- AT_isTranslationEngineSupported, AT_identifyLanguage, AT_openSupportPage, AT_telemetry */
+ AT_isTranslationEngineSupported, AT_isInAutomation, AT_identifyLanguage,
+ AT_openSupportPage, AT_telemetry */
import { Translator } from "chrome://global/content/translations/Translator.mjs";
@@ -18,6 +19,22 @@ import { Translator } from "chrome://global/content/translations/Translator.mjs"
window.DEBOUNCE_DELAY = 200;
/**
+ * The default duration, in milliseconds, that the copy button remains in the "copied" state
+ * before reverting back to its default state.
+ */
+window.COPY_BUTTON_RESET_DELAY = 1500;
+
+/**
+ * Tests can set this to true to manually trigger copy button resets.
+ *
+ * When enabled, the copy button will remain in its copied state until tests
+ * call {@link AboutTranslations.testResetCopyButton}.
+ *
+ * @type {boolean}
+ */
+window.testManualCopyButtonReset = false;
+
+/**
* Limits how long the "text" parameter can be in the URL.
*/
const URL_MAX_TEXT_LENGTH = 5000;
@@ -95,6 +112,13 @@ class AboutTranslations {
#readyPromiseWithResolvers = Promise.withResolvers();
/**
+ * A timeout id for resetting the copy button's "copied" state.
+ *
+ * @type {number | null}
+ */
+ #copyButtonResetTimeoutId = null;
+
+ /**
* The orientation of the page's content.
*
* When the page orientation is horizontal the source and target sections
@@ -335,6 +359,7 @@ class AboutTranslations {
*/
#initializeEventListeners() {
const {
+ copyButton,
learnMoreLink,
sourceLanguageSelector,
sourceSectionTextArea,
@@ -344,6 +369,7 @@ class AboutTranslations {
targetSectionTextArea,
} = this.elements;
+ copyButton.addEventListener("click", this.#onCopyButton);
learnMoreLink.addEventListener("click", this.#onLearnMoreLink);
sourceLanguageSelector.addEventListener(
"input",
@@ -450,6 +476,33 @@ class AboutTranslations {
};
/**
+ * Handles copying the translated text to the clipboard when the copy button is invoked.
+ */
+ #onCopyButton = async () => {
+ const { copyButton, targetSectionTextArea } = this.elements;
+ if (copyButton.disabled) {
+ return;
+ }
+
+ const targetText = targetSectionTextArea.value;
+ if (!targetText) {
+ return;
+ }
+
+ try {
+ if (!navigator.clipboard?.writeText) {
+ throw new Error("Clipboard API is unavailable.");
+ }
+ await navigator.clipboard.writeText(targetText);
+ } catch (error) {
+ AT_logError(error);
+ return;
+ }
+
+ this.#showCopyButtonCopiedState();
+ };
+
+ /**
* Handles resize events, including resizing the window and zooming.
*/
#onResize = () => {
@@ -746,16 +799,102 @@ class AboutTranslations {
#setCopyButtonEnabled(shouldEnable) {
const { copyButton } = this.elements;
+ if (copyButton.disabled !== shouldEnable) {
+ // The state isn't going to change: nothing to do.
+ return;
+ }
+
+ if (
+ this.#copyButtonResetTimeoutId !== null ||
+ copyButton.classList.contains("copied")
+ ) {
+ // When the copy button's enabled state changes while it is in the "copied" state,
+ // then we want to reset it immediately, instead of waiting for the timeout.
+ this.#resetCopyButton();
+ }
+
copyButton.disabled = !shouldEnable;
const eventName = shouldEnable
? "AboutTranslationsTest:CopyButtonEnabled"
: "AboutTranslationsTest:CopyButtonDisabled";
-
document.dispatchEvent(new CustomEvent(eventName));
}
/**
+ * Applies the "copied" state visuals to the copy button.
+ */
+ #showCopyButtonCopiedState() {
+ const { copyButton } = this.elements;
+
+ if (this.#copyButtonResetTimeoutId !== null) {
+ // If there was a previously set timeout id, then we need to clear it to restart the timer.
+ // This occurs when the button is clicked a subsequent time when it is already in the "copied" state.
+ window.clearTimeout(this.#copyButtonResetTimeoutId);
+ this.#copyButtonResetTimeoutId = null;
+ }
+
+ copyButton.classList.add("copied");
+ copyButton.iconSrc = "chrome://global/skin/icons/check.svg";
+
+ document.l10n.setAttributes(
+ copyButton,
+ "about-translations-copy-button-copied"
+ );
+ document.dispatchEvent(
+ new CustomEvent("AboutTranslationsTest:CopyButtonShowCopied")
+ );
+
+ if (!window.testManualCopyButtonReset) {
+ this.#copyButtonResetTimeoutId = window.setTimeout(() => {
+ this.#resetCopyButton();
+ }, window.COPY_BUTTON_RESET_DELAY);
+ }
+ }
+
+ /**
+ * Restores the copy button to its default visual state.
+ */
+ #resetCopyButton() {
+ if (this.#copyButtonResetTimeoutId !== null) {
+ window.clearTimeout(this.#copyButtonResetTimeoutId);
+ this.#copyButtonResetTimeoutId = null;
+ }
+
+ const { copyButton } = this.elements;
+ if (!copyButton.classList.contains("copied")) {
+ return;
+ }
+
+ copyButton.classList.remove("copied");
+ copyButton.iconSrc = "chrome://global/skin/icons/edit-copy.svg";
+
+ document.l10n.setAttributes(
+ copyButton,
+ "about-translations-copy-button-default"
+ );
+ document.dispatchEvent(
+ new CustomEvent("AboutTranslationsTest:CopyButtonReset")
+ );
+ }
+
+ /**
+ * Manually resets the state of the copy button.
+ * This function is only expected to be called by automated tests.
+ */
+ testResetCopyButton() {
+ if (!AT_isInAutomation()) {
+ throw new Error("Test-only function called outside of automation.");
+ }
+
+ if (!window.testManualCopyButtonReset) {
+ throw new Error("Unexpected call to testResetCopyButton.");
+ }
+
+ this.#resetCopyButton();
+ }
+
+ /**
* If the currently selected language pair is determined to be swappable,
* swaps the active source language with the active target language,
* and moves the translated output to be the new source text.
diff --git a/toolkit/components/translations/tests/browser/browser.toml b/toolkit/components/translations/tests/browser/browser.toml
@@ -26,6 +26,8 @@ support-files = [
["browser_about_translations_copy_button_enabled_states.js"]
+["browser_about_translations_copy_button_functionality.js"]
+
["browser_about_translations_debounce.js"]
skip-if = [
"os == 'linux' && os_version == '24.04' && arch == 'x86_64' && display == 'x11'", # Bug 1821461
diff --git a/toolkit/components/translations/tests/browser/browser_about_translations_copy_button_functionality.js b/toolkit/components/translations/tests/browser/browser_about_translations_copy_button_functionality.js
@@ -0,0 +1,320 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const languagePairs = [
+ { fromLang: "en", toLang: "fr" },
+ { fromLang: "fr", toLang: "en" },
+ { fromLang: "en", toLang: "de" },
+ { fromLang: "de", toLang: "en" },
+];
+
+add_task(async function test_copy_button_copies_text_and_resets() {
+ const { aboutTranslationsTestUtils, cleanup } = await openAboutTranslations({
+ languagePairs,
+ autoDownloadFromRemoteSettings: false,
+ requireManualCopyButtonReset: true,
+ });
+ await aboutTranslationsTestUtils.setSourceLanguageSelectorValue("en");
+ await aboutTranslationsTestUtils.setTargetLanguageSelectorValue("fr");
+
+ await aboutTranslationsTestUtils.setSourceTextAreaValue("Hello clipboard");
+ await aboutTranslationsTestUtils.assertEvents(
+ {
+ expected: [
+ [
+ AboutTranslationsTestUtils.Events.TranslationComplete,
+ AboutTranslationsTestUtils.AnyEventDetail,
+ ],
+ [AboutTranslationsTestUtils.Events.CopyButtonEnabled],
+ ],
+ },
+ async () => {
+ await aboutTranslationsTestUtils.resolveDownloads(1);
+ }
+ );
+
+ await aboutTranslationsTestUtils.assertTranslatedText({
+ sourceLanguage: "en",
+ targetLanguage: "fr",
+ sourceText: "Hello clipboard",
+ });
+ await aboutTranslationsTestUtils.assertCopyButton({
+ enabled: true,
+ copied: false,
+ l10nId: "about-translations-copy-button-default",
+ });
+
+ const expectedClipboardText =
+ await aboutTranslationsTestUtils.getTargetTextAreaValue();
+ SpecialPowers.clipboardCopyString("initial clipboard value");
+
+ await aboutTranslationsTestUtils.assertEvents(
+ {
+ expected: [[AboutTranslationsTestUtils.Events.CopyButtonShowCopied]],
+ },
+ async () => {
+ await aboutTranslationsTestUtils.clickCopyButton();
+ }
+ );
+ await aboutTranslationsTestUtils.assertCopyButton({
+ enabled: true,
+ copied: true,
+ l10nId: "about-translations-copy-button-copied",
+ });
+
+ await TestUtils.waitForCondition(
+ () =>
+ SpecialPowers.getClipboardData("text/plain") === expectedClipboardText,
+ "Waiting for the translated text to reach the clipboard."
+ );
+
+ await aboutTranslationsTestUtils.assertEvents(
+ {
+ expected: [[AboutTranslationsTestUtils.Events.CopyButtonReset]],
+ },
+ async () => {
+ await aboutTranslationsTestUtils.resetCopyButton();
+ }
+ );
+ await aboutTranslationsTestUtils.assertCopyButton({
+ enabled: true,
+ copied: false,
+ l10nId: "about-translations-copy-button-default",
+ });
+
+ await cleanup();
+});
+
+add_task(async function test_copy_button_reset_clears_copied_state() {
+ const { aboutTranslationsTestUtils, cleanup } = await openAboutTranslations({
+ languagePairs,
+ autoDownloadFromRemoteSettings: false,
+ requireManualCopyButtonReset: true,
+ });
+ await aboutTranslationsTestUtils.setSourceLanguageSelectorValue("en");
+ await aboutTranslationsTestUtils.setTargetLanguageSelectorValue("fr");
+
+ const sourceText = "Hello clipboard";
+ await aboutTranslationsTestUtils.setSourceTextAreaValue(sourceText);
+ await aboutTranslationsTestUtils.assertEvents(
+ {
+ expected: [
+ [
+ AboutTranslationsTestUtils.Events.TranslationComplete,
+ AboutTranslationsTestUtils.AnyEventDetail,
+ ],
+ [AboutTranslationsTestUtils.Events.CopyButtonEnabled],
+ ],
+ },
+ async () => {
+ await aboutTranslationsTestUtils.resolveDownloads(1);
+ }
+ );
+
+ await aboutTranslationsTestUtils.assertTranslatedText({
+ sourceLanguage: "en",
+ targetLanguage: "fr",
+ sourceText,
+ });
+
+ await aboutTranslationsTestUtils.assertEvents(
+ {
+ expected: [[AboutTranslationsTestUtils.Events.CopyButtonShowCopied]],
+ },
+ async () => {
+ await aboutTranslationsTestUtils.clickCopyButton();
+ }
+ );
+ await aboutTranslationsTestUtils.assertCopyButton({
+ enabled: true,
+ copied: true,
+ });
+
+ await aboutTranslationsTestUtils.assertEvents(
+ {
+ expected: [[AboutTranslationsTestUtils.Events.CopyButtonShowCopied]],
+ },
+ async () => {
+ await aboutTranslationsTestUtils.clickCopyButton();
+ }
+ );
+ await aboutTranslationsTestUtils.assertCopyButton({
+ enabled: true,
+ copied: true,
+ });
+
+ await aboutTranslationsTestUtils.assertEvents(
+ {
+ expected: [[AboutTranslationsTestUtils.Events.CopyButtonReset]],
+ },
+ async () => {
+ await aboutTranslationsTestUtils.resetCopyButton();
+ }
+ );
+ await aboutTranslationsTestUtils.assertCopyButton({
+ enabled: true,
+ copied: false,
+ });
+
+ await aboutTranslationsTestUtils.assertEvents(
+ {
+ expected: [[AboutTranslationsTestUtils.Events.CopyButtonShowCopied]],
+ },
+ async () => {
+ await aboutTranslationsTestUtils.clickCopyButton();
+ }
+ );
+ await aboutTranslationsTestUtils.assertCopyButton({
+ enabled: true,
+ copied: true,
+ });
+
+ await cleanup();
+});
+
+add_task(async function test_copy_button_reset_when_target_language_changes() {
+ const { aboutTranslationsTestUtils, cleanup } = await openAboutTranslations({
+ languagePairs,
+ autoDownloadFromRemoteSettings: false,
+ requireManualCopyButtonReset: true,
+ });
+ await aboutTranslationsTestUtils.setSourceLanguageSelectorValue("en");
+ await aboutTranslationsTestUtils.setTargetLanguageSelectorValue("fr");
+
+ const sourceText = "Hello clipboard";
+ await aboutTranslationsTestUtils.setSourceTextAreaValue(sourceText);
+ await aboutTranslationsTestUtils.assertEvents(
+ {
+ expected: [
+ [
+ AboutTranslationsTestUtils.Events.TranslationComplete,
+ AboutTranslationsTestUtils.AnyEventDetail,
+ ],
+ [AboutTranslationsTestUtils.Events.CopyButtonEnabled],
+ ],
+ },
+ async () => {
+ await aboutTranslationsTestUtils.resolveDownloads(1);
+ }
+ );
+
+ await aboutTranslationsTestUtils.assertTranslatedText({
+ sourceLanguage: "en",
+ targetLanguage: "fr",
+ sourceText,
+ });
+
+ await aboutTranslationsTestUtils.assertEvents(
+ {
+ expected: [[AboutTranslationsTestUtils.Events.CopyButtonShowCopied]],
+ },
+ async () => {
+ await aboutTranslationsTestUtils.clickCopyButton();
+ }
+ );
+ await aboutTranslationsTestUtils.assertCopyButton({
+ enabled: true,
+ copied: true,
+ });
+
+ await aboutTranslationsTestUtils.assertEvents(
+ {
+ expected: [
+ [AboutTranslationsTestUtils.Events.CopyButtonReset],
+ [AboutTranslationsTestUtils.Events.CopyButtonDisabled],
+ ],
+ },
+ async () => {
+ await aboutTranslationsTestUtils.setTargetLanguageSelectorValue("de");
+ }
+ );
+ await aboutTranslationsTestUtils.assertCopyButton({
+ enabled: false,
+ copied: false,
+ l10nId: "about-translations-copy-button-default",
+ });
+
+ await aboutTranslationsTestUtils.assertEvents(
+ {
+ expected: [
+ [
+ AboutTranslationsTestUtils.Events.TranslationComplete,
+ AboutTranslationsTestUtils.AnyEventDetail,
+ ],
+ [AboutTranslationsTestUtils.Events.CopyButtonEnabled],
+ ],
+ },
+ async () => {
+ await aboutTranslationsTestUtils.resolveDownloads(1);
+ }
+ );
+ await aboutTranslationsTestUtils.assertTranslatedText({
+ sourceLanguage: "en",
+ targetLanguage: "de",
+ sourceText,
+ });
+ await aboutTranslationsTestUtils.assertCopyButton({
+ enabled: true,
+ copied: false,
+ l10nId: "about-translations-copy-button-default",
+ });
+
+ await cleanup();
+});
+
+add_task(async function test_copy_button_reset_timeout_fires_event() {
+ const { aboutTranslationsTestUtils, cleanup } = await openAboutTranslations({
+ languagePairs,
+ autoDownloadFromRemoteSettings: false,
+ copyButtonResetDelay: 200,
+ });
+ await aboutTranslationsTestUtils.setSourceLanguageSelectorValue("en");
+ await aboutTranslationsTestUtils.setTargetLanguageSelectorValue("fr");
+
+ await aboutTranslationsTestUtils.setSourceTextAreaValue("Timeout reset");
+ await aboutTranslationsTestUtils.assertEvents(
+ {
+ expected: [
+ [
+ AboutTranslationsTestUtils.Events.TranslationComplete,
+ AboutTranslationsTestUtils.AnyEventDetail,
+ ],
+ [AboutTranslationsTestUtils.Events.CopyButtonEnabled],
+ ],
+ },
+ async () => {
+ await aboutTranslationsTestUtils.resolveDownloads(1);
+ }
+ );
+
+ await aboutTranslationsTestUtils.assertCopyButton({
+ enabled: true,
+ copied: false,
+ l10nId: "about-translations-copy-button-default",
+ });
+
+ SpecialPowers.clipboardCopyString("initial clipboard value");
+ const resetEventPromise = aboutTranslationsTestUtils.waitForEvent(
+ AboutTranslationsTestUtils.Events.CopyButtonReset
+ );
+
+ await aboutTranslationsTestUtils.assertEvents(
+ {
+ expected: [[AboutTranslationsTestUtils.Events.CopyButtonShowCopied]],
+ },
+ async () => {
+ await aboutTranslationsTestUtils.clickCopyButton();
+ }
+ );
+
+ await resetEventPromise;
+ await aboutTranslationsTestUtils.assertCopyButton({
+ enabled: true,
+ copied: false,
+ l10nId: "about-translations-copy-button-default",
+ });
+
+ await cleanup();
+});
diff --git a/toolkit/components/translations/tests/browser/shared-head.js b/toolkit/components/translations/tests/browser/shared-head.js
@@ -151,19 +151,48 @@ async function loadNewPage(browser, url) {
* The mochitest runs in the parent process. This function opens up a new tab,
* opens up about:translations, and passes the test requirements into the content process.
*
+ * @param {object} [options={}]
+ * @param {boolean} [options.disabled]
+ * When true, ensures that Translations is disabled via pref before opening the page.
+ * @param {Array<{fromLang: string, toLang: string}>} [options.languagePairs=LANGUAGE_PAIRS]
+ * Language pairs that should be available in Remote Settings mocks.
+ * @param {Array<[string, any]>} [options.prefs]
+ * Preference tuples to push before the page loads.
+ * @param {boolean} [options.autoDownloadFromRemoteSettings=false]
+ * When true, Remote Settings downloads resolve automatically.
+ * When false, resolveDownloads or rejectDownloads must be manually called.
+ * @param {number} [options.copyButtonResetDelay]
+ * Overrides the copy button reset timeout ms to be shorter for testing.
+ * @param {boolean} [options.requireManualCopyButtonReset]
+ * When true, copy button resets must be triggered manually by tests.
+ * @returns {Promise<{
+ * aboutTranslationsTestUtils: AboutTranslationsTestUtils,
+ * cleanup: () => Promise<void>
+ * }>}
*/
async function openAboutTranslations({
disabled,
languagePairs = LANGUAGE_PAIRS,
prefs,
autoDownloadFromRemoteSettings = false,
+ copyButtonResetDelay,
+ requireManualCopyButtonReset,
} = {}) {
+ if (
+ copyButtonResetDelay !== undefined &&
+ requireManualCopyButtonReset !== undefined
+ ) {
+ throw new Error(
+ "copyButtonResetDelay and requireManualCopyButtonReset cannot both be defined."
+ );
+ }
await SpecialPowers.pushPrefEnv({
set: [
// Enabled by default.
["browser.translations.enable", !disabled],
["browser.translations.logLevel", "All"],
["browser.translations.mostRecentTargetLanguages", ""],
+ ["dom.events.testing.asyncClipboard", true],
[USE_LEXICAL_SHORTLIST_PREF, false],
...(prefs ?? []),
],
@@ -247,13 +276,33 @@ async function openAboutTranslations({
autoDownloadFromRemoteSettings
);
+ let originalCopyButtonResetDelay;
+
if (!disabled) {
await aboutTranslationsTestUtils.waitForReady();
+
+ if (requireManualCopyButtonReset !== undefined) {
+ await aboutTranslationsTestUtils.setManualCopyButtonResetEnabled(
+ requireManualCopyButtonReset
+ );
+ } else if (copyButtonResetDelay !== undefined) {
+ originalCopyButtonResetDelay =
+ await aboutTranslationsTestUtils.getCopyButtonResetDelay();
+ await aboutTranslationsTestUtils.setCopyButtonResetDelay(
+ copyButtonResetDelay
+ );
+ }
}
return {
aboutTranslationsTestUtils,
async cleanup() {
+ await aboutTranslationsTestUtils.setManualCopyButtonResetEnabled(false);
+ if (originalCopyButtonResetDelay) {
+ await aboutTranslationsTestUtils.setCopyButtonResetDelay(
+ originalCopyButtonResetDelay
+ );
+ }
await loadBlankPage();
BrowserTestUtils.removeTab(tab);
@@ -4050,6 +4099,8 @@ async function destroyTranslationsEngine() {
}
class AboutTranslationsTestUtils {
+ static AnyEventDetail = Symbol("AboutTranslationsTestUtils.AnyEventDetail");
+
/**
* A collection of custom events that the about:translations document may dispatch.
*/
@@ -4122,6 +4173,20 @@ class AboutTranslationsTestUtils {
static CopyButtonDisabled = "AboutTranslationsTest:CopyButtonDisabled";
/**
+ * Event fired when the copy button shows the "copied" feedback state.
+ *
+ * @type {string}
+ */
+ static CopyButtonShowCopied = "AboutTranslationsTest:CopyButtonShowCopied";
+
+ /**
+ * Event fired when the copy button exits the "copied" feedback state.
+ *
+ * @type {string}
+ */
+ static CopyButtonReset = "AboutTranslationsTest:CopyButtonReset";
+
+ /**
* Event fired when the page layout changes.
*
* @type {string}
@@ -4402,6 +4467,85 @@ class AboutTranslationsTestUtils {
}
/**
+ * Overrides the duration that the copy button remains in its copied state.
+ *
+ * @param {number} ms
+ */
+ async setCopyButtonResetDelay(ms) {
+ try {
+ await this.#runInPage(
+ (_, { delayMs }) => {
+ const { window } = content;
+ Cu.waiveXrays(window).COPY_BUTTON_RESET_DELAY = delayMs;
+ },
+ { delayMs: ms }
+ );
+ } catch (error) {
+ AboutTranslationsTestUtils.#reportTestFailure(error);
+ }
+ }
+
+ /**
+ * Returns the current copy button reset delay applied within the page.
+ *
+ * @returns {Promise<number>}
+ */
+ async getCopyButtonResetDelay() {
+ try {
+ return await this.#runInPage(() => {
+ const { window } = content;
+ return Cu.waiveXrays(window).COPY_BUTTON_RESET_DELAY;
+ });
+ } catch (error) {
+ AboutTranslationsTestUtils.#reportTestFailure(error);
+ }
+
+ return NaN;
+ }
+
+ /**
+ * Enables or disables manual copy button resets for testing.
+ *
+ * When enabled, tests are expected to reset the copy button manually.
+ * When disabled (default), the copy button resets based on its reset timeout.
+ *
+ * @param {boolean} enabled
+ */
+ async setManualCopyButtonResetEnabled(enabled) {
+ logAction(enabled);
+ try {
+ await this.#runInPage(
+ (_, { enabled }) => {
+ const { window } = content;
+ Cu.waiveXrays(window).testManualCopyButtonReset = enabled;
+ },
+ { enabled }
+ );
+ } catch (error) {
+ AboutTranslationsTestUtils.#reportTestFailure(error);
+ }
+ }
+
+ /**
+ * Manually resets the copy button.
+ */
+ async resetCopyButton() {
+ logAction();
+ try {
+ await this.#runInPage(() => {
+ const { window } = content;
+ const aboutTranslations = Cu.waiveXrays(window).aboutTranslations;
+ if (!aboutTranslations) {
+ throw new Error("aboutTranslations instance is unavailable.");
+ }
+ aboutTranslations.testResetCopyButton();
+ });
+ } catch (error) {
+ AboutTranslationsTestUtils.#reportTestFailure(error);
+ }
+ }
+
+ /**
* Clicks the swap-languages button in the about:translations UI.
*/
async clickSwapLanguagesButton() {
@@ -4419,6 +4563,21 @@ class AboutTranslationsTestUtils {
}
/**
+ * Clicks the copy button in the about:translations UI.
+ */
+ async clickCopyButton() {
+ logAction();
+ try {
+ await this.#runInPage(selectors => {
+ const button = content.document.querySelector(selectors.copyButton);
+ button.click();
+ });
+ } catch (error) {
+ AboutTranslationsTestUtils.#reportTestFailure(error);
+ }
+ }
+
+ /**
* Waits for the specified AboutTranslations event to fire, then returns its detail payload.
* Rejects if the event doesn’t fire within the given time limit.
*
@@ -4498,6 +4657,9 @@ class AboutTranslationsTestUtils {
for (const [eventName, expectedDetail] of expected) {
const actualDetail = await expectedEventWaiters[eventName];
+ if (expectedDetail === AboutTranslationsTestUtils.AnyEventDetail) {
+ continue;
+ }
is(
JSON.stringify(actualDetail ?? {}),
JSON.stringify(expectedDetail ?? {}),
@@ -4925,31 +5087,58 @@ class AboutTranslationsTestUtils {
}
/**
- * Asserts properties of the copy button.
+ * Retrieves the current state of the copy button.
*
- * @param {object} options
- * @param {boolean} [options.visible=true]
- * @param {boolean} [options.enabled=false]
- * @returns {Promise<void>}
+ * @returns {Promise<{exists: boolean, isDisabled: boolean, isCopied: boolean, l10nId: string}>}
*/
- async assertCopyButton({ visible = true, enabled = false } = {}) {
+ async getCopyButtonState() {
await doubleRaf(document);
- let pageResult = {};
try {
- pageResult = await this.#runInPage(selectors => {
+ return await this.#runInPage(selectors => {
const { document } = content;
const button = document.querySelector(selectors.copyButton);
return {
exists: !!button,
isDisabled: button?.hasAttribute("disabled") ?? true,
+ isCopied: button?.classList.contains("copied") ?? false,
+ l10nId: button?.getAttribute("data-l10n-id") ?? "",
};
});
} catch (error) {
AboutTranslationsTestUtils.#reportTestFailure(error);
}
- const { exists, isDisabled } = pageResult;
+ return {
+ exists: false,
+ isDisabled: true,
+ isCopied: false,
+ l10nId: "",
+ };
+ }
+
+ /**
+ * Asserts properties of the copy button.
+ *
+ * @param {object} options
+ * @param {boolean} [options.visible=true]
+ * @param {boolean} [options.enabled=false]
+ * @param {boolean} [options.copied]
+ * @param {string} [options.l10nId]
+ * @returns {Promise<void>}
+ */
+ async assertCopyButton({
+ visible = true,
+ enabled = false,
+ copied,
+ l10nId,
+ } = {}) {
+ const {
+ exists,
+ isDisabled,
+ isCopied,
+ l10nId: actualL10nId,
+ } = await this.getCopyButtonState();
ok(exists, "Expected copy button to be present.");
@@ -4971,6 +5160,42 @@ class AboutTranslationsTestUtils {
ok(isDisabled, "Expected copy button to be disabled.");
}
}
+
+ if (copied !== undefined) {
+ if (copied) {
+ ok(isCopied, "Expected copy button to show the copied state.");
+ } else {
+ ok(!isCopied, "Expected copy button to show the default state.");
+ }
+ }
+
+ if (l10nId !== undefined) {
+ is(
+ actualL10nId,
+ l10nId,
+ `Expected copy button to use the "${l10nId}" localization id.`
+ );
+ }
+ }
+
+ /**
+ * Retrieves the current value of the target textarea.
+ *
+ * @returns {Promise<string>}
+ */
+ async getTargetTextAreaValue() {
+ await doubleRaf(document);
+ try {
+ return await this.#runInPage(selectors => {
+ const textarea = content.document.querySelector(
+ selectors.targetSectionTextArea
+ );
+ return textarea?.value ?? "";
+ });
+ } catch (error) {
+ AboutTranslationsTestUtils.#reportTestFailure(error);
+ }
+ return "";
}
/**
diff --git a/toolkit/locales/en-US/toolkit/about/aboutTranslations.ftl b/toolkit/locales/en-US/toolkit/about/aboutTranslations.ftl
@@ -44,6 +44,11 @@ about-translations-copy-button-default =
.label = Copy
.title = Copy translation
+# Button label shown after the translated output has been copied to the clipboard.
+about-translations-copy-button-copied =
+ .label = Copied
+ .title = Copy translation
+
# Text displayed on target-language selector when no language option is selected.
about-translations-select = Select language