tor-browser

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

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:
Mtoolkit/components/translations/actors/AboutTranslationsChild.sys.mjs | 10++++++++++
Mtoolkit/components/translations/content/about-translations.mjs | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtoolkit/components/translations/tests/browser/browser.toml | 2++
Atoolkit/components/translations/tests/browser/browser_about_translations_copy_button_functionality.js | 320+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtoolkit/components/translations/tests/browser/shared-head.js | 243++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mtoolkit/locales/en-US/toolkit/about/aboutTranslations.ftl | 5+++++
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