/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
/**
* @type {import("../../../ml/content/EngineProcess.sys.mjs")}
*/
const { EngineProcess } = ChromeUtils.importESModule(
"chrome://global/content/ml/EngineProcess.sys.mjs"
);
const { TranslationsPanelShared } = ChromeUtils.importESModule(
"chrome://browser/content/translations/TranslationsPanelShared.sys.mjs"
);
const { TranslationsUtils } = ChromeUtils.importESModule(
"chrome://global/content/translations/TranslationsUtils.mjs"
);
// This is a bit silly, but ml/tests/browser/head.js relies on this function:
// https://searchfox.org/mozilla-central/rev/14f68f084d6a3bc438a3f973ed81d3a4dbab9629/toolkit/components/ml/tests/browser/head.js#23-25
//
// And it also pulls in the entirety of this file.
// https://searchfox.org/mozilla-central/rev/14f68f084d6a3bc438a3f973ed81d3a4dbab9629/toolkit/components/ml/tests/browser/head.js#41-46
//
// So we can't have a naming conflict of a variable defined twice like this.
// https://bugzilla.mozilla.org/show_bug.cgi?id=1949530
const { getInferenceProcessInfo: fetchInferenceProcessInfo } =
ChromeUtils.importESModule("chrome://global/content/ml/Utils.sys.mjs");
// Avoid about:blank's non-standard behavior.
const BLANK_PAGE =
"data:text/html;charset=utf-8,
BlankBlank 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/";
/**
* Use a utility function to make this easier to read.
*
* @param {string} path
* @returns {string}
*/
function _url(path) {
return URL_COM_PREFIX + DIR_PATH + path;
}
const BLANK_PAGE_URL = _url("translations-tester-blank.html");
const SPANISH_PAGE_URL = _url("translations-tester-es.html");
const SPANISH_PAGE_URL_2 = _url("translations-tester-es-2.html");
const SPANISH_PAGE_SHORT_URL = _url("translations-tester-es-short.html");
const SPANISH_PAGE_MISMATCH_URL = _url("translations-tester-es-mismatch.html");
const SPANISH_PAGE_MISMATCH_SHORT_URL = _url("translations-tester-es-mismatch-short.html"); // prettier-ignore
const SPANISH_PAGE_UNDECLARED_URL = _url("translations-tester-es-undeclared.html"); // prettier-ignore
const SPANISH_PAGE_UNDECLARED_SHORT_URL = _url("translations-tester-es-undeclared-short.html"); // prettier-ignore
const ENGLISH_PAGE_URL = _url("translations-tester-en.html");
const FRENCH_PAGE_URL = _url("translations-tester-fr.html");
const NO_LANGUAGE_URL = _url("translations-tester-no-tag.html");
const PDF_TEST_PAGE_URL = _url("translations-tester-pdf-file.pdf");
const SELECT_TEST_PAGE_URL = _url("translations-tester-select.html");
const TEXT_CLEANING_URL = _url("translations-text-cleaning.html");
const SPANISH_BENCHMARK_PAGE_URL = _url("translations-bencher-es.html");
const SPANISH_PAGE_URL_DOT_ORG =
URL_ORG_PREFIX + DIR_PATH + "translations-tester-es.html";
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";
const USE_LEXICAL_SHORTLIST_PREF = "browser.translations.useLexicalShortlist";
/**
* Generates a sorted list of Translation model file names for the given language pairs.
*
* @param {Array<{ fromLang: string, toLang: string }>} languagePairs - An array of language pair objects.
*
* @returns {string[]} A sorted array of translation model file names.
*/
function languageModelNames(languagePairs) {
return languagePairs
.flatMap(({ fromLang, toLang }) => [
`model.${fromLang}${toLang}.intgemm.alphas.bin`,
`vocab.${fromLang}${toLang}.spm`,
...(Services.prefs.getBoolPref(USE_LEXICAL_SHORTLIST_PREF)
? [`lex.50.50.${fromLang}${toLang}.s2t.bin`]
: []),
])
.sort();
}
/**
* 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 {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.
*
* @param {boolean} [options.autoDownloadFromRemoteSettings=true]
* Initiate the mock model downloads when this function is invoked instead of
* waiting for the resolveDownloads or rejectDownloads to be externally invoked
*
* @returns {object} object
*
* @returns {(args: { dataForContent: T, selectors: Record }) => Promise} object.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.
*
* @returns {() => Promise} object.cleanup
*
* @returns {(count: number) => Promise} object.resolveDownloads
*
* @returns {(count: number) => Promise} object.rejectDownloads
*/
async function openAboutTranslations({
dataForContent,
disabled,
languagePairs = LANGUAGE_PAIRS,
prefs,
autoDownloadFromRemoteSettings = false,
} = {}) {
await SpecialPowers.pushPrefEnv({
set: [
// Enabled by default.
["browser.translations.enable", !disabled],
["browser.translations.logLevel", "All"],
["browser.translations.mostRecentTargetLanguages", ""],
[USE_LEXICAL_SHORTLIST_PREF, false],
...(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",
languageSwapButton: "button#language-swap",
translationTextarea: "textarea#translation-from",
translationResult: "#translation-to",
translationResultBlank: "#translation-to-blank",
translationInfo: "#translation-info",
translationResultsPlaceholder: "#translation-results-placeholder",
noSupportMessage: "[data-l10n-id='about-translations-no-support']",
languageLoadErrorMessage:
"[data-l10n-id='about-translations-language-load-error']",
};
// Start the tab at a blank page.
let tab = await BrowserTestUtils.openNewForegroundTab(
gBrowser,
BLANK_PAGE,
true // waitForLoad
);
const { removeMocks, remoteClients } = await createAndMockRemoteSettings({
languagePairs,
autoDownloadFromRemoteSettings,
});
// Now load the about:translations page, since the actor could be mocked.
BrowserTestUtils.startLoadingURIString(
tab.linkedBrowser,
"about:translations"
);
await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
/**
* @param {number} count - Count of the language pairs expected.
*/
const resolveDownloads = async count => {
await remoteClients.translationsWasm.resolvePendingDownloads(1);
await remoteClients.translationModels.resolvePendingDownloads(
downloadedFilesPerLanguagePair() * count
);
};
/**
* @param {number} count - Count of the language pairs expected.
*/
const rejectDownloads = async count => {
await remoteClients.translationsWasm.rejectPendingDownloads(1);
await remoteClients.translationModels.rejectPendingDownloads(
downloadedFilesPerLanguagePair() * count
);
};
return {
runInPage(callback) {
return ContentTask.spawn(
tab.linkedBrowser,
{ dataForContent, selectors }, // Data to inject.
callback
);
},
async cleanup() {
await loadBlankPage();
BrowserTestUtils.removeTab(tab);
await removeMocks();
await EngineProcess.destroyTranslationsEngine();
await SpecialPowers.popPrefEnv();
TestTranslationsTelemetry.reset();
Services.fog.testResetFOG();
},
resolveDownloads,
rejectDownloads,
};
}
/**
* 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.
// "
foobar
"
// ^^^
startIndex = endIndex;
continue;
}
// Find all of the text.
// "
foobar
"
// ^^^^^^
while (endIndex < html.length && html[endIndex] !== "<") {
endIndex++;
}
addText(endIndex);
if (html[endIndex] === "<") {
if (html[endIndex + 1] === "/") {
// "
\s*M̅u̅t̅a̅t̅i̅o̅n̅ 5 o̅n̅ e̅l̅e̅m̅e̅n̅t̅ \(id:[1-5]\)\s*<\/div>\s*$/su
*
* Which allows us to match the actual HTML to the expected HTML
* regardless of whether the translation id was 1, 2, 3, 4, or 5.
*
* @param {string} html
* @returns {RegExp}
*/
function expectedHtmlToRegex(html) {
// All characters that will need to be escaped with a backslash in the
// final regex if they are contained within the HTML string.
const ESCAPABLE_CHARACTERS = /[.*+?^${}()|[\]\\]/g;
// Our own escape syntax to signify a {{ regex literal }} within the
// HTML string that should be preserved in its original form.
const REGEX_LITERAL = /\{\{(.*?)\}\}/gsu;
// The same matcher as above, after escaping the curly braces with backslash.
const ESCAPED_REGEX_LITERAL = /\\\{\\\{.*?\\\}\\\}/su;
// Collect all regex literals that were escaped by using {{ literal }}
// syntax into a single array. We will place them back in at the end.
const regexLiterals = [...html.matchAll(REGEX_LITERAL)].map(
match => match[1]
);
let pattern = html
// Escape each character that needs it with a backslash.
.replaceAll(ESCAPABLE_CHARACTERS, "\\$&")
// Add a 0+ blank space matcher \s* before each opening angle bracket <
.replaceAll(/\s*
.replaceAll(/>\s*/g, ">\\s*")
// Collapse more than one blank space into a 1+ matcher
.replaceAll(/\s\s+/g, "\\s+")
// Replace a 1+ blank space matcher at the beginning with a 0+ matcher.
.replace(/^\\s\+/, "\\s*")
// Replace a 1+ blank space matcher at the end with a 0+ matcher.
.replace(/\\s\+$/, "\\s*");
// Go back through and replace each {{ regex literal }} that we preserved
// at the start with its captured content.
for (const regexLiteral of regexLiterals) {
pattern = pattern.replace(ESCAPED_REGEX_LITERAL, regexLiteral.trim());
}
return new RegExp(`^${pattern}$`, "su");
}
/**
* Test utility to check that the document matches the expected markup.
* If `html` is a string, the prettified innerHTML must match exactly.
* If `html` is a RegExp, the prettified innerHTML must satisfy the
* regular expression.
*
* @param {string} message
* @param {string} expectedHtml
* @param {Document} [sourceDoc]
* @param {() => void} [resolveRequests]
*/
async function htmlMatches(
message,
expectedHtml,
sourceDoc = document,
resolveRequests
) {
const prettyHtml = naivelyPrettify(expectedHtml);
const expected = expectedHtmlToRegex(expectedHtml);
let didSimulateIntersectionObservation = false;
try {
await waitForCondition(async () => {
await waitForCondition(
() => !translationsDoc.hasPendingCallbackOnEventLoop()
);
while (
translationsDoc.hasPendingCallbackOnEventLoop() ||
translationsDoc.hasPendingTranslationRequests()
) {
if (resolveRequests) {
// Since resolveRequests is defined, we must manually resolve
// them as the scheduler sends them until all are fulfilled.
await waitForCondition(
() =>
resolveRequests() ||
(!translationsDoc.hasPendingCallbackOnEventLoop() &&
!translationsDoc.hasPendingTranslationRequests()),
"Manually resolving requests as they come in..."
);
} else {
// Since resolveRequests is not defined, requests will resolve
// automatically when the scheduler sends them. We simply have
// to wait until they are all fulfilled.
await waitForCondition(
() =>
!translationsDoc.hasPendingCallbackOnEventLoop() &&
!translationsDoc.hasPendingTranslationRequests(),
"Waiting for all requests to come in..."
);
}
}
await waitForCondition(
() => !translationsDoc.hasPendingCallbackOnEventLoop()
);
const actualHtml = naivelyPrettify(sourceDoc.body.innerHTML);
const htmlMatches = expected.test(actualHtml);
if (!htmlMatches && !didSimulateIntersectionObservation) {
// If all of the requests have been resolved, and the HTML doesn't match,
// then it may be because the request was never sent to the scheduler,
// so we need to manually simulate intersection observation.
//
// This is a valid case, and not a bug. For example, if an attribute is mutated,
// then it will not be scheduled for translation until it is observed.
// However, we should never have to do this more than one time.
didSimulateIntersectionObservation = true;
translationsDoc.simulateIntersectionObservationForNonPendingNodes();
}
if (htmlMatches) {
await waitForCondition(
() =>
!translationsDoc.hasPendingCallbackOnEventLoop() &&
!translationsDoc.hasPendingTranslationRequests() &&
!translationsDoc.isObservingAnyElementForContentIntersection() &&
!translationsDoc.isObservingAnyElementForAttributeIntersection(),
"Ensuring that the entire document is translated."
);
}
return htmlMatches;
}, "Waiting for HTML to match.");
ok(true, message);
} catch (error) {
console.error(error);
// Provide a nice error message.
const actual = naivelyPrettify(sourceDoc.body.innerHTML);
ok(
false,
`${message}\n\nExpected HTML:\n\n${
prettyHtml
}\n\nActual HTML:\n\n${actual}\n\n${String(error)}`
);
}
}
function cleanup() {
SpecialPowers.popPrefEnv();
}
return { htmlMatches, cleanup, translate, document };
}
/**
* Perform a double requestAnimationFrame, which is used by the TranslationsDocument
* to handle mutations.
*
* @param {Document} doc
*/
function doubleRaf(doc) {
return new Promise(resolve => {
doc.ownerGlobal.requestAnimationFrame(() => {
doc.ownerGlobal.requestAnimationFrame(() => {
resolve(
// Wait for a tick to be after anything that resolves with a double rAF.
TestUtils.waitForTick()
);
});
});
});
}
/**
* 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(win = window) {
return TranslationsParent.getTranslationsActor(win.gBrowser.selectedBrowser);
}
/**
* Closes all open panels and menu popups related to Translations.
*
* @param {ChromeWindow} [win]
*/
async function closeAllOpenPanelsAndMenus(win) {
await closeFullPagePanelSettingsMenuIfOpen(win);
await closeFullPageTranslationsPanelIfOpen(win);
await closeSelectPanelSettingsMenuIfOpen(win);
await closeSelectTranslationsPanelIfOpen(win);
await closeContextMenuIfOpen(win);
}
/**
* Closes the popup element with the given Id if it is open.
*
* @param {string} popupElementId
* @param {ChromeWindow} [win]
*/
async function closePopupIfOpen(popupElementId, win = window) {
await waitForCondition(async () => {
const popupElement = win.document.getElementById(popupElementId);
if (!popupElement) {
return true;
}
if (popupElement.state === "closed") {
return true;
}
let popuphiddenPromise = BrowserTestUtils.waitForEvent(
popupElement,
"popuphidden"
);
popupElement.hidePopup();
PanelMultiView.hidePopup(popupElement);
await popuphiddenPromise;
return false;
});
}
/**
* Closes the context menu if it is open.
*
* @param {ChromeWindow} [win]
*/
async function closeContextMenuIfOpen(win) {
await closePopupIfOpen("contentAreaContextMenu", win);
}
/**
* Closes the full-page translations panel settings menu if it is open.
*
* @param {ChromeWindow} [win]
*/
async function closeFullPagePanelSettingsMenuIfOpen(win) {
await closePopupIfOpen(
"full-page-translations-panel-settings-menupopup",
win
);
}
/**
* Closes the select translations panel settings menu if it is open.
*
* @param {ChromeWindow} [win]
*/
async function closeSelectPanelSettingsMenuIfOpen(win) {
await closePopupIfOpen("select-translations-panel-settings-menupopup", win);
}
/**
* Closes the translations panel if it is open.
*
* @param {ChromeWindow} [win]
*/
async function closeFullPageTranslationsPanelIfOpen(win) {
await closePopupIfOpen("full-page-translations-panel", win);
}
/**
* Closes the translations panel if it is open.
*
* @param {ChromeWindow} [win]
*/
async function closeSelectTranslationsPanelIfOpen(win) {
await closePopupIfOpen("select-translations-panel", win);
}
/**
* 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"],
[USE_LEXICAL_SHORTLIST_PREF, false],
...(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 closeAllOpenPanelsAndMenus();
await loadBlankPage();
await EngineProcess.destroyTranslationsEngine();
BrowserTestUtils.removeTab(tab);
await removeMocks();
TestTranslationsTelemetry.reset();
return SpecialPowers.popPrefEnv();
},
};
}
/**
* Creates and mocks remote settings for translations.
*
* @param {object} options - The options for creating and mocking remote settings.
* @param {Array<{fromLang: string, toLang: string}>} [options.languagePairs=LANGUAGE_PAIRS]
* - The language pairs to be used.
* @param {boolean} [options.useMockedTranslator=true]
* - Whether to use a mocked translator.
* @param {boolean} [options.autoDownloadFromRemoteSettings=false]
* - Whether to automatically download from remote settings.
*
* @returns {Promise