diff --git a/js/__tests__/loader.test.js b/js/__tests__/loader.test.js index 65af3faeb1..377a1296bb 100644 --- a/js/__tests__/loader.test.js +++ b/js/__tests__/loader.test.js @@ -1,16 +1,66 @@ +/** + * @license + * MusicBlocks v3.4.1 + * Copyright (C) 2026 Ashutosh Kumar + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Tests for loader.js module + * + * The loader.js file is an entry point that uses RequireJS to load the application. + * Since it executes immediately upon require and depends on external modules (i18next), + * we test its configuration and behavior through mocks. + */ + +let _mockImpl = jest.fn(); + +// Proxy is installed at file-parse time, before any describe/test runs. +global.requirejs = new Proxy( + function (...args) { + return _mockImpl(...args); + }, + { + get(_, prop) { + if (prop === "config") return _mockImpl.config; + if (prop === "defined") return _mockImpl.defined; + return _mockImpl[prop]; + }, + set(_, prop, value) { + _mockImpl[prop] = value; + return true; + } + } +); +global.define = jest.fn(); + +// ───────────────────────────────────────────────────────────────────────────── + describe("loader.js coverage", () => { - let mockRequireJS; - let mockRequireJSConfig; let mockI18next; let mockI18nextHttpBackend; let consoleErrorSpy; + let consoleWarnSpy; + let alertSpy; beforeEach(() => { jest.resetModules(); - consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => { - // Mock empty implementation - }); + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + alertSpy = jest.spyOn(global, "alert").mockImplementation(() => {}); document.body.innerHTML = `
Original Title
@@ -25,38 +75,87 @@ describe("loader.js coverage", () => { on: jest.fn(), isInitialized: false }; - mockI18nextHttpBackend = {}; - mockRequireJSConfig = jest.fn(); - mockRequireJS = jest.fn(); - mockRequireJS.config = mockRequireJSConfig; - mockRequireJS.defined = jest.fn(() => false); + // Fresh mock for each test + _mockImpl = jest.fn(); + _mockImpl.config = jest.fn(); + _mockImpl.defined = jest.fn(() => false); - global.requirejs = mockRequireJS; global.define = jest.fn(); + global.window = document.defaultView; global.window.createjs = { Stage: jest.fn(), Ticker: { framerate: 60, addEventListener: jest.fn() } }; + + Object.defineProperty(document, "readyState", { + value: "complete", + configurable: true + }); }); afterEach(() => { - delete global.define; jest.restoreAllMocks(); + delete global.window.M; + delete global.window.Materialize; + delete global.window.hljs; + delete global.window._THIS_IS_MUSIC_BLOCKS_; + delete global.window._THIS_IS_TURTLE_BLOCKS_; }); - const loadScript = async ({ initError = false, langError = false } = {}) => { - mockRequireJS.mockImplementation((deps, callback) => { + // ─── loadScript helper ──────────────────────────────────────────────────── + + const loadScript = async ({ + initError = false, + langError = false, + hljsAvailable = false, + hljsFails = false, + i18nNotInitialized = false, + materializeAutoInit = false, + missingCreatejs = false, + coreBootstrapFails = false, + activityFails = false, + preloadedDefined = false, + readyState = "complete", + waitMs = 400 + } = {}) => { + Object.defineProperty(document, "readyState", { + value: readyState, + configurable: true + }); + + if (materializeAutoInit) { + global.window.M = { AutoInit: jest.fn() }; + } + if (missingCreatejs) { + delete global.window.createjs; + } + + _mockImpl.defined = jest.fn(() => preloadedDefined); + + _mockImpl.mockImplementation((deps, callback, errback) => { + if (!Array.isArray(deps)) return null; + + // ── highlight.js optional load ───────────────────────────────── if (deps.includes("highlight")) { - if (callback) callback(null); - } else if (deps.includes("i18next")) { + if (hljsFails) { + if (errback) errback(new Error("hljs load error")); + } else { + const hljs = hljsAvailable ? { highlightAll: jest.fn() } : null; + if (callback) callback(hljs); + } + return null; + } + + // ── i18next bootstrap ────────────────────────────────────────── + if (deps.includes("i18next")) { mockI18next.init.mockImplementation((config, cb) => { if (initError) { cb("Init Failed"); } else { - mockI18next.isInitialized = true; + if (!i18nNotInitialized) mockI18next.isInitialized = true; cb(null); } }); @@ -64,32 +163,47 @@ describe("loader.js coverage", () => { if (langError) cb("Lang Change Failed"); else cb(null); }); - if (callback) { - callback(mockI18next, mockI18nextHttpBackend); + if (callback) callback(mockI18next, mockI18nextHttpBackend); + return null; + } + + // ── CORE_BOOTSTRAP_MODULES (many deps, includes easeljs.min) ── + if (deps.includes("easeljs.min") && deps.length > 2) { + if (coreBootstrapFails) { + if (errback) errback(new Error("Core bootstrap error")); + } else { + window.createDefaultStack = jest.fn(); + window.Logo = jest.fn(); + window.Blocks = jest.fn(); + window.Turtles = jest.fn(); + if (callback) callback(); } - } else if (deps.includes("easeljs.min")) { - // Phase 1 bootstrap - // Mock globals expected by verification - window.createDefaultStack = jest.fn(); - window.Logo = jest.fn(); - window.Blocks = jest.fn(); - window.Turtles = jest.fn(); - if (callback) callback(); - } else if (deps.includes("activity/activity")) { - // Phase 2 bootstrap - if (callback) callback(); + return null; } + + // ── activity/activity phase-2 ────────────────────────────────── + if (deps.includes("activity/activity")) { + if (activityFails) { + if (errback) errback(new Error("Activity load error")); + } else { + if (callback) callback(); + } + return null; + } + + if (callback) callback(); return null; }); require("../loader.js"); + await new Promise(resolve => setTimeout(resolve, waitMs)); + }; - await new Promise(resolve => setTimeout(resolve, 200)); // More time - }; // Allow async main() to proceed + // ─── requirejs.config ───────────────────────────────────────────────────── test("Configures requirejs correctly", async () => { await loadScript(); - expect(mockRequireJSConfig).toHaveBeenCalledWith( + expect(_mockImpl.config).toHaveBeenCalledWith( expect.objectContaining({ baseUrl: "./", paths: expect.any(Object), @@ -98,12 +212,41 @@ describe("loader.js coverage", () => { ); }); - test("Full success path: initializes i18n, updates DOM, and loads app", async () => { - Object.defineProperty(document, "readyState", { - value: "complete", - configurable: true + test("requirejs paths config includes all expected module paths", async () => { + await loadScript(); + const config = _mockImpl.config.mock.calls[0][0]; + expect(config.paths).toMatchObject({ + utils: "js/utils", + widgets: "js/widgets", + activity: "js", + Tone: "lib/Tone" }); + }); + + test("requirejs shim config wires activity/activity with correct deps", async () => { + await loadScript(); + const config = _mockImpl.config.mock.calls[0][0]; + expect(config.shim["activity/activity"].deps).toEqual( + expect.arrayContaining([ + "utils/utils", + "activity/logo", + "activity/blocks", + "activity/turtles" + ]) + ); + }); + + // ─── Global flags ───────────────────────────────────────────────────────── + + test("sets _THIS_IS_MUSIC_BLOCKS_ and _THIS_IS_TURTLE_BLOCKS_ globals", async () => { + await loadScript(); + expect(window._THIS_IS_MUSIC_BLOCKS_).toBe(true); + expect(window._THIS_IS_TURTLE_BLOCKS_).toBe(false); + }); + // ─── Full success path ──────────────────────────────────────────────────── + + test("Full success path: initializes i18n, updates DOM, and loads app", async () => { await loadScript(); expect(mockI18next.use).toHaveBeenCalledWith(mockI18nextHttpBackend); @@ -112,74 +255,238 @@ describe("loader.js coverage", () => { expect.any(Function) ); expect(window.i18next).toBe(mockI18next); - expect(mockI18next.changeLanguage).toHaveBeenCalledWith("en", expect.any(Function)); const title = document.querySelector('[data-i18n="title"]'); const label = document.querySelector('[data-i18n="label"]'); - expect(mockI18next.t).toHaveBeenCalledWith("title"); expect(mockI18next.t).toHaveBeenCalledWith("label"); expect(title.textContent).toBe("TRANSLATED_title"); expect(label.textContent).toBe("TRANSLATED_label"); expect(mockI18next.on).toHaveBeenCalledWith("languageChanged", expect.any(Function)); - - // Verify Phase 2 was reached - expect(mockRequireJS).toHaveBeenCalledWith( + expect(_mockImpl).toHaveBeenCalledWith( ["activity/activity"], expect.any(Function), expect.any(Function) ); }); + // ─── i18next init error ─────────────────────────────────────────────────── + test("Handles i18next initialization error", async () => { await loadScript({ initError: true }); - expect(consoleErrorSpy).toHaveBeenCalledWith("i18next init failed:", "Init Failed"); expect(window.i18next).toBe(mockI18next); }); + test("assigns i18next to window even when init errors", async () => { + await loadScript({ initError: true }); + expect(window.i18next).toBe(mockI18next); + }); + + // ─── changeLanguage error ───────────────────────────────────────────────── + test("Handles changeLanguage error", async () => { await loadScript({ langError: true }); - expect(consoleErrorSpy).toHaveBeenCalledWith( "Error changing language:", "Lang Change Failed" ); }); - test("Handles DOMContentLoaded when document is loading", async () => { - Object.defineProperty(document, "readyState", { - value: "loading", - configurable: true - }); + // ─── DOMContentLoaded when document is still loading ───────────────────── + test("Handles DOMContentLoaded when document is loading", async () => { const addEventListenerSpy = jest.spyOn(document, "addEventListener"); - await loadScript(); + await loadScript({ readyState: "loading" }); expect(addEventListenerSpy).toHaveBeenCalledWith("DOMContentLoaded", expect.any(Function)); - const eventHandler = addEventListenerSpy.mock.calls.find( - call => call[0] === "DOMContentLoaded" - )[1]; + const handler = addEventListenerSpy.mock.calls.find(c => c[0] === "DOMContentLoaded")?.[1]; + expect(handler).toBeDefined(); mockI18next.t.mockClear(); - eventHandler(); + handler(); expect(mockI18next.t).toHaveBeenCalled(); }); + // ─── languageChanged event ──────────────────────────────────────────────── + test("Triggering languageChanged event updates content", async () => { await loadScript(); - const onCall = mockI18next.on.mock.calls.find(call => call[0] === "languageChanged"); - const onHandler = onCall[1]; + const onCall = mockI18next.on.mock.calls.find(c => c[0] === "languageChanged"); + const onHandler = onCall?.[1]; + expect(onHandler).toBeDefined(); mockI18next.t.mockClear(); - onHandler(); - expect(mockI18next.t).toHaveBeenCalled(); }); + + // ─── highlight.js ───────────────────────────────────────── + + test("highlight.js: registers on window and calls highlightAll when resolved", async () => { + await loadScript({ hljsAvailable: true }); + expect(window.hljs).toBeDefined(); + expect(window.hljs.highlightAll).toHaveBeenCalled(); + }); + + test("highlight.js: skips window registration when callback receives null", async () => { + await loadScript({ hljsAvailable: false }); + expect(window.hljs).toBeUndefined(); + }); + + test("logs a warning when highlight.js fails to load", async () => { + await loadScript({ hljsFails: true }); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Highlight.js failed to load"), + expect.any(Error) + ); + }); + + // ─── updateContent early-return ──────────────────────────── + + test("updateContent does nothing when i18next.isInitialized is false", async () => { + await loadScript({ i18nNotInitialized: true }); + const title = document.querySelector('[data-i18n="title"]'); + expect(title.textContent).toBe("Original Title"); + }); + + // ───M.AutoInit ───────────────────────────────────────────────── + + test("calls M.AutoInit when Materialize is available", async () => { + await loadScript({ materializeAutoInit: true }); + expect(global.window.M.AutoInit).toHaveBeenCalled(); + }); + + test("aliases window.Materialize to M when M is defined", async () => { + await loadScript({ materializeAutoInit: true }); + expect(global.window.Materialize).toBe(global.window.M); + }); + + // ─── waitForGlobals retry loop ───────────────────────────── + + test("waitForGlobals retries until createjs becomes available", async () => { + delete global.window.createjs; + let ticks = 0; + const realSetTimeout = global.setTimeout; + + jest.spyOn(global, "setTimeout").mockImplementation((fn, delay) => { + ticks++; + if (ticks === 1) { + // Restore createjs so the retry exits on the next iteration + global.window.createjs = { Stage: jest.fn() }; + } + return realSetTimeout(fn, 0); + }); + + await loadScript({ waitMs: 600 }); + + // The fatal EaselJS alert must NOT have fired + expect(alertSpy).not.toHaveBeenCalledWith( + expect.stringContaining("Failed to load EaselJS") + ); + }); + + // ─── define() for PRELOADED_SCRIPTS ──────────────────────────── + + test("calls define() for PRELOADED_SCRIPTS when requirejs.defined returns false", async () => { + await loadScript({ preloadedDefined: false }); + expect(global.define).toHaveBeenCalledWith( + expect.stringMatching(/easeljs\.min|tweenjs\.min/), + [], + expect.any(Function) + ); + }); + + test("skips define() for PRELOADED_SCRIPTS when requirejs.defined returns true", async () => { + await loadScript({ preloadedDefined: true }); + expect(global.define).not.toHaveBeenCalled(); + }); + + // ─── fatal — createjs missing after core bootstrap ───────── + // + // The verification block lives inside setTimeout(fn, 100) which is reached + // after an async waitForGlobals() chain. Because waitForGlobals uses + // `await new Promise(resolve => setTimeout(resolve, 100))`, simply calling + // jest.runAllTimers() is not enough — the Promise microtask queue must also + // be drained between timer ticks. jest.runAllTimersAsync() handles both. + + test("logs fatal error and alerts when createjs is missing after core bootstrap", async () => { + // Install fake timers before require() so every setTimeout inside + // loader.js is captured under Jest's fake clock. + jest.useFakeTimers(); + + delete global.window.createjs; + _mockImpl.defined = jest.fn(() => false); + + _mockImpl.mockImplementation((deps, callback, errback) => { + if (!Array.isArray(deps)) return null; + if (deps.includes("highlight")) { + if (callback) callback(null); + return null; + } + if (deps.includes("i18next")) { + mockI18next.init.mockImplementation((_, cb) => { + mockI18next.isInitialized = true; + cb(null); + }); + mockI18next.changeLanguage.mockImplementation((_, cb) => cb(null)); + if (callback) callback(mockI18next, mockI18nextHttpBackend); + return null; + } + // CORE_BOOTSTRAP_MODULES — createjs is intentionally absent. + // Firing the outer callback queues the inner setTimeout(fn, 100) + // into fake-timer land. + if (deps.includes("easeljs.min") && deps.length > 2) { + if (callback) callback(); + return null; + } + if (callback) callback(); + return null; + }); + + require("../loader.js"); + + // runAllTimersAsync() advances the fake clock AND drains Promise + // microtasks between each tick, so the full async chain completes: + // waitForGlobals retries → outer setTimeout → inner setTimeout(100ms) + // → verification block → console.error + alert + await jest.runAllTimersAsync(); + + // Restore real timers for the rest of the test suite + jest.useRealTimers(); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("FATAL: createjs")); + expect(alertSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to load EaselJS")); + }); + + // ─── activity/activity errback ───────────────────────────── + + test("logs error and alerts when activity/activity fails to load", async () => { + await loadScript({ activityFails: true }); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to load activity/activity"), + expect.any(Error) + ); + expect(alertSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to load Music Blocks") + ); + }); + + // ─── core bootstrap errback ─────────────────────────────── + + test("logs error and alerts when core bootstrap modules fail to load", async () => { + await loadScript({ coreBootstrapFails: true }); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Core bootstrap failed"), + expect.any(Error) + ); + expect(alertSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to initialize Music Blocks core") + ); + }); });