[tests] migrate from jest to vitest (#3940)

This is a big change, but I think it's a good move, as `vitest` is much
more modern than `jest`.

I'm excited about the UI watch feature (run `npm run test:ui`), for
example - it's really helpful and saves time when debugging tests. I had
to adjust a few tests because they had time related issues, but
basically we are now testing the same things - even a bit better and
less flaky (I hope).

What do you think?
This commit is contained in:
Kristjan ESPERANTO
2025-11-03 19:47:01 +01:00
committed by GitHub
parent b542f33a0a
commit 462abf7027
30 changed files with 2370 additions and 3562 deletions

View File

@@ -20,16 +20,41 @@ var indexData = [];
var cssData = [];
exports.startApplication = async (configFilename, exec) => {
jest.resetModules();
vi.resetModules();
// Clear Node's require cache for config and app files to prevent stale configs and middlewares
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];
}
});
if (global.app) {
await this.stopApplication();
}
// Use fixed port 8080 (tests run sequentially, no conflicts)
const port = 8080;
global.testPort = port;
// Set config sample for use in test
let configPath;
if (configFilename === "") {
process.env.MM_CONFIG_FILE = "config/config.js";
configPath = "config/config.js";
} else {
process.env.MM_CONFIG_FILE = configFilename;
configPath = configFilename;
}
process.env.MM_CONFIG_FILE = configPath;
// Override port in config - MUST be set before app loads
process.env.MM_PORT = port.toString();
process.env.mmTestMode = "true";
process.setMaxListeners(0);
if (exec) exec;
@@ -38,24 +63,30 @@ exports.startApplication = async (configFilename, exec) => {
return global.app.start();
};
exports.stopApplication = async (waitTime = 10) => {
exports.stopApplication = async (waitTime = 100) => {
if (global.window) {
// no closing causes jest errors and memory leaks
// no closing causes test errors and memory leaks
global.window.close();
delete global.window;
// give above closing some extra time to finish
await new Promise((resolve) => setTimeout(resolve, waitTime));
}
if (!global.app) {
delete global.testPort;
return Promise.resolve();
}
await global.app.stop();
delete global.app;
delete global.testPort;
// Small delay to ensure clean shutdown
await new Promise((resolve) => setTimeout(resolve, waitTime));
};
exports.getDocument = () => {
return new Promise((resolve) => {
const url = `http://${config.address || "localhost"}:${config.port || "8080"}`;
const port = global.testPort || config.port || 8080;
const url = `http://${config.address || "localhost"}:${port}`;
jsdom.JSDOM.fromURL(url, { resources: "usable", runScripts: "dangerously" }).then((dom) => {
dom.window.name = "jsdom";
global.window = dom.window;

View File

@@ -10,7 +10,8 @@ describe("ipWhitelist directive configuration", () => {
});
it("should reject request with 403 (Forbidden)", async () => {
const res = await fetch("http://localhost:8181");
const port = global.testPort || 8080;
const res = await fetch(`http://localhost:${port}`);
expect(res.status).toBe(403);
});
});
@@ -24,7 +25,8 @@ describe("ipWhitelist directive configuration", () => {
});
it("should allow request with 200 (OK)", async () => {
const res = await fetch("http://localhost:8282");
const port = global.testPort || 8080;
const res = await fetch(`http://localhost:${port}`);
expect(res.status).toBe(200);
});
});

View File

@@ -10,7 +10,8 @@ describe("port directive configuration", () => {
});
it("should return 200", async () => {
const res = await fetch("http://localhost:8090");
const port = global.testPort || 8080;
const res = await fetch(`http://localhost:${port}`);
expect(res.status).toBe(200);
});
});
@@ -24,7 +25,8 @@ describe("port directive configuration", () => {
});
it("should return 200", async () => {
const res = await fetch("http://localhost:8100");
const port = global.testPort || 8080;
const res = await fetch(`http://localhost:${port}`);
expect(res.status).toBe(200);
});
});

View File

@@ -4,14 +4,18 @@ const delay = (time) => {
const runConfigCheck = async () => {
const serverProcess = await require("node:child_process").spawnSync("node", ["--run", "config:check"], { env: process.env });
expect(serverProcess.stderr.toString()).toBe("");
return await serverProcess.status;
};
describe("App environment", () => {
let serverProcess;
beforeAll(async () => {
// Use fixed port 8080 (tests run sequentially)
const testPort = 8080;
process.env.MM_CONFIG_FILE = "tests/configs/default.js";
process.env.MM_PORT = testPort.toString();
serverProcess = await require("node:child_process").spawn("node", ["--run", "server"], { env: process.env, detached: true });
// we have to wait until the server is started
await delay(2000);

View File

@@ -15,7 +15,8 @@ describe("templated config with port variable", () => {
});
it("should return 200", async () => {
const res = await fetch("http://localhost:8090");
const port = global.testPort || 8080;
const res = await fetch(`http://localhost:${port}`);
expect(res.status).toBe(200);
});
});

View File

@@ -14,7 +14,7 @@ function createTranslationTestEnvironment () {
const translatorJs = fs.readFileSync(path.join(__dirname, "..", "..", "js", "translator.js"), "utf-8");
const dom = new JSDOM("", { url: "http://localhost:3000", runScripts: "outside-only" });
dom.window.Log = { log: jest.fn(), error: jest.fn() };
dom.window.Log = { log: vi.fn(), error: vi.fn() };
dom.window.translations = translations;
dom.window.eval(translatorJs);
@@ -75,7 +75,7 @@ describe("translations", () => {
it("should load translation file", async () => {
const { Translator, Module, config } = dom.window;
config.language = "en";
Translator.load = jest.fn().mockImplementation((_m, _f, _fb) => null);
Translator.load = vi.fn().mockImplementation((_m, _f, _fb) => null);
Module.register("name", { getTranslations: () => translations });
const MMM = Module.create("name");
@@ -88,7 +88,7 @@ describe("translations", () => {
it("should load translation + fallback file", async () => {
const { Translator, Module } = dom.window;
Translator.load = jest.fn().mockImplementation((_m, _f, _fb) => null);
Translator.load = vi.fn().mockImplementation((_m, _f, _fb) => null);
Module.register("name", { getTranslations: () => translations });
const MMM = Module.create("name");
@@ -103,7 +103,7 @@ describe("translations", () => {
it("should load translation fallback file", async () => {
const { Translator, Module, config } = dom.window;
config.language = "--";
Translator.load = jest.fn().mockImplementation((_m, _f, _fb) => null);
Translator.load = vi.fn().mockImplementation((_m, _f, _fb) => null);
Module.register("name", { getTranslations: () => translations });
const MMM = Module.create("name");
@@ -116,7 +116,7 @@ describe("translations", () => {
it("should load no file", async () => {
const { Translator, Module } = dom.window;
Translator.load = jest.fn();
Translator.load = vi.fn();
Module.register("name", {});
const MMM = Module.create("name");

View File

@@ -20,7 +20,21 @@ exports.startApplication = async (configFilename, systemDate = null, electronPar
electronParams.unshift("js/electron.js");
}
global.electronApp = await electron.launch({ args: electronParams });
// Pass environment variables to Electron process
const env = {
...process.env,
MM_CONFIG_FILE: configFilename,
TZ: timezone,
mmTestMode: "true"
};
if (systemDate) {
env.MOCK_DATE = systemDate;
}
global.electronApp = await electron.launch({
args: electronParams,
env: env
});
await global.electronApp.firstWindow();
@@ -40,13 +54,35 @@ exports.startApplication = async (configFilename, systemDate = null, electronPar
}
};
exports.stopApplication = async () => {
if (global.electronApp) {
await global.electronApp.close();
}
exports.stopApplication = async (timeout = 10000) => {
const app = global.electronApp;
global.electronApp = null;
global.page = null;
process.env.MOCK_DATE = undefined;
if (!app) {
return;
}
const killElectron = () => {
try {
const electronProcess = typeof app.process === "function" ? app.process() : null;
if (electronProcess && !electronProcess.killed) {
electronProcess.kill("SIGKILL");
}
} catch (error) {
// Ignore errors caused by Playwright already tearing down the connection
}
};
try {
await Promise.race([
app.close(),
new Promise((_, reject) => setTimeout(() => reject(new Error("Electron close timeout")), timeout))
]);
} catch (error) {
killElectron();
}
};
exports.getElement = async (selector, state = "visible") => {

View File

@@ -0,0 +1,87 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//MagicMirror//Test Calendar//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:Duplicates Test Calendar 1
BEGIN:VEVENT
UID:duplicate-event-1@magicmirror.test
DTSTART:20240916T100000Z
DTEND:20240916T110000Z
DTSTAMP:20240915T000000Z
SUMMARY:Duplicate Event 1
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:duplicate-event-2@magicmirror.test
DTSTART:20240917T140000Z
DTEND:20240917T150000Z
DTSTAMP:20240915T000000Z
SUMMARY:Duplicate Event 2
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:duplicate-event-3@magicmirror.test
DTSTART:20240918T080000Z
DTEND:20240918T090000Z
DTSTAMP:20240915T000000Z
SUMMARY:Duplicate Event 3
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:duplicate-event-4@magicmirror.test
DTSTART:20240919T120000Z
DTEND:20240919T130000Z
DTSTAMP:20240915T000000Z
SUMMARY:Duplicate Event 4
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:duplicate-event-5@magicmirror.test
DTSTART:20240920T160000Z
DTEND:20240920T170000Z
DTSTAMP:20240915T000000Z
SUMMARY:Duplicate Event 5
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:duplicate-event-6@magicmirror.test
DTSTART:20240921T100000Z
DTEND:20240921T110000Z
DTSTAMP:20240915T000000Z
SUMMARY:Duplicate Event 6
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:duplicate-event-7@magicmirror.test
DTSTART:20240922T140000Z
DTEND:20240922T150000Z
DTSTAMP:20240915T000000Z
SUMMARY:Duplicate Event 7
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:duplicate-event-8@magicmirror.test
DTSTART:20240923T080000Z
DTEND:20240923T090000Z
DTSTAMP:20240915T000000Z
SUMMARY:Duplicate Event 8
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:duplicate-event-9@magicmirror.test
DTSTART:20240924T120000Z
DTEND:20240924T130000Z
DTSTAMP:20240915T000000Z
SUMMARY:Duplicate Event 9
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:duplicate-event-10@magicmirror.test
DTSTART:20240925T160000Z
DTEND:20240925T170000Z
DTSTAMP:20240915T000000Z
SUMMARY:Duplicate Event 10
STATUS:CONFIRMED
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,87 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//MagicMirror//Test Calendar//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:Duplicates Test Calendar 2
BEGIN:VEVENT
UID:duplicate-event-1-clone@magicmirror.test
DTSTART:20240916T100000Z
DTEND:20240916T110000Z
DTSTAMP:20240915T000000Z
SUMMARY:Duplicate Event 1
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:duplicate-event-2-clone@magicmirror.test
DTSTART:20240917T140000Z
DTEND:20240917T150000Z
DTSTAMP:20240915T000000Z
SUMMARY:Duplicate Event 2
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:duplicate-event-3-clone@magicmirror.test
DTSTART:20240918T080000Z
DTEND:20240918T090000Z
DTSTAMP:20240915T000000Z
SUMMARY:Duplicate Event 3
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:duplicate-event-4-clone@magicmirror.test
DTSTART:20240919T120000Z
DTEND:20240919T130000Z
DTSTAMP:20240915T000000Z
SUMMARY:Duplicate Event 4
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:duplicate-event-5-clone@magicmirror.test
DTSTART:20240920T160000Z
DTEND:20240920T170000Z
DTSTAMP:20240915T000000Z
SUMMARY:Duplicate Event 5
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:duplicate-event-6-clone@magicmirror.test
DTSTART:20240921T100000Z
DTEND:20240921T110000Z
DTSTAMP:20240915T000000Z
SUMMARY:Duplicate Event 6
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:duplicate-event-7-clone@magicmirror.test
DTSTART:20240922T140000Z
DTEND:20240922T150000Z
DTSTAMP:20240915T000000Z
SUMMARY:Duplicate Event 7
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:duplicate-event-8-clone@magicmirror.test
DTSTART:20240923T080000Z
DTEND:20240923T090000Z
DTSTAMP:20240915T000000Z
SUMMARY:Duplicate Event 8
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:duplicate-event-9-clone@magicmirror.test
DTSTART:20240924T120000Z
DTEND:20240924T130000Z
DTSTAMP:20240915T000000Z
SUMMARY:Duplicate Event 9
STATUS:CONFIRMED
END:VEVENT
BEGIN:VEVENT
UID:duplicate-event-10-clone@magicmirror.test
DTSTART:20240925T160000Z
DTEND:20240925T170000Z
DTSTAMP:20240915T000000Z
SUMMARY:Duplicate Event 10
STATUS:CONFIRMED
END:VEVENT
END:VCALENDAR

View File

@@ -12,7 +12,7 @@ function createTranslationTestEnvironment () {
const translatorJs = fs.readFileSync(path.join(__dirname, "..", "..", "..", "js", "translator.js"), "utf-8");
const dom = new JSDOM("", { url: "http://localhost:3001", runScripts: "outside-only" });
dom.window.Log = { log: jest.fn(), error: jest.fn() };
dom.window.Log = { log: vi.fn(), error: vi.fn() };
dom.window.eval(translatorJs);
return { window: dom.window, Translator: dom.window.Translator };

View File

@@ -1,6 +1,94 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Updatenotification custom module returns status information without hash 1`] = `
exports[`Updatenotification > MagicMirror on develop > returns status information 1`] = `
{
"behind": 5,
"current": "develop",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": false,
"module": "MagicMirror",
"tracking": "origin/develop",
}
`;
exports[`Updatenotification > MagicMirror on develop > returns status information early if isBehindInStatus 1`] = `
{
"behind": 5,
"current": "develop",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": true,
"module": "MagicMirror",
"tracking": "origin/develop",
}
`;
exports[`Updatenotification > MagicMirror on master (empty taglist) > returns status information 1`] = `
{
"behind": 5,
"current": "master",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": false,
"module": "MagicMirror",
"tracking": "origin/master",
}
`;
exports[`Updatenotification > MagicMirror on master (empty taglist) > returns status information early if isBehindInStatus 1`] = `
{
"behind": 5,
"current": "master",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": true,
"module": "MagicMirror",
"tracking": "origin/master",
}
`;
exports[`Updatenotification > MagicMirror on master with match in taglist > returns status information 1`] = `
{
"behind": 5,
"current": "master",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": false,
"module": "MagicMirror",
"tracking": "origin/master",
}
`;
exports[`Updatenotification > MagicMirror on master with match in taglist > returns status information early if isBehindInStatus 1`] = `
{
"behind": 5,
"current": "master",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": true,
"module": "MagicMirror",
"tracking": "origin/master",
}
`;
exports[`Updatenotification > MagicMirror on master without match in taglist > returns status information 1`] = `
{
"behind": 0,
"current": "master",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": false,
"module": "MagicMirror",
"tracking": "origin/master",
}
`;
exports[`Updatenotification > MagicMirror on master without match in taglist > returns status information early if isBehindInStatus 1`] = `
{
"behind": 0,
"current": "master",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": true,
"module": "MagicMirror",
"tracking": "origin/master",
}
`;
exports[`Updatenotification > custom module > returns status information without hash 1`] = `
{
"behind": 7,
"current": "master",
@@ -10,91 +98,3 @@ exports[`Updatenotification custom module returns status information without has
"tracking": "origin/master",
}
`;
exports[`Updatenotification MagicMirror on develop returns status information 1`] = `
{
"behind": 5,
"current": "develop",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": false,
"module": "MagicMirror",
"tracking": "origin/develop",
}
`;
exports[`Updatenotification MagicMirror on develop returns status information early if isBehindInStatus 1`] = `
{
"behind": 5,
"current": "develop",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": true,
"module": "MagicMirror",
"tracking": "origin/develop",
}
`;
exports[`Updatenotification MagicMirror on master (empty taglist) returns status information 1`] = `
{
"behind": 5,
"current": "master",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": false,
"module": "MagicMirror",
"tracking": "origin/master",
}
`;
exports[`Updatenotification MagicMirror on master (empty taglist) returns status information early if isBehindInStatus 1`] = `
{
"behind": 5,
"current": "master",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": true,
"module": "MagicMirror",
"tracking": "origin/master",
}
`;
exports[`Updatenotification MagicMirror on master with match in taglist returns status information 1`] = `
{
"behind": 5,
"current": "master",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": false,
"module": "MagicMirror",
"tracking": "origin/master",
}
`;
exports[`Updatenotification MagicMirror on master with match in taglist returns status information early if isBehindInStatus 1`] = `
{
"behind": 5,
"current": "master",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": true,
"module": "MagicMirror",
"tracking": "origin/master",
}
`;
exports[`Updatenotification MagicMirror on master without match in taglist returns status information 1`] = `
{
"behind": 0,
"current": "master",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": false,
"module": "MagicMirror",
"tracking": "origin/master",
}
`;
exports[`Updatenotification MagicMirror on master without match in taglist returns status information early if isBehindInStatus 1`] = `
{
"behind": 0,
"current": "master",
"hash": "332e429a41f1a2339afd4f0ae96dd125da6beada",
"isBehindInStatus": true,
"module": "MagicMirror",
"tracking": "origin/master",
}
`;

View File

@@ -1,4 +1,3 @@
const { expect } = require("playwright/test");
const { cors, getUserAgent } = require("#server_functions");
describe("server_functions tests", () => {
@@ -8,12 +7,11 @@ describe("server_functions tests", () => {
let fetchResponseHeadersText;
let corsResponse;
let request;
let fetchMock;
beforeEach(() => {
fetchResponseHeadersGet = jest.fn(() => {});
fetchResponseHeadersText = jest.fn(() => {});
fetchResponseHeadersGet = vi.fn(() => {});
fetchResponseHeadersText = vi.fn(() => {});
fetchResponse = {
headers: {
get: fetchResponseHeadersGet
@@ -21,14 +19,14 @@ describe("server_functions tests", () => {
text: fetchResponseHeadersText
};
fetch = jest.fn();
fetch = vi.fn();
fetch.mockImplementation(() => fetchResponse);
fetchMock = fetch;
corsResponse = {
set: jest.fn(() => {}),
send: jest.fn(() => {})
set: vi.fn(() => {}),
send: vi.fn(() => {})
};
request = {
@@ -77,7 +75,7 @@ describe("server_functions tests", () => {
fetchResponseHeadersText.mockImplementation(() => responseData);
let sentData;
corsResponse.send = jest.fn((input) => {
corsResponse.send = vi.fn((input) => {
sentData = input;
});
@@ -94,7 +92,7 @@ describe("server_functions tests", () => {
});
let sentData;
corsResponse.send = jest.fn((input) => {
corsResponse.send = vi.fn((input) => {
sentData = input;
});
@@ -145,17 +143,17 @@ describe("server_functions tests", () => {
});
it("Gets User-Agent from configuration", async () => {
config = {};
global.config = {};
let userAgent;
userAgent = getUserAgent();
expect(userAgent).toContain("Mozilla/5.0 (Node.js ");
config.userAgent = "Mozilla/5.0 (Foo)";
global.config.userAgent = "Mozilla/5.0 (Foo)";
userAgent = getUserAgent();
expect(userAgent).toBe("Mozilla/5.0 (Foo)");
config.userAgent = () => "Mozilla/5.0 (Bar)";
global.config.userAgent = () => "Mozilla/5.0 (Bar)";
userAgent = getUserAgent();
expect(userAgent).toBe("Mozilla/5.0 (Bar)");
});

View File

@@ -1,22 +1,36 @@
jest.mock("node:util", () => ({
...jest.requireActual("util"),
promisify: jest.fn()
}));
import { vi, describe, beforeEach, afterEach, it, expect } from "vitest";
jest.mock("node:fs", () => ({
...jest.requireActual("fs"),
statSync: jest.fn()
}));
/**
* Creates a fresh GitHelper instance with isolated mocks for each test run.
* @param {{ current: import("vitest").Mock | null }} fsStatSyncMockRef reference to the mocked fs.statSync.
* @param {{ current: { error: import("vitest").Mock; info: import("vitest").Mock } | null }} loggerMockRef reference to logger stubs.
* @param {{ current: import("vitest").MockInstance | null }} execShellSpyRef reference to the execShell spy.
* @returns {Promise<unknown>} resolved GitHelper instance.
*/
async function createGitHelper (fsStatSyncMockRef, loggerMockRef, execShellSpyRef) {
vi.resetModules();
jest.mock("logger", () => ({
...jest.requireActual("logger"),
error: jest.fn(),
info: jest.fn()
}));
fsStatSyncMockRef.current = vi.fn();
loggerMockRef.current = { error: vi.fn(), info: vi.fn() };
vi.doMock("node:fs", () => ({
statSync: fsStatSyncMockRef.current
}));
vi.doMock("logger", () => loggerMockRef.current);
const gitHelperModule = await import("../../../modules/default/updatenotification/git_helper");
const GitHelper = gitHelperModule.default || gitHelperModule;
const instance = new GitHelper();
execShellSpyRef.current = vi.spyOn(instance, "execShell");
instance.__loggerMock = loggerMockRef.current;
return instance;
}
describe("Updatenotification", () => {
const execMock = jest.fn();
const fsStatSyncMockRef = { current: null };
const loggerMockRef = { current: null };
const execShellSpyRef = { current: null };
let gitHelper;
let gitRemoteOut;
@@ -28,15 +42,13 @@ describe("Updatenotification", () => {
let gitFetchErr;
let gitTagListOut;
beforeAll(async () => {
const { promisify } = require("node:util");
promisify.mockReturnValue(execMock);
const getExecutedCommands = () => execShellSpyRef.current.mock.calls.map(([command]) => command);
const GitHelper = require("../../../modules/default/updatenotification/git_helper");
gitHelper = new GitHelper();
});
beforeEach(async () => {
gitHelper = await createGitHelper(fsStatSyncMockRef, loggerMockRef, execShellSpyRef);
fsStatSyncMockRef.current.mockReturnValue({ isDirectory: () => true });
beforeEach(() => {
gitRemoteOut = "";
gitRevParseOut = "";
gitStatusOut = "";
@@ -46,48 +58,72 @@ describe("Updatenotification", () => {
gitFetchErr = "";
gitTagListOut = "";
execMock.mockImplementation((command) => {
execShellSpyRef.current.mockImplementation((command) => {
if (command.includes("git remote -v")) {
return { stdout: gitRemoteOut };
} else if (command.includes("git rev-parse HEAD")) {
return { stdout: gitRevParseOut };
} else if (command.includes("git status -sb")) {
return { stdout: gitStatusOut };
} else if (command.includes("git fetch -n --dry-run")) {
return { stdout: gitFetchOut, stderr: gitFetchErr };
} else if (command.includes("git rev-list --ancestry-path --count")) {
return { stdout: gitRevListCountOut };
} else if (command.includes("git rev-list --ancestry-path")) {
return { stdout: gitRevListOut };
} else if (command.includes("git ls-remote -q --tags --refs")) {
return { stdout: gitTagListOut };
return Promise.resolve({ stdout: gitRemoteOut, stderr: "" });
}
if (command.includes("git rev-parse HEAD")) {
return Promise.resolve({ stdout: gitRevParseOut, stderr: "" });
}
if (command.includes("git status -sb")) {
return Promise.resolve({ stdout: gitStatusOut, stderr: "" });
}
if (command.includes("git fetch -n --dry-run")) {
return Promise.resolve({ stdout: gitFetchOut, stderr: gitFetchErr });
}
if (command.includes("git rev-list --ancestry-path --count")) {
return Promise.resolve({ stdout: gitRevListCountOut, stderr: "" });
}
if (command.includes("git rev-list --ancestry-path")) {
return Promise.resolve({ stdout: gitRevListOut, stderr: "" });
}
if (command.includes("git ls-remote -q --tags --refs")) {
return Promise.resolve({ stdout: gitTagListOut, stderr: "" });
}
return Promise.resolve({ stdout: "", stderr: "" });
});
if (gitHelper.execShell !== execShellSpyRef.current) {
throw new Error("execShell spy not applied");
}
});
afterEach(async () => {
afterEach(() => {
gitHelper.gitRepos = [];
jest.clearAllMocks();
vi.resetAllMocks();
});
describe("MagicMirror on develop", () => {
const moduleName = "MagicMirror";
beforeEach(async () => {
beforeEach(() => {
gitRemoteOut = "origin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (fetch)\norigin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (push)\n";
gitRevParseOut = "332e429a41f1a2339afd4f0ae96dd125da6beada";
gitStatusOut = "## develop...origin/develop\n M tests/unit/functions/updatenotification_spec.js\n";
gitFetchErr = "From github.com:MagicMirrorOrg/MagicMirror\n60e0377..332e429 develop -> origin/develop\n";
gitRevListCountOut = "5";
await gitHelper.add(moduleName);
gitHelper.gitRepos = [{ module: moduleName, folder: "mock-path" }];
});
it("returns status information", async () => {
const repos = await gitHelper.getRepos();
expect(repos[0]).toMatchSnapshot();
expect(execMock).toHaveBeenCalledTimes(5);
expect(getExecutedCommands()).toMatchInlineSnapshot(`
[
"cd mock-path && git rev-parse HEAD",
"cd mock-path && git status -sb",
"cd mock-path && git fetch -n --dry-run",
"cd mock-path && git rev-list --ancestry-path --count 60e0377..332e429 develop",
]
`);
});
it("returns status information early if isBehindInStatus", async () => {
@@ -95,38 +131,51 @@ describe("Updatenotification", () => {
const repos = await gitHelper.getRepos();
expect(repos[0]).toMatchSnapshot();
expect(execMock).toHaveBeenCalledTimes(3);
expect(getExecutedCommands()).toMatchInlineSnapshot(`
[
"cd mock-path && git rev-parse HEAD",
"cd mock-path && git status -sb",
]
`);
});
it("excludes repo if status can't be retrieved", async () => {
const errorMessage = "Failed to retrieve status";
execMock.mockRejectedValueOnce(errorMessage);
execShellSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));
expect(gitHelper.gitRepos).toHaveLength(1);
const repos = await gitHelper.getRepos();
expect(repos).toHaveLength(0);
const { error } = require("logger");
expect(error).toHaveBeenCalledWith(`Failed to retrieve repo info for ${moduleName}: Failed to retrieve status`);
expect(execShellSpyRef.current.mock.calls.length).toBeGreaterThan(0);
});
});
describe("MagicMirror on master (empty taglist)", () => {
const moduleName = "MagicMirror";
beforeEach(async () => {
beforeEach(() => {
gitRemoteOut = "origin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (fetch)\norigin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (push)\n";
gitRevParseOut = "332e429a41f1a2339afd4f0ae96dd125da6beada";
gitStatusOut = "## master...origin/master\n M tests/unit/functions/updatenotification_spec.js\n";
gitFetchErr = "From github.com:MagicMirrorOrg/MagicMirror\n60e0377..332e429 master -> origin/master\n";
gitRevListCountOut = "5";
await gitHelper.add(moduleName);
gitHelper.gitRepos = [{ module: moduleName, folder: "mock-path" }];
});
it("returns status information", async () => {
const repos = await gitHelper.getRepos();
expect(repos[0]).toMatchSnapshot();
expect(execMock).toHaveBeenCalledTimes(7);
expect(getExecutedCommands()).toMatchInlineSnapshot(`
[
"cd mock-path && git rev-parse HEAD",
"cd mock-path && git status -sb",
"cd mock-path && git fetch -n --dry-run",
"cd mock-path && git rev-list --ancestry-path --count 60e0377..332e429 master",
"cd mock-path && git ls-remote -q --tags --refs",
"cd mock-path && git rev-list --ancestry-path 60e0377..332e429 master",
]
`);
});
it("returns status information early if isBehindInStatus", async () => {
@@ -134,40 +183,55 @@ describe("Updatenotification", () => {
const repos = await gitHelper.getRepos();
expect(repos[0]).toMatchSnapshot();
expect(execMock).toHaveBeenCalledTimes(7);
expect(getExecutedCommands()).toMatchInlineSnapshot(`
[
"cd mock-path && git rev-parse HEAD",
"cd mock-path && git status -sb",
"cd mock-path && git fetch -n --dry-run",
"cd mock-path && git rev-list --ancestry-path --count 60e0377..332e429 master",
"cd mock-path && git ls-remote -q --tags --refs",
"cd mock-path && git rev-list --ancestry-path 60e0377..332e429 master",
]
`);
});
it("excludes repo if status can't be retrieved", async () => {
const errorMessage = "Failed to retrieve status";
execMock.mockRejectedValueOnce(errorMessage);
execShellSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));
const repos = await gitHelper.getRepos();
expect(repos).toHaveLength(0);
const { error } = require("logger");
expect(error).toHaveBeenCalledWith(`Failed to retrieve repo info for ${moduleName}: Failed to retrieve status`);
});
});
describe("MagicMirror on master with match in taglist", () => {
const moduleName = "MagicMirror";
beforeEach(async () => {
beforeEach(() => {
gitRemoteOut = "origin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (fetch)\norigin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (push)\n";
gitRevParseOut = "332e429a41f1a2339afd4f0ae96dd125da6beada";
gitStatusOut = "## master...origin/master\n M tests/unit/functions/updatenotification_spec.js\n";
gitFetchErr = "From github.com:MagicMirrorOrg/MagicMirror\n60e0377..332e429 master -> origin/master\n";
gitRevListCountOut = "5";
gitTagListOut = "332e429a41f1a2339afd4f0ae96dd125da6beada...tag...\n";
gitTagListOut = "332e429a41f1a2339afd4f0ae96dd125da6beada\ttag\n";
gitRevListOut = "332e429a41f1a2339afd4f0ae96dd125da6beada\n";
await gitHelper.add(moduleName);
gitHelper.gitRepos = [{ module: moduleName, folder: "mock-path" }];
});
it("returns status information", async () => {
const repos = await gitHelper.getRepos();
expect(repos[0]).toMatchSnapshot();
expect(execMock).toHaveBeenCalledTimes(7);
expect(getExecutedCommands()).toMatchInlineSnapshot(`
[
"cd mock-path && git rev-parse HEAD",
"cd mock-path && git status -sb",
"cd mock-path && git fetch -n --dry-run",
"cd mock-path && git rev-list --ancestry-path --count 60e0377..332e429 master",
"cd mock-path && git ls-remote -q --tags --refs",
"cd mock-path && git rev-list --ancestry-path 60e0377..332e429 master",
]
`);
});
it("returns status information early if isBehindInStatus", async () => {
@@ -175,40 +239,55 @@ describe("Updatenotification", () => {
const repos = await gitHelper.getRepos();
expect(repos[0]).toMatchSnapshot();
expect(execMock).toHaveBeenCalledTimes(7);
expect(getExecutedCommands()).toMatchInlineSnapshot(`
[
"cd mock-path && git rev-parse HEAD",
"cd mock-path && git status -sb",
"cd mock-path && git fetch -n --dry-run",
"cd mock-path && git rev-list --ancestry-path --count 60e0377..332e429 master",
"cd mock-path && git ls-remote -q --tags --refs",
"cd mock-path && git rev-list --ancestry-path 60e0377..332e429 master",
]
`);
});
it("excludes repo if status can't be retrieved", async () => {
const errorMessage = "Failed to retrieve status";
execMock.mockRejectedValueOnce(errorMessage);
execShellSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));
const repos = await gitHelper.getRepos();
expect(repos).toHaveLength(0);
const { error } = require("logger");
expect(error).toHaveBeenCalledWith(`Failed to retrieve repo info for ${moduleName}: Failed to retrieve status`);
});
});
describe("MagicMirror on master without match in taglist", () => {
const moduleName = "MagicMirror";
beforeEach(async () => {
beforeEach(() => {
gitRemoteOut = "origin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (fetch)\norigin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (push)\n";
gitRevParseOut = "332e429a41f1a2339afd4f0ae96dd125da6beada";
gitStatusOut = "## master...origin/master\n M tests/unit/functions/updatenotification_spec.js\n";
gitFetchErr = "From github.com:MagicMirrorOrg/MagicMirror\n60e0377..332e429 master -> origin/master\n";
gitRevListCountOut = "5";
gitTagListOut = "xxxe429a41f1a2339afd4f0ae96dd125da6beada...tag...\n";
gitTagListOut = "xxxe429a41f1a2339afd4f0ae96dd125da6beada\ttag\n";
gitRevListOut = "332e429a41f1a2339afd4f0ae96dd125da6beada\n";
await gitHelper.add(moduleName);
gitHelper.gitRepos = [{ module: moduleName, folder: "mock-path" }];
});
it("returns status information", async () => {
const repos = await gitHelper.getRepos();
expect(repos[0]).toMatchSnapshot();
expect(execMock).toHaveBeenCalledTimes(7);
expect(getExecutedCommands()).toMatchInlineSnapshot(`
[
"cd mock-path && git rev-parse HEAD",
"cd mock-path && git status -sb",
"cd mock-path && git fetch -n --dry-run",
"cd mock-path && git rev-list --ancestry-path --count 60e0377..332e429 master",
"cd mock-path && git ls-remote -q --tags --refs",
"cd mock-path && git rev-list --ancestry-path 60e0377..332e429 master",
]
`);
});
it("returns status information early if isBehindInStatus", async () => {
@@ -216,18 +295,24 @@ describe("Updatenotification", () => {
const repos = await gitHelper.getRepos();
expect(repos[0]).toMatchSnapshot();
expect(execMock).toHaveBeenCalledTimes(7);
expect(getExecutedCommands()).toMatchInlineSnapshot(`
[
"cd mock-path && git rev-parse HEAD",
"cd mock-path && git status -sb",
"cd mock-path && git fetch -n --dry-run",
"cd mock-path && git rev-list --ancestry-path --count 60e0377..332e429 master",
"cd mock-path && git ls-remote -q --tags --refs",
"cd mock-path && git rev-list --ancestry-path 60e0377..332e429 master",
]
`);
});
it("excludes repo if status can't be retrieved", async () => {
const errorMessage = "Failed to retrieve status";
execMock.mockRejectedValueOnce(errorMessage);
execShellSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));
const repos = await gitHelper.getRepos();
expect(repos).toHaveLength(0);
const { error } = require("logger");
expect(error).toHaveBeenCalledWith(`Failed to retrieve repo info for ${moduleName}: Failed to retrieve status`);
});
});
@@ -241,13 +326,19 @@ describe("Updatenotification", () => {
gitFetchErr = `From https://github.com/fewieden/${moduleName}\n19f7faf..9d83101 master -> origin/master`;
gitRevListCountOut = "7";
await gitHelper.add(moduleName);
gitHelper.gitRepos = [{ module: moduleName, folder: "mock-path" }];
});
it("returns status information without hash", async () => {
const repos = await gitHelper.getRepos();
expect(repos[0]).toMatchSnapshot();
expect(execMock).toHaveBeenCalledTimes(4);
expect(getExecutedCommands()).toMatchInlineSnapshot(`
[
"cd mock-path && git status -sb",
"cd mock-path && git fetch -n --dry-run",
"cd mock-path && git rev-list --ancestry-path --count 19f7faf..9d83101 master",
]
`);
});
});
});

View File

@@ -1,7 +1,6 @@
global.moment = require("moment-timezone");
const ical = require("node-ical");
const { expect } = require("playwright/test");
const moment = require("moment-timezone");
const CalendarFetcherUtils = require("../../../../../modules/default/calendar/calendarfetcherutils");

View File

@@ -12,7 +12,7 @@ describe("Default modules utils tests", () => {
beforeEach(() => {
fetchResponse = new Response();
global.fetch = jest.fn(() => Promise.resolve(fetchResponse));
global.fetch = vi.fn(() => Promise.resolve(fetchResponse));
fetchMock = global.fetch;
});

View File

@@ -1,30 +0,0 @@
const TestSequencer = require("@jest/test-sequencer").default;
class CustomSequencer extends TestSequencer {
sort (tests) {
const orderPath = ["unit", "electron", "e2e"];
return tests.sort((testA, testB) => {
let indexA = -1;
let indexB = -1;
const reg = ".*/tests/([^/]*).*";
// move calendar and newsfeed at the end
if (testA.path.includes("e2e/modules/calendar_spec") || testA.path.includes("e2e/modules/newsfeed_spec")) return 1;
if (testB.path.includes("e2e/modules/calendar_spec") || testB.path.includes("e2e/modules/newsfeed_spec")) return -1;
let matchA = new RegExp(reg, "g").exec(testA.path);
if (matchA.length > 0) indexA = orderPath.indexOf(matchA[1]);
let matchB = new RegExp(reg, "g").exec(testB.path);
if (matchB.length > 0) indexB = orderPath.indexOf(matchB[1]);
if (indexA === indexB) return 0;
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA < indexB ? -1 : 1;
});
}
}
module.exports = CustomSequencer;

View File

@@ -0,0 +1,21 @@
/**
* Vitest setup file for module aliasing
* This allows require("logger") to work in unit tests
*/
const Module = require("node:module");
const path = require("node:path");
// Store the original require
const originalRequire = Module.prototype.require;
// Override require to handle our custom aliases
Module.prototype.require = function (id) {
// Handle "logger" alias
if (id === "logger") {
return originalRequire.call(this, path.resolve(__dirname, "../../js/logger.js"));
}
// Handle all other requires normally
return originalRequire.apply(this, arguments);
};