Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
112 changes: 53 additions & 59 deletions js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -53,7 +53,7 @@ function App() {
/**
* Loads the config file. Combines it with the defaults and returns the config
* @async
* @returns {Promise<object>} 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 ...");
Expand All @@ -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");
Expand Down Expand Up @@ -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}`;
Expand All @@ -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();

Expand All @@ -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();
}
}

Expand Down Expand Up @@ -236,19 +226,38 @@ 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<object>} 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();

Log.setLogLevel(config.logLevel);

let modules = [];

for (const module of config.modules) {
if (!modules.includes(module.module) && !module.disabled) {
modules.push(module.module);
Expand All @@ -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") {
Expand All @@ -284,7 +292,6 @@ function App() {
});

Log.log("Sockets connected & modules started ...");

return config;
};

Expand All @@ -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;
}
};

/**
Expand All @@ -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);
});

Expand All @@ -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);
});
}
Expand Down
20 changes: 15 additions & 5 deletions js/electron.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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();
}
}

/**
*
*/
Expand Down Expand Up @@ -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);
});

Expand Down
9 changes: 4 additions & 5 deletions tests/e2e/helpers/global-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
11 changes: 6 additions & 5 deletions tests/electron/env_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,19 @@ 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 () => {
await helpers.stopApplication();
});

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());
}
});
Expand Down
61 changes: 40 additions & 21 deletions tests/electron/helpers/global-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
};
Loading