Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e25685b
[core] refactor: replace `XMLHttpRequest` with `fetch`
KristjanESPERANTO Nov 8, 2025
7758dcf
refactor(tests): remove unnecessary onload callback in JSDOM setup
KristjanESPERANTO Nov 8, 2025
fd02f5d
refactor(tests): modernize getDocument with async/await and events.once
KristjanESPERANTO Nov 8, 2025
cd99927
test: migrate E2E tests from JSDOM to Playwright
KristjanESPERANTO Nov 8, 2025
d639140
ci: install Playwright browsers in automated test workflow
KristjanESPERANTO Nov 8, 2025
7ab1eb8
refactor(tests): remove helper abstractions and assert text content d…
KristjanESPERANTO Nov 8, 2025
4fadfaf
refactor(tests): replace nullish coalescing with explicit null check
KristjanESPERANTO Nov 8, 2025
a499791
refactor(tests): simplify querySelectorAll using Playwright's all() m…
KristjanESPERANTO Nov 8, 2025
b028698
refactor(test): Improve readability of wait helper functions
KristjanESPERANTO Nov 8, 2025
8443e51
test: migrate E2E tests to Playwright assertions
KristjanESPERANTO Nov 8, 2025
0866343
test(e2e): use native Playwright assertions in weather tests
KristjanESPERANTO Nov 8, 2025
c584a1b
test(e2e): use native Playwright assertions for style and opacity checks
KristjanESPERANTO Nov 8, 2025
df50479
test(e2e): simplify E2E test assertions
KristjanESPERANTO Nov 8, 2025
c765aaa
test(e2e): add Playwright linting and handle detected issue
KristjanESPERANTO Nov 8, 2025
af21fcd
test: configure Vitest projects for optimized test timeouts
KristjanESPERANTO Nov 8, 2025
17c5629
refactor(loader): use URL constructor for robust env endpoint fetch
KristjanESPERANTO Nov 8, 2025
98a256e
[tests] add auto-install Playwright browser in `install-mm:dev` script
KristjanESPERANTO Nov 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/automated-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ jobs:
- name: "Install MagicMirror²"
run: |
node --run install-mm:dev
- name: "Install Playwright browsers"
run: |
npx playwright install --with-deps chromium
Comment thread
khassel marked this conversation as resolved.
- name: "Prepare environment for tests"
run: |
# Fix chrome-sandbox permissions:
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ planned for 2026-01-01
- [tests] replace `node-libgpiod` with `serialport` in electron-rebuild workflow (#3945)
- [calendar] hide repeatingCountTitle if the event count is zero (#3949)
- [core] configure cspell to check default modules only and fix typos (#3955)
- [core] refactor: replace `XMLHttpRequest` with `fetch` in `translator.js` (#3950)
- [tests] migrate e2e tests to Playwright (#3950)

### Fixed

Expand Down
22 changes: 22 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {flatConfigs as importX} from "eslint-plugin-import-x";
import js from "@eslint/js";
import jsdocPlugin from "eslint-plugin-jsdoc";
import packageJson from "eslint-plugin-package-json";
import playwright from "eslint-plugin-playwright";
import stylistic from "@stylistic/eslint-plugin";
import vitest from "eslint-plugin-vitest";

Expand Down Expand Up @@ -59,6 +60,20 @@ export default defineConfig([
"import-x/order": "error",
"init-declarations": "off",
"vitest/consistent-test-it": "warn",
"vitest/expect-expect": [
"warn",
{
assertFunctionNames: [
"expect",
"testElementLength",
"testTextContain",
"doTest",
"runAnimationTest",
"waitForAnimationClass",
"assertNoAnimationWithin"
]
}
],
"vitest/prefer-to-be": "warn",
"vitest/prefer-to-have-length": "warn",
"max-lines-per-function": ["warn", 400],
Expand Down Expand Up @@ -125,5 +140,12 @@ export default defineConfig([
rules: {
"@stylistic/quotes": "off"
}
},
{
files: ["tests/e2e/**/*.js"],
extends: [playwright.configs["flat/recommended"]],
rules: {
"playwright/no-standalone-expect": "off"
}
}
]);
27 changes: 25 additions & 2 deletions js/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,36 @@ const Loader = (function () {

/* Private Methods */

/**
* Get environment variables from config.
* @returns {object} Env vars with modulesDir and customCss paths from config.
*/
const getEnvVarsFromConfig = function () {
return {
modulesDir: config.foreignModulesDir || "modules",
customCss: config.customCss || "css/custom.css"
};
};

/**
* Retrieve object of env variables.
* @returns {object} with key: values as assembled in js/server_functions.js
*/
const getEnvVars = async function () {
const res = await fetch(`${location.protocol}//${location.host}${config.basePath}env`);
return JSON.parse(await res.text());
// In test mode, skip server fetch and use config values directly
if (typeof process !== "undefined" && process.env && process.env.mmTestMode === "true") {
return getEnvVarsFromConfig();
}

// In production, fetch env vars from server
try {
const res = await fetch(new URL("env", `${location.origin}${config.basePath}`));
return JSON.parse(await res.text());
} catch (error) {
// Fallback to config values if server fetch fails
Log.error("Unable to retrieve env configuration", error);
return getEnvVarsFromConfig();
}
};

/**
Expand Down
34 changes: 14 additions & 20 deletions js/translator.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,24 @@
const Translator = (function () {

/**
* Load a JSON file via XHR.
* Load a JSON file via fetch.
* @param {string} file Path of the file we want to load.
* @returns {Promise<object>} the translations in the specified file
*/
async function loadJSON (file) {
const xhr = new XMLHttpRequest();
return new Promise(function (resolve) {
xhr.overrideMimeType("application/json");
xhr.open("GET", file, true);
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
// needs error handler try/catch at least
let fileInfo = null;
try {
fileInfo = JSON.parse(xhr.responseText);
} catch (exception) {
// nothing here, but don't die
Log.error(`[translator] loading json file =${file} failed`);
}
resolve(fileInfo);
}
};
xhr.send(null);
});
const baseHref = document.baseURI;
const url = new URL(file, baseHref);

try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Unexpected response status: ${response.status}`);
}
return await response.json();
} catch (exception) {
Log.error(`Loading json file =${file} failed`);
return null;
}
}

return {
Expand Down
30 changes: 30 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"config:check": "node js/check_config.js",
"postinstall": "git clean -df fonts vendor",
"install-mm": "npm install --no-audit --no-fund --no-update-notifier --only=prod --omit=dev",
"install-mm:dev": "npm install --no-audit --no-fund --no-update-notifier",
"install-mm:dev": "npm install --no-audit --no-fund --no-update-notifier && npx playwright install chromium",
"lint:css": "stylelint 'css/main.css' 'css/roboto.css' 'css/font-awesome.css' 'modules/default/**/*.css' --fix",
"lint:js": "eslint --fix",
"lint:markdown": "markdownlint-cli2 . --fix",
Expand Down Expand Up @@ -106,6 +106,7 @@
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-jsdoc": "^61.1.11",
"eslint-plugin-package-json": "^0.59.1",
"eslint-plugin-playwright": "^2.3.0",
"eslint-plugin-vitest": "^0.5.4",
"express-basic-auth": "^1.2.1",
"husky": "^9.1.7",
Expand Down
44 changes: 21 additions & 23 deletions tests/e2e/animateCSS_spec.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
const { expect } = require("playwright/test");
const helpers = require("./helpers/global-setup");

// Validate Animate.css integration for compliments module using class toggling.
// We intentionally ignore computed animation styles (jsdom doesn't simulate real animations).
describe("AnimateCSS integration Test", () => {
let page;

// Config variants under test
const TEST_CONFIG_ANIM = "tests/configs/modules/compliments/compliments_animateCSS.js";
const TEST_CONFIG_FALLBACK = "tests/configs/modules/compliments/compliments_animateCSS_fallbackToDefault.js"; // invalid animation names
Expand All @@ -11,32 +14,26 @@ describe("AnimateCSS integration Test", () => {

/**
* Get the compliments container element (waits until available).
* @returns {Promise<HTMLElement>} compliments root element
* @returns {Promise<void>}
*/
async function getComplimentsElement () {
await helpers.getDocument();
const el = await helpers.waitForElement(".compliments");
expect(el).not.toBeNull();
return el;
page = helpers.getPage();
await expect(page.locator(".compliments")).toBeVisible();
}

/**
* Wait for an Animate.css class to appear and persist briefly.
* @param {string} cls Animation class name without leading dot (e.g. animate__flipInX)
* @param {{timeout?: number}} [options] Poll timeout in ms (default 6000)
* @returns {Promise<boolean>} true if class detected in time
* @returns {Promise<void>}
*/
async function waitForAnimationClass (cls, { timeout = 6000 } = {}) {
const start = Date.now();
while (Date.now() - start < timeout) {
if (document.querySelector(`.compliments.animate__animated.${cls}`)) {
// small stability wait
await new Promise((r) => setTimeout(r, 50));
if (document.querySelector(`.compliments.animate__animated.${cls}`)) return true;
}
await new Promise((r) => setTimeout(r, 100));
}
throw new Error(`Timeout waiting for class ${cls}`);
const locator = page.locator(`.compliments.animate__animated.${cls}`);
await locator.waitFor({ state: "attached", timeout });
// small stability wait
await new Promise((r) => setTimeout(r, 50));
await expect(locator).toBeAttached();
}

/**
Expand All @@ -46,8 +43,10 @@ describe("AnimateCSS integration Test", () => {
*/
async function assertNoAnimationWithin (ms = 2000) {
const start = Date.now();
const locator = page.locator(".compliments.animate__animated");
while (Date.now() - start < ms) {
if (document.querySelector(".compliments.animate__animated")) {
const count = await locator.count();
if (count > 0) {
throw new Error("Unexpected animate__animated class present in non-animation scenario");
}
await new Promise((r) => setTimeout(r, 100));
Expand All @@ -58,21 +57,20 @@ describe("AnimateCSS integration Test", () => {
* Run one animation test scenario.
* @param {string} [animationIn] Expected animate-in name
* @param {string} [animationOut] Expected animate-out name
* @returns {Promise<boolean>} true when scenario assertions pass
* @returns {Promise<void>} Throws on assertion failure
*/
async function runAnimationTest (animationIn, animationOut) {
await getComplimentsElement();
if (!animationIn && !animationOut) {
await assertNoAnimationWithin(2000);
return true;
return;
}
if (animationIn) await waitForAnimationClass(`animate__${animationIn}`);
if (animationOut) {
// Wait just beyond one update cycle (updateInterval=2000ms) before expecting animateOut.
await new Promise((r) => setTimeout(r, 2100));
await waitForAnimationClass(`animate__${animationOut}`);
}
return true;
}

afterEach(async () => {
Expand All @@ -82,28 +80,28 @@ describe("AnimateCSS integration Test", () => {
describe("animateIn and animateOut Test", () => {
it("with flipInX and flipOutX animation", async () => {
await helpers.startApplication(TEST_CONFIG_ANIM);
await expect(runAnimationTest("flipInX", "flipOutX")).resolves.toBe(true);
await runAnimationTest("flipInX", "flipOutX");
});
});

describe("use animateOut name for animateIn (vice versa) Test", () => {
it("without animation (inverted names)", async () => {
await helpers.startApplication(TEST_CONFIG_INVERTED);
await expect(runAnimationTest()).resolves.toBe(true);
await runAnimationTest();
});
});

describe("false Animation name test", () => {
it("without animation (invalid names)", async () => {
await helpers.startApplication(TEST_CONFIG_FALLBACK);
await expect(runAnimationTest()).resolves.toBe(true);
await runAnimationTest();
});
});

describe("no Animation defined test", () => {
it("without animation (no config)", async () => {
await helpers.startApplication(TEST_CONFIG_NONE);
await expect(runAnimationTest()).resolves.toBe(true);
await runAnimationTest();
});
});
});
13 changes: 7 additions & 6 deletions tests/e2e/custom_module_regions_spec.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
const { expect } = require("playwright/test");
const helpers = require("./helpers/global-setup");

describe("Custom Position of modules", () => {
let page;

beforeAll(async () => {
await helpers.fixupIndex();
await helpers.startApplication("tests/configs/customregions.js");
await helpers.getDocument();
page = helpers.getPage();
});
afterAll(async () => {
await helpers.stopApplication();
Expand All @@ -16,15 +20,12 @@ describe("Custom Position of modules", () => {
const className1 = positions[i].replace("_", ".");
let message1 = positions[i];
it(`should show text in ${message1}`, async () => {
const elem = await helpers.waitForElement(`.${className1}`);
expect(elem).not.toBeNull();
expect(elem.textContent).toContain(`Text in ${message1}`);
await expect(page.locator(`.${className1} .module-content`)).toContainText(`Text in ${message1}`);
});
i = 1;
const className2 = positions[i].replace("_", ".");
let message2 = positions[i];
it(`should NOT show text in ${message2}`, async () => {
const elem = await helpers.waitForElement(`.${className2}`, "", 1500);
expect(elem).toBeNull();
}, 1510);
await expect(page.locator(`.${className2} .module-content`)).toHaveCount(0);
});
});
8 changes: 5 additions & 3 deletions tests/e2e/env_spec.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
const { expect } = require("playwright/test");
const helpers = require("./helpers/global-setup");

describe("App environment", () => {
let page;

beforeAll(async () => {
await helpers.startApplication("tests/configs/default.js");
await helpers.getDocument();
page = helpers.getPage();
});
afterAll(async () => {
await helpers.stopApplication();
Expand All @@ -20,8 +24,6 @@ describe("App environment", () => {
});

it("should show the title MagicMirror²", async () => {
const elem = await helpers.waitForElement("title");
expect(elem).not.toBeNull();
expect(elem.textContent).toBe("MagicMirror²");
await expect(page).toHaveTitle("MagicMirror²");
});
});
Loading