Compare commits

...

1 Commits

Author SHA1 Message Date
sam detweiler
b742e839be Release 2.34.0 (#3999)
Thanks to: @Blackspirits, @Crazylegstoo, @jarnoml, @jboucly, @JHWelch,
@khassel, @KristjanESPERANTO, @rejas, @sdetweil, @xsorifc28

⚠️ This release needs nodejs version >=22.21.1 <23 || >=24

[Compare to previous Release
v2.33.0](https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.33.0...develop)

[core]
Prepare Release 2.34.0
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3998)
dependency update + adjust Playwright setup + fix linter issue
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3994)
demo with gif (https://github.com/MagicMirrorOrg/MagicMirror/pull/3995)
[core] fix: allow browser globals in config files
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3992)
[core] fix: restore --ozone-platform=wayland flag for reliable Wayland
support (https://github.com/MagicMirrorOrg/MagicMirror/pull/3989)
[core] auto create release notes with every push on develop
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3985)
[core] chore: simplify Wayland start script
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3974)
[gitignore] restore the old Git behavior for the default modules
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3968)
[core] configure cspell to check default modules only and fix typos
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3955)
[gitignore] restoring the old Git behavior for the CSS directory
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3954)
feat(core): add server:watch script with automatic restart on file
changes (https://github.com/MagicMirrorOrg/MagicMirror/pull/3920)
[check_config] refactor: improve error handling
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3927)
refactor: replace express-ipfilter with lightweight custom middleware
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3917)
refactor: replace module-alias dependency with internal alias resolver
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3893)

[dependencies]

[chore] update dependencies and min. node version
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3986)
[core] bump dependencies into december
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3982)
Bump actions/checkout from 5 to 6
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3972)
Update deps, unpin parse5
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3934)
[core] Update deps and pin jsdom to v27.0.0
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3925)
chore: update dependencies
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3921)
update deps, exclude node v23
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3916)
remove eslint warnings, add npm publish process to Collaboration.md
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3913)
feat: add ESlint rule no-sparse-arrays for config check
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3911)
chore: bump dependencies into october
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3909)

[logging]

logger: add calling filename as prefix on server side
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3926)
[logger] Add prefixes to most Log messages
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3923)

[modules/alert]
Add new pt and pt-BR translations for Alert module and update global PT
strings (https://github.com/MagicMirrorOrg/MagicMirror/pull/3965)

[modules/calendar]
add checksum to test whether calendar event list changed
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3988)
[calendar] fix: prevent excessive fetching on client reload and refactor
calendarfetcherutils.js
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3976)
[calendar] refactor: migrate CalendarFetcher to ES6 class and improve
error handling (https://github.com/MagicMirrorOrg/MagicMirror/pull/3959)
[calendar] Show repeatingCountTitle only if yearDiff > 0
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3949)
[tests] suppress debug logs in CI environment + improve calendar symbol
test stability (https://github.com/MagicMirrorOrg/MagicMirror/pull/3941)
[calendar] chore: remove requiresVersion: "2.1.0"
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3932)
[calendar] test: remove "Recurring event per timezone" test
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3929)

[modules/compliments]
[compliments] refactor: optimize loadComplimentFile method and add unit
tests (https://github.com/MagicMirrorOrg/MagicMirror/pull/3969)
fix: set compliments remote file minimum delay to 15 minutes
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3970)
[compliments] fix: duplicate query param "?" in compliments module
refresh url (https://github.com/MagicMirrorOrg/MagicMirror/pull/3967)

[modules/newsfeed]
[newsfeed] fix header layout issue
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3946)

[modules/weather]
[weatherprovider] update subclass language use override
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3914)
[weather] fix wind-icon not showing in pirateweather
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3957)
[weather] add error handling to weather fetch functions, including cors
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3791)
remove deprecated ukmetoffice datapoint provider, cleanup .gitignore
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3952)
fixes problems with daylight-saving-time in weather provider openmeteo
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3931)
Fix for envcanada Provider to use updated Env Canada URL
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3919)
[weather] feat: add configurable forecast date format option
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3918)

[testing]
testing: update "Enforce Pull-Request Rules" workflow
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3987)
[core] refactor: replace XMLHttpRequest with fetch and migrate e2e tests
to Playwright (https://github.com/MagicMirrorOrg/MagicMirror/pull/3950)
[test] replace node-libgpiod with serialport in electron-rebuild
workflow (https://github.com/MagicMirrorOrg/MagicMirror/pull/3945)
[ci] Add concurrency to automated tests workflow to cancel outdated runs
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3943)
[tests] migrate from jest to vitest
(https://github.com/MagicMirrorOrg/MagicMirror/pull/3940)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Karsten Hassel <hassel@gmx.de>
Co-authored-by: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com>
Co-authored-by: Ryan Williams <65094007+ryan-d-williams@users.noreply.github.com>
Co-authored-by: Veeck <github@veeck.de>
Co-authored-by: Bugsounet - Cédric <github@bugsounet.fr>
Co-authored-by: Marc Landis <dirk.rettschlag@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: HeikoGr <20295490+HeikoGr@users.noreply.github.com>
Co-authored-by: Pedro Lamas <pedrolamas@gmail.com>
Co-authored-by: veeck <gitkraken@veeck.de>
Co-authored-by: Magnus <34011212+MagMar94@users.noreply.github.com>
Co-authored-by: Ikko Eltociear Ashimine <eltociear@gmail.com>
Co-authored-by: DevIncomin <56730075+Developer-Incoming@users.noreply.github.com>
Co-authored-by: Nathan <n8nyoung@gmail.com>
Co-authored-by: mixasgr <mixasgr@users.noreply.github.com>
Co-authored-by: Savvas Adamtziloglou <savvas-gr@greeklug.gr>
Co-authored-by: Konstantinos <geraki@gmail.com>
Co-authored-by: OWL4C <124401812+OWL4C@users.noreply.github.com>
Co-authored-by: BugHaver <43462320+bughaver@users.noreply.github.com>
Co-authored-by: BugHaver <43462320+lsaadeh@users.noreply.github.com>
Co-authored-by: Koen Konst <koenspero@gmail.com>
Co-authored-by: Koen Konst <c.h.konst@avisi.nl>
Co-authored-by: dathbe <github@beffa.us>
Co-authored-by: Marcel <m-idler@users.noreply.github.com>
Co-authored-by: Kevin G. <crazylegstoo@gmail.com>
Co-authored-by: Jboucly <33218155+jboucly@users.noreply.github.com>
Co-authored-by: Jboucly <contact@jboucly.fr>
Co-authored-by: Jarno <54169345+jarnoml@users.noreply.github.com>
Co-authored-by: Jordan Welch <JordanHWelch@gmail.com>
Co-authored-by: Blackspirits <blackspirits@gmail.com>
Co-authored-by: Samed Ozdemir <samed@xsor.io>
2026-01-01 08:45:36 -06:00
114 changed files with 5671 additions and 6071 deletions

View File

@@ -30,9 +30,19 @@ To run markdownlint, use `node --run lint:markdown`.
## Testing
We use [Jest](https://jestjs.io) for JavaScript testing.
We use [Vitest](https://vitest.dev) for JavaScript testing.
To run all tests, use `node --run test`.
The specific test commands are defined in `package.json`.
So you can also run the specific tests with other commands, e.g. `node --run test:unit` or `npx jest tests/e2e/env_spec.js`.
The `package.json` scripts expose finer-grained test commands:
- `test:unit` run unit tests only
- `test:e2e` execute browser-driven end-to-end tests
- `test:electron` launch the Electron-based regression suite
- `test:coverage` collect coverage while running every suite
- `test:watch` keep Vitest in watch mode for fast local feedback
- `test:ui` open the Vitest UI dashboard (needs OS file-watch support enabled)
- `test:calendar` run the legacy calendar debug helper
- `test:css`, `test:markdown`, `test:prettier`, `test:spelling`, `test:js` lint-only scripts that enforce formatting, spelling, markdown style, and ESLint.
You can invoke any script with `node --run <script>` (or `npm run <script>`). Individual files can still be targeted directly, e.g. `npx vitest run tests/e2e/env_spec.js`.

View File

@@ -1,6 +1,6 @@
Hello and thank you for wanting to contribute to the MagicMirror² project!
**Please make sure that you have followed these 4 rules before submitting your Pull Request:**
**Please make sure that you have followed these 3 rules before submitting your Pull Request:**
> 1. Base your pull requests against the `develop` branch.
> 2. Include these infos in the description:
@@ -12,8 +12,6 @@ Hello and thank you for wanting to contribute to the MagicMirror² project!
>
> 3. Please run `node --run lint:prettier` before submitting so that
> style issues are fixed.
> 4. Don't forget to add an entry about your changes to
> the CHANGELOG.md file.
**Note**: Sometimes the development moves very fast. It is highly
recommended that you update your branch of `develop` before creating a

View File

@@ -6,7 +6,6 @@ updates:
interval: "weekly"
target-branch: "develop"
labels:
- "Skip Changelog"
- "dependencies"
- package-ecosystem: "npm"
@@ -15,6 +14,5 @@ updates:
interval: "monthly"
target-branch: "develop"
labels:
- "Skip Changelog"
- "dependencies"
- "javascript"

View File

@@ -12,15 +12,19 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
code-style-check:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: "Use Node.js"
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: lts/*
cache: "npm"
@@ -38,16 +42,16 @@ jobs:
timeout-minutes: 30
strategy:
matrix:
node-version: [22.18.0, 22.x, 24.x]
node-version: [22.21.1, 22.x, 24.x]
steps:
- name: Install electron dependencies and labwc
run: |
sudo apt-get update
sudo apt-get install -y libnss3 libasound2t64 labwc
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: "Use Node.js ${{ matrix.node-version }}"
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
check-latest: true
@@ -55,6 +59,9 @@ jobs:
- name: "Install MagicMirror²"
run: |
node --run install-mm:dev
- name: "Install Playwright browsers"
run: |
npx playwright install --with-deps chromium
- name: "Prepare environment for tests"
run: |
# Fix chrome-sandbox permissions:

View File

@@ -13,6 +13,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: "Checkout code"
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: "Dependency Review"
uses: actions/dependency-review-action@v4

View File

@@ -8,12 +8,12 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22.18.0, 22.x, 24.x]
node-version: [22.21.1, 22.x, 24.x]
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: "Use Node.js ${{ matrix.node-version }}"
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
check-latest: true
@@ -21,12 +21,8 @@ jobs:
run: node --run install-mm
- name: Install @electron/rebuild
run: npm install @electron/rebuild
- name: Install node-libgpiod deps
run: |
sudo apt-get update
sudo apt-get install gpiod libgpiod2 libgpiod-dev
- name: Install test library (node-libgpiod) to be rebuilded
run: npm install node-libgpiod
- name: Install test library (serialport) to be rebuilt
run: npm install serialport
- name: Run electron-rebuild
run: npx electron-rebuild
continue-on-error: false

View File

@@ -1,28 +1,26 @@
# This workflow enforces on every pull request:
# - the update of our CHANGELOG.md file, see: https://github.com/dangoslen/changelog-enforcer
# - that the PR is not based against master, taken from https://github.com/oppia/oppia-android/pull/2832/files
# This workflow enforces on every pull request that the PR is not based against master,
# taken from https://github.com/oppia/oppia-android/blob/develop/.github/workflows/static_checks.yml
name: "Enforce Pull-Request Rules"
on:
pull_request_target:
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
pull_request:
push:
branches-ignore:
- develop
- master
jobs:
check:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
timeout-minutes: 10
steps:
- name: "Enforce changelog"
uses: dangoslen/changelog-enforcer@v3
with:
changeLogPath: "CHANGELOG.md"
skipLabels: "Skip Changelog"
- name: "Enforce develop branch"
if: ${{ github.event.pull_request.base.ref == 'master' && !contains(github.event.pull_request.labels.*.name, 'mastermerge') }}
- name: "Branch is not based on develop"
if: ${{ github.base_ref != 'develop' && !contains(github.event.pull_request.labels.*.name, 'mastermerge') }}
run: |
echo "This PR is based against the master branch and not a release or hotfix."
echo "Please don't do this. Switch the branch to 'develop'."
echo "Current base branch: $BASE_BRANCH"
echo "Note: PRs should only ever be merged into develop so please rebase your branch on develop and try again."
exit 1
env:
BASE_BRANCH: ${{ github.event.pull_request.base.ref }}
BASE_BRANCH: ${{ github.base_ref }}

33
.github/workflows/release-notes.yaml vendored Normal file
View File

@@ -0,0 +1,33 @@
# This workflow writes a draft release on GitHub named `unreleased` after every push on develop
name: "Create Release Notes"
on:
push:
branches: [develop]
permissions:
contents: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
release-notes:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: "Checkout code"
uses: actions/checkout@v6
with:
fetch-depth: "0"
- name: "Use Node.js"
uses: actions/setup-node@v6
with:
node-version: lts/*
cache: "npm"
- name: "Create Markdown content"
run: |
export GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}
node js/releasenotes.js

View File

@@ -15,11 +15,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
ref: develop
- name: Set up Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: lts/*
check-latest: true

16
.gitignore vendored
View File

@@ -9,8 +9,7 @@ lib-cov
coverage
.lock-wscript
build/Release
/node_modules/**/*
!/tests/node_modules/**/*
node_modules
jspm_modules
.npm
.node_repl_history
@@ -56,21 +55,19 @@ Temporary Items
.Trash-*
# Ignore all modules except the default modules.
/modules/**
/modules/*
!/modules/default
!/modules/default/**
!/modules/README.md**
# Ignore changes to the custom css files but keep the sample and main.
/css/*
!/css/custom.css.sample
!/css/font-awesome.css
!/css/main.css
!/css/roboto.css
!/css/font-awesome.css
# Ignore users config file but keep the sample.
/config/*
!/config/config.js.sample
config
!config/config.js.sample
# Vim
## swap
@@ -88,3 +85,6 @@ js/positions.js
# Ignore lock files other than package-lock.json
pnpm-lock.yaml
yarn.lock
# Vitest temporary test files
tests/**/.tmp/

View File

@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
❤️ **Donate:** Enjoying MagicMirror²? [Please consider a donation!](https://magicmirror.builders/#donate) With your help we can continue to improve the MagicMirror².
## Obsolete
This file is no longer being updated. Release notes are now automatically generated via a GitHub action.
## [2.33.0] - 2025-10-01
Thanks to: @Crazylegstoo, @dathbe, @m-idler, @plebcity, @khassel, @KristjanESPERANTO, @rejas and @sdetweil!
@@ -335,7 +339,7 @@ For more info, please read the following post: [A New Chapter for MagicMirror: T
### Fixed
- [weather] Correct apiBase of weathergov weatherProvider to match documentation (#2926)
- Worked around several issues in the RRULE library that were causing deleted calender events to still show, some
- Worked around several issues in the RRULE library that were causing deleted calendar events to still show, some
initial and recurring events to not show, and some event times to be off an hour. (#3291)
- Skip changelog requirement when running tests for dependency updates (#3320)
- Display precipitation probability when it is 0% instead of blank/empty (#3345)

View File

@@ -22,6 +22,7 @@ Are done by
- [ ] @rejas
- [ ] @sdetweil
- [ ] @khassel
- [ ] @KristjanESPERANTO
### Pre-Deployment steps
@@ -33,29 +34,28 @@ Are done by
- [ ] create `prep-release` branch from `develop`
- [ ] update `package.json` and `package-lock.json` to reflect correct version number `2.xx.0`
- [ ] test `prep-release` branch
- [ ] update `CHANGELOG.md`
- [ ] add all contributor names: `...`
- [ ] add min. node version: > ⚠️ This release needs nodejs version `v22.18.0` or higher
- [ ] check release link at the bottom of the file
- [ ] commit and push all changes
- [ ] create pull request from `prep-release` to `develop` branch with title `Prepare Release 2.xx.0`
- [ ] after successful test run via github actions: merge pull request to `develop`
- [ ] review the content of the automatically generated draft release named `unreleased`
- [ ] check contributor names
- [ ] check auto generated min. node version and adjust it for better readability if necessary
- [ ] check if all elements are assigned to the correct category
- [ ] change release name to `v2.xx.0`
- [ ] after successful test run via github actions: create pull request from `develop` to `master` branch
- [ ] add label `mastermerge`
- [ ] title of the PR is `Release 2.xx.0`
- [ ] description of the PR is the section of the `CHANGELOG.md`
- [ ] description of the PR is the body of the draft release with name `v2.xx.0`
- [ ] after PR tests run without issues, merge PR
- [ ] create new release with
- [ ] corresponding version tag `v2.xx.0`
- [ ] a release name: `...`
- [ ] description of the release is the section of the `CHANGELOG.md`
- [ ] edit draft release with name `v2.xx.0`
- [ ] set corresponding version tag `v2.xx.0` (with `Select tag` and then `Create new tag`)
- [ ] update release link in `Compare to previous Release` by replacing `develop` with new tag `v2.xx.0`
- [ ] publish the release (button at the bottom)
### Draft new development release
- [ ] checkout `develop` branch
- [ ] update `package.json` and `package-lock.json` to reflect correct version number `2.xx.0-develop`
- [ ] draft new section in `CHANGELOG.md`
- [ ] create new release link at the bottom of the file
- [ ] commit and push `develop` branch
- [ ] if new release will be in January, update the year in LICENSE.md
@@ -65,3 +65,7 @@ Are done by
- [ ] close all issues with label `ready (coming with next release)`
- [ ] release new documentation by merging `develop` on `master` in documentation repository
- [ ] publish new version on [npm](https://www.npmjs.com/package/magicmirror)
- [ ] use a clean environment (e.g. container)
- [ ] clone this repository with the new `master` branch and `cd` into the local repository directory
- [ ] log in to npm with `npm login --auth-type legacy` which will ask for username and password and one-time-password which is sent via mail
- [ ] execute `npm publish`

View File

@@ -1,6 +1,6 @@
# The MIT License (MIT)
Copyright © 2016-2025 Michael Teeuw
Copyright © 2016-2026 Michael Teeuw
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation

View File

@@ -15,6 +15,8 @@
MagicMirror² focuses on a modular plugin system and uses [Electron](https://www.electronjs.org/) as an application wrapper. So no more web server or browser installs necessary!
![Animated demonstration of MagicMirror²](https://magicmirror.builders/img/demo.gif)
## Documentation
For the full documentation including **[installation instructions](https://docs.magicmirror.builders/getting-started/installation.html)**, please visit our dedicated documentation website: [https://docs.magicmirror.builders](https://docs.magicmirror.builders).

View File

@@ -3,19 +3,25 @@
"language": "en",
"words": [
"aarch",
"Adak",
"Alvinger",
"Ampio",
"andrezibaia",
"angeldeejay",
"apikey",
"apiontek",
"armv",
"ashishtank",
"autoplay",
"Autorestart",
"beada",
"Behaviour",
"Binney",
"bluemanos",
"bnitkin",
"bokmål",
"bouncyflip",
"boxspinner",
"Brasileiro",
"Brento",
"browserwindow",
@@ -25,14 +31,22 @@
"bugsounet",
"buxxi",
"byday",
"calcage",
"calendarfetcher",
"calendarfetcherutils",
"calendarutils",
"calevents",
"chamakura",
"Citypage",
"cjbrunner",
"clearsky",
"clientonly",
"clockfaces",
"cloudcover",
"cmdline",
"codac",
"Codrops",
"cornerexpand",
"Crazylegstoo",
"crazyscot",
"Creepin",
@@ -43,14 +57,23 @@
"Cymraeg",
"dariom",
"darksky",
"dataheaders",
"Datamart",
"dateheader",
"dateheaders",
"datekey",
"dathbe",
"davide",
"DAYAFTERTOMORROW",
"DAYBEFOREYESTERDAY",
"defaultmodules",
"Deificit",
"Descr",
"dewpoint",
"dgoth",
"difflink",
"dismissttl",
"Displayer",
"dkallen",
"drivelist",
"DTEND",
@@ -63,18 +86,26 @@
"Edgardos",
"Ekristoffe",
"elec",
"elif",
"eltociear",
"endfor",
"endmacro",
"envcanada",
"envsub",
"envsubst",
"eouia",
"Evapotranspration",
"exdate",
"exdates",
"expectedheaders",
"exploader",
"ezeholz",
"Fadesteps",
"Faizan",
"feedme",
"feelslike",
"Fenner",
"Feuchte",
"fewieden",
"fixuppm",
"flopp",
@@ -83,7 +114,9 @@
"forecastweather",
"fortawesome",
"frameguard",
"freezinglevel",
"Frysk",
"fullarticle",
"fulldate",
"fullday",
"fullscreen",
@@ -92,9 +125,19 @@
"GHSA",
"ghsas",
"grenagit",
"Halfclear",
"heavyrain",
"heavyrainandthunder",
"heavyrainshowers",
"heavyrainshowersandthunder",
"heavysleet",
"heavysleetshowersandthunder",
"heavysnow",
"heavysnowandthunder",
"Heiko",
"Hirschberger",
"hourlyweather",
"humidex",
"Hwind",
"ical",
"illimarkangur",
@@ -123,15 +166,18 @@
"Knapoc",
"Koepke",
"kolbyjack",
"Komplex",
"krekos",
"Kristjan",
"krukle",
"labwc",
"Landis",
"larryare",
"Lastberechnung",
"letsencrypt",
"libgpiod",
"Lightspeed",
"loadingcircle",
"locationforecast",
"lockstring",
"lstrip",
@@ -159,9 +205,12 @@
"Ñandú",
"nathannaveen",
"naveensrinivasan",
"nbsp",
"ndom",
"Nerfzooka",
"NEWSFEED",
"newsfeedfetcher",
"newsfetcher",
"newsitems",
"nfogal",
"njwilliams",
@@ -170,44 +219,62 @@
"nunjuck",
"odroid",
"oemel",
"oldconfig",
"onecall",
"onevent",
"openmeteo",
"openmeto",
"openweathermap",
"oraclesean",
"oscarb",
"pcat",
"philnagel",
"pirateweather",
"plained",
"plebcity",
"pmax",
"pmean",
"pmedian",
"pmin",
"Português",
"PRECIP",
"Problema",
"psieg",
"pubdate",
"radokristof",
"rajniszp",
"rebuilded",
"Reis",
"rejas",
"relativehumidity",
"Resig",
"roboto",
"rohitdharavath",
"Rosso",
"Rothfusz",
"rrule",
"savvadam",
"sdetweil",
"searchstr",
"sendheaders",
"serveronly",
"sexualized",
"Sitecode",
"skpanagiotis",
"SMHI",
"Snille",
"snowandthunder",
"snowshowersandthunder",
"socketclient",
"socketio",
"spectron",
"Starinvest",
"stationid",
"STEADMAN",
"sthuber",
"Stieber",
"strinner",
"stylelintrc",
"subclassing",
"sunaction",
"suncalc",
"suntimes",
@@ -216,22 +283,36 @@
"tada",
"taglist",
"Teeuw",
"Teil",
"TESTMODE",
"thomasrockhu",
"thumbslider",
"timeformat",
"titlereplacestr",
"titlesearchstr",
"todaytemp",
"tomzt",
"trunc",
"ttlms",
"ukmetoffice",
"ukmetofficedatahub",
"unitless",
"unixtime",
"unparseable",
"updatenotification",
"uxdt",
"Vaice",
"veeck",
"verjaardag",
"VEVENT",
"vgtu",
"Vitest",
"Voelt",
"Vorberechnung",
"vppencilsharpener",
"Wallys",
"Weatherbit",
"weathercode",
"WEATHERDATA",
"Weatherflow",
"weatherforecast",
@@ -239,19 +320,37 @@
"weathericon",
"weathericons",
"weatherobject",
"weatherprovider",
"weatherutils",
"webcal",
"winddirection",
"windgusts",
"windspeed",
"Woolridge",
"worktree",
"Wsymb",
"xlarge",
"xmark",
"xrandr",
"xsmall",
"xsorifc",
"xwindows",
"xxxe",
"Ybbet",
"yearmatch",
"yearmatchgroup"
],
"ignorePaths": ["node_modules/**", "modules/**", "translations/**", "tests/mocks/**", "tests/e2e/modules/clock_es_spec.js", "css/roboto.css"],
"ignorePaths": [
"css/roboto.css",
"node_modules/**",
"modules/!(default)/**",
"modules/default/**/translations/!(en).json",
"modules/default/calendar/windowsZones.json",
"modules/default/clock/faces/*.svg",
"modules/default/weather/providers/yr.js",
"tests/mocks/**",
"tests/e2e/modules/clock_es_spec.js",
"translations/**"
],
"dictionaries": ["node"]
}

View File

@@ -1,11 +1,12 @@
import {defineConfig, globalIgnores} from "eslint/config";
import globals from "globals";
import {flatConfigs as importX} from "eslint-plugin-import-x";
import jest from "eslint-plugin-jest";
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";
export default defineConfig([
globalIgnores(["config/**", "modules/**/*", "!modules/default/**", "js/positions.js"]),
@@ -16,6 +17,7 @@ export default defineConfig([
globals: {
...globals.browser,
...globals.node,
...vitest.environments.env.globals,
Log: "readonly",
MM: "readonly",
Module: "readonly",
@@ -23,8 +25,8 @@ export default defineConfig([
moment: "readonly"
}
},
plugins: {js, stylistic},
extends: [importX.recommended, jest.configs["flat/recommended"], "js/recommended", jsdocPlugin.configs["flat/recommended"], "stylistic/all"],
plugins: {js, stylistic, vitest},
extends: [importX.recommended, vitest.configs.recommended, "js/recommended", jsdocPlugin.configs["flat/recommended"], "stylistic/all"],
rules: {
"@stylistic/array-element-newline": ["error", "consistent"],
"@stylistic/arrow-parens": ["error", "always"],
@@ -57,12 +59,23 @@ export default defineConfig([
"import-x/newline-after-import": "error",
"import-x/order": "error",
"init-declarations": "off",
"jest/consistent-test-it": "warn",
"jest/no-done-callback": "warn",
"jest/prefer-expect-resolves": "warn",
"jest/prefer-mock-promise-shorthand": "warn",
"jest/prefer-to-be": "warn",
"jest/prefer-to-have-length": "warn",
"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],
"max-statements": "off",
"no-global-assign": "off",
@@ -127,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"
}
}
]);

View File

@@ -1,3 +1,7 @@
const aliasMapper = {
logger: "<rootDir>/js/logger.js"
};
const config = {
verbose: true,
testTimeout: 20000,
@@ -6,21 +10,21 @@ const config = {
{
displayName: "unit",
globalSetup: "<rootDir>/tests/unit/helpers/global-setup.js",
moduleNameMapper: {
logger: "<rootDir>/js/logger.js"
},
moduleNameMapper: aliasMapper,
testMatch: ["**/tests/unit/**/*.[jt]s?(x)"],
testPathIgnorePatterns: ["<rootDir>/tests/unit/mocks", "<rootDir>/tests/unit/helpers"]
},
{
displayName: "electron",
testMatch: ["**/tests/electron/**/*.[jt]s?(x)"],
moduleNameMapper: aliasMapper,
testPathIgnorePatterns: ["<rootDir>/tests/electron/helpers"]
},
{
displayName: "e2e",
testMatch: ["**/tests/e2e/**/*.[jt]s?(x)"],
modulePaths: ["<rootDir>/js/"],
moduleNameMapper: aliasMapper,
testPathIgnorePatterns: ["<rootDir>/tests/e2e/helpers", "<rootDir>/tests/e2e/mocks"]
}
],

31
js/alias-resolver.js Normal file
View File

@@ -0,0 +1,31 @@
// Internal alias mapping for default and 3rd party modules.
// Provides short require identifiers: "logger" and "node_helper".
// For a future ESM migration, replace this with a public export/import surface.
const path = require("node:path");
const Module = require("module");
const root = path.join(__dirname, "..");
// Keep this list minimal; do not add new aliases without architectural review.
const ALIASES = {
logger: "js/logger.js",
node_helper: "js/node_helper.js"
};
// Resolve to absolute paths now.
const resolved = Object.fromEntries(
Object.entries(ALIASES).map(([k, rel]) => [k, path.join(root, rel)])
);
// Prevent multiple patching if this file is required more than once.
if (!Module._mmAliasPatched) {
const origResolveFilename = Module._resolveFilename;
Module._resolveFilename = function (request, parent, isMain, options) {
if (Object.prototype.hasOwnProperty.call(resolved, request)) {
return resolved[request];
}
return origResolveFilename.call(this, request, parent, isMain, options);
};
Module._mmAliasPatched = true; // non-enumerable marker would be overkill here
}

View File

@@ -132,7 +132,7 @@ function addAnimateCSS (element, animation, animationTime) {
const node = document.getElementById(element);
if (!node) {
// don't execute animate: we don't find div
Log.warn("addAnimateCSS: node not found for", element);
Log.warn("node not found for adding", element);
return;
}
node.style.setProperty("--animate-duration", `${animationTime}s`);
@@ -149,7 +149,7 @@ function removeAnimateCSS (element, animation) {
const node = document.getElementById(element);
if (!node) {
// don't execute animate: we don't find div
Log.warn("removeAnimateCSS: node not found for", element);
Log.warn("node not found for removing", element);
return;
}
node.classList.remove("animate__animated", animationName);

View File

@@ -1,5 +1,5 @@
// Alias modules mentioned in package.js under _moduleAliases.
require("module-alias/register");
// Load lightweight internal alias resolver
require("./alias-resolver");
const fs = require("node:fs");
const path = require("node:path");
@@ -15,7 +15,7 @@ const Utils = require(`${__dirname}/utils`);
const defaultModules = require(`${global.root_path}/modules/default/defaultmodules`);
// used to control fetch timeout for node_helpers
const { setGlobalDispatcher, Agent } = require("undici");
const { getEnvVarsAsObj } = require("#server_functions");
const { getEnvVarsAsObj, getConfigFilePath } = require("#server_functions");
// common timeout value, provide environment override in case
const fetch_timeout = process.env.mmFetchTimeout !== undefined ? process.env.mmFetchTimeout : 30000;
@@ -65,14 +65,14 @@ function App () {
async function loadConfig () {
Log.log("Loading config ...");
const defaults = require(`${__dirname}/defaults`);
if (process.env.JEST_WORKER_ID !== undefined) {
// if we are running with jest
if (global.mmTestMode) {
// if we are running in test mode
defaults.address = "0.0.0.0";
}
// For this check proposed to TestSuite
// https://forum.magicmirror.builders/topic/1456/test-suite-for-magicmirror/8
const configFilename = path.resolve(global.configuration_file || `${global.root_path}/config/config.js`);
const configFilename = getConfigFilePath();
let templateFile = `${configFilename}.template`;
// check if templateFile exists
@@ -158,7 +158,7 @@ function App () {
const deprecatedOptions = deprecated.configs;
const usedDeprecated = deprecatedOptions.filter((option) => userConfig.hasOwnProperty(option));
if (usedDeprecated.length > 0) {
Log.warn(`WARNING! Your config is using deprecated option(s): ${usedDeprecated.join(", ")}. Check README and CHANGELOG for more up-to-date ways of getting the same functionality.`);
Log.warn(`WARNING! Your config is using deprecated option(s): ${usedDeprecated.join(", ")}. Check README and Documentation for more up-to-date ways of getting the same functionality.`);
}
// check for deprecated module options
@@ -167,7 +167,7 @@ function App () {
const deprecatedModuleOptions = deprecated[element.module];
const usedDeprecatedModuleOptions = deprecatedModuleOptions.filter((option) => element.config.hasOwnProperty(option));
if (usedDeprecatedModuleOptions.length > 0) {
Log.warn(`WARNING! Your config for module ${element.module} is using deprecated option(s): ${usedDeprecatedModuleOptions.join(", ")}. Check README and CHANGELOG for more up-to-date ways of getting the same functionality.`);
Log.warn(`WARNING! Your config for module ${element.module} is using deprecated option(s): ${usedDeprecatedModuleOptions.join(", ")}. Check README and Documentation for more up-to-date ways of getting the same functionality.`);
}
}
}
@@ -185,10 +185,10 @@ function App () {
if (defaultModules.includes(moduleName)) {
const defaultModuleFolder = path.resolve(`${global.root_path}/modules/default/`, module);
if (process.env.JEST_WORKER_ID === undefined) {
if (!global.mmTestMode) {
moduleFolder = defaultModuleFolder;
} else {
// running in Jest, allow defaultModules placed under moduleDir for testing
// running in test mode, allow defaultModules placed under moduleDir for testing
if (env.modulesDir === "modules" || env.modulesDir === "tests/mocks") {
moduleFolder = defaultModuleFolder;
}

View File

@@ -1,12 +1,15 @@
// Ensure internal require aliases (e.g., "logger") resolve when this file is run as a standalone script
require("./alias-resolver");
const path = require("node:path");
const fs = require("node:fs");
const { styleText } = require("node:util");
const Ajv = require("ajv");
const globals = require("globals");
const { Linter } = require("eslint");
const Log = require("logger");
const rootPath = path.resolve(`${__dirname}/../`);
const Log = require(`${rootPath}/js/logger.js`);
const Utils = require(`${rootPath}/js/utils.js`);
const linter = new Linter({ configType: "flat" });
@@ -28,16 +31,18 @@ function getConfigFile () {
function checkConfigFile () {
const configFileName = getConfigFile();
// Check if file is present
if (fs.existsSync(configFileName) === false) {
throw new Error(`File not found: ${configFileName}\nNo config file present!`);
}
// Check permission
// Check if file exists and is accessible
try {
fs.accessSync(configFileName, fs.constants.F_OK);
fs.accessSync(configFileName, fs.constants.R_OK);
} catch (error) {
throw new Error(`${error}\nNo permission to access config file!`);
if (error.code === "ENOENT") {
Log.error(`File not found: ${configFileName}`);
} else if (error.code === "EACCES") {
Log.error(`No permission to read config file: ${configFileName}`);
} else {
Log.error(`Cannot access config file: ${configFileName}\n${error.message}`);
}
process.exit(1);
}
// Validate syntax of the configuration file.
@@ -52,10 +57,14 @@ function checkConfigFile () {
languageOptions: {
ecmaVersion: "latest",
globals: {
...globals.browser,
...globals.node
}
},
rules: { "no-undef": "error" }
rules: {
"no-sparse-arrays": "error",
"no-undef": "error"
}
},
configFileName
);
@@ -69,7 +78,8 @@ function checkConfigFile () {
for (const error of errors) {
errorMessage += `\nLine ${error.line} column ${error.column}: ${error.message}`;
}
throw new Error(errorMessage);
Log.error(errorMessage);
process.exit(1);
}
}
@@ -96,8 +106,7 @@ function validateModulePositions (configFileName) {
type: "string"
},
position: {
type: "string",
enum: positionList
type: "string"
}
},
required: ["module"]
@@ -113,6 +122,16 @@ function validateModulePositions (configFileName) {
const valid = validate(data);
if (valid) {
Log.info(styleText("green", "Your modules structure configuration doesn't contain errors :)"));
// Check for unknown positions (warning only, not an error)
if (data.modules) {
for (const [index, module] of data.modules.entries()) {
if (module.position && !positionList.includes(module.position)) {
Log.warn(`Module ${index} ("${module.module}") uses unknown position: "${module.position}"`);
Log.warn(`Known positions are: ${positionList.join(", ")}`);
}
}
}
} else {
const module = validate.errors[0].instancePath.split("/")[2];
const position = validate.errors[0].instancePath.split("/")[3];
@@ -125,12 +144,14 @@ function validateModulePositions (configFileName) {
errorMessage += validate.errors[0].message;
}
Log.error(errorMessage);
process.exit(1);
}
}
try {
checkConfigFile();
} catch (error) {
Log.error(error.message);
const message = error && error.message ? error.message : error;
Log.error(`Unexpected error: ${message}`);
process.exit(1);
}

View File

@@ -76,8 +76,8 @@ function createWindow () {
const electronOptions = Object.assign({}, electronOptionsDefaults, config.electronOptions);
if (process.env.JEST_WORKER_ID !== undefined && process.env.MOCK_DATE !== undefined) {
// if we are running with jest and we want to mock the current date
if (process.env.MOCK_DATE !== undefined) {
// if we are running tests and we want to mock the current date
const fakeNow = new Date(process.env.MOCK_DATE).valueOf();
Date = class extends Date {
constructor (...args) {
@@ -114,8 +114,8 @@ function createWindow () {
// Open the DevTools if run with "node --run start:dev"
if (process.argv.includes("dev")) {
if (process.env.JEST_WORKER_ID !== undefined) {
// if we are running with jest
if (process.env.mmTestMode) {
// if we are running tests
const devtools = new BrowserWindow(electronOptions);
mainWindow.webContents.setDevToolsWebContents(devtools.webContents);
}
@@ -169,8 +169,8 @@ function createWindow () {
// Quit when all windows are closed.
app.on("window-all-closed", function () {
if (process.env.JEST_WORKER_ID !== undefined) {
// if we are running with jest
if (process.env.mmTestMode) {
// if we are running tests
app.quit();
} else {
createWindow();

63
js/ip_access_control.js Normal file
View File

@@ -0,0 +1,63 @@
const ipaddr = require("ipaddr.js");
const Log = require("logger");
/**
* Checks if a client IP matches any entry in the whitelist
* @param {string} clientIp - The IP address to check
* @param {string[]} whitelist - Array of IP addresses or CIDR ranges
* @returns {boolean} True if IP is allowed
*/
function isAllowed (clientIp, whitelist) {
try {
const addr = ipaddr.process(clientIp);
return whitelist.some((entry) => {
try {
// CIDR notation
if (entry.includes("/")) {
const [rangeAddr, prefixLen] = ipaddr.parseCIDR(entry);
return addr.match(rangeAddr, prefixLen);
}
// Single IP address - let ipaddr.process normalize both
const allowedAddr = ipaddr.process(entry);
return addr.toString() === allowedAddr.toString();
} catch (err) {
Log.warn(`Invalid whitelist entry: ${entry}`);
return false;
}
});
} catch (err) {
Log.warn(`Failed to parse client IP: ${clientIp}`);
return false;
}
}
/**
* Creates an Express middleware for IP whitelisting
* @param {string[]} whitelist - Array of allowed IP addresses or CIDR ranges
* @returns {import("express").RequestHandler} Express middleware function
*/
function ipAccessControl (whitelist) {
// Empty whitelist means allow all
if (!Array.isArray(whitelist) || whitelist.length === 0) {
return function (req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
next();
};
}
return function (req, res, next) {
const clientIp = req.ip || req.socket.remoteAddress;
if (isAllowed(clientIp, whitelist)) {
res.header("Access-Control-Allow-Origin", "*");
next();
} else {
Log.log(`IP ${clientIp} is not allowed to access the mirror`);
res.status(403).send("This device is not allowed to access your mirror. <br> Please check your config.js or config.js.sample to change this.");
}
};
}
module.exports = { ipAccessControl };

View File

@@ -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();
}
};
/**
@@ -84,7 +107,7 @@ const Loader = (function () {
if (window.name !== "jsdom") {
moduleFolder = defaultModuleFolder;
} else {
// running in Jest, allow defaultModules placed under moduleDir for testing
// running in test mode, allow defaultModules placed under moduleDir for testing
if (envVars.modulesDir === "modules") {
moduleFolder = defaultModuleFolder;
}

View File

@@ -1,13 +1,35 @@
// This logger is very simple, but needs to be extended.
(function (root, factory) {
if (typeof exports === "object") {
if (process.env.JEST_WORKER_ID === undefined) {
if (process.env.mmTestMode !== "true") {
const { styleText } = require("node:util");
// add timestamps in front of log messages
require("console-stamp")(console, {
format: ":date(yyyy-mm-dd HH:MM:ss.l) :label(7) :msg",
format: ":date(yyyy-mm-dd HH:MM:ss.l) :label(7) :pre() :msg",
tokens: {
pre: () => {
const err = new Error();
Error.prepareStackTrace = (_, stack) => stack;
const stack = err.stack;
Error.prepareStackTrace = undefined;
try {
for (const line of stack) {
const file = line.getFileName();
if (file && !file.includes("node:") && !file.includes("js/logger.js") && !file.includes("node_modules")) {
const filename = file.replace(/.*\/(.*).js/, "$1");
const filepath = file.replace(/.*\/(.*)\/.*.js/, "$1");
if (filepath === "js") {
return styleText("grey", `[${filename}]`);
} else {
return styleText("grey", `[${filepath}]`);
}
}
}
} catch (err) {
return styleText("grey", "[unknown]");
}
},
label: (arg) => {
const { method, defaultTokens } = arg;
let label = defaultTokens.label(arg);
@@ -56,8 +78,8 @@
let logLevel;
let enableLog;
if (typeof exports === "object") {
// in nodejs and not running with jest
enableLog = process.env.JEST_WORKER_ID === undefined;
// in nodejs and not running in test mode
enableLog = process.env.mmTestMode !== "true";
} else {
// in browser and not running with jsdom
enableLog = typeof window === "object" && window.name !== "jsdom";
@@ -75,7 +97,7 @@
groupEnd: Function.prototype.bind.call(console.groupEnd, console),
time: Function.prototype.bind.call(console.time, console),
timeEnd: Function.prototype.bind.call(console.timeEnd, console),
timeStamp: Function.prototype.bind.call(console.timeStamp, console)
timeStamp: console.timeStamp ? Function.prototype.bind.call(console.timeStamp, console) : function () {}
};
logLevel.setLogLevel = function (newLevel) {

View File

@@ -1,4 +1,4 @@
/* global Loader, defaults, Translator, addAnimateCSS, removeAnimateCSS, AnimateCSSIn, AnimateCSSOut, modulePositions */
/* global Loader, defaults, Translator, addAnimateCSS, removeAnimateCSS, AnimateCSSIn, AnimateCSSOut, modulePositions, io */
const MM = (function () {
let modules = [];
@@ -268,7 +268,6 @@ const MM = (function () {
const hideModule = function (module, speed, callback, options = {}) {
// set lockString if set in options.
if (options.lockString) {
// Log.log("Has lockstring: " + options.lockString);
if (module.lockStrings.indexOf(options.lockString) === -1) {
module.lockStrings.push(options.lockString);
}
@@ -606,6 +605,18 @@ const MM = (function () {
createDomObjects();
// Setup global socket listener for RELOAD event (watch mode)
if (typeof io !== "undefined") {
const socket = io("/", {
path: `${config.basePath || "/"}socket.io`
});
socket.on("RELOAD", () => {
Log.warn("Reload notification received from server");
window.location.reload(true);
});
}
if (config.reloadAfterServerRestart) {
setInterval(async () => {
// if server startup time has changed (which means server was restarted)

View File

@@ -7,9 +7,9 @@
const Module = Class.extend({
/**
********************************************************
* All methods (and properties) below can be subclassed. *
********************************************************
*********************************************************
* All methods (and properties) below can be overridden. *
*********************************************************
*/
// Set the minimum MagicMirror² module version for this module.
@@ -38,7 +38,6 @@ const Module = Class.extend({
* Called when the module is instantiated.
*/
init () {
//Log.log(this.defaults);
},
/**
@@ -76,8 +75,8 @@ const Module = Class.extend({
/**
* Generates the dom which needs to be displayed. This method is called by the MagicMirror² core.
* This method can to be subclassed if the module wants to display info on the mirror.
* Alternatively, the getTemplate method could be subclassed.
* This method can to be overridden if the module wants to display info on the mirror.
* Alternatively, the getTemplate method could be overridden.
* @returns {HTMLElement|Promise} The dom or a promise with the dom to display.
*/
getDom () {
@@ -110,7 +109,7 @@ const Module = Class.extend({
/**
* Generates the header string which needs to be displayed if a user has a header configured for this module.
* This method is called by the MagicMirror² core, but only if the user has configured a default header for the module.
* This method needs to be subclassed if the module wants to display modified headers on the mirror.
* This method needs to be overridden if the module wants to display modified headers on the mirror.
* @returns {string} The header to display above the header.
*/
getHeader () {
@@ -119,8 +118,8 @@ const Module = Class.extend({
/**
* Returns the template for the module which is used by the default getDom implementation.
* This method needs to be subclassed if the module wants to use a template.
* It can either return a template sting, or a template filename.
* This method needs to be overridden if the module wants to use a template.
* It can either return a template string, or a template filename.
* If the string ends with '.html' it's considered a file from within the module's folder.
* @returns {string} The template string of filename.
*/
@@ -130,7 +129,7 @@ const Module = Class.extend({
/**
* Returns the data to be used in the template.
* This method needs to be subclassed if the module wants to use a custom data.
* This method needs to be overridden if the module wants to use a custom data.
* @returns {object} The data for the template
*/
getTemplateData () {
@@ -145,9 +144,9 @@ const Module = Class.extend({
*/
notificationReceived (notification, payload, sender) {
if (sender) {
// Log.log(this.name + " received a module notification: " + notification + " from sender: " + sender.name);
Log.debug(`${this.name} received a module notification: ${notification} from sender: ${sender.name}`);
} else {
// Log.log(this.name + " received a system notification: " + notification);
Log.debug(`${this.name} received a system notification: ${notification}`);
}
},
@@ -197,9 +196,9 @@ const Module = Class.extend({
},
/**
********************************************
* The methods below don't need subclassing. *
********************************************
***********************************************
* The methods below should not be overridden. *
***********************************************
*/
/**
@@ -415,7 +414,7 @@ const Module = Class.extend({
});
/**
* Merging MagicMirror² (or other) default/config script by @bugsounet
* Merging MagicMirror² (or other) default/config script by `@bugsounet`
* Merge 2 objects or/with array
*
* Usage:

View File

@@ -5,8 +5,8 @@
* @param {Promise} callback function to call when the timer expires
*/
const scheduleTimer = function (timer, intervalMS, callback) {
if (process.env.JEST_WORKER_ID === undefined) {
// only set timer when not running in jest
if (process.env.mmTestMode !== "true") {
// only set timer when not running in test mode
let tmr = timer;
clearTimeout(tmr);
tmr = setTimeout(function () {

View File

@@ -113,8 +113,11 @@ NodeHelper.checkFetchError = function (error) {
let error_type = "MODULE_ERROR_UNSPECIFIED";
if (error.code === "EAI_AGAIN") {
error_type = "MODULE_ERROR_NO_CONNECTION";
} else if (error.message === "Unauthorized") {
error_type = "MODULE_ERROR_UNAUTHORIZED";
} else {
const message = typeof error.message === "string" ? error.message.toLowerCase() : "";
if (message.includes("unauthorized") || message.includes("http 401") || message.includes("http 403")) {
error_type = "MODULE_ERROR_UNAUTHORIZED";
}
}
return error_type;
};

198
js/releasenotes.js Normal file
View File

@@ -0,0 +1,198 @@
/* eslint no-console: "off" */
const util = require("node:util");
const exec = util.promisify(require("node:child_process").exec);
const fs = require("node:fs");
const createReleaseNotes = async () => {
let repoName = "MagicMirrorOrg/MagicMirror";
if (process.env.GITHUB_REPOSITORY) {
repoName = process.env.GITHUB_REPOSITORY;
}
const baseUrl = `https://api.github.com/repos/${repoName}`;
const getOptions = (type) => {
if (process.env.GITHUB_TOKEN) {
return { method: `${type}`, headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } };
} else {
return { method: `${type}` };
}
};
const execShell = async (command) => {
const { stdout = "", stderr = "" } = await exec(command);
if (stderr) console.error(`Error in execShell executing command ${command}: ${stderr}`);
return stdout;
};
// Check Draft Release
const draftReleases = [];
const jsonReleases = await fetch(`${baseUrl}/releases`, getOptions("GET")).then((res) => res.json());
for (const rel of jsonReleases) {
if (rel.draft && rel.tag_name === "" && rel.published_at === null && rel.name === "unreleased") draftReleases.push(rel);
}
let draftReleaseId = 0;
if (draftReleases.length > 1) {
throw new Error("More than one draft release found, exiting.");
} else {
if (draftReleases[0]) draftReleaseId = draftReleases[0].id;
}
// Get last Git Tag
const gitTag = await execShell("git describe --tags `git rev-list --tags --max-count=1`");
const lastTag = gitTag.toString().replaceAll("\n", "");
console.info(`latest tag is ${lastTag}`);
// Get Git Commits
const gitOut = await execShell(`git log develop --pretty=format:"%H --- %s" --after="$(git log -1 --format=%aI ${lastTag})"`);
console.info(gitOut);
const commits = gitOut.toString().split("\n");
// Get Node engine version from package.json
const nodeVersion = JSON.parse(fs.readFileSync("package.json")).engines.node;
// Search strings
const labelArr = ["alert", "calendar", "clock", "compliments", "helloworld", "newsfeed", "updatenotification", "weather", "envcanada", "openmeteo", "openweathermap", "smhi", "ukmetoffice", "yr", "eslint", "bump", "dependencies", "deps", "logg", "translation", "test", "ci"];
// Map search strings to categories
const getFirstLabel = (text) => {
let res;
labelArr.every((item) => {
const labelIncl = text.includes(item);
if (labelIncl) {
switch (item) {
case "ci":
case "test":
res = "testing";
break;
case "logg":
res = "logging";
break;
case "eslint":
case "bump":
case "deps":
res = "dependencies";
break;
case "envcanada":
case "openmeteo":
case "openweathermap":
case "smhi":
case "ukmetoffice":
case "yr":
case "weather":
res = "modules/weather";
break;
case "alert":
res = "modules/alert";
break;
case "calendar":
res = "modules/calendar";
break;
case "clock":
res = "modules/clock";
break;
case "compliments":
res = "modules/compliments";
break;
case "helloworld":
res = "modules/helloworld";
break;
case "newsfeed":
res = "modules/newsfeed";
break;
case "updatenotification":
res = "modules/updatenotification";
break;
default:
res = item;
break;
}
return false;
} else {
return true;
}
});
if (!res) res = "core";
return res;
};
const grouped = {};
const contrib = [];
const sha = [];
// Loop through each Commit
for (const item of commits) {
const cm = item.trim();
// ignore `prepare release` line
if (cm.length > 0 && !cm.match(/^.* --- prepare .*-develop$/gi)) {
const [ref, title] = cm.split(" --- ");
const groupTitle = getFirstLabel(title.toLowerCase());
if (!grouped[groupTitle]) {
grouped[groupTitle] = [];
}
grouped[groupTitle].push(`- ${title}`);
sha.push(ref);
}
}
// function to remove duplicates
const sortedArr = (arr) => {
return arr.filter((item,
index) => (arr.indexOf(item) === index && item !== "@dependabot[bot]")).sort(function (a, b) {
return a.toLowerCase().localeCompare(b.toLowerCase());
});
};
// Get Contributors logins
for (const ref of sha) {
const jsonRes = await fetch(`${baseUrl}/commits/${ref}`, getOptions("GET")).then((res) => res.json());
if (jsonRes && jsonRes.author && jsonRes.author.login) contrib.push(`@${jsonRes.author.login}`);
}
// Build Markdown content
let markdown = "## Release Notes\n";
markdown += `Thanks to: ${sortedArr(contrib).join(", ")}\n`;
markdown += `> ⚠️ This release needs nodejs version ${nodeVersion}\n`;
markdown += "\n";
markdown += `[Compare to previous Release ${lastTag}](https://github.com/${repoName}/compare/${lastTag}...develop)\n\n`;
const sorted = Object.keys(grouped)
.sort() // Sort the keys alphabetically
.reduce((obj, key) => {
obj[key] = grouped[key]; // Rebuild the object with sorted keys
return obj;
}, {});
for (const group in sorted) {
markdown += `\n### [${group}]\n`;
markdown += `${sorted[group].join("\n")}\n`;
}
console.info(markdown);
// Create Github Release
if (process.env.GITHUB_TOKEN) {
if (draftReleaseId > 0) {
// delete release
await fetch(`${baseUrl}/releases/${draftReleaseId}`, getOptions("DELETE"));
console.info(`Old Release with id ${draftReleaseId} deleted.`);
}
const relContent = getOptions("POST");
relContent.body = JSON.stringify(
{ tag_name: "", name: "unreleased", body: `${markdown}`, draft: true }
);
const createRelease = await fetch(`${baseUrl}/releases`, relContent).then((res) => res.json());
console.info(`New release created with id ${createRelease.id}, GitHub-Url: ${createRelease.html_url}`);
}
};
createReleaseNotes();

View File

@@ -3,12 +3,13 @@ const http = require("node:http");
const https = require("node:https");
const path = require("node:path");
const express = require("express");
const ipfilter = require("express-ipfilter").IpFilter;
const helmet = require("helmet");
const socketio = require("socket.io");
const Log = require("logger");
const { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars } = require("#server_functions");
const { ipAccessControl } = require(`${__dirname}/ip_access_control`);
const vendor = require(`${__dirname}/vendor`);
/**
@@ -84,17 +85,7 @@ function Server (config) {
Log.warn("You're using a full whitelist configuration to allow for all IPs");
}
app.use(function (req, res, next) {
ipfilter(config.ipWhitelist, { mode: config.ipWhitelist.length === 0 ? "deny" : "allow", log: false })(req, res, function (err) {
if (err === undefined) {
res.header("Access-Control-Allow-Origin", "*");
return next();
}
Log.log(err.message);
res.status(403).send("This device is not allowed to access your mirror. <br> Please check your config.js or config.js.sample to change this.");
});
});
app.use(ipAccessControl(config.ipWhitelist));
app.use(helmet(config.httpHeaders));
app.use("/js", express.static(__dirname));
@@ -120,6 +111,13 @@ function Server (config) {
app.get("/", (req, res) => getHtml(req, res));
// Reload endpoint for watch mode - triggers browser reload
app.get("/reload", (req, res) => {
Log.info("Reload request received, notifying all clients");
io.emit("RELOAD");
res.status(200).send("OK");
});
server.on("listening", () => {
resolve({
app,

View File

@@ -30,6 +30,7 @@ function getStartup (req, res) {
* Only the url-param of the input request url is required. It must be the last parameter.
* @param {Request} req - the request
* @param {Response} res - the result
* @returns {Promise<void>} A promise that resolves when the response is sent
*/
async function cors (req, res) {
try {
@@ -40,26 +41,32 @@ async function cors (req, res) {
if (!match) {
url = `invalid url: ${req.url}`;
Log.error(url);
res.send(url);
return res.status(400).send(url);
} else {
url = match[1];
const headersToSend = getHeadersToSend(req.url);
const expectedReceivedHeaders = geExpectedReceivedHeaders(req.url);
Log.log(`cors url: ${url}`);
const response = await fetch(url, { headers: headersToSend });
for (const header of expectedReceivedHeaders) {
const headerValue = response.headers.get(header);
if (header) res.set(header, headerValue);
const response = await fetch(url, { headers: headersToSend });
if (response.ok) {
for (const header of expectedReceivedHeaders) {
const headerValue = response.headers.get(header);
if (header) res.set(header, headerValue);
}
const data = await response.text();
res.send(data);
} else {
throw new Error(`Response status: ${response.status}`);
}
const data = await response.text();
res.send(data);
}
} catch (error) {
Log.error(error);
res.send(error);
// Only log errors in non-test environments to keep test output clean
if (process.env.mmTestMode !== "true") {
Log.error(`Error in CORS request: ${error}`);
}
res.status(500).json({ error: error.message });
}
}
@@ -176,4 +183,22 @@ function getEnvVars (req, res) {
res.send(obj);
}
module.exports = { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj, getUserAgent };
/**
* Get the config file path from environment or default location
* @returns {string} The absolute config file path
*/
function getConfigFilePath () {
// Ensure root_path is set (for standalone contexts like watcher)
if (!global.root_path) {
global.root_path = path.resolve(`${__dirname}/../`);
}
// Check environment variable if global not set
if (!global.configuration_file && process.env.MM_CONFIG_FILE) {
global.configuration_file = process.env.MM_CONFIG_FILE;
}
return path.resolve(global.configuration_file || `${global.root_path}/config/config.js`);
}
module.exports = { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj, getUserAgent, getConfigFilePath };

View File

@@ -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(` 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 {
@@ -67,22 +61,18 @@ const Translator = (function () {
}
if (this.translations[module.name] && key in this.translations[module.name]) {
// Log.log("Got translation for " + key + " from module translation: ");
return createStringFromTemplate(this.translations[module.name][key], variables);
}
if (key in this.coreTranslations) {
// Log.log("Got translation for " + key + " from core translation.");
return createStringFromTemplate(this.coreTranslations[key], variables);
}
if (this.translationsFallback[module.name] && key in this.translationsFallback[module.name]) {
// Log.log("Got translation for " + key + " from module translation fallback.");
return createStringFromTemplate(this.translationsFallback[module.name][key], variables);
}
if (key in this.coreTranslationsFallback) {
// Log.log("Got translation for " + key + " from core translation fallback.");
return createStringFromTemplate(this.coreTranslationsFallback[key], variables);
}
@@ -96,7 +86,7 @@ const Translator = (function () {
* @param {boolean} isFallback Flag to indicate fallback translations.
*/
async load (module, file, isFallback) {
Log.log(`${module.name} - Load translation${isFallback ? " fallback" : ""}: ${file}`);
Log.log(`[translator] ${module.name} - Load translation${isFallback ? " fallback" : ""}: ${file}`);
if (this.translationsFallback[module.name]) {
return;
@@ -113,10 +103,10 @@ const Translator = (function () {
*/
async loadCoreTranslations (lang) {
if (lang in translations) {
Log.log(`Loading core translation file: ${translations[lang]}`);
Log.log(`[translator] Loading core translation file: ${translations[lang]}`);
this.coreTranslations = await loadJSON(translations[lang]);
} else {
Log.log("Configured language not found in core translations.");
Log.log("[translator] Configured language not found in core translations.");
}
await this.loadCoreTranslationsFallback();
@@ -129,7 +119,7 @@ const Translator = (function () {
async loadCoreTranslationsFallback () {
let first = Object.keys(translations)[0];
if (first) {
Log.log(`Loading core translation fallback file: ${translations[first]}`);
Log.log(`[translator] Loading core translation fallback file: ${translations[first]}`);
this.coreTranslationsFallback = await loadJSON(translations[first]);
}
}

View File

@@ -1,10 +1,7 @@
const path = require("node:path");
const rootPath = path.resolve(`${__dirname}/../`);
const Log = require(`${rootPath}/js/logger.js`);
const os = require("node:os");
const fs = require("node:fs");
const si = require("systeminformation");
const Log = require("logger");
const modulePositions = []; // will get list from index.html
const regionRegEx = /"region ([^"]*)/i;
@@ -37,7 +34,7 @@ module.exports = {
].join("\n");
Log.info(systemDataString);
// Return is currently only for jest
// Return is currently only for tests
return systemDataString;
} catch (error) {
Log.error(error);
@@ -68,8 +65,10 @@ module.exports = {
if (results && results.length > 0) {
// get the position parts and replace space with underscore
const positionName = results[1].replace(" ", "_");
// add it to the list
modulePositions.push(positionName);
// add it to the list only if not already present (avoid duplicates)
if (!modulePositions.includes(positionName)) {
modulePositions.push(positionName);
}
}
});
try {

View File

@@ -30,6 +30,8 @@ Module.register("alert", {
fr: "translations/fr.json",
hu: "translations/hu.json",
nl: "translations/nl.json",
pt: "translations/pt.json",
"pt-br": "translations/pt-br.json",
ru: "translations/ru.json",
th: "translations/th.json"
};
@@ -124,7 +126,7 @@ Module.register("alert", {
return new Promise((resolve) => {
this.nunjucksEnvironment().render(this.getTemplate(type), data, function (err, res) {
if (err) {
Log.error("Failed to render alert", err);
Log.error("[alert] Failed to render alert", err);
}
resolve(res);

View File

@@ -59,7 +59,7 @@
// notice, warning, error, success
// will add class ns-type-warning, ns-type-error or ns-type-success
type: "notice",
// if the user doesn´t close the notification then we remove it
// if the user doesn't close the notification then we remove it
// after the following time
ttl: 6000,
al_no: "ns-box",

View File

@@ -0,0 +1,4 @@
{
"sysTitle": "Notificação do MagicMirror²",
"welcome": "Bem-vindo, o sistema iniciou com sucesso!"
}

View File

@@ -0,0 +1,4 @@
{
"sysTitle": "Notificação do MagicMirror²",
"welcome": "Bem-vindo, o sistema iniciou com sucesso!"
}

View File

@@ -68,8 +68,6 @@ Module.register("calendar", {
updateOnFetch: true
},
requiresVersion: "2.1.0",
// Define required scripts.
getStyles () {
return ["calendar.css", "font-awesome.css"];
@@ -96,12 +94,12 @@ Module.register("calendar", {
Log.info(`Starting module: ${this.name}`);
if (this.config.colored) {
Log.warn("Your are using the deprecated config values 'colored'. Please switch to 'coloredSymbol' & 'coloredText'!");
Log.warn("[calendar] Your are using the deprecated config values 'colored'. Please switch to 'coloredSymbol' & 'coloredText'!");
this.config.coloredText = true;
this.config.coloredSymbol = true;
}
if (this.config.coloredSymbolOnly) {
Log.warn("Your are using the deprecated config values 'coloredSymbolOnly'. Please switch to 'coloredSymbol' & 'coloredText'!");
Log.warn("[calendar] Your are using the deprecated config values 'coloredSymbolOnly'. Please switch to 'coloredSymbol' & 'coloredText'!");
this.config.coloredText = false;
this.config.coloredSymbol = true;
}
@@ -143,7 +141,7 @@ Module.register("calendar", {
// we check user and password here for backwards compatibility with old configs
if (calendar.user && calendar.pass) {
Log.warn("Deprecation warning: Please update your calendar authentication configuration.");
Log.warn("[calendar] Deprecation warning: Please update your calendar authentication configuration.");
Log.warn("https://docs.magicmirror.builders/modules/calendar.html#configuration-options");
calendar.auth = {
user: calendar.user,
@@ -160,7 +158,7 @@ Module.register("calendar", {
// for backward compatibility titleReplace
if (typeof this.config.titleReplace !== "undefined") {
Log.warn("Deprecation warning: Please consider upgrading your calendar titleReplace configuration to customEvents.");
Log.warn("[calendar] Deprecation warning: Please consider upgrading your calendar titleReplace configuration to customEvents.");
for (const [titlesearchstr, titlereplacestr] of Object.entries(this.config.titleReplace)) {
this.config.customEvents.push({ keyword: ".*", transform: { search: titlesearchstr, replace: titlereplacestr } });
}
@@ -186,13 +184,27 @@ Module.register("calendar", {
if (notification === "CALENDAR_EVENTS") {
if (this.hasCalendarURL(payload.url)) {
this.calendarData[payload.url] = payload.events;
// have we received events for this url
if (!this.calendarData[payload.url]) {
// no, setup the structure to hold the info
this.calendarData[payload.url] = { events: null, checksum: null };
}
// save the event list
this.calendarData[payload.url].events = payload.events;
this.error = null;
this.loaded = true;
if (this.config.broadcastEvents) {
this.broadcastEvents();
}
// if the checksum is the same
if (this.calendarData[payload.url].checksum === payload.checksum) {
// then don't update the UI
return;
}
// haven't seen or the checksum is different
this.calendarData[payload.url].checksum = payload.checksum;
if (!this.config.updateOnFetch) {
if (this.calendarDisplayer[payload.url] === undefined) {
@@ -201,7 +213,7 @@ Module.register("calendar", {
// set this calendar as displayed
this.calendarDisplayer[payload.url] = true;
} else {
Log.debug("[Calendar] DOM not updated waiting self update()");
Log.debug("[calendar] DOM not updated waiting self update()");
}
return;
}
@@ -332,7 +344,9 @@ Module.register("calendar", {
const thisYear = eventStartDateMoment.year(),
yearDiff = thisYear - event.firstYear;
repeatingCountTitle = `, ${yearDiff} ${repeatingCountTitle}`;
if (yearDiff > 0) {
repeatingCountTitle = `, ${yearDiff} ${repeatingCountTitle}`;
}
}
}
@@ -411,7 +425,7 @@ Module.register("calendar", {
timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.dateFormat));
// Add end time if showEnd
if (this.config.showEnd) {
// and has a duation
// and has a duration
if (event.startDate !== event.endDate) {
timeWrapper.innerHTML += "-";
timeWrapper.innerHTML += CalendarUtils.capFirst(eventEndDateMoment.format(this.config.dateEndFormat));
@@ -461,10 +475,10 @@ Module.register("calendar", {
if (eventStartDateMoment.isSameOrAfter(now) || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) {
// Use relative time
if (!this.config.hideTime && !event.fullDayEvent) {
Log.debug("event not hidden and not fullday");
Log.debug("[calendar] event not hidden and not fullday");
timeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.calendar(null, { sameElse: this.config.dateFormat }))}`;
} else {
Log.debug("event full day or hidden");
Log.debug("[calendar] event full day or hidden");
timeWrapper.innerHTML = `${CalendarUtils.capFirst(
eventStartDateMoment.calendar(null, {
sameDay: this.config.showTimeToday ? "LT" : `[${this.translate("TODAY")}]`,
@@ -491,9 +505,9 @@ Module.register("calendar", {
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW"));
}
}
Log.info("event fullday");
Log.info("[calendar] event fullday");
} else if (eventStartDateMoment.diff(now, "h") < this.config.getRelative) {
Log.info("not full day but within getrelative size");
Log.info("[calendar] not full day but within getRelative size");
// If event is within getRelative hours, display 'in xxx' time format or moment.fromNow()
timeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.fromNow())}`;
}
@@ -602,7 +616,7 @@ Module.register("calendar", {
let events = [];
for (const calendarUrl in this.calendarData) {
const calendar = this.calendarData[calendarUrl];
const calendar = this.calendarData[calendarUrl].events;
let remainingEntries = this.maximumEntriesForUrl(calendarUrl);
let maxPastDaysCompare = now.clone().subtract(this.maximumPastDaysForUrl(calendarUrl), "days");
let by_url_calevents = [];
@@ -680,14 +694,14 @@ Module.register("calendar", {
by_url_calevents.sort(function (a, b) {
return a.startDate - b.startDate;
});
Log.debug(`pushing ${by_url_calevents.length} events to total with room for ${remainingEntries}`);
Log.debug(`[calendar] pushing ${by_url_calevents.length} events to total with room for ${remainingEntries}`);
events = events.concat(by_url_calevents.slice(0, remainingEntries));
Log.debug(`events for calendar=${events.length}`);
Log.debug(`[calendar] events for calendar=${events.length}`);
} else {
events = events.concat(by_url_calevents);
}
}
Log.info(`sorting events count=${events.length}`);
Log.info(`[calendar] sorting events count=${events.length}`);
events.sort(function (a, b) {
return a.startDate - b.startDate;
});
@@ -721,7 +735,7 @@ Module.register("calendar", {
}
events = newEvents;
}
Log.info(`slicing events total maxcount=${this.config.maximumEntries}`);
Log.info(`[calendar] slicing events total maxCount=${this.config.maximumEntries}`);
return events.slice(0, this.config.maximumEntries);
},
@@ -876,7 +890,7 @@ Module.register("calendar", {
* @param {string} url The calendar url
* @param {string} property The property to look for
* @param {string} defaultValue The value if the property is not found
* @returns {property} The property
* @returns {string} The property
*/
getCalendarProperty (url, property, defaultValue) {
for (const calendar of this.config.calendars) {
@@ -909,7 +923,7 @@ Module.register("calendar", {
/**
* Broadcasts the events to all other modules for reuse.
* The all events available in one array, sorted on startdate.
* The all events available in one array, sorted on startDate.
*/
broadcastEvents () {
const eventList = this.createEventList(false);
@@ -936,7 +950,7 @@ Module.register("calendar", {
setTimeout(
() => {
setInterval(() => {
Log.debug("[Calendar] self update");
Log.debug("[calendar] self update");
if (this.config.updateOnFetch) {
this.updateDom(1);
} else {

View File

@@ -1,131 +1,222 @@
const https = require("node:https");
const ical = require("node-ical");
const Log = require("logger");
const NodeHelper = require("node_helper");
const CalendarFetcherUtils = require("./calendarfetcherutils");
const { getUserAgent } = require("#server_functions");
const { scheduleTimer } = require("#module_functions");
const FIFTEEN_MINUTES = 15 * 60 * 1000;
const THIRTY_MINUTES = 30 * 60 * 1000;
const MAX_SERVER_BACKOFF = 3;
/**
*
* @param {string} url The url of the calendar to fetch
* @param {number} reloadInterval Time in ms the calendar is fetched again
* @param {string[]} excludedEvents An array of words / phrases from event titles that will be excluded from being shown.
* @param {number} maximumEntries The maximum number of events fetched.
* @param {number} maximumNumberOfDays The maximum number of days an event should be in the future.
* @param {object} auth The object containing options for authentication against the calendar.
* @param {boolean} includePastEvents If true events from the past maximumNumberOfDays will be fetched too
* @param {boolean} selfSignedCert If true, the server certificate is not verified against the list of supplied CAs.
* CalendarFetcher - Fetches and parses iCal calendar data with MagicMirror-focused error handling
* @class
*/
const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) {
let reloadTimer = null;
let events = [];
let fetchFailedCallback = function () {};
let eventsReceivedCallback = function () {};
class CalendarFetcher {
/**
* Initiates calendar fetch.
* Creates a new CalendarFetcher instance
* @param {string} url - The URL of the calendar to fetch
* @param {number} reloadInterval - Time in ms between fetches
* @param {string[]} excludedEvents - Event titles to exclude
* @param {number} maximumEntries - Maximum number of events to return
* @param {number} maximumNumberOfDays - Maximum days in the future to fetch
* @param {object} auth - Authentication options {method: 'basic'|'bearer', user, pass}
* @param {boolean} includePastEvents - Whether to include past events
* @param {boolean} selfSignedCert - Whether to accept self-signed certificates
*/
const fetchCalendar = () => {
clearTimeout(reloadTimer);
reloadTimer = null;
let httpsAgent = null;
let headers = {
"User-Agent": getUserAgent()
};
constructor (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) {
this.url = url;
this.reloadInterval = reloadInterval;
this.excludedEvents = excludedEvents;
this.maximumEntries = maximumEntries;
this.maximumNumberOfDays = maximumNumberOfDays;
this.auth = auth;
this.includePastEvents = includePastEvents;
this.selfSignedCert = selfSignedCert;
if (selfSignedCert) {
httpsAgent = new https.Agent({
rejectUnauthorized: false
});
this.events = [];
this.reloadTimer = null;
this.serverErrorCount = 0;
this.lastFetch = null;
this.fetchFailedCallback = () => {};
this.eventsReceivedCallback = () => {};
}
/**
* Clears any pending reload timer
*/
clearReloadTimer () {
if (this.reloadTimer) {
clearTimeout(this.reloadTimer);
this.reloadTimer = null;
}
if (auth) {
if (auth.method === "bearer") {
headers.Authorization = `Bearer ${auth.pass}`;
}
/**
* Schedules the next fetch respecting MagicMirror test mode
* @param {number} delay - Delay in milliseconds
*/
scheduleNextFetch (delay) {
const nextDelay = Math.max(delay || this.reloadInterval, this.reloadInterval);
if (process.env.mmTestMode === "true") {
return;
}
this.reloadTimer = setTimeout(() => this.fetchCalendar(), nextDelay);
}
/**
* Builds the options object for fetch
* @returns {object} Options object containing headers (and agent if needed)
*/
getRequestOptions () {
const headers = { "User-Agent": getUserAgent() };
const options = { headers };
if (this.selfSignedCert) {
options.agent = new https.Agent({ rejectUnauthorized: false });
}
if (this.auth) {
if (this.auth.method === "bearer") {
headers.Authorization = `Bearer ${this.auth.pass}`;
} else {
headers.Authorization = `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString("base64")}`;
headers.Authorization = `Basic ${Buffer.from(`${this.auth.user}:${this.auth.pass}`).toString("base64")}`;
}
}
fetch(url, { headers: headers, agent: httpsAgent })
.then(NodeHelper.checkFetchStatus)
.then((response) => response.text())
.then((responseData) => {
let data = [];
return options;
}
/**
* Parses the Retry-After header value
* @param {string} retryAfter - The Retry-After header value
* @returns {number|null} Milliseconds to wait or null if parsing failed
*/
parseRetryAfter (retryAfter) {
const seconds = Number(retryAfter);
if (!Number.isNaN(seconds) && seconds >= 0) {
return seconds * 1000;
}
const retryDate = Date.parse(retryAfter);
if (!Number.isNaN(retryDate)) {
return Math.max(0, retryDate - Date.now());
}
return null;
}
/**
* Determines the retry delay for a non-ok response
* @param {Response} response - The fetch Response object
* @returns {{delay: number, error: Error}} Error describing the issue and computed retry delay
*/
getDelayForResponse (response) {
const { status, statusText = "" } = response;
let delay = this.reloadInterval;
if (status === 401 || status === 403) {
delay = Math.max(this.reloadInterval * 5, THIRTY_MINUTES);
Log.error(`${this.url} - Authentication failed (${status}). Waiting ${Math.round(delay / 60000)} minutes before retry.`);
} else if (status === 429) {
const retryAfter = response.headers.get("retry-after");
const parsed = retryAfter ? this.parseRetryAfter(retryAfter) : null;
delay = parsed !== null ? Math.max(parsed, this.reloadInterval) : Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES);
Log.warn(`${this.url} - Rate limited (429). Retrying in ${Math.round(delay / 60000)} minutes.`);
} else if (status >= 500) {
this.serverErrorCount = Math.min(this.serverErrorCount + 1, MAX_SERVER_BACKOFF);
delay = this.reloadInterval * Math.pow(2, this.serverErrorCount);
Log.error(`${this.url} - Server error (${status}). Retry #${this.serverErrorCount} in ${Math.round(delay / 60000)} minutes.`);
} else if (status >= 400) {
delay = Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES);
Log.error(`${this.url} - Client error (${status}). Retrying in ${Math.round(delay / 60000)} minutes.`);
} else {
Log.error(`${this.url} - Unexpected HTTP status ${status}.`);
}
return {
delay,
error: new Error(`HTTP ${status} ${statusText}`.trim())
};
}
/**
* Fetches and processes calendar data
*/
async fetchCalendar () {
this.clearReloadTimer();
let nextDelay = this.reloadInterval;
try {
const response = await fetch(this.url, this.getRequestOptions());
if (!response.ok) {
const { delay, error } = this.getDelayForResponse(response);
nextDelay = delay;
this.fetchFailedCallback(this, error);
} else {
this.serverErrorCount = 0;
const responseData = await response.text();
try {
data = ical.parseICS(responseData);
Log.debug(`parsed data=${JSON.stringify(data, null, 2)}`);
events = CalendarFetcherUtils.filterEvents(data, {
excludedEvents,
includePastEvents,
maximumEntries,
maximumNumberOfDays
const parsed = ical.parseICS(responseData);
Log.debug(`Parsed iCal data from ${this.url} with ${Object.keys(parsed).length} entries.`);
this.events = CalendarFetcherUtils.filterEvents(parsed, {
excludedEvents: this.excludedEvents,
includePastEvents: this.includePastEvents,
maximumEntries: this.maximumEntries,
maximumNumberOfDays: this.maximumNumberOfDays
});
this.lastFetch = Date.now();
this.broadcastEvents();
} catch (error) {
fetchFailedCallback(this, error);
scheduleTimer(reloadTimer, reloadInterval, fetchCalendar);
return;
Log.error(`${this.url} - iCal parsing failed: ${error.message}`);
this.fetchFailedCallback(this, error);
}
this.broadcastEvents();
scheduleTimer(reloadTimer, reloadInterval, fetchCalendar);
})
.catch((error) => {
fetchFailedCallback(this, error);
scheduleTimer(reloadTimer, reloadInterval, fetchCalendar);
});
};
}
} catch (error) {
Log.error(`${this.url} - Fetch failed: ${error.message}`);
this.fetchFailedCallback(this, error);
}
/* public methods */
this.scheduleNextFetch(nextDelay);
}
/**
* Initiate fetchCalendar();
* Check if enough time has passed since the last fetch to warrant a new one.
* Uses reloadInterval as the threshold to respect user's configured fetchInterval.
* @returns {boolean} True if a new fetch should be performed
*/
this.startFetch = function () {
fetchCalendar();
};
shouldRefetch () {
if (!this.lastFetch) {
return true;
}
const timeSinceLastFetch = Date.now() - this.lastFetch;
return timeSinceLastFetch >= this.reloadInterval;
}
/**
* Broadcast the existing events.
* Broadcasts the current events to listeners
*/
this.broadcastEvents = function () {
Log.info(`Calendar-Fetcher: Broadcasting ${events.length} events from ${url}.`);
eventsReceivedCallback(this);
};
broadcastEvents () {
Log.info(`Broadcasting ${this.events.length} events from ${this.url}.`);
this.eventsReceivedCallback(this);
}
/**
* Sets the on success callback
* @param {eventsReceivedCallback} callback The on success callback.
* Sets the callback for successful event fetches
* @param {(fetcher: CalendarFetcher) => void} callback - Called when events are received
*/
this.onReceive = function (callback) {
eventsReceivedCallback = callback;
};
onReceive (callback) {
this.eventsReceivedCallback = callback;
}
/**
* Sets the on error callback
* @param {fetchFailedCallback} callback The on error callback.
* Sets the callback for fetch failures
* @param {(fetcher: CalendarFetcher, error: Error) => void} callback - Called when a fetch fails
*/
this.onError = function (callback) {
fetchFailedCallback = callback;
};
/**
* Returns the url of this fetcher.
* @returns {string} The url of this fetcher.
*/
this.url = function () {
return url;
};
/**
* Returns current available events for this fetcher.
* @returns {object[]} The current available events for this fetcher.
*/
this.events = function () {
return events;
};
};
onError (callback) {
this.fetchFailedCallback = callback;
}
}
module.exports = CalendarFetcher;

View File

@@ -9,60 +9,26 @@ const CalendarFetcherUtils = {
/**
* Determine based on the title of an event if it should be excluded from the list of events
* TODO This seems like an overly complicated way to exclude events based on the title.
* @param {object} config the global config
* @param {string} title the title of the event
* @returns {object} excluded: true if the event should be excluded, false otherwise
* until: the date until the event should be excluded.
*/
shouldEventBeExcluded (config, title) {
let result = {
for (const filterConfig of config.excludedEvents) {
const match = CalendarFetcherUtils.checkEventAgainstFilter(title, filterConfig);
if (match) {
return {
excluded: !match.until,
until: match.until
};
}
}
return {
excluded: false,
until: null
};
for (let f in config.excludedEvents) {
let filter = config.excludedEvents[f],
testTitle = title.toLowerCase(),
until = null,
useRegex = false,
regexFlags = "g";
if (filter instanceof Object) {
if (typeof filter.until !== "undefined") {
until = filter.until;
}
if (typeof filter.regex !== "undefined") {
useRegex = filter.regex;
}
// If additional advanced filtering is added in, this section
// must remain last as we overwrite the filter object with the
// filterBy string
if (filter.caseSensitive) {
filter = filter.filterBy;
testTitle = title;
} else if (useRegex) {
filter = filter.filterBy;
testTitle = title;
regexFlags += "i";
} else {
filter = filter.filterBy.toLowerCase();
}
} else {
filter = filter.toLowerCase();
}
if (CalendarFetcherUtils.titleFilterApplies(testTitle, filter, useRegex, regexFlags)) {
if (until) {
result.until = until;
} else {
result.excluded = true;
}
break;
}
}
return result;
},
/**
@@ -84,47 +50,44 @@ const CalendarFetcherUtils = {
*/
getMomentsFromRecurringEvent (event, pastLocalMoment, futureLocalMoment, durationInMs) {
const rule = event.rrule;
const isFullDayEvent = CalendarFetcherUtils.isFullDayEvent(event);
const eventTimezone = event.start.tz || CalendarFetcherUtils.getLocalTimezone();
// can cause problems with e.g. birthdays before 1900
if ((rule.options && rule.origOptions && rule.origOptions.dtstart && rule.origOptions.dtstart.getFullYear() < 1900) || (rule.options && rule.options.dtstart && rule.options.dtstart.getFullYear() < 1900)) {
rule.origOptions.dtstart.setYear(1900);
rule.options.dtstart.setYear(1900);
// rrule.js interprets years < 1900 as offsets from 1900, causing issues with some birthday calendars
if (rule.origOptions?.dtstart?.getFullYear() < 1900) {
rule.origOptions.dtstart.setFullYear(1900);
}
if (rule.options?.dtstart?.getFullYear() < 1900) {
rule.options.dtstart.setFullYear(1900);
}
// subtract the max of the duration of this event or 1 day to find events in the past that are currently still running and should therefor be displayed.
const oneDayInMs = 24 * 60 * 60000;
let searchFromDate = pastLocalMoment.clone().subtract(Math.max(durationInMs, oneDayInMs), "milliseconds").toDate();
let searchToDate = futureLocalMoment.clone().add(1, "days").toDate();
Log.debug(`Search for recurring events between: ${searchFromDate} and ${searchToDate}`);
// Expand search window to include ongoing events
const oneDayInMs = 24 * 60 * 60 * 1000;
const searchFromDate = pastLocalMoment.clone().subtract(Math.max(durationInMs, oneDayInMs), "milliseconds").toDate();
const searchToDate = futureLocalMoment.clone().add(1, "days").toDate();
// if until is set, and its a full day event, force the time to midnight. rrule gets confused with non-00 offset
// looks like MS Outlook sets the until time incorrectly for fullday events
if ((rule.options.until !== undefined) && CalendarFetcherUtils.isFullDayEvent(event)) {
Log.debug("fixup rrule until");
rule.options.until = moment(rule.options.until).clone().startOf("day").add(1, "day")
.toDate();
// For all-day events, extend "until" to end of day to include the final occurrence
if (isFullDayEvent && rule.options?.until) {
rule.options.until = moment(rule.options.until).endOf("day").toDate();
}
Log.debug("fix rrule start=", rule.options.dtstart);
Log.debug("event before rrule.between=", JSON.stringify(event, null, 2), "exdates=", event.exdate);
// Clear tzid to prevent rrule.js from double-adjusting times
if (rule.options) {
rule.options.tzid = null;
}
Log.debug(`RRule: ${rule.toString()}`);
rule.options.tzid = null; // RRule gets *very* confused with timezones
const dates = rule.between(searchFromDate, searchToDate, true) || [];
let dates = rule.between(searchFromDate, searchToDate, true, () => {
return true;
// Convert dates to moments in the appropriate timezone
// rrule.js returns UTC dates with tzid cleared, so we interpret them in the event's original timezone
return dates.map((date) => {
if (isFullDayEvent) {
// For all-day events, anchor to calendar day in event's timezone
return moment.tz(date, eventTimezone).startOf("day");
}
// For timed events, preserve the time in the event's original timezone
return moment.tz(date, "UTC").tz(eventTimezone, true);
});
Log.debug(`Title: ${event.summary}, with dates: \n\n${JSON.stringify(dates)}\n`);
// shouldn't need this anymore, as RRULE not passed junk
dates = dates.filter((d) => {
return JSON.stringify(d) !== "null";
});
// Dates are returned in UTC timezone but with localdatetime because tzid is null.
// So we map the date to a moment using the original timezone of the event.
return dates.map((d) => (event.start.tz ? moment.tz(d, "UTC").tz(event.start.tz, true) : moment.tz(d, "UTC").tz(CalendarFetcherUtils.getLocalTimezone(), true)));
},
/**
@@ -193,7 +156,7 @@ const CalendarFetcherUtils = {
}
Log.debug(`start: ${eventStartMoment.toDate()}`);
Log.debug(`end:: ${eventEndMoment.toDate()}`);
Log.debug(`end: ${eventEndMoment.toDate()}`);
// Calculate the duration of the event for use with recurring events.
const durationMs = eventEndMoment.valueOf() - eventStartMoment.valueOf();
@@ -203,137 +166,51 @@ const CalendarFetcherUtils = {
const geo = event.geo || false;
const description = event.description || false;
// TODO This should be a seperate function.
let instances = [];
if (event.rrule && typeof event.rrule !== "undefined" && !isFacebookBirthday) {
// Recurring event.
let moments = CalendarFetcherUtils.getMomentsFromRecurringEvent(event, pastLocalMoment, futureLocalMoment, durationMs);
// Loop through the set of moment entries to see which recurrences should be added to our event list.
// TODO This should create an event per moment so we can change anything we want.
for (let m in moments) {
let curEvent = event;
let showRecurrence = true;
let recurringEventStartMoment = moments[m].tz(CalendarFetcherUtils.getLocalTimezone()).clone();
let recurringEventEndMoment = recurringEventStartMoment.clone().add(durationMs, "ms");
let dateKey = recurringEventStartMoment.tz("UTC").format("YYYY-MM-DD");
Log.debug("event date dateKey=", dateKey);
// For each date that we're checking, it's possible that there is a recurrence override for that one day.
if (curEvent.recurrences !== undefined) {
Log.debug("have recurrences=", curEvent.recurrences);
if (curEvent.recurrences[dateKey] !== undefined) {
Log.debug("have a recurrence match for dateKey=", dateKey);
// We found an override, so for this recurrence, use a potentially different title, start date, and duration.
curEvent = curEvent.recurrences[dateKey];
// Some event start/end dates don't have timezones
if (curEvent.start.tz) {
recurringEventStartMoment = moment(curEvent.start).tz(curEvent.start.tz).tz(CalendarFetcherUtils.getLocalTimezone());
} else {
recurringEventStartMoment = moment(curEvent.start).tz(CalendarFetcherUtils.getLocalTimezone());
}
if (curEvent.end.tz) {
recurringEventEndMoment = moment(curEvent.end).tz(curEvent.end.tz).tz(CalendarFetcherUtils.getLocalTimezone());
} else {
recurringEventEndMoment = moment(curEvent.end).tz(CalendarFetcherUtils.getLocalTimezone());
}
} else {
Log.debug("recurrence key ", dateKey, " doesn't match");
}
}
// If there's no recurrence override, check for an exception date. Exception dates represent exceptions to the rule.
if (curEvent.exdate !== undefined) {
Log.debug("have datekey=", dateKey, " exdates=", curEvent.exdate);
if (curEvent.exdate[dateKey] !== undefined) {
// This date is an exception date, which means we should skip it in the recurrence pattern.
showRecurrence = false;
}
}
if (recurringEventStartMoment.valueOf() === recurringEventEndMoment.valueOf()) {
recurringEventEndMoment = recurringEventEndMoment.endOf("day");
}
const recurrenceTitle = CalendarFetcherUtils.getTitleFromEvent(curEvent);
// If this recurrence ends before the start of the date range, or starts after the end of the date range, don"t add
// it to the event list.
if (recurringEventEndMoment.isBefore(pastLocalMoment) || recurringEventStartMoment.isAfter(futureLocalMoment)) {
showRecurrence = false;
}
if (CalendarFetcherUtils.timeFilterApplies(now, recurringEventEndMoment, eventFilterUntil)) {
showRecurrence = false;
}
if (showRecurrence === true) {
Log.debug(`saving event: ${recurrenceTitle}`);
newEvents.push({
title: recurrenceTitle,
startDate: recurringEventStartMoment.format("x"),
endDate: recurringEventEndMoment.format("x"),
fullDayEvent: CalendarFetcherUtils.isFullDayEvent(event),
recurringEvent: true,
class: event.class,
firstYear: event.start.getFullYear(),
location: location,
geo: geo,
description: description
});
} else {
Log.debug("not saving event ", recurrenceTitle, eventStartMoment);
}
Log.debug(" ");
}
// End recurring event parsing.
instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment, durationMs);
} else {
// Single event.
const fullDayEvent = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(event);
// Log.debug("full day event")
// if the start and end are the same, then make end the 'end of day' value (start is at 00:00:00)
if (fullDayEvent && eventStartMoment.valueOf() === eventEndMoment.valueOf()) {
eventEndMoment = eventEndMoment.endOf("day");
let end = eventEndMoment;
if (fullDayEvent && eventStartMoment.valueOf() === end.valueOf()) {
end = end.endOf("day");
}
if (config.includePastEvents) {
// Past event is too far in the past, so skip.
if (eventEndMoment < pastLocalMoment) {
return;
}
} else {
// It's not a fullday event, and it is in the past, so skip.
if (!fullDayEvent && eventEndMoment < now) {
return;
}
instances.push({
event: event,
startMoment: eventStartMoment,
endMoment: end,
isRecurring: false
});
}
// It's a fullday event, and it is before today, So skip.
if (fullDayEvent && eventEndMoment <= now.startOf("day")) {
return;
}
for (const instance of instances) {
const { event: instanceEvent, startMoment, endMoment, isRecurring } = instance;
// Filter logic
if (endMoment.isBefore(pastLocalMoment) || startMoment.isAfter(futureLocalMoment)) {
continue;
}
// It exceeds the maximumNumberOfDays limit, so skip.
if (eventStartMoment > futureLocalMoment) {
return;
if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, eventFilterUntil)) {
continue;
}
if (CalendarFetcherUtils.timeFilterApplies(now, eventEndMoment, eventFilterUntil)) {
return;
}
const title = CalendarFetcherUtils.getTitleFromEvent(instanceEvent);
const fullDay = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(event);
// Every thing is good. Add it to the list.
Log.debug(`saving event: ${title}`);
newEvents.push({
title: title,
startDate: eventStartMoment.format("x"),
endDate: eventEndMoment.format("x"),
fullDayEvent: fullDayEvent,
recurringEvent: false,
startDate: startMoment.format("x"),
endDate: endMoment.format("x"),
fullDayEvent: fullDay,
recurringEvent: isRecurring,
class: event.class,
firstYear: event.start.getFullYear(),
location: location,
geo: geo,
description: description
location: instanceEvent.location || location,
geo: instanceEvent.geo || geo,
description: instanceEvent.description || description
});
}
}
@@ -423,6 +300,106 @@ const CalendarFetcherUtils = {
} else {
return title.includes(filter);
}
},
/**
* Expands a recurring event into individual event instances.
* @param {object} event The recurring event object
* @param {moment.Moment} pastLocalMoment The past date limit
* @param {moment.Moment} futureLocalMoment The future date limit
* @param {number} durationMs The duration of the event in milliseconds
* @returns {object[]} Array of event instances
*/
expandRecurringEvent (event, pastLocalMoment, futureLocalMoment, durationMs) {
const moments = CalendarFetcherUtils.getMomentsFromRecurringEvent(event, pastLocalMoment, futureLocalMoment, durationMs);
const instances = [];
for (const startMoment of moments) {
let curEvent = event;
let showRecurrence = true;
let recurringEventStartMoment = startMoment.clone().tz(CalendarFetcherUtils.getLocalTimezone());
let recurringEventEndMoment = recurringEventStartMoment.clone().add(durationMs, "ms");
const dateKey = recurringEventStartMoment.tz("UTC").format("YYYY-MM-DD");
// Check for overrides
if (curEvent.recurrences !== undefined) {
if (curEvent.recurrences[dateKey] !== undefined) {
curEvent = curEvent.recurrences[dateKey];
// Re-calculate start/end based on override
const start = curEvent.start;
const end = curEvent.end;
const localTimezone = CalendarFetcherUtils.getLocalTimezone();
recurringEventStartMoment = (start.tz ? moment(start).tz(start.tz) : moment(start)).tz(localTimezone);
recurringEventEndMoment = (end.tz ? moment(end).tz(end.tz) : moment(end)).tz(localTimezone);
}
}
// Check for exceptions
if (curEvent.exdate !== undefined) {
if (curEvent.exdate[dateKey] !== undefined) {
showRecurrence = false;
}
}
if (recurringEventStartMoment.valueOf() === recurringEventEndMoment.valueOf()) {
recurringEventEndMoment = recurringEventEndMoment.endOf("day");
}
if (showRecurrence) {
instances.push({
event: curEvent,
startMoment: recurringEventStartMoment,
endMoment: recurringEventEndMoment,
isRecurring: true
});
}
}
return instances;
},
/**
* Checks if an event title matches a specific filter configuration.
* @param {string} title The event title to check
* @param {string|object} filterConfig The filter configuration (string or object)
* @returns {object|null} Object with {until: string|null} if matched, null otherwise
*/
checkEventAgainstFilter (title, filterConfig) {
let filter = filterConfig;
let testTitle = title.toLowerCase();
let until = null;
let useRegex = false;
let regexFlags = "g";
if (filter instanceof Object) {
if (typeof filter.until !== "undefined") {
until = filter.until;
}
if (typeof filter.regex !== "undefined") {
useRegex = filter.regex;
}
if (filter.caseSensitive) {
filter = filter.filterBy;
testTitle = title;
} else if (useRegex) {
filter = filter.filterBy;
testTitle = title;
regexFlags += "i";
} else {
filter = filter.filterBy.toLowerCase();
}
} else {
filter = filter.toLowerCase();
}
if (CalendarFetcherUtils.titleFilterApplies(testTitle, filter, useRegex, regexFlags)) {
return { until };
}
return null;
}
};

View File

@@ -88,7 +88,7 @@ const CalendarUtils = {
* @param {string} title The title to transform.
* @param {object} titleReplace object definition of parts to be replaced in the title
* object definition:
* search: {string,required} RegEx in format //x or simple string to be searched. For (birthday) year calcluation, the element matching the year must be in a RegEx group
* search: {string,required} RegEx in format //x or simple string to be searched. For (birthday) year calculation, the element matching the year must be in a RegEx group
* replace: {string,required} Replacement string, may contain match group references (latter is required for year calculation)
* yearmatchgroup: {number,optional} match group for year element
* @returns {string} The transformed title.

View File

@@ -3,8 +3,8 @@
* use this script with `node debug.js` to test the fetcher without the need
* of starting the MagicMirror² core. Adjust the values below to your desire.
*/
// Alias modules mentioned in package.js under _moduleAliases.
require("module-alias/register");
// Load internal alias resolver
require("../../../js/alias-resolver");
const Log = require("logger");
const CalendarFetcher = require("./calendarfetcher");
@@ -26,14 +26,12 @@ Log.log("Create fetcher ...");
const fetcher = new CalendarFetcher(url, fetchInterval, [], maximumEntries, maximumNumberOfDays, auth);
fetcher.onReceive(function (fetcher) {
Log.log(fetcher.events());
Log.log("------------------------------------------------------------");
Log.log(fetcher.events);
process.exit(0);
});
fetcher.onError(function (fetcher, error) {
Log.log("Fetcher error:");
Log.log(error);
Log.log("Fetcher error:", error);
process.exit(1);
});

View File

@@ -1,3 +1,4 @@
const zlib = require("node:zlib");
const NodeHelper = require("node_helper");
const Log = require("logger");
const CalendarFetcher = require("./calendarfetcher");
@@ -16,11 +17,11 @@ module.exports = NodeHelper.create({
} else if (notification === "FETCH_CALENDAR") {
const key = payload.id + payload.url;
if (typeof this.fetchers[key] === "undefined") {
Log.error("Calendar Error. No fetcher exists with key: ", key);
Log.error("No fetcher exists with key: ", key);
this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_UNSPECIFIED" });
return;
}
this.fetchers[key].startFetch();
this.fetchers[key].fetchCalendar();
}
},
@@ -41,7 +42,7 @@ module.exports = NodeHelper.create({
try {
new URL(url);
} catch (error) {
Log.error("Calendar Error. Malformed calendar url: ", url, error);
Log.error("Malformed calendar url: ", url, error);
this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_MALFORMED_URL" });
return;
}
@@ -61,7 +62,7 @@ module.exports = NodeHelper.create({
});
fetcher.onError((fetcher, error) => {
Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url(), error);
Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url, error);
let error_type = NodeHelper.checkFetchError(error);
this.sendSocketNotification("CALENDAR_ERROR", {
id: identifier,
@@ -70,13 +71,18 @@ module.exports = NodeHelper.create({
});
this.fetchers[identifier + url] = fetcher;
fetcher.fetchCalendar();
} else {
Log.log(`Use existing calendarfetcher for url: ${url}`);
fetcher = this.fetchers[identifier + url];
fetcher.broadcastEvents();
// Check if calendar data is stale and needs refresh
if (fetcher.shouldRefetch()) {
Log.log(`Calendar data is stale, fetching fresh data for url: ${url}`);
fetcher.fetchCalendar();
} else {
fetcher.broadcastEvents();
}
}
fetcher.startFetch();
},
/**
@@ -85,10 +91,12 @@ module.exports = NodeHelper.create({
* @param {string} identifier the identifier of the calendar
*/
broadcastEvents (fetcher, identifier) {
const checksum = zlib.crc32(Buffer.from(JSON.stringify(fetcher.events), "utf8"));
this.sendSocketNotification("CALENDAR_EVENTS", {
id: identifier,
url: fetcher.url(),
events: fetcher.events()
url: fetcher.url,
events: fetcher.events,
checksum: checksum
});
}
});

View File

@@ -21,9 +21,8 @@ Module.register("compliments", {
random: true,
specialDayUnique: false
},
urlSuffix: "",
compliments_new: null,
refreshMinimumDelay: 15 * 60 * 60 * 1000, // 15 minutes
refreshMinimumDelay: 15 * 60 * 1000, // 15 minutes
lastIndexUsed: -1,
// Set currentweather from module
currentWeatherType: "",
@@ -53,12 +52,12 @@ Module.register("compliments", {
this.compliments_new = JSON.parse(response);
}
else {
Log.error(`${this.name} remoteFile refresh failed`);
Log.error(`[compliments] ${this.name} remoteFile refresh failed`);
}
},
this.config.remoteFileRefreshInterval);
} else {
Log.error(`${this.name} remoteFileRefreshInterval less than minimum`);
Log.error(`[compliments] ${this.name} remoteFileRefreshInterval less than minimum`);
}
}
}
@@ -81,7 +80,7 @@ Module.register("compliments", {
minute_sync_delay);
},
// check to see if this entry could be a cron entry wich contains spaces
// check to see if this entry could be a cron entry which contains spaces
isCronEntry (entry) {
return entry.includes(" ");
},
@@ -183,7 +182,7 @@ Module.register("compliments", {
// if so, use its notice entries
Array.prototype.push.apply(date_compliments, this.config.compliments[entry]);
}
} else Log.error(`compliments cron syntax invalid=${JSON.stringify(entry)}`);
} else Log.error(`[compliments] cron syntax invalid=${JSON.stringify(entry)}`);
} else if (new RegExp(entry).test(date)) {
Array.prototype.push.apply(date_compliments, this.config.compliments[entry]);
}
@@ -205,22 +204,35 @@ Module.register("compliments", {
/**
* Retrieve a file from the local filesystem
* @returns {Promise} Resolved when the file is loaded
* @returns {Promise<string|null>} Resolved with file content or null on error
*/
async loadComplimentFile () {
const isRemote = this.config.remoteFile.indexOf("http://") === 0 || this.config.remoteFile.indexOf("https://") === 0,
url = isRemote ? this.config.remoteFile : this.file(this.config.remoteFile);
// because we may be fetching the same url,
// we need to force the server to not give us the cached result
// create an extra property (ignored by the server handler) just so the url string is different
// that will never be the same, using the ms value of date
if (isRemote && this.config.remoteFileRefreshInterval !== 0) this.urlSuffix = `?dummy=${Date.now()}`;
//
const { remoteFile, remoteFileRefreshInterval } = this.config;
const isRemote = remoteFile.startsWith("http://") || remoteFile.startsWith("https://");
let url = isRemote ? remoteFile : this.file(remoteFile);
try {
const response = await fetch(url + this.urlSuffix);
// Validate URL
const urlObj = new URL(url);
// Add cache-busting parameter to remote URLs to prevent cached responses
if (isRemote && remoteFileRefreshInterval !== 0) {
urlObj.searchParams.set("dummy", Date.now());
}
url = urlObj.toString();
} catch {
Log.warn(`[compliments] Invalid URL: ${url}`);
}
try {
const response = await fetch(url);
if (!response.ok) {
Log.error(`[compliments] HTTP error: ${response.status} ${response.statusText}`);
return null;
}
return await response.text();
} catch (error) {
Log.info(`${this.name} fetch failed error=`, error);
Log.info("[compliments] fetch failed:", error.message);
return null;
}
},

View File

@@ -1,5 +1,5 @@
<!--
Use ` | safe` to allow html tages within the text string.
Use ` | safe` to allow html tags within the text string.
https://mozilla.github.io/nunjucks/templating.html#autoescaping
-->
<div>{{ text | safe }}</div>

View File

@@ -181,7 +181,7 @@ Module.register("newsfeed", {
* Gets a feed property by name
* @param {object} feed A feed object.
* @param {string} property The name of the property.
* @returns {property} The value of the specified property for the feed.
* @returns {string} The value of the specified property for the feed.
*/
getFeedProperty (feed, property) {
let res = this.config[property];
@@ -375,7 +375,7 @@ Module.register("newsfeed", {
this.activeItem = 0;
}
this.resetDescrOrFullArticleAndTimer();
Log.debug(`${this.name} - going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`);
Log.debug(`[newsfeed] going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`);
this.updateDom(100);
} else if (notification === "ARTICLE_PREVIOUS") {
this.activeItem--;
@@ -383,7 +383,7 @@ Module.register("newsfeed", {
this.activeItem = this.newsItems.length - 1;
}
this.resetDescrOrFullArticleAndTimer();
Log.debug(`${this.name} - going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`);
Log.debug(`[newsfeed] going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`);
this.updateDom(100);
}
// if "more details" is received the first time: show article summary, on second time show full article
@@ -392,8 +392,8 @@ Module.register("newsfeed", {
if (this.config.showFullArticle === true) {
this.scrollPosition += this.config.scrollLength;
window.scrollTo(0, this.scrollPosition);
Log.debug(`${this.name} - scrolling down`);
Log.debug(`${this.name} - ARTICLE_MORE_DETAILS, scroll position: ${this.config.scrollLength}`);
Log.debug("[newsfeed] scrolling down");
Log.debug(`[newsfeed] ARTICLE_MORE_DETAILS, scroll position: ${this.config.scrollLength}`);
} else {
this.showFullArticle();
}
@@ -401,12 +401,12 @@ Module.register("newsfeed", {
if (this.config.showFullArticle === true) {
this.scrollPosition -= this.config.scrollLength;
window.scrollTo(0, this.scrollPosition);
Log.debug(`${this.name} - scrolling up`);
Log.debug(`${this.name} - ARTICLE_SCROLL_UP, scroll position: ${this.config.scrollLength}`);
Log.debug("[newsfeed] scrolling up");
Log.debug(`[newsfeed] ARTICLE_SCROLL_UP, scroll position: ${this.config.scrollLength}`);
}
} else if (notification === "ARTICLE_LESS_DETAILS") {
this.resetDescrOrFullArticleAndTimer();
Log.debug(`${this.name} - showing only article titles again`);
Log.debug("[newsfeed] showing only article titles again");
this.updateDom(100);
} else if (notification === "ARTICLE_TOGGLE_FULL") {
if (this.config.showFullArticle) {
@@ -435,7 +435,7 @@ Module.register("newsfeed", {
}
clearInterval(this.timer);
this.timer = null;
Log.debug(`${this.name} - showing ${this.isShowingDescription ? "article description" : "full article"}`);
Log.debug(`[newsfeed] showing ${this.isShowingDescription ? "article description" : "full article"}`);
this.updateDom(100);
}
});

View File

@@ -40,7 +40,7 @@
{% if (config.showSourceTitle and item.sourceTitle) or config.showPublishDate %}
<div class="newsfeed-source light small dimmed">
{% if item.sourceTitle and config.showSourceTitle %}
{{ item.sourceTitle }}{% if config.showPublishDate %},{% else %}:{% endif %}
{{ item.sourceTitle }}{% if config.showPublishDate %},&nbsp;{% else %}:{% endif %}
{% endif %}
{% if config.showPublishDate %}{{ item.publishDate }}:{% endif %}
</div>
@@ -63,7 +63,7 @@
{% if (config.showSourceTitle and sourceTitle) or config.showPublishDate %}
<div class="newsfeed-source light small dimmed">
{% if sourceTitle and config.showSourceTitle %}
{{ escapeText(sourceTitle, config.dangerouslyDisableAutoEscaping) }}{% if config.showPublishDate %},{% else %}:{% endif %}
{{ escapeText(sourceTitle, config.dangerouslyDisableAutoEscaping) }}{% if config.showPublishDate %},&nbsp;{% else %}:{% endif %}
{% endif %}
{% if config.showPublishDate %}{{ publishDate }}:{% endif %}
</div>

View File

@@ -67,8 +67,7 @@ const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings
hash: crypto.createHash("sha256").update(`${pubdate} :: ${title} :: ${url}`).digest("hex")
});
} else if (logFeedWarnings) {
Log.warn("Can't parse feed item:");
Log.warn(item);
Log.warn("Can't parse feed item:", item);
Log.warn(`Title: ${title}`);
Log.warn(`Description: ${description}`);
Log.warn(`Pubdate: ${pubdate}`);
@@ -95,10 +94,10 @@ const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings
const ttlms = Math.min(minutes * 60 * 1000, 86400000);
if (ttlms > reloadIntervalMS) {
reloadIntervalMS = ttlms;
Log.info(`Newsfeed-Fetcher: reloadInterval set to ttl=${reloadIntervalMS} for url ${url}`);
Log.info(`reloadInterval set to ttl=${reloadIntervalMS} for url ${url}`);
}
} catch (error) {
Log.warn(`Newsfeed-Fetcher: feed ttl is no valid integer=${minutes} for url ${url}`);
Log.warn(`feed ttl is no valid integer=${minutes} for url ${url}`);
}
});
@@ -149,10 +148,10 @@ const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings
*/
this.broadcastItems = function () {
if (items.length <= 0) {
Log.info("Newsfeed-Fetcher: No items to broadcast yet.");
Log.info("No items to broadcast yet.");
return;
}
Log.info(`Newsfeed-Fetcher: Broadcasting ${items.length} items.`);
Log.info(`Broadcasting ${items.length} items.`);
itemsReceivedCallback(this);
};

View File

@@ -32,7 +32,7 @@ module.exports = NodeHelper.create({
try {
new URL(url);
} catch (error) {
Log.error("Newsfeed Error. Malformed newsfeed url: ", url, error);
Log.error("Error: Malformed newsfeed url: ", url, error);
this.sendSocketNotification("NEWSFEED_ERROR", { error_type: "MODULE_ERROR_MALFORMED_URL" });
return;
}
@@ -47,7 +47,7 @@ module.exports = NodeHelper.create({
});
fetcher.onError((fetcher, error) => {
Log.error("Newsfeed Error. Could not fetch newsfeed: ", url, error);
Log.error("Error: Could not fetch newsfeed: ", url, error);
let error_type = NodeHelper.checkFetchError(error);
this.sendSocketNotification("NEWSFEED_ERROR", {
error_type

View File

@@ -183,7 +183,10 @@ class GitHelper {
this.gitResultList.push(gitInfo);
}
} catch (e) {
Log.error(`Failed to retrieve repo info for ${repo.module}: ${e}`);
// Only log errors in non-test environments to keep test output clean
if (process.env.mmTestMode !== "true") {
Log.error(`Failed to retrieve repo info for ${repo.module}: ${e}`);
}
}
}

View File

@@ -51,7 +51,7 @@ class Updater {
this.PM2Id = null; // pm2 process number
this.version = global.version;
this.root_path = global.root_path;
Log.info("updatenotification: Updater Class Loaded!");
Log.info("Updater Class Loaded!");
}
// [main command] parse if module update is needed
@@ -81,7 +81,7 @@ class Updater {
await Promise.all(parser);
let updater = Object.values(this.moduleList);
Log.debug("updatenotification Update Result:", updater);
Log.debug("Update Result:", updater);
return updater;
}
@@ -107,24 +107,24 @@ class Updater {
if (module.updateCommand) {
Command = module.updateCommand;
} else {
Log.warn(`updatenotification: Update of ${module.name} is not supported.`);
Log.warn(`Update of ${module.name} is not supported.`);
return Result;
}
Log.info(`updatenotification: Updating ${module.name}...`);
Log.info(`Updating ${module.name}...`);
return new Promise((resolve) => {
Exec(Command, { cwd: modulePath, timeout: this.timeout }, (error, stdout, stderr) => {
if (error) {
Log.error(`updatenotification: exec error: ${error}`);
Log.error(`exec error: ${error}`);
Result.error = true;
} else {
Log.info(`updatenotification: Update logs of ${module.name}: ${stdout}`);
Log.info(`Update logs of ${module.name}: ${stdout}`);
Result.updated = true;
if (this.autoRestart) {
Log.info("updatenotification: Update done");
Log.info("Update done");
setTimeout(() => this.restart(), 3000);
} else {
Log.info("updatenotification: Update done, don't forget to restart MagicMirror!");
Log.info("Update done, don't forget to restart MagicMirror!");
Result.needRestart = true;
}
}
@@ -139,20 +139,20 @@ class Updater {
else this.nodeRestart();
}
// restart MagicMiror with "pm2": use PM2Id for restart it
// restart MagicMirror with "pm2": use PM2Id for restart it
pm2Restart () {
Log.info("updatenotification: PM2 will restarting MagicMirror...");
Log.info("[PM2] restarting MagicMirror...");
const pm2 = require("pm2");
pm2.restart(this.PM2Id, (err, proc) => {
if (err) {
Log.error("updatenotification:[PM2] restart Error", err);
Log.error("[PM2] restart Error", err);
}
});
}
// restart MagicMiror with "node --run start"
// restart MagicMirror with "node --run start"
nodeRestart () {
Log.info("updatenotification: Restarting MagicMirror...");
Log.info("Restarting MagicMirror...");
const out = process.stdout;
const err = process.stderr;
const subprocess = Spawn("node --run start", { cwd: this.root_path, shell: true, detached: true, stdio: ["ignore", out, err] });
@@ -162,49 +162,49 @@ class Updater {
// Check using pm2
check_PM2_Process () {
Log.info("updatenotification: Checking PM2 using...");
Log.info("Checking PM2 using...");
return new Promise((resolve) => {
if (fs.existsSync("/.dockerenv")) {
Log.info("updatenotification: Running in docker container, not using PM2 ...");
Log.info("[PM2] Running in docker container, not using PM2 ...");
resolve(false);
return;
}
if (process.env.unique_id === undefined) {
Log.info("updatenotification: [PM2] You are not using pm2");
Log.info("[PM2] You are not using pm2");
resolve(false);
return;
}
Log.debug(`updatenotification: [PM2] Search for pm2 id: ${process.env.pm_id} -- name: ${process.env.name} -- unique_id: ${process.env.unique_id}`);
Log.debug(`[PM2] Search for pm2 id: ${process.env.pm_id} -- name: ${process.env.name} -- unique_id: ${process.env.unique_id}`);
const pm2 = require("pm2");
pm2.connect((err) => {
if (err) {
Log.error("updatenotification: [PM2]", err);
Log.error("[PM2]", err);
resolve(false);
return;
}
pm2.list((err, list) => {
if (err) {
Log.error("updatenotification: [PM2] Can't get process List!");
Log.error("[PM2] Can't get process List!");
resolve(false);
return;
}
list.forEach((pm) => {
Log.debug(`updatenotification: [PM2] found pm2 process id: ${pm.pm_id} -- name: ${pm.name} -- unique_id: ${pm.pm2_env.unique_id}`);
Log.debug(`[PM2] found pm2 process id: ${pm.pm_id} -- name: ${pm.name} -- unique_id: ${pm.pm2_env.unique_id}`);
if (pm.pm2_env.status === "online" && process.env.name === pm.name && +process.env.pm_id === +pm.pm_id && process.env.unique_id === pm.pm2_env.unique_id) {
this.PM2Id = pm.pm_id;
this.usePM2 = true;
Log.info(`updatenotification: [PM2] You are using pm2 with id: ${this.PM2Id} (${pm.name})`);
Log.info(`[PM2] You are using pm2 with id: ${this.PM2Id} (${pm.name})`);
resolve(true);
} else {
Log.debug(`updatenotification: [PM2] pm2 process id: ${pm.pm_id} don't match...`);
Log.debug(`[PM2] pm2 process id: ${pm.pm_id} don't match...`);
}
});
pm2.disconnect();
if (!this.usePM2) {
Log.info("updatenotification: [PM2] You are not using pm2");
Log.info("[PM2] You are not using pm2");
resolve(false);
}
});

View File

@@ -24,7 +24,7 @@
{% for name, status in updatesList %}
<div class="small bright">
{% if status.done %}
<i class="fas fa-check" style="color: lightgreen;"></i>
<i class="fas fa-check" style="color: LightGreen;"></i>
<span>
{% set updateTextLabel = "UPDATE_NOTIFICATION_DONE" %}
{{ updateTextLabel | translate({MODULE_NAME: name}) | safe }}

View File

@@ -1,7 +1,7 @@
/**
* A function to make HTTP requests via the server to avoid CORS-errors.
* @param {string} url the url to fetch from
* @param {string} type what contenttype to expect in the response, can be "json" or "xml"
* @param {string} type what content-type to expect in the response, can be "json" or "xml"
* @param {boolean} useCorsProxy A flag to indicate
* @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send
* @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to receive
@@ -17,19 +17,29 @@ async function performWebRequest (url, type = "json", useCorsProxy = false, requ
requestUrl = url;
request.headers = getHeadersToSend(requestHeaders);
}
const response = await fetch(requestUrl, request);
const data = await response.text();
if (type === "xml") {
return new DOMParser().parseFromString(data, "text/html");
} else {
if (!data || !data.length > 0) return undefined;
try {
const response = await fetch(requestUrl, request);
if (response.ok) {
const data = await response.text();
const dataResponse = JSON.parse(data);
if (!dataResponse.headers) {
dataResponse.headers = getHeadersFromResponse(expectedResponseHeaders, response);
if (type === "xml") {
return new DOMParser().parseFromString(data, "text/html");
} else {
if (!data || !data.length > 0) return undefined;
const dataResponse = JSON.parse(data);
if (!dataResponse.headers) {
dataResponse.headers = getHeadersFromResponse(expectedResponseHeaders, response);
}
return dataResponse;
}
} else {
throw new Error(`Response status: ${response.status}`);
}
return dataResponse;
} catch (error) {
Log.error(`Error fetching data from ${url}: ${error}`);
return undefined;
}
}

View File

@@ -16,7 +16,7 @@
{% elif (currentStep == 1) and config.ignoreToday == false and config.absoluteDates == false %}
<td class="day">{{ "TOMORROW" | translate }}</td>
{% else %}
<td class="day">{{ f.date.format("ddd") }}</td>
<td class="day">{{ f.date.format(config.forecastDateFormat) }}</td>
{% endif %}
<td class="bright weather-icon">
<span class="wi weathericon wi-{{ f.weatherType }}"></span>

View File

@@ -60,7 +60,7 @@ WeatherProvider.register("envcanada", {
* Called when the weather provider is started
*/
start () {
Log.info(`Weather provider: ${this.providerName} started.`);
Log.info(`[weatherprovider.envcanada] ${this.providerName} started.`);
this.setFetchedLocation(this.config.location);
},
@@ -101,15 +101,15 @@ WeatherProvider.register("envcanada", {
* city specified in the Weather module Config information
*/
fetchCommon (target) {
const forecastURL = this.getUrl(); // Get the approriate URL for the MSC Datamart Index page
const forecastURL = this.getUrl(); // Get the appropriate URL for the MSC Datamart Index page
Log.debug(`[weather.envcanada] ${target} Index url: ${forecastURL}`);
Log.debug(`[weatherprovider.envcanada] ${target} Index url: ${forecastURL}`);
this.fetchData(forecastURL, "xml") // Query the Index page URL
.then((indexData) => {
if (!indexData) {
// Did not receive usable new data.
Log.info(`weather.envcanada ${target} - did not receive usable index data`);
Log.info(`[weatherprovider.envcanada] ${target} - did not receive usable index data`);
this.updateAvailable(); // If there were issues, update anyways to reset timer
return;
}
@@ -127,13 +127,13 @@ WeatherProvider.register("envcanada", {
const fileSuffix = `${this.config.siteCode}_en.xml`; // Build city filename
const nextFile = indexData.body.innerHTML.split(fileSuffix); // Find filename on Index page
if (nextFile.length > 1) { // Parse out the full unqiue file city filename
if (nextFile.length > 1) { // Parse out the full unique file city filename
// Find the last occurrence
forecastFile = nextFile[nextFile.length - 2].slice(-41) + fileSuffix;
forecastFileURL = forecastURL + forecastFile; // Create full URL to the city's weather data
}
Log.debug(`[weather.envcanada] ${target} Citypage url: ${forecastFileURL}`);
Log.debug(`[weatherprovider.envcanada] ${target} Citypage url: ${forecastFileURL}`);
/*
* If the Citypage filename has not changed since the last Weather refresh, the forecast has not changed and
@@ -141,19 +141,19 @@ WeatherProvider.register("envcanada", {
*/
if (target === "Current" && this.lastCityPageCurrent === forecastFileURL) {
Log.debug(`[weather.envcanada] ${target} - Newest Citypage has already been seen - skipping!`);
Log.debug(`[weatherprovider.envcanada] ${target} - Newest Citypage has already been seen - skipping!`);
this.updateAvailable(); // Update anyways to reset refresh timer
return;
}
if (target === "Forecast" && this.lastCityPageForecast === forecastFileURL) {
Log.debug(`[weather.envcanada] ${target} - Newest Citypage has already been seen - skipping!`);
Log.debug(`[weatherprovider.envcanada] ${target} - Newest Citypage has already been seen - skipping!`);
this.updateAvailable(); // Update anyways to reset refresh timer
return;
}
if (target === "Hourly" && this.lastCityPageHourly === forecastFileURL) {
Log.debug(`[weather.envcanada] ${target} - Newest Citypage has already been seen - skipping!`);
Log.debug(`[weatherprovider.envcanada] ${target} - Newest Citypage has already been seen - skipping!`);
this.updateAvailable(); // Update anyways to reset refresh timer
return;
}
@@ -162,7 +162,7 @@ WeatherProvider.register("envcanada", {
.then((cityData) => {
if (!cityData) {
// Did not receive usable new data.
Log.info(`weather.envcanada ${target} - did not receive usable citypage data`);
Log.info(`[weatherprovider.envcanada] ${target} - did not receive usable citypage data`);
return;
}
@@ -170,7 +170,7 @@ WeatherProvider.register("envcanada", {
* With the city's weather data read, parse the resulting XML document for the appropriate weather data
* elements to create a weather object. Next, set Weather modules details from that object.
*/
Log.debug(`[weather.envcanada] ${target} - Citypage has been read and will be processed for updates`);
Log.debug(`[weatherprovider.envcanada] ${target} - Citypage has been read and will be processed for updates`);
if (target === "Current") {
const currentWeather = this.generateWeatherObjectFromCurrentWeather(cityData);
@@ -191,12 +191,12 @@ WeatherProvider.register("envcanada", {
}
})
.catch(function (cityRequest) {
Log.info(`weather.envcanada ${target} - could not load citypage data from: ${forecastFileURL}`);
Log.info(`[weatherprovider.envcanada] ${target} - could not load citypage data from: ${forecastFileURL}`);
})
.finally(() => this.updateAvailable()); // Update no matter what to reset weather refresh timer
})
.catch(function (indexRequest) {
Log.error(`weather.envcanada ${target} - could not load index data ... `, indexRequest);
Log.error(`[weatherprovider.envcanada] ${target} - could not load index data ... `, indexRequest);
this.updateAvailable(); // If there were issues, update anyways to reset timer
});
},
@@ -208,7 +208,7 @@ WeatherProvider.register("envcanada", {
* Fixed value + Prov code specified in Weather module Config.js + current hour as GMT
*/
getUrl () {
let forecastURL = `https://dd.weather.gc.ca/citypage_weather/${this.config.provCode}`;
let forecastURL = `https://dd.weather.gc.ca/today/citypage_weather/${this.config.provCode}`;
const hour = this.getCurrentHourGMT();
forecastURL += `/${hour}/`;
return forecastURL;
@@ -244,7 +244,12 @@ WeatherProvider.register("envcanada", {
currentWeather.temperature = this.cacheCurrentTemp;
}
currentWeather.windSpeed = WeatherUtils.convertWindToMs(ECdoc.querySelector("siteData currentConditions wind speed").textContent);
if (ECdoc.querySelector("siteData currentConditions wind speed").textContent === "calm") {
currentWeather.windSpeed = "0";
} else {
currentWeather.windSpeed = WeatherUtils.convertWindToMs(ECdoc.querySelector("siteData currentConditions wind speed").textContent);
}
currentWeather.windFromDirection = ECdoc.querySelector("siteData currentConditions wind bearing").textContent;
currentWeather.humidity = ECdoc.querySelector("siteData currentConditions relativeHumidity").textContent;
@@ -320,7 +325,7 @@ WeatherProvider.register("envcanada", {
* off and Element 0 will contain Current Tonight. From there, the next 5 days will be contained in
* Elements 1/2, 3/4, 5/6, 7/8, and 9/10. As well, Element 11 will contain a forecast for a 6th day,
* but only for the Today portion (not Tonight). This module will create a 6-day forecast using
* Elements 0 to 11, and will ignore the additional Todat forecast in Element 11.
* Elements 0 to 11, and will ignore the additional Today forecast in Element 11.
*
* We need to determine if Element 0 is showing the forecast for Current Today or Current Tonight.
* This is required to understand how Min and Max temperature will be determined, and to understand
@@ -426,7 +431,7 @@ WeatherProvider.register("envcanada", {
/*
* The EC hourly forecast is held in a 24-element array - Elements 0 to 23 - with Element 0 holding
* the forecast for the next 'on the hour' timeslot. This means the array is a rolling 24 hours.
* the forecast for the next 'on the hour' time slot. This means the array is a rolling 24 hours.
*/
const hourGroup = ECdoc.querySelectorAll("siteData hourlyForecastGroup hourlyForecast");
@@ -459,7 +464,7 @@ WeatherProvider.register("envcanada", {
},
/*
* Determine Min and Max temp based on a supplied Forecast Element index and a boolen that denotes if
* Determine Min and Max temp based on a supplied Forecast Element index and a boolean that denotes if
* the next Forecast element should be considered - i.e. look at Today *and* Tonight vs.Tonight-only
*/
setMinMaxTemps (weather, foreGroup, today, fullDay, currentTemp) {

View File

@@ -155,7 +155,7 @@ WeatherProvider.register("openmeteo", {
this.setCurrentWeather(currentWeather);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
Log.error("[weatherprovider.openmeteo] Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
@@ -173,7 +173,7 @@ WeatherProvider.register("openmeteo", {
this.setWeatherForecast(dailyForecast);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
Log.error("[weatherprovider.openmeteo] Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
@@ -191,7 +191,7 @@ WeatherProvider.register("openmeteo", {
this.setWeatherHourly(hourlyForecast);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
Log.error("[weatherprovider.openmeteo] Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
@@ -217,7 +217,7 @@ WeatherProvider.register("openmeteo", {
this.config.maxEntries = Math.max(1, Math.min(this.config.maxEntries, maxEntriesLimit));
if (!this.config.type) {
Log.error("type not configured and could not resolve it");
Log.error("[weatherprovider.openmeteo] type not configured and could not resolve it");
}
this.fetchLocation();
@@ -280,13 +280,24 @@ WeatherProvider.register("openmeteo", {
return `${this.config.apiBase}/forecast?${this.getQueryParameters()}`;
},
// fix daylight-saving-time differences
checkDST (dt) {
const uxdt = moment.unix(dt);
const nowDST = moment().isDST();
if (nowDST === moment(uxdt).isDST()) {
return uxdt;
} else {
return uxdt.add(nowDST ? +1 : -1, "hour");
}
},
// Transpose hourly and daily data matrices
transposeDataMatrix (data) {
return data.time.map((_, index) => Object.keys(data).reduce((row, key) => {
return {
...row,
// Parse time values as momentjs instances
[key]: ["time", "sunrise", "sunset"].includes(key) ? moment.unix(data[key][index]) : data[key][index]
// Parse time values as moment.js instances
[key]: ["time", "sunrise", "sunset"].includes(key) ? this.checkDST(data[key][index]) : data[key][index]
};
}, {}));
},
@@ -340,7 +351,7 @@ WeatherProvider.register("openmeteo", {
this.fetchedLocationName = `${data.city}, ${data.principalSubdivisionCode}`;
})
.catch((request) => {
Log.error("Could not load data ... ", request);
Log.error("[weatherprovider.openmeteo] Could not load data ... ", request);
});
},

View File

@@ -42,7 +42,7 @@ WeatherProvider.register("openweathermap", {
this.setCurrentWeather(currentWeather);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
Log.error("[weatherprovider.openweathermap] Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
@@ -64,7 +64,7 @@ WeatherProvider.register("openweathermap", {
this.setFetchedLocation(location);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
Log.error("[weatherprovider.openweathermap] Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
@@ -88,7 +88,7 @@ WeatherProvider.register("openweathermap", {
this.setWeatherHourly(weatherData.hours);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
Log.error("[weatherprovider.openweathermap] Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
@@ -427,7 +427,7 @@ WeatherProvider.register("openweathermap", {
} else if (this.firstEvent && this.firstEvent.location) {
params += `q=${this.firstEvent.location}`;
} else {
// TODO hide doesnt exist!
// TODO hide doesn't exist!
this.hide(this.config.animationSpeed, { lockString: this.identifier });
return;
}

View File

@@ -46,7 +46,7 @@ const OverrideWrapper = Class.extend({
this.baseProvider.fetchWeatherForecast();
},
fetchWeatherHourly () {
this.baseProvider.fetchEatherHourly();
this.baseProvider.fetchWeatherHourly();
},
weatherForecast () {
this.baseProvider.weatherForecast();
@@ -84,7 +84,7 @@ const OverrideWrapper = Class.extend({
},
/**
* Override to combine the overrideWeatherObejct provided in the
* Override to combine the overrideWeatherObject provided in the
* notificationReceived method with the currentOverrideWeatherObject provided by the
* api provider fetchData implementation.
* @param {WeatherObject} currentWeatherObject - the api provider weather object

View File

@@ -22,38 +22,36 @@ WeatherProvider.register("pirateweather", {
lon: 0
},
fetchCurrentWeather () {
this.fetchData(this.getUrl())
.then((data) => {
if (!data || !data.currently || typeof data.currently.temperature === "undefined") {
// No usable data?
return;
}
async fetchCurrentWeather () {
try {
const data = await this.fetchData(this.getUrl());
if (!data || !data.currently || typeof data.currently.temperature === "undefined") {
throw new Error("No usable data received from Pirate Weather API.");
}
const currentWeather = this.generateWeatherDayFromCurrentWeather(data);
this.setCurrentWeather(currentWeather);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
const currentWeather = this.generateWeatherDayFromCurrentWeather(data);
this.setCurrentWeather(currentWeather);
} catch (error) {
Log.error("Could not load data ... ", error);
} finally {
this.updateAvailable();
}
},
fetchWeatherForecast () {
this.fetchData(this.getUrl())
.then((data) => {
if (!data || !data.daily || !data.daily.data.length) {
// No usable data?
return;
}
async fetchWeatherForecast () {
try {
const data = await this.fetchData(this.getUrl());
if (!data || !data.daily || !data.daily.data.length) {
throw new Error("No usable data received from Pirate Weather API.");
}
const forecast = this.generateWeatherObjectsFromForecast(data.daily.data);
this.setWeatherForecast(forecast);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
const forecast = this.generateWeatherObjectsFromForecast(data.daily.data);
this.setWeatherForecast(forecast);
} catch (error) {
Log.error("Could not load data ... ", error);
} finally {
this.updateAvailable();
}
},
// Create a URL from the config and base URL.
@@ -118,7 +116,7 @@ WeatherProvider.register("pirateweather", {
rain: "rain",
snow: "snow",
sleet: "snow",
wind: "wind",
wind: "windy",
fog: "fog",
cloudy: "cloudy",
"partly-cloudy-day": "day-cloudy",

View File

@@ -28,7 +28,7 @@ WeatherProvider.register("smhi", {
this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`);
this.setCurrentWeather(weatherObject);
})
.catch((error) => Log.error(`Could not load data: ${error.message}`))
.catch((error) => Log.error(`[weatherprovider.smhi] Could not load data: ${error.message}`))
.finally(() => this.updateAvailable());
},
@@ -43,7 +43,7 @@ WeatherProvider.register("smhi", {
this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`);
this.setWeatherForecast(weatherObjects);
})
.catch((error) => Log.error(`Could not load data: ${error.message}`))
.catch((error) => Log.error(`[weatherprovider.smhi] Could not load data: ${error.message}`))
.finally(() => this.updateAvailable());
},
@@ -58,7 +58,7 @@ WeatherProvider.register("smhi", {
this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`);
this.setWeatherHourly(weatherObjects);
})
.catch((error) => Log.error(`Could not load data: ${error.message}`))
.catch((error) => Log.error(`[weatherprovider.smhi] Could not load data: ${error.message}`))
.finally(() => this.updateAvailable());
},
@@ -69,7 +69,7 @@ WeatherProvider.register("smhi", {
setConfig (config) {
this.config = config;
if (!config.precipitationValue || ["pmin", "pmean", "pmedian", "pmax"].indexOf(config.precipitationValue) === -1) {
Log.log(`invalid or not set: ${config.precipitationValue}`);
Log.log(`[weatherprovider.smhi] invalid or not set: ${config.precipitationValue}`);
config.precipitationValue = this.defaults.precipitationValue;
}
},
@@ -141,7 +141,7 @@ WeatherProvider.register("smhi", {
/*
* Determine the precipitation amount and category and update the
* weatherObject with it, the valuetype to use can be configured or uses
* weatherObject with it, the value type to use can be configured or uses
* median as default.
*/
let precipitationValue = this.paramValue(weatherData, this.config.precipitationValue);
@@ -173,7 +173,7 @@ WeatherProvider.register("smhi", {
* @param {object[]} allWeatherData Array of weatherdata
* @param {object} coordinates Coordinates of the locations of the weather
* @param {string} groupBy The interval to use for grouping the data (day, hour)
* @returns {WeatherObject[]} Array of weatherobjects
* @returns {WeatherObject[]} Array of weather objects
*/
convertWeatherDataGroupedBy (allWeatherData, coordinates, groupBy = "day") {
let currentWeather;

View File

@@ -1,211 +0,0 @@
/* global WeatherProvider, WeatherObject, WeatherUtils */
/*
* This class is a provider for UK Met Office Datapoint,
* see https://www.metoffice.gov.uk/
*/
WeatherProvider.register("ukmetoffice", {
/*
* Set the name of the provider.
* This isn't strictly necessary, since it will fallback to the provider identifier
* But for debugging (and future alerts) it would be nice to have the real name.
*/
providerName: "UK Met Office",
// Set the default config properties that is specific to this provider
defaults: {
apiBase: "http://datapoint.metoffice.gov.uk/public/data/val/wxfcs/all/json/",
locationID: false,
apiKey: ""
},
// Overwrite the fetchCurrentWeather method.
fetchCurrentWeather () {
this.fetchData(this.getUrl("3hourly"))
.then((data) => {
if (!data || !data.SiteRep || !data.SiteRep.DV || !data.SiteRep.DV.Location || !data.SiteRep.DV.Location.Period || data.SiteRep.DV.Location.Period.length === 0) {
/*
* Did not receive usable new data.
* Maybe this needs a better check?
*/
return;
}
this.setFetchedLocation(`${data.SiteRep.DV.Location.name}, ${data.SiteRep.DV.Location.country}`);
const currentWeather = this.generateWeatherObjectFromCurrentWeather(data);
this.setCurrentWeather(currentWeather);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
// Overwrite the fetchCurrentWeather method.
fetchWeatherForecast () {
this.fetchData(this.getUrl("daily"))
.then((data) => {
if (!data || !data.SiteRep || !data.SiteRep.DV || !data.SiteRep.DV.Location || !data.SiteRep.DV.Location.Period || data.SiteRep.DV.Location.Period.length === 0) {
/*
* Did not receive usable new data.
* Maybe this needs a better check?
*/
return;
}
this.setFetchedLocation(`${data.SiteRep.DV.Location.name}, ${data.SiteRep.DV.Location.country}`);
const forecast = this.generateWeatherObjectsFromForecast(data);
this.setWeatherForecast(forecast);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
/** UK Met Office Specific Methods - These are not part of the default provider methods */
/*
* Gets the complete url for the request
*/
getUrl (forecastType) {
return this.config.apiBase + this.config.locationID + this.getParams(forecastType);
},
/*
* Generate a WeatherObject based on currentWeatherInformation
*/
generateWeatherObjectFromCurrentWeather (currentWeatherData) {
const currentWeather = new WeatherObject();
const location = currentWeatherData.SiteRep.DV.Location;
// data times are always UTC
let nowUtc = moment.utc();
let midnightUtc = nowUtc.clone().startOf("day");
let timeInMins = nowUtc.diff(midnightUtc, "minutes");
// loop round each of the (5) periods, look for today (the first period may be yesterday)
for (const period of location.Period) {
const periodDate = moment.utc(period.value.substr(0, 10), "YYYY-MM-DD");
// ignore if period is before today
if (periodDate.isSameOrAfter(moment.utc().startOf("day"))) {
// check this is the period we want, after today the diff will be -ve
if (moment().diff(periodDate, "minutes") > 0) {
/*
* loop round the reports looking for the one we are in
* $ value specifies the time in minutes-of-the-day: 0, 180, 360,...1260
*/
for (const rep of period.Rep) {
const p = rep.$;
if (timeInMins >= p && timeInMins - 180 < p) {
// finally got the one we want, so populate weather object
currentWeather.humidity = rep.H;
currentWeather.temperature = rep.T;
currentWeather.feelsLikeTemp = rep.F;
currentWeather.precipitationProbability = parseInt(rep.Pp);
currentWeather.windSpeed = WeatherUtils.convertWindToMetric(rep.S);
currentWeather.windFromDirection = WeatherUtils.convertWindDirection(rep.D);
currentWeather.weatherType = this.convertWeatherType(rep.W);
}
}
}
}
}
// determine the sunrise/sunset times - not supplied in UK Met Office data
currentWeather.updateSunTime(location.lat, location.lon);
return currentWeather;
},
/*
* Generate WeatherObjects based on forecast information
*/
generateWeatherObjectsFromForecast (forecasts) {
const days = [];
/*
* loop round the (5) periods getting the data
* for each period array, Day is [0], Night is [1]
*/
for (const period of forecasts.SiteRep.DV.Location.Period) {
const weather = new WeatherObject();
// data times are always UTC
const dateStr = period.value;
let periodDate = moment.utc(dateStr.substr(0, 10), "YYYY-MM-DD");
// ignore if period is before today
if (periodDate.isSameOrAfter(moment.utc().startOf("day"))) {
// populate the weather object
weather.date = moment.utc(dateStr.substr(0, 10), "YYYY-MM-DD");
weather.minTemperature = period.Rep[1].Nm;
weather.maxTemperature = period.Rep[0].Dm;
weather.weatherType = this.convertWeatherType(period.Rep[0].W);
weather.precipitationProbability = parseInt(period.Rep[0].PPd);
days.push(weather);
}
}
return days;
},
/*
* Convert the Met Office icons to a more usable name.
*/
convertWeatherType (weatherType) {
const weatherTypes = {
0: "night-clear",
1: "day-sunny",
2: "night-alt-cloudy",
3: "day-cloudy",
5: "fog",
6: "fog",
7: "cloudy",
8: "cloud",
9: "night-sprinkle",
10: "day-sprinkle",
11: "raindrops",
12: "sprinkle",
13: "night-alt-showers",
14: "day-showers",
15: "rain",
16: "night-alt-sleet",
17: "day-sleet",
18: "sleet",
19: "night-alt-hail",
20: "day-hail",
21: "hail",
22: "night-alt-snow",
23: "day-snow",
24: "snow",
25: "night-alt-snow",
26: "day-snow",
27: "snow",
28: "night-alt-thunderstorm",
29: "day-thunderstorm",
30: "thunderstorm"
};
return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;
},
/**
* Generates an url with api parameters based on the config.
* @param {string} forecastType daily or 3hourly forecast
* @returns {string} url
*/
getParams (forecastType) {
let params = "?";
params += `res=${forecastType}`;
params += `&key=${this.config.apiKey}`;
return params;
}
});

View File

@@ -27,7 +27,7 @@
* - Pressures are in Pascals (Pa)
* - Distances are in metres (m)
* - Probabilities and humidity are given as percentages (%)
* - Precipitation is measured in millimetres (mm) with rates per hour (mm/h)
* - Precipitation is measured in millimeters (mm) with rates per hour (mm/h)
*
* See the PDFs linked above for more information on the data their corresponding units.
*/
@@ -86,8 +86,7 @@ WeatherProvider.register("ukmetofficedatahub", {
* Did not receive usable new data.
* Maybe this needs a better check?
*/
Log.error("Possibly bad current/hourly data?");
Log.error(data);
Log.error("[weatherprovider.ukmetofficedatahub] Possibly bad current/hourly data?", data);
return;
}
@@ -100,7 +99,7 @@ WeatherProvider.register("ukmetofficedatahub", {
})
// Catch any error(s)
.catch((error) => Log.error(`Could not load data: ${error.message}`))
.catch((error) => Log.error(`[weatherprovider.ukmetofficedatahub] Could not load data: ${error.message}`))
// Let the module know there is data available
.finally(() => this.updateAvailable());
@@ -162,8 +161,7 @@ WeatherProvider.register("ukmetofficedatahub", {
* Did not receive usable new data.
* Maybe this needs a better check?
*/
Log.error("Possibly bad forecast data?");
Log.error(data);
Log.error("[weatherprovider.ukmetofficedatahub] Possibly bad forecast data?", data);
return;
}
@@ -176,7 +174,7 @@ WeatherProvider.register("ukmetofficedatahub", {
})
// Catch any error(s)
.catch((error) => Log.error(`Could not load data: ${error.message}`))
.catch((error) => Log.error(`[weatherprovider.ukmetofficedatahub] Could not load data: ${error.message}`))
// Let the module know there is new data available
.finally(() => this.updateAvailable());

View File

@@ -36,7 +36,7 @@ WeatherProvider.register("weatherbit", {
this.setCurrentWeather(currentWeather);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
Log.error("[weatherprovider.weatherbit] Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
@@ -55,7 +55,7 @@ WeatherProvider.register("weatherbit", {
this.fetchedLocationName = `${data.city_name}, ${data.state_code}`;
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
Log.error("[weatherprovider.weatherbit] Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
@@ -79,7 +79,7 @@ WeatherProvider.register("weatherbit", {
this.config.weatherEndpoint = "/current";
break;
default:
Log.error("weatherEndpoint not configured and could not resolve it based on type");
Log.error("[weatherprovider.weatherbit] weatherEndpoint not configured and could not resolve it based on type");
}
}
},

View File

@@ -41,7 +41,7 @@ WeatherProvider.register("weatherflow", {
this.fetchedLocationName = data.location_name;
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
Log.error("[weatherprovider.weatherflow] Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
@@ -81,7 +81,7 @@ WeatherProvider.register("weatherflow", {
this.fetchedLocationName = data.location_name;
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
Log.error("[weatherprovider.weatherflow] Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
@@ -112,7 +112,7 @@ WeatherProvider.register("weatherflow", {
this.fetchedLocationName = data.location_name;
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
Log.error("[weatherprovider.weatherflow] Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},

View File

@@ -41,11 +41,6 @@ WeatherProvider.register("weathergov", {
this.fetchWxGovURLs(this.config);
},
// Called when the weather provider is about to start.
start () {
Log.info(`Weather provider: ${this.providerName} started.`);
},
// This returns the name of the fetched location or an empty string.
fetchedLocation () {
return this.fetchedLocationName || "";
@@ -54,7 +49,7 @@ WeatherProvider.register("weathergov", {
// Overwrite the fetchCurrentWeather method.
fetchCurrentWeather () {
if (!this.configURLs) {
Log.info("fetchCurrentWeather: fetch wx waiting on config URLs");
Log.info("[weatherprovider.weathergov] fetchCurrentWeather: fetch wx waiting on config URLs");
return;
}
this.fetchData(this.stationObsURL)
@@ -67,7 +62,7 @@ WeatherProvider.register("weathergov", {
this.setCurrentWeather(currentWeather);
})
.catch(function (request) {
Log.error("Could not load station obs data ... ", request);
Log.error("[weatherprovider.weathergov] Could not load station obs data ... ", request);
})
.finally(() => this.updateAvailable());
},
@@ -75,7 +70,7 @@ WeatherProvider.register("weathergov", {
// Overwrite the fetchWeatherForecast method.
fetchWeatherForecast () {
if (!this.configURLs) {
Log.info("fetchWeatherForecast: fetch wx waiting on config URLs");
Log.info("[weatherprovider.weathergov] fetchWeatherForecast: fetch wx waiting on config URLs");
return;
}
this.fetchData(this.forecastURL)
@@ -88,7 +83,7 @@ WeatherProvider.register("weathergov", {
this.setWeatherForecast(forecast);
})
.catch(function (request) {
Log.error("Could not load forecast hourly data ... ", request);
Log.error("[weatherprovider.weathergov] Could not load forecast hourly data ... ", request);
})
.finally(() => this.updateAvailable());
},
@@ -96,7 +91,7 @@ WeatherProvider.register("weathergov", {
// Overwrite the fetchWeatherHourly method.
fetchWeatherHourly () {
if (!this.configURLs) {
Log.info("fetchWeatherHourly: fetch wx waiting on config URLs");
Log.info("[weatherprovider.weathergov] fetchWeatherHourly: fetch wx waiting on config URLs");
return;
}
this.fetchData(this.forecastHourlyURL)
@@ -113,7 +108,7 @@ WeatherProvider.register("weathergov", {
this.setWeatherHourly(hourly);
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
Log.error("[weatherprovider.weathergov] Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
@@ -131,7 +126,7 @@ WeatherProvider.register("weathergov", {
return;
}
this.fetchedLocationName = `${data.properties.relativeLocation.properties.city}, ${data.properties.relativeLocation.properties.state}`;
Log.log(`Forecast location is ${this.fetchedLocationName}`);
Log.log(`[weatherprovider.weathergov] Forecast location is ${this.fetchedLocationName}`);
this.forecastURL = `${data.properties.forecast}?units=si`;
this.forecastHourlyURL = `${data.properties.forecastHourly}?units=si`;
this.forecastGridDataURL = data.properties.forecastGridData;
@@ -147,7 +142,7 @@ WeatherProvider.register("weathergov", {
this.stationObsURL = `${obsData.features[0].id}/observations/latest`;
})
.catch((err) => {
Log.error(err);
Log.error("[weatherprovider.weathergov] fetchWxGovURLs error: ", err);
})
.finally(() => {
// excellent, let's fetch some actual wx data

View File

@@ -20,14 +20,14 @@ WeatherProvider.register("yr", {
start () {
if (typeof Storage === "undefined") {
//local storage unavailable
Log.error("The Yr weather provider requires local storage.");
Log.error("[weatherprovider.yr] The Yr weather provider requires local storage.");
throw new Error("Local storage not available");
}
if (this.config.updateInterval < 600000) {
Log.warn("The Yr weather provider requires a minimum update interval of 10 minutes (600 000 ms). The configuration has been adjusted to meet this requirement.");
Log.warn("[weatherprovider.yr] The Yr weather provider requires a minimum update interval of 10 minutes (600 000 ms). The configuration has been adjusted to meet this requirement.");
this.delegate.config.updateInterval = 600000;
}
Log.info(`Weather provider: ${this.providerName} started.`);
Log.info(`[weatherprovider.yr] ${this.providerName} started.`);
},
fetchCurrentWeather () {
@@ -37,7 +37,7 @@ WeatherProvider.register("yr", {
this.updateAvailable();
})
.catch((error) => {
Log.error(error);
Log.error("[weatherprovider.yr] fetchCurrentWeather error:", error);
this.updateAvailable();
});
},
@@ -45,10 +45,10 @@ WeatherProvider.register("yr", {
async getCurrentWeather () {
const [weatherData, stellarData] = await Promise.all([this.getWeatherData(), this.getStellarData()]);
if (!stellarData) {
Log.warn("No stellar data available.");
Log.warn("[weatherprovider.yr] No stellar data available.");
}
if (!weatherData.properties.timeseries || !weatherData.properties.timeseries[0]) {
Log.error("No weather data available.");
Log.error("[weatherprovider.yr] No weather data available.");
return;
}
const currentTime = moment();
@@ -105,12 +105,12 @@ WeatherProvider.register("yr", {
let weatherData = this.getWeatherDataFromCache();
if (this.weatherDataIsValid(weatherData)) {
localStorage.removeItem("yrIsFetchingWeatherData");
Log.debug("Weather data found in cache.");
Log.debug("[weatherprovider.yr] Weather data found in cache.");
resolve(weatherData);
} else {
this.getWeatherDataFromYr(weatherData?.downloadedAt)
.then((weatherData) => {
Log.debug("Got weather data from yr.");
Log.debug("[weatherprovider.yr] Got weather data from yr.");
let data;
if (weatherData) {
this.cacheWeatherData(weatherData);
@@ -122,9 +122,9 @@ WeatherProvider.register("yr", {
resolve(data);
})
.catch((err) => {
Log.error(err);
Log.error("[weatherprovider.yr] getWeatherDataFromYr error: ", err);
if (weatherData) {
Log.warn("Using outdated cached weather data.");
Log.warn("[weatherprovider.yr] Using outdated cached weather data.");
resolve(weatherData);
} else {
reject("Unable to get weather data from Yr.");
@@ -171,18 +171,18 @@ WeatherProvider.register("yr", {
return data;
})
.catch((err) => {
Log.error("Could not load weather data.", err);
Log.error("[weatherprovider.yr] Could not load weather data.", err);
throw new Error(err);
});
},
getConfigOptions () {
if (!this.config.lat) {
Log.error("Latitude not provided.");
Log.error("[weatherprovider.yr] Latitude not provided.");
throw new Error("Latitude not provided.");
}
if (!this.config.lon) {
Log.error("Longitude not provided.");
Log.error("[weatherprovider.yr] Longitude not provided.");
throw new Error("Longitude not provided.");
}
@@ -196,12 +196,12 @@ WeatherProvider.register("yr", {
let { lat, lon, altitude } = this.getConfigOptions();
if (lat.includes(".") && lat.split(".")[1].length > 4) {
Log.warn("Latitude is too specific for weather data. Do not use more than four decimals. Trimming to maximum length.");
Log.warn("[weatherprovider.yr] Latitude is too specific for weather data. Do not use more than four decimals. Trimming to maximum length.");
const latParts = lat.split(".");
lat = `${latParts[0]}.${latParts[1].substring(0, 4)}`;
}
if (lon.includes(".") && lon.split(".")[1].length > 4) {
Log.warn("Longitude is too specific for weather data. Do not use more than four decimals. Trimming to maximum length.");
Log.warn("[weatherprovider.yr] Longitude is too specific for weather data. Do not use more than four decimals. Trimming to maximum length.");
const lonParts = lon.split(".");
lon = `${lonParts[0]}.${lonParts[1].substring(0, 4)}`;
}
@@ -249,11 +249,11 @@ WeatherProvider.register("yr", {
const today = moment().format("YYYY-MM-DD");
const tomorrow = moment().add(1, "days").format("YYYY-MM-DD");
if (stellarData && stellarData.today && stellarData.today.date === today && stellarData.tomorrow && stellarData.tomorrow.date === tomorrow) {
Log.debug("Stellar data found in cache.");
Log.debug("[weatherprovider.yr] Stellar data found in cache.");
localStorage.removeItem("yrIsFetchingStellarData");
resolve(stellarData);
} else if (stellarData && stellarData.tomorrow && stellarData.tomorrow.date === today) {
Log.debug("stellar data for today found in cache, but not for tomorrow.");
Log.debug("[weatherprovider.yr] Stellar data for today found in cache, but not for tomorrow.");
stellarData.today = stellarData.tomorrow;
this.getStellarDataFromYr(tomorrow)
.then((data) => {
@@ -267,7 +267,7 @@ WeatherProvider.register("yr", {
}
})
.catch((err) => {
Log.error(err);
Log.error("[weatherprovider.yr] getStellarDataFromYr error: ", err);
reject(`Unable to get stellar data from Yr for ${tomorrow}`);
})
.finally(() => {
@@ -286,12 +286,12 @@ WeatherProvider.register("yr", {
this.cacheStellarData(data);
resolve(data);
} else {
Log.error(`Something went wrong when fetching stellar data. Responses: ${stellarData}`);
Log.error(`[weatherprovider.yr] Something went wrong when fetching stellar data. Responses: ${stellarData}`);
reject(stellarData);
}
})
.catch((err) => {
Log.error(err);
Log.error("[weatherprovider.yr] getStellarDataFromYr error: ", err);
reject("Unable to get stellar data from Yr.");
})
.finally(() => {
@@ -313,11 +313,11 @@ WeatherProvider.register("yr", {
const requestHeaders = [{ name: "Accept", value: "application/json" }];
return this.fetchData(this.getStellarDataUrl(date, days), "json", requestHeaders)
.then((data) => {
Log.debug("Got stellar data from yr.");
Log.debug("[weatherprovider.yr] Got stellar data from yr.");
return data;
})
.catch((err) => {
Log.error("Could not load weather data.", err);
Log.error("[weatherprovider.yr] Could not load weather data.", err);
throw new Error(err);
});
},
@@ -326,12 +326,12 @@ WeatherProvider.register("yr", {
let { lat, lon, altitude } = this.getConfigOptions();
if (lat.includes(".") && lat.split(".")[1].length > 4) {
Log.warn("Latitude is too specific for stellar data. Do not use more than four decimals. Trimming to maximum length.");
Log.warn("[weatherprovider.yr] Latitude is too specific for stellar data. Do not use more than four decimals. Trimming to maximum length.");
const latParts = lat.split(".");
lat = `${latParts[0]}.${latParts[1].substring(0, 4)}`;
}
if (lon.includes(".") && lon.split(".")[1].length > 4) {
Log.warn("Longitude is too specific for stellar data. Do not use more than four decimals. Trimming to maximum length.");
Log.warn("[weatherprovider.yr] Longitude is too specific for stellar data. Do not use more than four decimals. Trimming to maximum length.");
const lonParts = lon.split(".");
lon = `${lonParts[0]}.${lonParts[1].substring(0, 4)}`;
}
@@ -505,7 +505,7 @@ WeatherProvider.register("yr", {
this.updateAvailable();
})
.catch((error) => {
Log.error(error);
Log.error("[weatherprovider.yr] fetchWeatherHourly error: ", error);
this.updateAvailable();
});
},
@@ -513,11 +513,11 @@ WeatherProvider.register("yr", {
async getWeatherForecast (type) {
const [weatherData, stellarData] = await Promise.all([this.getWeatherData(), this.getStellarData()]);
if (!weatherData.properties.timeseries || !weatherData.properties.timeseries[0]) {
Log.error("No weather data available.");
Log.error("[weatherprovider.yr] No weather data available.");
return;
}
if (!stellarData) {
Log.warn("No stellar data available.");
Log.warn("[weatherprovider.yr] No stellar data available.");
}
let forecasts;
switch (type) {
@@ -616,7 +616,7 @@ WeatherProvider.register("yr", {
this.updateAvailable();
})
.catch((error) => {
Log.error(error);
Log.error("[weatherprovider.yr] fetchWeatherForecast error: ", error);
this.updateAvailable();
});
}

View File

@@ -41,6 +41,7 @@ Module.register("weather", {
onlyTemp: false,
colored: false,
absoluteDates: false,
forecastDateFormat: "ddd", // format for forecast date display, e.g., "ddd" = Mon, "dddd" = Monday, "D MMM" = 18 Oct
hourlyForecastIncrements: 1
},
@@ -75,10 +76,10 @@ Module.register("weather", {
moment.locale(this.config.lang);
if (this.config.useKmh) {
Log.warn("Your are using the deprecated config values 'useKmh'. Please switch to windUnits!");
Log.warn("[weather] Deprecation warning: Your are using the deprecated config values 'useKmh'. Please switch to windUnits!");
this.windUnits = "kmh";
} else if (this.config.useBeaufort) {
Log.warn("Your are using the deprecated config values 'useBeaufort'. Please switch to windUnits!");
Log.warn("[weather] Deprecation warning: Your are using the deprecated config values 'useBeaufort'. Please switch to windUnits!");
this.windUnits = "beaufort";
}
if (typeof this.config.showHumidity === "boolean") {
@@ -108,7 +109,7 @@ Module.register("weather", {
for (let event of payload) {
if (event.location || event.geo) {
this.firstEvent = event;
Log.debug("First upcoming event with location: ", event);
Log.debug("[weather] First upcoming event with location: ", event);
break;
}
}
@@ -162,7 +163,7 @@ Module.register("weather", {
// What to do when the weather provider has new information available?
updateAvailable () {
Log.log("New weather information available.");
Log.log("[weather] New weather information available.");
// this value was changed from 0 to 300 to stabilize weather tests:
this.updateDom(300);
this.scheduleUpdate();
@@ -207,7 +208,7 @@ Module.register("weather", {
this.weatherProvider.fetchWeatherForecast();
break;
default:
Log.error(`Invalid type ${this.config.type} configured (must be one of 'current', 'hourly', 'daily' or 'forecast')`);
Log.error(`[weather] Invalid type ${this.config.type} configured (must be one of 'current', 'hourly', 'daily' or 'forecast')`);
}
}, nextLoad);
},

View File

@@ -25,36 +25,36 @@ const WeatherProvider = Class.extend({
// Called when a weather provider is initialized.
init (config) {
this.config = config;
Log.info(`Weather provider: ${this.providerName} initialized.`);
Log.info(`[weatherprovider] ${this.providerName} initialized.`);
},
// Called to set the config, this config is the same as the weather module's config.
setConfig (config) {
this.config = config;
Log.info(`Weather provider: ${this.providerName} config set.`, this.config);
Log.info(`[weatherprovider] ${this.providerName} config set.`, this.config);
},
// Called when the weather provider is about to start.
start () {
Log.info(`Weather provider: ${this.providerName} started.`);
Log.info(`[weatherprovider] ${this.providerName} started.`);
},
// This method should start the API request to fetch the current weather.
// This method should definitely be overwritten in the provider.
fetchCurrentWeather () {
Log.warn(`Weather provider: ${this.providerName} does not subclass the fetchCurrentWeather method.`);
Log.warn(`[weatherprovider] ${this.providerName} does not override the fetchCurrentWeather method.`);
},
// This method should start the API request to fetch the weather forecast.
// This method should definitely be overwritten in the provider.
fetchWeatherForecast () {
Log.warn(`Weather provider: ${this.providerName} does not subclass the fetchWeatherForecast method.`);
Log.warn(`[weatherprovider] ${this.providerName} does not override the fetchWeatherForecast method.`);
},
// This method should start the API request to fetch the weather hourly.
// This method should definitely be overwritten in the provider.
fetchWeatherHourly () {
Log.warn(`Weather provider: ${this.providerName} does not subclass the fetchWeatherHourly method.`);
Log.warn(`[weatherprovider] ${this.providerName} does not override the fetchWeatherHourly method.`);
},
// This returns a WeatherDay object for the current weather.
@@ -107,9 +107,9 @@ const WeatherProvider = Class.extend({
/**
* A convenience function to make requests.
* @param {string} url the url to fetch from
* @param {string} type what contenttype to expect in the response, can be "json" or "xml"
* @param {string} type what content-type to expect in the response, can be "json" or "xml"
* @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send
* @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to recieve
* @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to receive
* @returns {Promise} resolved when the fetch is done
*/
async fetchData (url, type = "json", requestHeaders = undefined, expectedResponseHeaders = undefined) {

7197
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "magicmirror",
"version": "2.33.0",
"version": "2.34.0",
"description": "The open source modular smart mirror platform.",
"keywords": [
"magic mirror",
@@ -20,7 +20,10 @@
"license": "MIT",
"author": "Michael Teeuw",
"contributors": [
"https://github.com/MagicMirrorOrg/MagicMirror/graphs/contributors"
{
"name": "MagicMirror contributors",
"url": "https://github.com/MagicMirrorOrg/MagicMirror/graphs/contributors"
}
],
"type": "commonjs",
"imports": {
@@ -36,32 +39,35 @@
"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",
"lint:prettier": "prettier . --write",
"prepare": "[ -f node_modules/.bin/husky ] && husky || echo no husky installed.",
"server": "node ./serveronly",
"server:watch": "node ./serveronly/watcher.js",
"start": "node --run start:x11",
"start:dev": "node --run start:x11 -- dev",
"start:wayland": "WAYLAND_DISPLAY=\"${WAYLAND_DISPLAY:=wayland-1}\" ./node_modules/.bin/electron js/electron.js --enable-features=UseOzonePlatform --ozone-platform=wayland",
"start:wayland": "WAYLAND_DISPLAY=\"${WAYLAND_DISPLAY:=wayland-1}\" ./node_modules/.bin/electron js/electron.js --ozone-platform=wayland",
"start:wayland:dev": "node --run start:wayland -- dev",
"start:windows": ".\\node_modules\\.bin\\electron js\\electron.js",
"start:windows:dev": "node --run start:windows -- dev",
"start:x11": "DISPLAY=\"${DISPLAY:=:0}\" ./node_modules/.bin/electron js/electron.js",
"start:x11:dev": "node --run start:x11 -- dev",
"test": "NODE_ENV=test jest -i --forceExit",
"test": "vitest run",
"test:calendar": "node ./modules/default/calendar/debug.js",
"test:coverage": "NODE_ENV=test jest --coverage -i --verbose false --forceExit",
"test:coverage": "vitest run --coverage",
"test:css": "stylelint 'css/main.css' 'css/roboto.css' 'css/font-awesome.css' 'modules/default/**/*.css'",
"test:e2e": "NODE_ENV=test jest --selectProjects e2e -i --forceExit",
"test:electron": "NODE_ENV=test jest --selectProjects electron -i --forceExit",
"test:e2e": "vitest run tests/e2e",
"test:electron": "vitest run tests/electron",
"test:js": "eslint",
"test:markdown": "markdownlint-cli2 .",
"test:prettier": "prettier . --check",
"test:spelling": "cspell . --gitignore",
"test:unit": "NODE_ENV=test jest --selectProjects unit"
"test:ui": "vitest --ui",
"test:unit": "vitest run tests/unit",
"test:watch": "vitest"
},
"lint-staged": {
"*": "prettier --ignore-unknown --write",
@@ -69,61 +75,59 @@
"*.css": "stylelint --fix"
},
"dependencies": {
"@fontsource/roboto": "^5.2.8",
"@fontsource/roboto": "^5.2.9",
"@fontsource/roboto-condensed": "^5.2.8",
"@fortawesome/fontawesome-free": "^7.0.1",
"@fortawesome/fontawesome-free": "^7.1.0",
"ajv": "^8.17.1",
"animate.css": "^4.1.1",
"console-stamp": "^3.1.2",
"croner": "^9.1.0",
"envsub": "^4.1.0",
"eslint": "^9.36.0",
"express": "^5.1.0",
"express-ipfilter": "^1.3.2",
"eslint": "^9.39.2",
"express": "^5.2.1",
"feedme": "^2.0.2",
"helmet": "^8.1.0",
"html-to-text": "^9.0.5",
"iconv-lite": "^0.7.0",
"module-alias": "^2.2.3",
"iconv-lite": "^0.7.1",
"ipaddr.js": "^2.3.0",
"moment": "^2.30.1",
"moment-timezone": "^0.6.0",
"node-ical": "^0.21.0",
"node-ical": "^0.22.1",
"nunjucks": "^3.2.4",
"pm2": "^6.0.13",
"socket.io": "^4.8.1",
"pm2": "^6.0.14",
"socket.io": "^4.8.3",
"suncalc": "^1.9.0",
"systeminformation": "^5.27.10",
"systeminformation": "^5.28.2",
"undici": "^7.16.0",
"weathericons": "^2.1.0"
},
"devDependencies": {
"@stylistic/eslint-plugin": "^5.4.0",
"cspell": "^9.2.1",
"@stylistic/eslint-plugin": "^5.6.1",
"@vitest/coverage-v8": "^4.0.16",
"@vitest/ui": "^4.0.16",
"cspell": "^9.4.0",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-jest": "^29.0.1",
"eslint-plugin-jsdoc": "^60.1.1",
"eslint-plugin-package-json": "^0.56.3",
"eslint-plugin-jsdoc": "^61.5.0",
"eslint-plugin-package-json": "^0.85.0",
"eslint-plugin-playwright": "^2.4.0",
"eslint-plugin-vitest": "^0.5.4",
"express-basic-auth": "^1.2.1",
"husky": "^9.1.7",
"jest": "^30.1.3",
"jsdom": "^27.0.0",
"lint-staged": "^16.2.0",
"markdownlint-cli2": "^0.18.1",
"playwright": "^1.55.0",
"prettier": "^3.6.2",
"jsdom": "^27.4.0",
"lint-staged": "^16.2.7",
"markdownlint-cli2": "^0.20.0",
"playwright": "^1.57.0",
"prettier": "^3.7.4",
"prettier-plugin-jinja-template": "^2.1.0",
"stylelint": "^16.24.0",
"stylelint-config-standard": "^39.0.0",
"stylelint-prettier": "^5.0.3"
"stylelint": "^16.26.1",
"stylelint-config-standard": "^39.0.1",
"stylelint-prettier": "^5.0.3",
"vitest": "^4.0.16"
},
"optionalDependencies": {
"electron": "^38.1.2"
"electron": "^39.2.7"
},
"engines": {
"node": ">=22.18.0"
},
"_moduleAliases": {
"node_helper": "js/node_helper.js",
"logger": "js/logger.js"
"node": ">=22.21.1 <23 || >=24"
}
}

261
serveronly/watcher.js Normal file
View File

@@ -0,0 +1,261 @@
// Load lightweight internal alias resolver to enable require("logger")
require("../js/alias-resolver");
const { spawn } = require("child_process");
const fs = require("fs");
const path = require("path");
const net = require("net");
const http = require("http");
const Log = require("logger");
const { getConfigFilePath } = require("#server_functions");
const RESTART_DELAY_MS = 500;
const PORT_CHECK_MAX_ATTEMPTS = 20;
const PORT_CHECK_INTERVAL_MS = 500;
let child = null;
let restartTimer = null;
let isShuttingDown = false;
let isRestarting = false;
let serverConfig = null;
const rootDir = path.join(__dirname, "..");
/**
* Get the server configuration (port and address)
* @returns {{port: number, address: string}} The server config
*/
function getServerConfig () {
if (serverConfig) return serverConfig;
try {
const configPath = getConfigFilePath();
delete require.cache[require.resolve(configPath)];
const config = require(configPath);
serverConfig = {
port: global.mmPort || config.port || 8080,
address: config.address || "localhost"
};
} catch (err) {
serverConfig = { port: 8080, address: "localhost" };
}
return serverConfig;
}
/**
* Check if a port is available on the configured address
* @param {number} port The port to check
* @returns {Promise<boolean>} True if port is available
*/
function isPortAvailable (port) {
return new Promise((resolve) => {
const server = net.createServer();
server.once("error", () => {
resolve(false);
});
server.once("listening", () => {
server.close();
resolve(true);
});
// Use the same address as the actual server will bind to
const { address } = getServerConfig();
server.listen(port, address);
});
}
/**
* Wait until port is available
* @param {number} port The port to wait for
* @param {number} maxAttempts Maximum number of attempts
* @returns {Promise<void>}
*/
async function waitForPort (port, maxAttempts = PORT_CHECK_MAX_ATTEMPTS) {
for (let i = 0; i < maxAttempts; i++) {
if (await isPortAvailable(port)) {
Log.info(`Port ${port} is now available`);
return;
}
await new Promise((resolve) => setTimeout(resolve, PORT_CHECK_INTERVAL_MS));
}
Log.warn(`Port ${port} still not available after ${maxAttempts} attempts`);
}
/**
* Start the server process
*/
function startServer () {
// Start node directly instead of via npm to avoid process tree issues
child = spawn("node", ["./serveronly"], {
stdio: "inherit",
cwd: path.join(__dirname, "..")
});
child.on("error", (error) => {
Log.error("Failed to start server process:", error.message);
child = null;
});
child.on("exit", (code, signal) => {
child = null;
if (isShuttingDown) {
return;
}
if (isRestarting) {
// Expected restart - don't log as error
isRestarting = false;
} else {
// Unexpected exit
Log.error(`Server exited unexpectedly with code ${code} and signal ${signal}`);
}
});
}
/**
* Send reload notification to all connected clients
*/
function notifyClientsToReload () {
const { port, address } = getServerConfig();
const options = {
hostname: address,
port: port,
path: "/reload",
method: "GET"
};
const req = http.request(options, (res) => {
if (res.statusCode === 200) {
Log.info("Reload notification sent to clients");
}
});
req.on("error", (err) => {
// Server might not be running yet, ignore
Log.debug(`Could not send reload notification: ${err.message}`);
});
req.end();
}
/**
* Restart the server process
* @param {string} reason The reason for the restart
*/
async function restartServer (reason) {
if (restartTimer) clearTimeout(restartTimer);
restartTimer = setTimeout(async () => {
Log.info(reason);
if (child) {
isRestarting = true;
// Get the actual port being used
const { port } = getServerConfig();
// Notify clients to reload before restart
notifyClientsToReload();
// Set up one-time listener for the exit event
child.once("exit", async () => {
// Wait until port is actually available
await waitForPort(port);
// Reset config cache in case it changed
serverConfig = null;
startServer();
});
child.kill("SIGTERM");
} else {
startServer();
}
}, RESTART_DELAY_MS);
}
/**
* Watch a specific file for changes and restart the server on change
* Watches the parent directory to handle editors that use atomic writes
* @param {string} file The file path to watch
*/
function watchFile (file) {
try {
const fileName = path.basename(file);
const dirName = path.dirname(file);
const watcher = fs.watch(dirName, (_eventType, changedFile) => {
// Only trigger for the specific file we're interested in
if (changedFile !== fileName) return;
Log.info(`[watchFile] Change detected in: ${file}`);
if (restartTimer) clearTimeout(restartTimer);
restartTimer = setTimeout(() => {
Log.info(`[watchFile] Triggering restart due to change in: ${file}`);
restartServer(`File changed: ${path.basename(file)} — restarting...`);
}, RESTART_DELAY_MS);
});
watcher.on("error", (error) => {
Log.error(`Watcher error for ${file}:`, error.message);
});
Log.log(`Watching file: ${file}`);
} catch (error) {
Log.error(`Failed to watch file ${file}:`, error.message);
}
}
startServer();
// Setup file watching based on config
try {
const configPath = getConfigFilePath();
delete require.cache[require.resolve(configPath)];
const config = require(configPath);
let watchTargets = [];
if (Array.isArray(config.watchTargets) && config.watchTargets.length > 0) {
watchTargets = config.watchTargets.filter((target) => typeof target === "string" && target.trim() !== "");
}
if (watchTargets.length === 0) {
Log.warn("Watch mode is enabled but no watchTargets are configured. No files will be monitored. Set the watchTargets array in your config.js to enable file watching.");
}
Log.log(`Watch mode enabled. Watching ${watchTargets.length} file(s)`);
// Watch each target file
for (const target of watchTargets) {
const targetPath = path.isAbsolute(target)
? target
: path.join(rootDir, target);
// Check if file exists
if (!fs.existsSync(targetPath)) {
Log.warn(`Watch target does not exist: ${targetPath}`);
continue;
}
// Check if it's a file (directories are not supported)
const stats = fs.statSync(targetPath);
if (stats.isFile()) {
watchFile(targetPath);
} else {
Log.warn(`Watch target is not a file (directories not supported): ${targetPath}`);
}
}
} catch (err) {
// Config file might not exist or be invalid, use fallback targets
Log.warn("Could not load watchTargets from config.");
}
process.on("SIGINT", () => {
isShuttingDown = true;
if (restartTimer) clearTimeout(restartTimer);
if (child) child.kill("SIGTERM");
process.exit(0);
});

View File

@@ -12,9 +12,8 @@ let config = {
maximumEntries: 1,
calendars: [
{
fetchInterval: 7 * 24 * 60 * 60 * 1000,
symbol: ["calendar-check", "google"],
url: "https://ics.calendarlabs.com/76/mm3137/US_Holidays.ics"
url: "http://localhost:8080/tests/mocks/12_events.ics"
}
]
}

View File

@@ -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
@@ -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();
}
/**
@@ -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));
@@ -58,13 +57,13 @@ 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) {
@@ -72,7 +71,6 @@ describe("AnimateCSS integration Test", () => {
await new Promise((r) => setTimeout(r, 2100));
await waitForAnimationClass(`animate__${animationOut}`);
}
return true;
}
afterEach(async () => {
@@ -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();
});
});
});

View File

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

View File

@@ -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();
@@ -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²");
});
});

View File

@@ -1,7 +1,7 @@
const path = require("node:path");
const os = require("node:os");
const fs = require("node:fs");
const jsdom = require("jsdom");
const { chromium } = require("playwright");
// global absolute root path
global.root_path = path.resolve(`${__dirname}/../../../`);
@@ -16,20 +16,115 @@ const sampleCss = [
" top: 100%;",
"}"
];
var indexData = [];
var cssData = [];
let indexData = "";
let cssData = "";
let browser;
let context;
let page;
/**
* Ensure Playwright browser and context are available.
* @returns {Promise<void>}
*/
async function ensureContext () {
if (!browser) {
// Additional args for CI stability to prevent crashes
const launchOptions = {
headless: true,
args: [
"--disable-dev-shm-usage", // Overcome limited resource problems in Docker/CI
"--disable-gpu", // Disable GPU hardware acceleration
"--no-sandbox", // Required for running as root in some CI environments
"--disable-setuid-sandbox",
"--single-process" // Run in single process mode for better stability in CI
]
};
browser = await chromium.launch(launchOptions);
}
if (!context) {
context = await browser.newContext();
}
}
/**
* Open a fresh page pointing to the provided url.
* @param {string} url target url
* @returns {Promise<import('playwright').Page>} initialized page instance
*/
async function openPage (url) {
await ensureContext();
if (page) {
await page.close();
}
page = await context.newPage();
await page.goto(url, { waitUntil: "load" });
return page;
}
/**
* Close page, context and browser if they exist.
* @returns {Promise<void>}
*/
async function closeBrowser () {
if (page) {
await page.close();
page = null;
}
if (context) {
await context.close();
context = null;
}
if (browser) {
await browser.close();
browser = null;
}
}
exports.getPage = () => {
if (!page) {
throw new Error("Playwright page is not initialized. Call getDocument() first.");
}
return page;
};
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();
await exports.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,100 +133,35 @@ exports.startApplication = async (configFilename, exec) => {
return global.app.start();
};
exports.stopApplication = async (waitTime = 10) => {
if (global.window) {
// no closing causes jest 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));
}
exports.stopApplication = async (waitTime = 100) => {
await closeBrowser();
if (!global.app) {
delete global.testPort;
return Promise.resolve();
}
await global.app.stop();
delete global.app;
delete global.testPort;
// Wait for any pending async operations to complete before closing DOM
await new Promise((resolve) => setTimeout(resolve, waitTime));
};
exports.getDocument = () => {
return new Promise((resolve) => {
const url = `http://${config.address || "localhost"}:${config.port || "8080"}`;
jsdom.JSDOM.fromURL(url, { resources: "usable", runScripts: "dangerously" }).then((dom) => {
dom.window.name = "jsdom";
global.window = dom.window;
// Following fixes `navigator is not defined` errors in e2e tests, found here
// https://www.appsloveworld.com/reactjs/100/37/mocha-react-navigator-is-not-defined
global.navigator = {
useragent: "node.js"
};
dom.window.fetch = fetch;
dom.window.onload = () => {
global.document = dom.window.document;
resolve();
};
});
});
};
exports.getDocument = async () => {
const port = global.testPort || config.port || 8080;
const address = config.address === "0.0.0.0" ? "localhost" : config.address || "localhost";
const url = `http://${address}:${port}`;
exports.waitForElement = (selector, ignoreValue = "", timeout = 0) => {
return new Promise((resolve) => {
let oldVal = "dummy12345";
let element = null;
const interval = setInterval(() => {
element = document.querySelector(selector);
if (element) {
let newVal = element.textContent;
if (newVal === oldVal) {
clearInterval(interval);
resolve(element);
} else {
if (ignoreValue === "") {
oldVal = newVal;
} else {
if (!newVal.includes(ignoreValue)) oldVal = newVal;
}
}
}
}, 100);
if (timeout !== 0) {
setTimeout(() => {
if (interval) clearInterval(interval);
resolve(null);
}, timeout);
}
});
};
exports.waitForAllElements = (selector) => {
return new Promise((resolve) => {
let oldVal = 999999;
const interval = setInterval(() => {
const element = document.querySelectorAll(selector);
if (element) {
let newVal = element.length;
if (newVal === oldVal) {
clearInterval(interval);
resolve(element);
} else {
if (newVal !== 0) oldVal = newVal;
}
}
}, 100);
});
};
exports.testMatch = async (element, regex) => {
const elem = await this.waitForElement(element);
expect(elem).not.toBeNull();
expect(elem.textContent).toMatch(regex);
return true;
await openPage(url);
};
exports.fixupIndex = async () => {
// read and save the git level index file
indexData = (await fs.promises.readFile(indexFile)).toString();
// make lines of the content
let workIndexLines = indexData.split(os.EOL);
const workIndexLines = indexData.split(os.EOL);
// loop thru the lines to find place to insert new region
for (let l in workIndexLines) {
if (workIndexLines[l].includes("region top right")) {
@@ -150,7 +180,7 @@ exports.fixupIndex = async () => {
exports.restoreIndex = async () => {
// if we read in data
if (indexData.length > 1) {
if (indexData.length > 0) {
//write out saved index.html
await fs.promises.writeFile(indexFile, indexData, { flush: true });
// write out saved custom.css

View File

@@ -1,18 +1,6 @@
const { injectMockData, cleanupMockData } = require("../../utils/weather_mocker");
const helpers = require("./global-setup");
exports.getText = async (element, result) => {
const elem = await helpers.waitForElement(element);
expect(elem).not.toBeNull();
expect(
elem.textContent
.trim()
.replace(/(\r\n|\n|\r)/gm, "")
.replace(/[ ]+/g, " ")
).toBe(result);
return true;
};
exports.startApplication = async (configFileName, additionalMockData) => {
await helpers.startApplication(injectMockData(configFileName, additionalMockData));
await helpers.getDocument();

View File

@@ -1,7 +1,7 @@
const helpers = require("./helpers/global-setup");
describe("ipWhitelist directive configuration", () => {
describe("Set ipWhitelist without access", () => {
describe("When IP is not in whitelist", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/noIpWhiteList.js");
});
@@ -9,13 +9,14 @@ describe("ipWhitelist directive configuration", () => {
await helpers.stopApplication();
});
it("should return 403", async () => {
const res = await fetch("http://localhost:8181");
it("should reject request with 403 (Forbidden)", async () => {
const port = global.testPort || 8080;
const res = await fetch(`http://localhost:${port}`);
expect(res.status).toBe(403);
});
});
describe("Set ipWhitelist []", () => {
describe("When whitelist is empty (allow all IPs)", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/empty_ipWhiteList.js");
});
@@ -23,8 +24,9 @@ describe("ipWhitelist directive configuration", () => {
await helpers.stopApplication();
});
it("should return 200", async () => {
const res = await fetch("http://localhost:8282");
it("should allow request with 200 (OK)", async () => {
const port = global.testPort || 8080;
const res = await fetch(`http://localhost:${port}`);
expect(res.status).toBe(200);
});
});

View File

@@ -1,6 +1,9 @@
const { expect } = require("playwright/test");
const helpers = require("../helpers/global-setup");
describe("Alert module", () => {
let page;
afterAll(async () => {
await helpers.stopApplication();
});
@@ -9,6 +12,7 @@ describe("Alert module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/alert/welcome_false.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should not show any welcome message", async () => {
@@ -16,8 +20,7 @@ describe("Alert module", () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
// Check that no alert/notification elements are present
const alertElements = document.querySelectorAll(".ns-box .ns-box-inner .light.bright.small");
expect(alertElements).toHaveLength(0);
await expect(page.locator(".ns-box .ns-box-inner .light.bright.small")).toHaveCount(0);
});
});
@@ -25,15 +28,14 @@ describe("Alert module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/alert/welcome_true.js");
await helpers.getDocument();
page = helpers.getPage();
// Wait for the application to initialize
await new Promise((resolve) => setTimeout(resolve, 1000));
});
it("should show the translated welcome message", async () => {
const elem = await helpers.waitForElement(".ns-box .ns-box-inner .light.bright.small");
expect(elem).not.toBeNull();
expect(elem.textContent).toContain("Welcome, start was successful!");
await expect(page.locator(".ns-box .ns-box-inner .light.bright.small")).toContainText("Welcome, start was successful!");
});
});
@@ -41,12 +43,11 @@ describe("Alert module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/alert/welcome_string.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should show the custom welcome message", async () => {
const elem = await helpers.waitForElement(".ns-box .ns-box-inner .light.bright.small");
expect(elem).not.toBeNull();
expect(elem.textContent).toContain("Custom welcome message!");
await expect(page.locator(".ns-box .ns-box-inner .light.bright.small")).toContainText("Custom welcome message!");
});
});
});

View File

@@ -1,30 +1,28 @@
const { expect } = require("playwright/test");
const helpers = require("../helpers/global-setup");
const serverBasicAuth = require("../helpers/basic-auth");
describe("Calendar module", () => {
let page;
/**
* @param {string} element css selector
* @param {string} result expected number
* @param {string} not reverse result
* @returns {boolean} result
* Assert the number of matching elements.
* @param {string} selector css selector
* @param {number} expectedLength expected number of elements
* @param {string} [not] optional negation marker (use "not" to negate)
* @returns {Promise<void>}
*/
const testElementLength = async (element, result, not) => {
const elem = await helpers.waitForAllElements(element);
expect(elem).not.toBeNull();
const testElementLength = async (selector, expectedLength, not) => {
const locator = page.locator(selector);
if (not === "not") {
expect(elem).not.toHaveLength(result);
await expect(locator).not.toHaveCount(expectedLength);
} else {
expect(elem).toHaveLength(result);
await expect(locator).toHaveCount(expectedLength);
}
return true;
};
const testTextContain = async (element, text) => {
const elem = await helpers.waitForElement(element, "undefinedLoading");
expect(elem).not.toBeNull();
expect(elem.textContent).toContain(text);
return true;
const testTextContain = async (selector, expectedText) => {
await expect(page.locator(selector).first()).toContainText(expectedText);
};
afterAll(async () => {
@@ -35,14 +33,15 @@ describe("Calendar module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/calendar/default.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should show the default maximumEntries of 10", async () => {
await expect(testElementLength(".calendar .event", 10)).resolves.toBe(true);
await testElementLength(".calendar .event", 10);
});
it("should show the default calendar symbol in each event", async () => {
await expect(testElementLength(".calendar .event .fa-calendar-days", 0, "not")).resolves.toBe(true);
await testElementLength(".calendar .event .fa-calendar-days", 0, "not");
});
});
@@ -50,30 +49,31 @@ describe("Calendar module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/calendar/custom.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should show the custom maximumEntries of 5", async () => {
await expect(testElementLength(".calendar .event", 5)).resolves.toBe(true);
await testElementLength(".calendar .event", 5);
});
it("should show the custom calendar symbol in four events", async () => {
await expect(testElementLength(".calendar .event .fa-birthday-cake", 4)).resolves.toBe(true);
await testElementLength(".calendar .event .fa-birthday-cake", 4);
});
it("should show a customEvent calendar symbol in one event", async () => {
await expect(testElementLength(".calendar .event .fa-dice", 1)).resolves.toBe(true);
await testElementLength(".calendar .event .fa-dice", 1);
});
it("should show a customEvent calendar eventClass in one event", async () => {
await expect(testElementLength(".calendar .event.undo", 1)).resolves.toBe(true);
await testElementLength(".calendar .event.undo", 1);
});
it("should show two custom icons for repeating events", async () => {
await expect(testElementLength(".calendar .event .fa-undo", 2)).resolves.toBe(true);
await testElementLength(".calendar .event .fa-undo", 2);
});
it("should show two custom icons for day events", async () => {
await expect(testElementLength(".calendar .event .fa-calendar-day", 2)).resolves.toBe(true);
await testElementLength(".calendar .event .fa-calendar-day", 2);
});
});
@@ -81,10 +81,11 @@ describe("Calendar module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/calendar/recurring.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should show the recurring birthday event 6 times", async () => {
await expect(testElementLength(".calendar .event", 6)).resolves.toBe(true);
await testElementLength(".calendar .event", 6);
});
});
@@ -93,15 +94,16 @@ describe("Calendar module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/calendar/long-fullday-event.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should contain text 'Ends in' with the left days", async () => {
await expect(testTextContain(".calendar .today .time", "Ends in")).resolves.toBe(true);
await expect(testTextContain(".calendar .yesterday .time", "Today")).resolves.toBe(true);
await expect(testTextContain(".calendar .tomorrow .time", "Tomorrow")).resolves.toBe(true);
await testTextContain(".calendar .today .time", "Ends in");
await testTextContain(".calendar .yesterday .time", "Today");
await testTextContain(".calendar .tomorrow .time", "Tomorrow");
});
it("should contain in total three events", async () => {
await expect(testElementLength(".calendar .event", 3)).resolves.toBe(true);
await testElementLength(".calendar .event", 3);
});
});
@@ -109,37 +111,23 @@ describe("Calendar module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/calendar/single-fullday-event.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should contain text 'Today'", async () => {
await expect(testTextContain(".calendar .time", "Today")).resolves.toBe(true);
await testTextContain(".calendar .time", "Today");
});
it("should contain in total two events", async () => {
await expect(testElementLength(".calendar .event", 2)).resolves.toBe(true);
await testElementLength(".calendar .event", 2);
});
});
for (let i = -12; i < 12; i++) {
describe("Recurring event per timezone", () => {
beforeAll(async () => {
Date.prototype.getTimezoneOffset = () => {
return i * 60;
};
await helpers.startApplication("tests/configs/modules/calendar/recurring.js");
await helpers.getDocument();
});
it(`should contain text "Mar 25th" in timezone UTC ${-i}`, async () => {
await expect(testTextContain(".calendar", "Mar 25th")).resolves.toBe(true);
});
});
}
describe("Changed port", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/calendar/changed-port.js");
serverBasicAuth.listen(8010);
await helpers.getDocument();
page = helpers.getPage();
});
afterAll(async () => {
@@ -147,7 +135,7 @@ describe("Calendar module", () => {
});
it("should return TestEvents", async () => {
await expect(testElementLength(".calendar .event", 0, "not")).resolves.toBe(true);
await testElementLength(".calendar .event", 0, "not");
});
});
@@ -155,10 +143,11 @@ describe("Calendar module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/calendar/basic-auth.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should return TestEvents", async () => {
await expect(testElementLength(".calendar .event", 0, "not")).resolves.toBe(true);
await testElementLength(".calendar .event", 0, "not");
});
});
@@ -166,10 +155,11 @@ describe("Calendar module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/calendar/auth-default.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should return TestEvents", async () => {
await expect(testElementLength(".calendar .event", 0, "not")).resolves.toBe(true);
await testElementLength(".calendar .event", 0, "not");
});
});
@@ -177,10 +167,11 @@ describe("Calendar module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/calendar/old-basic-auth.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should return TestEvents", async () => {
await expect(testElementLength(".calendar .event", 0, "not")).resolves.toBe(true);
await testElementLength(".calendar .event", 0, "not");
});
});
@@ -189,6 +180,7 @@ describe("Calendar module", () => {
await helpers.startApplication("tests/configs/modules/calendar/fail-basic-auth.js");
serverBasicAuth.listen(8020);
await helpers.getDocument();
page = helpers.getPage();
});
afterAll(async () => {
@@ -196,7 +188,7 @@ describe("Calendar module", () => {
});
it("should show Unauthorized error", async () => {
await expect(testTextContain(".calendar", "Error in the calendar module. Authorization failed")).resolves.toBe(true);
await testTextContain(".calendar", "Error in the calendar module. Authorization failed");
});
});
});

View File

@@ -1,6 +1,9 @@
const { expect } = require("playwright/test");
const helpers = require("../helpers/global-setup");
describe("Clock set to german language module", () => {
let page;
afterAll(async () => {
await helpers.stopApplication();
});
@@ -9,11 +12,12 @@ describe("Clock set to german language module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/de/clock_showWeek.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("shows week with correct format", async () => {
const weekRegex = /^[0-9]{1,2}. Kalenderwoche$/;
await expect(helpers.testMatch(".clock .week", weekRegex)).resolves.toBe(true);
await expect(page.locator(".clock .week")).toHaveText(weekRegex);
});
});
@@ -21,11 +25,12 @@ describe("Clock set to german language module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/de/clock_showWeek_short.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("shows week with correct format", async () => {
const weekRegex = /^[0-9]{1,2}KW$/;
await expect(helpers.testMatch(".clock .week", weekRegex)).resolves.toBe(true);
await expect(page.locator(".clock .week")).toHaveText(weekRegex);
});
});
});

View File

@@ -1,6 +1,9 @@
const { expect } = require("playwright/test");
const helpers = require("../helpers/global-setup");
describe("Clock set to spanish language module", () => {
let page;
afterAll(async () => {
await helpers.stopApplication();
});
@@ -9,16 +12,17 @@ describe("Clock set to spanish language module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/es/clock_24hr.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("shows date with correct format", async () => {
const dateRegex = /^(?:lunes|martes|miércoles|jueves|viernes|sábado|domingo), \d{1,2} de (?:enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|octubre|noviembre|diciembre) de \d{4}$/;
await expect(helpers.testMatch(".clock .date", dateRegex)).resolves.toBe(true);
await expect(page.locator(".clock .date")).toHaveText(dateRegex);
});
it("shows time in 24hr format", async () => {
const timeRegex = /^(?:2[0-3]|[01]\d):[0-5]\d[0-5]\d$/;
await expect(helpers.testMatch(".clock .time", timeRegex)).resolves.toBe(true);
await expect(page.locator(".clock .time")).toHaveText(timeRegex);
});
});
@@ -26,16 +30,17 @@ describe("Clock set to spanish language module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/es/clock_12hr.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("shows date with correct format", async () => {
const dateRegex = /^(?:lunes|martes|miércoles|jueves|viernes|sábado|domingo), \d{1,2} de (?:enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|octubre|noviembre|diciembre) de \d{4}$/;
await expect(helpers.testMatch(".clock .date", dateRegex)).resolves.toBe(true);
await expect(page.locator(".clock .date")).toHaveText(dateRegex);
});
it("shows time in 12hr format", async () => {
const timeRegex = /^(?:1[0-2]|[1-9]):[0-5]\d[0-5]\d[ap]m$/;
await expect(helpers.testMatch(".clock .time", timeRegex)).resolves.toBe(true);
await expect(page.locator(".clock .time")).toHaveText(timeRegex);
});
});
@@ -43,11 +48,12 @@ describe("Clock set to spanish language module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/es/clock_showPeriodUpper.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("shows 12hr time with upper case AM/PM", async () => {
const timeRegex = /^(?:1[0-2]|[1-9]):[0-5]\d[0-5]\d[AP]M$/;
await expect(helpers.testMatch(".clock .time", timeRegex)).resolves.toBe(true);
await expect(page.locator(".clock .time")).toHaveText(timeRegex);
});
});
@@ -55,11 +61,12 @@ describe("Clock set to spanish language module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/es/clock_showWeek.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("shows week with correct format", async () => {
const weekRegex = /^Semana [0-9]{1,2}$/;
await expect(helpers.testMatch(".clock .week", weekRegex)).resolves.toBe(true);
await expect(page.locator(".clock .week")).toHaveText(weekRegex);
});
});
@@ -67,11 +74,12 @@ describe("Clock set to spanish language module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/es/clock_showWeek_short.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("shows week with correct format", async () => {
const weekRegex = /^S[0-9]{1,2}$/;
await expect(helpers.testMatch(".clock .week", weekRegex)).resolves.toBe(true);
await expect(page.locator(".clock .week")).toHaveText(weekRegex);
});
});
});

View File

@@ -1,7 +1,10 @@
const { expect } = require("playwright/test");
const moment = require("moment");
const helpers = require("../helpers/global-setup");
describe("Clock module", () => {
let page;
afterAll(async () => {
await helpers.stopApplication();
});
@@ -10,16 +13,17 @@ describe("Clock module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_24hr.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should show the date in the correct format", async () => {
const dateRegex = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (?:January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}, \d{4}$/;
await expect(helpers.testMatch(".clock .date", dateRegex)).resolves.toBe(true);
await expect(page.locator(".clock .date")).toHaveText(dateRegex);
});
it("should show the time in 24hr format", async () => {
const timeRegex = /^(?:2[0-3]|[01]\d):[0-5]\d[0-5]\d$/;
await expect(helpers.testMatch(".clock .time", timeRegex)).resolves.toBe(true);
await expect(page.locator(".clock .time")).toHaveText(timeRegex);
});
});
@@ -27,23 +31,22 @@ describe("Clock module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_12hr.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should show the date in the correct format", async () => {
const dateRegex = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (?:January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}, \d{4}$/;
await expect(helpers.testMatch(".clock .date", dateRegex)).resolves.toBe(true);
await expect(page.locator(".clock .date")).toHaveText(dateRegex);
});
it("should show the time in 12hr format", async () => {
const timeRegex = /^(?:1[0-2]|[1-9]):[0-5]\d[0-5]\d[ap]m$/;
await expect(helpers.testMatch(".clock .time", timeRegex)).resolves.toBe(true);
await expect(page.locator(".clock .time")).toHaveText(timeRegex);
});
it("check for discreet elements of clock", async () => {
let elemClock = await helpers.waitForElement(".clock-hour-digital");
await expect(elemClock).not.toBeNull();
elemClock = await helpers.waitForElement(".clock-minute-digital");
await expect(elemClock).not.toBeNull();
await expect(page.locator(".clock-hour-digital")).toBeVisible();
await expect(page.locator(".clock-minute-digital")).toBeVisible();
});
});
@@ -51,11 +54,12 @@ describe("Clock module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_showPeriodUpper.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should show 12hr time with upper case AM/PM", async () => {
const timeRegex = /^(?:1[0-2]|[1-9]):[0-5]\d[0-5]\d[AP]M$/;
await expect(helpers.testMatch(".clock .time", timeRegex)).resolves.toBe(true);
await expect(page.locator(".clock .time")).toHaveText(timeRegex);
});
});
@@ -63,11 +67,12 @@ describe("Clock module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_displaySeconds_false.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should show 12hr time without seconds am/pm", async () => {
const timeRegex = /^(?:1[0-2]|[1-9]):[0-5]\d[ap]m$/;
await expect(helpers.testMatch(".clock .time", timeRegex)).resolves.toBe(true);
await expect(page.locator(".clock .time")).toHaveText(timeRegex);
});
});
@@ -75,11 +80,11 @@ describe("Clock module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_showTime.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should not show the time when digital clock is shown", async () => {
const elem = document.querySelector(".clock .digital .time");
expect(elem).toBeNull();
await expect(page.locator(".clock .digital .time")).toHaveCount(0);
});
});
@@ -87,19 +92,16 @@ describe("Clock module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_showSunMoon.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should show the sun times", async () => {
const elem = await helpers.waitForElement(".clock .digital .sun");
expect(elem).not.toBeNull();
const elem2 = await helpers.waitForElement(".clock .digital .sun .fas.fa-sun");
expect(elem2).not.toBeNull();
await expect(page.locator(".clock .digital .sun")).toBeVisible();
await expect(page.locator(".clock .digital .sun .fas.fa-sun")).toBeVisible();
});
it("should show the moon times", async () => {
const elem = await helpers.waitForElement(".clock .digital .moon");
expect(elem).not.toBeNull();
await expect(page.locator(".clock .digital .moon")).toBeVisible();
});
});
@@ -107,14 +109,12 @@ describe("Clock module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_showSunNoEvent.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should show the sun times", async () => {
const elem = await helpers.waitForElement(".clock .digital .sun");
expect(elem).not.toBeNull();
const elem2 = document.querySelector(".clock .digital .sun .fas.fa-sun");
expect(elem2).toBeNull();
await expect(page.locator(".clock .digital .sun")).toBeVisible();
await expect(page.locator(".clock .digital .sun .fas.fa-sun")).toHaveCount(0);
});
});
@@ -122,19 +122,18 @@ describe("Clock module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_showWeek.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should show the week in the correct format", async () => {
const weekRegex = /^Week [0-9]{1,2}$/;
await expect(helpers.testMatch(".clock .week", weekRegex)).resolves.toBe(true);
await expect(page.locator(".clock .week")).toHaveText(weekRegex);
});
it("should show the week with the correct number of week of year", async () => {
const currentWeekNumber = moment().week();
const weekToShow = `Week ${currentWeekNumber}`;
const elem = await helpers.waitForElement(".clock .week");
expect(elem).not.toBeNull();
expect(elem.textContent).toBe(weekToShow);
await expect(page.locator(".clock .week")).toHaveText(weekToShow);
});
});
@@ -142,19 +141,18 @@ describe("Clock module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_showWeek_short.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should show the week in the correct format", async () => {
const weekRegex = /^W[0-9]{1,2}$/;
await expect(helpers.testMatch(".clock .week", weekRegex)).resolves.toBe(true);
await expect(page.locator(".clock .week")).toHaveText(weekRegex);
});
it("should show the week with the correct number of week of year", async () => {
const currentWeekNumber = moment().week();
const weekToShow = `W${currentWeekNumber}`;
const elem = await helpers.waitForElement(".clock .week");
expect(elem).not.toBeNull();
expect(elem.textContent).toBe(weekToShow);
await expect(page.locator(".clock .week")).toHaveText(weekToShow);
});
});
@@ -162,11 +160,11 @@ describe("Clock module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_analog.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should show the analog clock face", async () => {
const elem = await helpers.waitForElement(".clock-circle");
expect(elem).not.toBeNull();
await expect(page.locator(".clock-circle")).toBeVisible();
});
});
@@ -174,13 +172,12 @@ describe("Clock module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_showDateAnalog.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should show the analog clock face and the date", async () => {
const elemClock = await helpers.waitForElement(".clock-circle");
await expect(elemClock).not.toBeNull();
const elemDate = await helpers.waitForElement(".clock .date");
await expect(elemDate).not.toBeNull();
await expect(page.locator(".clock-circle")).toBeVisible();
await expect(page.locator(".clock .date")).toBeVisible();
});
});
});

View File

@@ -1,19 +1,20 @@
const { expect } = require("playwright/test");
const helpers = require("../helpers/global-setup");
describe("Compliments module", () => {
let page;
/**
* move similar tests in function doTest
* @param {Array} complimentsArray The array of compliments.
* @returns {boolean} result
* @returns {Promise<void>}
*/
const doTest = async (complimentsArray) => {
let elem = await helpers.waitForElement(".compliments");
expect(elem).not.toBeNull();
elem = await helpers.waitForElement(".module-content");
expect(elem).not.toBeNull();
expect(complimentsArray).toContain(elem.textContent);
return true;
await expect(page.locator(".compliments")).toBeVisible();
const contentLocator = page.locator(".module-content");
await contentLocator.waitFor({ state: "visible" });
const content = await contentLocator.textContent();
expect(complimentsArray).toContain(content);
};
afterAll(async () => {
@@ -25,10 +26,11 @@ describe("Compliments module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/compliments/compliments_anytime.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("shows anytime because if configure empty parts of day compliments and set anytime compliments", async () => {
await expect(doTest(["Anytime here"])).resolves.toBe(true);
await doTest(["Anytime here"]);
});
});
@@ -36,10 +38,11 @@ describe("Compliments module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/compliments/compliments_only_anytime.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("shows anytime compliments", async () => {
await expect(doTest(["Anytime here"])).resolves.toBe(true);
await doTest(["Anytime here"]);
});
});
});
@@ -48,10 +51,11 @@ describe("Compliments module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/compliments/compliments_remote.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should show compliments from a remote file", async () => {
await expect(doTest(["Remote compliment file works!"])).resolves.toBe(true);
await doTest(["Remote compliment file works!"]);
});
});
@@ -60,10 +64,11 @@ describe("Compliments module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/compliments/compliments_specialDayUnique_false.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("compliments array can contain all values", async () => {
await expect(doTest(["Special day message", "Typical message 1", "Typical message 2", "Typical message 3"])).resolves.toBe(true);
await doTest(["Special day message", "Typical message 1", "Typical message 2", "Typical message 3"]);
});
});
@@ -71,10 +76,11 @@ describe("Compliments module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/compliments/compliments_specialDayUnique_true.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("compliments array contains only special value", async () => {
await expect(doTest(["Special day message"])).resolves.toBe(true);
await doTest(["Special day message"]);
});
});
@@ -82,10 +88,11 @@ describe("Compliments module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/compliments/compliments_e2e_cron_entry.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("compliments array contains only special value", async () => {
await expect(doTest(["anytime cron"])).resolves.toBe(true);
await doTest(["anytime cron"]);
});
});
});
@@ -95,10 +102,11 @@ describe("Compliments module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/compliments/compliments_file.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("shows 'Remote compliment file works!' as only anytime list set", async () => {
//await helpers.startApplication("tests/configs/modules/compliments/compliments_file.js", "01 Jan 2022 10:00:00 GMT");
await expect(doTest(["Remote compliment file works!"])).resolves.toBe(true);
await doTest(["Remote compliment file works!"]);
});
// afterAll(async () =>{
// await helpers.stopApplication()
@@ -109,12 +117,13 @@ describe("Compliments module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/compliments/compliments_file_change.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("shows 'test in morning' as test time set to 10am", async () => {
//await helpers.startApplication("tests/configs/modules/compliments/compliments_file_change.js", "01 Jan 2022 10:00:00 GMT");
await expect(doTest(["Remote compliment file works!"])).resolves.toBe(true);
await doTest(["Remote compliment file works!"]);
await new Promise((r) => setTimeout(r, 10000));
await expect(doTest(["test in morning"])).resolves.toBe(true);
await doTest(["test in morning"]);
});
// afterAll(async () =>{
// await helpers.stopApplication()

View File

@@ -1,6 +1,9 @@
const { expect } = require("playwright/test");
const helpers = require("../helpers/global-setup");
describe("Test helloworld module", () => {
let page;
afterAll(async () => {
await helpers.stopApplication();
});
@@ -9,12 +12,11 @@ describe("Test helloworld module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/helloworld/helloworld.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("Test message helloworld module", async () => {
const elem = await helpers.waitForElement(".helloworld");
expect(elem).not.toBeNull();
expect(elem.textContent).toContain("Test HelloWorld Module");
await expect(page.locator(".helloworld")).toContainText("Test HelloWorld Module");
});
});
@@ -22,12 +24,11 @@ describe("Test helloworld module", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/helloworld/helloworld_default.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("Test message helloworld module", async () => {
const elem = await helpers.waitForElement(".helloworld");
expect(elem).not.toBeNull();
expect(elem.textContent).toContain("Hello World!");
await expect(page.locator(".helloworld")).toContainText("Hello World!");
});
});
});

View File

@@ -1,29 +1,28 @@
const fs = require("node:fs");
const { expect } = require("playwright/test");
const helpers = require("../helpers/global-setup");
const runTests = async () => {
let page;
describe("Default configuration", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/newsfeed/default.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should show the newsfeed title", async () => {
const elem = await helpers.waitForElement(".newsfeed .newsfeed-source");
expect(elem).not.toBeNull();
expect(elem.textContent).toContain("Rodrigo Ramirez Blog");
await expect(page.locator(".newsfeed .newsfeed-source")).toContainText("Rodrigo Ramirez Blog");
});
it("should show the newsfeed article", async () => {
const elem = await helpers.waitForElement(".newsfeed .newsfeed-title");
expect(elem).not.toBeNull();
expect(elem.textContent).toContain("QPanel");
await expect(page.locator(".newsfeed .newsfeed-title")).toContainText("QPanel");
});
it("should NOT show the newsfeed description", async () => {
await helpers.waitForElement(".newsfeed");
const elem = document.querySelector(".newsfeed .newsfeed-desc");
expect(elem).toBeNull();
await page.locator(".newsfeed").waitFor({ state: "visible" });
await expect(page.locator(".newsfeed .newsfeed-desc")).toHaveCount(0);
});
});
@@ -31,18 +30,18 @@ const runTests = async () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/newsfeed/prohibited_words.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should not show articles with prohibited words", async () => {
const elem = await helpers.waitForElement(".newsfeed .newsfeed-title");
expect(elem).not.toBeNull();
expect(elem.textContent).toContain("Problema VirtualBox");
await expect(page.locator(".newsfeed .newsfeed-title")).toContainText("Problema VirtualBox");
});
it("should show the newsfeed description", async () => {
const elem = await helpers.waitForElement(".newsfeed .newsfeed-desc");
expect(elem).not.toBeNull();
expect(elem.textContent).not.toHaveLength(0);
const locator = page.locator(".newsfeed .newsfeed-desc");
await expect(locator).toBeVisible();
const text = await locator.textContent();
expect(text).toMatch(/\S/);
});
});
@@ -50,12 +49,11 @@ const runTests = async () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/newsfeed/incorrect_url.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should show malformed url warning", async () => {
const elem = await helpers.waitForElement(".newsfeed .small", "No news at the moment.");
expect(elem).not.toBeNull();
expect(elem.textContent).toContain("Error in the Newsfeed module. Malformed url.");
await expect(page.locator(".newsfeed .small")).toContainText("Error in the Newsfeed module. Malformed url.");
});
});
@@ -63,12 +61,11 @@ const runTests = async () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/newsfeed/ignore_items.js");
await helpers.getDocument();
page = helpers.getPage();
});
it("should show empty items info message", async () => {
const elem = await helpers.waitForElement(".newsfeed .small");
expect(elem).not.toBeNull();
expect(elem.textContent).toContain("No news at the moment.");
await expect(page.locator(".newsfeed .small")).toContainText("No news at the moment.");
});
});
};

View File

@@ -1,7 +1,10 @@
const { expect } = require("playwright/test");
const helpers = require("../helpers/global-setup");
const weatherFunc = require("../helpers/weather-functions");
describe("Weather module", () => {
let page;
afterAll(async () => {
await weatherFunc.stopApplication();
});
@@ -10,26 +13,25 @@ describe("Weather module", () => {
describe("Default configuration", () => {
beforeAll(async () => {
await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_default.js", {});
page = helpers.getPage();
});
it("should render wind speed and wind direction", async () => {
await expect(weatherFunc.getText(".weather .normal.medium span:nth-child(2)", "12 WSW")).resolves.toBe(true);
await expect(page.locator(".weather .normal.medium span:nth-child(2)")).toHaveText("12 WSW");
});
it("should render temperature with icon", async () => {
await expect(weatherFunc.getText(".weather .large span.light.bright", "1.5°")).resolves.toBe(true);
const elem = await helpers.waitForElement(".weather .large span.weathericon");
expect(elem).not.toBeNull();
await expect(page.locator(".weather .large span.light.bright")).toHaveText("1.5°");
await expect(page.locator(".weather .large span.weathericon")).toBeVisible();
});
it("should render feels like temperature", async () => {
// Template contains &nbsp; which renders as \xa0
await expect(weatherFunc.getText(".weather .normal.medium.feelslike span.dimmed", "93.7\xa0 Feels like -5.6°")).resolves.toBe(true);
await expect(page.locator(".weather .normal.medium.feelslike span.dimmed")).toHaveText("93.7\xa0 Feels like -5.6°");
});
it("should render humidity next to feels-like", async () => {
await expect(weatherFunc.getText(".weather .normal.medium.feelslike span.dimmed .humidity", "93.7")).resolves.toBe(true);
await expect(page.locator(".weather .normal.medium.feelslike span.dimmed .humidity")).toHaveText("93.7");
});
});
});
@@ -37,56 +39,60 @@ describe("Weather module", () => {
describe("Compliments Integration", () => {
beforeAll(async () => {
await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_compliments.js", {});
page = helpers.getPage();
});
it("should render a compliment based on the current weather", async () => {
await expect(weatherFunc.getText(".compliments .module-content span", "snow")).resolves.toBe(true);
const compliment = page.locator(".compliments .module-content span");
await compliment.waitFor({ state: "visible" });
await expect(compliment).toHaveText("snow");
});
});
describe("Configuration Options", () => {
beforeAll(async () => {
await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_options.js", {});
page = helpers.getPage();
});
it("should render windUnits in beaufort", async () => {
await expect(weatherFunc.getText(".weather .normal.medium span:nth-child(2)", "6")).resolves.toBe(true);
await expect(page.locator(".weather .normal.medium span:nth-child(2)")).toHaveText("6");
});
it("should render windDirection with an arrow", async () => {
const elem = await helpers.waitForElement(".weather .normal.medium sup i.fa-long-arrow-alt-down");
expect(elem).not.toBeNull();
expect(elem.outerHTML).toContain("transform:rotate(250deg)");
const arrow = page.locator(".weather .normal.medium sup i.fa-long-arrow-alt-down");
await expect(arrow).toHaveAttribute("style", "transform:rotate(250deg)");
});
it("should render humidity next to wind", async () => {
await expect(weatherFunc.getText(".weather .normal.medium .humidity", "93.7")).resolves.toBe(true);
await expect(page.locator(".weather .normal.medium .humidity")).toHaveText("93.7");
});
it("should render degreeLabel for temp", async () => {
await expect(weatherFunc.getText(".weather .large span.bright.light", "1°C")).resolves.toBe(true);
await expect(page.locator(".weather .large span.bright.light")).toHaveText("1°C");
});
it("should render degreeLabel for feels like", async () => {
await expect(weatherFunc.getText(".weather .normal.medium.feelslike span.dimmed", "Feels like -6°C")).resolves.toBe(true);
await expect(page.locator(".weather .normal.medium.feelslike span.dimmed")).toHaveText("Feels like -6°C");
});
});
describe("Current weather with imperial units", () => {
beforeAll(async () => {
await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_units.js", {});
page = helpers.getPage();
});
it("should render wind in imperial units", async () => {
await expect(weatherFunc.getText(".weather .normal.medium span:nth-child(2)", "26 WSW")).resolves.toBe(true);
await expect(page.locator(".weather .normal.medium span:nth-child(2)")).toHaveText("26 WSW");
});
it("should render temperatures in fahrenheit", async () => {
await expect(weatherFunc.getText(".weather .large span.bright.light", "34,7°")).resolves.toBe(true);
await expect(page.locator(".weather .large span.bright.light")).toHaveText("34,7°");
});
it("should render 'feels like' in fahrenheit", async () => {
await expect(weatherFunc.getText(".weather .normal.medium.feelslike span.dimmed", "Feels like 21,9°")).resolves.toBe(true);
await expect(page.locator(".weather .normal.medium.feelslike span.dimmed")).toHaveText("Feels like 21,9°");
});
});
});

View File

@@ -1,7 +1,10 @@
const { expect } = require("playwright/test");
const helpers = require("../helpers/global-setup");
const weatherFunc = require("../helpers/weather-functions");
describe("Weather module: Weather Forecast", () => {
let page;
afterAll(async () => {
await weatherFunc.stopApplication();
});
@@ -9,43 +12,46 @@ describe("Weather module: Weather Forecast", () => {
describe("Default configuration", () => {
beforeAll(async () => {
await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_default.js", {});
page = helpers.getPage();
});
const days = ["Today", "Tomorrow", "Sun", "Mon", "Tue"];
for (const [index, day] of days.entries()) {
it(`should render day ${day}`, async () => {
await expect(weatherFunc.getText(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(1)`, day)).resolves.toBe(true);
const dayCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(1)`);
await expect(dayCell).toHaveText(day);
});
}
const icons = ["day-cloudy", "rain", "day-sunny", "day-sunny", "day-sunny"];
for (const [index, icon] of icons.entries()) {
it(`should render icon ${icon}`, async () => {
const elem = await helpers.waitForElement(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(2) span.wi-${icon}`);
expect(elem).not.toBeNull();
const iconElement = page.locator(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(2) span.wi-${icon}`);
await expect(iconElement).toBeVisible();
});
}
const maxTemps = ["24.4°", "21.0°", "22.9°", "23.4°", "20.6°"];
for (const [index, temp] of maxTemps.entries()) {
it(`should render max temperature ${temp}`, async () => {
await expect(weatherFunc.getText(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(3)`, temp)).resolves.toBe(true);
const maxTempCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(3)`);
await expect(maxTempCell).toHaveText(temp);
});
}
const minTemps = ["15.3°", "13.6°", "13.8°", "13.9°", "10.9°"];
for (const [index, temp] of minTemps.entries()) {
it(`should render min temperature ${temp}`, async () => {
await expect(weatherFunc.getText(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(4)`, temp)).resolves.toBe(true);
const minTempCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(4)`);
await expect(minTempCell).toHaveText(temp);
});
}
const opacities = [1, 1, 0.8, 0.5333333333333333, 0.2666666666666667];
for (const [index, opacity] of opacities.entries()) {
it(`should render fading of rows with opacity=${opacity}`, async () => {
const elem = await helpers.waitForElement(`.weather table.small tr:nth-child(${index + 1})`);
expect(elem).not.toBeNull();
expect(elem.outerHTML).toContain(`<tr style="opacity: ${opacity};">`);
const row = page.locator(`.weather table.small tr:nth-child(${index + 1})`);
await expect(row).toHaveAttribute("style", `opacity: ${opacity};`);
});
}
});
@@ -53,12 +59,14 @@ describe("Weather module: Weather Forecast", () => {
describe("Absolute configuration", () => {
beforeAll(async () => {
await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_absolute.js", {});
page = helpers.getPage();
});
const days = ["Fri", "Sat", "Sun", "Mon", "Tue"];
for (const [index, day] of days.entries()) {
it(`should render day ${day}`, async () => {
await expect(weatherFunc.getText(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(1)`, day)).resolves.toBe(true);
const dayCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(1)`);
await expect(dayCell).toHaveText(day);
});
}
});
@@ -66,25 +74,24 @@ describe("Weather module: Weather Forecast", () => {
describe("Configuration Options", () => {
beforeAll(async () => {
await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_options.js", {});
page = helpers.getPage();
});
it("should render custom table class", async () => {
const elem = await helpers.waitForElement(".weather table.myTableClass");
expect(elem).not.toBeNull();
await expect(page.locator(".weather table.myTableClass")).toBeVisible();
});
it("should render colored rows", async () => {
const table = await helpers.waitForElement(".weather table.myTableClass");
expect(table).not.toBeNull();
expect(table.rows).not.toBeNull();
expect(table.rows).toHaveLength(5);
const rows = page.locator(".weather table.myTableClass tr");
await expect(rows).toHaveCount(5);
});
const precipitations = [undefined, "2.51 mm"];
for (const [index, precipitation] of precipitations.entries()) {
if (precipitation) {
it(`should render precipitation amount ${precipitation}`, async () => {
await expect(weatherFunc.getText(`.weather table tr:nth-child(${index + 1}) td.precipitation-amount`, precipitation)).resolves.toBe(true);
const precipCell = page.locator(`.weather table tr:nth-child(${index + 1}) td.precipitation-amount`);
await expect(precipCell).toHaveText(precipitation);
});
}
}
@@ -93,13 +100,15 @@ describe("Weather module: Weather Forecast", () => {
describe("Forecast weather with imperial units", () => {
beforeAll(async () => {
await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_units.js", {});
page = helpers.getPage();
});
describe("Temperature units", () => {
const temperatures = ["75_9°", "69_8°", "73_2°", "74_1°", "69_1°"];
for (const [index, temp] of temperatures.entries()) {
it(`should render custom decimalSymbol = '_' for temp ${temp}`, async () => {
await expect(weatherFunc.getText(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(3)`, temp)).resolves.toBe(true);
const tempCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(3)`);
await expect(tempCell).toHaveText(temp);
});
}
});
@@ -109,7 +118,8 @@ describe("Weather module: Weather Forecast", () => {
for (const [index, precipitation] of precipitations.entries()) {
if (precipitation) {
it(`should render precipitation amount ${precipitation}`, async () => {
await expect(weatherFunc.getText(`.weather table.small tr:nth-child(${index + 1}) td.precipitation-amount`, precipitation)).resolves.toBe(true);
const precipCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td.precipitation-amount`);
await expect(precipCell).toHaveText(precipitation);
});
}
}

View File

@@ -1,6 +1,10 @@
const { expect } = require("playwright/test");
const helpers = require("../helpers/global-setup");
const weatherFunc = require("../helpers/weather-functions");
describe("Weather module: Weather Hourly Forecast", () => {
let page;
afterAll(async () => {
await weatherFunc.stopApplication();
});
@@ -8,12 +12,14 @@ describe("Weather module: Weather Hourly Forecast", () => {
describe("Default configuration", () => {
beforeAll(async () => {
await weatherFunc.startApplication("tests/configs/modules/weather/hourlyweather_default.js", {});
page = helpers.getPage();
});
const minTemps = ["7:00 pm", "8:00 pm", "9:00 pm", "10:00 pm", "11:00 pm"];
for (const [index, hour] of minTemps.entries()) {
it(`should render forecast for hour ${hour}`, async () => {
await expect(weatherFunc.getText(`.weather table.small tr:nth-child(${index + 1}) td.day`, hour)).resolves.toBe(true);
const dayCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td.day`);
await expect(dayCell).toHaveText(hour);
});
}
});
@@ -21,13 +27,15 @@ describe("Weather module: Weather Hourly Forecast", () => {
describe("Hourly weather options", () => {
beforeAll(async () => {
await weatherFunc.startApplication("tests/configs/modules/weather/hourlyweather_options.js", {});
page = helpers.getPage();
});
describe("Hourly increments of 2", () => {
const minTemps = ["7:00 pm", "9:00 pm", "11:00 pm", "1:00 am", "3:00 am"];
for (const [index, hour] of minTemps.entries()) {
it(`should render forecast for hour ${hour}`, async () => {
await expect(weatherFunc.getText(`.weather table.small tr:nth-child(${index + 1}) td.day`, hour)).resolves.toBe(true);
const dayCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td.day`);
await expect(dayCell).toHaveText(hour);
});
}
});
@@ -36,6 +44,7 @@ describe("Weather module: Weather Hourly Forecast", () => {
describe("Show precipitations", () => {
beforeAll(async () => {
await weatherFunc.startApplication("tests/configs/modules/weather/hourlyweather_showPrecipitation.js", {});
page = helpers.getPage();
});
describe("Shows precipitation amount", () => {
@@ -43,7 +52,8 @@ describe("Weather module: Weather Hourly Forecast", () => {
for (const [index, amount] of amounts.entries()) {
if (amount) {
it(`should render precipitation amount ${amount}`, async () => {
await expect(weatherFunc.getText(`.weather table.small tr:nth-child(${index + 1}) td.precipitation-amount`, amount)).resolves.toBe(true);
const amountCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td.precipitation-amount`);
await expect(amountCell).toHaveText(amount);
});
}
}
@@ -51,10 +61,11 @@ describe("Weather module: Weather Hourly Forecast", () => {
describe("Shows precipitation probability", () => {
const probabilities = [undefined, undefined, "12 %", "36 %", "44 %"];
for (const [index, pop] of probabilities.entries()) {
if (pop) {
it(`should render probability ${pop}`, async () => {
await expect(weatherFunc.getText(`.weather table.small tr:nth-child(${index + 1}) td.precipitation-prob`, pop)).resolves.toBe(true);
for (const [index, probability] of probabilities.entries()) {
if (probability) {
it(`should render probability ${probability}`, async () => {
const probabilityCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td.precipitation-prob`);
await expect(probabilityCell).toHaveText(probability);
});
}
}

View File

@@ -1,24 +1,24 @@
const { expect } = require("playwright/test");
const helpers = require("./helpers/global-setup");
describe("Display of modules", () => {
let page;
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/display.js");
await helpers.getDocument();
page = helpers.getPage();
});
afterAll(async () => {
await helpers.stopApplication();
});
it("should show the test header", async () => {
const elem = await helpers.waitForElement("#module_0_helloworld .module-header");
expect(elem).not.toBeNull();
// textContent returns lowercase here, the uppercase is realized by CSS, which therefore does not end up in textContent
expect(elem.textContent).toBe("test_header");
await expect(page.locator("#module_0_helloworld .module-header")).toHaveText("test_header");
});
it("should show no header if no header text is specified", async () => {
const elem = await helpers.waitForElement("#module_1_helloworld .module-header");
expect(elem).not.toBeNull();
expect(elem.textContent).toBe("undefined");
await expect(page.locator("#module_1_helloworld .module-header")).toHaveText("undefined");
});
});

View File

@@ -1,23 +1,23 @@
const { expect } = require("playwright/test");
const helpers = require("./helpers/global-setup");
describe("Check configuration without modules", () => {
let page;
beforeAll(async () => {
await helpers.startApplication("tests/configs/without_modules.js");
await helpers.getDocument();
page = helpers.getPage();
});
afterAll(async () => {
await helpers.stopApplication();
});
it("shows the message MagicMirror² title", async () => {
const elem = await helpers.waitForElement("#module_1_helloworld .module-content");
expect(elem).not.toBeNull();
expect(elem.textContent).toContain("MagicMirror²");
await expect(page.locator("#module_1_helloworld .module-content")).toContainText("MagicMirror²");
});
it("shows the project URL", async () => {
const elem = await helpers.waitForElement("#module_5_helloworld .module-content");
expect(elem).not.toBeNull();
expect(elem.textContent).toContain("https://magicmirror.builders/");
await expect(page.locator("#module_5_helloworld .module-content")).toContainText("https://magicmirror.builders/");
});
});

View File

@@ -1,5 +1,7 @@
const helpers = require("./helpers/global-setup");
const getPage = () => helpers.getPage();
describe("Position of modules", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/positions.js");
@@ -14,9 +16,11 @@ describe("Position of modules", () => {
for (const position of positions) {
const className = position.replace("_", ".");
it(`should show text in ${position}`, async () => {
const elem = await helpers.waitForElement(`.${className}`);
expect(elem).not.toBeNull();
expect(elem.textContent).toContain(`Text in ${position}`);
const locator = getPage().locator(`.${className} .module-content`).first();
await locator.waitFor({ state: "visible" });
const text = await locator.textContent();
expect(text).not.toBeNull();
expect(text).toContain(`Text in ${position}`);
});
}
});

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);
@@ -34,11 +38,13 @@ describe("App environment", () => {
describe("Check config", () => {
it("config check should return without errors", async () => {
process.env.MM_CONFIG_FILE = "tests/configs/default.js";
await expect(runConfigCheck()).resolves.toBe(0);
const exitCode = await runConfigCheck();
expect(exitCode).toBe(0);
});
it("config check should fail with non existent config file", async () => {
process.env.MM_CONFIG_FILE = "tests/configs/not_exists.js";
await expect(runConfigCheck()).resolves.toBe(1);
const exitCode = await runConfigCheck();
expect(exitCode).toBe(1);
});
});

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,8 +14,9 @@ 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.fetch = fetch;
dom.window.eval(translatorJs);
const window = dom.window;
@@ -75,7 +76,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 +89,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 +104,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 +117,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

@@ -11,8 +11,9 @@ describe("Electron app environment", () => {
});
it("should open browserwindow", async () => {
const module = await helpers.getElement("#module_0_helloworld");
await expect(module.textContent()).resolves.toContain("Test Display Header");
// Wait for module content to be rendered, not just the module wrapper
const moduleContent = await helpers.getElement("#module_0_helloworld .module-content");
await expect(moduleContent.textContent()).resolves.toContain("Test Display Header");
expect(global.electronApp.windows()).toHaveLength(1);
});
});

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

Some files were not shown because too many files have changed in this diff Show More