From 6832b1596ef2342d887c42a78052954990b8e464 Mon Sep 17 00:00:00 2001 From: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:32:18 +0200 Subject: [PATCH] refactor: convert Translator to ES module --- index.html | 1 - js/main.js | 4 +- js/module.js | 2 + js/translator.js | 4 +- tests/e2e/translations_spec.js | 114 ++++++++------------ tests/unit/classes/module_spec.js | 7 -- tests/unit/classes/translator_spec.js | 77 +++++++------ tests/unit/functions/cmp_versions_spec.js | 7 -- tests/utils/translation_test_environment.js | 43 ++++++++ 9 files changed, 134 insertions(+), 125 deletions(-) create mode 100644 tests/utils/translation_test_environment.js diff --git a/index.html b/index.html index d2d96d2951..dba2552069 100644 --- a/index.html +++ b/index.html @@ -49,7 +49,6 @@ - diff --git a/js/main.js b/js/main.js index 8cf9e20593..a13e11b41c 100644 --- a/js/main.js +++ b/js/main.js @@ -2,6 +2,8 @@ // eslint-disable-next-line import-x/extensions import { loadModules } from "./loader.js"; +// eslint-disable-next-line import-x/extensions +import { Translator } from "./translator.js"; let modules = []; @@ -571,7 +573,7 @@ export const MM = { Log.setLogLevel(config.logLevel); - await globalThis.Translator.loadCoreTranslations(config.language); + await Translator.loadCoreTranslations(config.language); await loadModules(); }, diff --git a/js/module.js b/js/module.js index 756e0c771c..e853a38659 100644 --- a/js/module.js +++ b/js/module.js @@ -4,6 +4,8 @@ import { loadFileForModule } from "./loader.js"; // eslint-disable-next-line import-x/extensions import { MMSocket } from "./socketclient.js"; +// eslint-disable-next-line import-x/extensions +import { Translator } from "./translator.js"; /* * Module Blueprint. diff --git a/js/translator.js b/js/translator.js index 05338e1ef4..38d3787bdc 100644 --- a/js/translator.js +++ b/js/translator.js @@ -1,6 +1,6 @@ /* global translations */ -const Translator = (function () { +export const Translator = (function () { /** * Load a JSON file via fetch. @@ -125,5 +125,3 @@ const Translator = (function () { } }; }()); - -window.Translator = Translator; diff --git a/tests/e2e/translations_spec.js b/tests/e2e/translations_spec.js index ee275ae7fa..93d9a477b6 100644 --- a/tests/e2e/translations_spec.js +++ b/tests/e2e/translations_spec.js @@ -2,27 +2,23 @@ const fs = require("node:fs"); const path = require("node:path"); const { pathToFileURL } = require("node:url"); const helmet = require("helmet"); -const { JSDOM } = require("jsdom"); const express = require("express"); const translations = require("../../translations/translations"); +const { + setupTranslationTestEnvironment, + TRANSLATOR_MODULE_URL, + resetTranslatorState +} = require("../utils/translation_test_environment"); /** - * Helper function to create a fresh Translator instance with DOM environment. - * @returns {object} Object containing window and Translator + * Create a fresh Translator state for each test. + * @returns {Promise} Shared Translator singleton with cleared state */ -function createTranslationTestEnvironment () { - // Setup DOM environment with Translator - const translatorJs = fs.readFileSync(path.join(__dirname, "..", "..", "js", "translator.js"), "utf-8"); - const dom = new JSDOM("", { url: "http://localhost:3000", runScripts: "outside-only" }); - - dom.window.Log = { log: vi.fn(), error: vi.fn() }; - dom.window.translations = translations; - dom.window.fetch = fetch; - dom.window.eval(translatorJs); - - const window = dom.window; - - return { window, Translator: window.Translator }; +async function getFreshTranslator () { + setupTranslationTestEnvironment(3000); + const { Translator } = await import(TRANSLATOR_MODULE_URL); + resetTranslatorState(Translator); + return Translator; } describe("translations", () => { @@ -40,6 +36,13 @@ describe("translations", () => { server = app.listen(3000); }); + afterEach(() => { + vi.restoreAllMocks(); + delete global.document; + delete global.Log; + delete global.config; + }); + afterAll(async () => { await server.close(); }); @@ -53,101 +56,70 @@ describe("translations", () => { }); describe("loadTranslations", () => { - let dom; + let Translator; + let Module; + let config; beforeEach(async () => { - // Create a new translation test environment for each test - const env = createTranslationTestEnvironment(); - const window = env.window; - - // Bridge JSDOM globals to Node.js so module.js (ES module) can access them - global.Log = window.Log; - global.Translator = window.Translator; - global.config = { language: "de" }; - global.window = { name: "", mmVersion: "2.0.0" }; - global.MM = { hideModule: () => {}, showModule: () => {}, sendNotification: () => {}, updateDom: () => {} }; - global.nunjucks = { - Environment () { - this.addFilter = () => {}; - this.renderString = () => ""; - this.render = (_t, _d, cb) => cb(null, ""); - }, - WebLoader () {}, - runtime: { markSafe: (str) => str } - }; - - // Import Module directly — eval can't handle ES module syntax - const modulePath = pathToFileURL(path.join(__dirname, "..", "..", "js", "module.js")).href; - const { Module } = await import(`${modulePath}?test=${Date.now()}`); - window.Module = Module; - - // Expose config on window so tests can modify dom.window.config - window.config = global.config; + global.Log = { log: vi.fn(), error: vi.fn(), warn: vi.fn() }; + config = { language: "de" }; + global.config = config; - dom = { window }; - }); + // module.js and translator.js are ES modules that read these globals at call time. + Translator = await getFreshTranslator(); - afterEach(() => { - delete global.Log; - delete global.Translator; - delete global.config; - delete global.window; - delete global.MM; - delete global.nunjucks; + const modulePath = pathToFileURL(path.join(__dirname, "..", "..", "js", "module.js")).href; + ({ Module } = await import(modulePath)); }); it("should load translation file", async () => { - const { Translator, Module, config } = dom.window; config.language = "en"; - Translator.load = vi.fn().mockImplementation(() => null); + const loadSpy = vi.spyOn(Translator, "load").mockResolvedValue(null); Module.register("name", { getTranslations: () => translations }); const MMM = Module.create("name"); await MMM.loadTranslations(); - expect(Translator.load.mock.calls).toHaveLength(1); - expect(Translator.load).toHaveBeenCalledWith(MMM, "translations/en.json", false); + expect(loadSpy.mock.calls).toHaveLength(1); + expect(loadSpy).toHaveBeenCalledWith(MMM, "translations/en.json", false); }); it("should load translation + fallback file", async () => { - const { Translator, Module } = dom.window; - Translator.load = vi.fn().mockImplementation(() => null); + const loadSpy = vi.spyOn(Translator, "load").mockResolvedValue(null); Module.register("name", { getTranslations: () => translations }); const MMM = Module.create("name"); await MMM.loadTranslations(); - expect(Translator.load.mock.calls).toHaveLength(2); - expect(Translator.load).toHaveBeenCalledWith(MMM, "translations/de.json", false); - expect(Translator.load).toHaveBeenCalledWith(MMM, "translations/en.json", true); + expect(loadSpy.mock.calls).toHaveLength(2); + expect(loadSpy).toHaveBeenCalledWith(MMM, "translations/de.json", false); + expect(loadSpy).toHaveBeenCalledWith(MMM, "translations/en.json", true); }); it("should load translation fallback file", async () => { - const { Translator, Module, config } = dom.window; config.language = "--"; - Translator.load = vi.fn().mockImplementation(() => null); + const loadSpy = vi.spyOn(Translator, "load").mockResolvedValue(null); Module.register("name", { getTranslations: () => translations }); const MMM = Module.create("name"); await MMM.loadTranslations(); - expect(Translator.load.mock.calls).toHaveLength(1); - expect(Translator.load).toHaveBeenCalledWith(MMM, "translations/en.json", true); + expect(loadSpy.mock.calls).toHaveLength(1); + expect(loadSpy).toHaveBeenCalledWith(MMM, "translations/en.json", true); }); it("should load no file", async () => { - const { Translator, Module } = dom.window; - Translator.load = vi.fn(); + const loadSpy = vi.spyOn(Translator, "load").mockResolvedValue(null); Module.register("name", {}); const MMM = Module.create("name"); await MMM.loadTranslations(); - expect(Translator.load.mock.calls).toHaveLength(0); + expect(loadSpy.mock.calls).toHaveLength(0); }); }); @@ -161,7 +133,7 @@ describe("translations", () => { describe("parsing language files through the Translator class", () => { for (const language in translations) { it(`should parse ${language}`, async () => { - const { Translator } = createTranslationTestEnvironment(); + const Translator = await getFreshTranslator(); await Translator.load(mmm, translations[language], false); expect(typeof Translator.translations[mmm.name]).toBe("object"); @@ -193,7 +165,7 @@ describe("translations", () => { // Function to initialize JSDOM and load translations const initializeTranslationDOM = async (language) => { - const { Translator } = createTranslationTestEnvironment(); + const Translator = await getFreshTranslator(); await Translator.load(mmm, translations[language], false); return Translator.translations[mmm.name]; }; diff --git a/tests/unit/classes/module_spec.js b/tests/unit/classes/module_spec.js index 8f62c39c54..60d6b503ba 100644 --- a/tests/unit/classes/module_spec.js +++ b/tests/unit/classes/module_spec.js @@ -9,7 +9,6 @@ describe("File js/module (cloneObject)", () => { let originalLog; let originalConfig; let originalMM; - let originalTranslator; let originalNunjucks; beforeAll(async () => { @@ -17,7 +16,6 @@ describe("File js/module (cloneObject)", () => { originalLog = global.Log; originalConfig = global.config; originalMM = global.MM; - originalTranslator = global.Translator; originalNunjucks = global.nunjucks; global.window = { mmVersion: "2.0.0" }; @@ -29,10 +27,6 @@ describe("File js/module (cloneObject)", () => { sendNotification: () => {}, updateDom: () => {} }; - global.Translator = { - load: () => Promise.resolve(), - translate: () => "" - }; global.nunjucks = { Environment () { this.addFilter = () => {}; @@ -57,7 +51,6 @@ describe("File js/module (cloneObject)", () => { global.Log = originalLog; global.config = originalConfig; global.MM = originalMM; - global.Translator = originalTranslator; global.nunjucks = originalNunjucks; }); diff --git a/tests/unit/classes/translator_spec.js b/tests/unit/classes/translator_spec.js index 2f4de65393..e50607d711 100644 --- a/tests/unit/classes/translator_spec.js +++ b/tests/unit/classes/translator_spec.js @@ -1,22 +1,22 @@ const fs = require("node:fs"); const path = require("node:path"); const helmet = require("helmet"); -const { JSDOM } = require("jsdom"); const express = require("express"); +const { + setupTranslationTestEnvironment, + TRANSLATOR_MODULE_URL, + resetTranslatorState +} = require("../../utils/translation_test_environment"); /** - * Helper function to create a fresh Translator instance with DOM environment. - * @returns {object} Object containing window and Translator + * Create a fresh Translator state for each test. + * @returns {Promise} Shared Translator singleton with cleared state */ -function createTranslationTestEnvironment () { - const translatorJs = fs.readFileSync(path.join(__dirname, "..", "..", "..", "js", "translator.js"), "utf-8"); - const dom = new JSDOM("", { url: "http://localhost:3001", runScripts: "outside-only" }); - - dom.window.Log = { log: vi.fn(), error: vi.fn() }; - dom.window.fetch = fetch; - dom.window.eval(translatorJs); - - return { window: dom.window, Translator: dom.window.Translator }; +async function getFreshTranslator () { + setupTranslationTestEnvironment(3001); + const { Translator } = await import(TRANSLATOR_MODULE_URL); + resetTranslatorState(Translator); + return Translator; } describe("Translator", () => { @@ -49,6 +49,13 @@ describe("Translator", () => { await server.close(); }); + afterEach(() => { + vi.restoreAllMocks(); + delete global.document; + delete global.Log; + delete global.translations; + }); + describe("translate", () => { const translations = { "MMM-Module": { @@ -93,8 +100,8 @@ describe("Translator", () => { Translator.coreTranslationsFallback = coreTranslationsFallback; }; - it("should return custom module translation", () => { - const { Translator } = createTranslationTestEnvironment(); + it("should return custom module translation", async () => { + const Translator = await getFreshTranslator(); setTranslations(Translator); let translation = Translator.translate({ name: "MMM-Module" }, "Hello"); @@ -104,8 +111,8 @@ describe("Translator", () => { expect(translation).toBe("Hallo fewieden"); }); - it("should return core translation", () => { - const { Translator } = createTranslationTestEnvironment(); + it("should return core translation", async () => { + const Translator = await getFreshTranslator(); setTranslations(Translator); let translation = Translator.translate({ name: "MMM-Module" }, "FOO"); expect(translation).toBe("Foo"); @@ -113,29 +120,29 @@ describe("Translator", () => { expect(translation).toBe("Bar Lorem Ipsum"); }); - it("should return custom module translation fallback", () => { - const { Translator } = createTranslationTestEnvironment(); + it("should return custom module translation fallback", async () => { + const Translator = await getFreshTranslator(); setTranslations(Translator); const translation = Translator.translate({ name: "MMM-Module" }, "A key"); expect(translation).toBe("A translation"); }); - it("should return core translation fallback", () => { - const { Translator } = createTranslationTestEnvironment(); + it("should return core translation fallback", async () => { + const Translator = await getFreshTranslator(); setTranslations(Translator); const translation = Translator.translate({ name: "MMM-Module" }, "Fallback"); expect(translation).toBe("core fallback"); }); - it("should return translation with placeholder for missing variables", () => { - const { Translator } = createTranslationTestEnvironment(); + it("should return translation with placeholder for missing variables", async () => { + const Translator = await getFreshTranslator(); setTranslations(Translator); const translation = Translator.translate({ name: "MMM-Module" }, "Hello {username}"); expect(translation).toBe("Hallo {username}"); }); - it("should return key if no translation was found", () => { - const { Translator } = createTranslationTestEnvironment(); + it("should return key if no translation was found", async () => { + const Translator = await getFreshTranslator(); setTranslations(Translator); const translation = Translator.translate({ name: "MMM-Module" }, "MISSING"); expect(translation).toBe("MISSING"); @@ -151,7 +158,7 @@ describe("Translator", () => { }; it("should load translations", async () => { - const { Translator } = createTranslationTestEnvironment(); + const Translator = await getFreshTranslator(); const file = "translation_test.json"; await Translator.load(mmm, file, false); @@ -160,7 +167,7 @@ describe("Translator", () => { }); it("should load translation fallbacks", async () => { - const { Translator } = createTranslationTestEnvironment(); + const Translator = await getFreshTranslator(); const file = "translation_test.json"; await Translator.load(mmm, file, true); @@ -169,7 +176,7 @@ describe("Translator", () => { }); it("should not load translations, if module fallback exists", async () => { - const { Translator } = createTranslationTestEnvironment(); + const Translator = await getFreshTranslator(); const file = "translation_test.json"; Translator.translationsFallback[mmm.name] = { @@ -186,8 +193,8 @@ describe("Translator", () => { describe("loadCoreTranslations", () => { it("should load core translations and fallback", async () => { - const { window, Translator } = createTranslationTestEnvironment(); - window.translations = { en: "http://localhost:3001/translations/translation_test.json" }; + const Translator = await getFreshTranslator(); + global.translations = { en: "http://localhost:3001/translations/translation_test.json" }; await Translator.loadCoreTranslations("en"); const en = translationTestData; @@ -197,8 +204,8 @@ describe("Translator", () => { }); it("should load core fallback if language cannot be found", async () => { - const { window, Translator } = createTranslationTestEnvironment(); - window.translations = { en: "http://localhost:3001/translations/translation_test.json" }; + const Translator = await getFreshTranslator(); + global.translations = { en: "http://localhost:3001/translations/translation_test.json" }; await Translator.loadCoreTranslations("MISSINGLANG"); const en = translationTestData; @@ -210,8 +217,8 @@ describe("Translator", () => { describe("loadCoreTranslationsFallback", () => { it("should load core translations fallback", async () => { - const { window, Translator } = createTranslationTestEnvironment(); - window.translations = { en: "http://localhost:3001/translations/translation_test.json" }; + const Translator = await getFreshTranslator(); + global.translations = { en: "http://localhost:3001/translations/translation_test.json" }; await Translator.loadCoreTranslationsFallback(); const en = translationTestData; @@ -220,8 +227,8 @@ describe("Translator", () => { }); it("should load core fallback if language cannot be found", async () => { - const { window, Translator } = createTranslationTestEnvironment(); - window.translations = {}; + const Translator = await getFreshTranslator(); + global.translations = {}; await Translator.loadCoreTranslations(); expect(Translator.coreTranslationsFallback).toEqual({}); diff --git a/tests/unit/functions/cmp_versions_spec.js b/tests/unit/functions/cmp_versions_spec.js index a0cc43b3b6..c70d99fbeb 100644 --- a/tests/unit/functions/cmp_versions_spec.js +++ b/tests/unit/functions/cmp_versions_spec.js @@ -7,7 +7,6 @@ describe("Test function cmpVersions in js/module.js", () => { let originalLog; let originalConfig; let originalMM; - let originalTranslator; let originalNunjucks; beforeAll(async () => { @@ -15,7 +14,6 @@ describe("Test function cmpVersions in js/module.js", () => { originalLog = global.Log; originalConfig = global.config; originalMM = global.MM; - originalTranslator = global.Translator; originalNunjucks = global.nunjucks; global.window = { mmVersion: "2.0.0" }; @@ -27,10 +25,6 @@ describe("Test function cmpVersions in js/module.js", () => { sendNotification: () => {}, updateDom: () => {} }; - global.Translator = { - load: () => Promise.resolve(), - translate: () => "" - }; global.nunjucks = { Environment () { this.addFilter = () => {}; @@ -53,7 +47,6 @@ describe("Test function cmpVersions in js/module.js", () => { global.Log = originalLog; global.config = originalConfig; global.MM = originalMM; - global.Translator = originalTranslator; global.nunjucks = originalNunjucks; }); diff --git a/tests/utils/translation_test_environment.js b/tests/utils/translation_test_environment.js new file mode 100644 index 0000000000..8cebb7c53c --- /dev/null +++ b/tests/utils/translation_test_environment.js @@ -0,0 +1,43 @@ +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); +const { JSDOM } = require("jsdom"); + +const TRANSLATOR_MODULE_URL = pathToFileURL(path.join(__dirname, "..", "..", "js", "translator.js")).href; + +/** + * Reset mutable Translator state between tests. + * Clears all non-function plain-object properties to remain resilient + * when additional translation stores are introduced. + * @param {object} Translator The shared Translator module instance. + */ +function resetTranslatorState (Translator) { + for (const [key, value] of Object.entries(Translator)) { + if (typeof value === "function") { + continue; + } + + if (Object.prototype.toString.call(value) === "[object Object]") { + Translator[key] = {}; + } + } +} + +/** + * Set up DOM globals used by translation tests. + * @param {number} [port] Base URI port used to resolve relative translation paths. + * @returns {void} + */ +function setupTranslationTestEnvironment (port = 3000) { + const dom = new JSDOM("", { url: `http://localhost:${port}` }); + + global.document = dom.window.document; + if (!global.Log) { + global.Log = { log: vi.fn(), error: vi.fn() }; + } +} + +module.exports = { + setupTranslationTestEnvironment, + TRANSLATOR_MODULE_URL, + resetTranslatorState +};