mirror of
https://github.com/MagicMirrorOrg/MagicMirror.git
synced 2026-03-14 10:41:43 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b742e839be |
16
.github/CONTRIBUTING.md
vendored
16
.github/CONTRIBUTING.md
vendored
@@ -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`.
|
||||
|
||||
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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
|
||||
|
||||
2
.github/dependabot.yaml
vendored
2
.github/dependabot.yaml
vendored
@@ -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"
|
||||
|
||||
17
.github/workflows/automated-tests.yaml
vendored
17
.github/workflows/automated-tests.yaml
vendored
@@ -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:
|
||||
|
||||
2
.github/workflows/dep-review.yaml
vendored
2
.github/workflows/dep-review.yaml
vendored
@@ -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
|
||||
|
||||
14
.github/workflows/electron-rebuild.yaml
vendored
14
.github/workflows/electron-rebuild.yaml
vendored
@@ -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
|
||||
|
||||
28
.github/workflows/enforce-pullrequest-rules.yaml
vendored
28
.github/workflows/enforce-pullrequest-rules.yaml
vendored
@@ -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
33
.github/workflows/release-notes.yaml
vendored
Normal 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
|
||||
4
.github/workflows/spellcheck.yaml
vendored
4
.github/workflows/spellcheck.yaml
vendored
@@ -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
16
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!
|
||||
|
||||

|
||||
|
||||
## 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).
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -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
31
js/alias-resolver.js
Normal 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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
20
js/app.js
20
js/app.js
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
63
js/ip_access_control.js
Normal 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 };
|
||||
29
js/loader.js
29
js/loader.js
@@ -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;
|
||||
}
|
||||
|
||||
32
js/logger.js
32
js/logger.js
@@ -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) {
|
||||
|
||||
15
js/main.js
15
js/main.js
@@ -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)
|
||||
|
||||
31
js/module.js
31
js/module.js
@@ -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:
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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
198
js/releasenotes.js
Normal 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();
|
||||
22
js/server.js
22
js/server.js
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
13
js/utils.js
13
js/utils.js
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
4
modules/default/alert/translations/pt-br.json
Normal file
4
modules/default/alert/translations/pt-br.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"sysTitle": "Notificação do MagicMirror²",
|
||||
"welcome": "Bem-vindo, o sistema iniciou com sucesso!"
|
||||
}
|
||||
4
modules/default/alert/translations/pt.json
Normal file
4
modules/default/alert/translations/pt.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"sysTitle": "Notificação do MagicMirror²",
|
||||
"welcome": "Bem-vindo, o sistema iniciou com sucesso!"
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 %}, {% 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 %}, {% else %}:{% endif %}
|
||||
{% endif %}
|
||||
{% if config.showPublishDate %}{{ publishDate }}:{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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
7197
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
84
package.json
84
package.json
@@ -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
261
serveronly/watcher.js
Normal 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);
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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²");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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!");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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!");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 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°");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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") => {
|
||||
|
||||
87
tests/mocks/calendar_duplicates_1.ics
Normal file
87
tests/mocks/calendar_duplicates_1.ics
Normal 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
Reference in New Issue
Block a user