diff --git a/CHANGELOG.md b/CHANGELOG.md index f06974f635..5487818c3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -191,6 +191,7 @@ Special thanks to @khassel, @rejas and @sdetweil for taking over most (if not al - Reworked how weather-providers handle units (#2849) - Use unix() method for parsing times, fix suntimes on the way (#2950) - Refactor conversion functions into utils class (#2958) +- Use async/await for startup of the application instead of callbacks - The `cors`-method in `server.js` now supports sending and receiving HTTP headers - Replace `…` by `…` - Cleanup compliments module diff --git a/js/app.js b/js/app.js index 184940189c..eb5fa03347 100644 --- a/js/app.js +++ b/js/app.js @@ -8,7 +8,7 @@ // Alias modules mentioned in package.js under _moduleAliases. require("module-alias/register"); -const fs = require("fs"); +const fs = require("fs/promises"); const path = require("path"); const envsub = require("envsub"); const Log = require("logger"); @@ -53,7 +53,7 @@ function App() { /** * Loads the config file. Combines it with the defaults and returns the config * @async - * @returns {Promise} the loaded config or the defaults if something goes wrong + * @returns {Promise} A promise with the config that should be used */ async function loadConfig() { Log.log("Loading config ..."); @@ -66,7 +66,7 @@ function App() { // check if templateFile exists try { - fs.accessSync(templateFile, fs.F_OK); + await fs.access(configFilename, fs.F_OK); } catch (err) { templateFile = null; Log.debug("config template file not exists, no envsubst"); @@ -148,8 +148,9 @@ function App() { /** * Loads a specific module. * @param {string} module The name of the module (including subpath). + * @returns {Promise} A promise that resolves as soon as the module is loaded. */ - function loadModule(module) { + async function loadModule(module) { const elements = module.split("/"); const moduleName = elements[elements.length - 1]; let moduleFolder = `${__dirname}/../modules/${module}`; @@ -158,25 +159,9 @@ function App() { moduleFolder = `${__dirname}/../modules/default/${module}`; } - const moduleFile = `${moduleFolder}/${module}.js`; + const helperPath = await resolveHelperPath(moduleFolder); - try { - fs.accessSync(moduleFile, fs.R_OK); - } catch (e) { - Log.warn(`No ${moduleFile} found for module: ${moduleName}.`); - } - - const helperPath = `${moduleFolder}/node_helper.js`; - - let loadHelper = true; - try { - fs.accessSync(helperPath, fs.R_OK); - } catch (e) { - loadHelper = false; - Log.log(`No helper found for module: ${moduleName}.`); - } - - if (loadHelper) { + if (helperPath) { const Module = require(helperPath); let m = new Module(); @@ -194,7 +179,12 @@ function App() { m.setPath(path.resolve(moduleFolder)); nodeHelpers.push(m); - m.loaded(); + return new Promise((resolve, reject) => { + m.loaded(resolve); + }); + } else { + Log.log(`No helper found for module: ${moduleName}.`); + return Promise.resolve(); } } @@ -236,12 +226,30 @@ function App() { return segmentsA.length - segmentsB.length; } + /** + * Resolves the path to the node_helper + * + * @param {string} moduleFolder the folder that should contain the node_helper + * @returns {Promise} A promise with the path to the node_helper that should be used, or undefined if none exists + */ + async function resolveHelperPath(moduleFolder) { + const helperPath = `${moduleFolder}/node_helper.js`; + + try { + await fs.access(helperPath, fs.R_OK); + return helperPath; + } catch (e) { + // The current extension may not have been found, try the next instead + return undefined; + } + } + /** * Start the core app. * * It loads the config, then it loads all modules. * @async - * @returns {Promise} the config used + * @returns {Promise} A promise containing the config, it is resolved when the server has loaded all modules and are listening for requests */ this.start = async function () { config = await loadConfig(); @@ -249,6 +257,7 @@ function App() { Log.setLogLevel(config.logLevel); let modules = []; + for (const module of config.modules) { if (!modules.includes(module.module) && !module.disabled) { modules.push(module.module); @@ -274,8 +283,7 @@ function App() { } } - const results = await Promise.allSettled(nodePromises); - + let results = await Promise.allSettled(nodePromises); // Log errors that happened during async node_helper startup results.forEach((result) => { if (result.status === "rejected") { @@ -284,7 +292,6 @@ function App() { }); Log.log("Sockets connected & modules started ..."); - return config; }; @@ -296,37 +303,30 @@ function App() { * @returns {Promise} A promise that is resolved when all node_helpers and * the http server has been closed */ - this.stop = async function () { - const nodePromises = []; - for (let nodeHelper of nodeHelpers) { - try { - if (typeof nodeHelper.stop === "function") { - nodePromises.push(nodeHelper.stop()); - } - } catch (error) { - Log.error(`Error when stopping node_helper for module ${nodeHelper.name}:`); - console.error(error); + this.stop = async function (timeout) { + for (const nodeHelper of nodeHelpers) { + if (typeof nodeHelper.stop === "function") { + nodeHelper.stop(); } } - const results = await Promise.allSettled(nodePromises); - - // Log errors that happened during async node_helper stopping - results.forEach((result) => { - if (result.status === "rejected") { - Log.error(result.reason); - } - }); - - Log.log("Node_helpers stopped ..."); - - // To be able to stop the app even if it hasn't been started (when - // running with Electron against another server) + // To be able to stop the app even if it hasn't been started (when running with Electron against another server) if (!httpServer) { return Promise.resolve(); } - return httpServer.close(); + let serverClosePromise = httpServer.close(); + + // If a timeout is set, resolve when the server is closed or the timeout has been reached + if (timeout) { + let timeoutPromise = new Promise((resolve) => { + setTimeout(resolve, timeout); + }); + + return Promise.race([serverClosePromise, timeoutPromise]); + } else { + return serverClosePromise; + } }; /** @@ -338,10 +338,7 @@ function App() { */ process.on("SIGINT", async () => { Log.log("[SIGINT] Received. Shutting down server..."); - setTimeout(() => { - process.exit(0); - }, 3000); // Force quit after 3 seconds - await this.stop(); + await this.stop(3000); // Force quit after 3 seconds process.exit(0); }); @@ -351,10 +348,7 @@ function App() { */ process.on("SIGTERM", async () => { Log.log("[SIGTERM] Received. Shutting down server..."); - setTimeout(() => { - process.exit(0); - }, 3000); // Force quit after 3 seconds - await this.stop(); + await this.stop(3000); // Force quit after 3 seconds process.exit(0); }); } diff --git a/js/electron.js b/js/electron.js index 43f637acbb..7c9736399d 100644 --- a/js/electron.js +++ b/js/electron.js @@ -5,7 +5,7 @@ const core = require("./app"); const Log = require("./logger"); // Config -let config = process.env.config ? JSON.parse(process.env.config) : {}; +let config; // Module to control application life. const app = electron.app; // If ELECTRON_DISABLE_GPU is set electron is started with --disable-gpu flag. @@ -21,6 +21,19 @@ const BrowserWindow = electron.BrowserWindow; // be closed automatically when the JavaScript object is garbage collected. let mainWindow; +/** + * Start the core application if server is run on localhost + * This starts all node helpers and starts the webserver. + * + * @returns {Promise} A promise that is resolved when the server has started + */ +async function startAppIfNeeded() { + let localConfig = process.env.config ? JSON.parse(process.env.config) : {}; + if (["localhost", "127.0.0.1", "::1", "::ffff:127.0.0.1", undefined].includes(localConfig.address)) { + config = await core.start(); + } +} + /** * */ @@ -162,10 +175,7 @@ app.on("activate", function () { app.on("before-quit", async (event) => { Log.log("Shutting down server..."); event.preventDefault(); - setTimeout(() => { - process.exit(0); - }, 3000); // Force-quit after 3 seconds. - await core.stop(); + await core.stop(3000); // Force-quit after 3 seconds. process.exit(0); }); diff --git a/tests/e2e/helpers/global-setup.js b/tests/e2e/helpers/global-setup.js index d5506024af..c60e0d60ef 100644 --- a/tests/e2e/helpers/global-setup.js +++ b/tests/e2e/helpers/global-setup.js @@ -14,15 +14,14 @@ exports.startApplication = async (configFilename, exec) => { if (exec) exec; global.app = require("../../../js/app"); - return global.app.start(); + await global.app.start(); }; exports.stopApplication = async () => { - if (!global.app) { - return Promise.resolve(); + if (global.app) { + await global.app.stop(); + delete global.app; } - await global.app.stop(); - delete global.app; }; exports.getDocument = () => { diff --git a/tests/electron/env_spec.js b/tests/electron/env_spec.js index 32b9f6fc3d..c519801158 100644 --- a/tests/electron/env_spec.js +++ b/tests/electron/env_spec.js @@ -19,7 +19,7 @@ describe("Electron app environment", () => { describe("Development console tests", () => { beforeEach(async () => { - await helpers.startApplication("tests/configs/modules/display.js", null, ["js/electron.js", "dev"]); + await helpers.startApplication("tests/configs/modules/display.js", ["js/electron.js", "dev"]); }); afterEach(async () => { @@ -27,10 +27,11 @@ describe("Development console tests", () => { }); it("should open browserwindow and dev console", async () => { - while (global.electronApp.windows().length < 2) await events.once(global.electronApp, "window"); - const pageArray = await global.electronApp.windows(); - expect(pageArray.length).toBe(2); - for (const page of pageArray) { + while (global.electronApp.windows().length < 2) { + await global.electronApp.waitForEvent("window"); + } + + for (let page of global.electronApp.windows()) { expect(["MagicMirror²", "DevTools"]).toContain(await page.title()); } }); diff --git a/tests/electron/helpers/global-setup.js b/tests/electron/helpers/global-setup.js index f6e071be72..f9e99b71c9 100644 --- a/tests/electron/helpers/global-setup.js +++ b/tests/electron/helpers/global-setup.js @@ -3,43 +3,62 @@ // https://www.anycodings.com/1questions/958135/can-i-set-the-date-for-playwright-browser const { _electron: electron } = require("playwright"); -exports.startApplication = async (configFilename, systemDate = null, electronParams = ["js/electron.js"]) => { - global.electronApp = null; - global.page = null; +function applyMocks(page) { + if (global.mocks) { + for (let mock of global.mocks) { + mock(page); + } + } +} + +exports.startApplication = async (configFilename, electronParams = ["js/electron.js"]) => { + await this.stopApplication(); process.env.MM_CONFIG_FILE = configFilename; process.env.TZ = "GMT"; global.electronApp = await electron.launch({ args: electronParams }); - await global.electronApp.firstWindow(); - - for (const win of global.electronApp.windows()) { - const title = await win.title(); - expect(["MagicMirror²", "DevTools"]).toContain(title); - if (title === "MagicMirror²") { - global.page = win; - if (systemDate) { - await global.page.evaluate((systemDate) => { - Date.now = () => { - return new Date(systemDate); - }; - }, systemDate); - } - } + //Make sure new open windows gets mocked too + global.electronApp.on("window", applyMocks); + + //Apply mocks for all existing pages + for (let page of global.electronApp.windows()) { + applyMocks(page); } + + //We only need the first window for the majority of the tests + global.page = await global.electronApp.firstWindow(); + + //Wait for the body element to be visible + await global.page.waitForSelector("body"); }; exports.stopApplication = async () => { if (global.electronApp) { await global.electronApp.close(); + global.electronApp = null; + global.page = null; + global.mocks = null; } - global.electronApp = null; - global.page = null; }; exports.getElement = async (selector) => { - expect(global.page); + expect(global.page).not.toBe(null); let elem = global.page.locator(selector); await elem.waitFor(); expect(elem).not.toBe(null); return elem; }; + +exports.mockSystemDate = (mockedSystemDate) => { + if (!global.mocks) { + global.mocks = []; + } + + global.mocks.push(async (page) => { + await page.evaluate((systemDate) => { + Date.now = () => { + return new Date(systemDate); + }; + }, mockedSystemDate); + }); +}; diff --git a/tests/electron/helpers/weather-setup.js b/tests/electron/helpers/weather-setup.js index 4dd3cdb2a6..3a4cc4c3e2 100644 --- a/tests/electron/helpers/weather-setup.js +++ b/tests/electron/helpers/weather-setup.js @@ -15,5 +15,6 @@ exports.getText = async (element, result) => { exports.startApp = async (configFileNameName, systemDate) => { injectMockData(configFileNameName); - await helpers.startApplication("", systemDate); + helpers.mockSystemDate(systemDate); + await helpers.startApplication(""); }; diff --git a/tests/electron/modules/calendar_spec.js b/tests/electron/modules/calendar_spec.js index b0b7c27679..9fa017e8b6 100644 --- a/tests/electron/modules/calendar_spec.js +++ b/tests/electron/modules/calendar_spec.js @@ -26,12 +26,14 @@ describe("Calendar module", () => { }); it("has css class today", async () => { - await helpers.startApplication("tests/configs/modules/calendar/custom.js", "01 Jan 2030 12:30:00 GMT"); + helpers.mockSystemDate("01 Jan 2030 12:30:00 GMT"); + await helpers.startApplication("tests/configs/modules/calendar/custom.js"); await doTest(".today"); }); it("has css class tomorrow", async () => { - await helpers.startApplication("tests/configs/modules/calendar/custom.js", "31 Dec 2029 12:30:00 GMT"); + helpers.mockSystemDate("31 Dec 2029 12:30:00 GMT"); + await helpers.startApplication("tests/configs/modules/calendar/custom.js"); await doTest(".tomorrow"); }); diff --git a/tests/electron/modules/compliments_spec.js b/tests/electron/modules/compliments_spec.js index a6dd384e70..758d95baef 100644 --- a/tests/electron/modules/compliments_spec.js +++ b/tests/electron/modules/compliments_spec.js @@ -18,17 +18,20 @@ describe("Compliments module", () => { describe("parts of days", () => { it("Morning compliments for that part of day", async () => { - await helpers.startApplication("tests/configs/modules/compliments/compliments_parts_day.js", "01 Oct 2022 10:00:00 GMT"); + helpers.mockSystemDate("01 Oct 2022 10:00:00 GMT"); + await helpers.startApplication("tests/configs/modules/compliments/compliments_parts_day.js"); await doTest(["Hi", "Good Morning", "Morning test"]); }); it("Afternoon show Compliments for that part of day", async () => { - await helpers.startApplication("tests/configs/modules/compliments/compliments_parts_day.js", "01 Oct 2022 15:00:00 GMT"); + helpers.mockSystemDate("01 Oct 2022 15:00:00 GMT"); + await helpers.startApplication("tests/configs/modules/compliments/compliments_parts_day.js"); await doTest(["Hello", "Good Afternoon", "Afternoon test"]); }); it("Evening show Compliments for that part of day", async () => { - await helpers.startApplication("tests/configs/modules/compliments/compliments_parts_day.js", "01 Oct 2022 20:00:00 GMT"); + helpers.mockSystemDate("01 Oct 2022 20:00:00 GMT"); + await helpers.startApplication("tests/configs/modules/compliments/compliments_parts_day.js"); await doTest(["Hello There", "Good Evening", "Evening test"]); }); });