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")
+ );
+ });
});