summaryrefslogtreecommitdiffstats
path: root/browser/components/translation
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
commit36d22d82aa202bb199967e9512281e9a53db42c9 (patch)
tree105e8c98ddea1c1e4784a60a5a6410fa416be2de /browser/components/translation
parentInitial commit. (diff)
downloadfirefox-esr-upstream.tar.xz
firefox-esr-upstream.zip
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r--browser/components/translation/BingTranslator.jsm488
-rw-r--r--browser/components/translation/GoogleTranslator.jsm306
-rw-r--r--browser/components/translation/TranslationChild.jsm163
-rw-r--r--browser/components/translation/TranslationDocument.jsm679
-rw-r--r--browser/components/translation/TranslationParent.jsm455
-rw-r--r--browser/components/translation/YandexTranslator.jsm357
-rw-r--r--browser/components/translation/content/.eslintrc.js13
-rw-r--r--browser/components/translation/content/jar.mn5
-rw-r--r--browser/components/translation/content/microsoft-translator-attribution.pngbin0 -> 2220 bytes
-rw-r--r--browser/components/translation/content/moz.build7
-rw-r--r--browser/components/translation/moz.build21
-rw-r--r--browser/components/translation/test/bing.sjs250
-rw-r--r--browser/components/translation/test/browser.ini13
-rw-r--r--browser/components/translation/test/browser_translation_bing.js160
-rw-r--r--browser/components/translation/test/browser_translation_exceptions.js239
-rw-r--r--browser/components/translation/test/browser_translation_yandex.js134
-rw-r--r--browser/components/translation/test/browser_translations_settings.js384
-rw-r--r--browser/components/translation/test/fixtures/bug1022725-fr.html15
-rw-r--r--browser/components/translation/test/fixtures/result-da39a3ee5e.txt22
-rw-r--r--browser/components/translation/test/fixtures/result-yandex-d448894848.json8
-rw-r--r--browser/components/translation/test/yandex.sjs203
-rw-r--r--browser/components/translations/TranslationsTelemetry.sys.mjs19
-rw-r--r--browser/components/translations/content/translationsPanel.inc.xhtml149
-rw-r--r--browser/components/translations/content/translationsPanel.js1132
-rw-r--r--browser/components/translations/jar.mn7
-rw-r--r--browser/components/translations/metrics.yaml30
-rw-r--r--browser/components/translations/moz.build10
-rw-r--r--browser/components/translations/tests/browser/browser.ini19
-rw-r--r--browser/components/translations/tests/browser/browser_manage_languages.js195
-rw-r--r--browser/components/translations/tests/browser/browser_translations_panel_always_translate_language.js413
-rw-r--r--browser/components/translations/tests/browser/browser_translations_panel_basics.js94
-rw-r--r--browser/components/translations/tests/browser/browser_translations_panel_beta_langs.js66
-rw-r--r--browser/components/translations/tests/browser/browser_translations_panel_button.js80
-rw-r--r--browser/components/translations/tests/browser/browser_translations_panel_cancel.js32
-rw-r--r--browser/components/translations/tests/browser/browser_translations_panel_gear.js42
-rw-r--r--browser/components/translations/tests/browser/browser_translations_panel_never_translate_language.js343
-rw-r--r--browser/components/translations/tests/browser/browser_translations_panel_never_translate_site.js432
-rw-r--r--browser/components/translations/tests/browser/browser_translations_panel_retry.js80
-rw-r--r--browser/components/translations/tests/browser/browser_translations_panel_switch_languages.js97
-rw-r--r--browser/components/translations/tests/browser/browser_translations_telemetry_open_panel.js73
-rw-r--r--browser/components/translations/tests/browser/browser_translations_telemetry_translation_failure.js195
-rw-r--r--browser/components/translations/tests/browser/browser_translations_telemetry_translation_request.js173
-rw-r--r--browser/components/translations/tests/browser/head.js348
43 files changed, 7951 insertions, 0 deletions
diff --git a/browser/components/translation/BingTranslator.jsm b/browser/components/translation/BingTranslator.jsm
new file mode 100644
index 0000000000..b457e16324
--- /dev/null
+++ b/browser/components/translation/BingTranslator.jsm
@@ -0,0 +1,488 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["BingTranslator"];
+
+const { PromiseUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromiseUtils.sys.mjs"
+);
+const { Async } = ChromeUtils.importESModule(
+ "resource://services-common/async.sys.mjs"
+);
+const { httpRequest } = ChromeUtils.importESModule(
+ "resource://gre/modules/Http.sys.mjs"
+);
+
+// The maximum amount of net data allowed per request on Bing's API.
+const MAX_REQUEST_DATA = 5000; // Documentation says 10000 but anywhere
+// close to that is refused by the service.
+
+// The maximum number of chunks allowed to be translated in a single
+// request.
+const MAX_REQUEST_CHUNKS = 1000; // Documentation says 2000.
+
+// Self-imposed limit of 15 requests. This means that a page that would need
+// to be broken in more than 15 requests won't be fully translated.
+// The maximum amount of data that we will translate for a single page
+// is MAX_REQUESTS * MAX_REQUEST_DATA.
+const MAX_REQUESTS = 15;
+
+/**
+ * Translates a webpage using Bing's Translation API.
+ *
+ * @param translationDocument The TranslationDocument object that represents
+ * the webpage to be translated
+ * @param sourceLanguage The source language of the document
+ * @param targetLanguage The target language for the translation
+ *
+ * @returns {Promise} A promise that will resolve when the translation
+ * task is finished.
+ */
+var BingTranslator = function (
+ translationDocument,
+ sourceLanguage,
+ targetLanguage
+) {
+ this.translationDocument = translationDocument;
+ this.sourceLanguage = sourceLanguage;
+ this.targetLanguage = targetLanguage;
+ this._pendingRequests = 0;
+ this._partialSuccess = false;
+ this._serviceUnavailable = false;
+ this._translatedCharacterCount = 0;
+};
+
+BingTranslator.prototype = {
+ /**
+ * Performs the translation, splitting the document into several chunks
+ * respecting the data limits of the API.
+ *
+ * @returns {Promise} A promise that will resolve when the translation
+ * task is finished.
+ */
+ translate() {
+ return (async () => {
+ let currentIndex = 0;
+ this._onFinishedDeferred = PromiseUtils.defer();
+
+ // Let's split the document into various requests to be sent to
+ // Bing's Translation API.
+ for (let requestCount = 0; requestCount < MAX_REQUESTS; requestCount++) {
+ // Generating the text for each request can be expensive, so
+ // let's take the opportunity of the chunkification process to
+ // allow for the event loop to attend other pending events
+ // before we continue.
+ await Async.promiseYield();
+
+ // Determine the data for the next request.
+ let request = this._generateNextTranslationRequest(currentIndex);
+
+ // Create a real request to the server, and put it on the
+ // pending requests list.
+ let bingRequest = new BingRequest(
+ request.data,
+ this.sourceLanguage,
+ this.targetLanguage
+ );
+ this._pendingRequests++;
+ bingRequest
+ .fireRequest()
+ .then(this._chunkCompleted.bind(this), this._chunkFailed.bind(this));
+
+ currentIndex = request.lastIndex;
+ if (request.finished) {
+ break;
+ }
+ }
+
+ return this._onFinishedDeferred.promise;
+ })();
+ },
+
+ /**
+ * Resets the expiration time of the current token, in order to
+ * force the token manager to ask for a new token during the next request.
+ */
+ _resetToken() {
+ // Force the token manager to get update token
+ BingTokenManager._currentExpiryTime = 0;
+ },
+
+ /**
+ * Function called when a request sent to the server completed successfully.
+ * This function handles calling the function to parse the result and the
+ * function to resolve the promise returned by the public `translate()`
+ * method when there's no pending request left.
+ *
+ * @param request The BingRequest sent to the server.
+ */
+ _chunkCompleted(bingRequest) {
+ if (this._parseChunkResult(bingRequest)) {
+ this._partialSuccess = true;
+ // Count the number of characters successfully translated.
+ this._translatedCharacterCount += bingRequest.characterCount;
+ }
+
+ this._checkIfFinished();
+ },
+
+ /**
+ * Function called when a request sent to the server has failed.
+ * This function handles deciding if the error is transient or means the
+ * service is unavailable (zero balance on the key or request credentials are
+ * not in an active state) and calling the function to resolve the promise
+ * returned by the public `translate()` method when there's no pending.
+ * request left.
+ *
+ * @param aError [optional] The XHR object of the request that failed.
+ */
+ _chunkFailed(aError) {
+ if (
+ XMLHttpRequest.isInstance(aError) &&
+ [400, 401].includes(aError.status)
+ ) {
+ let body = aError.responseText;
+ if (
+ body &&
+ body.includes("TranslateApiException") &&
+ (body.includes("balance") || body.includes("active state"))
+ ) {
+ this._serviceUnavailable = true;
+ }
+ }
+
+ this._checkIfFinished();
+ },
+
+ /**
+ * Function called when a request sent to the server has completed.
+ * This function handles resolving the promise
+ * returned by the public `translate()` method when all chunks are completed.
+ */
+ _checkIfFinished() {
+ // Check if all pending requests have been
+ // completed and then resolves the promise.
+ // If at least one chunk was successful, the
+ // promise will be resolved positively which will
+ // display the "Success" state for the infobar. Otherwise,
+ // the "Error" state will appear.
+ if (--this._pendingRequests == 0) {
+ if (this._partialSuccess) {
+ this._onFinishedDeferred.resolve({
+ characterCount: this._translatedCharacterCount,
+ });
+ } else {
+ let error = this._serviceUnavailable ? "unavailable" : "failure";
+ this._onFinishedDeferred.reject(error);
+ }
+ }
+ },
+
+ /**
+ * This function parses the result returned by Bing's Http.svc API,
+ * which is a XML file that contains a number of elements. To our
+ * particular interest, the only part of the response that matters
+ * are the <TranslatedText> nodes, which contains the resulting
+ * items that were sent to be translated.
+ *
+ * @param request The request sent to the server.
+ * @returns boolean True if parsing of this chunk was successful.
+ */
+ _parseChunkResult(bingRequest) {
+ let results;
+ try {
+ let doc = bingRequest.networkRequest.responseXML;
+ results = doc.querySelectorAll("TranslatedText");
+ } catch (e) {
+ return false;
+ }
+
+ let len = results.length;
+ if (len != bingRequest.translationData.length) {
+ // This should never happen, but if the service returns a different number
+ // of items (from the number of items submitted), we can't use this chunk
+ // because all items would be paired incorrectly.
+ return false;
+ }
+
+ let error = false;
+ for (let i = 0; i < len; i++) {
+ try {
+ let result = results[i].firstChild.nodeValue;
+ let root = bingRequest.translationData[i][0];
+
+ if (root.isSimpleRoot) {
+ // Workaround for Bing's service problem in which "&" chars in
+ // plain-text TranslationItems are double-escaped.
+ result = result.replace(/&amp;/g, "&");
+ }
+
+ root.parseResult(result);
+ } catch (e) {
+ error = true;
+ }
+ }
+
+ return !error;
+ },
+
+ /**
+ * This function will determine what is the data to be used for
+ * the Nth request we are generating, based on the input params.
+ *
+ * @param startIndex What is the index, in the roots list, that the
+ * chunk should start.
+ */
+ _generateNextTranslationRequest(startIndex) {
+ let currentDataSize = 0;
+ let currentChunks = 0;
+ let output = [];
+ let rootsList = this.translationDocument.roots;
+
+ for (let i = startIndex; i < rootsList.length; i++) {
+ let root = rootsList[i];
+ let text = this.translationDocument.generateTextForItem(root);
+ if (!text) {
+ continue;
+ }
+
+ text = escapeXML(text);
+ let newCurSize = currentDataSize + text.length;
+ let newChunks = currentChunks + 1;
+
+ if (newCurSize > MAX_REQUEST_DATA || newChunks > MAX_REQUEST_CHUNKS) {
+ // If we've reached the API limits, let's stop accumulating data
+ // for this request and return. We return information useful for
+ // the caller to pass back on the next call, so that the function
+ // can keep working from where it stopped.
+ return {
+ data: output,
+ finished: false,
+ lastIndex: i,
+ };
+ }
+
+ currentDataSize = newCurSize;
+ currentChunks = newChunks;
+ output.push([root, text]);
+ }
+
+ return {
+ data: output,
+ finished: true,
+ lastIndex: 0,
+ };
+ },
+};
+
+/**
+ * Represents a request (for 1 chunk) sent off to Bing's service.
+ *
+ * @params translationData The data to be used for this translation,
+ * generated by the generateNextTranslationRequest...
+ * function.
+ * @param sourceLanguage The source language of the document.
+ * @param targetLanguage The target language for the translation.
+ *
+ */
+function BingRequest(translationData, sourceLanguage, targetLanguage) {
+ this.translationData = translationData;
+ this.sourceLanguage = sourceLanguage;
+ this.targetLanguage = targetLanguage;
+ this.characterCount = 0;
+}
+
+BingRequest.prototype = {
+ /**
+ * Initiates the request
+ */
+ fireRequest() {
+ return (async () => {
+ // Prepare authentication.
+ let token = await BingTokenManager.getToken();
+ let auth = "Bearer " + token;
+
+ // Prepare URL.
+ let url = getUrlParam(
+ "https://api.microsofttranslator.com/v2/Http.svc/TranslateArray",
+ "browser.translation.bing.translateArrayURL"
+ );
+
+ // Prepare request headers.
+ let headers = [
+ ["Content-type", "text/xml"],
+ ["Authorization", auth],
+ ];
+
+ // Prepare the request body.
+ let requestString =
+ "<TranslateArrayRequest>" +
+ "<AppId/>" +
+ "<From>" +
+ this.sourceLanguage +
+ "</From>" +
+ "<Options>" +
+ '<ContentType xmlns="http://schemas.datacontract.org/2004/07/Microsoft.MT.Web.Service.V2">text/html</ContentType>' +
+ '<ReservedFlags xmlns="http://schemas.datacontract.org/2004/07/Microsoft.MT.Web.Service.V2" />' +
+ "</Options>" +
+ '<Texts xmlns:s="http://schemas.microsoft.com/2003/10/Serialization/Arrays">';
+
+ for (let [, text] of this.translationData) {
+ requestString += "<s:string>" + text + "</s:string>";
+ this.characterCount += text.length;
+ }
+
+ requestString +=
+ "</Texts>" +
+ "<To>" +
+ this.targetLanguage +
+ "</To>" +
+ "</TranslateArrayRequest>";
+
+ // Set up request options.
+ return new Promise((resolve, reject) => {
+ let options = {
+ onLoad: (responseText, xhr) => {
+ resolve(this);
+ },
+ onError(e, responseText, xhr) {
+ reject(xhr);
+ },
+ postData: requestString,
+ headers,
+ };
+
+ // Fire the request.
+ let request = httpRequest(url, options);
+
+ // Override the response MIME type.
+ request.overrideMimeType("text/xml");
+ this.networkRequest = request;
+ });
+ })();
+ },
+};
+
+/**
+ * Authentication Token manager for the API
+ */
+var BingTokenManager = {
+ _currentToken: null,
+ _currentExpiryTime: 0,
+ _pendingRequest: null,
+
+ /**
+ * Get a valid, non-expired token to be used for the API calls.
+ *
+ * @returns {Promise} A promise that resolves with the token
+ * string once it is obtained. The token returned
+ * can be the same one used in the past if it is still
+ * valid.
+ */
+ getToken() {
+ if (this._pendingRequest) {
+ return this._pendingRequest;
+ }
+
+ let remainingMs = this._currentExpiryTime - new Date();
+ // Our existing token is still good for more than a minute, let's use it.
+ if (remainingMs > 60 * 1000) {
+ return Promise.resolve(this._currentToken);
+ }
+
+ return this._getNewToken();
+ },
+
+ /**
+ * Generates a new token from the server.
+ *
+ * @returns {Promise} A promise that resolves with the token
+ * string once it is obtained.
+ */
+ _getNewToken() {
+ let url = getUrlParam(
+ "https://datamarket.accesscontrol.windows.net/v2/OAuth2-13",
+ "browser.translation.bing.authURL"
+ );
+ let params = [
+ ["grant_type", "client_credentials"],
+ ["scope", "http://api.microsofttranslator.com"],
+ [
+ "client_id",
+ getUrlParam(
+ "%BING_API_CLIENTID%",
+ "browser.translation.bing.clientIdOverride"
+ ),
+ ],
+ [
+ "client_secret",
+ getUrlParam(
+ "%BING_API_KEY%",
+ "browser.translation.bing.apiKeyOverride"
+ ),
+ ],
+ ];
+
+ this._pendingRequest = new Promise((resolve, reject) => {
+ let options = {
+ onLoad(responseText, xhr) {
+ BingTokenManager._pendingRequest = null;
+ try {
+ let json = JSON.parse(responseText);
+
+ if (json.error) {
+ reject(json.error);
+ return;
+ }
+
+ let token = json.access_token;
+ let expires_in = json.expires_in;
+ BingTokenManager._currentToken = token;
+ BingTokenManager._currentExpiryTime = new Date(
+ Date.now() + expires_in * 1000
+ );
+ resolve(token);
+ } catch (e) {
+ reject(e);
+ }
+ },
+ onError(e, responseText, xhr) {
+ BingTokenManager._pendingRequest = null;
+ reject(e);
+ },
+ postData: params,
+ };
+
+ httpRequest(url, options);
+ });
+ return this._pendingRequest;
+ },
+};
+
+/**
+ * Escape a string to be valid XML content.
+ */
+function escapeXML(aStr) {
+ return aStr
+ .toString()
+ .replace(/&/g, "&amp;")
+ .replace(/\"/g, "&quot;")
+ .replace(/\'/g, "&apos;")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;");
+}
+
+/**
+ * Fetch an auth token (clientID or client secret), which may be overridden by
+ * a pref if it's set.
+ */
+function getUrlParam(paramValue, prefName) {
+ if (Services.prefs.getPrefType(prefName)) {
+ paramValue = Services.prefs.getCharPref(prefName);
+ }
+ paramValue = Services.urlFormatter.formatURL(paramValue);
+ return paramValue;
+}
diff --git a/browser/components/translation/GoogleTranslator.jsm b/browser/components/translation/GoogleTranslator.jsm
new file mode 100644
index 0000000000..dd5da98b8b
--- /dev/null
+++ b/browser/components/translation/GoogleTranslator.jsm
@@ -0,0 +1,306 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["GoogleTranslator"];
+
+const { PromiseUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromiseUtils.sys.mjs"
+);
+const { httpRequest } = ChromeUtils.importESModule(
+ "resource://gre/modules/Http.sys.mjs"
+);
+
+// The maximum amount of net data allowed per request on Google's API.
+const MAX_REQUEST_DATA = 5000; // XXX This is the Bing value
+
+// The maximum number of chunks allowed to be translated in a single
+// request.
+const MAX_REQUEST_CHUNKS = 128; // Undocumented, but the de facto upper limit.
+
+// Self-imposed limit of 15 requests. This means that a page that would need
+// to be broken in more than 15 requests won't be fully translated.
+// The maximum amount of data that we will translate for a single page
+// is MAX_REQUESTS * MAX_REQUEST_DATA.
+const MAX_REQUESTS = 15;
+
+const URL = "https://translation.googleapis.com/language/translate/v2";
+
+/**
+ * Translates a webpage using Google's Translation API.
+ *
+ * @param translationDocument The TranslationDocument object that represents
+ * the webpage to be translated
+ * @param sourceLanguage The source language of the document
+ * @param targetLanguage The target language for the translation
+ *
+ * @returns {Promise} A promise that will resolve when the translation
+ * task is finished.
+ */
+var GoogleTranslator = function (
+ translationDocument,
+ sourceLanguage,
+ targetLanguage
+) {
+ this.translationDocument = translationDocument;
+ this.sourceLanguage = sourceLanguage;
+ this.targetLanguage = targetLanguage;
+ this._pendingRequests = 0;
+ this._partialSuccess = false;
+ this._translatedCharacterCount = 0;
+};
+
+GoogleTranslator.prototype = {
+ /**
+ * Performs the translation, splitting the document into several chunks
+ * respecting the data limits of the API.
+ *
+ * @returns {Promise} A promise that will resolve when the translation
+ * task is finished.
+ */
+ async translate() {
+ let currentIndex = 0;
+ this._onFinishedDeferred = PromiseUtils.defer();
+
+ // Let's split the document into various requests to be sent to
+ // Google's Translation API.
+ for (let requestCount = 0; requestCount < MAX_REQUESTS; requestCount++) {
+ // Generating the text for each request can be expensive, so
+ // let's take the opportunity of the chunkification process to
+ // allow for the event loop to attend other pending events
+ // before we continue.
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+
+ // Determine the data for the next request.
+ let request = this._generateNextTranslationRequest(currentIndex);
+
+ // Create a real request to the server, and put it on the
+ // pending requests list.
+ let googleRequest = new GoogleRequest(
+ request.data,
+ this.sourceLanguage,
+ this.targetLanguage
+ );
+ this._pendingRequests++;
+ googleRequest
+ .fireRequest()
+ .then(this._chunkCompleted.bind(this), this._chunkFailed.bind(this));
+
+ currentIndex = request.lastIndex;
+ if (request.finished) {
+ break;
+ }
+ }
+
+ return this._onFinishedDeferred.promise;
+ },
+
+ /**
+ * Function called when a request sent to the server completed successfully.
+ * This function handles calling the function to parse the result and the
+ * function to resolve the promise returned by the public `translate()`
+ * method when there's no pending request left.
+ *
+ * @param request The GoogleRequest sent to the server.
+ */
+ _chunkCompleted(googleRequest) {
+ if (this._parseChunkResult(googleRequest)) {
+ this._partialSuccess = true;
+ // Count the number of characters successfully translated.
+ this._translatedCharacterCount += googleRequest.characterCount;
+ }
+
+ this._checkIfFinished();
+ },
+
+ /**
+ * Function called when a request sent to the server has failed.
+ * This function handles deciding if the error is transient or means the
+ * service is unavailable (zero balance on the key or request credentials are
+ * not in an active state) and calling the function to resolve the promise
+ * returned by the public `translate()` method when there's no pending.
+ * request left.
+ *
+ * @param aError [optional] The XHR object of the request that failed.
+ */
+ _chunkFailed(aError) {
+ this._checkIfFinished();
+ },
+
+ /**
+ * Function called when a request sent to the server has completed.
+ * This function handles resolving the promise
+ * returned by the public `translate()` method when all chunks are completed.
+ */
+ _checkIfFinished() {
+ // Check if all pending requests have been
+ // completed and then resolves the promise.
+ // If at least one chunk was successful, the
+ // promise will be resolved positively which will
+ // display the "Success" state for the infobar. Otherwise,
+ // the "Error" state will appear.
+ if (--this._pendingRequests == 0) {
+ if (this._partialSuccess) {
+ this._onFinishedDeferred.resolve({
+ characterCount: this._translatedCharacterCount,
+ });
+ } else {
+ this._onFinishedDeferred.reject("failure");
+ }
+ }
+ },
+
+ /**
+ * This function parses the result returned by Bing's Http.svc API,
+ * which is a XML file that contains a number of elements. To our
+ * particular interest, the only part of the response that matters
+ * are the <TranslatedText> nodes, which contains the resulting
+ * items that were sent to be translated.
+ *
+ * @param request The request sent to the server.
+ * @returns boolean True if parsing of this chunk was successful.
+ */
+ _parseChunkResult(googleRequest) {
+ let results;
+ try {
+ let response = googleRequest.networkRequest.response;
+ results = JSON.parse(response).data.translations;
+ } catch (e) {
+ return false;
+ }
+ let len = results.length;
+ if (len != googleRequest.translationData.length) {
+ // This should never happen, but if the service returns a different number
+ // of items (from the number of items submitted), we can't use this chunk
+ // because all items would be paired incorrectly.
+ return false;
+ }
+
+ let error = false;
+ for (let i = 0; i < len; i++) {
+ try {
+ let result = results[i].translatedText;
+ let root = googleRequest.translationData[i][0];
+ if (root.isSimpleRoot && result.includes("&")) {
+ // If the result contains HTML entities, we need to convert them as
+ // simple roots expect a plain text result.
+ let doc = new DOMParser().parseFromString(result, "text/html");
+ result = doc.body.firstChild.nodeValue;
+ }
+ root.parseResult(result);
+ } catch (e) {
+ error = true;
+ }
+ }
+
+ return !error;
+ },
+
+ /**
+ * This function will determine what is the data to be used for
+ * the Nth request we are generating, based on the input params.
+ *
+ * @param startIndex What is the index, in the roots list, that the
+ * chunk should start.
+ */
+ _generateNextTranslationRequest(startIndex) {
+ let currentDataSize = 0;
+ let currentChunks = 0;
+ let output = [];
+ let rootsList = this.translationDocument.roots;
+
+ for (let i = startIndex; i < rootsList.length; i++) {
+ let root = rootsList[i];
+ let text = this.translationDocument.generateTextForItem(root);
+ if (!text) {
+ continue;
+ }
+
+ let newCurSize = currentDataSize + text.length;
+ let newChunks = currentChunks + 1;
+
+ if (newCurSize > MAX_REQUEST_DATA || newChunks > MAX_REQUEST_CHUNKS) {
+ // If we've reached the API limits, let's stop accumulating data
+ // for this request and return. We return information useful for
+ // the caller to pass back on the next call, so that the function
+ // can keep working from where it stopped.
+ return {
+ data: output,
+ finished: false,
+ lastIndex: i,
+ };
+ }
+
+ currentDataSize = newCurSize;
+ currentChunks = newChunks;
+ output.push([root, text]);
+ }
+
+ return {
+ data: output,
+ finished: true,
+ lastIndex: 0,
+ };
+ },
+};
+
+/**
+ * Represents a request (for 1 chunk) sent off to Google's service.
+ *
+ * @params translationData The data to be used for this translation,
+ * generated by the generateNextTranslationRequest...
+ * function.
+ * @param sourceLanguage The source language of the document.
+ * @param targetLanguage The target language for the translation.
+ *
+ */
+function GoogleRequest(translationData, sourceLanguage, targetLanguage) {
+ this.translationData = translationData;
+ this.sourceLanguage = sourceLanguage;
+ this.targetLanguage = targetLanguage;
+ this.characterCount = 0;
+}
+
+GoogleRequest.prototype = {
+ /**
+ * Initiates the request
+ */
+ fireRequest() {
+ let key =
+ Services.cpmm.sharedData.get("translationKey") ||
+ Services.prefs.getStringPref("browser.translation.google.apiKey", "");
+ if (!key) {
+ return Promise.reject("no API key");
+ }
+
+ // Prepare the request body.
+ let postData = [
+ ["key", key],
+ ["source", this.sourceLanguage],
+ ["target", this.targetLanguage],
+ ];
+
+ for (let [, text] of this.translationData) {
+ postData.push(["q", text]);
+ this.characterCount += text.length;
+ }
+
+ // Set up request options.
+ return new Promise((resolve, reject) => {
+ let options = {
+ onLoad: (responseText, xhr) => {
+ resolve(this);
+ },
+ onError(e, responseText, xhr) {
+ reject(xhr);
+ },
+ postData,
+ };
+
+ // Fire the request.
+ this.networkRequest = httpRequest(URL, options);
+ });
+ },
+};
diff --git a/browser/components/translation/TranslationChild.jsm b/browser/components/translation/TranslationChild.jsm
new file mode 100644
index 0000000000..80a4c48661
--- /dev/null
+++ b/browser/components/translation/TranslationChild.jsm
@@ -0,0 +1,163 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["TranslationChild"];
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ LanguageDetector:
+ "resource://gre/modules/translation/LanguageDetector.sys.mjs",
+});
+
+const STATE_OFFER = 0;
+const STATE_TRANSLATED = 2;
+const STATE_ERROR = 3;
+
+class TranslationChild extends JSWindowActorChild {
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "pageshow":
+ // We are only listening to pageshow events.
+ let content = this.contentWindow;
+ if (!content.detectedLanguage) {
+ return;
+ }
+
+ let data = {};
+ let trDoc = content.translationDocument;
+ if (trDoc) {
+ data.state = trDoc.translationError ? STATE_ERROR : STATE_TRANSLATED;
+ data.translatedFrom = trDoc.translatedFrom;
+ data.translatedTo = trDoc.translatedTo;
+ data.originalShown = trDoc.originalShown;
+ } else {
+ data.state = STATE_OFFER;
+ data.originalShown = true;
+ }
+ data.detectedLanguage = content.detectedLanguage;
+
+ this.sendAsyncMessage("Translation:DocumentState", data);
+ break;
+
+ case "load":
+ this.checkForTranslation();
+ break;
+ }
+ }
+
+ checkForTranslation() {
+ let url = String(this.document.location);
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
+ return;
+ }
+
+ let content = this.contentWindow;
+ if (content.detectedLanguage) {
+ return;
+ }
+
+ // Grab a 60k sample of text from the page.
+ let encoder = Cu.createDocumentEncoder("text/plain");
+ encoder.init(this.document, "text/plain", encoder.SkipInvisibleContent);
+ let string = encoder.encodeToStringWithMaxLength(60 * 1024);
+
+ // Language detection isn't reliable on very short strings.
+ if (string.length < 100) {
+ return;
+ }
+
+ lazy.LanguageDetector.detectLanguage(string).then(result => {
+ // Bail if we're not confident.
+ if (!result.confident) {
+ return;
+ }
+
+ // The window might be gone by now.
+ if (Cu.isDeadWrapper(content)) {
+ return;
+ }
+
+ content.detectedLanguage = result.language;
+
+ let data = {
+ state: STATE_OFFER,
+ originalShown: true,
+ detectedLanguage: result.language,
+ };
+ this.sendAsyncMessage("Translation:DocumentState", data);
+ });
+ }
+
+ async doTranslation(aFrom, aTo) {
+ var { TranslationDocument } = ChromeUtils.import(
+ "resource:///modules/translation/TranslationDocument.jsm"
+ );
+
+ // If a TranslationDocument already exists for this document, it should
+ // be used instead of creating a new one so that we can use the original
+ // content of the page for the new translation instead of the newly
+ // translated text.
+ let translationDocument =
+ this.contentWindow.translationDocument ||
+ new TranslationDocument(this.document);
+
+ let engine = Services.prefs.getCharPref("browser.translation.engine");
+ let importScope = ChromeUtils.import(
+ `resource:///modules/translation/${engine}Translator.jsm`
+ );
+ let translator = new importScope[engine + "Translator"](
+ translationDocument,
+ aFrom,
+ aTo
+ );
+
+ this.contentWindow.translationDocument = translationDocument;
+ translationDocument.translatedFrom = aFrom;
+ translationDocument.translatedTo = aTo;
+ translationDocument.translationError = false;
+
+ let result;
+ try {
+ let translateResult = await translator.translate();
+
+ result = {
+ characterCount: translateResult.characterCount,
+ from: aFrom,
+ to: aTo,
+ success: true,
+ };
+
+ translationDocument.showTranslation();
+ return result;
+ } catch (ex) {
+ translationDocument.translationError = true;
+ result = { success: false };
+ if (ex == "unavailable") {
+ result.unavailable = true;
+ }
+ }
+
+ return result;
+ }
+
+ receiveMessage(aMessage) {
+ switch (aMessage.name) {
+ case "Translation:TranslateDocument": {
+ return this.doTranslation(aMessage.data.from, aMessage.data.to);
+ }
+
+ case "Translation:ShowOriginal":
+ this.contentWindow.translationDocument.showOriginal();
+ break;
+
+ case "Translation:ShowTranslation":
+ this.contentWindow.translationDocument.showTranslation();
+ break;
+ }
+
+ return undefined;
+ }
+}
diff --git a/browser/components/translation/TranslationDocument.jsm b/browser/components/translation/TranslationDocument.jsm
new file mode 100644
index 0000000000..d5ea99f377
--- /dev/null
+++ b/browser/components/translation/TranslationDocument.jsm
@@ -0,0 +1,679 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["TranslationDocument"];
+
+const { Async } = ChromeUtils.importESModule(
+ "resource://services-common/async.sys.mjs"
+);
+
+/**
+ * This class represents a document that is being translated,
+ * and it is responsible for parsing the document,
+ * generating the data structures translation (the list of
+ * translation items and roots), and managing the original
+ * and translated texts on the translation items.
+ *
+ * @param document The document to be translated
+ */
+var TranslationDocument = function (document) {
+ this.itemsMap = new Map();
+ this.roots = [];
+ this._init(document);
+};
+
+TranslationDocument.prototype = {
+ translatedFrom: "",
+ translatedTo: "",
+ translationError: false,
+ originalShown: true,
+
+ /**
+ * Initializes the object and populates
+ * the roots lists.
+ *
+ * @param document The document to be translated
+ */
+ _init(document) {
+ let winUtils = document.defaultView.windowUtils;
+
+ // Get all the translation nodes in the document's body:
+ // a translation node is a node from the document which
+ // contains useful content for translation, and therefore
+ // must be included in the translation process.
+ let nodeList = winUtils.getTranslationNodes(document.body);
+
+ let length = nodeList.length;
+
+ for (let i = 0; i < length; i++) {
+ let node = nodeList.item(i);
+ let isRoot = nodeList.isTranslationRootAtIndex(i);
+
+ // Create a TranslationItem object for this node.
+ // This function will also add it to the this.roots array.
+ this._createItemForNode(node, i, isRoot);
+ }
+
+ // At first all roots are stored in the roots list, and only after
+ // the process has finished we're able to determine which roots are
+ // simple, and which ones are not.
+
+ // A simple root is defined by a root with no children items, which
+ // basically represents an element from a page with only text content
+ // inside.
+
+ // This distinction is useful for optimization purposes: we treat a
+ // simple root as plain-text in the translation process and with that
+ // we are able to reduce their data payload sent to the translation service.
+
+ for (let root of this.roots) {
+ if (!root.children.length && root.nodeRef.childElementCount == 0) {
+ root.isSimpleRoot = true;
+ }
+ }
+ },
+
+ /**
+ * Creates a TranslationItem object, which should be called
+ * for each node returned by getTranslationNodes.
+ *
+ * @param node The DOM node for this item.
+ * @param id A unique, numeric id for this item.
+ * @parem isRoot A boolean saying whether this item is a root.
+ *
+ * @returns A TranslationItem object.
+ */
+ _createItemForNode(node, id, isRoot) {
+ if (this.itemsMap.has(node)) {
+ return this.itemsMap.get(node);
+ }
+
+ let item = new TranslationItem(node, id, isRoot);
+
+ if (isRoot) {
+ // Root items do not have a parent item.
+ this.roots.push(item);
+ } else {
+ let parentItem = this.itemsMap.get(node.parentNode);
+ if (parentItem) {
+ parentItem.children.push(item);
+ }
+ }
+
+ this.itemsMap.set(node, item);
+ return item;
+ },
+
+ /**
+ * Generate the text string that represents a TranslationItem object.
+ * Besides generating the string, it's also stored in the "original"
+ * field of the TranslationItem object, which needs to be stored for
+ * later to be used in the "Show Original" functionality.
+ * If this function had already been called for the given item (determined
+ * by the presence of the "original" array in the item), the text will
+ * be regenerated from the "original" data instead of from the related
+ * DOM nodes (because the nodes might contain translated data).
+ *
+ * @param item A TranslationItem object
+ *
+ * @returns A string representation of the TranslationItem.
+ */
+ generateTextForItem(item) {
+ if (item.original) {
+ return regenerateTextFromOriginalHelper(item);
+ }
+
+ if (item.isSimpleRoot) {
+ let text = item.nodeRef.firstChild.nodeValue.trim();
+ item.original = [text];
+ return text;
+ }
+
+ let str = "";
+ item.original = [];
+ let wasLastItemPlaceholder = false;
+
+ for (let child of item.nodeRef.childNodes) {
+ if (child.nodeType == child.TEXT_NODE) {
+ let x = child.nodeValue.trim();
+ if (x != "") {
+ item.original.push(x);
+ str += x;
+ wasLastItemPlaceholder = false;
+ }
+ continue;
+ }
+
+ let objInMap = this.itemsMap.get(child);
+ if (objInMap && !objInMap.isRoot) {
+ // If this childNode is present in the itemsMap, it means
+ // it's a translation node: it has useful content for translation.
+ // In this case, we need to stringify this node.
+ // However, if this item is a root, we should skip it here in this
+ // object's child list (and just add a placeholder for it), because
+ // it will be stringfied separately for being a root.
+ item.original.push(objInMap);
+ str += this.generateTextForItem(objInMap);
+ wasLastItemPlaceholder = false;
+ } else if (!wasLastItemPlaceholder) {
+ // Otherwise, if this node doesn't contain any useful content,
+ // or if it is a root itself, we can replace it with a placeholder node.
+ // We can't simply eliminate this node from our string representation
+ // because that could change the HTML structure (e.g., it would
+ // probably merge two separate text nodes).
+ // It's not necessary to add more than one placeholder in sequence;
+ // we can optimize them away.
+ item.original.push(TranslationItem_NodePlaceholder);
+ str += "<br>";
+ wasLastItemPlaceholder = true;
+ }
+ }
+
+ return generateTranslationHtmlForItem(item, str);
+ },
+
+ /**
+ * Changes the document to display its translated
+ * content.
+ */
+ showTranslation() {
+ this.originalShown = false;
+ this._swapDocumentContent("translation");
+ },
+
+ /**
+ * Changes the document to display its original
+ * content.
+ */
+ showOriginal() {
+ this.originalShown = true;
+ this._swapDocumentContent("original");
+ },
+
+ /**
+ * Swap the document with the resulting translation,
+ * or back with the original content.
+ *
+ * @param target A string that is either "translation"
+ * or "original".
+ */
+ _swapDocumentContent(target) {
+ (async () => {
+ // Let the event loop breath on every 100 nodes
+ // that are replaced.
+ const YIELD_INTERVAL = 100;
+ await Async.yieldingForEach(
+ this.roots,
+ root => root.swapText(target),
+ YIELD_INTERVAL
+ );
+ })();
+ },
+};
+
+/**
+ * This class represents an item for translation. It's basically our
+ * wrapper class around a node returned by getTranslationNode, with
+ * more data and structural information on it.
+ *
+ * At the end of the translation process, besides the properties below,
+ * a TranslationItem will contain two other properties: one called "original"
+ * and one called "translation". They are twin objects, one which reflect
+ * the structure of that node in its original state, and the other in its
+ * translated state.
+ *
+ * The "original" array is generated in the generateTextForItem function,
+ * and the "translation" array is generated when the translation results
+ * are parsed.
+ *
+ * They are both arrays, which contain a mix of strings and references to
+ * child TranslationItems. The references in both arrays point to the * same *
+ * TranslationItem object, but they might appear in different orders between the
+ * "original" and "translation" arrays.
+ *
+ * An example:
+ *
+ * English: <div id="n1">Welcome to <b id="n2">Mozilla's</b> website</div>
+ * Portuguese: <div id="n1">Bem vindo a pagina <b id="n2">da Mozilla</b></div>
+ *
+ * TranslationItem n1 = {
+ * id: 1,
+ * original: ["Welcome to", ptr to n2, "website"]
+ * translation: ["Bem vindo a pagina", ptr to n2]
+ * }
+ *
+ * TranslationItem n2 = {
+ * id: 2,
+ * original: ["Mozilla's"],
+ * translation: ["da Mozilla"]
+ * }
+ */
+function TranslationItem(node, id, isRoot) {
+ this.nodeRef = node;
+ this.id = id;
+ this.isRoot = isRoot;
+ this.children = [];
+}
+
+TranslationItem.prototype = {
+ isRoot: false,
+ isSimpleRoot: false,
+
+ toString() {
+ let rootType = "";
+ if (this.isRoot) {
+ if (this.isSimpleRoot) {
+ rootType = " (simple root)";
+ } else {
+ rootType = " (non simple root)";
+ }
+ }
+ return (
+ "[object TranslationItem: <" +
+ this.nodeRef.localName +
+ ">" +
+ rootType +
+ "]"
+ );
+ },
+
+ /**
+ * This function will parse the result of the translation of one translation
+ * item. If this item was a simple root, all we sent was a plain-text version
+ * of it, so the result is also straightforward text.
+ *
+ * For non-simple roots, we sent a simplified HTML representation of that
+ * node, and we'll first parse that into an HTML doc and then call the
+ * parseResultNode helper function to parse it.
+ *
+ * While parsing, the result is stored in the "translation" field of the
+ * TranslationItem, which will be used to display the final translation when
+ * all items are finished. It remains stored too to allow back-and-forth
+ * switching between the "Show Original" and "Show Translation" functions.
+ *
+ * @param result A string with the textual result received from the server,
+ * which can be plain-text or a serialized HTML doc.
+ */
+ parseResult(result) {
+ if (this.isSimpleRoot) {
+ this.translation = [result];
+ return;
+ }
+
+ let domParser = new DOMParser();
+
+ let doc = domParser.parseFromString(result, "text/html");
+ parseResultNode(this, doc.body.firstChild);
+ },
+
+ /**
+ * This function finds a child TranslationItem
+ * with the given id.
+ * @param id The id to look for, in the format "n#"
+ * @returns A TranslationItem with the given id, or null if
+ * it was not found.
+ */
+ getChildById(id) {
+ for (let child of this.children) {
+ if ("n" + child.id == id) {
+ return child;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Swap the text of this TranslationItem between
+ * its original and translated states.
+ *
+ * @param target A string that is either "translation"
+ * or "original".
+ */
+ swapText(target) {
+ swapTextForItem(this, target);
+ },
+};
+
+/**
+ * This object represents a placeholder item for translation. It's similar to
+ * the TranslationItem class, but it represents nodes that have no meaningful
+ * content for translation. These nodes will be replaced by "<br>" in a
+ * translation request. It's necessary to keep them to use it as a mark
+ * for correct positioning and spliting of text nodes.
+ */
+const TranslationItem_NodePlaceholder = {
+ toString() {
+ return "[object TranslationItem_NodePlaceholder]";
+ },
+};
+
+/**
+ * Generate the outer HTML representation for a given item.
+ *
+ * @param item A TranslationItem object.
+ * param content The inner content for this item.
+ * @returns string The outer HTML needed for translation
+ * of this item.
+ */
+function generateTranslationHtmlForItem(item, content) {
+ let localName = item.isRoot ? "div" : "b";
+ return (
+ "<" + localName + " id=n" + item.id + ">" + content + "</" + localName + ">"
+ );
+}
+
+/**
+ * Regenerate the text string that represents a TranslationItem object,
+ * with data from its "original" array. The array must have already
+ * been created by TranslationDocument.generateTextForItem().
+ *
+ * @param item A TranslationItem object
+ *
+ * @returns A string representation of the TranslationItem.
+ */
+function regenerateTextFromOriginalHelper(item) {
+ if (item.isSimpleRoot) {
+ return item.original[0];
+ }
+
+ let str = "";
+ for (let child of item.original) {
+ if (child instanceof TranslationItem) {
+ str += regenerateTextFromOriginalHelper(child);
+ } else if (child === TranslationItem_NodePlaceholder) {
+ str += "<br>";
+ } else {
+ str += child;
+ }
+ }
+
+ return generateTranslationHtmlForItem(item, str);
+}
+
+/**
+ * Helper function to parse a HTML doc result.
+ * How it works:
+ *
+ * An example result string is:
+ *
+ * <div id="n1">Hello <b id="n2">World</b> of Mozilla.</div>
+ *
+ * For an element node, we look at its id and find the corresponding
+ * TranslationItem that was associated with this node, and then we
+ * walk down it repeating the process.
+ *
+ * For text nodes we simply add it as a string.
+ */
+function parseResultNode(item, node) {
+ item.translation = [];
+ for (let child of node.childNodes) {
+ if (child.nodeType == child.TEXT_NODE) {
+ item.translation.push(child.nodeValue);
+ } else if (child.localName == "br") {
+ item.translation.push(TranslationItem_NodePlaceholder);
+ } else {
+ let translationItemChild = item.getChildById(child.id);
+
+ if (translationItemChild) {
+ item.translation.push(translationItemChild);
+ parseResultNode(translationItemChild, child);
+ }
+ }
+ }
+}
+
+/**
+ * Helper function to swap the text of a TranslationItem
+ * between its original and translated states.
+ * How it works:
+ *
+ * The function iterates through the target array (either the `original` or
+ * `translation` array from the TranslationItem), while also keeping a pointer
+ * to a current position in the child nodes from the actual DOM node that we
+ * are modifying. This pointer is moved forward after each item of the array
+ * is translated. If, at any given time, the pointer doesn't match the expected
+ * node that was supposed to be seen, it means that the original and translated
+ * contents have a different ordering, and thus we need to adjust that.
+ *
+ * A full example of the reordering process, swapping from Original to
+ * Translation:
+ *
+ * Original (en): <div>I <em>miss</em> <b>you</b></div>
+ *
+ * Translation (fr): <div><b>Tu</b> me <em>manques</em></div>
+ *
+ * Step 1:
+ * pointer points to firstChild of the DOM node, textnode "I "
+ * first item in item.translation is [object TranslationItem <b>]
+ *
+ * pointer does not match the expected element, <b>. So let's move <b> to the
+ * pointer position.
+ *
+ * Current state of the DOM:
+ * <div><b>you</b>I <em>miss</em> </div>
+ *
+ * Step 2:
+ * pointer moves forward to nextSibling, textnode "I " again.
+ * second item in item.translation is the string " me "
+ *
+ * pointer points to a text node, and we were expecting a text node. Match!
+ * just replace the text content.
+ *
+ * Current state of the DOM:
+ * <div><b>you</b> me <em>miss</em> </div>
+ *
+ * Step 3:
+ * pointer moves forward to nextSibling, <em>miss</em>
+ * third item in item.translation is [object TranslationItem <em>]
+ *
+ * pointer points to the expected node. Match! Nothing to do.
+ *
+ * Step 4:
+ * all items in this item.translation were transformed. The remaining
+ * text nodes are cleared to "", and domNode.normalize() removes them.
+ *
+ * Current state of the DOM:
+ * <div><b>you</b> me <em>miss</em></div>
+ *
+ * Further steps:
+ * After that, the function will visit the child items (from the visitStack),
+ * and the text inside the <b> and <em> nodes will be swapped as well,
+ * yielding the final result:
+ *
+ * <div><b>Tu</b> me <em>manques</em></div>
+ *
+ *
+ * @param item A TranslationItem object
+ * @param target A string that is either "translation"
+ * or "original".
+ */
+function swapTextForItem(item, target) {
+ // visitStack is the stack of items that we still need to visit.
+ // Let's start the process by adding the root item.
+ let visitStack = [item];
+
+ while (visitStack.length) {
+ let curItem = visitStack.shift();
+
+ let domNode = curItem.nodeRef;
+ if (!domNode) {
+ // Skipping this item due to a missing node.
+ continue;
+ }
+
+ if (!curItem[target]) {
+ // Translation not found for this item. This could be due to
+ // an error in the server response. For example, if a translation
+ // was broken in various chunks, and one of the chunks failed,
+ // the items from that chunk will be missing its "translation"
+ // field.
+ continue;
+ }
+
+ domNode.normalize();
+
+ // curNode points to the child nodes of the DOM node that we are
+ // modifying. During most of the process, while the target array is
+ // being iterated (in the for loop below), it should walk together with
+ // the array and be pointing to the correct node that needs to modified.
+ // If it's not pointing to it, that means some sort of node reordering
+ // will be necessary to produce the correct translation.
+ // Note that text nodes don't need to be reordered, as we can just replace
+ // the content of one text node with another.
+ //
+ // curNode starts in the firstChild...
+ let curNode = domNode.firstChild;
+
+ // ... actually, let's make curNode start at the first useful node (either
+ // a non-blank text node or something else). This is not strictly necessary,
+ // as the reordering algorithm would correctly handle this case. However,
+ // this better aligns the resulting translation with the DOM content of the
+ // page, avoiding cases that would need to be unecessarily reordered.
+ //
+ // An example of how this helps:
+ //
+ // ---- Original: <div> <b>Hello </b> world.</div>
+ // ^textnode 1 ^item 1 ^textnode 2
+ //
+ // - Translation: <div><b>Hallo </b> Welt.</div>
+ //
+ // Transformation process without this optimization:
+ // 1 - start pointer at textnode 1
+ // 2 - move item 1 to first position inside the <div>
+ //
+ // Node now looks like: <div><b>Hello </b>[ ][ world.]</div>
+ // textnode 1^ ^textnode 2
+ //
+ // 3 - replace textnode 1 with " Welt."
+ // 4 - clear remaining text nodes (in this case, textnode 2)
+ //
+ // Transformation process with this optimization:
+ // 1 - start pointer at item 1
+ // 2 - item 1 is already in position
+ // 3 - replace textnode 2 with " Welt."
+ //
+ // which completely avoids any node reordering, and requires only one
+ // text change instead of two (while also leaving the page closer to
+ // its original state).
+ while (
+ curNode &&
+ curNode.nodeType == curNode.TEXT_NODE &&
+ curNode.nodeValue.trim() == ""
+ ) {
+ curNode = curNode.nextSibling;
+ }
+
+ // Now let's walk through all items in the `target` array of the
+ // TranslationItem. This means either the TranslationItem.original or
+ // TranslationItem.translation array.
+ for (let targetItem of curItem[target]) {
+ if (targetItem instanceof TranslationItem) {
+ // If the array element is another TranslationItem object, let's
+ // add it to the stack to be visited.
+ visitStack.push(targetItem);
+
+ let targetNode = targetItem.nodeRef;
+
+ // If the node is not in the expected position, let's reorder
+ // it into position...
+ if (
+ curNode != targetNode &&
+ // ...unless the page has reparented this node under a totally
+ // different node (or removed it). In this case, all bets are off
+ // on being able to do anything correctly, so it's better not to
+ // bring back the node to this parent.
+ targetNode.parentNode == domNode
+ ) {
+ // We don't need to null-check curNode because insertBefore(..., null)
+ // does what we need in that case: reorder this node to the end
+ // of child nodes.
+ domNode.insertBefore(targetNode, curNode);
+ curNode = targetNode;
+ }
+
+ // Move pointer forward. Since we do not add empty text nodes to the
+ // list of translation items, we must skip them here too while
+ // traversing the DOM in order to get better alignment between the
+ // text nodes and the translation items.
+ if (curNode) {
+ curNode = getNextSiblingSkippingEmptyTextNodes(curNode);
+ }
+ } else if (targetItem === TranslationItem_NodePlaceholder) {
+ // If the current item is a placeholder node, we need to move
+ // our pointer "past" it, jumping from one side of a block of
+ // elements + empty text nodes to the other side. Even if
+ // non-placeholder elements exists inside the jumped block,
+ // they will be pulled correctly later in the process when the
+ // targetItem for those nodes are handled.
+
+ while (
+ curNode &&
+ (curNode.nodeType != curNode.TEXT_NODE ||
+ curNode.nodeValue.trim() == "")
+ ) {
+ curNode = curNode.nextSibling;
+ }
+ } else {
+ // Finally, if it's a text item, we just need to find the next
+ // text node to use. Text nodes don't need to be reordered, so
+ // the first one found can be used.
+ while (curNode && curNode.nodeType != curNode.TEXT_NODE) {
+ curNode = curNode.nextSibling;
+ }
+
+ // If none was found and we reached the end of the child nodes,
+ // let's create a new one.
+ if (!curNode) {
+ // We don't know if the original content had a space or not,
+ // so the best bet is to create the text node with " " which
+ // will add one space at the beginning and one at the end.
+ curNode = domNode.appendChild(
+ domNode.ownerDocument.createTextNode(" ")
+ );
+ }
+
+ // A trailing and a leading space must be preserved because
+ // they are meaningful in HTML.
+ let preSpace = /^\s/.test(curNode.nodeValue) ? " " : "";
+ let endSpace = /\s$/.test(curNode.nodeValue) ? " " : "";
+
+ curNode.nodeValue = preSpace + targetItem + endSpace;
+ curNode = getNextSiblingSkippingEmptyTextNodes(curNode);
+ }
+ }
+
+ // The translated version of a node might have less text nodes than its
+ // original version. If that's the case, let's clear the remaining nodes.
+ if (curNode) {
+ clearRemainingNonEmptyTextNodesFromElement(curNode);
+ }
+
+ // And remove any garbage "" nodes left after clearing.
+ domNode.normalize();
+ }
+}
+
+function getNextSiblingSkippingEmptyTextNodes(startSibling) {
+ let item = startSibling.nextSibling;
+ while (
+ item &&
+ item.nodeType == item.TEXT_NODE &&
+ item.nodeValue.trim() == ""
+ ) {
+ item = item.nextSibling;
+ }
+ return item;
+}
+
+function clearRemainingNonEmptyTextNodesFromElement(startSibling) {
+ let item = startSibling;
+ while (item) {
+ if (item.nodeType == item.TEXT_NODE && item.nodeValue != "") {
+ item.nodeValue = "";
+ }
+ item = item.nextSibling;
+ }
+}
diff --git a/browser/components/translation/TranslationParent.jsm b/browser/components/translation/TranslationParent.jsm
new file mode 100644
index 0000000000..8baf3b2779
--- /dev/null
+++ b/browser/components/translation/TranslationParent.jsm
@@ -0,0 +1,455 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = [
+ "Translation",
+ "TranslationParent",
+ "TranslationTelemetry",
+];
+
+const TRANSLATION_PREF_SHOWUI = "browser.translation.ui.show";
+const TRANSLATION_PREF_DETECT_LANG = "browser.translation.detectLanguage";
+
+var Translation = {
+ STATE_OFFER: 0,
+ STATE_TRANSLATING: 1,
+ STATE_TRANSLATED: 2,
+ STATE_ERROR: 3,
+ STATE_UNAVAILABLE: 4,
+
+ translationListener: null,
+
+ serviceUnavailable: false,
+
+ supportedSourceLanguages: [
+ "bg",
+ "cs",
+ "de",
+ "en",
+ "es",
+ "fr",
+ "ja",
+ "ko",
+ "nl",
+ "no",
+ "pl",
+ "pt",
+ "ru",
+ "tr",
+ "vi",
+ "zh",
+ ],
+ supportedTargetLanguages: [
+ "bg",
+ "cs",
+ "de",
+ "en",
+ "es",
+ "fr",
+ "ja",
+ "ko",
+ "nl",
+ "no",
+ "pl",
+ "pt",
+ "ru",
+ "tr",
+ "vi",
+ "zh",
+ ],
+
+ setListenerForTests(listener) {
+ this.translationListener = listener;
+ },
+
+ _defaultTargetLanguage: "",
+ get defaultTargetLanguage() {
+ if (!this._defaultTargetLanguage) {
+ this._defaultTargetLanguage =
+ Services.locale.appLocaleAsBCP47.split("-")[0];
+ }
+ return this._defaultTargetLanguage;
+ },
+
+ openProviderAttribution() {
+ let attribution = this.supportedEngines[this.translationEngine];
+ const { BrowserWindowTracker } = ChromeUtils.import(
+ "resource:///modules/BrowserWindowTracker.jsm"
+ );
+ BrowserWindowTracker.getTopWindow().openWebLinkIn(attribution, "tab");
+ },
+
+ /**
+ * The list of translation engines and their attributions.
+ */
+ supportedEngines: {
+ Google: "",
+ Bing: "http://aka.ms/MicrosoftTranslatorAttribution",
+ Yandex: "http://translate.yandex.com/",
+ },
+
+ /**
+ * Fallback engine (currently Google) if the preferences seem confusing.
+ */
+ get defaultEngine() {
+ return Object.keys(this.supportedEngines)[0];
+ },
+
+ /**
+ * Returns the name of the preferred translation engine.
+ */
+ get translationEngine() {
+ let engine = Services.prefs.getCharPref("browser.translation.engine");
+ return !Object.keys(this.supportedEngines).includes(engine)
+ ? this.defaultEngine
+ : engine;
+ },
+};
+
+/* Translation objects keep the information related to translation for
+ * a specific browser. The properties exposed to the infobar are:
+ * - detectedLanguage, code of the language detected on the web page.
+ * - state, the state in which the infobar should be displayed
+ * - translatedFrom, if already translated, source language code.
+ * - translatedTo, if already translated, target language code.
+ * - translate, method starting the translation of the current page.
+ * - showOriginalContent, method showing the original page content.
+ * - showTranslatedContent, method showing the translation for an
+ * already translated page whose original content is shown.
+ * - originalShown, boolean indicating if the original or translated
+ * version of the page is shown.
+ */
+class TranslationParent extends JSWindowActorParent {
+ actorCreated() {
+ this._state = 0;
+ this.originalShown = true;
+ }
+
+ get browser() {
+ return this.browsingContext.top.embedderElement;
+ }
+
+ receiveMessage(aMessage) {
+ switch (aMessage.name) {
+ case "Translation:DocumentState":
+ this.documentStateReceived(aMessage.data);
+ break;
+ }
+ }
+
+ documentStateReceived(aData) {
+ if (aData.state == Translation.STATE_OFFER) {
+ if (aData.detectedLanguage == Translation.defaultTargetLanguage) {
+ // Detected language is the same as the user's locale.
+ return;
+ }
+
+ if (
+ !Translation.supportedTargetLanguages.includes(aData.detectedLanguage)
+ ) {
+ // Detected language is not part of the supported languages.
+ TranslationTelemetry.recordMissedTranslationOpportunity(
+ aData.detectedLanguage
+ );
+ return;
+ }
+
+ TranslationTelemetry.recordTranslationOpportunity(aData.detectedLanguage);
+ }
+
+ if (!Services.prefs.getBoolPref(TRANSLATION_PREF_SHOWUI)) {
+ return;
+ }
+
+ // Set all values before showing a new translation infobar.
+ this._state = Translation.serviceUnavailable
+ ? Translation.STATE_UNAVAILABLE
+ : aData.state;
+ this.detectedLanguage = aData.detectedLanguage;
+ this.translatedFrom = aData.translatedFrom;
+ this.translatedTo = aData.translatedTo;
+ this.originalShown = aData.originalShown;
+
+ this.showURLBarIcon();
+ }
+
+ translate(aFrom, aTo) {
+ if (
+ aFrom == aTo ||
+ (this.state == Translation.STATE_TRANSLATED &&
+ this.translatedFrom == aFrom &&
+ this.translatedTo == aTo)
+ ) {
+ // Nothing to do.
+ return;
+ }
+
+ if (this.state == Translation.STATE_OFFER) {
+ if (this.detectedLanguage != aFrom) {
+ TranslationTelemetry.recordDetectedLanguageChange(true);
+ }
+ } else {
+ if (this.translatedFrom != aFrom) {
+ TranslationTelemetry.recordDetectedLanguageChange(false);
+ }
+ if (this.translatedTo != aTo) {
+ TranslationTelemetry.recordTargetLanguageChange();
+ }
+ }
+
+ this.state = Translation.STATE_TRANSLATING;
+ this.translatedFrom = aFrom;
+ this.translatedTo = aTo;
+
+ this.sendQuery("Translation:TranslateDocument", {
+ from: aFrom,
+ to: aTo,
+ }).then(
+ result => {
+ this.translationFinished(result);
+ },
+ () => {}
+ );
+ }
+
+ showURLBarIcon() {
+ let chromeWin = this.browser.ownerGlobal;
+ let PopupNotifications = chromeWin.PopupNotifications;
+ let removeId = this.originalShown ? "translated" : "translate";
+ let notification = PopupNotifications.getNotification(
+ removeId,
+ this.browser
+ );
+ if (notification) {
+ PopupNotifications.remove(notification);
+ }
+
+ let callback = (aTopic, aNewBrowser) => {
+ if (aTopic == "swapping") {
+ return true;
+ }
+
+ if (aTopic != "showing") {
+ return false;
+ }
+ let translationNotification =
+ this.notificationBox.getNotificationWithValue("translation");
+ if (translationNotification) {
+ translationNotification.close();
+ }
+ return true;
+ };
+
+ let addId = this.originalShown ? "translate" : "translated";
+ PopupNotifications.show(
+ this.browser,
+ addId,
+ null,
+ addId + "-notification-icon",
+ null,
+ null,
+ { dismissed: true, eventCallback: callback }
+ );
+ }
+
+ get state() {
+ return this._state;
+ }
+
+ set state(val) {
+ let notif = this.notificationBox.getNotificationWithValue("translation");
+ if (notif) {
+ notif.state = val;
+ }
+ this._state = val;
+ }
+
+ showOriginalContent() {
+ this.originalShown = true;
+ this.showURLBarIcon();
+ this.sendAsyncMessage("Translation:ShowOriginal");
+ TranslationTelemetry.recordShowOriginalContent();
+ }
+
+ showTranslatedContent() {
+ this.originalShown = false;
+ this.showURLBarIcon();
+ this.sendAsyncMessage("Translation:ShowTranslation");
+ }
+
+ get notificationBox() {
+ return this.browser.ownerGlobal.gBrowser.getNotificationBox(this.browser);
+ }
+
+ translationFinished(result) {
+ if (result.success) {
+ this.originalShown = false;
+ this.state = Translation.STATE_TRANSLATED;
+ this.showURLBarIcon();
+
+ // Record the number of characters translated.
+ TranslationTelemetry.recordTranslation(
+ result.from,
+ result.to,
+ result.characterCount
+ );
+ } else if (result.unavailable) {
+ Translation.serviceUnavailable = true;
+ this.state = Translation.STATE_UNAVAILABLE;
+ } else {
+ this.state = Translation.STATE_ERROR;
+ }
+
+ if (Translation.translationListener) {
+ Translation.translationListener();
+ }
+ }
+
+ infobarClosed() {
+ if (this.state == Translation.STATE_OFFER) {
+ TranslationTelemetry.recordDeniedTranslationOffer();
+ }
+ }
+}
+
+/**
+ * Uses telemetry histograms for collecting statistics on the usage of the
+ * translation component.
+ *
+ * NOTE: Metrics are only recorded if the user enabled the telemetry option.
+ */
+var TranslationTelemetry = {
+ init() {
+ // Constructing histograms.
+ const plain = id => Services.telemetry.getHistogramById(id);
+ const keyed = id => Services.telemetry.getKeyedHistogramById(id);
+ this.HISTOGRAMS = {
+ OPPORTUNITIES: () => plain("TRANSLATION_OPPORTUNITIES"),
+ OPPORTUNITIES_BY_LANG: () =>
+ keyed("TRANSLATION_OPPORTUNITIES_BY_LANGUAGE"),
+ PAGES: () => plain("TRANSLATED_PAGES"),
+ PAGES_BY_LANG: () => keyed("TRANSLATED_PAGES_BY_LANGUAGE"),
+ CHARACTERS: () => plain("TRANSLATED_CHARACTERS"),
+ DENIED: () => plain("DENIED_TRANSLATION_OFFERS"),
+ AUTO_REJECTED: () => plain("AUTO_REJECTED_TRANSLATION_OFFERS"),
+ SHOW_ORIGINAL: () => plain("REQUESTS_OF_ORIGINAL_CONTENT"),
+ TARGET_CHANGES: () => plain("CHANGES_OF_TARGET_LANGUAGE"),
+ DETECTION_CHANGES: () => plain("CHANGES_OF_DETECTED_LANGUAGE"),
+ SHOW_UI: () => plain("SHOULD_TRANSLATION_UI_APPEAR"),
+ DETECT_LANG: () => plain("SHOULD_AUTO_DETECT_LANGUAGE"),
+ };
+
+ // Capturing the values of flags at the startup.
+ this.recordPreferences();
+ },
+
+ /**
+ * Record a translation opportunity in the health report.
+ * @param language
+ * The language of the page.
+ */
+ recordTranslationOpportunity(language) {
+ return this._recordOpportunity(language, true);
+ },
+
+ /**
+ * Record a missed translation opportunity in the health report.
+ * A missed opportunity is when the language detected is not part
+ * of the supported languages.
+ * @param language
+ * The language of the page.
+ */
+ recordMissedTranslationOpportunity(language) {
+ return this._recordOpportunity(language, false);
+ },
+
+ /**
+ * Record an automatically rejected translation offer in the health
+ * report. A translation offer is automatically rejected when a user
+ * has previously clicked "Never translate this language" or "Never
+ * translate this site", which results in the infobar not being shown for
+ * the translation opportunity.
+ *
+ * These translation opportunities should still be recorded in addition to
+ * recording the automatic rejection of the offer.
+ */
+ recordAutoRejectedTranslationOffer() {
+ this.HISTOGRAMS.AUTO_REJECTED().add();
+ },
+
+ /**
+ * Record a translation in the health report.
+ * @param langFrom
+ * The language of the page.
+ * @param langTo
+ * The language translated to
+ * @param numCharacters
+ * The number of characters that were translated
+ */
+ recordTranslation(langFrom, langTo, numCharacters) {
+ this.HISTOGRAMS.PAGES().add();
+ this.HISTOGRAMS.PAGES_BY_LANG().add(langFrom + " -> " + langTo);
+ this.HISTOGRAMS.CHARACTERS().add(numCharacters);
+ },
+
+ /**
+ * Record a change of the detected language in the health report. This should
+ * only be called when actually executing a translation, not every time the
+ * user changes in the language in the UI.
+ *
+ * @param beforeFirstTranslation
+ * A boolean indicating if we are recording a change of detected
+ * language before translating the page for the first time. If we
+ * have already translated the page from the detected language and
+ * the user has manually adjusted the detected language false should
+ * be passed.
+ */
+ recordDetectedLanguageChange(beforeFirstTranslation) {
+ this.HISTOGRAMS.DETECTION_CHANGES().add(beforeFirstTranslation);
+ },
+
+ /**
+ * Record a change of the target language in the health report. This should
+ * only be called when actually executing a translation, not every time the
+ * user changes in the language in the UI.
+ */
+ recordTargetLanguageChange() {
+ this.HISTOGRAMS.TARGET_CHANGES().add();
+ },
+
+ /**
+ * Record a denied translation offer.
+ */
+ recordDeniedTranslationOffer() {
+ this.HISTOGRAMS.DENIED().add();
+ },
+
+ /**
+ * Record a "Show Original" command use.
+ */
+ recordShowOriginalContent() {
+ this.HISTOGRAMS.SHOW_ORIGINAL().add();
+ },
+
+ /**
+ * Record the state of translation preferences.
+ */
+ recordPreferences() {
+ if (Services.prefs.getBoolPref(TRANSLATION_PREF_SHOWUI)) {
+ this.HISTOGRAMS.SHOW_UI().add(1);
+ }
+ if (Services.prefs.getBoolPref(TRANSLATION_PREF_DETECT_LANG)) {
+ this.HISTOGRAMS.DETECT_LANG().add(1);
+ }
+ },
+
+ _recordOpportunity(language, success) {
+ this.HISTOGRAMS.OPPORTUNITIES().add(success);
+ this.HISTOGRAMS.OPPORTUNITIES_BY_LANG().add(language, success);
+ },
+};
+
+TranslationTelemetry.init();
diff --git a/browser/components/translation/YandexTranslator.jsm b/browser/components/translation/YandexTranslator.jsm
new file mode 100644
index 0000000000..c44283733d
--- /dev/null
+++ b/browser/components/translation/YandexTranslator.jsm
@@ -0,0 +1,357 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["YandexTranslator"];
+
+const { PromiseUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromiseUtils.sys.mjs"
+);
+const { Async } = ChromeUtils.importESModule(
+ "resource://services-common/async.sys.mjs"
+);
+const { httpRequest } = ChromeUtils.importESModule(
+ "resource://gre/modules/Http.sys.mjs"
+);
+
+// The maximum amount of net data allowed per request on Bing's API.
+const MAX_REQUEST_DATA = 5000; // Documentation says 10000 but anywhere
+// close to that is refused by the service.
+
+// The maximum number of chunks allowed to be translated in a single
+// request.
+const MAX_REQUEST_CHUNKS = 1000; // Documentation says 2000.
+
+// Self-imposed limit of 15 requests. This means that a page that would need
+// to be broken in more than 15 requests won't be fully translated.
+// The maximum amount of data that we will translate for a single page
+// is MAX_REQUESTS * MAX_REQUEST_DATA.
+const MAX_REQUESTS = 15;
+
+const YANDEX_ERR_KEY_INVALID = 401; // Invalid API key
+const YANDEX_ERR_KEY_BLOCKED = 402; // This API key has been blocked
+const YANDEX_ERR_DAILY_REQ_LIMIT_EXCEEDED = 403; // Daily limit for requests reached
+const YANDEX_ERR_DAILY_CHAR_LIMIT_EXCEEDED = 404; // Daily limit of chars reached
+// const YANDEX_ERR_TEXT_TOO_LONG = 413; // The text size exceeds the maximum
+// const YANDEX_ERR_UNPROCESSABLE_TEXT = 422; // The text could not be translated
+// const YANDEX_ERR_LANG_NOT_SUPPORTED = 501; // The specified translation direction is not supported
+
+// Errors that should activate the service unavailable handling
+const YANDEX_PERMANENT_ERRORS = [
+ YANDEX_ERR_KEY_INVALID,
+ YANDEX_ERR_KEY_BLOCKED,
+ YANDEX_ERR_DAILY_REQ_LIMIT_EXCEEDED,
+ YANDEX_ERR_DAILY_CHAR_LIMIT_EXCEEDED,
+];
+
+/**
+ * Translates a webpage using Yandex's Translation API.
+ *
+ * @param translationDocument The TranslationDocument object that represents
+ * the webpage to be translated
+ * @param sourceLanguage The source language of the document
+ * @param targetLanguage The target language for the translation
+ *
+ * @returns {Promise} A promise that will resolve when the translation
+ * task is finished.
+ */
+var YandexTranslator = function (
+ translationDocument,
+ sourceLanguage,
+ targetLanguage
+) {
+ this.translationDocument = translationDocument;
+ this.sourceLanguage = sourceLanguage;
+ this.targetLanguage = targetLanguage;
+ this._pendingRequests = 0;
+ this._partialSuccess = false;
+ this._serviceUnavailable = false;
+ this._translatedCharacterCount = 0;
+};
+
+YandexTranslator.prototype = {
+ /**
+ * Performs the translation, splitting the document into several chunks
+ * respecting the data limits of the API.
+ *
+ * @returns {Promise} A promise that will resolve when the translation
+ * task is finished.
+ */
+ translate() {
+ return (async () => {
+ let currentIndex = 0;
+ this._onFinishedDeferred = PromiseUtils.defer();
+
+ // Let's split the document into various requests to be sent to
+ // Yandex's Translation API.
+ for (let requestCount = 0; requestCount < MAX_REQUESTS; requestCount++) {
+ // Generating the text for each request can be expensive, so
+ // let's take the opportunity of the chunkification process to
+ // allow for the event loop to attend other pending events
+ // before we continue.
+ await Async.promiseYield();
+
+ // Determine the data for the next request.
+ let request = this._generateNextTranslationRequest(currentIndex);
+
+ // Create a real request to the server, and put it on the
+ // pending requests list.
+ let yandexRequest = new YandexRequest(
+ request.data,
+ this.sourceLanguage,
+ this.targetLanguage
+ );
+ this._pendingRequests++;
+ yandexRequest
+ .fireRequest()
+ .then(this._chunkCompleted.bind(this), this._chunkFailed.bind(this));
+
+ currentIndex = request.lastIndex;
+ if (request.finished) {
+ break;
+ }
+ }
+
+ return this._onFinishedDeferred.promise;
+ })();
+ },
+
+ /**
+ * Function called when a request sent to the server completed successfully.
+ * This function handles calling the function to parse the result and the
+ * function to resolve the promise returned by the public `translate()`
+ * method when there are no pending requests left.
+ *
+ * @param request The YandexRequest sent to the server
+ */
+ _chunkCompleted(yandexRequest) {
+ if (this._parseChunkResult(yandexRequest)) {
+ this._partialSuccess = true;
+ // Count the number of characters successfully translated.
+ this._translatedCharacterCount += yandexRequest.characterCount;
+ }
+
+ this._checkIfFinished();
+ },
+
+ /**
+ * Function called when a request sent to the server has failed.
+ * This function handles deciding if the error is transient or means the
+ * service is unavailable (zero balance on the key or request credentials are
+ * not in an active state) and calling the function to resolve the promise
+ * returned by the public `translate()` method when there are no pending
+ * requests left.
+ *
+ * @param aError [optional] The XHR object of the request that failed.
+ */
+ _chunkFailed(aError) {
+ if (XMLHttpRequest.isInstance(aError)) {
+ let body = aError.responseText;
+ let json = { code: 0 };
+ try {
+ json = JSON.parse(body);
+ } catch (e) {}
+
+ if (json.code && YANDEX_PERMANENT_ERRORS.includes(json.code)) {
+ this._serviceUnavailable = true;
+ }
+ }
+
+ this._checkIfFinished();
+ },
+
+ /**
+ * Function called when a request sent to the server has completed.
+ * This function handles resolving the promise
+ * returned by the public `translate()` method when all chunks are completed.
+ */
+ _checkIfFinished() {
+ // Check if all pending requests have been
+ // completed and then resolves the promise.
+ // If at least one chunk was successful, the
+ // promise will be resolved positively which will
+ // display the "Success" state for the infobar. Otherwise,
+ // the "Error" state will appear.
+ if (--this._pendingRequests == 0) {
+ if (this._partialSuccess) {
+ this._onFinishedDeferred.resolve({
+ characterCount: this._translatedCharacterCount,
+ });
+ } else {
+ let error = this._serviceUnavailable ? "unavailable" : "failure";
+ this._onFinishedDeferred.reject(error);
+ }
+ }
+ },
+
+ /**
+ * This function parses the result returned by Yandex's Translation API,
+ * which returns a JSON result that contains a number of elements. The
+ * API is documented here:
+ * http://api.yandex.com/translate/doc/dg/reference/translate.xml
+ *
+ * @param request The request sent to the server.
+ * @returns boolean True if parsing of this chunk was successful.
+ */
+ _parseChunkResult(yandexRequest) {
+ let results;
+ try {
+ let result = JSON.parse(yandexRequest.networkRequest.responseText);
+ if (result.code != 200) {
+ Services.console.logStringMessage(
+ "YandexTranslator: Result is " + result.code
+ );
+ return false;
+ }
+ results = result.text;
+ } catch (e) {
+ return false;
+ }
+
+ let len = results.length;
+ if (len != yandexRequest.translationData.length) {
+ // This should never happen, but if the service returns a different number
+ // of items (from the number of items submitted), we can't use this chunk
+ // because all items would be paired incorrectly.
+ return false;
+ }
+
+ let error = false;
+ for (let i = 0; i < len; i++) {
+ try {
+ let result = results[i];
+ let root = yandexRequest.translationData[i][0];
+ root.parseResult(result);
+ } catch (e) {
+ error = true;
+ }
+ }
+
+ return !error;
+ },
+
+ /**
+ * This function will determine what is the data to be used for
+ * the Nth request we are generating, based on the input params.
+ *
+ * @param startIndex What is the index, in the roots list, that the
+ * chunk should start.
+ */
+ _generateNextTranslationRequest(startIndex) {
+ let currentDataSize = 0;
+ let currentChunks = 0;
+ let output = [];
+ let rootsList = this.translationDocument.roots;
+
+ for (let i = startIndex; i < rootsList.length; i++) {
+ let root = rootsList[i];
+ let text = this.translationDocument.generateTextForItem(root);
+ if (!text) {
+ continue;
+ }
+
+ let newCurSize = currentDataSize + text.length;
+ let newChunks = currentChunks + 1;
+
+ if (newCurSize > MAX_REQUEST_DATA || newChunks > MAX_REQUEST_CHUNKS) {
+ // If we've reached the API limits, let's stop accumulating data
+ // for this request and return. We return information useful for
+ // the caller to pass back on the next call, so that the function
+ // can keep working from where it stopped.
+ return {
+ data: output,
+ finished: false,
+ lastIndex: i,
+ };
+ }
+
+ currentDataSize = newCurSize;
+ currentChunks = newChunks;
+ output.push([root, text]);
+ }
+
+ return {
+ data: output,
+ finished: true,
+ lastIndex: 0,
+ };
+ },
+};
+
+/**
+ * Represents a request (for 1 chunk) sent off to Yandex's service.
+ *
+ * @params translationData The data to be used for this translation,
+ * generated by the generateNextTranslationRequest...
+ * function.
+ * @param sourceLanguage The source language of the document.
+ * @param targetLanguage The target language for the translation.
+ *
+ */
+function YandexRequest(translationData, sourceLanguage, targetLanguage) {
+ this.translationData = translationData;
+ this.sourceLanguage = sourceLanguage;
+ this.targetLanguage = targetLanguage;
+ this.characterCount = 0;
+}
+
+YandexRequest.prototype = {
+ /**
+ * Initiates the request
+ */
+ fireRequest() {
+ return (async () => {
+ // Prepare URL.
+ let url = getUrlParam(
+ "https://translate.yandex.net/api/v1.5/tr.json/translate",
+ "browser.translation.yandex.translateURLOverride"
+ );
+
+ // Prepare the request body.
+ let apiKey = getUrlParam(
+ "%YANDEX_API_KEY%",
+ "browser.translation.yandex.apiKeyOverride"
+ );
+ let params = [
+ ["key", apiKey],
+ ["format", "html"],
+ ["lang", this.sourceLanguage + "-" + this.targetLanguage],
+ ];
+
+ for (let [, text] of this.translationData) {
+ params.push(["text", text]);
+ this.characterCount += text.length;
+ }
+
+ // Set up request options.
+ return new Promise((resolve, reject) => {
+ let options = {
+ onLoad: (responseText, xhr) => {
+ resolve(this);
+ },
+ onError(e, responseText, xhr) {
+ reject(xhr);
+ },
+ postData: params,
+ };
+
+ // Fire the request.
+ this.networkRequest = httpRequest(url, options);
+ });
+ })();
+ },
+};
+
+/**
+ * Fetch an auth token (clientID or client secret), which may be overridden by
+ * a pref if it's set.
+ */
+function getUrlParam(paramValue, prefName) {
+ if (Services.prefs.getPrefType(prefName)) {
+ paramValue = Services.prefs.getCharPref(prefName);
+ }
+ paramValue = Services.urlFormatter.formatURL(paramValue);
+ return paramValue;
+}
diff --git a/browser/components/translation/content/.eslintrc.js b/browser/components/translation/content/.eslintrc.js
new file mode 100644
index 0000000000..43ab18578d
--- /dev/null
+++ b/browser/components/translation/content/.eslintrc.js
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+module.exports = {
+ env: {
+ "mozilla/browser-window": true,
+ },
+
+ plugins: ["mozilla"],
+};
diff --git a/browser/components/translation/content/jar.mn b/browser/components/translation/content/jar.mn
new file mode 100644
index 0000000000..046645bcb7
--- /dev/null
+++ b/browser/components/translation/content/jar.mn
@@ -0,0 +1,5 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+browser.jar:
+ content/browser/microsoft-translator-attribution.png
diff --git a/browser/components/translation/content/microsoft-translator-attribution.png b/browser/components/translation/content/microsoft-translator-attribution.png
new file mode 100644
index 0000000000..bd96cf2036
--- /dev/null
+++ b/browser/components/translation/content/microsoft-translator-attribution.png
Binary files differ
diff --git a/browser/components/translation/content/moz.build b/browser/components/translation/content/moz.build
new file mode 100644
index 0000000000..d988c0ff9b
--- /dev/null
+++ b/browser/components/translation/content/moz.build
@@ -0,0 +1,7 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/browser/components/translation/moz.build b/browser/components/translation/moz.build
new file mode 100644
index 0000000000..02968f17a9
--- /dev/null
+++ b/browser/components/translation/moz.build
@@ -0,0 +1,21 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+DIRS += [
+ "content",
+]
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "Translation")
+
+EXTRA_JS_MODULES.translation = [
+ "BingTranslator.jsm",
+ "GoogleTranslator.jsm",
+ "TranslationChild.jsm",
+ "TranslationDocument.jsm",
+ "TranslationParent.jsm",
+ "YandexTranslator.jsm",
+]
+
+BROWSER_CHROME_MANIFESTS += ["test/browser.ini"]
diff --git a/browser/components/translation/test/bing.sjs b/browser/components/translation/test/bing.sjs
new file mode 100644
index 0000000000..3769517e36
--- /dev/null
+++ b/browser/components/translation/test/bing.sjs
@@ -0,0 +1,250 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const CC = Components.Constructor;
+const BinaryInputStream = CC(
+ "@mozilla.org/binaryinputstream;1",
+ "nsIBinaryInputStream",
+ "setInputStream"
+);
+Cu.importGlobalProperties(["DOMParser", "TextEncoder"]);
+
+function handleRequest(req, res) {
+ try {
+ reallyHandleRequest(req, res);
+ } catch (ex) {
+ res.setStatusLine("1.0", 200, "AlmostOK");
+ let msg = "Error handling request: " + ex + "\n" + ex.stack;
+ log(msg);
+ res.write(msg);
+ }
+}
+
+function log(msg) {
+ // dump("BING-SERVER-MOCK: " + msg + "\n");
+}
+
+const statusCodes = {
+ 400: "Bad Request",
+ 401: "Unauthorized",
+ 403: "Forbidden",
+ 404: "Not Found",
+ 405: "Method Not Allowed",
+ 500: "Internal Server Error",
+ 501: "Not Implemented",
+ 503: "Service Unavailable",
+};
+
+function HTTPError(code = 500, message) {
+ this.code = code;
+ this.name = statusCodes[code] || "HTTPError";
+ this.message = message || this.name;
+}
+HTTPError.prototype = new Error();
+HTTPError.prototype.constructor = HTTPError;
+
+function sendError(res, err) {
+ if (!(err instanceof HTTPError)) {
+ err = new HTTPError(
+ typeof err == "number" ? err : 500,
+ err.message || typeof err == "string" ? err : ""
+ );
+ }
+ res.setStatusLine("1.1", err.code, err.name);
+ res.write(err.message);
+}
+
+function parseQuery(query) {
+ let ret = {};
+ for (let param of query.replace(/^[?&]/, "").split("&")) {
+ param = param.split("=");
+ if (!param[0]) {
+ continue;
+ }
+ ret[unescape(param[0])] = unescape(param[1]);
+ }
+ return ret;
+}
+
+function getRequestBody(req) {
+ let avail;
+ let bytes = [];
+ let body = new BinaryInputStream(req.bodyInputStream);
+
+ while ((avail = body.available()) > 0) {
+ Array.prototype.push.apply(bytes, body.readByteArray(avail));
+ }
+
+ return String.fromCharCode.apply(null, bytes);
+}
+
+function sha1(str) {
+ // `data` is an array of bytes.
+ let data = new TextEncoder().encode(str);
+ let ch = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash);
+ ch.init(ch.SHA1);
+ ch.update(data, data.length);
+ let hash = ch.finish(false);
+
+ // Return the two-digit hexadecimal code for a byte.
+ function toHexString(charCode) {
+ return ("0" + charCode.toString(16)).slice(-2);
+ }
+
+ // Convert the binary hash data to a hex string.
+ return Array.from(hash, (c, i) => toHexString(hash.charCodeAt(i))).join("");
+}
+
+function parseXml(body) {
+ let parser = new DOMParser();
+ let xml = parser.parseFromString(body, "text/xml");
+ if (xml.documentElement.localName == "parsererror") {
+ throw new Error("Invalid XML");
+ }
+ return xml;
+}
+
+function getInputStream(path) {
+ let file = Services.dirsvc.get("CurWorkD", Ci.nsIFile);
+ for (let part of path.split("/")) {
+ file.append(part);
+ }
+ let fileStream = Cc[
+ "@mozilla.org/network/file-input-stream;1"
+ ].createInstance(Ci.nsIFileInputStream);
+ fileStream.init(file, 1, 0, false);
+ return fileStream;
+}
+
+function checkAuth(req) {
+ let err = new Error("Authorization failed");
+ err.code = 401;
+
+ if (!req.hasHeader("Authorization")) {
+ throw new HTTPError(401, "No Authorization header provided.");
+ }
+
+ let auth = req.getHeader("Authorization");
+ if (!auth.startsWith("Bearer ")) {
+ throw new HTTPError(
+ 401,
+ "Invalid Authorization header content: '" + auth + "'"
+ );
+ }
+
+ // Rejecting inactive subscriptions.
+ if (auth.includes("inactive")) {
+ const INACTIVE_STATE_RESPONSE =
+ "<html><body><h1>TranslateApiException</h1><p>Method: TranslateArray()</p><p>Message: The Azure Market Place Translator Subscription associated with the request credentials is not in an active state.</p><code></code><p>message id=5641.V2_Rest.TranslateArray.48CC6470</p></body></html>";
+ throw new HTTPError(401, INACTIVE_STATE_RESPONSE);
+ }
+}
+
+function reallyHandleRequest(req, res) {
+ log("method: " + req.method);
+ if (req.method != "POST") {
+ sendError(
+ res,
+ "Bing only deals with POST requests, not '" + req.method + "'."
+ );
+ return;
+ }
+
+ let body = getRequestBody(req);
+ log("body: " + body);
+
+ // First, we'll see if we're dealing with an XML body:
+ let contentType = req.hasHeader("Content-Type")
+ ? req.getHeader("Content-Type")
+ : null;
+ log("contentType: " + contentType);
+
+ if (contentType.startsWith("text/xml")) {
+ try {
+ // For all these requests the client needs to supply the correct
+ // authentication headers.
+ checkAuth(req);
+
+ let xml = parseXml(body);
+ let method = xml.documentElement.localName;
+ log("invoking method: " + method);
+ // If the requested method is supported, delegate it to its handler.
+ if (methodHandlers[method]) {
+ methodHandlers[method](res, xml);
+ } else {
+ throw new HTTPError(501);
+ }
+ } catch (ex) {
+ sendError(res, ex, ex.code);
+ }
+ } else {
+ // Not XML, so it must be a query-string.
+ let params = parseQuery(body);
+
+ // Delegate an authentication request to the correct handler.
+ if ("grant_type" in params && params.grant_type == "client_credentials") {
+ methodHandlers.authenticate(res, params);
+ } else {
+ sendError(res, 501);
+ }
+ }
+}
+
+const methodHandlers = {
+ authenticate(res, params) {
+ // Validate a few required parameters.
+ if (params.scope != "http://api.microsofttranslator.com") {
+ sendError(res, "Invalid scope.");
+ return;
+ }
+ if (!params.client_id) {
+ sendError(res, "Missing client_id param.");
+ return;
+ }
+ if (!params.client_secret) {
+ sendError(res, "Missing client_secret param.");
+ return;
+ }
+
+ // Defines the tokens for certain client ids.
+ const TOKEN_MAP = {
+ testInactive: "inactive",
+ testClient: "test",
+ };
+ let token = "test"; // Default token.
+ if (params.client_id in TOKEN_MAP) {
+ token = TOKEN_MAP[params.client_id];
+ }
+ let content = JSON.stringify({
+ access_token: token,
+ expires_in: 600,
+ });
+
+ res.setStatusLine("1.1", 200, "OK");
+ res.setHeader("Content-Length", String(content.length));
+ res.setHeader("Content-Type", "application/json");
+ res.write(content);
+ },
+
+ TranslateArrayRequest(res, xml, body) {
+ let from = xml.querySelector("From").firstChild.nodeValue;
+ let to = xml.querySelector("To").firstChild.nodeValue;
+ log("translating from '" + from + "' to '" + to + "'");
+
+ res.setStatusLine("1.1", 200, "OK");
+ res.setHeader("Content-Type", "text/xml");
+
+ let hash = sha1(body).substr(0, 10);
+ log("SHA1 hash of content: " + hash);
+ let inputStream = getInputStream(
+ "browser/browser/components/translation/test/fixtures/result-" +
+ hash +
+ ".txt"
+ );
+ res.bodyOutputStream.writeFrom(inputStream, inputStream.available());
+ inputStream.close();
+ },
+};
diff --git a/browser/components/translation/test/browser.ini b/browser/components/translation/test/browser.ini
new file mode 100644
index 0000000000..2c09e9992a
--- /dev/null
+++ b/browser/components/translation/test/browser.ini
@@ -0,0 +1,13 @@
+[DEFAULT]
+support-files =
+ bing.sjs
+ yandex.sjs
+ fixtures/bug1022725-fr.html
+ fixtures/result-da39a3ee5e.txt
+ fixtures/result-yandex-d448894848.json
+
+[browser_translation_bing.js]
+[browser_translation_exceptions.js]
+https_first_disabled = true
+[browser_translation_yandex.js]
+[browser_translations_settings.js]
diff --git a/browser/components/translation/test/browser_translation_bing.js b/browser/components/translation/test/browser_translation_bing.js
new file mode 100644
index 0000000000..8b4620f059
--- /dev/null
+++ b/browser/components/translation/test/browser_translation_bing.js
@@ -0,0 +1,160 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Test the Bing Translator client against a mock Bing service, bing.sjs.
+
+"use strict";
+
+const kClientIdPref = "browser.translation.bing.clientIdOverride";
+const kClientSecretPref = "browser.translation.bing.apiKeyOverride";
+
+const { BingTranslator } = ChromeUtils.import(
+ "resource:///modules/translation/BingTranslator.jsm"
+);
+const { TranslationDocument } = ChromeUtils.import(
+ "resource:///modules/translation/TranslationDocument.jsm"
+);
+
+add_setup(async function () {
+ Services.prefs.setCharPref(kClientIdPref, "testClient");
+ Services.prefs.setCharPref(kClientSecretPref, "testSecret");
+
+ registerCleanupFunction(function () {
+ Services.prefs.clearUserPref(kClientIdPref);
+ Services.prefs.clearUserPref(kClientSecretPref);
+ });
+});
+
+/**
+ * Checks if the translation is happening.
+ */
+add_task(async function test_bing_translation() {
+ // Ensure the correct client id is used for authentication.
+ Services.prefs.setCharPref(kClientIdPref, "testClient");
+
+ // Loading the fixture page.
+ let url = constructFixtureURL("bug1022725-fr.html");
+ let tab = await promiseTestPageLoad(url);
+
+ // Translating the contents of the loaded tab.
+ gBrowser.selectedTab = tab;
+ let browser = tab.linkedBrowser;
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ // eslint-disable-next-line no-shadow
+ const { BingTranslator } = ChromeUtils.import(
+ "resource:///modules/translation/BingTranslator.jsm"
+ );
+ // eslint-disable-next-line no-shadow
+ const { TranslationDocument } = ChromeUtils.import(
+ "resource:///modules/translation/TranslationDocument.jsm"
+ );
+
+ let client = new BingTranslator(
+ new TranslationDocument(content.document),
+ "fr",
+ "en"
+ );
+ let result = await client.translate();
+
+ // XXXmikedeboer; here you would continue the test/ content inspection.
+ Assert.ok(result, "There should be a result");
+ });
+
+ gBrowser.removeTab(tab);
+});
+
+/**
+ * Ensures that the BingTranslator handles out-of-valid-key response
+ * correctly. Sometimes Bing Translate replies with
+ * "request credentials is not in an active state" error. BingTranslator
+ * should catch this error and classify it as Service Unavailable.
+ *
+ */
+add_task(async function test_handling_out_of_valid_key_error() {
+ // Simulating request from inactive subscription.
+ Services.prefs.setCharPref(kClientIdPref, "testInactive");
+
+ // Loading the fixture page.
+ let url = constructFixtureURL("bug1022725-fr.html");
+ let tab = await promiseTestPageLoad(url);
+
+ // Translating the contents of the loaded tab.
+ gBrowser.selectedTab = tab;
+ let browser = tab.linkedBrowser;
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ // eslint-disable-next-line no-shadow
+ const { BingTranslator } = ChromeUtils.import(
+ "resource:///modules/translation/BingTranslator.jsm"
+ );
+ // eslint-disable-next-line no-shadow
+ const { TranslationDocument } = ChromeUtils.import(
+ "resource:///modules/translation/TranslationDocument.jsm"
+ );
+
+ let client = new BingTranslator(
+ new TranslationDocument(content.document),
+ "fr",
+ "en"
+ );
+ client._resetToken();
+ try {
+ await client.translate();
+ } catch (ex) {
+ // It is alright that the translation fails.
+ }
+ client._resetToken();
+
+ // Checking if the client detected service and unavailable.
+ Assert.ok(
+ client._serviceUnavailable,
+ "Service should be detected unavailable."
+ );
+ });
+
+ // Cleaning up.
+ Services.prefs.setCharPref(kClientIdPref, "testClient");
+ gBrowser.removeTab(tab);
+});
+
+/**
+ * A helper function for constructing a URL to a page stored in the
+ * local fixture folder.
+ *
+ * @param filename Name of a fixture file.
+ */
+function constructFixtureURL(filename) {
+ // Deduce the Mochitest server address in use from a pref that was pre-processed.
+ let server = Services.prefs
+ .getCharPref("browser.translation.bing.authURL")
+ .replace("http://", "");
+ server = server.substr(0, server.indexOf("/"));
+ let url =
+ "http://" +
+ server +
+ "/browser/browser/components/translation/test/fixtures/" +
+ filename;
+ return url;
+}
+
+/**
+ * A helper function to open a new tab and wait for its content to load.
+ *
+ * @param String url A URL to be loaded in the new tab.
+ */
+function promiseTestPageLoad(url) {
+ return new Promise(resolve => {
+ let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, url));
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ loadurl => loadurl != "about:blank"
+ ).then(() => {
+ info("Page loaded: " + browser.currentURI.spec);
+ resolve(tab);
+ });
+ });
+}
diff --git a/browser/components/translation/test/browser_translation_exceptions.js b/browser/components/translation/test/browser_translation_exceptions.js
new file mode 100644
index 0000000000..c0d325c6f7
--- /dev/null
+++ b/browser/components/translation/test/browser_translation_exceptions.js
@@ -0,0 +1,239 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// TODO (Bug 1817084) Remove this file when we disable the extension
+// tests the translation infobar, using a fake 'Translation' implementation.
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const kLanguagesPref = "browser.translation.neverForLanguages";
+const kShowUIPref = "browser.translation.ui.show";
+const kEnableTranslationPref = "browser.translation.detectLanguage";
+
+function test() {
+ waitForExplicitFinish();
+
+ Services.prefs.setBoolPref(kShowUIPref, true);
+ Services.prefs.setBoolPref(kEnableTranslationPref, true);
+ let tab = BrowserTestUtils.addTab(gBrowser);
+ gBrowser.selectedTab = tab;
+ registerCleanupFunction(function () {
+ gBrowser.removeTab(tab);
+ Services.prefs.clearUserPref(kShowUIPref);
+ Services.prefs.clearUserPref(kEnableTranslationPref);
+ });
+ BrowserTestUtils.browserLoaded(tab.linkedBrowser).then(() => {
+ (async function () {
+ for (let testCase of gTests) {
+ info(testCase.desc);
+ await testCase.run();
+ }
+ })().then(finish, ex => {
+ ok(false, "Unexpected Exception: " + ex);
+ finish();
+ });
+ });
+
+ BrowserTestUtils.loadURIString(
+ gBrowser.selectedBrowser,
+ "http://example.com/"
+ );
+}
+
+function getLanguageExceptions() {
+ let langs = Services.prefs.getCharPref(kLanguagesPref);
+ return langs ? langs.split(",") : [];
+}
+
+function getDomainExceptions() {
+ let results = [];
+ for (let perm of Services.perms.all) {
+ if (
+ perm.type == "translate" &&
+ perm.capability == Services.perms.DENY_ACTION
+ ) {
+ results.push(perm.principal);
+ }
+ }
+
+ return results;
+}
+
+function openPopup(aPopup) {
+ return new Promise(resolve => {
+ aPopup.addEventListener(
+ "popupshown",
+ function () {
+ TestUtils.executeSoon(resolve);
+ },
+ { once: true }
+ );
+
+ aPopup.focus();
+ // One down event to open the popup.
+ EventUtils.synthesizeKey("VK_DOWN", {
+ altKey: !navigator.platform.includes("Mac"),
+ });
+ });
+}
+
+function waitForWindowLoad(aWin) {
+ return new Promise(resolve => {
+ aWin.addEventListener(
+ "load",
+ function () {
+ TestUtils.executeSoon(resolve);
+ },
+ { capture: true, once: true }
+ );
+ });
+}
+
+var gTests = [
+ {
+ desc: "clean exception lists at startup",
+ run: function checkNeverForLanguage() {
+ is(
+ getLanguageExceptions().length,
+ 0,
+ "we start with an empty list of languages to never translate"
+ );
+ is(
+ getDomainExceptions().length,
+ 0,
+ "we start with an empty list of sites to never translate"
+ );
+ },
+ },
+
+ {
+ desc: "language exception list",
+ run: async function checkLanguageExceptions() {
+ // Put 2 languages in the pref before opening the window to check
+ // the list is displayed on load.
+ Services.prefs.setCharPref(kLanguagesPref, "fr,de");
+
+ // Open the translation exceptions dialog.
+ let win = openDialog(
+ "chrome://browser/content/preferences/dialogs/translationExceptions.xhtml",
+ "Browser:TranslationExceptions",
+ "",
+ null
+ );
+ await waitForWindowLoad(win);
+
+ // Check that the list of language exceptions is loaded.
+ let getById = win.document.getElementById.bind(win.document);
+ let tree = getById("languagesTree");
+ let remove = getById("removeLanguage");
+ let removeAll = getById("removeAllLanguages");
+ is(tree.view.rowCount, 2, "The language exceptions list has 2 items");
+ ok(remove.disabled, "The 'Remove Language' button is disabled");
+ ok(!removeAll.disabled, "The 'Remove All Languages' button is enabled");
+
+ // Select the first item.
+ tree.view.selection.select(0);
+ ok(!remove.disabled, "The 'Remove Language' button is enabled");
+
+ // Click the 'Remove' button.
+ remove.click();
+ is(tree.view.rowCount, 1, "The language exceptions now contains 1 item");
+ is(getLanguageExceptions().length, 1, "One exception in the pref");
+
+ // Clear the pref, and check the last item is removed from the display.
+ Services.prefs.setCharPref(kLanguagesPref, "");
+ is(tree.view.rowCount, 0, "The language exceptions list is empty");
+ ok(remove.disabled, "The 'Remove Language' button is disabled");
+ ok(removeAll.disabled, "The 'Remove All Languages' button is disabled");
+
+ // Add an item and check it appears.
+ Services.prefs.setCharPref(kLanguagesPref, "fr");
+ is(tree.view.rowCount, 1, "The language exceptions list has 1 item");
+ ok(remove.disabled, "The 'Remove Language' button is disabled");
+ ok(!removeAll.disabled, "The 'Remove All Languages' button is enabled");
+
+ // Click the 'Remove All' button.
+ removeAll.click();
+ is(tree.view.rowCount, 0, "The language exceptions list is empty");
+ ok(remove.disabled, "The 'Remove Language' button is disabled");
+ ok(removeAll.disabled, "The 'Remove All Languages' button is disabled");
+ is(Services.prefs.getCharPref(kLanguagesPref), "", "The pref is empty");
+
+ win.close();
+ },
+ },
+
+ {
+ desc: "domains exception list",
+ run: async function checkDomainExceptions() {
+ // Put 2 exceptions before opening the window to check the list is
+ // displayed on load.
+ PermissionTestUtils.add(
+ "http://example.org",
+ "translate",
+ Services.perms.DENY_ACTION
+ );
+ PermissionTestUtils.add(
+ "http://example.com",
+ "translate",
+ Services.perms.DENY_ACTION
+ );
+
+ // Open the translation exceptions dialog.
+ let win = openDialog(
+ "chrome://browser/content/preferences/dialogs/translationExceptions.xhtml",
+ "Browser:TranslationExceptions",
+ "",
+ null
+ );
+ await waitForWindowLoad(win);
+
+ // Check that the list of language exceptions is loaded.
+ let getById = win.document.getElementById.bind(win.document);
+ let tree = getById("sitesTree");
+ let remove = getById("removeSite");
+ let removeAll = getById("removeAllSites");
+ is(tree.view.rowCount, 2, "The sites exceptions list has 2 items");
+ ok(remove.disabled, "The 'Remove Site' button is disabled");
+ ok(!removeAll.disabled, "The 'Remove All Sites' button is enabled");
+
+ // Select the first item.
+ tree.view.selection.select(0);
+ ok(!remove.disabled, "The 'Remove Site' button is enabled");
+
+ // Click the 'Remove' button.
+ remove.click();
+ is(tree.view.rowCount, 1, "The site exceptions now contains 1 item");
+ is(getDomainExceptions().length, 1, "One exception in the permissions");
+
+ // Clear the permissions, and check the last item is removed from the display.
+ PermissionTestUtils.remove("http://example.org", "translate");
+ PermissionTestUtils.remove("http://example.com", "translate");
+ is(tree.view.rowCount, 0, "The site exceptions list is empty");
+ ok(remove.disabled, "The 'Remove Site' button is disabled");
+ ok(removeAll.disabled, "The 'Remove All Site' button is disabled");
+
+ // Add an item and check it appears.
+ PermissionTestUtils.add(
+ "http://example.com",
+ "translate",
+ Services.perms.DENY_ACTION
+ );
+ is(tree.view.rowCount, 1, "The site exceptions list has 1 item");
+ ok(remove.disabled, "The 'Remove Site' button is disabled");
+ ok(!removeAll.disabled, "The 'Remove All Sites' button is enabled");
+
+ // Click the 'Remove All' button.
+ removeAll.click();
+ is(tree.view.rowCount, 0, "The site exceptions list is empty");
+ ok(remove.disabled, "The 'Remove Site' button is disabled");
+ ok(removeAll.disabled, "The 'Remove All Sites' button is disabled");
+ is(getDomainExceptions().length, 0, "No exceptions in the permissions");
+
+ win.close();
+ },
+ },
+];
diff --git a/browser/components/translation/test/browser_translation_yandex.js b/browser/components/translation/test/browser_translation_yandex.js
new file mode 100644
index 0000000000..c6c97571de
--- /dev/null
+++ b/browser/components/translation/test/browser_translation_yandex.js
@@ -0,0 +1,134 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Test the Yandex Translator client against a mock Yandex service, yandex.sjs.
+
+"use strict";
+
+// The folllowing rejection is left unhandled in some cases. This bug should be
+// fixed, but for the moment this file allows a class of rejections.
+//
+// NOTE: Allowing a whole class of rejections should be avoided. Normally you
+// should use "expectUncaughtRejection" to flag individual failures.
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(/NS_ERROR_ILLEGAL_VALUE/);
+
+const kEnginePref = "browser.translation.engine";
+const kApiKeyPref = "browser.translation.yandex.apiKeyOverride";
+const kDetectLanguagePref = "browser.translation.detectLanguage";
+const kShowUIPref = "browser.translation.ui.show";
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [kEnginePref, "Yandex"],
+ [kApiKeyPref, "yandexValidKey"],
+ [kDetectLanguagePref, true],
+ [kShowUIPref, true],
+ ],
+ });
+});
+
+/**
+ * Ensure that the translation engine behaives as expected when translating
+ * a sample page.
+ */
+add_task(async function test_yandex_translation() {
+ // Loading the fixture page.
+ let url = constructFixtureURL("bug1022725-fr.html");
+ let tab = await promiseTestPageLoad(url);
+
+ // Translating the contents of the loaded tab.
+ gBrowser.selectedTab = tab;
+ let browser = tab.linkedBrowser;
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ const { TranslationDocument } = ChromeUtils.import(
+ "resource:///modules/translation/TranslationDocument.jsm"
+ );
+ const { YandexTranslator } = ChromeUtils.import(
+ "resource:///modules/translation/YandexTranslator.jsm"
+ );
+
+ let client = new YandexTranslator(
+ new TranslationDocument(content.document),
+ "fr",
+ "en"
+ );
+ let result = await client.translate();
+
+ Assert.ok(result, "There should be a result.");
+ });
+
+ gBrowser.removeTab(tab);
+});
+
+add_task(async function test_preference_attribution() {
+ let prefUrl = "about:preferences#general";
+ let waitPrefLoaded = TestUtils.topicObserved("sync-pane-loaded", () => true);
+ let tab = await promiseTestPageLoad(prefUrl);
+ await waitPrefLoaded;
+ let browser = gBrowser.getBrowserForTab(tab);
+ let win = browser.contentWindow;
+ let bingAttribution = win.document.getElementById("bingAttribution");
+ ok(bingAttribution, "Bing attribution should exist.");
+ ok(bingAttribution.hidden, "Bing attribution should be hidden.");
+
+ gBrowser.removeTab(tab);
+});
+
+/**
+ * A helper function for constructing a URL to a page stored in the
+ * local fixture folder.
+ *
+ * @param filename Name of a fixture file.
+ */
+function constructFixtureURL(filename) {
+ // Deduce the Mochitest server address in use from a pref that was pre-processed.
+ let server = Services.prefs
+ .getCharPref("browser.translation.yandex.translateURLOverride")
+ .replace("http://", "");
+ server = server.substr(0, server.indexOf("/"));
+ let url =
+ "http://" +
+ server +
+ "/browser/browser/components/translation/test/fixtures/" +
+ filename;
+ return url;
+}
+
+/**
+ * A helper function to open a new tab and wait for its content to load.
+ *
+ * @param String url A URL to be loaded in the new tab.
+ */
+function promiseTestPageLoad(url) {
+ return new Promise(resolve => {
+ let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, url));
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ loadurl => loadurl != "about:blank"
+ ).then(() => {
+ info("Page loaded: " + browser.currentURI.spec);
+ resolve(tab);
+ });
+ });
+}
+
+function showTranslationUI(tab, aDetectedLanguage) {
+ let browser = gBrowser.selectedBrowser;
+ let actor =
+ browser.browsingContext.currentWindowGlobal.getActor("Translation");
+ actor.documentStateReceived({
+ state: Translation.STATE_OFFER,
+ originalShown: true,
+ detectedLanguage: aDetectedLanguage,
+ });
+
+ return actor.notificationBox.getNotificationWithValue("translation");
+}
diff --git a/browser/components/translation/test/browser_translations_settings.js b/browser/components/translation/test/browser_translations_settings.js
new file mode 100644
index 0000000000..87860ad9bf
--- /dev/null
+++ b/browser/components/translation/test/browser_translations_settings.js
@@ -0,0 +1,384 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// tests the translation infobar, using a fake 'Translation' implementation.
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const TRANSLATIONS_PERMISSION = "translations";
+const ENABLE_TRANSLATIONS_PREF = "browser.translations.enable";
+const ALWAYS_TRANSLATE_LANGS_PREF =
+ "browser.translations.alwaysTranslateLanguages";
+const NEVER_TRANSLATE_LANGS_PREF =
+ "browser.translations.neverTranslateLanguages";
+
+function test() {
+ waitForExplicitFinish();
+ Services.prefs.setBoolPref(ENABLE_TRANSLATIONS_PREF, true);
+ Services.prefs.setCharPref(ALWAYS_TRANSLATE_LANGS_PREF, "");
+ Services.prefs.setCharPref(NEVER_TRANSLATE_LANGS_PREF, "");
+ let tab = BrowserTestUtils.addTab(gBrowser);
+ gBrowser.selectedTab = tab;
+ registerCleanupFunction(function () {
+ gBrowser.removeTab(tab);
+ Services.prefs.clearUserPref(ENABLE_TRANSLATIONS_PREF);
+ Services.prefs.clearUserPref(ALWAYS_TRANSLATE_LANGS_PREF);
+ Services.prefs.clearUserPref(NEVER_TRANSLATE_LANGS_PREF);
+ });
+ BrowserTestUtils.browserLoaded(tab.linkedBrowser).then(() => {
+ (async function () {
+ for (let testCase of gTests) {
+ info(testCase.desc);
+ await testCase.run();
+ }
+ })().then(finish, ex => {
+ ok(false, "Unexpected Exception: " + ex);
+ finish();
+ });
+ });
+
+ BrowserTestUtils.loadURIString(
+ gBrowser.selectedBrowser,
+ "https://example.com/"
+ );
+}
+
+/**
+ * Retrieves the always-translate language list as an array.
+ * @returns {Array<string>}
+ */
+function getAlwaysTranslateLanguages() {
+ let langs = Services.prefs.getCharPref(ALWAYS_TRANSLATE_LANGS_PREF);
+ return langs ? langs.split(",") : [];
+}
+
+/**
+ * Retrieves the always-translate language list as an array.
+ * @returns {Array<string>}
+ */
+function getNeverTranslateLanguages() {
+ let langs = Services.prefs.getCharPref(NEVER_TRANSLATE_LANGS_PREF);
+ return langs ? langs.split(",") : [];
+}
+
+/**
+ * Retrieves the always-translate site list as an array.
+ * @returns {Array<string>}
+ */
+function getNeverTranslateSites() {
+ let results = [];
+ for (let perm of Services.perms.all) {
+ if (
+ perm.type == TRANSLATIONS_PERMISSION &&
+ perm.capability == Services.perms.DENY_ACTION
+ ) {
+ results.push(perm.principal);
+ }
+ }
+
+ return results;
+}
+
+function openPopup(aPopup) {
+ return new Promise(resolve => {
+ aPopup.addEventListener(
+ "popupshown",
+ function () {
+ TestUtils.executeSoon(resolve);
+ },
+ { once: true }
+ );
+
+ aPopup.focus();
+ // One down event to open the popup.
+ EventUtils.synthesizeKey("VK_DOWN");
+ });
+}
+
+function waitForWindowLoad(aWin) {
+ return new Promise(resolve => {
+ aWin.addEventListener(
+ "load",
+ function () {
+ TestUtils.executeSoon(resolve);
+ },
+ { capture: true, once: true }
+ );
+ });
+}
+
+var gTests = [
+ {
+ desc: "ensure lists are empty on startup",
+ run: function checkPreferencesAreEmpty() {
+ is(
+ getAlwaysTranslateLanguages().length,
+ 0,
+ "we start with an empty list of languages to always translate"
+ );
+ is(
+ getNeverTranslateLanguages().length,
+ 0,
+ "we start with an empty list of languages to never translate"
+ );
+ is(
+ getNeverTranslateSites().length,
+ 0,
+ "we start with an empty list of sites to never translate"
+ );
+ },
+ },
+
+ {
+ desc: "ensure always-translate languages function correctly",
+ run: async function testAlwaysTranslateLanguages() {
+ // Put 2 languages in the pref before opening the window to check
+ // the list is displayed on load.
+ Services.prefs.setCharPref(ALWAYS_TRANSLATE_LANGS_PREF, "fr,de");
+
+ // Open the translations settings dialog.
+ let win = openDialog(
+ "chrome://browser/content/preferences/dialogs/translations.xhtml",
+ "Browser:TranslationsPreferences",
+ "",
+ null
+ );
+ await waitForWindowLoad(win);
+
+ // Check that the list of always-translate languages is loaded.
+ let getById = win.document.getElementById.bind(win.document);
+ let tree = getById("alwaysTranslateLanguagesTree");
+ let remove = getById("removeAlwaysTranslateLanguage");
+ let removeAll = getById("removeAllAlwaysTranslateLanguages");
+ is(
+ tree.view.rowCount,
+ 2,
+ "The always-translate languages list has 2 items"
+ );
+ ok(remove.disabled, "The 'Remove Language' button is disabled");
+ ok(!removeAll.disabled, "The 'Remove All Languages' button is enabled");
+
+ // Select the first item.
+ tree.view.selection.select(0);
+ ok(!remove.disabled, "The 'Remove Language' button is enabled");
+
+ // Click the 'Remove' button.
+ remove.click();
+ is(
+ tree.view.rowCount,
+ 1,
+ "The always-translate languages list now contains 1 item"
+ );
+ is(
+ getAlwaysTranslateLanguages().length,
+ 1,
+ "One language tag in the pref"
+ );
+
+ // Clear the pref, and check the last item is removed from the display.
+ Services.prefs.setCharPref(ALWAYS_TRANSLATE_LANGS_PREF, "");
+ is(tree.view.rowCount, 0, "The always-translate languages list is empty");
+ ok(remove.disabled, "The 'Remove Language' button is disabled");
+ ok(removeAll.disabled, "The 'Remove All Languages' button is disabled");
+
+ // Add an item and check it appears.
+ Services.prefs.setCharPref(ALWAYS_TRANSLATE_LANGS_PREF, "fr,en,es");
+ is(
+ tree.view.rowCount,
+ 3,
+ "The always-translate languages list has 3 items"
+ );
+ ok(remove.disabled, "The 'Remove Language' button is disabled");
+ ok(!removeAll.disabled, "The 'Remove All Languages' button is enabled");
+
+ // Click the 'Remove All' button.
+ removeAll.click();
+ is(tree.view.rowCount, 0, "The always-translate languages list is empty");
+ ok(remove.disabled, "The 'Remove Language' button is disabled");
+ ok(removeAll.disabled, "The 'Remove All Languages' button is disabled");
+ is(
+ Services.prefs.getCharPref(ALWAYS_TRANSLATE_LANGS_PREF),
+ "",
+ "The pref is empty"
+ );
+
+ win.close();
+ },
+ },
+
+ {
+ desc: "ensure never-translate languages function correctly",
+ run: async function testNeverTranslateLanguages() {
+ // Put 2 languages in the pref before opening the window to check
+ // the list is displayed on load.
+ Services.prefs.setCharPref(NEVER_TRANSLATE_LANGS_PREF, "fr,de");
+
+ // Open the translations settings dialog.
+ let win = openDialog(
+ "chrome://browser/content/preferences/dialogs/translations.xhtml",
+ "Browser:TranslationsPreferences",
+ "",
+ null
+ );
+ await waitForWindowLoad(win);
+
+ // Check that the list of never-translate languages is loaded.
+ let getById = win.document.getElementById.bind(win.document);
+ let tree = getById("neverTranslateLanguagesTree");
+ let remove = getById("removeNeverTranslateLanguage");
+ let removeAll = getById("removeAllNeverTranslateLanguages");
+ is(
+ tree.view.rowCount,
+ 2,
+ "The never-translate languages list has 2 items"
+ );
+ ok(remove.disabled, "The 'Remove Language' button is disabled");
+ ok(!removeAll.disabled, "The 'Remove All Languages' button is enabled");
+
+ // Select the first item.
+ tree.view.selection.select(0);
+ ok(!remove.disabled, "The 'Remove Language' button is enabled");
+
+ // Click the 'Remove' button.
+ remove.click();
+ is(
+ tree.view.rowCount,
+ 1,
+ "The never-translate language list now contains 1 item"
+ );
+ is(getNeverTranslateLanguages().length, 1, "One langtag in the pref");
+
+ // Clear the pref, and check the last item is removed from the display.
+ Services.prefs.setCharPref(NEVER_TRANSLATE_LANGS_PREF, "");
+ is(tree.view.rowCount, 0, "The never-translate languages list is empty");
+ ok(remove.disabled, "The 'Remove Language' button is disabled");
+ ok(removeAll.disabled, "The 'Remove All Languages' button is disabled");
+
+ // Add an item and check it appears.
+ Services.prefs.setCharPref(NEVER_TRANSLATE_LANGS_PREF, "fr,en,es");
+ is(
+ tree.view.rowCount,
+ 3,
+ "The never-translate languages list has 3 items"
+ );
+ ok(remove.disabled, "The 'Remove Language' button is disabled");
+ ok(!removeAll.disabled, "The 'Remove All Languages' button is enabled");
+
+ // Click the 'Remove All' button.
+ removeAll.click();
+ is(tree.view.rowCount, 0, "The never-translate languages list is empty");
+ ok(remove.disabled, "The 'Remove Language' button is disabled");
+ ok(removeAll.disabled, "The 'Remove All Languages' button is disabled");
+ is(
+ Services.prefs.getCharPref(NEVER_TRANSLATE_LANGS_PREF),
+ "",
+ "The pref is empty"
+ );
+
+ win.close();
+ },
+ },
+
+ {
+ desc: "ensure never-translate sites function correctly",
+ run: async function testNeverTranslateSites() {
+ // Add two deny permissions before opening the window to
+ // check the list is displayed on load.
+ PermissionTestUtils.add(
+ "https://example.org",
+ TRANSLATIONS_PERMISSION,
+ Services.perms.DENY_ACTION
+ );
+ PermissionTestUtils.add(
+ "https://example.com",
+ TRANSLATIONS_PERMISSION,
+ Services.perms.DENY_ACTION
+ );
+
+ // Open the translations settings dialog.
+ let win = openDialog(
+ "chrome://browser/content/preferences/dialogs/translations.xhtml",
+ "Browser:TranslationsPreferences",
+ "",
+ null
+ );
+ await waitForWindowLoad(win);
+
+ // Check that the list of never-translate sites is loaded.
+ let getById = win.document.getElementById.bind(win.document);
+ let tree = getById("neverTranslateSitesTree");
+ let remove = getById("removeNeverTranslateSite");
+ let removeAll = getById("removeAllNeverTranslateSites");
+ is(tree.view.rowCount, 2, "The never-translate sites list has 2 items");
+ ok(remove.disabled, "The 'Remove Site' button is disabled");
+ ok(!removeAll.disabled, "The 'Remove All Sites' button is enabled");
+
+ // Select the first item.
+ tree.view.selection.select(0);
+ ok(!remove.disabled, "The 'Remove Site' button is enabled");
+
+ // Click the 'Remove' button.
+ remove.click();
+ is(
+ tree.view.rowCount,
+ 1,
+ "The never-translate sites list now contains 1 item"
+ );
+ is(
+ getNeverTranslateSites().length,
+ 1,
+ "One domain in the site permissions"
+ );
+
+ // Clear the permissions, and check the last item is removed from the display.
+ PermissionTestUtils.remove(
+ "https://example.org",
+ TRANSLATIONS_PERMISSION
+ );
+ PermissionTestUtils.remove(
+ "https://example.com",
+ TRANSLATIONS_PERMISSION
+ );
+ is(tree.view.rowCount, 0, "The never-translate sites list is empty");
+ ok(remove.disabled, "The 'Remove Site' button is disabled");
+ ok(removeAll.disabled, "The 'Remove All Site' button is disabled");
+
+ // Add items back and check that they appear
+ PermissionTestUtils.add(
+ "https://example.org",
+ TRANSLATIONS_PERMISSION,
+ Services.perms.DENY_ACTION
+ );
+ PermissionTestUtils.add(
+ "https://example.com",
+ TRANSLATIONS_PERMISSION,
+ Services.perms.DENY_ACTION
+ );
+ PermissionTestUtils.add(
+ "https://example.net",
+ TRANSLATIONS_PERMISSION,
+ Services.perms.DENY_ACTION
+ );
+
+ is(tree.view.rowCount, 3, "The never-translate sites list has 3 item");
+ ok(remove.disabled, "The 'Remove Site' button is disabled");
+ ok(!removeAll.disabled, "The 'Remove All Sites' button is enabled");
+
+ // Click the 'Remove All' button.
+ removeAll.click();
+ is(tree.view.rowCount, 0, "The never-translate sites list is empty");
+ ok(remove.disabled, "The 'Remove Site' button is disabled");
+ ok(removeAll.disabled, "The 'Remove All Sites' button is disabled");
+ is(
+ getNeverTranslateSites().length,
+ 0,
+ "No domains in the site permissions"
+ );
+
+ win.close();
+ },
+ },
+];
diff --git a/browser/components/translation/test/fixtures/bug1022725-fr.html b/browser/components/translation/test/fixtures/bug1022725-fr.html
new file mode 100644
index 0000000000..f30edf52eb
--- /dev/null
+++ b/browser/components/translation/test/fixtures/bug1022725-fr.html
@@ -0,0 +1,15 @@
+<!doctype html>
+<html lang="fr">
+ <head>
+ <!--
+ - Text retrieved from http://fr.wikipedia.org/wiki/Coupe_du_monde_de_football_de_2014
+ - at 06/13/2014, Creative Commons Attribution-ShareAlike License.
+ -->
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+ <title>test</title>
+ </head>
+ <body>
+ <h1>Coupe du monde de football de 2014</h1>
+ <div>La Coupe du monde de football de 2014 est la 20e édition de la Coupe du monde de football, compétition organisée par la FIFA et qui réunit les trente-deux meilleures sélections nationales. Sa phase finale a lieu à l'été 2014 au Brésil. Avec le pays organisateur, toutes les équipes championnes du monde depuis 1930 (Uruguay, Italie, Allemagne, Angleterre, Argentine, France et Espagne) se sont qualifiées pour cette compétition. Elle est aussi la première compétition internationale de la Bosnie-Herzégovine.</div>
+ </body>
+</html>
diff --git a/browser/components/translation/test/fixtures/result-da39a3ee5e.txt b/browser/components/translation/test/fixtures/result-da39a3ee5e.txt
new file mode 100644
index 0000000000..d2d14c7885
--- /dev/null
+++ b/browser/components/translation/test/fixtures/result-da39a3ee5e.txt
@@ -0,0 +1,22 @@
+<ArrayOfTranslateArrayResponse xmlns="http://schemas.datacontract.org/2004/07/Microsoft.MT.Web.Service.V2" xmlns:i="http://www.w3.org/2001/XMLSchema-instance">
+ <TranslateArrayResponse>
+ <From>fr</From>
+ <OriginalTextSentenceLengths xmlns:a="http://schemas.microsoft.com/2003/10/Serialization/Arrays">
+ <a:int>34</a:int>
+ </OriginalTextSentenceLengths>
+ <TranslatedText>Football's 2014 World Cup</TranslatedText>
+ <TranslatedTextSentenceLengths xmlns:a="http://schemas.microsoft.com/2003/10/Serialization/Arrays">
+ <a:int>25</a:int>
+ </TranslatedTextSentenceLengths>
+ </TranslateArrayResponse>
+ <TranslateArrayResponse>
+ <From>fr</From>
+ <OriginalTextSentenceLengths xmlns:a="http://schemas.microsoft.com/2003/10/Serialization/Arrays">
+ <a:int>508</a:int>
+ </OriginalTextSentenceLengths>
+ <TranslatedText>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus diam sem, porttitor eget neque sit amet, ultricies posuere metus. Cras placerat rutrum risus, nec dignissim magna dictum vitae. Fusce eleifend fermentum lacinia. Nulla sagittis cursus nibh. Praesent adipiscing, elit at pulvinar dapibus, neque massa tincidunt sapien, eu consectetur lectus metus sit amet odio. Proin blandit consequat porttitor. Pellentesque vehicula justo sed luctus vestibulum. Donec metus.</TranslatedText>
+ <TranslatedTextSentenceLengths xmlns:a="http://schemas.microsoft.com/2003/10/Serialization/Arrays">
+ <a:int>475</a:int>
+ </TranslatedTextSentenceLengths>
+ </TranslateArrayResponse>
+</ArrayOfTranslateArrayResponse>
diff --git a/browser/components/translation/test/fixtures/result-yandex-d448894848.json b/browser/components/translation/test/fixtures/result-yandex-d448894848.json
new file mode 100644
index 0000000000..9287611105
--- /dev/null
+++ b/browser/components/translation/test/fixtures/result-yandex-d448894848.json
@@ -0,0 +1,8 @@
+{
+ "code": 200,
+ "lang": "fr-en",
+ "text": [
+ "Football's 2014 World Cup",
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus diam sem, porttitor eget neque sit amet, ultricies posuere metus. Cras placerat rutrum risus, nec dignissim magna dictum vitae. Fusce eleifend fermentum lacinia. Nulla sagittis cursus nibh. Praesent adipiscing, elit at pulvinar dapibus, neque massa tincidunt sapien, eu consectetur lectus metus sit amet odio. Proin blandit consequat porttitor. Pellentesque vehicula justo sed luctus vestibulum. Donec metus."
+ ]
+}
diff --git a/browser/components/translation/test/yandex.sjs b/browser/components/translation/test/yandex.sjs
new file mode 100644
index 0000000000..24dd0b6e7c
--- /dev/null
+++ b/browser/components/translation/test/yandex.sjs
@@ -0,0 +1,203 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const CC = Components.Constructor;
+const BinaryInputStream = CC(
+ "@mozilla.org/binaryinputstream;1",
+ "nsIBinaryInputStream",
+ "setInputStream"
+);
+Cu.importGlobalProperties(["TextEncoder"]);
+
+function handleRequest(req, res) {
+ try {
+ reallyHandleRequest(req, res);
+ } catch (ex) {
+ res.setStatusLine("1.0", 200, "AlmostOK");
+ let msg = "Error handling request: " + ex + "\n" + ex.stack;
+ log(msg);
+ res.write(msg);
+ }
+}
+
+function log(msg) {
+ dump("YANDEX-SERVER-MOCK: " + msg + "\n");
+}
+
+const statusCodes = {
+ 400: "Bad Request",
+ 401: "Invalid API key",
+ 402: "This API key has been blocked",
+ 403: "Daily limit for requests reached",
+ 404: "Daily limit for chars reached",
+ 413: "The text size exceeds the maximum",
+ 422: "The text could not be translated",
+ 500: "Internal Server Error",
+ 501: "The specified translation direction is not supported",
+ 503: "Service Unavailable",
+};
+
+function HTTPError(code = 500, message) {
+ this.code = code;
+ this.name = statusCodes[code] || "HTTPError";
+ this.message = message || this.name;
+}
+HTTPError.prototype = new Error();
+HTTPError.prototype.constructor = HTTPError;
+
+function sendError(res, err) {
+ if (!(err instanceof HTTPError)) {
+ err = new HTTPError(
+ typeof err == "number" ? err : 500,
+ err.message || typeof err == "string" ? err : ""
+ );
+ }
+ res.setStatusLine("1.1", err.code, err.name);
+ res.write(err.message);
+}
+
+// Based on the code borrowed from:
+// http://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript
+function parseQuery(query) {
+ let match,
+ params = {},
+ pl = /\+/g,
+ search = /([^&=]+)=?([^&]*)/g,
+ decode = function (s) {
+ return decodeURIComponent(s.replace(pl, " "));
+ };
+
+ while ((match = search.exec(query))) {
+ let k = decode(match[1]),
+ v = decode(match[2]);
+ if (k in params) {
+ if (params[k] instanceof Array) {
+ params[k].push(v);
+ } else {
+ params[k] = [params[k], v];
+ }
+ } else {
+ params[k] = v;
+ }
+ }
+
+ return params;
+}
+
+function sha1(str) {
+ // `data` is an array of bytes.
+ let data = new TextEncoder().encode(str);
+ let ch = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash);
+ ch.init(ch.SHA1);
+ ch.update(data, data.length);
+ let hash = ch.finish(false);
+
+ // Return the two-digit hexadecimal code for a byte.
+ function toHexString(charCode) {
+ return ("0" + charCode.toString(16)).slice(-2);
+ }
+
+ // Convert the binary hash data to a hex string.
+ return Array.from(hash, (c, i) => toHexString(hash.charCodeAt(i))).join("");
+}
+
+function getRequestBody(req) {
+ let avail;
+ let bytes = [];
+ let body = new BinaryInputStream(req.bodyInputStream);
+
+ while ((avail = body.available()) > 0) {
+ Array.prototype.push.apply(bytes, body.readByteArray(avail));
+ }
+
+ return String.fromCharCode.apply(null, bytes);
+}
+
+function getInputStream(path) {
+ let file = Services.dirsvc.get("CurWorkD", Ci.nsIFile);
+ for (let part of path.split("/")) {
+ file.append(part);
+ }
+ let fileStream = Cc[
+ "@mozilla.org/network/file-input-stream;1"
+ ].createInstance(Ci.nsIFileInputStream);
+ fileStream.init(file, 1, 0, false);
+ return fileStream;
+}
+
+/**
+ * Yandex Requests have to be signed with an API Key. This mock server
+ * supports the following keys:
+ *
+ * yandexValidKey Always passes the authentication,
+ * yandexInvalidKey Never passes authentication and fails with 401 code,
+ * yandexBlockedKey Never passes authentication and fails with 402 code,
+ * yandexOutOfRequestsKey Never passes authentication and fails with 403 code,
+ * yandexOutOfCharsKey Never passes authentication and fails with 404 code.
+ *
+ * If any other key is used the server reponds with 401 error code.
+ */
+function checkAuth(params) {
+ if (!("key" in params)) {
+ throw new HTTPError(400);
+ }
+
+ let key = params.key;
+ if (key === "yandexValidKey") {
+ return true;
+ }
+
+ let invalidKeys = {
+ yandexInvalidKey: 401,
+ yandexBlockedKey: 402,
+ yandexOutOfRequestsKey: 403,
+ yandexOutOfCharsKey: 404,
+ };
+
+ if (key in invalidKeys) {
+ throw new HTTPError(invalidKeys[key]);
+ }
+
+ throw new HTTPError(401);
+}
+
+function reallyHandleRequest(req, res) {
+ try {
+ // Preparing the query parameters.
+ let params = {};
+ if (req.method == "POST") {
+ params = parseQuery(getRequestBody(req));
+ }
+
+ // Extracting the API key and attempting to authenticate the request.
+ log(JSON.stringify(params));
+
+ checkAuth(params);
+ methodHandlers.translate(res, params);
+ } catch (ex) {
+ sendError(res, ex, ex.code);
+ }
+}
+
+const methodHandlers = {
+ translate(res, params) {
+ res.setStatusLine("1.1", 200, "OK");
+ res.setHeader("Content-Type", "application/json");
+
+ let hash = sha1(JSON.stringify(params)).substr(0, 10);
+ log("SHA1 hash of content: " + hash);
+
+ let fixture =
+ "browser/browser/components/translation/test/fixtures/result-yandex-" +
+ hash +
+ ".json";
+ log("PATH: " + fixture);
+
+ let inputStream = getInputStream(fixture);
+ res.bodyOutputStream.writeFrom(inputStream, inputStream.available());
+ inputStream.close();
+ },
+};
diff --git a/browser/components/translations/TranslationsTelemetry.sys.mjs b/browser/components/translations/TranslationsTelemetry.sys.mjs
new file mode 100644
index 0000000000..7b6bdfb830
--- /dev/null
+++ b/browser/components/translations/TranslationsTelemetry.sys.mjs
@@ -0,0 +1,19 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Telemetry functions for Translations desktop UI
+ */
+export class TranslationsTelemetry {
+ /**
+ * Records a telemetry event when the translations panel is opened.
+ *
+ * @param {boolean} openedFromAppMenu
+ */
+ static onOpenPanel(openedFromAppMenu) {
+ Glean.translationsPanel.open.record({
+ opened_from: openedFromAppMenu ? "appMenu" : "translationsButton",
+ });
+ }
+}
diff --git a/browser/components/translations/content/translationsPanel.inc.xhtml b/browser/components/translations/content/translationsPanel.inc.xhtml
new file mode 100644
index 0000000000..df08469822
--- /dev/null
+++ b/browser/components/translations/content/translationsPanel.inc.xhtml
@@ -0,0 +1,149 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<html:template id="template-translations-panel">
+<panel id="translations-panel"
+ class="panel-no-padding translations-panel"
+ type="arrow"
+ role="alertdialog"
+ noautofocus="true"
+ aria-labelledby="translations-panel-main-header-label"
+ orient="vertical">
+ <panelmultiview id="translations-panel-multiview"
+ mainViewId="translations-panel-view-default">
+ <panelview id="translations-panel-view-default"
+ class="PanelUI-subView translations-panel-view"
+ role="document"
+ showheader="true">
+ <hbox class="panel-header translations-panel-header">
+ <html:h1>
+ <html:span id="translations-panel-header"></html:span>
+ </html:h1>
+ <toolbarbutton id="translations-panel-settings" class="panel-info-button"
+ data-l10n-id="translations-panel-settings-button"
+ closemenu="none"
+ oncommand="TranslationsPanel.openSettingsPopup(this)">
+ <image class="translations-panel-gear-icon" />
+ <menupopup>
+ <menuitem class="always-translate-language-menuitem"
+ data-l10n-id="translations-panel-settings-always-translate-unknown-language"
+ type="checkbox"
+ checked="false"
+ autocheck="false"
+ oncommand="TranslationsPanel.onAlwaysTranslateLanguage()"/>
+ <menuitem class="never-translate-language-menuitem"
+ data-l10n-id="translations-panel-settings-never-translate-unknown-language"
+ type="checkbox"
+ checked="false"
+ autocheck="false"
+ oncommand="TranslationsPanel.onNeverTranslateLanguage()"/>
+ <menuitem class="never-translate-site-menuitem"
+ data-l10n-id="translations-panel-settings-never-translate-site"
+ type="checkbox"
+ checked="false"
+ autocheck="false"
+ oncommand="TranslationsPanel.onNeverTranslateSite()"/>
+ <menuseparator/>
+ <menuitem data-l10n-id="translations-panel-settings-manage-languages"
+ oncommand="TranslationsPanel.openManageLanguages()"/>
+ </menupopup>
+ </toolbarbutton>
+ </hbox>
+
+ <vbox class="translations-panel-content">
+ <vbox id="translations-panel-lang-selection">
+ <label data-l10n-id="translations-panel-from-label"></label>
+ <menulist id="translations-panel-from"
+ flex="1"
+ value="detect"
+ size="large"
+ oncommand="TranslationsPanel.onChangeLanguages(event)">
+ <menupopup id="translations-panel-from-menupopup"
+ class="translations-panel-language-menupopup-from">
+ <menuitem data-l10n-id="translations-panel-choose-language" value=""></menuitem>
+ <!-- The list of <menuitem> will be dynamically inserted. -->
+ </menupopup>
+ </menulist>
+
+ <label data-l10n-id="translations-panel-to-label"></label>
+ <menulist id="translations-panel-to"
+ flex="1"
+ value="detect"
+ size="large"
+ oncommand="TranslationsPanel.onChangeLanguages(event)">
+ <menupopup id="translations-panel-to-menupopup"
+ class="translations-panel-language-menupopup-to">
+ <menuitem data-l10n-id="translations-panel-choose-language" value=""></menuitem>
+ <!-- The list of <menuitem> will be dynamically inserted. -->
+ </menupopup>
+ </menulist>
+ </vbox>
+
+ <vbox id="translations-panel-error">
+ <hbox class="translations-panel-error-header">
+ <image class="translations-panel-error-icon translations-panel-error-header-icon" />
+ <description id="translations-panel-error-message"></description>
+ </hbox>
+ <hbox id="translations-panel-error-message-hint"></hbox>
+ <hbox pack="end">
+ <button id="translations-panel-translate-hint-action" />
+ </hbox>
+ </vbox>
+ </vbox>
+
+ <hbox class="panel-footer translations-panel-footer">
+ <button id="translations-panel-restore-button"
+ class="subviewbutton panel-subview-footer-button"
+ oncommand="TranslationsPanel.onRestore(event);"
+ data-l10n-id="translations-panel-restore-button"
+ tabindex="0">
+ </button>
+ <button id="translations-panel-not-now"
+ class="subviewbutton panel-subview-footer-button"
+ oncommand="TranslationsPanel.onCancel(event);"
+ data-l10n-id="translations-panel-translate-cancel"
+ tabindex="0">
+ </button>
+ <button id="translations-panel-translate"
+ class="subviewbutton panel-subview-footer-button"
+ oncommand="TranslationsPanel.onTranslate(event);"
+ data-l10n-id="translations-panel-translate-button"
+ default="true"
+ tabindex="0">
+ </button>
+ </hbox>
+ </panelview>
+
+ <panelview id="translations-panel-view-unsupported-language"
+ class="PanelUI-subView translations-panel-view"
+ role="document"
+ showheader="true">
+ <hbox class="panel-header translations-panel-header">
+ <image class="translations-panel-error-icon" />
+ <html:h1>
+ <html:span data-l10n-id="translations-panel-error-unsupported"></html:span>
+ </html:h1>
+ </hbox>
+
+ <vbox class="translations-panel-content">
+ <description id="translations-panel-error-unsupported-hint"></description>
+ </vbox>
+
+ <hbox class="panel-footer translations-panel-footer">
+ <button class="subviewbutton panel-subview-footer-button"
+ oncommand="TranslationsPanel.onChangeSourceLanguage(event);"
+ data-l10n-id="translations-panel-error-change-button"
+ tabindex="0">
+ </button>
+ <button class="subviewbutton panel-subview-footer-button"
+ oncommand="TranslationsPanel.onCancel(event);"
+ data-l10n-id="translations-panel-error-dismiss-button"
+ default="true"
+ tabindex="0">
+ </button>
+ </hbox>
+ </panelview>
+ </panelmultiview>
+</panel>
+</html:template>
diff --git a/browser/components/translations/content/translationsPanel.js b/browser/components/translations/content/translationsPanel.js
new file mode 100644
index 0000000000..3d7d5be5fc
--- /dev/null
+++ b/browser/components/translations/content/translationsPanel.js
@@ -0,0 +1,1132 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint-env mozilla/browser-window */
+
+/* eslint-disable jsdoc/valid-types */
+/**
+ * @typedef {import("../../../../toolkit/components/translations/translations").LangTags} LangTags
+ */
+/* eslint-enable jsdoc/valid-types */
+
+ChromeUtils.defineESModuleGetters(this, {
+ TranslationsTelemetry:
+ "chrome://browser/content/translations/TranslationsTelemetry.sys.mjs",
+});
+
+/**
+ * The set of actions that can occur from interaction with the
+ * translations panel.
+ */
+const PageAction = Object.freeze({
+ NO_CHANGE: "NO_CHANGE",
+ HIDE_BUTTON: "HIDE_BUTTON",
+ RESTORE_PAGE: "RESTORE_PAGE",
+ TRANSLATE_PAGE: "TRANSLATE_PAGE",
+});
+
+/**
+ * A mechanism for determining the next relevant page action
+ * based on the current translated state of the page and the state
+ * of the persistent options in the translations panel settings.
+ */
+class CheckboxStateMachine {
+ /**
+ * Whether or not translations is active on the page.
+ *
+ * @type {boolean}
+ */
+ #translationsActive = false;
+
+ /**
+ * Whether the always-translate-language menuitem is checked
+ * in the translations panel settings menu.
+ *
+ * @type {boolean}
+ */
+ #alwaysTranslateLanguage = false;
+
+ /**
+ * Whether the never-translate-language menuitem is checked
+ * in the translations panel settings menu.
+ *
+ * @type {boolean}
+ */
+ #neverTranslateLanguage = false;
+
+ /**
+ * Whether the never-translate-site menuitem is checked
+ * in the translations panel settings menu.
+ *
+ * @type {boolean}
+ */
+ #neverTranslateSite = false;
+
+ /**
+ * @param {boolean} translationsActive
+ * @param {boolean} alwaysTranslateLanguage
+ * @param {boolean} neverTranslateLanguage
+ * @param {boolean} neverTranslateSite
+ */
+ constructor(
+ translationsActive,
+ alwaysTranslateLanguage,
+ neverTranslateLanguage,
+ neverTranslateSite
+ ) {
+ this.#translationsActive = translationsActive;
+ this.#alwaysTranslateLanguage = alwaysTranslateLanguage;
+ this.#neverTranslateLanguage = neverTranslateLanguage;
+ this.#neverTranslateSite = neverTranslateSite;
+ }
+
+ /**
+ * Accepts four integers that are either 0 or 1 and returns
+ * a single, unique number for each possible combination of
+ * values.
+ *
+ * @param {number} translationsActive
+ * @param {number} alwaysTranslateLanguage
+ * @param {number} neverTranslateLanguage
+ * @param {number} neverTranslateSite
+ *
+ * @returns {number} - An integer representation of the state
+ */
+ static #computeState(
+ translationsActive,
+ alwaysTranslateLanguage,
+ neverTranslateLanguage,
+ neverTranslateSite
+ ) {
+ return (
+ (translationsActive << 3) |
+ (alwaysTranslateLanguage << 2) |
+ (neverTranslateLanguage << 1) |
+ neverTranslateSite
+ );
+ }
+
+ /**
+ * Returns the current state of the data members as a single number.
+ *
+ * @returns {number} - An integer representation of the state
+ */
+ #state() {
+ return CheckboxStateMachine.#computeState(
+ Number(this.#translationsActive),
+ Number(this.#alwaysTranslateLanguage),
+ Number(this.#neverTranslateLanguage),
+ Number(this.#neverTranslateSite)
+ );
+ }
+
+ /**
+ * Returns the next page action to take when the always-translate-language
+ * menuitem is toggled in the translations panel settings menu.
+ *
+ * @returns {PageAction}
+ */
+ onAlwaysTranslateLanguage() {
+ switch (this.#state()) {
+ case CheckboxStateMachine.#computeState(1, 1, 0, 1):
+ case CheckboxStateMachine.#computeState(1, 1, 0, 0): {
+ return PageAction.RESTORE_PAGE;
+ }
+ case CheckboxStateMachine.#computeState(0, 0, 1, 0):
+ case CheckboxStateMachine.#computeState(0, 0, 0, 0): {
+ return PageAction.TRANSLATE_PAGE;
+ }
+ }
+ return PageAction.NO_CHANGE;
+ }
+
+ /**
+ * Returns the next page action to take when the never-translate-language
+ * menuitem is toggled in the translations panel settings menu.
+ *
+ * @returns {PageAction}
+ */
+ onNeverTranslateLanguage() {
+ switch (this.#state()) {
+ case CheckboxStateMachine.#computeState(1, 1, 0, 1):
+ case CheckboxStateMachine.#computeState(1, 1, 0, 0):
+ case CheckboxStateMachine.#computeState(1, 0, 0, 1):
+ case CheckboxStateMachine.#computeState(1, 0, 0, 0): {
+ return PageAction.RESTORE_PAGE;
+ }
+ case CheckboxStateMachine.#computeState(0, 1, 0, 0):
+ case CheckboxStateMachine.#computeState(0, 0, 0, 0): {
+ return PageAction.HIDE_BUTTON;
+ }
+ }
+ return PageAction.NO_CHANGE;
+ }
+
+ /**
+ * Returns the next page action to take when the never-translate-site
+ * menuitem is toggled in the translations panel settings menu.
+ *
+ * @returns {PageAction}
+ */
+ onNeverTranslateSite() {
+ switch (this.#state()) {
+ case CheckboxStateMachine.#computeState(1, 1, 0, 0):
+ case CheckboxStateMachine.#computeState(1, 0, 1, 0):
+ case CheckboxStateMachine.#computeState(1, 0, 0, 0): {
+ return PageAction.RESTORE_PAGE;
+ }
+ case CheckboxStateMachine.#computeState(0, 1, 0, 0):
+ case CheckboxStateMachine.#computeState(0, 0, 0, 0): {
+ return PageAction.HIDE_BUTTON;
+ }
+ case CheckboxStateMachine.#computeState(0, 1, 0, 1): {
+ return PageAction.TRANSLATE_PAGE;
+ }
+ }
+ return PageAction.NO_CHANGE;
+ }
+}
+
+/**
+ * This singleton class controls the Translations popup panel.
+ *
+ * This component is a `/browser` component, and the actor is a `/toolkit` actor, so care
+ * must be taken to keep the presentation (this component) from the state management
+ * (the Translations actor). This class reacts to state changes coming from the
+ * Translations actor.
+ */
+var TranslationsPanel = new (class {
+ /** @type {Console?} */
+ #console;
+
+ /**
+ * The cached detected languages for both the document and the user.
+ *
+ * @type {null | LangTags}
+ */
+ detectedLanguages = null;
+
+ /**
+ * Lazily get a console instance.
+ *
+ * @returns {Console}
+ */
+ get console() {
+ if (!this.#console) {
+ this.#console = console.createInstance({
+ maxLogLevelPref: "browser.translations.logLevel",
+ prefix: "Translations",
+ });
+ }
+ return this.#console;
+ }
+
+ /**
+ * Where the lazy elements are stored.
+ *
+ * @type {Record<string, Element>?}
+ */
+ #lazyElements;
+
+ /**
+ * Lazily creates the dom elements, and lazily selects them.
+ *
+ * @returns {Record<string, Element>}
+ */
+ get elements() {
+ if (!this.#lazyElements) {
+ // Lazily turn the template into a DOM element.
+ /** @type {HTMLTemplateElement} */
+ const wrapper = document.getElementById("template-translations-panel");
+ const panel = wrapper.content.firstElementChild;
+ wrapper.replaceWith(wrapper.content);
+
+ const settingsButton = document.getElementById(
+ "translations-panel-settings"
+ );
+ // Clone the settings toolbarbutton across all the views.
+ for (const header of panel.querySelectorAll(".panel-header")) {
+ if (header.contains(settingsButton)) {
+ continue;
+ }
+ const settingsButtonClone = settingsButton.cloneNode(true);
+ settingsButtonClone.removeAttribute("id");
+ header.appendChild(settingsButtonClone);
+ }
+
+ // Lazily select the elements.
+ this.#lazyElements = {
+ panel,
+ settingsButton,
+ // The rest of the elements are set by the getter below.
+ };
+
+ /**
+ * Define a getter on #lazyElements that gets the element by an id
+ * or class name.
+ */
+ const getter = (name, discriminator) => {
+ let element;
+ Object.defineProperty(this.#lazyElements, name, {
+ get: () => {
+ if (!element) {
+ if (discriminator[0] === ".") {
+ // Lookup by class
+ element = document.querySelector(discriminator);
+ } else {
+ // Lookup by id
+ element = document.getElementById(discriminator);
+ }
+ }
+ if (!element) {
+ throw new Error(
+ `Could not find "${name}" at "#${discriminator}".`
+ );
+ }
+ return element;
+ },
+ });
+ };
+
+ // Getters by id
+ getter("appMenuButton", "PanelUI-menu-button");
+ getter("button", "translations-button");
+ getter("buttonLocale", "translations-button-locale");
+ getter("buttonCircleArrows", "translations-button-circle-arrows");
+ getter("defaultTranslate", "translations-panel-translate");
+ getter("error", "translations-panel-error");
+ getter("errorMessage", "translations-panel-error-message");
+ getter("errorMessageHint", "translations-panel-error-message-hint");
+ getter("errorHintAction", "translations-panel-translate-hint-action");
+ getter("fromMenuList", "translations-panel-from");
+ getter("header", "translations-panel-header");
+ getter("langSelection", "translations-panel-lang-selection");
+ getter("multiview", "translations-panel-multiview");
+ getter("notNowButton", "translations-panel-not-now");
+ getter("restoreButton", "translations-panel-restore-button");
+ getter("toMenuList", "translations-panel-to");
+ getter("unsupportedHint", "translations-panel-error-unsupported-hint");
+
+ // Getters by class
+ getter(
+ "alwaysTranslateLanguageMenuItem",
+ ".always-translate-language-menuitem"
+ );
+ getter(
+ "neverTranslateLanguageMenuItem",
+ ".never-translate-language-menuitem"
+ );
+ getter("neverTranslateSiteMenuItem", ".never-translate-site-menuitem");
+ }
+
+ return this.#lazyElements;
+ }
+
+ /**
+ * Cache the last command used for error hints so that it can be later removed.
+ */
+ #lastHintCommand = null;
+
+ /**
+ * @param {object} options
+ * @param {string} options.message - l10n id
+ * @param {string} options.hint - l10n id
+ * @param {string} options.actionText - l10n id
+ * @param {Function} options.actionCommand - The action to perform.
+ */
+ #showError({
+ message,
+ hint,
+ actionText: hintCommandText,
+ actionCommand: hintCommand,
+ }) {
+ const { error, errorMessage, errorMessageHint, errorHintAction } =
+ this.elements;
+ error.hidden = false;
+ document.l10n.setAttributes(errorMessage, message);
+
+ if (hint) {
+ errorMessageHint.hidden = false;
+ document.l10n.setAttributes(errorMessageHint, hint);
+ } else {
+ errorMessageHint.hidden = true;
+ }
+
+ if (hintCommand && hintCommandText) {
+ errorHintAction.removeEventListener("command", this.#lastHintCommand);
+ this.#lastHintCommand = hintCommand;
+ errorHintAction.addEventListener("command", hintCommand);
+ errorHintAction.hidden = false;
+ document.l10n.setAttributes(errorHintAction, hintCommandText);
+ } else {
+ errorHintAction.hidden = true;
+ }
+ }
+
+ /**
+ * @returns {TranslationsParent}
+ */
+ #getTranslationsActor() {
+ const actor =
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getActor(
+ "Translations"
+ );
+
+ if (!actor) {
+ throw new Error("Unable to get the TranslationsParent");
+ }
+ return actor;
+ }
+
+ /**
+ * Fetches the language tags for the document and the user and caches the results
+ * Use `#getCachedDetectedLanguages` when the lang tags do not need to be re-fetched.
+ * This requires a bit of work to do, so prefer the cached version when possible.
+ *
+ * @returns {Promise<LangTags>}
+ */
+ async #fetchDetectedLanguages() {
+ this.detectedLanguages =
+ await this.#getTranslationsActor().getLangTagsForTranslation();
+ return this.detectedLanguages;
+ }
+
+ /**
+ * If the detected language tags have been retrieved previously, return the cached
+ * version. Otherwise do a fresh lookup of the document's language tag.
+ *
+ * @returns {Promise<LangTags>}
+ */
+ async #getCachedDetectedLanguages() {
+ if (!this.detectedLanguages) {
+ return this.#fetchDetectedLanguages();
+ }
+ return this.detectedLanguages;
+ }
+
+ /**
+ * @type {"initialized" | "error" | "uninitialized"}
+ */
+ #langListsPhase = "uninitialized";
+
+ /**
+ * Builds the <menulist> of languages for both the "from" and "to". This can be
+ * called every time the popup is shown, as it will retry when there is an error
+ * (such as a network error) or be a noop if it's already initialized.
+ *
+ * TODO(Bug 1813796) This needs to be updated when the supported languages change
+ * via RemoteSettings.
+ */
+ async #ensureLangListsBuilt() {
+ switch (this.#langListsPhase) {
+ case "initialized":
+ // This has already been initialized.
+ return;
+ case "error":
+ // Attempt to re-initialize.
+ this.#langListsPhase = "uninitialized";
+ break;
+ case "uninitialized":
+ // Ready to initialize.
+ break;
+ default:
+ this.console.error("Unknown langList phase", this.#langListsPhase);
+ }
+
+ try {
+ /** @type {SupportedLanguages} */
+ const { languagePairs, fromLanguages, toLanguages } =
+ await this.#getTranslationsActor().getSupportedLanguages();
+
+ // Verify that we are in a proper state.
+ if (languagePairs.length === 0) {
+ throw new Error("No translation languages were retrieved.");
+ }
+
+ const { panel } = this.elements;
+ const fromPopups = panel.querySelectorAll(
+ ".translations-panel-language-menupopup-from"
+ );
+ const toPopups = panel.querySelectorAll(
+ ".translations-panel-language-menupopup-to"
+ );
+
+ for (const popup of fromPopups) {
+ for (const { langTag, isBeta, displayName } of fromLanguages) {
+ const fromMenuItem = document.createXULElement("menuitem");
+ fromMenuItem.setAttribute("value", langTag);
+ if (isBeta) {
+ document.l10n.setAttributes(
+ fromMenuItem,
+ "translations-panel-displayname-beta",
+ { language: displayName }
+ );
+ } else {
+ fromMenuItem.setAttribute("label", displayName);
+ }
+ popup.appendChild(fromMenuItem);
+ }
+ }
+
+ for (const popup of toPopups) {
+ for (const { langTag, isBeta, displayName } of toLanguages) {
+ const toMenuItem = document.createXULElement("menuitem");
+ toMenuItem.setAttribute("value", langTag);
+ if (isBeta) {
+ document.l10n.setAttributes(
+ toMenuItem,
+ "translations-panel-displayname-beta",
+ { language: displayName }
+ );
+ } else {
+ toMenuItem.setAttribute("label", displayName);
+ }
+ popup.appendChild(toMenuItem);
+ }
+ }
+
+ this.#langListsPhase = "initialized";
+ } catch (error) {
+ this.console.error(error);
+ this.#langListsPhase = "error";
+ }
+ }
+
+ /**
+ * Show the default view of choosing a source and target language.
+ *
+ * @param {boolean} force - Force the page to show translation options.
+ */
+ async #showDefaultView(force = false) {
+ const {
+ fromMenuList,
+ multiview,
+ panel,
+ error,
+ toMenuList,
+ defaultTranslate,
+ langSelection,
+ } = this.elements;
+
+ if (this.#langListsPhase === "error") {
+ // There was an error, display it in the view rather than the language
+ // dropdowns.
+ const { restoreButton, notNowButton, header, errorHintAction } =
+ this.elements;
+
+ this.#showError({
+ message: "translations-panel-error-load-languages",
+ hint: "translations-panel-error-load-languages-hint",
+ actionText: "translations-panel-error-load-languages-hint-button",
+ actionCommand: () => this.#reloadLangList(),
+ });
+
+ document.l10n.setAttributes(header, "translations-panel-header");
+ defaultTranslate.disabled = true;
+ restoreButton.hidden = true;
+ notNowButton.hidden = false;
+ langSelection.hidden = true;
+ errorHintAction.disabled = false;
+ return;
+ }
+
+ // Remove any old selected values synchronously before asking for new ones.
+ fromMenuList.value = "";
+ error.hidden = true;
+ langSelection.hidden = false;
+
+ /** @type {null | LangTags} */
+ const langTags = await this.#fetchDetectedLanguages();
+ if (langTags?.isDocLangTagSupported || force) {
+ // Show the default view with the language selection
+ const { header, restoreButton, notNowButton } = this.elements;
+ document.l10n.setAttributes(header, "translations-panel-header");
+
+ if (langTags?.isDocLangTagSupported) {
+ fromMenuList.value = langTags?.docLangTag ?? "";
+ } else {
+ fromMenuList.value = "";
+ }
+ toMenuList.value = langTags?.userLangTag ?? "";
+
+ this.onChangeLanguages();
+
+ restoreButton.hidden = true;
+ notNowButton.hidden = false;
+ multiview.setAttribute("mainViewId", "translations-panel-view-default");
+ } else {
+ // Show the "unsupported language" view.
+ const { unsupportedHint } = this.elements;
+ multiview.setAttribute(
+ "mainViewId",
+ "translations-panel-view-unsupported-language"
+ );
+ let language;
+ if (langTags?.docLangTag) {
+ const displayNames = new Intl.DisplayNames(undefined, {
+ type: "language",
+ fallback: "none",
+ });
+ language = displayNames.of(langTags.docLangTag);
+ }
+ if (language) {
+ document.l10n.setAttributes(
+ unsupportedHint,
+ "translations-panel-error-unsupported-hint-known",
+ { language }
+ );
+ } else {
+ document.l10n.setAttributes(
+ unsupportedHint,
+ "translations-panel-error-unsupported-hint-unknown"
+ );
+ }
+ }
+
+ // Focus the "from" language, as it is the only field not set.
+ panel.addEventListener(
+ "ViewShown",
+ () => {
+ if (!fromMenuList.value) {
+ fromMenuList.focus();
+ }
+ if (!toMenuList.value) {
+ toMenuList.focus();
+ }
+ },
+ { once: true }
+ );
+ }
+
+ /**
+ * Updates the checked states of the settings menu checkboxes that
+ * pertain to languages.
+ */
+ async #updateSettingsMenuLanguageCheckboxStates() {
+ const { docLangTag, isDocLangTagSupported } =
+ await this.#getCachedDetectedLanguages();
+
+ const { panel } = this.elements;
+ const alwaysTranslateMenuItems = panel.querySelectorAll(
+ ".always-translate-language-menuitem"
+ );
+ const neverTranslateMenuItems = panel.querySelectorAll(
+ ".never-translate-language-menuitem"
+ );
+
+ if (
+ !docLangTag ||
+ !isDocLangTagSupported ||
+ docLangTag === new Intl.Locale(Services.locale.appLocaleAsBCP47).language
+ ) {
+ for (const menuitem of alwaysTranslateMenuItems) {
+ menuitem.disabled = true;
+ }
+ for (const menuitem of neverTranslateMenuItems) {
+ menuitem.disabled = true;
+ }
+ return;
+ }
+
+ const alwaysTranslateLanguage =
+ TranslationsParent.shouldAlwaysTranslateLanguage(docLangTag);
+ const neverTranslateLanguage =
+ TranslationsParent.shouldNeverTranslateLanguage(docLangTag);
+
+ for (const menuitem of alwaysTranslateMenuItems) {
+ menuitem.setAttribute(
+ "checked",
+ alwaysTranslateLanguage ? "true" : "false"
+ );
+ menuitem.disabled = false;
+ }
+ for (const menuitem of neverTranslateMenuItems) {
+ menuitem.setAttribute(
+ "checked",
+ neverTranslateLanguage ? "true" : "false"
+ );
+ menuitem.disabled = false;
+ }
+ }
+
+ /**
+ * Updates the checked states of the settings menu checkboxes that
+ * pertain to site permissions.
+ */
+ async #updateSettingsMenuSiteCheckboxStates() {
+ const { panel } = this.elements;
+ const neverTranslateSiteMenuItems = panel.querySelectorAll(
+ ".never-translate-site-menuitem"
+ );
+ const neverTranslateSite =
+ await this.#getTranslationsActor().shouldNeverTranslateSite();
+
+ for (const menuitem of neverTranslateSiteMenuItems) {
+ menuitem.setAttribute("checked", neverTranslateSite ? "true" : "false");
+ }
+ }
+
+ /**
+ * Populates the language-related settings menuitems by adding the
+ * localized display name of the document's detected language tag.
+ */
+ async #populateSettingsMenuItems() {
+ const { docLangTag } = await this.#getCachedDetectedLanguages();
+
+ const { panel } = this.elements;
+
+ const alwaysTranslateMenuItems = panel.querySelectorAll(
+ ".always-translate-language-menuitem"
+ );
+ const neverTranslateMenuItems = panel.querySelectorAll(
+ ".never-translate-language-menuitem"
+ );
+
+ /** @type {string | undefined} */
+ let docLangDisplayName;
+ if (docLangTag) {
+ const displayNames = new Services.intl.DisplayNames(undefined, {
+ type: "language",
+ fallback: "none",
+ });
+ // The display name will still be empty if the docLangTag is not known.
+ docLangDisplayName = displayNames.of(docLangTag);
+ }
+
+ for (const menuitem of alwaysTranslateMenuItems) {
+ if (docLangDisplayName) {
+ document.l10n.setAttributes(
+ menuitem,
+ "translations-panel-settings-always-translate-language",
+ { language: docLangDisplayName }
+ );
+ } else {
+ document.l10n.setAttributes(
+ menuitem,
+ "translations-panel-settings-always-translate-unknown-language"
+ );
+ }
+ }
+
+ for (const menuitem of neverTranslateMenuItems) {
+ if (docLangDisplayName) {
+ document.l10n.setAttributes(
+ menuitem,
+ "translations-panel-settings-never-translate-language",
+ { language: docLangDisplayName }
+ );
+ } else {
+ document.l10n.setAttributes(
+ menuitem,
+ "translations-panel-settings-never-translate-unknown-language"
+ );
+ }
+ }
+
+ await Promise.all([
+ this.#updateSettingsMenuLanguageCheckboxStates(),
+ this.#updateSettingsMenuSiteCheckboxStates(),
+ ]);
+ }
+
+ /**
+ * Configures the panel for the user to reset the page after it has been translated.
+ *
+ * @param {TranslationPair} translationPair
+ */
+ async #showRevisitView({ fromLanguage, toLanguage }) {
+ const { header, fromMenuList, toMenuList, restoreButton, notNowButton } =
+ this.elements;
+
+ fromMenuList.value = fromLanguage;
+ toMenuList.value = toLanguage;
+ this.onChangeLanguages();
+
+ restoreButton.hidden = false;
+ notNowButton.hidden = true;
+
+ const displayNames = new Services.intl.DisplayNames(undefined, {
+ type: "language",
+ });
+
+ document.l10n.setAttributes(header, "translations-panel-revisit-header", {
+ fromLanguage: displayNames.of(fromLanguage),
+ toLanguage: displayNames.of(toLanguage),
+ });
+ }
+
+ /**
+ * Handle the disable logic for when the menulist is changed for the "Translate to"
+ * on the "revisit" subview.
+ */
+ onChangeRevisitTo() {
+ const { revisitTranslate, revisitMenuList } = this.elements;
+ revisitTranslate.disabled = !revisitMenuList.value;
+ }
+
+ /**
+ * When changing the "dual" view's language, handle cases where the translate button
+ * should be disabled.
+ */
+ onChangeLanguages() {
+ const { defaultTranslate, toMenuList, fromMenuList } = this.elements;
+ const { requestedTranslationPair } =
+ this.#getTranslationsActor().languageState;
+ defaultTranslate.disabled =
+ // The translation languages are the same, don't allow this translation.
+ toMenuList.value === fromMenuList.value ||
+ // No "to" language was provided.
+ !toMenuList.value ||
+ // No "from" language was provided.
+ !fromMenuList.value ||
+ // The is the requested translation pair.
+ (requestedTranslationPair &&
+ requestedTranslationPair.fromLanguage === fromMenuList.value &&
+ requestedTranslationPair.toLanguage === toMenuList.value);
+ }
+
+ /**
+ * When a language is not supported and the menu is manually invoked, an error message
+ * is shown. This method switches the panel back to the language selection view.
+ * Note that this bypasses the showSubView method since the main view doesn't support
+ * a subview.
+ */
+ async onChangeSourceLanguage(event) {
+ const { panel } = this.elements;
+ panel.addEventListener("popuphidden", async () => {}, { once: true });
+ PanelMultiView.hidePopup(panel);
+
+ await this.#showDefaultView(true /* force this view to be shown */);
+
+ PanelMultiView.openPopup(panel, this.elements.appMenuButton, {
+ position: "bottomright topright",
+ triggeringEvent: event,
+ }).catch(error => this.console.error(error));
+ }
+
+ async #reloadLangList() {
+ try {
+ await this.#ensureLangListsBuilt();
+ await this.#showDefaultView();
+ } catch (error) {
+ this.elements.errorHintAction.disabled = false;
+ }
+ }
+
+ /**
+ * Opens the TranslationsPanel.
+ *
+ * @param {Event} event
+ */
+ async open(event) {
+ const { panel, button } = this.elements;
+
+ await this.#ensureLangListsBuilt();
+
+ const { requestedTranslationPair } =
+ this.#getTranslationsActor().languageState;
+
+ if (requestedTranslationPair) {
+ await this.#showRevisitView(requestedTranslationPair).catch(error => {
+ this.console.error(error);
+ });
+ } else {
+ await this.#showDefaultView().catch(error => {
+ this.console.error(error);
+ });
+ }
+
+ this.#populateSettingsMenuItems();
+
+ const [targetButton, openedFromAppMenu] = button.contains(event.target)
+ ? [button, false]
+ : [this.elements.appMenuButton, true];
+
+ panel.addEventListener(
+ "ViewShown",
+ () => TranslationsTelemetry.onOpenPanel(openedFromAppMenu),
+ { once: true }
+ );
+
+ PanelMultiView.openPopup(panel, targetButton, {
+ position: "bottomright topright",
+ triggerEvent: event,
+ }).catch(error => this.console.error(error));
+ }
+
+ /**
+ * Removes the translations button.
+ */
+ #hideTranslationsButton() {
+ const { button, buttonLocale, buttonCircleArrows } = this.elements;
+ button.hidden = true;
+ buttonLocale.hidden = true;
+ buttonCircleArrows.hidden = true;
+ button.removeAttribute("translationsactive");
+ }
+
+ /**
+ * Returns true if translations is currently active, otherwise false.
+ *
+ * @returns {boolean}
+ */
+ #isTranslationsActive() {
+ const { requestedTranslationPair } =
+ this.#getTranslationsActor().languageState;
+ return requestedTranslationPair !== null;
+ }
+
+ /**
+ * Handle the translation button being clicked when there are two language options.
+ */
+ async onTranslate() {
+ PanelMultiView.hidePopup(this.elements.panel);
+
+ const actor = this.#getTranslationsActor();
+ actor.translate(
+ this.elements.fromMenuList.value,
+ this.elements.toMenuList.value
+ );
+ }
+
+ onCancel() {
+ PanelMultiView.hidePopup(this.elements.panel);
+ }
+
+ /**
+ * A handler for opening the settings context menu.
+ */
+ openSettingsPopup(button) {
+ this.#updateSettingsMenuLanguageCheckboxStates();
+ this.#updateSettingsMenuSiteCheckboxStates();
+ const popup = button.querySelector("menupopup");
+ popup.openPopup(button);
+ }
+
+ /**
+ * Creates a new CheckboxStateMachine based on the current translated
+ * state of the page and the state of the persistent options in the
+ * translations panel settings.
+ *
+ * @returns {CheckboxStateMachine}
+ */
+ createCheckboxStateMachine() {
+ const {
+ alwaysTranslateLanguageMenuItem,
+ neverTranslateLanguageMenuItem,
+ neverTranslateSiteMenuItem,
+ } = this.elements;
+
+ const alwaysTranslateLanguage =
+ alwaysTranslateLanguageMenuItem.getAttribute("checked") === "true";
+ const neverTranslateLanguage =
+ neverTranslateLanguageMenuItem.getAttribute("checked") === "true";
+ const neverTranslateSite =
+ neverTranslateSiteMenuItem.getAttribute("checked") === "true";
+
+ return new CheckboxStateMachine(
+ this.#isTranslationsActive(),
+ alwaysTranslateLanguage,
+ neverTranslateLanguage,
+ neverTranslateSite
+ );
+ }
+
+ /**
+ * Redirect the user to about:preferences
+ */
+ openManageLanguages() {
+ const window =
+ gBrowser.selectedBrowser.browsingContext.top.embedderElement.ownerGlobal;
+ window.openTrustedLinkIn("about:preferences#general-translations", "tab");
+ }
+
+ /**
+ * Performs the given page action.
+ *
+ * @param {PageAction} pageAction
+ */
+ async #doPageAction(pageAction) {
+ switch (pageAction) {
+ case PageAction.NO_CHANGE: {
+ break;
+ }
+ case PageAction.HIDE_BUTTON: {
+ this.#hideTranslationsButton();
+ break;
+ }
+ case PageAction.RESTORE_PAGE: {
+ await this.onRestore();
+ break;
+ }
+ case PageAction.TRANSLATE_PAGE: {
+ await this.onTranslate();
+ break;
+ }
+ }
+ }
+
+ /**
+ * Updates the always-translate-language menuitem prefs and checked state.
+ * If auto-translate is currently active for the doc language, deactivates it.
+ * If auto-translate is currently inactive for the doc language, activates it.
+ */
+ async onAlwaysTranslateLanguage() {
+ const { docLangTag } = await this.#getCachedDetectedLanguages();
+ if (!docLangTag) {
+ throw new Error("Expected to have a document language tag.");
+ }
+ const pageAction =
+ this.createCheckboxStateMachine().onAlwaysTranslateLanguage();
+ TranslationsParent.toggleAlwaysTranslateLanguagePref(docLangTag);
+ this.#updateSettingsMenuLanguageCheckboxStates();
+ await this.#doPageAction(pageAction);
+ }
+
+ /**
+ * Updates the never-translate-language menuitem prefs and checked state.
+ * If never-translate is currently active for the doc language, deactivates it.
+ * If never-translate is currently inactive for the doc language, activates it.
+ */
+ async onNeverTranslateLanguage() {
+ const { docLangTag } = await this.#getCachedDetectedLanguages();
+ if (!docLangTag) {
+ throw new Error("Expected to have a document language tag.");
+ }
+ const pageAction =
+ this.createCheckboxStateMachine().onNeverTranslateLanguage();
+ TranslationsParent.toggleNeverTranslateLanguagePref(docLangTag);
+ this.#updateSettingsMenuLanguageCheckboxStates();
+ await this.#doPageAction(pageAction);
+ }
+
+ /**
+ * Updates the never-translate-site menuitem permissions and checked state.
+ * If never-translate is currently active for the site, deactivates it.
+ * If never-translate is currently inactive for the site, activates it.
+ */
+ async onNeverTranslateSite() {
+ const pageAction = this.createCheckboxStateMachine().onNeverTranslateSite();
+ await this.#getTranslationsActor().toggleNeverTranslateSitePermissions();
+ this.#updateSettingsMenuSiteCheckboxStates();
+ await this.#doPageAction(pageAction);
+ }
+
+ /**
+ * Handle the restore button being clicked.
+ */
+ async onRestore() {
+ const { panel } = this.elements;
+ PanelMultiView.hidePopup(panel);
+ const { docLangTag } = await this.#getCachedDetectedLanguages();
+ if (!docLangTag) {
+ throw new Error("Expected to have a document language tag.");
+ }
+
+ this.#getTranslationsActor().restorePage(docLangTag);
+ }
+
+ /**
+ * Set the state of the translations button in the URL bar.
+ *
+ * @param {CustomEvent} event
+ */
+ handleEvent = async event => {
+ switch (event.type) {
+ case "TranslationsParent:LanguageState":
+ const {
+ detectedLanguages,
+ requestedTranslationPair,
+ error,
+ isEngineReady,
+ } = event.detail;
+
+ const { panel, button, buttonLocale, buttonCircleArrows } =
+ this.elements;
+
+ const hasSupportedLanguage =
+ detectedLanguages?.docLangTag &&
+ detectedLanguages?.userLangTag &&
+ detectedLanguages?.isDocLangTagSupported;
+
+ if (detectedLanguages) {
+ // Ensure the cached detected languages are up to date, for instance whenever
+ // the user switches tabs.
+ TranslationsPanel.detectedLanguages = detectedLanguages;
+ }
+
+ /**
+ * Defer this check to the end of the `if` statement since it requires work.
+ */
+ const shouldNeverTranslate = async () => {
+ return Boolean(
+ TranslationsParent.shouldNeverTranslateLanguage(
+ detectedLanguages?.docLangTag
+ ) ||
+ // The site is present in the never-translate list.
+ (await this.#getTranslationsActor().shouldNeverTranslateSite())
+ );
+ };
+
+ if (
+ // We've already requested to translate this page, so always show the icon.
+ requestedTranslationPair ||
+ // There was an error translating, so always show the icon. This can happen
+ // when a user manually invokes the translation and we wouldn't normally show
+ // the icon.
+ error ||
+ // Finally check that this is a supported language that we should translate.
+ (hasSupportedLanguage && !(await shouldNeverTranslate()))
+ ) {
+ button.hidden = false;
+ if (requestedTranslationPair) {
+ // The translation is active, update the urlbar button.
+ button.setAttribute("translationsactive", true);
+ if (isEngineReady) {
+ // Show the locale of the page in the button.
+ buttonLocale.hidden = false;
+ buttonCircleArrows.hidden = true;
+ buttonLocale.innerText = requestedTranslationPair.toLanguage;
+ } else {
+ // Show the spinning circle arrows to indicate that the engine is
+ // still loading.
+ buttonCircleArrows.hidden = false;
+ buttonLocale.hidden = true;
+ }
+ } else {
+ // The translation is not active, update the urlbar button.
+ button.removeAttribute("translationsactive");
+ buttonLocale.hidden = true;
+ buttonCircleArrows.hidden = true;
+ }
+ } else {
+ this.#hideTranslationsButton();
+ }
+
+ switch (error) {
+ case null:
+ this.elements.error.hidden = true;
+ break;
+ case "engine-load-failure":
+ this.elements.error.hidden = false;
+ this.#showError({
+ message: "translations-panel-error-translating",
+ });
+ const targetButton = button.hidden
+ ? this.elements.appMenuButton
+ : button;
+
+ // Re-open the menu on an error.
+ PanelMultiView.openPopup(panel, targetButton, {
+ position: "bottomright topright",
+ }).catch(panelError => this.console.error(panelError));
+
+ break;
+ default:
+ console.error("Unknown translation error", error);
+ }
+ break;
+ }
+ };
+})();
diff --git a/browser/components/translations/jar.mn b/browser/components/translations/jar.mn
new file mode 100644
index 0000000000..63920beb19
--- /dev/null
+++ b/browser/components/translations/jar.mn
@@ -0,0 +1,7 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+browser.jar:
+ content/browser/translations/translationsPanel.js (content/translationsPanel.js)
+ content/browser/translations/TranslationsTelemetry.sys.mjs (TranslationsTelemetry.sys.mjs)
diff --git a/browser/components/translations/metrics.yaml b/browser/components/translations/metrics.yaml
new file mode 100644
index 0000000000..6042fa2332
--- /dev/null
+++ b/browser/components/translations/metrics.yaml
@@ -0,0 +1,30 @@
+# This Source Code Form is subject to the terms of the Mozilla Public↩
+# License, v. 2.0. If a copy of the MPL was not distributed with this↩
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.↩
+
+# Adding a new metric? We have docs for that!↩
+# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html↩
+
+---
+$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0
+$tags:
+ - 'Firefox :: Translation'
+
+translations.panel:
+ open:
+ type: event
+ description: >
+ Triggers when the translations panel is opened.
+ extra_keys:
+ opened_from:
+ type: string
+ description: The method by which the translations panel was opened.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1835502
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1835502#c7
+ data_sensitivity:
+ - interaction
+ notification_emails:
+ - translations-telemetry-alerts@mozilla.com
+ expires: 122
diff --git a/browser/components/translations/moz.build b/browser/components/translations/moz.build
new file mode 100644
index 0000000000..4fd489745f
--- /dev/null
+++ b/browser/components/translations/moz.build
@@ -0,0 +1,10 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "Translation")
+
+BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"]
+
+JAR_MANIFESTS += ["jar.mn"]
diff --git a/browser/components/translations/tests/browser/browser.ini b/browser/components/translations/tests/browser/browser.ini
new file mode 100644
index 0000000000..fd67597972
--- /dev/null
+++ b/browser/components/translations/tests/browser/browser.ini
@@ -0,0 +1,19 @@
+[DEFAULT]
+support-files =
+ head.js
+ !/toolkit/components/translations/tests/browser/shared-head.js
+ !/toolkit/components/translations/tests/browser/translations-test.mjs
+[browser_manage_languages.js]
+[browser_translations_panel_always_translate_language.js]
+[browser_translations_panel_basics.js]
+[browser_translations_panel_beta_langs.js]
+[browser_translations_panel_button.js]
+[browser_translations_panel_cancel.js]
+[browser_translations_panel_gear.js]
+[browser_translations_panel_never_translate_language.js]
+[browser_translations_panel_never_translate_site.js]
+[browser_translations_panel_retry.js]
+[browser_translations_panel_switch_languages.js]
+[browser_translations_telemetry_open_panel.js]
+[browser_translations_telemetry_translation_failure.js]
+[browser_translations_telemetry_translation_request.js]
diff --git a/browser/components/translations/tests/browser/browser_manage_languages.js b/browser/components/translations/tests/browser/browser_manage_languages.js
new file mode 100644
index 0000000000..651029ad86
--- /dev/null
+++ b/browser/components/translations/tests/browser/browser_manage_languages.js
@@ -0,0 +1,195 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const frenchModels = [
+ "lex.50.50.enfr.s2t.bin",
+ "lex.50.50.fren.s2t.bin",
+ "model.enfr.intgemm.alphas.bin",
+ "model.fren.intgemm.alphas.bin",
+ "vocab.enfr.spm",
+ "vocab.fren.spm",
+];
+
+add_task(async function test_about_preferences_manage_languages() {
+ const {
+ cleanup,
+ remoteClients,
+ elements: {
+ downloadAllLabel,
+ downloadAll,
+ deleteAll,
+ frenchLabel,
+ frenchDownload,
+ frenchDelete,
+ spanishLabel,
+ spanishDownload,
+ spanishDelete,
+ },
+ } = await setupAboutPreferences([
+ { fromLang: "en", toLang: "fr" },
+ { fromLang: "fr", toLang: "en" },
+ { fromLang: "en", toLang: "es" },
+ { fromLang: "es", toLang: "en" },
+ ]);
+
+ is(
+ downloadAllLabel.getAttribute("data-l10n-id"),
+ "translations-manage-all-language",
+ "The first row is all of the languages."
+ );
+ is(frenchLabel.textContent, "French", "There is a French row.");
+ is(spanishLabel.textContent, "Spanish", "There is a Spanish row.");
+
+ await assertVisibility({
+ message: "Everything starts out as available to download",
+ visible: { downloadAll, frenchDownload, spanishDownload },
+ hidden: { deleteAll, frenchDelete, spanishDelete },
+ });
+
+ click(frenchDownload, "Downloading French");
+
+ Assert.deepEqual(
+ await remoteClients.translationModels.resolvePendingDownloads(
+ frenchModels.length
+ ),
+ frenchModels,
+ "French models were downloaded."
+ );
+
+ await assertVisibility({
+ message: "French can now be deleted, and delete all is available.",
+ visible: { downloadAll, deleteAll, frenchDelete, spanishDownload },
+ hidden: { frenchDownload, spanishDelete },
+ });
+
+ click(frenchDelete, "Deleting French");
+
+ await assertVisibility({
+ message: "Everything can be downloaded.",
+ visible: { downloadAll, frenchDownload, spanishDownload },
+ hidden: { deleteAll, frenchDelete, spanishDelete },
+ });
+
+ click(downloadAll, "Downloading all languages.");
+
+ const allModels = [
+ "lex.50.50.enes.s2t.bin",
+ "lex.50.50.enfr.s2t.bin",
+ "lex.50.50.esen.s2t.bin",
+ "lex.50.50.fren.s2t.bin",
+ "model.enes.intgemm.alphas.bin",
+ "model.enfr.intgemm.alphas.bin",
+ "model.esen.intgemm.alphas.bin",
+ "model.fren.intgemm.alphas.bin",
+ "vocab.enes.spm",
+ "vocab.enfr.spm",
+ "vocab.esen.spm",
+ "vocab.fren.spm",
+ ];
+ Assert.deepEqual(
+ await remoteClients.translationModels.resolvePendingDownloads(
+ allModels.length
+ ),
+ allModels,
+ "All models were downloaded."
+ );
+ Assert.deepEqual(
+ await remoteClients.languageIdModels.resolvePendingDownloads(1),
+ ["lid.176.ftz"],
+ "Language ID model was downloaded."
+ );
+ Assert.deepEqual(
+ await remoteClients.translationsWasm.resolvePendingDownloads(2),
+ ["bergamot-translator", "fasttext-wasm"],
+ "Wasm was downloaded."
+ );
+
+ await assertVisibility({
+ message: "Everything can be deleted.",
+ visible: { deleteAll, frenchDelete, spanishDelete },
+ hidden: { downloadAll, frenchDownload, spanishDownload },
+ });
+
+ click(deleteAll, "Deleting all languages.");
+
+ await assertVisibility({
+ message: "Everything can be downloaded again",
+ visible: { downloadAll, frenchDownload, spanishDownload },
+ hidden: { deleteAll, frenchDelete, spanishDelete },
+ });
+
+ click(frenchDownload, "Downloading French.");
+ click(spanishDownload, "Downloading Spanish.");
+
+ Assert.deepEqual(
+ await remoteClients.translationModels.resolvePendingDownloads(
+ allModels.length
+ ),
+ allModels,
+ "All models were downloaded again."
+ );
+
+ remoteClients.translationsWasm.assertNoNewDownloads();
+ remoteClients.languageIdModels.assertNoNewDownloads();
+
+ await assertVisibility({
+ message: "Everything is downloaded again.",
+ visible: { deleteAll, frenchDelete, spanishDelete },
+ hidden: { downloadAll, frenchDownload, spanishDownload },
+ });
+
+ return cleanup();
+});
+
+add_task(async function test_about_preferences_download_reject() {
+ const {
+ cleanup,
+ remoteClients,
+ elements: { document, frenchDownload },
+ } = await setupAboutPreferences([
+ { fromLang: "en", toLang: "fr" },
+ { fromLang: "fr", toLang: "en" },
+ { fromLang: "en", toLang: "es" },
+ { fromLang: "es", toLang: "en" },
+ ]);
+
+ click(frenchDownload, "Downloading French");
+
+ is(
+ maybeGetByL10nId("translations-manage-error-download", document),
+ null,
+ "No error messages are present."
+ );
+
+ const errors = await captureTranslationsError(() =>
+ remoteClients.translationModels.rejectPendingDownloads(frenchModels.length)
+ );
+
+ ok(
+ !!errors.length,
+ `The errors for download should have been reported, found ${errors.length} errors`
+ );
+ for (const { error } of errors) {
+ is(
+ error?.message,
+ "Failed to download file.",
+ "The error reported was a download error."
+ );
+ }
+
+ await TestUtils.waitForCondition(
+ () => maybeGetByL10nId("translations-manage-error-download", document),
+ "The error message is now visible."
+ );
+
+ click(frenchDownload, "Attempting to download French again", document);
+ is(
+ maybeGetByL10nId("translations-manage-error-download", document),
+ null,
+ "The error message is hidden again."
+ );
+
+ return cleanup();
+});
diff --git a/browser/components/translations/tests/browser/browser_translations_panel_always_translate_language.js b/browser/components/translations/tests/browser/browser_translations_panel_always_translate_language.js
new file mode 100644
index 0000000000..4bc2c7dc15
--- /dev/null
+++ b/browser/components/translations/tests/browser/browser_translations_panel_always_translate_language.js
@@ -0,0 +1,413 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests the effect of toggling the always-translate-language menuitem.
+ * Checking the box on an untranslated page should immediately translate the page.
+ * Unchecking the box on a translated page should immediately restore the page.
+ */
+add_task(async function test_toggle_always_translate_language_menuitem() {
+ const { cleanup, resolveDownloads, runInPage } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ prefs: [["browser.translations.alwaysTranslateLanguages", "pl,fr"]],
+ });
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: false, icon: true },
+ "The translations button is visible."
+ );
+
+ info(
+ 'The document language "es" is not in the alwaysTranslateLanguages pref, ' +
+ "so the page should be untranslated, in its original form"
+ );
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ info(
+ "Simulate clicking always-translate-language in the settings menu, " +
+ "adding the document language to the alwaysTranslateLanguages pref"
+ );
+ await openSettingsMenu();
+
+ await assertIsAlwaysTranslateLanguage("es", false);
+ await toggleAlwaysTranslateLanguage();
+ await assertIsAlwaysTranslateLanguage("es", true);
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: true, locale: false, icon: true },
+ "The icon presents the loading indicator."
+ );
+
+ await resolveDownloads(1);
+
+ const { locale } = await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: true, icon: true },
+ "The icon presents the locale."
+ );
+
+ is(locale.innerText, "en", "The English language tag is shown.");
+
+ info(
+ "The page should now be automatically translated because the document language " +
+ "should be added to the always-translate pref"
+ );
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is translated automatically",
+ getH1,
+ "DON QUIJOTE DE LA MANCHA [es to en, html]"
+ );
+ });
+
+ info("Navigate to a different Spanish page");
+ await navigate(SPANISH_PAGE_URL_DOT_ORG);
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: true, locale: false, icon: true },
+ "The icon presents the loading indicator."
+ );
+
+ await resolveDownloads(1);
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: true, icon: true },
+ "The icon presents the locale."
+ );
+
+ is(locale.innerText, "en", "The English language tag is shown.");
+
+ info(
+ "The page should now be automatically translated because the document language " +
+ "should be added to the always-translate pref"
+ );
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is translated automatically",
+ getH1,
+ "DON QUIJOTE DE LA MANCHA [es to en, html]"
+ );
+ });
+
+ info(
+ "Simulate clicking always-translate-language in the settings menu " +
+ "removing the document language from the alwaysTranslateLanguages pref"
+ );
+ await openSettingsMenu();
+
+ await assertIsAlwaysTranslateLanguage("es", true);
+ await toggleAlwaysTranslateLanguage();
+ await assertIsAlwaysTranslateLanguage("es", false);
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: false, icon: true },
+ "Only the button appears"
+ );
+
+ info(
+ "The page should no longer automatically translated because the document language " +
+ "should be removed from the always-translate pref"
+ );
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await cleanup();
+});
+
+/**
+ * Tests the effect of toggling the always-translate-language menuitem after the page has
+ * been manually translated. This should not reload or retranslate the page, but just check
+ * the box.
+ */
+add_task(
+ async function test_activate_always_translate_language_after_manual_translation() {
+ const { cleanup, resolveDownloads, runInPage } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ prefs: [["browser.translations.alwaysTranslateLanguages", "pl,fr"]],
+ });
+
+ const { button } = await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: false, icon: true },
+ "The button is available."
+ );
+
+ info(
+ 'The document language "es" is not in the alwaysTranslateLanguages pref, ' +
+ "so the page should be untranslated, in its original form"
+ );
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await waitForTranslationsPopupEvent("popupshown", () => {
+ click(button, "Opening the popup");
+ });
+
+ await waitForTranslationsPopupEvent("popuphidden", () => {
+ click(
+ getByL10nId("translations-panel-translate-button"),
+ "Start translating by clicking the translate button."
+ );
+ });
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: true, locale: false, icon: true },
+ "The icon presents the loading indicator."
+ );
+
+ await resolveDownloads(1);
+
+ const { locale } = await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: true, icon: true },
+ "The icon presents the locale."
+ );
+
+ is(locale.innerText, "en", "The English language tag is shown.");
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The pages H1 is translated.",
+ getH1,
+ "DON QUIJOTE DE LA MANCHA [es to en, html]"
+ );
+ });
+
+ info(
+ "Simulate clicking always-translate-language in the settings menu, " +
+ "adding the document language to the alwaysTranslateLanguages pref"
+ );
+ await openSettingsMenu();
+
+ await assertIsAlwaysTranslateLanguage("es", false);
+ await toggleAlwaysTranslateLanguage();
+ await assertIsAlwaysTranslateLanguage("es", true);
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: true, icon: true },
+ "The continues to present the locale without pending downloads."
+ );
+
+ is(locale.innerText, "en", "The English language tag is shown.");
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The pages H1 is translated.",
+ getH1,
+ "DON QUIJOTE DE LA MANCHA [es to en, html]"
+ );
+ });
+
+ info(
+ "Simulate clicking always-translate-language in the settings menu " +
+ "removing the document language from the alwaysTranslateLanguages pref"
+ );
+ await assertIsAlwaysTranslateLanguage("es", true);
+ await toggleAlwaysTranslateLanguage();
+ await assertIsAlwaysTranslateLanguage("es", false);
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: false, icon: true },
+ "Only the button appears"
+ );
+
+ info(
+ "The page should no longer automatically translated because the document language " +
+ "should be removed from the always-translate pref"
+ );
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await cleanup();
+ }
+);
+
+/**
+ * Tests the effect of unchecking the always-translate language menuitem after the page has
+ * been manually restored to its original form.
+ * This should have no effect on the page, and further page loads should no longer auto-translate.
+ */
+add_task(
+ async function test_deactivate_always_translate_language_after_restore() {
+ const { cleanup, resolveDownloads, runInPage } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ prefs: [["browser.translations.alwaysTranslateLanguages", "pl,fr"]],
+ });
+
+ const { button } = await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: false, icon: true },
+ "The translations button is visible."
+ );
+
+ info(
+ 'The document language "es" is not in the alwaysTranslateLanguages pref, ' +
+ "so the page should be untranslated, in its original form"
+ );
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ info(
+ "Simulate clicking always-translate-language in the settings menu, " +
+ "adding the document language to the alwaysTranslateLanguages pref"
+ );
+ await openSettingsMenu();
+
+ await assertIsAlwaysTranslateLanguage("es", false);
+ await toggleAlwaysTranslateLanguage();
+ await assertIsAlwaysTranslateLanguage("es", true);
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: true, locale: false, icon: true },
+ "The icon presents the loading indicator."
+ );
+
+ await resolveDownloads(1);
+
+ const { locale } = await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: true, icon: true },
+ "The icon presents the locale."
+ );
+
+ is(locale.innerText, "en", "The English language tag is shown.");
+
+ info(
+ "The page should now be automatically translated because the document language " +
+ "should be added to the always-translate pref"
+ );
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is translated automatically",
+ getH1,
+ "DON QUIJOTE DE LA MANCHA [es to en, html]"
+ );
+ });
+
+ await navigate(
+ SPANISH_PAGE_URL_DOT_ORG,
+ "Navigate to a different Spanish page"
+ );
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: true, locale: false, icon: true },
+ "The icon presents the loading indicator."
+ );
+
+ await resolveDownloads(1);
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: true, icon: true },
+ "The icon presents the locale."
+ );
+
+ is(locale.innerText, "en", "The English language tag is shown.");
+
+ info(
+ "The page should now be automatically translated because the document language " +
+ "should be added to the always-translate pref"
+ );
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is translated automatically",
+ getH1,
+ "DON QUIJOTE DE LA MANCHA [es to en, html]"
+ );
+ });
+
+ await waitForTranslationsPopupEvent("popupshown", () => {
+ click(button, "Re-opening the popup");
+ });
+
+ await waitForTranslationsPopupEvent("popuphidden", () => {
+ click(
+ getByL10nId("translations-panel-restore-button"),
+ "Click the restore language button."
+ );
+ });
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: false, icon: true },
+ "The button is reverted to have an icon."
+ );
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is restored to Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ info(
+ "Simulate clicking always-translate-language in the settings menu, " +
+ "removing the document language to the alwaysTranslateLanguages pref"
+ );
+ await openSettingsMenu();
+
+ await assertIsAlwaysTranslateLanguage("es", true);
+ await toggleAlwaysTranslateLanguage();
+ await assertIsAlwaysTranslateLanguage("es", false);
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: false, icon: true },
+ "The button shows only the icon."
+ );
+
+ await navigate(SPANISH_PAGE_URL_DOT_ORG, "Reload the page");
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: false, icon: true },
+ "The button shows only the icon."
+ );
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is restored to Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await cleanup();
+ }
+);
diff --git a/browser/components/translations/tests/browser/browser_translations_panel_basics.js b/browser/components/translations/tests/browser/browser_translations_panel_basics.js
new file mode 100644
index 0000000000..df2b0da4e5
--- /dev/null
+++ b/browser/components/translations/tests/browser/browser_translations_panel_basics.js
@@ -0,0 +1,94 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests a basic panel open, translation, and restoration to the original language.
+ */
+add_task(async function test_translations_panel_basics() {
+ const { cleanup, resolveDownloads, runInPage } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ });
+
+ const { button } = await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: false, icon: true },
+ "The button is available."
+ );
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await waitForTranslationsPopupEvent("popupshown", () => {
+ click(button, "Opening the popup");
+ });
+
+ await waitForTranslationsPopupEvent("popuphidden", () => {
+ click(
+ getByL10nId("translations-panel-translate-button"),
+ "Start translating by clicking the translate button."
+ );
+ });
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: true, locale: false, icon: true },
+ "The icon presents the loading indicator."
+ );
+
+ await resolveDownloads(1);
+
+ const { locale } = await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: true, icon: true },
+ "The icon presents the locale."
+ );
+
+ is(locale.innerText, "en", "The English language tag is shown.");
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The pages H1 is translated.",
+ getH1,
+ "DON QUIJOTE DE LA MANCHA [es to en, html]"
+ );
+ });
+
+ await waitForTranslationsPopupEvent("popupshown", () => {
+ click(button, "Re-opening the popup");
+ });
+
+ ok(
+ getByL10nId("translations-panel-translate-button").disabled,
+ "The translate button is disabled when re-opening."
+ );
+
+ await waitForTranslationsPopupEvent("popuphidden", () => {
+ click(
+ getByL10nId("translations-panel-restore-button"),
+ "Click the restore language button."
+ );
+ });
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is restored to Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: false, icon: true },
+ "The button is reverted to have an icon."
+ );
+
+ await cleanup();
+});
diff --git a/browser/components/translations/tests/browser/browser_translations_panel_beta_langs.js b/browser/components/translations/tests/browser/browser_translations_panel_beta_langs.js
new file mode 100644
index 0000000000..f44f2ad9e8
--- /dev/null
+++ b/browser/components/translations/tests/browser/browser_translations_panel_beta_langs.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that languages are displayed correctly as being in beta or not.
+ */
+add_task(async function test_translations_panel_display_beta_languages() {
+ const { cleanup } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ });
+
+ function assertBetaDisplay(selectElement) {
+ const betaL10nId = "translations-panel-displayname-beta";
+ const options = selectElement.querySelectorAll("menuitem");
+ if (options.length === 0) {
+ throw new Error("Could not find the menuitems.");
+ }
+
+ for (const option of options) {
+ for (const languagePair of LANGUAGE_PAIRS) {
+ if (
+ languagePair.fromLang === option.value ||
+ languagePair.toLang === option.value
+ ) {
+ if (option.getAttribute("data-l10n-id") === betaL10nId) {
+ is(
+ languagePair.isBeta,
+ true,
+ `Since data-l10n-id was ${betaL10nId} for ${option.value}, then it must be part of a beta language pair, but it was not.`
+ );
+ }
+ if (!languagePair.isBeta) {
+ is(
+ option.getAttribute("data-l10n-id") === betaL10nId,
+ false,
+ `Since the languagePair is non-beta, the language option ${option.value} should not have a data-l10-id of ${betaL10nId}, but it does.`
+ );
+ }
+ }
+ }
+ }
+ }
+
+ const { button } = await assertTranslationsButton(
+ { button: true },
+ "The button is available."
+ );
+
+ await waitForTranslationsPopupEvent("popupshown", () => {
+ click(button, "Opening the popup");
+ });
+
+ assertBetaDisplay(document.getElementById("translations-panel-to"));
+
+ await waitForTranslationsPopupEvent("popuphidden", () => {
+ click(
+ getByL10nId("translations-panel-translate-cancel"),
+ "Click the cancel button."
+ );
+ });
+
+ await cleanup();
+});
diff --git a/browser/components/translations/tests/browser/browser_translations_panel_button.js b/browser/components/translations/tests/browser/browser_translations_panel_button.js
new file mode 100644
index 0000000000..57e09260fb
--- /dev/null
+++ b/browser/components/translations/tests/browser/browser_translations_panel_button.js
@@ -0,0 +1,80 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test that the translations button is correctly visible when navigating between pages.
+ */
+add_task(async function test_button_visible_navigation() {
+ info("Start at a page in Spanish.");
+ const { cleanup } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ });
+
+ await assertTranslationsButton(
+ { button: true },
+ "The button should be visible since the page can be translated from Spanish."
+ );
+
+ navigate(ENGLISH_PAGE_URL, "Navigate to an English page.");
+
+ await assertTranslationsButton(
+ { button: false },
+ "The button should be invisible since the page is in English."
+ );
+
+ navigate(SPANISH_PAGE_URL, "Navigate back to a Spanish page.");
+
+ await assertTranslationsButton(
+ { button: true },
+ "The button should be visible again since the page is in Spanish."
+ );
+
+ await cleanup();
+});
+
+/**
+ * Test that the translations button is correctly visible when opening and switch tabs.
+ */
+add_task(async function test_button_visible() {
+ info("Start at a page in Spanish.");
+
+ const { cleanup, tab: spanishTab } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ });
+
+ await assertTranslationsButton(
+ { button: true },
+ "The button should be visible since the page can be translated from Spanish."
+ );
+
+ const { removeTab, tab: englishTab } = await addTab(
+ ENGLISH_PAGE_URL,
+ "Creating a new tab for a page in English."
+ );
+
+ await assertTranslationsButton(
+ { button: false },
+ "The button should be invisible since the tab is in English."
+ );
+
+ await switchTab(spanishTab);
+
+ await assertTranslationsButton(
+ { button: true },
+ "The button should be visible again since the page is in Spanish."
+ );
+
+ await switchTab(englishTab);
+
+ await assertTranslationsButton(
+ { button: false },
+ "Don't show for english pages"
+ );
+
+ await removeTab();
+ await cleanup();
+});
diff --git a/browser/components/translations/tests/browser/browser_translations_panel_cancel.js b/browser/components/translations/tests/browser/browser_translations_panel_cancel.js
new file mode 100644
index 0000000000..b9bf532d96
--- /dev/null
+++ b/browser/components/translations/tests/browser/browser_translations_panel_cancel.js
@@ -0,0 +1,32 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests a panel open, and hitting the cancel button.
+ */
+add_task(async function test_translations_panel_cancel() {
+ const { cleanup } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ });
+
+ const { button } = await assertTranslationsButton(
+ { button: true },
+ "The button is available."
+ );
+
+ await waitForTranslationsPopupEvent("popupshown", () => {
+ click(button, "Opening the popup");
+ });
+
+ await waitForTranslationsPopupEvent("popuphidden", () => {
+ click(
+ getByL10nId("translations-panel-translate-cancel"),
+ "Click the cancel button."
+ );
+ });
+
+ await cleanup();
+});
diff --git a/browser/components/translations/tests/browser/browser_translations_panel_gear.js b/browser/components/translations/tests/browser/browser_translations_panel_gear.js
new file mode 100644
index 0000000000..46838accb4
--- /dev/null
+++ b/browser/components/translations/tests/browser/browser_translations_panel_gear.js
@@ -0,0 +1,42 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test managing the languages menu item.
+ */
+add_task(async function test_translations_panel_manage_languages() {
+ const { cleanup } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ });
+
+ const { button } = await assertTranslationsButton(
+ { button: true },
+ "The button is available."
+ );
+
+ await waitForTranslationsPopupEvent("popupshown", () => {
+ click(button, "Opening the popup");
+ });
+
+ const gearIcon = getByL10nId("translations-panel-settings-button");
+ click(gearIcon, "Open the preferences menu");
+
+ const manageLanguages = getByL10nId(
+ "translations-panel-settings-manage-languages"
+ );
+ info("Choose to manage the languages.");
+ manageLanguages.doCommand();
+
+ await TestUtils.waitForCondition(
+ () => gBrowser.currentURI.spec === "about:preferences#general",
+ "Waiting for about:preferences to be opened."
+ );
+
+ info("Remove the about:preferences tab");
+ gBrowser.removeCurrentTab();
+
+ await cleanup();
+});
diff --git a/browser/components/translations/tests/browser/browser_translations_panel_never_translate_language.js b/browser/components/translations/tests/browser/browser_translations_panel_never_translate_language.js
new file mode 100644
index 0000000000..c85443f457
--- /dev/null
+++ b/browser/components/translations/tests/browser/browser_translations_panel_never_translate_language.js
@@ -0,0 +1,343 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests the effect of toggling the never-translate-language menuitem.
+ * Checking the box on an untranslated page should immediately hide the button.
+ * The button should not appear again for sites in the disabled language.
+ */
+add_task(async function test_toggle_never_translate_language_menuitem() {
+ const { cleanup, runInPage } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ prefs: [["browser.translations.neverTranslateLanguages", "pl,fr"]],
+ });
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: false, icon: true },
+ "The translations button is visible."
+ );
+
+ info(
+ 'The document language "es" is not in the neverTranslateLanguages pref, ' +
+ "so the page should be untranslated, in its original form."
+ );
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ info(
+ "Simulate clicking never-translate-language in the settings menu, " +
+ "adding the document language from the neverTranslateLanguages pref"
+ );
+ await openSettingsMenu();
+
+ await assertIsNeverTranslateLanguage("es", false);
+ await toggleNeverTranslateLanguage();
+ await assertIsNeverTranslateLanguage("es", true);
+
+ await assertTranslationsButton(
+ { button: false },
+ "The translations button should be invisible"
+ );
+
+ info(
+ "The page should still be in its original, untranslated form because " +
+ "the document language is in the neverTranslateLanguages pref"
+ );
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await navigate(SPANISH_PAGE_URL, "Reload the page");
+
+ await assertTranslationsButton(
+ { button: false },
+ "The translations button should be invisible"
+ );
+
+ info(
+ "The page should still be in its original, untranslated form because " +
+ "the document language is in the neverTranslateLanguages pref"
+ );
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await navigate(
+ SPANISH_PAGE_URL_DOT_ORG,
+ "Navigate to a different Spanish page"
+ );
+
+ await assertTranslationsButton(
+ { button: false },
+ "The translations button should be invisible"
+ );
+
+ info(
+ "The page should still be in its original, untranslated form because " +
+ "the document language is in the neverTranslateLanguages pref"
+ );
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await cleanup();
+});
+
+/**
+ * Tests the effect of toggling the never-translate-language menuitem on a page where
+ * where translation is already active.
+ * Checking the box on a translated page should restore the page and hide the button.
+ * The button should not appear again for sites in the disabled language.
+ */
+add_task(
+ async function test_toggle_never_translate_language_menuitem_with_active_translations() {
+ const { cleanup, resolveDownloads, runInPage } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ prefs: [["browser.translations.neverTranslateLanguages", "pl,fr"]],
+ });
+
+ const { button } = await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: false, icon: true },
+ "The button is available."
+ );
+
+ info(
+ 'The document language "es" is not in the alwaysTranslateLanguages pref, ' +
+ "so the page should be untranslated, in its original form"
+ );
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await waitForTranslationsPopupEvent("popupshown", () => {
+ click(button, "Opening the popup");
+ });
+
+ await waitForTranslationsPopupEvent("popuphidden", () => {
+ click(
+ getByL10nId("translations-panel-translate-button"),
+ "Start translating by clicking the translate button."
+ );
+ });
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: true, locale: false, icon: true },
+ "The icon presents the loading indicator."
+ );
+
+ await resolveDownloads(1);
+
+ const { locale } = await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: true, icon: true },
+ "The icon presents the locale."
+ );
+
+ is(locale.innerText, "en", "The English language tag is shown.");
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The pages H1 is translated.",
+ getH1,
+ "DON QUIJOTE DE LA MANCHA [es to en, html]"
+ );
+ });
+
+ info(
+ "Simulate clicking never-translate-language in the settings menu, " +
+ "adding the document language from the neverTranslateLanguages pref"
+ );
+ await openSettingsMenu();
+
+ await assertIsNeverTranslateLanguage("es", false);
+ await toggleNeverTranslateLanguage();
+ await assertIsNeverTranslateLanguage("es", true);
+
+ await assertTranslationsButton(
+ { button: false },
+ "The translations button should be invisible"
+ );
+
+ info(
+ "The page should still be in its original, untranslated form because " +
+ "the document language is in the neverTranslateLanguages pref"
+ );
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await navigate(SPANISH_PAGE_URL, "Reload the page");
+
+ await assertTranslationsButton(
+ { button: false },
+ "The translations button should be invisible"
+ );
+
+ info(
+ "The page should still be in its original, untranslated form because " +
+ "the document language is in the neverTranslateLanguages pref"
+ );
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await cleanup();
+ }
+);
+
+/**
+ * Tests the effect of toggling the never-translate-language menuitem on a page where
+ * where translation is already active via always-translate.
+ * Checking the box on a translated page should restore the page and hide the button.
+ * The language should be moved from always-translate to never-translate.
+ * The button should not appear again for sites in the disabled language.
+ */
+add_task(
+ async function test_toggle_never_translate_language_menuitem_with_always_translate_active() {
+ const { cleanup, resolveDownloads, runInPage } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ prefs: [
+ ["browser.translations.alwaysTranslateLanguages", "uk,it"],
+ ["browser.translations.neverTranslateLanguages", "pl,fr"],
+ ],
+ });
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: false, icon: true },
+ "The button is available."
+ );
+
+ info(
+ "Simulate clicking always-translate-language in the settings menu, " +
+ "adding the document language to the alwaysTranslateLanguages pref"
+ );
+ await openSettingsMenu();
+
+ await assertIsAlwaysTranslateLanguage("es", false);
+ await assertIsNeverTranslateLanguage("es", false);
+
+ await toggleAlwaysTranslateLanguage();
+
+ await assertIsAlwaysTranslateLanguage("es", true);
+ await assertIsNeverTranslateLanguage("es", false);
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: true, locale: false, icon: true },
+ "The icon presents the loading indicator."
+ );
+
+ await resolveDownloads(1);
+
+ const { locale } = await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: true, icon: true },
+ "The icon presents the locale."
+ );
+
+ is(locale.innerText, "en", "The English language tag is shown.");
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The pages H1 is translated.",
+ getH1,
+ "DON QUIJOTE DE LA MANCHA [es to en, html]"
+ );
+ });
+
+ info(
+ "Simulate clicking never-translate-language in the settings menu, " +
+ "adding the document language from the neverTranslateLanguages pref " +
+ "and removing it from the alwaysTranslateLanguages pref"
+ );
+ await openSettingsMenu();
+
+ await assertIsAlwaysTranslateLanguage("es", true);
+ await assertIsNeverTranslateLanguage("es", false);
+
+ await toggleNeverTranslateLanguage();
+
+ await assertIsAlwaysTranslateLanguage("es", false);
+ await assertIsNeverTranslateLanguage("es", true);
+
+ await assertTranslationsButton(
+ { button: false },
+ "The translations button should be invisible"
+ );
+
+ info(
+ "The page should still be in its original, untranslated form because " +
+ "the document language is in the neverTranslateLanguages pref"
+ );
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await navigate(SPANISH_PAGE_URL, "Reload the page");
+
+ await assertTranslationsButton(
+ { button: false },
+ "The translations button should be invisible"
+ );
+
+ info(
+ "The page should still be in its original, untranslated form because " +
+ "the document language is in the neverTranslateLanguages pref"
+ );
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await cleanup();
+ }
+);
diff --git a/browser/components/translations/tests/browser/browser_translations_panel_never_translate_site.js b/browser/components/translations/tests/browser/browser_translations_panel_never_translate_site.js
new file mode 100644
index 0000000000..b54b5a4a2c
--- /dev/null
+++ b/browser/components/translations/tests/browser/browser_translations_panel_never_translate_site.js
@@ -0,0 +1,432 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests the effect of toggling the never-translate-site menuitem.
+ * Checking the box on an untranslated page should immediately hide the button.
+ * The button should not appear again for sites that share the same content principal
+ * of the disabled site.
+ */
+add_task(async function test_toggle_never_translate_site_menuitem() {
+ const { cleanup, runInPage } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ permissionsUrls: [SPANISH_PAGE_URL],
+ });
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: false, icon: true },
+ "The translations button is visible."
+ );
+
+ info(
+ "Translations permissions are currently allowed for this test page " +
+ "and the page should be untranslated, in its original form."
+ );
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ info(
+ "Simulate clicking never-translate-site in the settings menu, " +
+ "denying translations permissions for this content window principal"
+ );
+ await openSettingsMenu();
+
+ await assertIsNeverTranslateSite(SPANISH_PAGE_URL, false);
+ await toggleNeverTranslateSite();
+ await assertIsNeverTranslateSite(SPANISH_PAGE_URL, true);
+
+ await assertTranslationsButton(
+ { button: false },
+ "The translations button should be invisible"
+ );
+
+ info("The page should still be in its original, untranslated form");
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await navigate(SPANISH_PAGE_URL, "Reload the page");
+
+ await assertTranslationsButton(
+ { button: false },
+ "The translations button should be invisible"
+ );
+
+ info("The page should still be in its original, untranslated form");
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await navigate(
+ SPANISH_PAGE_URL_2,
+ "Navigate to a Spanish page with the same content principal"
+ );
+
+ await assertTranslationsButton(
+ { button: false },
+ "The translations button should be invisible, because this content principal is denied"
+ );
+
+ info("The page should still be in its original, untranslated form");
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await navigate(
+ SPANISH_PAGE_URL_DOT_ORG,
+ "Navigate to a Spanish page with a different content principal"
+ );
+
+ await assertTranslationsButton(
+ { button: false },
+ "The translations button should be visible, because this content principal " +
+ "has not been denied translations permissions"
+ );
+
+ info("The page should still be in its original, untranslated form");
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await cleanup();
+});
+
+/**
+ * Tests the effect of toggling the never-translate-site menuitem on a page where
+ * where translation is already active.
+ * Checking the box on a translated page should restore the page and hide the button.
+ * The button should not appear again for sites that share the same content principal
+ * of the disabled site.
+ */
+add_task(
+ async function test_toggle_never_translate_site_menuitem_with_active_translations() {
+ const { cleanup, resolveDownloads, runInPage } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ permissionsUrls: [SPANISH_PAGE_URL],
+ });
+
+ const { button } = await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: false, icon: true },
+ "The translations button is visible."
+ );
+
+ info(
+ "Translations permissions are currently allowed for this test page " +
+ "and the page should be untranslated, in its original form."
+ );
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await waitForTranslationsPopupEvent("popupshown", () => {
+ click(button, "Opening the popup");
+ });
+
+ await waitForTranslationsPopupEvent("popuphidden", () => {
+ click(
+ getByL10nId("translations-panel-translate-button"),
+ "Start translating by clicking the translate button."
+ );
+ });
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: true, locale: false, icon: true },
+ "The icon presents the loading indicator."
+ );
+
+ await resolveDownloads(1);
+
+ const { locale } = await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: true, icon: true },
+ "The icon presents the locale."
+ );
+
+ is(locale.innerText, "en", "The English language tag is shown.");
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The pages H1 is translated.",
+ getH1,
+ "DON QUIJOTE DE LA MANCHA [es to en, html]"
+ );
+ });
+
+ info(
+ "Simulate clicking never-translate-site in the settings menu, " +
+ "denying translations permissions for this content window principal"
+ );
+ await openSettingsMenu();
+
+ await assertIsNeverTranslateSite(SPANISH_PAGE_URL, false);
+ await toggleNeverTranslateSite();
+ await assertIsNeverTranslateSite(SPANISH_PAGE_URL, true);
+
+ await assertTranslationsButton(
+ { button: false },
+ "The translations button should be invisible"
+ );
+
+ info("The page should still be in its original, untranslated form");
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await navigate(SPANISH_PAGE_URL, "Reload the page");
+
+ await assertTranslationsButton(
+ { button: false },
+ "The translations button should be invisible"
+ );
+
+ info("The page should still be in its original, untranslated form");
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await navigate(
+ SPANISH_PAGE_URL_2,
+ "Navigate to a Spanish page with the same content principal"
+ );
+
+ await assertTranslationsButton(
+ { button: false },
+ "The translations button should be invisible, because this content principal is denied"
+ );
+
+ info("The page should still be in its original, untranslated form");
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await navigate(
+ SPANISH_PAGE_URL_DOT_ORG,
+ "Navigate to a Spanish page with a different content principal"
+ );
+
+ await assertTranslationsButton(
+ { button: false },
+ "The translations button should be visible, because this content principal " +
+ "has not been denied translations permissions"
+ );
+
+ info("The page should still be in its original, untranslated form");
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await cleanup();
+ }
+);
+
+/**
+ * Tests the effect of toggling the never-translate-site menuitem on a page where
+ * where translation is already active via always-translate.
+ * Checking the box on a translated page should restore the page and hide the button.
+ * The button should not appear again for sites that share the same content principal
+ * of the disabled site, and no auto-translation should occur.
+ * Other sites should still auto-translate for this language.
+ */
+add_task(
+ async function test_toggle_never_translate_site_menuitem_with_always_translate_active() {
+ const { cleanup, resolveDownloads, runInPage } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ prefs: [["browser.translations.alwaysTranslateLanguages", "uk,it"]],
+ permissionsUrls: [SPANISH_PAGE_URL],
+ });
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: false, icon: true },
+ "The button is available."
+ );
+
+ info(
+ "Simulate clicking always-translate-language in the settings menu, " +
+ "adding the document language to the alwaysTranslateLanguages pref"
+ );
+ await openSettingsMenu();
+
+ await assertIsAlwaysTranslateLanguage("es", false);
+ await assertIsNeverTranslateSite(SPANISH_PAGE_URL, false);
+
+ await toggleAlwaysTranslateLanguage();
+
+ await assertIsAlwaysTranslateLanguage("es", true);
+ await assertIsNeverTranslateSite(SPANISH_PAGE_URL, false);
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: true, locale: false, icon: true },
+ "The icon presents the loading indicator."
+ );
+
+ await resolveDownloads(1);
+
+ const { locale } = await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: true, icon: true },
+ "The icon presents the locale."
+ );
+
+ is(locale.innerText, "en", "The English language tag is shown.");
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The pages H1 is translated.",
+ getH1,
+ "DON QUIJOTE DE LA MANCHA [es to en, html]"
+ );
+ });
+
+ info(
+ "Simulate clicking never-translate-site in the settings menu, " +
+ "denying translations permissions for this content window principal"
+ );
+ await openSettingsMenu();
+
+ await assertIsAlwaysTranslateLanguage("es", true);
+ await assertIsNeverTranslateSite(SPANISH_PAGE_URL, false);
+
+ await toggleNeverTranslateSite();
+
+ await assertIsAlwaysTranslateLanguage("es", true);
+ await assertIsNeverTranslateSite(SPANISH_PAGE_URL, true);
+
+ await assertTranslationsButton(
+ { button: false },
+ "The translations button should be invisible"
+ );
+
+ info("The page should still be in its original, untranslated form");
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await navigate(SPANISH_PAGE_URL, "Reload the page");
+
+ await assertTranslationsButton(
+ { button: false },
+ "The translations button should be invisible"
+ );
+
+ info("The page should still be in its original, untranslated form");
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await navigate(
+ SPANISH_PAGE_URL_2,
+ "Navigate to a Spanish page with the same content principal"
+ );
+
+ await assertTranslationsButton(
+ { button: false },
+ "The translations button should be invisible, because this content principal is denied"
+ );
+
+ info("The page should still be in its original, untranslated form");
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await navigate(
+ SPANISH_PAGE_URL_DOT_ORG,
+ "Navigate to a Spanish page with a different content principal"
+ );
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: true, locale: false, icon: true },
+ "The icon presents the loading indicator."
+ );
+
+ await resolveDownloads(1);
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: true, icon: true },
+ "The icon presents the locale."
+ );
+
+ is(locale.innerText, "en", "The English language tag is shown.");
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The pages H1 is translated.",
+ getH1,
+ "DON QUIJOTE DE LA MANCHA [es to en, html]"
+ );
+ });
+
+ await cleanup();
+ }
+);
diff --git a/browser/components/translations/tests/browser/browser_translations_panel_retry.js b/browser/components/translations/tests/browser/browser_translations_panel_retry.js
new file mode 100644
index 0000000000..e9b3f93257
--- /dev/null
+++ b/browser/components/translations/tests/browser/browser_translations_panel_retry.js
@@ -0,0 +1,80 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests translating, and then immediately translating to a new language.
+ */
+add_task(async function test_translations_panel_retry() {
+ const { cleanup, resolveDownloads, runInPage } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ });
+
+ const { button } = await assertTranslationsButton(
+ { button: true },
+ "The button is available."
+ );
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await waitForTranslationsPopupEvent("popupshown", () => {
+ click(button, "Opening the popup");
+ });
+
+ await waitForTranslationsPopupEvent("popuphidden", () => {
+ click(
+ getByL10nId("translations-panel-translate-button"),
+ "Start translating by clicking the translate button."
+ );
+ });
+
+ await resolveDownloads(1);
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The pages H1 is translated.",
+ getH1,
+ "DON QUIJOTE DE LA MANCHA [es to en, html]"
+ );
+ });
+
+ await waitForTranslationsPopupEvent("popupshown", () => {
+ click(button, "Re-opening the popup");
+ });
+
+ info('Switch to language to "fr"');
+ const toSelect = getById("translations-panel-to");
+ toSelect.value = "fr";
+ toSelect.dispatchEvent(new Event("command"));
+
+ await waitForTranslationsPopupEvent("popuphidden", () => {
+ click(
+ getByL10nId("translations-panel-translate-button"),
+ "Re-translate the page by clicking the translate button."
+ );
+ });
+
+ // This is a pivot language which requires 2 models.
+ await resolveDownloads(2);
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The pages H1 is translated using the changed languages.",
+ getH1,
+ "DON QUIJOTE DE LA MANCHA [es to fr, html]"
+ );
+ });
+
+ await cleanup();
+});
diff --git a/browser/components/translations/tests/browser/browser_translations_panel_switch_languages.js b/browser/components/translations/tests/browser/browser_translations_panel_switch_languages.js
new file mode 100644
index 0000000000..58bcb212d7
--- /dev/null
+++ b/browser/components/translations/tests/browser/browser_translations_panel_switch_languages.js
@@ -0,0 +1,97 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests switching the language.
+ */
+add_task(async function test_translations_panel_switch_language() {
+ const { cleanup, resolveDownloads, runInPage } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ });
+
+ const { button } = await assertTranslationsButton(
+ { button: true },
+ "The button is available."
+ );
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await waitForTranslationsPopupEvent("popupshown", () => {
+ click(button, "Opening the popup");
+ });
+
+ const translateButton = getByL10nId("translations-panel-translate-button");
+ const fromSelect = getById("translations-panel-from");
+ const toSelect = getById("translations-panel-to");
+
+ ok(!translateButton.disabled, "The translate button starts as enabled");
+ is(fromSelect.value, "es", "The from select starts as Spanish");
+ is(toSelect.value, "en", "The to select starts as English");
+
+ info('Switch from language to "es"');
+ fromSelect.value = "en";
+ fromSelect.dispatchEvent(new Event("command"));
+
+ ok(
+ translateButton.disabled,
+ "The translate button is disabled when the languages are the same"
+ );
+
+ info('Switch from language back to "es"');
+ fromSelect.value = "es";
+ fromSelect.dispatchEvent(new Event("command"));
+
+ ok(
+ !translateButton.disabled,
+ "When the languages are different it can be translated"
+ );
+
+ info("Switch to language to nothing");
+ fromSelect.value = "";
+ fromSelect.dispatchEvent(new Event("command"));
+
+ ok(
+ translateButton.disabled,
+ "The translate button is disabled nothing is selected."
+ );
+
+ info('Switch from language to "en"');
+ fromSelect.value = "en";
+ fromSelect.dispatchEvent(new Event("command"));
+
+ info('Switch to language to "fr"');
+ toSelect.value = "fr";
+ toSelect.dispatchEvent(new Event("command"));
+
+ ok(!translateButton.disabled, "The translate button can now be used");
+
+ await waitForTranslationsPopupEvent("popuphidden", () => {
+ click(
+ translateButton,
+ "Start translating by clicking the translate button."
+ );
+ });
+
+ await resolveDownloads(1);
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The pages H1 is translated using the changed languages.",
+ getH1,
+ "DON QUIJOTE DE LA MANCHA [en to fr, html]"
+ );
+ });
+
+ await cleanup();
+});
diff --git a/browser/components/translations/tests/browser/browser_translations_telemetry_open_panel.js b/browser/components/translations/tests/browser/browser_translations_telemetry_open_panel.js
new file mode 100644
index 0000000000..41aa730863
--- /dev/null
+++ b/browser/components/translations/tests/browser/browser_translations_telemetry_open_panel.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests the telemetry event for opening the translations panel.
+ */
+add_task(async function test_translations_telemetry_open_panel() {
+ const { cleanup } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ });
+
+ await TestTranslationsTelemetry.assertEvent(
+ "OpenPanel",
+ Glean.translationsPanel.open,
+ {
+ expectedLength: 0,
+ }
+ );
+
+ const { button } = await assertTranslationsButton(
+ { button: true },
+ "The button is available."
+ );
+
+ await waitForTranslationsPopupEvent("popupshown", () => {
+ click(button, "Opening the popup");
+ });
+
+ await waitForTranslationsPopupEvent("popuphidden", () => {
+ click(
+ getByL10nId("translations-panel-translate-cancel"),
+ "Click the cancel button."
+ );
+ });
+
+ await TestTranslationsTelemetry.assertEvent(
+ "OpenPanel",
+ Glean.translationsPanel.open,
+ {
+ expectedLength: 1,
+ finalValuePredicates: [
+ value => value.extra.opened_from === "translationsButton",
+ ],
+ }
+ );
+
+ await waitForTranslationsPopupEvent("popupshown", () => {
+ click(button, "Opening the popup");
+ });
+
+ await waitForTranslationsPopupEvent("popuphidden", () => {
+ click(
+ getByL10nId("translations-panel-translate-cancel"),
+ "Click the cancel button."
+ );
+ });
+
+ await TestTranslationsTelemetry.assertEvent(
+ "OpenPanel",
+ Glean.translationsPanel.open,
+ {
+ expectedLength: 2,
+ allValuePredicates: [
+ value => value.extra.opened_from === "translationsButton",
+ ],
+ }
+ );
+
+ await cleanup();
+});
diff --git a/browser/components/translations/tests/browser/browser_translations_telemetry_translation_failure.js b/browser/components/translations/tests/browser/browser_translations_telemetry_translation_failure.js
new file mode 100644
index 0000000000..1268e6452b
--- /dev/null
+++ b/browser/components/translations/tests/browser/browser_translations_telemetry_translation_failure.js
@@ -0,0 +1,195 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+
+/**
+ * Tests the telemetry event for a manual translation request failure.
+ */
+add_task(
+ async function test_translations_telemetry_manual_translation_failure() {
+ PromiseTestUtils.expectUncaughtRejection(
+ /Intentionally rejecting downloads./
+ );
+
+ const { cleanup, rejectDownloads, runInPage } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ });
+
+ const { button } = await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: false, icon: true },
+ "The button is available."
+ );
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await TestTranslationsTelemetry.assertCounter(
+ "RequestCount",
+ Glean.translations.requestsCount,
+ 0
+ );
+ await TestTranslationsTelemetry.assertRate(
+ "ErrorRate",
+ Glean.translations.errorRate,
+ {
+ expectedNumerator: 0,
+ expectedDenominator: 0,
+ }
+ );
+ await TestTranslationsTelemetry.assertEvent(
+ "TranslationRequest",
+ Glean.translations.translationRequest,
+ {
+ expectedLength: 0,
+ }
+ );
+
+ await waitForTranslationsPopupEvent("popupshown", () => {
+ click(button, "Opening the popup");
+ });
+
+ await waitForTranslationsPopupEvent("popuphidden", () => {
+ click(
+ getByL10nId("translations-panel-translate-button"),
+ "Start translating by clicking the translate button."
+ );
+ });
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: true, locale: false, icon: true },
+ "The icon presents the loading indicator."
+ );
+
+ await rejectDownloads(1);
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await TestTranslationsTelemetry.assertCounter(
+ "RequestCount",
+ Glean.translations.requestsCount,
+ 1
+ );
+ await TestTranslationsTelemetry.assertRate(
+ "ErrorRate",
+ Glean.translations.errorRate,
+ {
+ expectedNumerator: 1,
+ expectedDenominator: 1,
+ }
+ );
+ await TestTranslationsTelemetry.assertEvent(
+ "Error",
+ Glean.translations.error,
+ {
+ expectedLength: 1,
+ finalValuePredicates: [
+ value =>
+ value.extra.reason === "Error: Intentionally rejecting downloads.",
+ ],
+ }
+ );
+ await TestTranslationsTelemetry.assertEvent(
+ "TranslationRequest",
+ Glean.translations.translationRequest,
+ {
+ expectedLength: 1,
+ finalValuePredicates: [
+ value => value.extra.from_language === "es",
+ value => value.extra.to_language === "en",
+ value => value.extra.auto_translate === "false",
+ ],
+ }
+ );
+
+ await cleanup();
+ }
+);
+
+/**
+ * Tests the telemetry event for an automatic translation request failure.
+ */
+add_task(async function test_translations_telemetry_auto_translation_failure() {
+ PromiseTestUtils.expectUncaughtRejection(
+ /Intentionally rejecting downloads./
+ );
+
+ const { cleanup, rejectDownloads, runInPage } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ prefs: [["browser.translations.alwaysTranslateLanguages", "es"]],
+ });
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: true, locale: false, icon: true },
+ "The icon presents the loading indicator."
+ );
+
+ await rejectDownloads(1);
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await TestTranslationsTelemetry.assertCounter(
+ "RequestCount",
+ Glean.translations.requestsCount,
+ 1
+ );
+ await TestTranslationsTelemetry.assertRate(
+ "ErrorRate",
+ Glean.translations.errorRate,
+ {
+ expectedNumerator: 1,
+ expectedDenominator: 1,
+ }
+ );
+ await TestTranslationsTelemetry.assertEvent(
+ "Error",
+ Glean.translations.error,
+ {
+ expectedLength: 1,
+ finalValuePredicates: [
+ value =>
+ value.extra.reason === "Error: Intentionally rejecting downloads.",
+ ],
+ }
+ );
+ await TestTranslationsTelemetry.assertEvent(
+ "TranslationRequest",
+ Glean.translations.translationRequest,
+ {
+ expectedLength: 1,
+ finalValuePredicates: [
+ value => value.extra.from_language === "es",
+ value => value.extra.to_language === "en",
+ value => value.extra.auto_translate === "true",
+ ],
+ }
+ );
+
+ await cleanup();
+});
diff --git a/browser/components/translations/tests/browser/browser_translations_telemetry_translation_request.js b/browser/components/translations/tests/browser/browser_translations_telemetry_translation_request.js
new file mode 100644
index 0000000000..ce22615630
--- /dev/null
+++ b/browser/components/translations/tests/browser/browser_translations_telemetry_translation_request.js
@@ -0,0 +1,173 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests the telemetry event for a manual translation request.
+ */
+add_task(async function test_translations_telemetry_manual_translation() {
+ const { cleanup, resolveDownloads, runInPage } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ });
+
+ const { button } = await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: false, icon: true },
+ "The button is available."
+ );
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The page's H1 is in Spanish.",
+ getH1,
+ "Don Quijote de La Mancha"
+ );
+ });
+
+ await TestTranslationsTelemetry.assertCounter(
+ "RequestCount",
+ Glean.translations.requestsCount,
+ 0
+ );
+ await TestTranslationsTelemetry.assertRate(
+ "ErrorRate",
+ Glean.translations.errorRate,
+ {
+ expectedNumerator: 0,
+ expectedDenominator: 0,
+ }
+ );
+ await TestTranslationsTelemetry.assertEvent(
+ "TranslationRequest",
+ Glean.translations.translationRequest,
+ {
+ expectedLength: 0,
+ }
+ );
+
+ await waitForTranslationsPopupEvent("popupshown", () => {
+ click(button, "Opening the popup");
+ });
+
+ await waitForTranslationsPopupEvent("popuphidden", () => {
+ click(
+ getByL10nId("translations-panel-translate-button"),
+ "Start translating by clicking the translate button."
+ );
+ });
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: true, locale: false, icon: true },
+ "The icon presents the loading indicator."
+ );
+
+ await resolveDownloads(1);
+
+ const { locale } = await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: true, icon: true },
+ "The icon presents the locale."
+ );
+
+ is(locale.innerText, "en", "The English language tag is shown.");
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The pages H1 is translated.",
+ getH1,
+ "DON QUIJOTE DE LA MANCHA [es to en, html]"
+ );
+ });
+
+ await TestTranslationsTelemetry.assertCounter(
+ "RequestCount",
+ Glean.translations.requestsCount,
+ 1
+ );
+ await TestTranslationsTelemetry.assertRate(
+ "ErrorRate",
+ Glean.translations.errorRate,
+ {
+ expectedNumerator: 0,
+ expectedDenominator: 1,
+ }
+ );
+ await TestTranslationsTelemetry.assertEvent(
+ "TranslationRequest",
+ Glean.translations.translationRequest,
+ {
+ expectedLength: 1,
+ finalValuePredicates: [
+ value => value.extra.from_language === "es",
+ value => value.extra.to_language === "en",
+ value => value.extra.auto_translate === "false",
+ ],
+ }
+ );
+
+ await cleanup();
+});
+
+/**
+ * Tests the telemetry event for an automatic translation request.
+ */
+add_task(async function test_translations_telemetry_auto_translation() {
+ const { cleanup, resolveDownloads, runInPage } = await loadTestPage({
+ page: SPANISH_PAGE_URL,
+ languagePairs: LANGUAGE_PAIRS,
+ prefs: [["browser.translations.alwaysTranslateLanguages", "es"]],
+ });
+
+ await assertTranslationsButton(
+ { button: true, circleArrows: true, locale: false, icon: true },
+ "The icon presents the loading indicator."
+ );
+
+ await resolveDownloads(1);
+
+ const { locale } = await assertTranslationsButton(
+ { button: true, circleArrows: false, locale: true, icon: true },
+ "The icon presents the locale."
+ );
+
+ is(locale.innerText, "en", "The English language tag is shown.");
+
+ await runInPage(async TranslationsTest => {
+ const { getH1 } = TranslationsTest.getSelectors();
+ await TranslationsTest.assertTranslationResult(
+ "The pages H1 is translated.",
+ getH1,
+ "DON QUIJOTE DE LA MANCHA [es to en, html]"
+ );
+ });
+
+ await TestTranslationsTelemetry.assertCounter(
+ "RequestCount",
+ Glean.translations.requestsCount,
+ 1
+ );
+ await TestTranslationsTelemetry.assertRate(
+ "ErrorRate",
+ Glean.translations.errorRate,
+ {
+ expectedNumerator: 0,
+ expectedDenominator: 1,
+ }
+ );
+ await TestTranslationsTelemetry.assertEvent(
+ "TranslationRequest",
+ Glean.translations.translationRequest,
+ {
+ expectedLength: 1,
+ finalValuePredicates: [
+ value => value.extra.from_language === "es",
+ value => value.extra.to_language === "en",
+ value => value.extra.auto_translate === "true",
+ ],
+ }
+ );
+
+ await cleanup();
+});
diff --git a/browser/components/translations/tests/browser/head.js b/browser/components/translations/tests/browser/head.js
new file mode 100644
index 0000000000..56839971b7
--- /dev/null
+++ b/browser/components/translations/tests/browser/head.js
@@ -0,0 +1,348 @@
+/* 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
+);
+
+/**
+ * Assert some property about the translations button.
+ *
+ * @param {Record<string, boolean>} visibleAssertions
+ * @param {string} message The message for the assertion.
+ * @returns {HTMLElement}
+ */
+async function assertTranslationsButton(visibleAssertions, message) {
+ const elements = {
+ button: document.getElementById("translations-button"),
+ icon: document.getElementById("translations-button-icon"),
+ circleArrows: document.getElementById("translations-button-circle-arrows"),
+ locale: 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 TestUtils.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;
+}
+
+/**
+ * A convenience function to open the settings menu of the
+ * translations panel. Fails the test if the menu cannot be opened.
+ */
+async function openSettingsMenu() {
+ const { button } = await assertTranslationsButton(
+ { button: true },
+ "The button is available."
+ );
+
+ await waitForTranslationsPopupEvent("popupshown", () => {
+ click(button, "Opening the popup");
+ });
+
+ const gearIcon = getByL10nId("translations-panel-settings-button");
+ click(gearIcon, "Open the settings menu");
+}
+
+/**
+ * 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.
+ */
+async function toggleAlwaysTranslateLanguage() {
+ const alwaysTranslateLanguage = getByL10nId(
+ "translations-panel-settings-always-translate-language"
+ );
+ info("Toggle the always-translate-language menuitem");
+ await alwaysTranslateLanguage.doCommand();
+}
+
+/**
+ * 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.
+ */
+async function toggleNeverTranslateLanguage() {
+ const neverTranslateLanguage = getByL10nId(
+ "translations-panel-settings-never-translate-language"
+ );
+ info("Toggle the never-translate-language menuitem");
+ await neverTranslateLanguage.doCommand();
+}
+
+/**
+ * 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.
+ */
+async function toggleNeverTranslateSite() {
+ const neverTranslateSite = getByL10nId(
+ "translations-panel-settings-never-translate-site"
+ );
+ info("Toggle the never-translate-site menuitem");
+ await neverTranslateSite.doCommand();
+}
+
+/**
+ * Asserts that the always-translate-language checkbox matches the expected checked state.
+ *
+ * @param {string} langTag - A BCP-47 language tag
+ * @param {boolean} expectChecked - Whether the checkbox should be checked
+ */
+async function assertIsAlwaysTranslateLanguage(langTag, expectChecked) {
+ await assertCheckboxState(
+ "translations-panel-settings-always-translate-language",
+ expectChecked
+ );
+}
+
+/**
+ * Asserts that the never-translate-language checkbox matches the expected checked state.
+ *
+ * @param {string} langTag - A BCP-47 language tag
+ * @param {boolean} expectChecked - Whether the checkbox should be checked
+ */
+async function assertIsNeverTranslateLanguage(langTag, expectChecked) {
+ await assertCheckboxState(
+ "translations-panel-settings-never-translate-language",
+ expectChecked
+ );
+}
+
+/**
+ * Asserts that the never-translate-site checkbox matches the expected checked state.
+ *
+ * @param {string} url - The url of a website
+ * @param {boolean} expectChecked - Whether the checkbox should be checked
+ */
+async function assertIsNeverTranslateSite(url, expectChecked) {
+ await assertCheckboxState(
+ "translations-panel-settings-never-translate-site",
+ expectChecked
+ );
+}
+
+/**
+ * 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 {boolean} expectChecked - Whether the checkbox should be checked.
+ */
+async function assertCheckboxState(dataL10nId, expectChecked) {
+ const menuItems = getAllByL10nId(dataL10nId);
+ for (const menuItem of menuItems) {
+ await TestUtils.waitForCondition(
+ () =>
+ menuItem.getAttribute("checked") === (expectChecked ? "true" : "false"),
+ "Waiting for checkbox state"
+ );
+ is(
+ menuItem.getAttribute("checked"),
+ expectChecked ? "true" : "false",
+ `Should match expected checkbox state for ${dataL10nId}`
+ );
+ }
+}
+
+/**
+ * Navigate to a URL and indicate a message as to why.
+ */
+async function navigate(url, message) {
+ 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.loadURIString(gBrowser.selectedBrowser, BLANK_PAGE);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, url);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+}
+
+/**
+ * Add a tab to the page
+ *
+ * @param {string} url
+ */
+async function addTab(url) {
+ info(`Adding tab for ` + url);
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ url,
+ true // Wait for laod
+ );
+ return {
+ tab,
+ removeTab() {
+ BrowserTestUtils.removeTab(tab);
+ },
+ };
+}
+
+async function switchTab(tab) {
+ info("Switching tabs");
+ await BrowserTestUtils.switchTab(gBrowser, tab);
+}
+
+function click(button, message) {
+ info(message);
+ EventUtils.synthesizeMouseAtCenter(button, {});
+}
+
+/**
+ * @param {Element} element
+ * @returns {boolean}
+ */
+function isVisible(element) {
+ const win = element.ownerDocument.ownerGlobal;
+ const { visibility, display } = win.getComputedStyle(element);
+ return visibility === "visible" && display !== "none";
+}
+
+/**
+ * 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 (isVisible(element)) {
+ return element;
+ }
+ }
+ throw new Error("The element is not visible in the DOM: " + l10nId);
+}
+
+/**
+ * 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;
+}
+
+/**
+ * @param {string} id
+ * @param {Document} [doc]
+ * @returns {Element}
+ */
+function getById(id, doc = document) {
+ const element = doc.getElementById(id);
+ if (!element) {
+ throw new Error("Could not find the element by id: #" + id);
+ }
+ if (isVisible(element)) {
+ return element;
+ }
+ throw new Error("The element is not visible in the DOM: #" + id);
+}
+
+/**
+ * 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 (isVisible(element)) {
+ return element;
+ }
+ }
+ return null;
+}
+
+/**
+ * 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
+ * @returns {Promise<void>}
+ */
+async function waitForTranslationsPopupEvent(eventName, callback) {
+ const panel = document.getElementById("translations-panel");
+ if (!panel) {
+ throw new Error("Unable to find the translations panel element.");
+ }
+ const promise = BrowserTestUtils.waitForEvent(panel, eventName);
+ callback();
+ info("Waiting for the translations panel popup to be shown");
+ await promise;
+ // Wait a single tick on the event loop.
+ await new Promise(resolve => setTimeout(resolve, 0));
+}
+
+/**
+ * When switching between between views in the popup panel, wait for the view to
+ * be fully shown.
+ *
+ * @param {Function} callback
+ */
+async function waitForViewShown(callback) {
+ const panel = document.getElementById("translations-panel");
+ if (!panel) {
+ throw new Error("Unable to find the translations panel element.");
+ }
+ const promise = BrowserTestUtils.waitForEvent(panel, "ViewShown");
+ callback();
+ info("Waiting for the translations panel view to be shown");
+ await promise;
+ await new Promise(resolve => setTimeout(resolve, 0));
+}
+
+const ENGLISH_PAGE_URL = TRANSLATIONS_TESTER_EN;
+const SPANISH_PAGE_URL = TRANSLATIONS_TESTER_ES;
+const SPANISH_PAGE_URL_2 = TRANSLATIONS_TESTER_ES_2;
+const SPANISH_PAGE_URL_DOT_ORG = TRANSLATIONS_TESTER_ES_DOT_ORG;
+const LANGUAGE_PAIRS = [
+ { fromLang: "es", toLang: "en", isBeta: false },
+ { fromLang: "en", toLang: "es", isBeta: false },
+ { fromLang: "fr", toLang: "en", isBeta: false },
+ { fromLang: "en", toLang: "fr", isBeta: false },
+ { fromLang: "en", toLang: "uk", isBeta: true },
+ { fromLang: "uk", toLang: "en", isBeta: true },
+];