diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
commit | 43a97878ce14b72f0981164f87f2e35e14151312 (patch) | |
tree | 620249daf56c0258faa40cbdcf9cfba06de2a846 /browser/components/translation/test | |
parent | Initial commit. (diff) | |
download | firefox-upstream.tar.xz firefox-upstream.zip |
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/translation/test')
11 files changed, 1895 insertions, 0 deletions
diff --git a/browser/components/translation/test/bing.sjs b/browser/components/translation/test/bing.sjs new file mode 100644 index 0000000000..ec385e7fb6 --- /dev/null +++ b/browser/components/translation/test/bing.sjs @@ -0,0 +1,256 @@ +/* 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"]); + +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) { + let converter = Cc[ + "@mozilla.org/intl/scriptableunicodeconverter" + ].createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + // `result` is an out parameter, `result.value` will contain the array length. + let result = {}; + // `data` is an array of bytes. + let data = converter.convertToByteArray(str, result); + 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..009d0cee9a --- /dev/null +++ b/browser/components/translation/test/browser.ini @@ -0,0 +1,14 @@ +[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_yandex.js] +[browser_translation_telemetry.js] +[browser_translation_infobar.js] +[browser_translation_exceptions.js] +https_first_disabled = true 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..8462833888 --- /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..8a5fa63090 --- /dev/null +++ b/browser/components/translation/test/browser_translation_exceptions.js @@ -0,0 +1,386 @@ +/* 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 { Translation } = ChromeUtils.import( + "resource:///modules/translation/TranslationParent.jsm" +); +const { PermissionTestUtils } = ChromeUtils.import( + "resource://testing-common/PermissionTestUtils.jsm" +); + +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.loadURI(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 getInfoBar() { + return new Promise(resolve => { + let infobar = gBrowser + .getNotificationBox() + .getNotificationWithValue("translation"); + + if (!infobar) { + resolve(); + } else { + // Wait for all animations to finish + Promise.all( + infobar.getAnimations().map(animation => animation.finished) + ).then(() => resolve(infobar)); + } + }); +} + +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: "never for language", + run: async function checkNeverForLanguage() { + // Show the infobar for example.com and fr. + let actor = gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getActor( + "Translation" + ); + actor.documentStateReceived({ + state: Translation.STATE_OFFER, + originalShown: true, + detectedLanguage: "fr", + }); + let notif = await getInfoBar(); + ok(notif, "the infobar is visible"); + + let principal = gBrowser.selectedBrowser.contentPrincipal; + ok( + actor.shouldShowInfoBar(principal, "fr"), + "check shouldShowInfoBar initially returns true" + ); + + // Open the "options" drop down. + await openPopup(notif._getAnonElt("options")); + ok( + notif._getAnonElt("options").getAttribute("open"), + "the options menu is open" + ); + + // Check that the item is not disabled. + ok( + !notif._getAnonElt("neverForLanguage").disabled, + "The 'Never translate <language>' item isn't disabled" + ); + + // Click the 'Never for French' item. + notif._getAnonElt("neverForLanguage").click(); + notif = await getInfoBar(); + ok(!notif, "infobar hidden"); + + // Check this has been saved to the exceptions list. + let langs = getLanguageExceptions(); + is(langs.length, 1, "one language in the exception list"); + is(langs[0], "fr", "correct language in the exception list"); + ok( + !actor.shouldShowInfoBar(principal, "fr"), + "the infobar wouldn't be shown anymore" + ); + + // Reopen the infobar. + PopupNotifications.getNotification("translate").anchorElement.click(); + notif = await getInfoBar(); + // Open the "options" drop down. + await openPopup(notif._getAnonElt("options")); + ok( + notif._getAnonElt("neverForLanguage").disabled, + "The 'Never translate French' item is disabled" + ); + + // Cleanup. + Services.prefs.setCharPref(kLanguagesPref, ""); + notif.close(); + }, + }, + + { + desc: "never for site", + run: async function checkNeverForSite() { + // Show the infobar for example.com and fr. + let actor = gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getActor( + "Translation" + ); + actor.documentStateReceived({ + state: Translation.STATE_OFFER, + originalShown: true, + detectedLanguage: "fr", + }); + let notif = await getInfoBar(); + ok(notif, "the infobar is visible"); + let principal = gBrowser.selectedBrowser.contentPrincipal; + ok( + actor.shouldShowInfoBar(principal, "fr"), + "check shouldShowInfoBar initially returns true" + ); + + // Open the "options" drop down. + await openPopup(notif._getAnonElt("options")); + ok( + notif._getAnonElt("options").getAttribute("open"), + "the options menu is open" + ); + + // Check that the item is not disabled. + ok( + !notif._getAnonElt("neverForSite").disabled, + "The 'Never translate site' item isn't disabled" + ); + + // Click the 'Never for French' item. + notif._getAnonElt("neverForSite").click(); + notif = await getInfoBar(); + ok(!notif, "infobar hidden"); + + // Check this has been saved to the exceptions list. + let sites = getDomainExceptions(); + is(sites.length, 1, "one site in the exception list"); + is( + sites[0].origin, + "http://example.com", + "correct site in the exception list" + ); + ok( + !actor.shouldShowInfoBar(principal, "fr"), + "the infobar wouldn't be shown anymore" + ); + + // Reopen the infobar. + PopupNotifications.getNotification("translate").anchorElement.click(); + notif = await getInfoBar(); + // Open the "options" drop down. + await openPopup(notif._getAnonElt("options")); + ok( + notif._getAnonElt("neverForSite").disabled, + "The 'Never translate French' item is disabled" + ); + + // Cleanup. + PermissionTestUtils.remove("http://example.com", "translate"); + notif.close(); + }, + }, + + { + 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/translation.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/translation.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_infobar.js b/browser/components/translation/test/browser_translation_infobar.js new file mode 100644 index 0000000000..feef5c78c6 --- /dev/null +++ b/browser/components/translation/test/browser_translation_infobar.js @@ -0,0 +1,346 @@ +/* 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 { Translation, TranslationParent } = ChromeUtils.import( + "resource:///modules/translation/TranslationParent.jsm" +); + +const kDetectLanguagePref = "browser.translation.detectLanguage"; +const kShowUIPref = "browser.translation.ui.show"; + +const text = + "Il y a aujourd'hui trois cent quarante-huit ans six mois et dix-neuf jours que les Parisiens s'éveillèrent au bruit de toutes les cloches sonnant à grande volée dans la triple enceinte de la Cité, de l'Université et de la Ville."; +const EXAMPLE_URL = + "http://example.com/document-builder.sjs?html=<html><body>" + + text + + "</body></html>"; + +// Create a subclass that overrides the translation functions. This can be +// instantiated separately from the normal actor creation process. This will +// allow testing translations even when the browser.translation.detectLanguage +// preference is disabled. +class TranslationStub extends TranslationParent { + constructor(browser) { + super(); + this._browser = browser; + } + + get browser() { + return this._browser; + } + + sendAsyncMessage(name, data) {} + + translate(aFrom, aTo) { + this.state = Translation.STATE_TRANSLATING; + this.translatedFrom = aFrom; + this.translatedTo = aTo; + } + + _reset() { + this.translatedFrom = ""; + this.translatedTo = ""; + } + + failTranslation() { + this.state = Translation.STATE_ERROR; + this._reset(); + } + + finishTranslation() { + this.showTranslatedContent(); + this.state = Translation.STATE_TRANSLATED; + this._reset(); + } +} + +function showTranslationUI(aDetectedLanguage) { + let browser = gBrowser.selectedBrowser; + let translation = new TranslationStub(browser); + translation.documentStateReceived({ + state: Translation.STATE_OFFER, + originalShown: true, + detectedLanguage: aDetectedLanguage, + }); + return translation.notificationBox.getNotificationWithValue("translation"); +} + +function hasTranslationInfoBar() { + return !!gBrowser + .getNotificationBox() + .getNotificationWithValue("translation"); +} + +function checkURLBarIcon(aExpectTranslated = false) { + is( + !PopupNotifications.getNotification("translate"), + aExpectTranslated, + "translate icon " + (aExpectTranslated ? "not " : "") + "shown" + ); + is( + !!PopupNotifications.getNotification("translated"), + aExpectTranslated, + "translated icon " + (aExpectTranslated ? "" : "not ") + "shown" + ); +} + +add_task(async function test_infobar() { + await SpecialPowers.pushPrefEnv({ + set: [[kShowUIPref, true]], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "data:text/plain,test page" + ); + + TranslationStub.browser = gBrowser.selectedBrowser; + + info("Show an info bar saying the current page is in French"); + let notif = showTranslationUI("fr"); + is( + notif.state, + Translation.STATE_OFFER, + "the infobar is offering translation" + ); + is( + notif._getAnonElt("detectedLanguage").value, + "fr", + "The detected language is displayed" + ); + checkURLBarIcon(); + + info("Click the 'Translate' button"); + notif._getAnonElt("translate").click(); + is( + notif.state, + Translation.STATE_TRANSLATING, + "the infobar is in the translating state" + ); + ok( + !!notif.translation.translatedFrom, + "Translation.translate has been called" + ); + is(notif.translation.translatedFrom, "fr", "from language correct"); + is( + notif.translation.translatedTo, + Translation.defaultTargetLanguage, + "from language correct" + ); + checkURLBarIcon(); + + info("Make the translation fail and check we are in the error state."); + notif.translation.failTranslation(); + is(notif.state, Translation.STATE_ERROR, "infobar in the error state"); + checkURLBarIcon(); + + info("Click the try again button"); + notif._getAnonElt("tryAgain").click(); + is( + notif.state, + Translation.STATE_TRANSLATING, + "infobar in the translating state" + ); + ok( + !!notif.translation.translatedFrom, + "Translation.translate has been called" + ); + is(notif.translation.translatedFrom, "fr", "from language correct"); + is( + notif.translation.translatedTo, + Translation.defaultTargetLanguage, + "from language correct" + ); + checkURLBarIcon(); + + info( + "Make the translation succeed and check we are in the 'translated' state." + ); + notif.translation.finishTranslation(); + is( + notif.state, + Translation.STATE_TRANSLATED, + "infobar in the translated state" + ); + checkURLBarIcon(true); + + info("Test 'Show original' / 'Show Translation' buttons."); + // First check 'Show Original' is visible and 'Show Translation' is hidden. + ok( + !notif._getAnonElt("showOriginal").hidden, + "'Show Original' button visible" + ); + ok( + notif._getAnonElt("showTranslation").hidden, + "'Show Translation' button hidden" + ); + // Click the button. + notif._getAnonElt("showOriginal").click(); + // Check that the url bar icon shows the original content is displayed. + checkURLBarIcon(); + // And the 'Show Translation' button is now visible. + ok(notif._getAnonElt("showOriginal").hidden, "'Show Original' button hidden"); + ok( + !notif._getAnonElt("showTranslation").hidden, + "'Show Translation' button visible" + ); + // Click the 'Show Translation' button + notif._getAnonElt("showTranslation").click(); + // Check that the url bar icon shows the page is translated. + checkURLBarIcon(true); + // Check that the 'Show Original' button is visible again. + ok( + !notif._getAnonElt("showOriginal").hidden, + "'Show Original' button visible" + ); + ok( + notif._getAnonElt("showTranslation").hidden, + "'Show Translation' button hidden" + ); + + info("Check that changing the source language causes a re-translation"); + let from = notif._getAnonElt("fromLanguage"); + from.value = "es"; + from.doCommand(); + is( + notif.state, + Translation.STATE_TRANSLATING, + "infobar in the translating state" + ); + ok( + !!notif.translation.translatedFrom, + "Translation.translate has been called" + ); + is(notif.translation.translatedFrom, "es", "from language correct"); + is( + notif.translation.translatedTo, + Translation.defaultTargetLanguage, + "to language correct" + ); + // We want to show the 'translated' icon while re-translating, + // because we are still displaying the previous translation. + checkURLBarIcon(true); + notif.translation.finishTranslation(); + checkURLBarIcon(true); + + info("Check that changing the target language causes a re-translation"); + let to = notif._getAnonElt("toLanguage"); + to.value = "pl"; + to.doCommand(); + is( + notif.state, + Translation.STATE_TRANSLATING, + "infobar in the translating state" + ); + ok( + !!notif.translation.translatedFrom, + "Translation.translate has been called" + ); + is(notif.translation.translatedFrom, "es", "from language correct"); + is(notif.translation.translatedTo, "pl", "to language correct"); + checkURLBarIcon(true); + notif.translation.finishTranslation(); + checkURLBarIcon(true); + + // Cleanup. + notif.close(); + + info( + "Reopen the info bar to check that it's possible to override the detected language." + ); + notif = showTranslationUI("fr"); + is( + notif.state, + Translation.STATE_OFFER, + "the infobar is offering translation" + ); + is( + notif._getAnonElt("detectedLanguage").value, + "fr", + "The detected language is displayed" + ); + // Change the language and click 'Translate' + notif._getAnonElt("detectedLanguage").value = "ja"; + notif._getAnonElt("translate").click(); + is( + notif.state, + Translation.STATE_TRANSLATING, + "the infobar is in the translating state" + ); + ok( + !!notif.translation.translatedFrom, + "Translation.translate has been called" + ); + is(notif.translation.translatedFrom, "ja", "from language correct"); + notif.close(); + + info("Reopen to check the 'Not Now' button closes the notification."); + notif = showTranslationUI("fr"); + is(hasTranslationInfoBar(), true, "there's a 'translate' notification"); + notif._getAnonElt("notNow").click(); + is( + hasTranslationInfoBar(), + false, + "no 'translate' notification after clicking 'not now'" + ); + + info("Reopen to check the url bar icon closes the notification."); + notif = showTranslationUI("fr"); + is(hasTranslationInfoBar(), true, "there's a 'translate' notification"); + PopupNotifications.getNotification("translate").anchorElement.click(); + is( + hasTranslationInfoBar(), + false, + "no 'translate' notification after clicking the url bar icon" + ); + + info("Check that clicking the url bar icon reopens the info bar"); + checkURLBarIcon(); + // Clicking the anchor element causes a 'showing' event to be sent + // asynchronously to our callback that will then show the infobar. + PopupNotifications.getNotification("translate").anchorElement.click(); + + await BrowserTestUtils.waitForCondition( + hasTranslationInfoBar, + "timeout waiting for the info bar to reappear" + ); + + ok(hasTranslationInfoBar(), "there's a 'translate' notification"); + + await BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_infobar_using_page() { + await SpecialPowers.pushPrefEnv({ + set: [ + [kDetectLanguagePref, true], + [kShowUIPref, true], + ], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, EXAMPLE_URL); + + await BrowserTestUtils.waitForCondition( + hasTranslationInfoBar, + "timeout waiting for the info bar to reappear" + ); + + let notificationBox = gBrowser.getNotificationBox(tab.linkedBrowser); + let notif = notificationBox.getNotificationWithValue("translation"); + is( + notif.state, + Translation.STATE_OFFER, + "the infobar is offering translation" + ); + is( + notif._getAnonElt("detectedLanguage").value, + "fr", + "The detected language is displayed" + ); + checkURLBarIcon(); + + await BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/translation/test/browser_translation_telemetry.js b/browser/components/translation/test/browser_translation_telemetry.js new file mode 100644 index 0000000000..768daea7c1 --- /dev/null +++ b/browser/components/translation/test/browser_translation_telemetry.js @@ -0,0 +1,332 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Translation, TranslationTelemetry } = ChromeUtils.import( + "resource:///modules/translation/TranslationParent.jsm" +); +const Telemetry = Services.telemetry; + +var MetricsChecker = { + HISTOGRAMS: { + OPPORTUNITIES: Services.telemetry.getHistogramById( + "TRANSLATION_OPPORTUNITIES" + ), + OPPORTUNITIES_BY_LANG: Services.telemetry.getKeyedHistogramById( + "TRANSLATION_OPPORTUNITIES_BY_LANGUAGE" + ), + PAGES: Services.telemetry.getHistogramById("TRANSLATED_PAGES"), + PAGES_BY_LANG: Services.telemetry.getKeyedHistogramById( + "TRANSLATED_PAGES_BY_LANGUAGE" + ), + CHARACTERS: Services.telemetry.getHistogramById("TRANSLATED_CHARACTERS"), + DENIED: Services.telemetry.getHistogramById("DENIED_TRANSLATION_OFFERS"), + AUTO_REJECTED: Services.telemetry.getHistogramById( + "AUTO_REJECTED_TRANSLATION_OFFERS" + ), + SHOW_ORIGINAL: Services.telemetry.getHistogramById( + "REQUESTS_OF_ORIGINAL_CONTENT" + ), + TARGET_CHANGES: Services.telemetry.getHistogramById( + "CHANGES_OF_TARGET_LANGUAGE" + ), + DETECTION_CHANGES: Services.telemetry.getHistogramById( + "CHANGES_OF_DETECTED_LANGUAGE" + ), + SHOW_UI: Services.telemetry.getHistogramById( + "SHOULD_TRANSLATION_UI_APPEAR" + ), + DETECT_LANG: Services.telemetry.getHistogramById( + "SHOULD_AUTO_DETECT_LANGUAGE" + ), + }, + + reset() { + for (let i of Object.keys(this.HISTOGRAMS)) { + this.HISTOGRAMS[i].clear(); + } + this.updateMetrics(); + }, + + updateMetrics() { + this._metrics = { + opportunitiesCount: this.HISTOGRAMS.OPPORTUNITIES.snapshot().sum || 0, + pageCount: this.HISTOGRAMS.PAGES.snapshot().sum || 0, + charCount: this.HISTOGRAMS.CHARACTERS.snapshot().sum || 0, + deniedOffers: this.HISTOGRAMS.DENIED.snapshot().sum || 0, + autoRejectedOffers: this.HISTOGRAMS.AUTO_REJECTED.snapshot().sum || 0, + showOriginal: this.HISTOGRAMS.SHOW_ORIGINAL.snapshot().sum || 0, + detectedLanguageChangedBefore: + this.HISTOGRAMS.DETECTION_CHANGES.snapshot().values[1] || 0, + detectedLanguageChangeAfter: + this.HISTOGRAMS.DETECTION_CHANGES.snapshot().values[0] || 0, + targetLanguageChanged: this.HISTOGRAMS.TARGET_CHANGES.snapshot().sum || 0, + showUI: this.HISTOGRAMS.SHOW_UI.snapshot().sum || 0, + detectLang: this.HISTOGRAMS.DETECT_LANG.snapshot().sum || 0, + // Metrics for Keyed histograms are estimated below. + opportunitiesCountByLang: {}, + pageCountByLang: {}, + }; + + let opportunities = this.HISTOGRAMS.OPPORTUNITIES_BY_LANG.snapshot(); + let pages = this.HISTOGRAMS.PAGES_BY_LANG.snapshot(); + for (let source of Translation.supportedSourceLanguages) { + this._metrics.opportunitiesCountByLang[source] = opportunities[source] + ? opportunities[source].sum + : 0; + for (let target of Translation.supportedTargetLanguages) { + if (source === target) { + continue; + } + let key = source + " -> " + target; + this._metrics.pageCountByLang[key] = pages[key] ? pages[key].sum : 0; + } + } + }, + + /** + * A recurrent loop for making assertions about collected metrics. + */ + _assertionLoop(prevMetrics, metrics, additions) { + for (let metric of Object.keys(additions)) { + let addition = additions[metric]; + // Allows nesting metrics. Useful for keyed histograms. + if (typeof addition === "object") { + this._assertionLoop(prevMetrics[metric], metrics[metric], addition); + continue; + } + Assert.equal(prevMetrics[metric] + addition, metrics[metric]); + } + }, + + checkAdditions(additions) { + let prevMetrics = this._metrics; + this.updateMetrics(); + this._assertionLoop(prevMetrics, this._metrics, additions); + }, +}; + +function getInfobarElement(browser, anonid) { + let actor = browser.browsingContext.currentWindowGlobal.getActor( + "Translation" + ); + let notif = actor.notificationBox.getNotificationWithValue("translation"); + return notif._getAnonElt(anonid); +} + +var offerTranslationFor = async function(text, from) { + // Create some content to translate. + const dataUrl = "data:text/html;charset=utf-8," + text; + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, dataUrl); + + let browser = gBrowser.getBrowserForTab(tab); + let actor = browser.browsingContext.currentWindowGlobal.getActor( + "Translation" + ); + + // Send a translation offer. + actor.documentStateReceived({ + state: Translation.STATE_OFFER, + originalShown: true, + detectedLanguage: from, + }); + + return tab; +}; + +var acceptTranslationOffer = async function(tab) { + let browser = tab.linkedBrowser; + let translationPromise = waitForTranslationDone(); + + getInfobarElement(browser, "translate").doCommand(); + await translationPromise; +}; + +var translate = async function(text, from, closeTab = true) { + let tab = await offerTranslationFor(text, from); + await acceptTranslationOffer(tab); + if (closeTab) { + gBrowser.removeTab(tab); + return null; + } + return tab; +}; + +function waitForTranslationDone() { + return new Promise(resolve => { + Translation.setListenerForTests(() => { + Translation.setListenerForTests(null); + resolve(); + }); + }); +} + +function simulateUserSelectInMenulist(menulist, value) { + menulist.value = value; + menulist.doCommand(); +} + +add_setup(async function() { + const setupPrefs = prefs => { + let prefsBackup = {}; + for (let p of prefs) { + prefsBackup[p] = Services.prefs.setBoolPref; + Services.prefs.setBoolPref(p, true); + } + return prefsBackup; + }; + + const restorePrefs = (prefs, backup) => { + for (let p of prefs) { + Services.prefs.setBoolPref(p, backup[p]); + } + }; + + const prefs = [ + "browser.translation.detectLanguage", + "browser.translation.ui.show", + ]; + + let prefsBackup = setupPrefs(prefs); + + let oldCanRecord = Telemetry.canRecordExtended; + Telemetry.canRecordExtended = true; + + registerCleanupFunction(() => { + restorePrefs(prefs, prefsBackup); + Telemetry.canRecordExtended = oldCanRecord; + }); + + // Reset histogram metrics. + MetricsChecker.reset(); +}); + +add_task(async function test_telemetry() { + // Translate a page. + await translate("<h1>Привет, мир!</h1>", "ru"); + + // Translate another page. + await translate("<h1>Hallo Welt!</h1><h1>Bratwurst!</h1>", "de"); + await MetricsChecker.checkAdditions({ + opportunitiesCount: 2, + opportunitiesCountByLang: { ru: 1, de: 1 }, + pageCount: 1, + pageCountByLang: { "de -> en": 1 }, + charCount: 21, + deniedOffers: 0, + }); +}); + +add_task(async function test_deny_translation_metric() { + async function offerAndDeny(elementAnonid) { + let tab = await offerTranslationFor("<h1>Hallo Welt!</h1>", "de", "en"); + getInfobarElement(tab.linkedBrowser, elementAnonid).doCommand(); + await MetricsChecker.checkAdditions({ deniedOffers: 1 }); + gBrowser.removeTab(tab); + } + + await offerAndDeny("notNow"); + await offerAndDeny("neverForSite"); + await offerAndDeny("neverForLanguage"); + await offerAndDeny("closeButton"); + + // Test that the close button doesn't record a denied translation if + // the infobar is not in its "offer" state. + let tab = await translate("<h1>Hallo Welt!</h1>", "de", false); + await MetricsChecker.checkAdditions({ deniedOffers: 0 }); + gBrowser.removeTab(tab); +}); + +add_task(async function test_show_original() { + let tab = await translate( + "<h1>Hallo Welt!</h1><h1>Bratwurst!</h1>", + "de", + false + ); + await MetricsChecker.checkAdditions({ pageCount: 1, showOriginal: 0 }); + getInfobarElement(tab.linkedBrowser, "showOriginal").doCommand(); + await MetricsChecker.checkAdditions({ pageCount: 0, showOriginal: 1 }); + gBrowser.removeTab(tab); +}); + +add_task(async function test_language_change() { + // This is run 4 times, the total additions are checked afterwards. + // eslint-disable-next-line no-unused-vars + for (let i of Array(4)) { + let tab = await offerTranslationFor("<h1>Hallo Welt!</h1>", "fr"); + let browser = tab.linkedBrowser; + // In the offer state, translation is executed by the Translate button, + // so we expect just a single recoding. + let detectedLangMenulist = getInfobarElement(browser, "detectedLanguage"); + simulateUserSelectInMenulist(detectedLangMenulist, "de"); + simulateUserSelectInMenulist(detectedLangMenulist, "it"); + simulateUserSelectInMenulist(detectedLangMenulist, "de"); + await acceptTranslationOffer(tab); + + // In the translated state, a change in the form or to menulists + // triggers re-translation right away. + let fromLangMenulist = getInfobarElement(browser, "fromLanguage"); + simulateUserSelectInMenulist(fromLangMenulist, "it"); + simulateUserSelectInMenulist(fromLangMenulist, "de"); + + // Selecting the same item shouldn't count. + simulateUserSelectInMenulist(fromLangMenulist, "de"); + + let toLangMenulist = getInfobarElement(browser, "toLanguage"); + simulateUserSelectInMenulist(toLangMenulist, "fr"); + simulateUserSelectInMenulist(toLangMenulist, "en"); + simulateUserSelectInMenulist(toLangMenulist, "it"); + + // Selecting the same item shouldn't count. + simulateUserSelectInMenulist(toLangMenulist, "it"); + + // Setting the target language to the source language is a no-op, + // so it shouldn't count. + simulateUserSelectInMenulist(toLangMenulist, "de"); + + gBrowser.removeTab(tab); + } + await MetricsChecker.checkAdditions({ + detectedLanguageChangedBefore: 4, + detectedLanguageChangeAfter: 8, + targetLanguageChanged: 12, + }); +}); + +add_task(async function test_never_offer_translation() { + Services.prefs.setCharPref("browser.translation.neverForLanguages", "fr"); + + let tab = await offerTranslationFor("<h1>Hallo Welt!</h1>", "fr"); + + await MetricsChecker.checkAdditions({ + autoRejectedOffers: 1, + }); + + gBrowser.removeTab(tab); + Services.prefs.clearUserPref("browser.translation.neverForLanguages"); +}); + +add_task(async function test_translation_preferences() { + let preferenceChecks = { + "browser.translation.ui.show": [ + { value: false, expected: { showUI: 0 } }, + { value: true, expected: { showUI: 1 } }, + ], + "browser.translation.detectLanguage": [ + { value: false, expected: { detectLang: 0 } }, + { value: true, expected: { detectLang: 1 } }, + ], + }; + + for (let preference of Object.keys(preferenceChecks)) { + for (let check of preferenceChecks[preference]) { + MetricsChecker.reset(); + Services.prefs.setBoolPref(preference, check.value); + // Preference metrics are collected once when the provider is initialized. + TranslationTelemetry.init(); + await MetricsChecker.checkAdditions(check.expected); + } + Services.prefs.clearUserPref(preference); + } +}); 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..10014fa899 --- /dev/null +++ b/browser/components/translation/test/browser_translation_yandex.js @@ -0,0 +1,155 @@ +/* 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"; + +const { Translation } = ChromeUtils.import( + "resource:///modules/translation/TranslationParent.jsm" +); + +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); +}); + +/** + * Ensure that Yandex.Translate is propertly attributed. + */ +add_task(async function test_yandex_attribution() { + // Loading the fixture page. + let url = constructFixtureURL("bug1022725-fr.html"); + let tab = await promiseTestPageLoad(url); + + info("Show an info bar saying the current page is in French"); + let notif = showTranslationUI(tab, "fr"); + let attribution = notif._getAnonElt("translationEngine").selectedIndex; + Assert.equal(attribution, 1, "Yandex attribution should be shown."); + + 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/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..de2f5650ef --- /dev/null +++ b/browser/components/translation/test/fixtures/result-yandex-d448894848.json @@ -0,0 +1 @@ +{"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..1f87809830 --- /dev/null +++ b/browser/components/translation/test/yandex.sjs @@ -0,0 +1,208 @@ +/* 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" +); + +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) { + let converter = Cc[ + "@mozilla.org/intl/scriptableunicodeconverter" + ].createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + // `result` is an out parameter, `result.value` will contain the array length. + let result = {}; + // `data` is an array of bytes. + let data = converter.convertToByteArray(str, result); + 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(); + }, +}; |