diff --git a/eslint.config.mjs b/eslint.config.mjs index 7eb072c704..a76d11d694 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -32,7 +32,7 @@ export default defineConfig([ } }, { - files: ["**/*.js"], + files: ["**/*.js", "js/app.mjs"], languageOptions: { ecmaVersion: "latest", globals: { @@ -104,7 +104,7 @@ export default defineConfig([ } }, { - files: ["**/*.js"], + files: ["**/*.js", "js/app.mjs"], ignores: [ "clientonly/index.js", "js/logger.js", @@ -118,6 +118,7 @@ export default defineConfig([ }, { files: ["**/*.mjs"], + ignores: ["js/app.mjs"], languageOptions: { ecmaVersion: "latest", globals: { diff --git a/js/app.js b/js/app.mjs similarity index 90% rename from js/app.js rename to js/app.mjs index 1c0aceacf4..28484db172 100644 --- a/js/app.js +++ b/js/app.mjs @@ -1,47 +1,44 @@ -// Load lightweight internal alias resolver -require("./alias-resolver"); - -const fs = require("node:fs"); -const path = require("node:path"); -const Spawn = require("node:child_process").spawn; -const Log = require("logger"); +import "./alias-resolver.js"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import fs from "node:fs"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { Agent, setGlobalDispatcher } from "undici"; +import packageJson from "../package.json" with { type: "json" }; +import Log from "./logger.js"; +import Server from "./server.js"; +import Utils from "./utils.js"; +import serverFunctions from "#server_functions"; + +const require = createRequire(import.meta.url); +const { ConfigError } = Utils; +const { getEnvVarsAsObj } = serverFunctions; // global absolute root path -global.root_path = path.resolve(`${__dirname}/../`); - -// used to control fetch timeout for node_helpers -const { setGlobalDispatcher, Agent } = require("undici"); +global.root_path = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const Server = require("./server"); -const Utils = require("./utils"); -const { ConfigError } = require("./utils"); - -const { getEnvVarsAsObj } = require("#server_functions"); // common timeout value, provide environment override in case const fetch_timeout = process.env.mmFetchTimeout !== undefined ? process.env.mmFetchTimeout : 30000; // Get version number. -global.version = require(`${global.root_path}/package.json`).version; +global.version = packageJson.version; global.mmTestMode = process.env.mmTestMode === "true"; Log.log(`Starting MagicMirror: v${global.version}`); // Log system information in a subprocess so it is shown even on early startup failures. -Spawn("node ./js/systeminformation.js", { +spawn("node ./js/systeminformation.js", { env: { ...process.env, ELECTRON_VERSION: `${process.versions.electron}`, USED_NODE_VERSION: `${process.versions.node}` }, - cwd: this.root_path, + cwd: global.root_path, shell: true, detached: true, stdio: "inherit" }); -if (process.env.MM_CONFIG_FILE) { - global.configuration_file = process.env.MM_CONFIG_FILE.replace(`${global.root_path}/`, ""); -} - // The next part is here to prevent a major exception when there // is no internet connection. This could probable be solved better. process.on("uncaughtException", (err) => { @@ -213,6 +210,10 @@ class App { */ async start () { try { + if (process.env.MM_CONFIG_FILE) { + global.configuration_file = process.env.MM_CONFIG_FILE.replace(`${global.root_path}/`, ""); + } + const configObj = Utils.loadConfig(); global.config = configObj.fullConf; // Keep a copy of the redacted config to later verify module secret permissions @@ -334,6 +335,7 @@ class App { }); Log.log("Node_helpers stopped ..."); + this.#nodeHelpers = []; // To be able to stop the app even if it hasn't been started (when // running with Electron against another server) @@ -341,8 +343,10 @@ class App { return Promise.resolve(); } - return this.#httpServer.close(); + const result = await this.#httpServer.close(); + this.#httpServer = null; + return result; } } -module.exports = new App(); +export default new App(); diff --git a/js/electron.js b/js/electron.js index bb6a61abe2..9b88d039f3 100644 --- a/js/electron.js +++ b/js/electron.js @@ -1,11 +1,14 @@ "use strict"; +require("./alias-resolver"); + const electron = require("electron"); -const core = require("./app"); const Log = require("./logger"); const { applyElectronSwitches } = require("./electron_helper"); const { getServerPort } = require("#server_functions"); +const corePromise = import("./app.mjs").then(({ default: core }) => core); + // Config let config = process.env.config ? JSON.parse(process.env.config) : {}; // Module to control application life. @@ -173,7 +176,7 @@ app.on("activate", () => { * Added to fix #1056 (no method to gracefully shut down modules on exit). * * Note: this is only used if running Electron. Otherwise - * core.stop() is called by process.on("SIGINT"... in `app.js` + * core.stop() is called by process.on("SIGINT"... in `app.mjs` */ app.on("before-quit", async (event) => { Log.log("Shutting down server..."); @@ -181,6 +184,7 @@ app.on("before-quit", async (event) => { setTimeout(() => { process.exit(0); }, 3000); // Force-quit after 3 seconds. + const core = await corePromise; await core.stop(); process.exit(0); }); @@ -197,6 +201,8 @@ app.on("certificate-error", (event, webContents, url, error, certificate, callba * Bootstrap Electron: launch the client-only viewer and/or the full core application. */ const bootstrapElectron = async () => { + const core = await corePromise; + if (process.env.clientonly) { await app.whenReady(); Log.log("Launching client viewer application."); diff --git a/js/logger.js b/js/logger.js index f30196bc5d..30a423e378 100644 --- a/js/logger.js +++ b/js/logger.js @@ -76,11 +76,11 @@ if (typeof module !== "undefined") { const lines = new Error().stack.split("\n"); for (const line of lines) { if (line.includes("node:") || line.includes("js/logger.js") || line.includes("node_modules")) continue; - const match = line.match(/\((.+?\.js):\d+:\d+\)/) || line.match(/at\s+(.+?\.js):\d+:\d+/); + const match = line.match(/\((.+?\.[cm]?js):\d+:\d+\)/) || line.match(/at\s+(.+?\.[cm]?js):\d+:\d+/); if (match) { const file = match[1]; - const baseName = file.replace(/.*\/(.*)\.js/, "$1"); - const parentDir = file.replace(/.*\/(.*)\/.*\.js/, "$1"); + const baseName = file.replace(/.*\/(.*)\.[cm]?js/, "$1"); + const parentDir = file.replace(/.*\/(.*)\/.*\.[cm]?js/, "$1"); return styleText("gray", parentDir === "js" ? `[${baseName}]` : `[${parentDir}]`); } } diff --git a/js/systeminformation.js b/js/systeminformation.js index 24245cbe3b..84fed93e5a 100644 --- a/js/systeminformation.js +++ b/js/systeminformation.js @@ -1,7 +1,7 @@ const os = require("node:os"); const { execFileSync } = require("node:child_process"); const si = require("systeminformation"); -// needed with relative path because logSystemInformation is called in an own process in app.js: +// needed with relative path because logSystemInformation is called in an own process in app.mjs: const mmVersion = require("../package").version; const Log = require("./logger"); diff --git a/serveronly/index.js b/serveronly/index.js index bd1470e2a5..7839aeeaf6 100644 --- a/serveronly/index.js +++ b/serveronly/index.js @@ -1,9 +1,15 @@ -const app = require("../js/app"); +require("../js/alias-resolver"); + const Log = require("../js/logger"); const { getServerPort } = require("#server_functions"); -app.start().then((config) => { + +const startServer = async () => { + const { default: app } = await import("../js/app.mjs"); + const config = await app.start(); const bindAddress = config.address ? config.address : "localhost"; const httpType = config.useHttps ? "https" : "http"; Log.info(`\n>>> Ready to go! Please point your browser to: ${httpType}://${bindAddress}:${getServerPort(config)} <<<`); -}); +}; + +startServer(); diff --git a/tests/e2e/app_spec.js b/tests/e2e/app_spec.js new file mode 100644 index 0000000000..7981453eeb --- /dev/null +++ b/tests/e2e/app_spec.js @@ -0,0 +1,32 @@ +const NodeHelper = require("../../js/node_helper"); +const helpers = require("./helpers/global-setup"); + +describe("App restart lifecycle", () => { + afterEach(async () => { + await helpers.stopApplication(0); + }); + + it("reloads configuration without retaining stopped helpers or adding signal handlers", async () => { + const startHelper = vi.spyOn(NodeHelper.prototype, "start"); + const stopHelper = vi.spyOn(NodeHelper.prototype, "stop"); + await helpers.startApplication("tests/configs/esm_helper.js"); + const app = global.app; + const sigintListeners = process.listenerCount("SIGINT"); + const sigtermListeners = process.listenerCount("SIGTERM"); + expect(startHelper).toHaveBeenCalledTimes(1); + expect(global.config.modules[0].module).toBe("esmHelper"); + + await helpers.startApplication("tests/configs/modules/clock/clock_24hr.js"); + expect(global.app).toBe(app); + expect(global.configuration_file).toBe("tests/configs/modules/clock/clock_24hr.js"); + expect(global.config.modules[0].module).toBe("clock"); + expect(startHelper).toHaveBeenCalledTimes(1); + expect(stopHelper).toHaveBeenCalledTimes(1); + expect(process.listenerCount("SIGINT")).toBe(sigintListeners); + expect(process.listenerCount("SIGTERM")).toBe(sigtermListeners); + + await app.stop(); + await app.stop(); + expect(stopHelper).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/e2e/helpers/global-setup.js b/tests/e2e/helpers/global-setup.js index 07f7aa91b7..8c2d4c158c 100644 --- a/tests/e2e/helpers/global-setup.js +++ b/tests/e2e/helpers/global-setup.js @@ -91,12 +91,11 @@ exports.getPage = () => { exports.startApplication = async (configFilename) => { vi.resetModules(); - // Clear Node's require cache for config and app files to prevent stale configs and middlewares + // Clear CommonJS config and server caches; the ESM app is reused across starts. Object.keys(require.cache).forEach((key) => { if ( key.includes("/tests/configs/") || key.includes("/config/config") - || key.includes("/js/app.js") || key.includes("/js/server.js") ) { delete require.cache[key]; @@ -126,7 +125,7 @@ exports.startApplication = async (configFilename) => { process.env.mmTestMode = "true"; process.setMaxListeners(0); - global.app = require(`${global.root_path}/js/app`); + global.app = (await import(`${global.root_path}/js/app.mjs`)).default; return global.app.start(); }; diff --git a/tests/unit/functions/logger_spec.js b/tests/unit/functions/logger_spec.js new file mode 100644 index 0000000000..cbbc969d41 --- /dev/null +++ b/tests/unit/functions/logger_spec.js @@ -0,0 +1,24 @@ +const { execFileSync } = require("node:child_process"); +const path = require("node:path"); + +describe("logger caller prefixes", () => { + const loggerPath = path.resolve(__dirname, "../../../js/logger.js"); + + it.each(["js", "mjs", "cjs"])("recognizes .%s callers", (extension) => { + const script = ` + require(${JSON.stringify(loggerPath)}); + const vm = require("node:vm"); + vm.runInThisContext('console.log("top-level");', {filename: "file:///MagicMirror/js/probe.${extension}"}); + vm.runInThisContext('(() => console.log("function"))();', {filename: "file:///MagicMirror/js/probe.${extension}"}); + vm.runInThisContext('console.log("module");', {filename: "file:///MagicMirror/modules/MMM-test/node_helper.${extension}"}); + `; + const output = execFileSync(process.execPath, ["-e", script], { + env: { ...process.env, mmTestMode: "false", FORCE_COLOR: "0" }, + encoding: "utf8" + }); + + expect(output).toContain("[probe] top-level"); + expect(output).toContain("[probe] function"); + expect(output).toContain("[MMM-test] module"); + }); +}); diff --git a/tests/unit/global_vars/root_path_spec.js b/tests/unit/global_vars/root_path_spec.js index dcba2bf593..baa00bed81 100644 --- a/tests/unit/global_vars/root_path_spec.js +++ b/tests/unit/global_vars/root_path_spec.js @@ -4,8 +4,8 @@ const path = require("node:path"); const root_path = path.join(__dirname, "../../.."); const version = require(`${root_path}/package.json`).version; -describe("'global.root_path' set in js/app.js", () => { - const expectedSubPaths = ["modules", "serveronly", "js", "js/app.js", "js/main.js", "js/electron.js", "config"]; +describe("'global.root_path' set in js/app.mjs", () => { + const expectedSubPaths = ["modules", "serveronly", "js", "js/app.mjs", "js/main.js", "js/electron.js", "config"]; expectedSubPaths.forEach((subpath) => { it(`contains a file/folder "${subpath}"`, () => {