// Behaviour tests for extras/auto-translate.js (tier-3 machine translation). // // Not wired into CI: the repo has no JavaScript test harness, and adding npm to // the pipeline is a bigger decision than this feature warrants. Run manually: // // npm install jsdom # once, anywhere on NODE_PATH // node tests/js/auto-translate.test.js // // Exits non-zero on failure. const fs = require("fs"); const assert = require("assert"); const { JSDOM, VirtualConsole } = require("jsdom"); const path = require("path"); const SRC = fs.readFileSync( path.join(__dirname, "..", "..", "extras", "auto-translate.js"), "utf8" ); const CONF = { label: "机器翻译 / Machine translation", source: "en", sourceLanguage: "english", service: "giteeAI", listener: false, failureTimeoutMs: 60, cdn: "https://cdn.example.test/translate.js", integrity: "sha384-TESTHASH", languages: [ { name: "french", label: "Français", locale: "fr" }, { name: "hebrew", label: "עברית", locale: "he", dir: "rtl" }, ], }; // A stand-in for translate.js with the same API surface the script touches, // including the lifecycle hooks the notice's state machine subscribes to. // Nothing fires on its own: each test drives the pass it wants to describe. function fakeTranslate({ lifecycle = true } = {}) { const t = { to: "", selectLanguageTag: { show: true }, service: { used: null, use(n) { this.used = n; } }, language: { local: null, setLocal(n) { this.local = n; } }, ignore: { tag: ["style", "script", "link", "pre", "code"], class: ["ignore"], id: [] }, listener: { started: false, start() { this.started = true; } }, calls: [], changeLanguage(n) { this.to = n; this.calls.push(["changeLanguage", n]); this.pass && this.pass.start(); }, execute() { this.calls.push(["execute"]); this.pass && this.pass.start(); }, }; if (!lifecycle) return t; t.lifecycle = { execute: { start: [], translateNetworkAfter: [], renderFinish: [], finally: [] }, }; const fire = (name, ...args) => t.lifecycle.execute[name].forEach((f) => f(...args)); let uuid = 0; // Mirrors what translate.js emits for one translate.execute() call. t.pass = { uuid: null, start() { this.uuid = "uuid-" + ++uuid; fire("start", { uuid: this.uuid, to: t.to }); }, batch(from, result) { fire("translateNetworkAfter", { uuid: this.uuid, from, to: t.to, result }); }, renderFinish() { fire("renderFinish", this.uuid, t.to); }, finally(state) { fire("finally", { uuid: this.uuid, to: t.to, state }); }, }; return t; } const stateOf = (w) => { const n = w.document.querySelector(".auto-translate-notice"); return n && n.getAttribute("data-state"); }; async function setup({ path = "/ai-agent-book/book-en/chapter1/", stored = null, urlFor = () => null, lifecycle = true, } = {}) { const navErrors = []; const vc = new VirtualConsole(); vc.on("jsdomError", (e) => navErrors.push(e.message)); const dom = new JSDOM( `

Chapter 1

`, { url: "https://example.test" + path, runScripts: "outside-only", virtualConsole: vc } ); const w = dom.window; w.AUTO_TRANSLATE_CONFIG = CONF; w.langSwitcher = { current: () => "en", urlFor }; if (stored) w.localStorage.setItem("auto-translate", JSON.stringify(stored)); // Capture script injection instead of hitting the network. const injected = []; const realAppend = w.document.head.appendChild.bind(w.document.head); w.document.head.appendChild = (node) => { if (node.tagName === "SCRIPT") { injected.push(node); setTimeout(() => { w.translate = w.__fakeTranslate; node.onload && node.onload(); }, 0); return node; } return realAppend(node); }; w.__fakeTranslate = fakeTranslate({ lifecycle }); const replaced = []; try { w.location.replace = (u) => replaced.push(u); } catch (_) {} w.eval(SRC); // jsdom fires DOMContentLoaded asynchronously; the script waits for it. await new Promise((r) => setTimeout(r, 5)); return { w, dom, injected, replaced, navErrors }; } const tick = () => new Promise((r) => setTimeout(r, 5)); const tests = []; const test = (name, fn) => tests.push([name, fn]); test("appends a labelled group with one option per tier-3 language", async () => { const { w } = await setup(); const menu = w.document.getElementById("lang-menu"); const group = menu.querySelector(".lang-menu__group"); assert.ok(group, "group heading missing"); assert.strictEqual(group.textContent, CONF.label); const options = menu.querySelectorAll(".lang-menu__option--auto"); assert.strictEqual(options.length, 2); assert.strictEqual(options[0].getAttribute("data-auto-lang"), "french"); // Must not carry data-lang-code, or lang-switcher.js would try to navigate. assert.strictEqual(options[0].getAttribute("data-lang-code"), null); // Built editions still come first. assert.ok(menu.children[0].getAttribute("data-lang-code")); }); test("is inert when no config is present", async () => { const { w } = await setup(); delete w.AUTO_TRANSLATE_CONFIG; const menu = w.document.getElementById("lang-menu"); const before = menu.children.length; w.document.dispatchEvent(new w.Event("DOMContentLoaded")); assert.strictEqual(menu.children.length, before); }); test("does not build the group twice", async () => { const { w } = await setup(); w.document.dispatchEvent(new w.Event("DOMContentLoaded")); assert.strictEqual(w.document.querySelectorAll(".lang-menu__group").length, 1); assert.strictEqual(w.document.querySelectorAll(".lang-menu__option--auto").length, 2); }); test("selecting a language off the English edition routes there first", async () => { const target = "https://example.test/ai-agent-book/book-en/chapter1/"; const asked = []; const { w, injected, navErrors } = await setup({ path: "/ai-agent-book/book/chapter1/", urlFor: (code) => { asked.push(code); return code === "en" ? target : null; }, }); w.document.querySelector('[data-auto-lang="french"]').click(); assert.deepStrictEqual(asked, ["en"], "should ask for the English edition URL"); assert.ok(navErrors.some((m) => /navigation/i.test(m)), "should navigate away"); assert.strictEqual(injected.length, 0, "must not load translate.js before navigating"); assert.strictEqual(JSON.parse(w.localStorage.getItem("auto-translate")).name, "french"); }); test("on the English edition it loads, configures and translates", async () => { const { w, injected } = await setup(); w.document.querySelector('[data-auto-lang="french"]').click(); await tick(); assert.strictEqual(injected.length, 1); assert.strictEqual(injected[0].src, CONF.cdn); assert.strictEqual(injected[0].integrity, CONF.integrity); assert.strictEqual(injected[0].crossOrigin, "anonymous"); const t = w.translate; assert.strictEqual(t.service.used, "giteeAI"); assert.strictEqual(t.language.local, "english"); assert.strictEqual(t.selectLanguageTag.show, false); assert.strictEqual(t.listener.started, false, "MutationObserver must stay off by default"); assert.ok(t.ignore.tag.includes("mjx-container"), "MathJax output must be ignored"); assert.ok(t.ignore.tag.includes("pre") && t.ignore.tag.includes("code"), "defaults kept"); for (const c of ["mermaid", "arithmatex", "highlight", "md-source"]) { assert.ok(t.ignore.class.includes(c), `${c} must be ignored`); } // The language menu lists every edition under its own endonym; translating // those is both wrong and one API request per script. for (const id of ["lang-menu", "lang-selector"]) { assert.ok(t.ignore.id.includes(id), `#${id} must be ignored`); } assert.deepStrictEqual(t.calls, [["changeLanguage", "french"]]); }); test("shows a labelled notice carrying every state's wording", async () => { const { w } = await setup(); w.document.querySelector('[data-auto-lang="hebrew"]').click(); await tick(); const notice = w.document.querySelector(".auto-translate-notice"); assert.ok(notice, "notice missing"); // All three are in the DOM so one translation pass covers them; CSS shows one. assert.match(notice.textContent, /Machine-translating this page/); assert.match(notice.textContent, /Machine-translated from the English edition/); assert.match(notice.textContent, /not reviewed/); assert.match(notice.textContent, /Figures and code stay in English/); assert.match(notice.textContent, /unavailable/); assert.strictEqual(w.document.querySelectorAll(".auto-translate-notice__text").length, 3); }); test("reports progress while the pass runs, then that it is translated", async () => { const { w } = await setup(); w.document.querySelector('[data-auto-lang="hebrew"]').click(); await tick(); // Mid-flight: the text on screen is still English, so neither the "done" // wording nor the Hebrew locale may be claimed yet. assert.strictEqual(stateOf(w), "pending"); assert.strictEqual(w.document.documentElement.lang, "en"); assert.strictEqual(w.document.documentElement.dir, "ltr"); w.translate.pass.batch("english", 1); w.translate.pass.renderFinish(); assert.strictEqual(stateOf(w), "ok"); assert.strictEqual(w.document.documentElement.lang, "he"); assert.strictEqual(w.document.documentElement.dir, "rtl"); }); test("a pass that renders without any request counts as translated", async () => { // Everything came out of translate.js' local cache — no network, no failure. const { w } = await setup(); w.document.querySelector('[data-auto-lang="french"]').click(); await tick(); w.translate.pass.renderFinish(); assert.strictEqual(stateOf(w), "ok"); }); test("a failed batch in another source language does not mask a translated page", async () => { const { w } = await setup(); w.document.querySelector('[data-auto-lang="french"]').click(); await tick(); w.translate.pass.batch("english", 1); // the book's prose w.translate.pass.batch("chinese_simplified", 0); // a stray string elsewhere w.translate.pass.renderFinish(); assert.strictEqual(stateOf(w), "ok"); }); test("a failed source-language batch is reported as unavailable", async () => { const { w } = await setup(); w.document.querySelector('[data-auto-lang="french"]').click(); await tick(); w.translate.pass.batch("english", 0); w.translate.pass.renderFinish(); assert.strictEqual(stateOf(w), "failed"); assert.strictEqual(w.document.documentElement.lang, "en", "must not claim the target locale"); }); test("a stored selection is re-applied on the next page load", async () => { const { w, injected } = await setup({ stored: { name: "french", label: "Français", locale: "fr" } }); await tick(); assert.strictEqual(injected.length, 1, "should load the library on its own"); assert.deepStrictEqual(w.translate.calls, [["changeLanguage", "french"]]); assert.ok(w.document.querySelector(".auto-translate-notice")); // The trigger must advertise the machine-translated language, not "English". assert.strictEqual(w.document.querySelector("[data-lang-label]").textContent, "Français"); assert.strictEqual( w.document.querySelector('[data-auto-lang="french"]').getAttribute("aria-checked"), "true" ); }); test("no library request happens for a reader who never opts in", async () => { const { w, injected } = await setup(); await tick(); assert.strictEqual(injected.length, 0); assert.strictEqual(w.translate, undefined); }); test("choosing a built edition clears the selection", async () => { const { w } = await setup({ stored: { name: "french", label: "Français", locale: "fr" } }); w.document.querySelector('[data-lang-code="zh"]').click(); assert.strictEqual(w.localStorage.getItem("auto-translate"), null); }); test("'Turn off' clears the selection and reloads", async () => { const { w, navErrors } = await setup({ stored: { name: "french", label: "Français", locale: "fr" } }); await tick(); const before = navErrors.length; w.document.querySelector(".auto-translate-notice__off").click(); assert.strictEqual(w.localStorage.getItem("auto-translate"), null); assert.ok(navErrors.length > before, "should reload the page"); }); test("a failed library load degrades to a visible warning", async () => { const { w } = await setup(); // Make the injected script fail instead of loading. w.document.head.appendChild = (node) => { if (node.tagName === "SCRIPT") { setTimeout(() => node.onerror && node.onerror(), 0); return node; } return node; }; w.console.warn = () => {}; w.document.querySelector('[data-auto-lang="french"]').click(); await tick(); assert.strictEqual(stateOf(w), "failed"); assert.ok(w.document.querySelector(".auto-translate-notice--failed"), "failure class missing"); }); test("corrupt storage is ignored rather than throwing", async () => { const { w, injected } = await setup(); w.localStorage.setItem("auto-translate", "{not json"); w.document.dispatchEvent(new w.Event("DOMContentLoaded")); await tick(); assert.strictEqual(injected.length, 0); }); test("starts translate.js' listener only when explicitly enabled", async () => { const { w } = await setup(); w.AUTO_TRANSLATE_CONFIG = { ...CONF, listener: true }; w.document.querySelector('[data-auto-lang="french"]').click(); await tick(); assert.strictEqual(w.translate.listener.started, true); }); test("warns when a pass stalls with nothing coming back", async () => { const { w } = await setup(); w.document.querySelector('[data-auto-lang="french"]').click(); await tick(); const notice = w.document.querySelector(".auto-translate-notice"); await new Promise((r) => setTimeout(r, 120)); // past failureTimeoutMs assert.strictEqual(stateOf(w), "failed"); // Same node, not a replacement — an in-flight pass may hold a reference. assert.strictEqual(w.document.querySelector(".auto-translate-notice"), notice); }); test("a batch coming back keeps a slow pass from being called dead", async () => { const { w } = await setup(); w.document.querySelector('[data-auto-lang="french"]').click(); await tick(); // The free channel retries against backup hosts, so batches can trickle in // for well past one timeout window. Progress restarts the clock, not trips it. for (let i = 0; i < 4; i++) { await new Promise((r) => setTimeout(r, 40)); w.translate.pass.batch("english", 1); } assert.strictEqual(stateOf(w), "pending", "must not cry wolf while batches land"); w.translate.pass.renderFinish(); assert.strictEqual(stateOf(w), "ok"); }); test("a late pass corrects a notice that already gave up", async () => { // The reported bug: the page ends up translated, yet the notice still says // the service is unavailable because nothing ever looked again. const { w } = await setup(); w.document.querySelector('[data-auto-lang="french"]').click(); await tick(); await new Promise((r) => setTimeout(r, 120)); assert.strictEqual(stateOf(w), "failed"); w.translate.pass.batch("english", 1); w.translate.pass.renderFinish(); assert.strictEqual(stateOf(w), "ok", "a translation that lands late must clear the warning"); assert.strictEqual(w.document.documentElement.lang, "fr"); }); test("a page already in the target language is not a failure", async () => { const { w } = await setup(); w.document.querySelector('[data-auto-lang="french"]').click(); await tick(); w.translate.pass.finally(5); // local language == target: no render pass follows await new Promise((r) => setTimeout(r, 120)); assert.strictEqual(stateOf(w), "ok"); }); test("without lifecycle hooks an untouched notice still warns", async () => { const { w } = await setup({ lifecycle: false }); w.document.querySelector('[data-auto-lang="french"]').click(); await tick(); await new Promise((r) => setTimeout(r, 120)); assert.strictEqual(stateOf(w), "failed"); }); test("without lifecycle hooks it falls back to watching its own text", async () => { const { w } = await setup({ lifecycle: false }); w.document.querySelector('[data-auto-lang="french"]').click(); await tick(); assert.strictEqual(stateOf(w), "pending"); // Simulate translate.js rewriting the page (including our notice). w.document.querySelector(".auto-translate-notice__text--ok").textContent = "Traduit automatiquement de l'\u00e9dition anglaise"; await new Promise((r) => setTimeout(r, 120)); assert.strictEqual(stateOf(w), "ok"); }); (async () => { let failed = 0; for (const [name, fn] of tests) { try { await fn(); console.log("PASS " + name); } catch (e) { failed++; console.log("FAIL " + name + "\n " + e.message); } } console.log(failed ? `\n${failed} FAILURES` : `\nALL ${tests.length} PASS`); process.exit(failed ? 1 : 0); })();