diff options
Diffstat (limited to 'toolkit/components/translations/tests/browser/shared-head.js')
-rw-r--r-- | toolkit/components/translations/tests/browser/shared-head.js | 1442 |
1 files changed, 1442 insertions, 0 deletions
diff --git a/toolkit/components/translations/tests/browser/shared-head.js b/toolkit/components/translations/tests/browser/shared-head.js new file mode 100644 index 0000000000..bad8e48a1b --- /dev/null +++ b/toolkit/components/translations/tests/browser/shared-head.js @@ -0,0 +1,1442 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Avoid about:blank's non-standard behavior. +const BLANK_PAGE = + "data:text/html;charset=utf-8,<!DOCTYPE html><title>Blank</title>Blank page"; + +const URL_COM_PREFIX = "https://example.com/browser/"; +const URL_ORG_PREFIX = "https://example.org/browser/"; +const CHROME_URL_PREFIX = "chrome://mochitests/content/browser/"; +const DIR_PATH = "toolkit/components/translations/tests/browser/"; +const ENGLISH_PAGE_URL = + URL_COM_PREFIX + DIR_PATH + "translations-tester-en.html"; +const SPANISH_PAGE_URL = + URL_COM_PREFIX + DIR_PATH + "translations-tester-es.html"; +const FRENCH_PAGE_URL = + URL_COM_PREFIX + DIR_PATH + "translations-tester-fr.html"; +const SPANISH_PAGE_URL_2 = + URL_COM_PREFIX + DIR_PATH + "translations-tester-es-2.html"; +const SPANISH_PAGE_URL_DOT_ORG = + URL_ORG_PREFIX + DIR_PATH + "translations-tester-es.html"; +const NO_LANGUAGE_URL = + URL_COM_PREFIX + DIR_PATH + "translations-tester-no-tag.html"; +const EMPTY_PDF_URL = + URL_COM_PREFIX + DIR_PATH + "translations-tester-empty-pdf-file.pdf"; + +const PIVOT_LANGUAGE = "en"; +const LANGUAGE_PAIRS = [ + { fromLang: PIVOT_LANGUAGE, toLang: "es" }, + { fromLang: "es", toLang: PIVOT_LANGUAGE }, + { fromLang: PIVOT_LANGUAGE, toLang: "fr" }, + { fromLang: "fr", toLang: PIVOT_LANGUAGE }, + { fromLang: PIVOT_LANGUAGE, toLang: "uk" }, + { fromLang: "uk", toLang: PIVOT_LANGUAGE }, +]; + +const TRANSLATIONS_PERMISSION = "translations"; +const ALWAYS_TRANSLATE_LANGS_PREF = + "browser.translations.alwaysTranslateLanguages"; +const NEVER_TRANSLATE_LANGS_PREF = + "browser.translations.neverTranslateLanguages"; + +/** + * The mochitest runs in the parent process. This function opens up a new tab, + * opens up about:translations, and passes the test requirements into the content process. + * + * @template T + * + * @param {object} options + * + * @param {T} options.dataForContent + * The data must support structural cloning and will be passed into the + * content process. + * + * @param {(args: { dataForContent: T, selectors: Record<string, string> }) => Promise<void>} options.runInPage + * This function must not capture any values, as it will be cloned in the content process. + * Any required data should be passed in using the "dataForContent" parameter. The + * "selectors" property contains any useful selectors for the content. + * + * @param {boolean} [options.disabled] + * Disable the panel through a pref. + * + * @param {Array<{ fromLang: string, toLang: string }>} options.languagePairs + * The translation languages pairs to mock for the test. + * + * @param {Array<[string, string]>} options.prefs + * Prefs to push on for the test. + */ +async function openAboutTranslations({ + dataForContent, + disabled, + runInPage, + languagePairs = LANGUAGE_PAIRS, + prefs, +}) { + await SpecialPowers.pushPrefEnv({ + set: [ + // Enabled by default. + ["browser.translations.enable", !disabled], + ["browser.translations.logLevel", "All"], + ...(prefs ?? []), + ], + }); + + /** + * Collect any relevant selectors for the page here. + */ + const selectors = { + pageHeader: '[data-l10n-id="about-translations-header"]', + fromLanguageSelect: "select#language-from", + toLanguageSelect: "select#language-to", + translationTextarea: "textarea#translation-from", + translationResult: "#translation-to", + translationResultBlank: "#translation-to-blank", + translationInfo: "#translation-info", + noSupportMessage: "[data-l10n-id='about-translations-no-support']", + }; + + // Start the tab at a blank page. + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + BLANK_PAGE, + true // waitForLoad + ); + + const { removeMocks, remoteClients } = await createAndMockRemoteSettings({ + languagePairs, + // TODO(Bug 1814168) - Do not test download behavior as this is not robustly + // handled for about:translations yet. + autoDownloadFromRemoteSettings: true, + }); + + // Now load the about:translations page, since the actor could be mocked. + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + "about:translations" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + await remoteClients.translationsWasm.resolvePendingDownloads(1); + await remoteClients.translationModels.resolvePendingDownloads( + languagePairs.length * FILES_PER_LANGUAGE_PAIR + ); + + await ContentTask.spawn( + tab.linkedBrowser, + { dataForContent, selectors }, + runInPage + ); + + await loadBlankPage(); + BrowserTestUtils.removeTab(tab); + + await removeMocks(); + await TranslationsParent.destroyEngineProcess(); + + await SpecialPowers.popPrefEnv(); +} + +/** + * Naively prettify's html based on the opening and closing tags. This is not robust + * for general usage, but should be adequate for these tests. + * @param {string} html + * @returns {string} + */ +function naivelyPrettify(html) { + let result = ""; + let indent = 0; + + function addText(actualEndIndex) { + const text = html.slice(startIndex, actualEndIndex).trim(); + if (text) { + for (let i = 0; i < indent; i++) { + result += " "; + } + result += text + "\n"; + } + startIndex = actualEndIndex; + } + + let startIndex = 0; + let endIndex = 0; + for (; endIndex < html.length; endIndex++) { + if ( + html[endIndex] === " " || + html[endIndex] === "\t" || + html[endIndex] === "n" + ) { + // Skip whitespace. + // " <div>foobar</div>" + // ^^^ + startIndex = endIndex; + continue; + } + + // Find all of the text. + // "<div>foobar</div>" + // ^^^^^^ + while (endIndex < html.length && html[endIndex] !== "<") { + endIndex++; + } + + addText(endIndex); + + if (html[endIndex] === "<") { + if (html[endIndex + 1] === "/") { + // "<div>foobar</div>" + // ^ + while (endIndex < html.length && html[endIndex] !== ">") { + endIndex++; + } + indent--; + addText(endIndex + 1); + } else { + // "<div>foobar</div>" + // ^ + while (endIndex < html.length && html[endIndex] !== ">") { + endIndex++; + } + // "<div>foobar</div>" + // ^ + addText(endIndex + 1); + indent++; + } + } + } + + return result.trim(); +} + +/** + * Recursively transforms all child nodes to have uppercased text. + * + * @param {Node} node + */ +function upperCaseNode(node) { + if (typeof node.nodeValue === "string") { + node.nodeValue = node.nodeValue.toUpperCase(); + } + for (const childNode of node.childNodes) { + upperCaseNode(childNode); + } +} + +/** + * Creates a mocked message port for translations. + * + * @returns {MessagePort} This is mocked + */ +function createMockedTranslatorPort(transformNode = upperCaseNode) { + const parser = new DOMParser(); + const mockedPort = { + async postMessage(message) { + // Make this response async. + await TestUtils.waitForTick(); + + switch (message.type) { + case "TranslationsPort:GetEngineStatusRequest": + mockedPort.onmessage({ + data: { + type: "TranslationsPort:GetEngineStatusResponse", + status: "ready", + }, + }); + break; + case "TranslationsPort:TranslationRequest": { + const { messageId, sourceText } = message; + + const translatedDoc = parser.parseFromString(sourceText, "text/html"); + transformNode(translatedDoc.body); + mockedPort.onmessage({ + data: { + type: "TranslationsPort:TranslationResponse", + targetText: translatedDoc.body.innerHTML, + messageId, + }, + }); + } + } + }, + }; + return mockedPort; +} + +/** + * This mocked translator reports on the batching of calls by replacing the text + * with a letter. Each call of the function moves the letter forward alphabetically. + * + * So consecutive calls would transform things like: + * "First translation" -> "aaaa aaaaaaaaa" + * "Second translation" -> "bbbbb bbbbbbbbb" + * "Third translation" -> "cccc ccccccccc" + * + * This can visually show what the translation batching behavior looks like. + * + * @returns {MessagePort} A mocked port. + */ +function createBatchedMockedTranslatorPort() { + let letter = "a"; + + /** + * @param {Node} node + */ + function transformNode(node) { + if (typeof node.nodeValue === "string") { + node.nodeValue = node.nodeValue.replace(/\w/g, letter); + } + for (const childNode of node.childNodes) { + transformNode(childNode); + } + } + + return createMockedTranslatorPort(node => { + transformNode(node); + letter = String.fromCodePoint(letter.codePointAt(0) + 1); + }); +} + +/** + * This mocked translator reorders Nodes to be in alphabetical order, and then + * uppercases the text. This allows for testing the reordering behavior of the + * translation engine. + * + * @returns {MessagePort} A mocked port. + */ +function createdReorderingMockedTranslatorPort() { + /** + * @param {Node} node + */ + function transformNode(node) { + if (typeof node.nodeValue === "string") { + node.nodeValue = node.nodeValue.toUpperCase(); + } + const nodes = [...node.childNodes]; + nodes.sort((a, b) => + (a.textContent?.trim() ?? "").localeCompare(b.textContent?.trim() ?? "") + ); + for (const childNode of nodes) { + childNode.remove(); + } + for (const childNode of nodes) { + // Re-append in sorted order. + node.appendChild(childNode); + transformNode(childNode); + } + } + + return createMockedTranslatorPort(transformNode); +} + +/** + * @returns {import("../../actors/TranslationsParent.sys.mjs").TranslationsParent} + */ +function getTranslationsParent() { + return gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getActor( + "Translations" + ); +} + +/** + * Closes the context menu if it is open. + */ +function closeContextMenuIfOpen() { + return waitForCondition(async () => { + const contextMenu = document.getElementById("contentAreaContextMenu"); + if (!contextMenu) { + return true; + } + if (contextMenu.state === "closed") { + return true; + } + let popuphiddenPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + PanelMultiView.hidePopup(contextMenu); + await popuphiddenPromise; + return false; + }); +} + +/** + * Closes the translations panel settings menu if it is open. + */ +function closeSettingsMenuIfOpen() { + return waitForCondition(async () => { + const settings = document.getElementById( + "translations-panel-settings-menupopup" + ); + if (!settings) { + return true; + } + if (settings.state === "closed") { + return true; + } + let popuphiddenPromise = BrowserTestUtils.waitForEvent( + settings, + "popuphidden" + ); + PanelMultiView.hidePopup(settings); + await popuphiddenPromise; + return false; + }); +} + +/** + * Closes the translations panel if it is open. + */ +async function closeTranslationsPanelIfOpen() { + await closeSettingsMenuIfOpen(); + return waitForCondition(async () => { + const panel = document.getElementById("translations-panel"); + if (!panel) { + return true; + } + if (panel.state === "closed") { + return true; + } + let popuphiddenPromise = BrowserTestUtils.waitForEvent( + panel, + "popuphidden" + ); + PanelMultiView.hidePopup(panel); + await popuphiddenPromise; + return false; + }); +} + +/** + * This is for tests that don't need a browser page to run. + */ +async function setupActorTest({ + languagePairs, + prefs, + autoDownloadFromRemoteSettings = false, +}) { + await SpecialPowers.pushPrefEnv({ + set: [ + // Enabled by default. + ["browser.translations.enable", true], + ["browser.translations.logLevel", "All"], + ...(prefs ?? []), + ], + }); + + const { remoteClients, removeMocks } = await createAndMockRemoteSettings({ + languagePairs, + autoDownloadFromRemoteSettings, + }); + + // Create a new tab so each test gets a new actor, and doesn't re-use the old one. + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + ENGLISH_PAGE_URL, + true // waitForLoad + ); + + const actor = getTranslationsParent(); + return { + actor, + remoteClients, + async cleanup() { + await loadBlankPage(); + await TranslationsParent.destroyEngineProcess(); + await closeTranslationsPanelIfOpen(); + await closeContextMenuIfOpen(); + BrowserTestUtils.removeTab(tab); + await removeMocks(); + TestTranslationsTelemetry.reset(); + return SpecialPowers.popPrefEnv(); + }, + }; +} + +async function createAndMockRemoteSettings({ + languagePairs = LANGUAGE_PAIRS, + autoDownloadFromRemoteSettings = false, +}) { + const remoteClients = { + translationModels: await createTranslationModelsRemoteClient( + autoDownloadFromRemoteSettings, + languagePairs + ), + translationsWasm: await createTranslationsWasmRemoteClient( + autoDownloadFromRemoteSettings + ), + }; + + // The TranslationsParent will pull the language pair values from the JSON dump + // of Remote Settings. Clear these before mocking the translations engine. + TranslationsParent.clearCache(); + + TranslationsParent.mockTranslationsEngine( + remoteClients.translationModels.client, + remoteClients.translationsWasm.client + ); + + return { + async removeMocks() { + await remoteClients.translationModels.client.attachments.deleteAll(); + await remoteClients.translationModels.client.db.clear(); + await remoteClients.translationsWasm.client.db.clear(); + + TranslationsParent.unmockTranslationsEngine(); + TranslationsParent.clearCache(); + }, + remoteClients, + }; +} + +async function loadTestPage({ + languagePairs, + autoDownloadFromRemoteSettings = false, + page, + prefs, + autoOffer, + permissionsUrls, +}) { + info(`Loading test page starting at url: ${page}`); + // Ensure no engine is being carried over from a previous test. + await TranslationsParent.destroyEngineProcess(); + Services.fog.testResetFOG(); + await SpecialPowers.pushPrefEnv({ + set: [ + // Enabled by default. + ["browser.translations.enable", true], + ["browser.translations.logLevel", "All"], + ["browser.translations.panelShown", true], + ["browser.translations.automaticallyPopup", true], + ["browser.translations.alwaysTranslateLanguages", ""], + ["browser.translations.neverTranslateLanguages", ""], + ...(prefs ?? []), + ], + }); + await SpecialPowers.pushPermissions( + [ + ENGLISH_PAGE_URL, + FRENCH_PAGE_URL, + NO_LANGUAGE_URL, + SPANISH_PAGE_URL, + SPANISH_PAGE_URL_2, + SPANISH_PAGE_URL_DOT_ORG, + ...(permissionsUrls || []), + ].map(url => ({ + type: TRANSLATIONS_PERMISSION, + allow: true, + context: url, + })) + ); + + if (autoOffer) { + TranslationsParent.testAutomaticPopup = true; + } + + // Start the tab at a blank page. + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + BLANK_PAGE, + true // waitForLoad + ); + + const { remoteClients, removeMocks } = await createAndMockRemoteSettings({ + languagePairs, + autoDownloadFromRemoteSettings, + }); + + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, page); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + if (autoOffer && TranslationsParent.shouldAlwaysOfferTranslations()) { + info("Waiting for the popup to be automatically shown."); + await waitForCondition(() => { + const panel = document.getElementById("translations-panel"); + return panel && panel.state === "open"; + }); + } + + return { + tab, + remoteClients, + + /** + * @param {number} count - Count of the language pairs expected. + */ + async resolveDownloads(count) { + await remoteClients.translationsWasm.resolvePendingDownloads(1); + await remoteClients.translationModels.resolvePendingDownloads( + FILES_PER_LANGUAGE_PAIR * count + ); + }, + + /** + * @param {number} count - Count of the language pairs expected. + */ + async rejectDownloads(count) { + await remoteClients.translationsWasm.rejectPendingDownloads(1); + await remoteClients.translationModels.rejectPendingDownloads( + FILES_PER_LANGUAGE_PAIR * count + ); + }, + + /** + * @returns {Promise<void>} + */ + async cleanup() { + await loadBlankPage(); + await TranslationsParent.destroyEngineProcess(); + await closeTranslationsPanelIfOpen(); + await closeContextMenuIfOpen(); + await removeMocks(); + Services.fog.testResetFOG(); + TranslationsParent.testAutomaticPopup = false; + TranslationsParent.resetHostsOffered(); + BrowserTestUtils.removeTab(tab); + TestTranslationsTelemetry.reset(); + return Promise.all([ + SpecialPowers.popPrefEnv(), + SpecialPowers.popPermissions(), + ]); + }, + + /** + * Runs a callback in the content page. The function's contents are serialized as + * a string, and run in the page. The `translations-test.mjs` module is made + * available to the page. + * + * @param {(TranslationsTest: import("./translations-test.mjs")) => any} callback + * @returns {Promise<void>} + */ + runInPage(callback, data = {}) { + // ContentTask.spawn runs the `Function.prototype.toString` on this function in + // order to send it into the content process. The following function is doing its + // own string manipulation in order to load in the TranslationsTest module. + const fn = new Function(/* js */ ` + const TranslationsTest = ChromeUtils.importESModule( + "chrome://mochitests/content/browser/toolkit/components/translations/tests/browser/translations-test.mjs" + ); + + // Pass in the values that get injected by the task runner. + TranslationsTest.setup({Assert, ContentTaskUtils, content}); + + const data = ${JSON.stringify(data)}; + + return (${callback.toString()})(TranslationsTest, data); + `); + + return ContentTask.spawn( + tab.linkedBrowser, + {}, // Data to inject. + fn + ); + }, + }; +} + +/** + * Captures any reported errors in the TranslationsParent. + * + * @param {Function} callback + * @returns {Array<{ error: Error, args: any[] }>} + */ +async function captureTranslationsError(callback) { + const { reportError } = TranslationsParent; + + let errors = []; + TranslationsParent.reportError = (error, ...args) => { + errors.push({ error, args }); + }; + + await callback(); + + // Restore the original function. + TranslationsParent.reportError = reportError; + return errors; +} + +/** + * Load a test page and run + * @param {Object} options - The options for `loadTestPage` plus a `runInPage` function. + */ +async function autoTranslatePage(options) { + const { prefs, languagePairs, ...otherOptions } = options; + const fromLangs = languagePairs.map(language => language.fromLang).join(","); + const { cleanup, runInPage } = await loadTestPage({ + autoDownloadFromRemoteSettings: true, + prefs: [ + ["browser.translations.alwaysTranslateLanguages", fromLangs], + ...(prefs ?? []), + ], + ...otherOptions, + }); + await runInPage(options.runInPage); + await cleanup(); +} + +/** + * @param {RemoteSettingsClient} client + * @param {string} mockedCollectionName - The name of the mocked collection without + * the incrementing "id" part. This is provided so that attachments can be asserted + * as being of a certain version. + * @param {boolean} autoDownloadFromRemoteSettings - Skip the manual download process, + * and automatically download the files. Normally it's preferrable to manually trigger + * the downloads to trigger the download behavior, but this flag lets you bypass this + * and automatically download the files. + */ +function createAttachmentMock( + client, + mockedCollectionName, + autoDownloadFromRemoteSettings +) { + const pendingDownloads = []; + client.attachments.download = record => + new Promise((resolve, reject) => { + console.log("Download requested:", client.collectionName, record.name); + if (autoDownloadFromRemoteSettings) { + const encoder = new TextEncoder(); + const { buffer } = encoder.encode( + `Mocked download: ${mockedCollectionName} ${record.name} ${record.version}` + ); + + resolve({ buffer }); + } else { + pendingDownloads.push({ record, resolve, reject }); + } + }); + + function resolvePendingDownloads(expectedDownloadCount) { + info( + `Resolving ${expectedDownloadCount} mocked downloads for "${client.collectionName}"` + ); + return downloadHandler(expectedDownloadCount, download => + download.resolve({ buffer: new ArrayBuffer() }) + ); + } + + async function rejectPendingDownloads(expectedDownloadCount) { + info( + `Intentionally rejecting ${expectedDownloadCount} mocked downloads for "${client.collectionName}"` + ); + + // Add 1 to account for the original attempt. + const attempts = TranslationsParent.MAX_DOWNLOAD_RETRIES + 1; + return downloadHandler(expectedDownloadCount * attempts, download => + download.reject(new Error("Intentionally rejecting downloads.")) + ); + } + + async function downloadHandler(expectedDownloadCount, action) { + const names = []; + let maxTries = 100; + while (names.length < expectedDownloadCount && maxTries-- > 0) { + await new Promise(resolve => setTimeout(resolve, 0)); + let download = pendingDownloads.shift(); + if (!download) { + // Uncomment the following to debug download issues: + // console.log(`No pending download:`, client.collectionName, names.length); + continue; + } + console.log(`Handling download:`, client.collectionName); + action(download); + names.push(download.record.name); + } + + // This next check is not guaranteed to catch an unexpected download, but wait + // at least one event loop tick to see if any more downloads were added. + await new Promise(resolve => setTimeout(resolve, 0)); + + if (pendingDownloads.length) { + throw new Error( + `An unexpected download was found, only expected ${expectedDownloadCount} downloads` + ); + } + + return names.sort((a, b) => a.localeCompare(b)); + } + + async function assertNoNewDownloads() { + await new Promise(resolve => setTimeout(resolve, 0)); + is( + pendingDownloads.length, + 0, + `No downloads happened for "${client.collectionName}"` + ); + } + + return { + client, + pendingDownloads, + resolvePendingDownloads, + rejectPendingDownloads, + assertNoNewDownloads, + }; +} + +/** + * The amount of files that are generated per mocked language pair. + */ +const FILES_PER_LANGUAGE_PAIR = 3; + +function createRecordsForLanguagePair(fromLang, toLang) { + const records = []; + const lang = fromLang + toLang; + const models = [ + { fileType: "model", name: `model.${lang}.intgemm.alphas.bin` }, + { fileType: "lex", name: `lex.50.50.${lang}.s2t.bin` }, + { fileType: "vocab", name: `vocab.${lang}.spm` }, + ]; + + const attachment = { + hash: `${crypto.randomUUID()}`, + size: `123`, + filename: `model.${lang}.intgemm.alphas.bin`, + location: `main-workspace/translations-models/${crypto.randomUUID()}.bin`, + mimetype: "application/octet-stream", + }; + + if (models.length !== FILES_PER_LANGUAGE_PAIR) { + throw new Error("Files per language pair was wrong."); + } + + for (const { fileType, name } of models) { + records.push({ + id: crypto.randomUUID(), + name, + fromLang, + toLang, + fileType, + version: TranslationsParent.LANGUAGE_MODEL_MAJOR_VERSION + ".0", + last_modified: Date.now(), + schema: Date.now(), + attachment, + }); + } + return records; +} + +/** + * Increments each time a remote settings client is created to ensure a unique client + * name for each test run. + */ +let _remoteSettingsMockId = 0; + +/** + * Creates a local RemoteSettingsClient for use within tests. + * + * @param {boolean} autoDownloadFromRemoteSettings + * @param {Object[]} langPairs + * @returns {RemoteSettingsClient} + */ +async function createTranslationModelsRemoteClient( + autoDownloadFromRemoteSettings, + langPairs +) { + const records = []; + for (const { fromLang, toLang } of langPairs) { + records.push(...createRecordsForLanguagePair(fromLang, toLang)); + } + + const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" + ); + const mockedCollectionName = "test-translation-models"; + const client = RemoteSettings( + `${mockedCollectionName}-${_remoteSettingsMockId++}` + ); + const metadata = {}; + await client.db.clear(); + await client.db.importChanges(metadata, Date.now(), records); + + return createAttachmentMock( + client, + mockedCollectionName, + autoDownloadFromRemoteSettings + ); +} + +/** + * Creates a local RemoteSettingsClient for use within tests. + * + * @param {boolean} autoDownloadFromRemoteSettings + * @returns {RemoteSettingsClient} + */ +async function createTranslationsWasmRemoteClient( + autoDownloadFromRemoteSettings +) { + const records = ["bergamot-translator"].map(name => ({ + id: crypto.randomUUID(), + name, + version: TranslationsParent.BERGAMOT_MAJOR_VERSION + ".0", + last_modified: Date.now(), + schema: Date.now(), + })); + + const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" + ); + const mockedCollectionName = "test-translation-wasm"; + const client = RemoteSettings( + `${mockedCollectionName}-${_remoteSettingsMockId++}` + ); + const metadata = {}; + await client.db.clear(); + await client.db.importChanges(metadata, Date.now(), records); + + return createAttachmentMock( + client, + mockedCollectionName, + autoDownloadFromRemoteSettings + ); +} + +async function selectAboutPreferencesElements() { + const document = gBrowser.selectedBrowser.contentDocument; + + const settingsButton = document.getElementById( + "translations-manage-settings-button" + ); + + const rows = await waitForCondition(() => { + const elements = document.querySelectorAll(".translations-manage-language"); + if (elements.length !== 4) { + return false; + } + return elements; + }, "Waiting for manage language rows."); + + const [downloadAllRow, frenchRow, spanishRow, ukrainianRow] = rows; + + const downloadAllLabel = downloadAllRow.querySelector("label"); + const downloadAll = downloadAllRow.querySelector( + "#translations-manage-install-all" + ); + const deleteAll = downloadAllRow.querySelector( + "#translations-manage-delete-all" + ); + const frenchLabel = frenchRow.querySelector("label"); + const frenchDownload = frenchRow.querySelector( + `[data-l10n-id="translations-manage-language-install-button"]` + ); + const frenchDelete = frenchRow.querySelector( + `[data-l10n-id="translations-manage-language-remove-button"]` + ); + const spanishLabel = spanishRow.querySelector("label"); + const spanishDownload = spanishRow.querySelector( + `[data-l10n-id="translations-manage-language-install-button"]` + ); + const spanishDelete = spanishRow.querySelector( + `[data-l10n-id="translations-manage-language-remove-button"]` + ); + const ukrainianLabel = ukrainianRow.querySelector("label"); + const ukrainianDownload = ukrainianRow.querySelector( + `[data-l10n-id="translations-manage-language-install-button"]` + ); + const ukrainianDelete = ukrainianRow.querySelector( + `[data-l10n-id="translations-manage-language-remove-button"]` + ); + + return { + document, + downloadAllLabel, + downloadAll, + deleteAll, + frenchLabel, + frenchDownload, + frenchDelete, + ukrainianLabel, + ukrainianDownload, + ukrainianDelete, + settingsButton, + spanishLabel, + spanishDownload, + spanishDelete, + }; +} + +function click(button, message) { + info(message); + if (button.hidden) { + throw new Error("The button was hidden when trying to click it."); + } + button.click(); +} + +function hitEnterKey(button, message) { + info(message); + button.dispatchEvent( + new KeyboardEvent("keypress", { + key: "Enter", + keyCode: KeyboardEvent.DOM_VK_RETURN, + }) + ); +} + +/** + * Similar to assertVisibility, but is asynchronous and attempts + * to wait for the elements to match the expected states if they + * do not already. + * + * @see assertVisibility + * + * @param {Object} options + * @param {string} options.message + * @param {Record<string, Element[]>} options.visible + * @param {Record<string, Element[]>} options.hidden + */ +async function ensureVisibility({ message = null, visible = {}, hidden = {} }) { + try { + // First wait for the condition to be met. + await waitForCondition(() => { + for (const element of Object.values(visible)) { + if (BrowserTestUtils.isHidden(element)) { + return false; + } + } + for (const element of Object.values(hidden)) { + if (BrowserTestUtils.isVisible(element)) { + return false; + } + } + return true; + }); + } catch (error) { + // Ignore, this will get caught below. + } + // Now report the conditions. + assertVisibility({ message, visible, hidden }); +} + +/** + * Asserts that the provided elements are either visible or hidden. + * + * @param {Object} options + * @param {string} options.message + * @param {Record<string, Element[]>} options.visible + * @param {Record<string, Element[]>} options.hidden + */ +function assertVisibility({ message = null, visible = {}, hidden = {} }) { + if (message) { + info(message); + } + for (const [name, element] of Object.entries(visible)) { + ok(BrowserTestUtils.isVisible(element), `${name} is visible.`); + } + for (const [name, element] of Object.entries(hidden)) { + ok(BrowserTestUtils.isHidden(element), `${name} is hidden.`); + } +} + +async function setupAboutPreferences( + languagePairs, + { prefs = [], permissionsUrls = [] } = {} +) { + await SpecialPowers.pushPrefEnv({ + set: [ + // Enabled by default. + ["browser.translations.enable", true], + ["browser.translations.logLevel", "All"], + ...prefs, + ], + }); + await SpecialPowers.pushPermissions( + permissionsUrls.map(url => ({ + type: TRANSLATIONS_PERMISSION, + allow: true, + context: url, + })) + ); + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + BLANK_PAGE, + true // waitForLoad + ); + + const { remoteClients, removeMocks } = await createAndMockRemoteSettings({ + languagePairs, + }); + + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + "about:preferences" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + const elements = await selectAboutPreferencesElements(); + + async function cleanup() { + await loadBlankPage(); + await TranslationsParent.destroyEngineProcess(); + await closeTranslationsPanelIfOpen(); + await closeContextMenuIfOpen(); + BrowserTestUtils.removeTab(tab); + await removeMocks(); + await SpecialPowers.popPrefEnv(); + TestTranslationsTelemetry.reset(); + } + + return { + cleanup, + remoteClients, + elements, + }; +} + +function waitForAppLocaleChanged() { + new Promise(resolve => { + function onChange() { + Services.obs.removeObserver(onChange, "intl:app-locales-changed"); + resolve(); + } + Services.obs.addObserver(onChange, "intl:app-locales-changed"); + }); +} + +async function mockLocales({ systemLocales, appLocales, webLanguages }) { + const appLocaleChanged1 = waitForAppLocaleChanged(); + + TranslationsParent.mockedSystemLocales = systemLocales; + const { availableLocales, requestedLocales } = Services.locale; + + info("Mocking locales, so expect potential .ftl resource errors."); + Services.locale.availableLocales = appLocales; + Services.locale.requestedLocales = appLocales; + + await appLocaleChanged1; + + await SpecialPowers.pushPrefEnv({ + set: [["intl.accept_languages", webLanguages.join(",")]], + }); + + return async () => { + const appLocaleChanged2 = waitForAppLocaleChanged(); + + // Reset back to the originals. + TranslationsParent.mockedSystemLocales = null; + Services.locale.availableLocales = availableLocales; + Services.locale.requestedLocales = requestedLocales; + + await appLocaleChanged2; + + await SpecialPowers.popPrefEnv(); + }; +} + +/** + * Helpful test functions for translations telemetry + */ +class TestTranslationsTelemetry { + static #previousFlowId = null; + + static reset() { + TestTranslationsTelemetry.#previousFlowId = null; + } + + /** + * Asserts qualities about a counter telemetry metric. + * + * @param {string} name - The name of the metric. + * @param {Object} counter - The Glean counter object. + * @param {Object} expectedCount - The expected value of the counter. + */ + static async assertCounter(name, counter, expectedCount) { + // Ensures that glean metrics are collected from all child processes + // so that calls to testGetValue() are up to date. + await Services.fog.testFlushAllChildren(); + const count = counter.testGetValue() ?? 0; + is( + count, + expectedCount, + `Telemetry counter ${name} should have expected count` + ); + } + + /** + * Asserts qualities about an event telemetry metric. + * + * @param {string} name - The name of the metric. + * @param {Object} event - The Glean event object. + * @param {Object} expectations - The test expectations. + * @param {number} expectations.expectedEventCount - The expected count of events. + * @param {boolean} expectations.expectNewFlowId + * - Expects the flowId to be different than the previous flowId if true, + * and expects it to be the same if false. + * @param {Array<function>} [expectations.allValuePredicates=[]] + * - An array of function predicates to assert for all event values. + * @param {Array<function>} [expectations.finalValuePredicates=[]] + * - An array of function predicates to assert for only the final event value. + */ + static async assertEvent( + event, + { + expectedEventCount, + expectNewFlowId = null, + expectFirstInteraction = null, + allValuePredicates = [], + finalValuePredicates = [], + } + ) { + // Ensures that glean metrics are collected from all child processes + // so that calls to testGetValue() are up to date. + await Services.fog.testFlushAllChildren(); + const events = event.testGetValue() ?? []; + const eventCount = events.length; + const name = + eventCount > 0 ? `${events[0].category}.${events[0].name}` : null; + + if (eventCount > 0 && expectFirstInteraction !== null) { + is( + events[eventCount - 1].extra.first_interaction, + expectFirstInteraction ? "true" : "false", + "The newest event should be match the given first-interaction expectation" + ); + } + + if (eventCount > 0 && expectNewFlowId !== null) { + const flowId = events[eventCount - 1].extra.flow_id; + if (expectNewFlowId) { + is( + events[eventCount - 1].extra.flow_id !== + TestTranslationsTelemetry.#previousFlowId, + true, + `The newest flowId ${flowId} should be different than the previous flowId ${ + TestTranslationsTelemetry.#previousFlowId + }` + ); + } else { + is( + events[eventCount - 1].extra.flow_id === + TestTranslationsTelemetry.#previousFlowId, + true, + `The newest flowId ${flowId} should be equal to the previous flowId ${ + TestTranslationsTelemetry.#previousFlowId + }` + ); + } + TestTranslationsTelemetry.#previousFlowId = flowId; + } + + if (eventCount !== expectedEventCount) { + console.error("Actual events:", events); + } + + is( + eventCount, + expectedEventCount, + `There should be ${expectedEventCount} telemetry events of type ${name}` + ); + + if (allValuePredicates.length !== 0) { + is( + eventCount > 0, + true, + `Telemetry event ${name} should contain values if allPredicates are specified` + ); + for (const value of events) { + for (const predicate of allValuePredicates) { + is( + predicate(value), + true, + `Telemetry event ${name} allPredicate { ${predicate.toString()} } should pass for each value` + ); + } + } + } + + if (finalValuePredicates.length !== 0) { + is( + eventCount > 0, + true, + `Telemetry event ${name} should contain values if finalPredicates are specified` + ); + for (const predicate of finalValuePredicates) { + is( + predicate(events[eventCount - 1]), + true, + `Telemetry event ${name} finalPredicate { ${predicate.toString()} } should pass for final value` + ); + } + } + } + + /** + * Asserts qualities about a rate telemetry metric. + * + * @param {string} name - The name of the metric. + * @param {Object} rate - The Glean rate object. + * @param {Object} expectations - The test expectations. + * @param {number} expectations.expectedNumerator - The expected value of the numerator. + * @param {number} expectations.expectedDenominator - The expected value of the denominator. + */ + static async assertRate( + name, + rate, + { expectedNumerator, expectedDenominator } + ) { + // Ensures that glean metrics are collected from all child processes + // so that calls to testGetValue() are up to date. + await Services.fog.testFlushAllChildren(); + const { numerator = 0, denominator = 0 } = rate.testGetValue() ?? {}; + is( + numerator, + expectedNumerator, + `Telemetry rate ${name} should have expected numerator` + ); + is( + denominator, + expectedDenominator, + `Telemetry rate ${name} should have expected denominator` + ); + } +} + +/** + * Provide longer defaults for the waitForCondition. + * + * @param {Function} callback + * @param {string} messages + */ +function waitForCondition(callback, message) { + const interval = 100; + // Use 4 times the defaults to guard against intermittents. Many of the tests rely on + // communication between the parent and child process, which is inherently async. + const maxTries = 50 * 4; + return TestUtils.waitForCondition(callback, message, interval, maxTries); +} + +/** + * Retrieves the always-translate language list as an array. + * + * @returns {Array<string>} + */ +function getAlwaysTranslateLanguagesFromPref() { + let langs = Services.prefs.getCharPref(ALWAYS_TRANSLATE_LANGS_PREF); + return langs ? langs.split(",") : []; +} + +/** + * Retrieves the never-translate language list as an array. + * + * @returns {Array<string>} + */ +function getNeverTranslateLanguagesFromPref() { + let langs = Services.prefs.getCharPref(NEVER_TRANSLATE_LANGS_PREF); + return langs ? langs.split(",") : []; +} + +/** + * Retrieves the never-translate site list as an array. + * + * @returns {Array<string>} + */ +function getNeverTranslateSitesFromPerms() { + 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; +} + +/** + * Opens a dialog window for about:preferences + * @param {string} dialogUrl - The URL of the dialog window + * @param {Function} callback - The function to open the dialog via UI + * @returns {Object} The dialog window object + */ +async function waitForOpenDialogWindow(dialogUrl, callback) { + const dialogLoaded = promiseLoadSubDialog(dialogUrl); + await callback(); + const dialogWindow = await dialogLoaded; + return dialogWindow; +} + +/** + * Closes an open dialog window and waits for it to close. + * + * @param {Object} dialogWindow + */ +async function waitForCloseDialogWindow(dialogWindow) { + const closePromise = BrowserTestUtils.waitForEvent( + content.gSubDialog._dialogStack, + "dialogclose" + ); + dialogWindow.close(); + await closePromise; +} + +// Extracted from https://searchfox.org/mozilla-central/rev/40ef22080910c2e2c27d9e2120642376b1d8b8b2/browser/components/preferences/in-content/tests/head.js#41 +function promiseLoadSubDialog(aURL) { + return new Promise((resolve, reject) => { + content.gSubDialog._dialogStack.addEventListener( + "dialogopen", + function dialogopen(aEvent) { + if ( + aEvent.detail.dialog._frame.contentWindow.location == "about:blank" + ) { + return; + } + content.gSubDialog._dialogStack.removeEventListener( + "dialogopen", + dialogopen + ); + + Assert.equal( + aEvent.detail.dialog._frame.contentWindow.location.toString(), + aURL, + "Check the proper URL is loaded" + ); + + // Check visibility + isnot( + aEvent.detail.dialog._overlay, + null, + "Element should not be null, when checking visibility" + ); + Assert.ok( + !BrowserTestUtils.isHidden(aEvent.detail.dialog._overlay), + "The element is visible" + ); + + // Check that stylesheets were injected + let expectedStyleSheetURLs = + aEvent.detail.dialog._injectedStyleSheets.slice(0); + for (let styleSheet of aEvent.detail.dialog._frame.contentDocument + .styleSheets) { + let i = expectedStyleSheetURLs.indexOf(styleSheet.href); + if (i >= 0) { + info("found " + styleSheet.href); + expectedStyleSheetURLs.splice(i, 1); + } + } + Assert.equal( + expectedStyleSheetURLs.length, + 0, + "All expectedStyleSheetURLs should have been found" + ); + + // Wait for the next event tick to make sure the remaining part of the + // testcase runs after the dialog gets ready for input. + executeSoon(() => resolve(aEvent.detail.dialog._frame.contentWindow)); + } + ); + }); +} + +/** + * Loads the blank-page URL. + * + * This is useful for resetting the state during cleanup, and also + * before starting a test, to further help ensure that there is no + * unintentional state left over from test case. + */ +async function loadBlankPage() { + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, BLANK_PAGE); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); +} |