Merge branch 'dev'

This commit is contained in:
shamoon
2026-03-14 08:58:38 -07:00
179 changed files with 2151 additions and 1403 deletions

View File

@@ -9,11 +9,11 @@ coverage:
project: project:
default: default:
target: 100% target: 100%
threshold: 25% threshold: 15%
patch: patch:
default: default:
target: 100% target: 100%
threshold: 25% threshold: 10%
comment: comment:
layout: "reach,diff,flags,files" layout: "reach,diff,flags,files"

View File

@@ -1,6 +1,10 @@
title: "[Feature Request] " title: "[Feature Request] "
labels: ["enhancement"] labels: ["enhancement"]
body: body:
- type: markdown
attributes:
value: |
#### ⚠️ Don't forget to search [existing issues](https://github.com/gethomepage/homepage/search?q=&type=issues) and [discussions](https://github.com/gethomepage/homepage/search?q=&type=discussions) (including closed ones!).
- type: textarea - type: textarea
id: description id: description
attributes: attributes:

View File

@@ -51,7 +51,7 @@ body:
id: troubleshooting id: troubleshooting
attributes: attributes:
label: Troubleshooting label: Troubleshooting
description: Please include output from your [troubleshooting steps](https://gethomepage.dev/more/troubleshooting/#service-widget-errors), if relevant. description: Please include output from your [troubleshooting steps](https://gethomepage.dev/troubleshooting/#service-widget-errors), if relevant.
validations: validations:
required: true required: true
- type: markdown - type: markdown

View File

@@ -35,8 +35,8 @@ What type of change does your PR introduce to Homepage?
## Checklist: ## Checklist:
- [ ] If applicable, I have added corresponding documentation changes. - [ ] If applicable, I have added corresponding documentation changes.
- [ ] If applicable, I have added or updated tests for new features and bug fixes. - [ ] If applicable, I have added or updated tests for new features and bug fixes (see [testing](https://gethomepage.dev/widgets/authoring/getting-started/#testing)).
- [ ] If applicable, I have reviewed the [feature / enhancement](https://gethomepage.dev/more/development/#new-feature-guidelines) and / or [service widget guidelines](https://gethomepage.dev/more/development/#service-widget-guidelines). - [ ] If applicable, I have reviewed the [feature / enhancement](https://gethomepage.dev/widgets/authoring/getting-started/#new-feature-guidelines) and / or [service widget guidelines](https://gethomepage.dev/widgets/authoring/getting-started/#service-widget-guidelines).
- [ ] I have checked that all code style checks pass using [pre-commit hooks](https://gethomepage.dev/more/development/#code-formatting-with-pre-commit-hooks) and [linting checks](https://gethomepage.dev/more/development/#code-linting). - [ ] I have checked that all code style checks pass using [pre-commit hooks](https://gethomepage.dev/widgets/authoring/getting-started/#code-formatting-with-pre-commit-hooks) and [linting checks](https://gethomepage.dev/widgets/authoring/getting-started/#code-linting).
- [ ] If applicable, I have tested my code for new features & regressions on both mobile & desktop devices, using the latest version of major browsers. - [ ] If applicable, I have tested my code for new features & regressions on both mobile & desktop devices, using the latest version of major browsers.
- [ ] In the description above I have disclosed the use of AI tools in the coding of this PR. - [ ] In the description above I have disclosed the use of AI tools in the coding of this PR.

View File

@@ -66,7 +66,7 @@ jobs:
- name: Extract Docker metadata - name: Extract Docker metadata
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: | images: |
${{ env.IMAGE_NAME }} ${{ env.IMAGE_NAME }}
@@ -115,7 +115,7 @@ jobs:
- name: Log into registry ${{ env.REGISTRY }} - name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -123,20 +123,20 @@ jobs:
- name: Login to Docker Hub - name: Login to Docker Hub
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Setup QEMU - name: Setup QEMU
uses: docker/setup-qemu-action@v3.7.0 uses: docker/setup-qemu-action@v4.0.0
- name: Setup Docker buildx - name: Setup Docker buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: Build and push Docker image - name: Build and push Docker image
id: build-and-push id: build-and-push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}

18
.github/workflows/pr-quality.yml vendored Normal file
View File

@@ -0,0 +1,18 @@
name: PR Quality
permissions:
contents: read
issues: read
pull-requests: write
on:
pull_request_target:
types: [opened, reopened]
jobs:
anti-slop:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0
with:
max-failures: 4

View File

@@ -13,13 +13,13 @@ jobs:
matrix: matrix:
shard: [1, 2, 3, 4] shard: [1, 2, 3, 4]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with: with:
version: 9 version: 9
- uses: actions/setup-node@v4 - uses: actions/setup-node@v6
with: with:
node-version: 20 node-version: 20
cache: pnpm cache: pnpm

View File

@@ -38,11 +38,11 @@ People _love_ thorough bug reports. I'm not even kidding.
## Development Guidelines ## Development Guidelines
Please see the [documentation regarding development](https://gethomepage.dev/more/development/) and specifically the [guidelines for new service widgets](https://gethomepage.dev/more/development/#service-widget-guidelines) if you are considering making one. Please see the [documentation regarding development](https://gethomepage.dev/widgets/authoring/getting-started/#development) and specifically the [guidelines for new service widgets](https://gethomepage.dev/widgets/authoring/getting-started/#service-widget-guidelines) if you are considering making one.
## Use a Consistent Coding Style ## Use a Consistent Coding Style
Please see information in the docs regarding [code formatting with pre-commit hooks](https://gethomepage.dev/more/development/#code-formatting-with-pre-commit-hooks). Please see information in the docs regarding [code formatting with pre-commit hooks](https://gethomepage.dev/widgets/authoring/getting-started/#code-formatting-with-pre-commit-hooks).
## License ## License

View File

@@ -177,6 +177,16 @@ labels:
- homepage.widget.fields=["field1","field2"] # optional - homepage.widget.fields=["field1","field2"] # optional
``` ```
!!! note
If you use mapping syntax (`:`) for labels instead of list syntax (`-`), array values like `fields` must be wrapped in single quotes so they are passed as a string:
```yaml
labels:
...
homepage.widget.fields: '["field1","field2"]'
```
Multiple widgets can be specified by incrementing the index, e.g. Multiple widgets can be specified by incrementing the index, e.g.
```yaml ```yaml

View File

@@ -223,13 +223,33 @@ spec:
- name: homepage - name: homepage
image: "ghcr.io/gethomepage/homepage:latest" image: "ghcr.io/gethomepage/homepage:latest"
imagePullPolicy: Always imagePullPolicy: Always
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
seccompProfile:
type: RuntimeDefault
env: env:
- name: MY_POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: HOMEPAGE_ALLOWED_HOSTS - name: HOMEPAGE_ALLOWED_HOSTS
value: gethomepage.dev # required, may need port. See gethomepage.dev/installation/#homepage_allowed_hosts value: "$(MY_POD_IP):3000,gethomepage.dev" # See gethomepage.dev/installation/#homepage_allowed_hosts . Value before the comma is required for the k8s probe
ports: ports:
- name: http - name: http
containerPort: 3000 containerPort: 3000
protocol: TCP protocol: TCP
livenessProbe:
httpGet:
path: /api/healthcheck
port: http
initialDelaySeconds: 5
periodSeconds: 15
volumeMounts: volumeMounts:
- mountPath: /app/config/custom.js - mountPath: /app/config/custom.js
name: homepage-config name: homepage-config

View File

@@ -7,13 +7,17 @@ You can include all or some of the available resources. If you do not want to se
The disk path is the path reported by `df` (Mounted On), or the mount point of the disk. The disk path is the path reported by `df` (Mounted On), or the mount point of the disk.
!!! note
Any disk you wish to access must be mounted to your container as a volume.
The cpu and memory resource information are the container's usage while [glances](glances.md) displays statistics for the host machine on which it is installed. The cpu and memory resource information are the container's usage while [glances](glances.md) displays statistics for the host machine on which it is installed.
The resources widget primarily relies on a popular tool called [systeminformation](https://systeminformation.io). Thus, any limitiations of that software apply, for example, BRTFS RAID is not supported for the disk usage. In this case users may want to use the [glances widget](glances.md) instead. The resources widget primarily relies on a popular tool called [systeminformation](https://systeminformation.io). Thus, any limitiations of that software apply, for example, BRTFS RAID is not supported for the disk usage. In this case users may want to use the [glances widget](glances.md) instead.
_Note: unfortunately, the package used for getting CPU temp ([systeminformation](https://systeminformation.io)) is not compatible with some setups and will not report any value(s) for CPU temp._ !!! warning
**Any disk you wish to access must be mounted to your container as a volume.** The package used for getting CPU temp ([systeminformation](https://systeminformation.io)) is not compatible with some setups and will not report any value(s) for CPU temp.
```yaml ```yaml
- resources: - resources:
@@ -75,3 +79,10 @@ You can additionally supply an optional `expanded` property set to true in order
``` ```
![194136533-c4238c82-4d67-41a4-b3c8-18bf26d33ac2](https://user-images.githubusercontent.com/3441425/194728642-a9885274-922b-4027-acf5-a746f58fdfce.png) ![194136533-c4238c82-4d67-41a4-b3c8-18bf26d33ac2](https://user-images.githubusercontent.com/3441425/194728642-a9885274-922b-4027-acf5-a746f58fdfce.png)
To monitor a named host network interface in Docker (for example `network: eno1`), mount host `/sys` (read-only):
```yaml
volumes:
- /sys:/sys:ro
```

View File

@@ -67,7 +67,7 @@ You can also find a list of all available service widgets in the sidebar navigat
- [Jackett](jackett.md) - [Jackett](jackett.md)
- [JDownloader](jdownloader.md) - [JDownloader](jdownloader.md)
- [Jellyfin](jellyfin.md) - [Jellyfin](jellyfin.md)
- [Jellyseerr](jellyseerr.md) - [Seerr](seerr.md)
- [Jellystat](jellystat.md) - [Jellystat](jellystat.md)
- [Kavita](kavita.md) - [Kavita](kavita.md)
- [Komga](komga.md) - [Komga](komga.md)
@@ -101,7 +101,6 @@ You can also find a list of all available service widgets in the sidebar navigat
- [OpenMediaVault](openmediavault.md) - [OpenMediaVault](openmediavault.md)
- [OpenWRT](openwrt.md) - [OpenWRT](openwrt.md)
- [OPNsense](opnsense.md) - [OPNsense](opnsense.md)
- [Overseerr](overseerr.md)
- [PaperlessNGX](paperlessngx.md) - [PaperlessNGX](paperlessngx.md)
- [Peanut](peanut.md) - [Peanut](peanut.md)
- [pfSense](pfsense.md) - [pfSense](pfsense.md)

View File

@@ -5,7 +5,7 @@ description: Jellyfin Widget Configuration
Learn more about [Jellyfin](https://github.com/jellyfin/jellyfin). Learn more about [Jellyfin](https://github.com/jellyfin/jellyfin).
You can create an API key from inside Jellyfin at `Settings > Advanced > Api Keys`. You can create an API key from inside the Jellyfin Administration Dashboard under `Advanced > API Keys`.
As of v0.6.11 the widget supports fields `["movies", "series", "episodes", "songs"]`. These blocks are disabled by default but can be enabled with the `enableBlocks` option, and the "Now Playing" feature (enabled by default) can be disabled with the `enableNowPlaying` option. As of v0.6.11 the widget supports fields `["movies", "series", "episodes", "songs"]`. These blocks are disabled by default but can be enabled with the `enableBlocks` option, and the "Now Playing" feature (enabled by default) can be disabled with the `enableNowPlaying` option.
@@ -17,7 +17,7 @@ As of v0.6.11 the widget supports fields `["movies", "series", "episodes", "song
```yaml ```yaml
widget: widget:
type: jellyfin type: jellyfin
url: http://jellyfin.host.or.ip url: http://jellyfin.host.or.ip:port
key: apikeyapikeyapikeyapikeyapikey key: apikeyapikeyapikeyapikeyapikey
version: 2 # optional, default is 1 version: 2 # optional, default is 1
enableBlocks: true # optional, defaults to false enableBlocks: true # optional, defaults to false

View File

@@ -1,18 +0,0 @@
---
title: Jellyseerr
description: Jellyseerr Widget Configuration
---
Learn more about [Jellyseerr](https://github.com/Fallenbagel/jellyseerr).
Find your API key under `Settings > General > API Key`.
Allowed fields: `["pending", "approved", "available", "issues"]`.
Default fields: `["pending", "approved", "available"]`.
```yaml
widget:
type: jellyseerr
url: http://jellyseerr.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
```

View File

@@ -1,17 +0,0 @@
---
title: Overseerr
description: Overseerr Widget Configuration
---
Learn more about [Overseerr](https://github.com/sct/overseerr).
Find your API key under `Settings > General`.
Allowed fields: `["pending", "approved", "available", "processing"]`.
```yaml
widget:
type: overseerr
url: http://overseerr.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
```

View File

@@ -12,7 +12,7 @@ Allowed fields: no configurable fields for this widget.
```yaml ```yaml
widget: widget:
type: tautulli type: tautulli
url: http://tautulli.host.or.ip url: http://tautulli.host.or.ip:port
key: apikeyapikeyapikeyapikeyapikey key: apikeyapikeyapikeyapikeyapikey
enableUser: true # optional, defaults to false enableUser: true # optional, defaults to false
showEpisodeNumber: true # optional, defaults to false showEpisodeNumber: true # optional, defaults to false

View File

@@ -0,0 +1,20 @@
---
title: Seerr Widget
description: Seerr Widget Configuration
---
Learn more about [Seerr](https://github.com/seerr-team/seerr).
Find your API key under `Settings > General > API Key`.
_Jellyseerr and Overseerr merged into Seerr. Use `type: seerr` (legacy `type: jellyseerr` and `type: overseerr` are aliased)._
Allowed fields: `["pending", "approved", "available", "completed", "processing", "issues"]`.
Default fields: `["pending", "approved", "completed"]`.
```yaml
widget:
type: seerr
url: http://seerr.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
```

View File

@@ -0,0 +1,15 @@
---
title: SparkyFitness
description: SparkyFitness Widget Configuration
---
Learn more about [SparkyFitness](https://github.com/CodeWithCJ/SparkyFitness).
Allowed fields: `["eaten", "burned", "remaining", "steps"]`.
```yaml
widget:
type: sparkyfitness
url: http://sparkyfitness.host.or.ip
key: apikeyapikeyapikeyapikeyapikey
```

View File

@@ -0,0 +1,21 @@
---
title: Tracearr
description: Tracearr Widget Configuration
---
Learn more about [Tracearr](https://www.tracearr.com/).
Provides detailed information about currently active streams across multiple servers.
Allowed fields (for summary view): `["streams", "transcodes", "directplay", "bitrate"]`.
```yaml
widget:
type: tracearr
url: http://tracearr.host.or.ip:3000
key: apikeyapikeyapikeyapikeyapikey
view: both # optional, "summary", "details", or "both", defaults to "details"
enableUser: true # optional, defaults to false
showEpisodeNumber: true # optional, defaults to false
expandOneStreamToTwoRows: false # optional, defaults to true
```

View File

@@ -91,7 +91,6 @@ nav:
- widgets/services/jackett.md - widgets/services/jackett.md
- widgets/services/jdownloader.md - widgets/services/jdownloader.md
- widgets/services/jellyfin.md - widgets/services/jellyfin.md
- widgets/services/jellyseerr.md
- widgets/services/jellystat.md - widgets/services/jellystat.md
- widgets/services/kavita.md - widgets/services/kavita.md
- widgets/services/komga.md - widgets/services/komga.md
@@ -125,7 +124,6 @@ nav:
- widgets/services/openmediavault.md - widgets/services/openmediavault.md
- widgets/services/opnsense.md - widgets/services/opnsense.md
- widgets/services/openwrt.md - widgets/services/openwrt.md
- widgets/services/overseerr.md
- widgets/services/pangolin.md - widgets/services/pangolin.md
- widgets/services/paperlessngx.md - widgets/services/paperlessngx.md
- widgets/services/peanut.md - widgets/services/peanut.md
@@ -151,8 +149,10 @@ nav:
- widgets/services/rutorrent.md - widgets/services/rutorrent.md
- widgets/services/sabnzbd.md - widgets/services/sabnzbd.md
- widgets/services/scrutiny.md - widgets/services/scrutiny.md
- widgets/services/seerr.md
- widgets/services/slskd.md - widgets/services/slskd.md
- widgets/services/sonarr.md - widgets/services/sonarr.md
- widgets/services/sparkyfitness.md
- widgets/services/speedtest-tracker.md - widgets/services/speedtest-tracker.md
- widgets/services/spoolman.md - widgets/services/spoolman.md
- widgets/services/stash.md - widgets/services/stash.md
@@ -165,6 +165,7 @@ nav:
- widgets/services/technitium.md - widgets/services/technitium.md
- widgets/services/tdarr.md - widgets/services/tdarr.md
- widgets/services/traefik.md - widgets/services/traefik.md
- widgets/services/tracearr.md
- widgets/services/transmission.md - widgets/services/transmission.md
- widgets/services/trilium.md - widgets/services/trilium.md
- widgets/services/truenas.md - widgets/services/truenas.md

View File

@@ -22,19 +22,19 @@
"follow-redirects": "^1.15.11", "follow-redirects": "^1.15.11",
"gamedig": "^5.3.2", "gamedig": "^5.3.2",
"i18next": "^25.8.0", "i18next": "^25.8.0",
"ical.js": "^2.1.0", "ical.js": "^2.2.1",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"json-rpc-2.0": "^1.7.0", "json-rpc-2.0": "^1.7.0",
"luxon": "^3.6.1", "luxon": "^3.6.1",
"memory-cache": "^0.2.0", "memory-cache": "^0.2.0",
"minecraftstatuspinger": "^1.2.2", "minecraftstatuspinger": "^1.2.2",
"next": "^15.5.11", "next": "^15.5.11",
"next-i18next": "^12.1.0", "next-i18next": "^15.4.3",
"ping": "^0.4.4", "ping": "^0.4.4",
"pretty-bytes": "^7.1.0", "pretty-bytes": "^7.1.0",
"raw-body": "^3.0.2", "raw-body": "^3.0.2",
"react": "^18.3.1", "react": "^19.2.4",
"react-dom": "^18.3.1", "react-dom": "^19.2.4",
"react-i18next": "^15.5.3", "react-i18next": "^15.5.3",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"recharts": "^3.1.2", "recharts": "^3.1.2",
@@ -63,9 +63,9 @@
"eslint-plugin-prettier": "^5.5.4", "eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.4", "eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"jsdom": "^26.1.0", "jsdom": "^28.1.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.7.3", "prettier": "^3.8.1",
"prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-organize-imports": "^4.3.0",
"tailwind-scrollbar": "^4.0.2", "tailwind-scrollbar": "^4.0.2",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",

726
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -184,6 +184,13 @@
"no_active": "No Active Streams", "no_active": "No Active Streams",
"plex_connection_error": "Check Plex Connection" "plex_connection_error": "Check Plex Connection"
}, },
"tracearr": {
"no_active": "No Active Streams",
"streams": "Streams",
"transcodes": "Transcodes",
"directplay": "Direct Play",
"bitrate": "Bitrate"
},
"omada": { "omada": {
"connectedAp": "Connected APs", "connectedAp": "Connected APs",
"activeUser": "Active devices", "activeUser": "Active devices",
@@ -282,17 +289,13 @@
"approved": "Approved", "approved": "Approved",
"available": "Available" "available": "Available"
}, },
"jellyseerr": { "seerr": {
"pending": "Pending", "pending": "Pending",
"approved": "Approved", "approved": "Approved",
"available": "Available", "available": "Available",
"issues": "Open Issues" "completed": "Completed",
},
"overseerr": {
"pending": "Pending",
"processing": "Processing", "processing": "Processing",
"approved": "Approved", "issues": "Open Issues"
"available": "Available"
}, },
"netalertx": { "netalertx": {
"total": "Total", "total": "Total",
@@ -1171,5 +1174,11 @@
"paused": "Paused", "paused": "Paused",
"total": "Total", "total": "Total",
"environment_not_found": "Environment Not Found" "environment_not_found": "Environment Not Found"
},
"sparkyfitness": {
"eaten": "Eaten",
"burned": "Burned",
"remaining": "Remaining",
"steps": "Steps"
} }
} }

View File

@@ -344,4 +344,17 @@ describe("pages/api/services/proxy", () => {
expect(res.statusCode).toBe(500); expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Unexpected error" }); expect(res.body).toEqual({ error: "Unexpected error" });
}); });
it("returns 500 when an async proxy handler throws", async () => {
getServiceWidget.mockResolvedValue({ type: "linkwarden" });
handlerFn.handler.mockRejectedValueOnce(new Error("proxy boom"));
const req = { method: "GET", query: { group: "g", service: "s", index: "0" } };
const res = createMockRes();
await servicesProxy(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Unexpected error" });
});
}); });

View File

@@ -90,17 +90,74 @@ describe("pages/api/widgets/resources", () => {
}); });
it("returns 404 when requested network interface does not exist", async () => { it("returns 404 when requested network interface does not exist", async () => {
si.networkStats.mockResolvedValueOnce([{ iface: "en0" }]); si.networkStats.mockResolvedValueOnce([{ iface: "en0" }]).mockResolvedValueOnce([
{
iface: "missing",
operstate: "unknown",
rx_bytes: 0,
rx_dropped: 0,
rx_errors: 0,
tx_bytes: 0,
tx_dropped: 0,
tx_errors: 0,
rx_sec: null,
tx_sec: null,
ms: 0,
},
]);
const req = { query: { type: "network", interfaceName: "missing" } }; const req = { query: { type: "network", interfaceName: "missing" } };
const res = createMockRes(); const res = createMockRes();
await handler(req, res); await handler(req, res);
expect(si.networkStats).toHaveBeenNthCalledWith(1, "*");
expect(si.networkStats).toHaveBeenNthCalledWith(2, "missing");
expect(res.statusCode).toBe(404); expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: "Interface not found" }); expect(res.body).toEqual({ error: "Interface not found" });
}); });
it("falls back to direct named interface query when wildcard enumeration misses it", async () => {
si.networkStats.mockResolvedValueOnce([{ iface: "eth0", rx_bytes: 1 }]).mockResolvedValueOnce([
{
iface: "eno1",
operstate: "up",
rx_bytes: 1000,
rx_dropped: 0,
rx_errors: 0,
tx_bytes: 500,
tx_dropped: 0,
tx_errors: 0,
rx_sec: null,
tx_sec: null,
ms: 0,
},
]);
const req = { query: { type: "network", interfaceName: "eno1" } };
const res = createMockRes();
await handler(req, res);
expect(si.networkStats).toHaveBeenNthCalledWith(1, "*");
expect(si.networkStats).toHaveBeenNthCalledWith(2, "eno1");
expect(res.statusCode).toBe(200);
expect(res.body.interface).toBe("eno1");
expect(res.body.network).toEqual({
iface: "eno1",
operstate: "up",
rx_bytes: 1000,
rx_dropped: 0,
rx_errors: 0,
tx_bytes: 500,
tx_dropped: 0,
tx_errors: 0,
rx_sec: null,
tx_sec: null,
ms: 0,
});
});
it("returns default interface network stats", async () => { it("returns default interface network stats", async () => {
si.networkStats.mockResolvedValueOnce([{ iface: "en0", rx_bytes: 1 }]); si.networkStats.mockResolvedValueOnce([{ iface: "en0", rx_bytes: 1 }]);
si.networkInterfaceDefault.mockResolvedValueOnce("en0"); si.networkInterfaceDefault.mockResolvedValueOnce("en0");

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { fireEvent, screen } from "@testing-library/react"; import { act, fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
@@ -188,7 +188,9 @@ describe("components/services/item", () => {
// Still rendered while the close animation runs. // Still rendered while the close animation runs.
expect(screen.getByTestId("docker-widget")).toBeInTheDocument(); expect(screen.getByTestId("docker-widget")).toBeInTheDocument();
await vi.advanceTimersByTimeAsync(300); act(() => {
vi.advanceTimersByTime(300);
});
expect(screen.queryByTestId("docker-widget")).not.toBeInTheDocument(); expect(screen.queryByTestId("docker-widget")).not.toBeInTheDocument();
vi.useRealTimers(); vi.useRealTimers();

View File

@@ -9,6 +9,8 @@ import { buildHighlightConfig } from "utils/highlights";
const ALIASED_WIDGETS = { const ALIASED_WIDGETS = {
pialert: "netalertx", pialert: "netalertx",
hoarder: "karakeep", hoarder: "karakeep",
jellyseerr: "seerr",
overseerr: "seerr",
}; };
export default function Container({ error = false, children, service }) { export default function Container({ error = false, children, service }) {

View File

@@ -58,6 +58,26 @@ describe("components/services/widget/container", () => {
expect(screen.getByTestId("karakeep.count")).toBeInTheDocument(); expect(screen.getByTestId("karakeep.count")).toBeInTheDocument();
}); });
it("supports seerr aliases when filtering (jellyseerr/overseerr -> seerr)", () => {
renderWithProviders(
<Container service={{ widget: { type: "jellyseerr", fields: ["pending"] } }}>
<Dummy label="seerr.pending" />
</Container>,
{ settings: {} },
);
expect(screen.getByTestId("seerr.pending")).toBeInTheDocument();
renderWithProviders(
<Container service={{ widget: { type: "overseerr", fields: ["processing"] } }}>
<Dummy label="seerr.processing" />
</Container>,
{ settings: {} },
);
expect(screen.getByTestId("seerr.processing")).toBeInTheDocument();
});
it("returns null when errors are hidden via settings.hideErrors", () => { it("returns null when errors are hidden via settings.hideErrors", () => {
const { container } = renderWithProviders( const { container } = renderWithProviders(
<Container error="nope" service={{ widget: { type: "omada", hide_errors: false } }}> <Container error="nope" service={{ widget: { type: "omada", hide_errors: false } }}>

View File

@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { screen } from "@testing-library/react"; import { act, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
@@ -21,7 +21,9 @@ describe("components/widgets/datetime", () => {
// `render` wraps in `act`, so effects should flush synchronously. // `render` wraps in `act`, so effects should flush synchronously.
expect(screen.getByText(expected0)).toBeInTheDocument(); expect(screen.getByText(expected0)).toBeInTheDocument();
await vi.advanceTimersByTimeAsync(1000); act(() => {
vi.advanceTimersByTime(1000);
});
const expected1 = new Intl.DateTimeFormat("en-US", format).format(new Date()); const expected1 = new Intl.DateTimeFormat("en-US", format).format(new Date());
expect(screen.getByText(expected1)).toBeInTheDocument(); expect(screen.getByText(expected1)).toBeInTheDocument();

View File

@@ -11,7 +11,7 @@ import Resource from "../widget/resource";
import Resources from "../widget/resources"; import Resources from "../widget/resources";
import WidgetLabel from "../widget/widget_label"; import WidgetLabel from "../widget/widget_label";
const cpuSensorLabels = ["cpu_thermal", "Core", "Tctl"]; const cpuSensorLabels = ["cpu_thermal", "Core", "Tctl", "Temperature"];
function convertToFahrenheit(t) { function convertToFahrenheit(t) {
return (t * 9) / 5 + 32; return (t * 9) / 5 + 32;

View File

@@ -29,7 +29,7 @@ export default async function handler(req, res) {
if (serviceProxyHandler instanceof Function) { if (serviceProxyHandler instanceof Function) {
// quick return for no endpoint services, calendar is an exception // quick return for no endpoint services, calendar is an exception
if (!req.query.endpoint || serviceProxyHandler === calendarProxyHandler) { if (!req.query.endpoint || serviceProxyHandler === calendarProxyHandler) {
return serviceProxyHandler(req, res); return await serviceProxyHandler(req, res);
} }
// map opaque endpoints to their actual endpoint // map opaque endpoints to their actual endpoint
@@ -90,15 +90,15 @@ export default async function handler(req, res) {
} }
if (endpointProxy instanceof Function) { if (endpointProxy instanceof Function) {
return endpointProxy(req, res, map); return await endpointProxy(req, res, map);
} }
return serviceProxyHandler(req, res, map); return await serviceProxyHandler(req, res, map);
} }
if (widget.allowedEndpoints instanceof RegExp) { if (widget.allowedEndpoints instanceof RegExp) {
if (widget.allowedEndpoints.test(req.query.endpoint)) { if (widget.allowedEndpoints.test(req.query.endpoint)) {
return serviceProxyHandler(req, res); return await serviceProxyHandler(req, res);
} }
} }

View File

@@ -4,6 +4,21 @@ import createLogger from "utils/logger";
const logger = createLogger("resources"); const logger = createLogger("resources");
function isMissingNetworkStat(networkData, interfaceName) {
return (
networkData.operstate === "unknown" &&
networkData.rx_bytes === 0 &&
networkData.rx_dropped === 0 &&
networkData.rx_errors === 0 &&
networkData.tx_bytes === 0 &&
networkData.tx_dropped === 0 &&
networkData.tx_errors === 0 &&
networkData.rx_sec === null &&
networkData.tx_sec === null &&
networkData.ms === 0
);
}
export default async function handler(req, res) { export default async function handler(req, res) {
const { type, target, interfaceName = "default" } = req.query; const { type, target, interfaceName = "default" } = req.query;
@@ -64,6 +79,17 @@ export default async function handler(req, res) {
logger.debug("networkData:", JSON.stringify(networkData)); logger.debug("networkData:", JSON.stringify(networkData));
if (interfaceName && interfaceName !== "default") { if (interfaceName && interfaceName !== "default") {
networkData = networkData.filter((network) => network.iface === interfaceName).at(0); networkData = networkData.filter((network) => network.iface === interfaceName).at(0);
if (!networkData) {
// Fallback for e.g. docker where networkStats("*") may not return stats for host interfaces
const directNetworkData = await si.networkStats(interfaceName);
logger.debug("directNetworkData:", JSON.stringify(directNetworkData));
networkData = Array.isArray(directNetworkData) ? directNetworkData.at(0) : null;
// si returns unknown + zeroes when interface truly does not exist
if (!networkData || isMissingNetworkStat(networkData, interfaceName)) {
networkData = null;
}
}
if (!networkData) { if (!networkData) {
return res.status(404).json({ return res.status(404).json({
error: "Interface not found", error: "Interface not found",

View File

@@ -1,4 +1,12 @@
import { expect } from "vitest";
export function findServiceBlockByLabel(container, label) { export function findServiceBlockByLabel(container, label) {
const blocks = Array.from(container.querySelectorAll(".service-block")); const blocks = Array.from(container.querySelectorAll(".service-block"));
return blocks.find((b) => b.textContent?.includes(label)); return blocks.find((b) => b.textContent?.includes(label));
} }
export function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}

View File

@@ -313,7 +313,7 @@ export function cleanServiceGroups(groups) {
enableNowPlaying, enableNowPlaying,
enableMediaControl, enableMediaControl,
// emby, jellyfin, tautulli // emby, jellyfin, tautulli, tracearr
enableUser, enableUser,
expandOneStreamToTwoRows, expandOneStreamToTwoRows,
showEpisodeNumber, showEpisodeNumber,
@@ -542,12 +542,15 @@ export function cleanServiceGroups(groups) {
if (enableBlocks !== undefined) widget.enableBlocks = JSON.parse(enableBlocks); if (enableBlocks !== undefined) widget.enableBlocks = JSON.parse(enableBlocks);
if (enableNowPlaying !== undefined) widget.enableNowPlaying = JSON.parse(enableNowPlaying); if (enableNowPlaying !== undefined) widget.enableNowPlaying = JSON.parse(enableNowPlaying);
} }
if (["emby", "jellyfin", "tautulli"].includes(type)) { if (["emby", "jellyfin", "tautulli", "tracearr"].includes(type)) {
if (expandOneStreamToTwoRows !== undefined) if (expandOneStreamToTwoRows !== undefined)
widget.expandOneStreamToTwoRows = !!JSON.parse(expandOneStreamToTwoRows); widget.expandOneStreamToTwoRows = !!JSON.parse(expandOneStreamToTwoRows);
if (showEpisodeNumber !== undefined) widget.showEpisodeNumber = !!JSON.parse(showEpisodeNumber); if (showEpisodeNumber !== undefined) widget.showEpisodeNumber = !!JSON.parse(showEpisodeNumber);
if (enableUser !== undefined) widget.enableUser = !!JSON.parse(enableUser); if (enableUser !== undefined) widget.enableUser = !!JSON.parse(enableUser);
} }
if (type === "tracearr") {
if (view !== undefined) widget.view = view;
}
if (["sonarr", "radarr"].includes(type)) { if (["sonarr", "radarr"].includes(type)) {
if (enableQueue !== undefined) widget.enableQueue = JSON.parse(enableQueue); if (enableQueue !== undefined) widget.enableQueue = JSON.parse(enableQueue);
} }

View File

@@ -312,6 +312,13 @@ describe("utils/config/service-helpers", () => {
{ type: "healthchecks", uuid: "u" }, { type: "healthchecks", uuid: "u" },
{ type: "speedtest", bitratePrecision: "3", version: "1" }, { type: "speedtest", bitratePrecision: "3", version: "1" },
{ type: "stocks", watchlist: "AAPL", showUSMarketStatus: true }, { type: "stocks", watchlist: "AAPL", showUSMarketStatus: true },
{
type: "tracearr",
expandOneStreamToTwoRows: "true",
showEpisodeNumber: "true",
enableUser: "true",
view: "both",
},
{ type: "wgeasy", threshold: "10", version: "1" }, { type: "wgeasy", threshold: "10", version: "1" },
{ type: "technitium", range: "24h" }, { type: "technitium", range: "24h" },
{ type: "lubelogger", vehicleID: "12" }, { type: "lubelogger", vehicleID: "12" },
@@ -350,6 +357,14 @@ describe("utils/config/service-helpers", () => {
expect(widgets.find((w) => w.type === "speedtest")).toEqual( expect(widgets.find((w) => w.type === "speedtest")).toEqual(
expect.objectContaining({ bitratePrecision: 3, version: 1 }), expect.objectContaining({ bitratePrecision: 3, version: 1 }),
); );
expect(widgets.find((w) => w.type === "tracearr")).toEqual(
expect.objectContaining({
expandOneStreamToTwoRows: true,
showEpisodeNumber: true,
enableUser: true,
view: "both",
}),
);
expect(widgets.find((w) => w.type === "jellystat")).toEqual(expect.objectContaining({ days: 7 })); expect(widgets.find((w) => w.type === "jellystat")).toEqual(expect.objectContaining({ days: 7 }));
expect(widgets.find((w) => w.type === "lubelogger")).toEqual(expect.objectContaining({ vehicleID: 12 })); expect(widgets.find((w) => w.type === "lubelogger")).toEqual(expect.objectContaining({ vehicleID: 12 }));
}); });

View File

@@ -74,6 +74,21 @@ const toNumber = (value) => {
return undefined; return undefined;
}; };
const extractNumericToken = (value) => {
if (typeof value !== "string") return undefined;
const match = value.match(/[-+]?\d[\d\s.,]*/);
if (!match) return undefined;
const token = match[0].trim();
if (!token) return undefined;
const prefix = value.slice(0, match.index).trim();
const suffix = value.slice((match.index ?? 0) + match[0].length).trim();
if (/\d/.test(prefix) || /\d/.test(suffix)) return undefined;
return token;
};
const parseNumericValue = (value) => { const parseNumericValue = (value) => {
if (value === null || value === undefined) return undefined; if (value === null || value === undefined) return undefined;
if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "number" && Number.isFinite(value)) return value;
@@ -85,7 +100,9 @@ const parseNumericValue = (value) => {
const direct = Number(trimmed); const direct = Number(trimmed);
if (!Number.isNaN(direct)) return direct; if (!Number.isNaN(direct)) return direct;
const compact = trimmed.replace(/\s+/g, ""); const candidate = extractNumericToken(trimmed);
const numericString = candidate ?? trimmed;
const compact = numericString.replace(/\s+/g, "");
if (!compact || !/^[-+]?[0-9.,]+$/.test(compact)) return undefined; if (!compact || !/^[-+]?[0-9.,]+$/.test(compact)) return undefined;
const commaCount = (compact.match(/,/g) || []).length; const commaCount = (compact.match(/,/g) || []).length;

View File

@@ -136,6 +136,9 @@ describe("utils/highlights", () => {
const cfg = buildHighlightConfig(null, { const cfg = buildHighlightConfig(null, {
// string numeric rule values go through toNumber() // string numeric rule values go through toNumber()
gt: { numeric: { when: "gt", value: "5", level: "warn" } }, gt: { numeric: { when: "gt", value: "5", level: "warn" } },
withUnitSuffix: { numeric: { when: "gt", value: 5, level: "warn" } },
withUnitPrefix: { numeric: { when: "gt", value: 5, level: "warn" } },
localizedUnitSuffix: { numeric: { when: "gt", value: 0.5, level: "warn" } },
commaGrouped: { numeric: { when: "eq", value: 1234, level: "good" } }, commaGrouped: { numeric: { when: "eq", value: 1234, level: "good" } },
commaDecimal: { numeric: { when: "eq", value: 12.34, level: "good" } }, commaDecimal: { numeric: { when: "eq", value: 12.34, level: "good" } },
dotDecimal: { numeric: { when: "eq", value: 12.34, level: "good" } }, dotDecimal: { numeric: { when: "eq", value: 12.34, level: "good" } },
@@ -143,6 +146,12 @@ describe("utils/highlights", () => {
}); });
expect(evaluateHighlight("gt", "6", cfg)).toMatchObject({ level: "warn", source: "numeric" }); expect(evaluateHighlight("gt", "6", cfg)).toMatchObject({ level: "warn", source: "numeric" });
expect(evaluateHighlight("withUnitSuffix", "5.2 ms", cfg)).toMatchObject({ level: "warn", source: "numeric" });
expect(evaluateHighlight("withUnitPrefix", "ms 5.2", cfg)).toMatchObject({ level: "warn", source: "numeric" });
expect(evaluateHighlight("localizedUnitSuffix", "0,71\u202Fms", cfg)).toMatchObject({
level: "warn",
source: "numeric",
});
expect(evaluateHighlight("commaGrouped", "1,234", cfg)).toMatchObject({ level: "good", source: "numeric" }); expect(evaluateHighlight("commaGrouped", "1,234", cfg)).toMatchObject({ level: "good", source: "numeric" });
expect(evaluateHighlight("commaDecimal", "12,34", cfg)).toMatchObject({ level: "good", source: "numeric" }); expect(evaluateHighlight("commaDecimal", "12,34", cfg)).toMatchObject({ level: "good", source: "numeric" });
// Include a space so Number(trimmed) fails and we exercise the dot parsing branch. // Include a space so Number(trimmed) fails and we exercise the dot parsing branch.
@@ -161,6 +170,9 @@ describe("utils/highlights", () => {
// "1.2.3" is not a valid grouped or decimal number for our parser. // "1.2.3" is not a valid grouped or decimal number for our parser.
expect(evaluateHighlight("num", "1.2.3", cfg)).toBeNull(); expect(evaluateHighlight("num", "1.2.3", cfg)).toBeNull();
// Multiple numbers in one string should not be treated as a single numeric value.
expect(evaluateHighlight("num", "5/10 ms", cfg)).toBeNull();
// JSX-ish values should not be treated as numeric. // JSX-ish values should not be treated as numeric.
expect(evaluateHighlight("num", { props: { children: "x" } }, cfg)).toBeNull(); expect(evaluateHighlight("num", { props: { children: "x" } }, cfg)).toBeNull();
}); });

View File

@@ -64,6 +64,7 @@ export default async function credentialedProxyHandler(req, res, map) {
"pangolin", "pangolin",
"tailscale", "tailscale",
"tandoor", "tandoor",
"tracearr",
"pterodactyl", "pterodactyl",
"vikunja", "vikunja",
"firefly", "firefly",

View File

@@ -4,6 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -13,13 +14,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/arcane/component", () => { describe("widgets/arcane/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,6 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -13,13 +14,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/argocd/component", () => { describe("widgets/argocd/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,6 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -13,13 +14,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/audiobookshelf/component", () => { describe("widgets/audiobookshelf/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,6 +4,7 @@ import { screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -13,13 +14,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/authentik/component", () => { describe("widgets/authentik/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,6 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -13,13 +14,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/autobrr/component", () => { describe("widgets/autobrr/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,6 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -13,13 +14,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/azuredevops/component", () => { describe("widgets/azuredevops/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -54,7 +54,10 @@ export default function Component({ service }) {
<Block label="beszel.cpu" value={t("common.percent", { value: system.info.cpu, maximumFractionDigits: 2 })} /> <Block label="beszel.cpu" value={t("common.percent", { value: system.info.cpu, maximumFractionDigits: 2 })} />
<Block label="beszel.memory" value={t("common.percent", { value: system.info.mp, maximumFractionDigits: 2 })} /> <Block label="beszel.memory" value={t("common.percent", { value: system.info.mp, maximumFractionDigits: 2 })} />
<Block label="beszel.disk" value={t("common.percent", { value: system.info.dp, maximumFractionDigits: 2 })} /> <Block label="beszel.disk" value={t("common.percent", { value: system.info.dp, maximumFractionDigits: 2 })} />
<Block label="beszel.network" value={t("common.percent", { value: system.info.b, maximumFractionDigits: 2 })} /> <Block
label="beszel.network"
value={t("common.byterate", { value: system.info.bb, maximumFractionDigits: 2 })}
/>
</Container> </Container>
); );
} }

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,12 +12,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/beszel/component", () => { describe("widgets/beszel/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -82,6 +76,35 @@ describe("widgets/beszel/component", () => {
expect(screen.queryByText("beszel.updated")).toBeNull(); expect(screen.queryByText("beszel.updated")).toBeNull();
}); });
it("renders optional fields", () => {
useWidgetAPI.mockReturnValue({
data: {
totalItems: 1,
items: [
{
id: "sys1",
name: "MySystem",
status: "up",
updated: 123,
info: { cpu: 10, mp: 20, dp: 30, b: 40, bb: 14.5 },
},
],
},
error: undefined,
});
const service = {
widget: { type: "beszel", systemId: "sys1", fields: ["name", "disk", "network"] },
};
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(service.widget.fields).toEqual(["name", "disk", "network"]);
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
expectBlockValue(container, "beszel.name", "MySystem");
expectBlockValue(container, "beszel.disk", 30);
expectBlockValue(container, "beszel.network", 14.5);
});
it("renders error when systemId is not found", () => { it("renders error when systemId is not found", () => {
useWidgetAPI.mockReturnValue({ useWidgetAPI.mockReturnValue({
data: { totalItems: 1, items: [{ id: "sys1", name: "MySystem", status: "up", info: {} }] }, data: { totalItems: 1, items: [{ id: "sys1", name: "MySystem", status: "up", info: {} }] },

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,12 +12,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/caddy/component", () => { describe("widgets/caddy/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,12 +12,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/changedetectionio/component", () => { describe("widgets/changedetectionio/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,12 +12,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/channelsdvrserver/component", () => { describe("widgets/channelsdvrserver/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,12 +12,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/checkmk/component", () => { describe("widgets/checkmk/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,12 +12,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/cloudflared/component", () => { describe("widgets/cloudflared/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -65,7 +65,7 @@ const components = {
jackett: dynamic(() => import("./jackett/component")), jackett: dynamic(() => import("./jackett/component")),
jdownloader: dynamic(() => import("./jdownloader/component")), jdownloader: dynamic(() => import("./jdownloader/component")),
jellyfin: dynamic(() => import("./jellyfin/component")), jellyfin: dynamic(() => import("./jellyfin/component")),
jellyseerr: dynamic(() => import("./jellyseerr/component")), jellyseerr: dynamic(() => import("./seerr/component")),
jellystat: dynamic(() => import("./jellystat/component")), jellystat: dynamic(() => import("./jellystat/component")),
kavita: dynamic(() => import("./kavita/component")), kavita: dynamic(() => import("./kavita/component")),
komga: dynamic(() => import("./komga/component")), komga: dynamic(() => import("./komga/component")),
@@ -97,7 +97,7 @@ const components = {
ombi: dynamic(() => import("./ombi/component")), ombi: dynamic(() => import("./ombi/component")),
opendtu: dynamic(() => import("./opendtu/component")), opendtu: dynamic(() => import("./opendtu/component")),
opnsense: dynamic(() => import("./opnsense/component")), opnsense: dynamic(() => import("./opnsense/component")),
overseerr: dynamic(() => import("./overseerr/component")), overseerr: dynamic(() => import("./seerr/component")),
openmediavault: dynamic(() => import("./openmediavault/component")), openmediavault: dynamic(() => import("./openmediavault/component")),
openwrt: dynamic(() => import("./openwrt/component")), openwrt: dynamic(() => import("./openwrt/component")),
paperlessngx: dynamic(() => import("./paperlessngx/component")), paperlessngx: dynamic(() => import("./paperlessngx/component")),
@@ -124,8 +124,10 @@ const components = {
rutorrent: dynamic(() => import("./rutorrent/component")), rutorrent: dynamic(() => import("./rutorrent/component")),
sabnzbd: dynamic(() => import("./sabnzbd/component")), sabnzbd: dynamic(() => import("./sabnzbd/component")),
scrutiny: dynamic(() => import("./scrutiny/component")), scrutiny: dynamic(() => import("./scrutiny/component")),
seerr: dynamic(() => import("./seerr/component")),
slskd: dynamic(() => import("./slskd/component")), slskd: dynamic(() => import("./slskd/component")),
sonarr: dynamic(() => import("./sonarr/component")), sonarr: dynamic(() => import("./sonarr/component")),
sparkyfitness: dynamic(() => import("./sparkyfitness/component")),
speedtest: dynamic(() => import("./speedtest/component")), speedtest: dynamic(() => import("./speedtest/component")),
spoolman: dynamic(() => import("./spoolman/component")), spoolman: dynamic(() => import("./spoolman/component")),
stash: dynamic(() => import("./stash/component")), stash: dynamic(() => import("./stash/component")),
@@ -138,6 +140,7 @@ const components = {
tautulli: dynamic(() => import("./tautulli/component")), tautulli: dynamic(() => import("./tautulli/component")),
technitium: dynamic(() => import("./technitium/component")), technitium: dynamic(() => import("./technitium/component")),
tdarr: dynamic(() => import("./tdarr/component")), tdarr: dynamic(() => import("./tdarr/component")),
tracearr: dynamic(() => import("./tracearr/component")),
traefik: dynamic(() => import("./traefik/component")), traefik: dynamic(() => import("./traefik/component")),
transmission: dynamic(() => import("./transmission/component")), transmission: dynamic(() => import("./transmission/component")),
trilium: dynamic(() => import("./trilium/component")), trilium: dynamic(() => import("./trilium/component")),

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -12,12 +12,6 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/crowdsec/component", () => { describe("widgets/crowdsec/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -25,13 +25,25 @@ async function login(widget, service) {
}), }),
}); });
const dataParsed = JSON.parse(data); let dataParsed;
try {
dataParsed = JSON.parse(data);
} catch {
logger.error("Failed to parse Crowdsec login response, status: %d", status);
cache.del(`${sessionTokenCacheKey}.${service}`);
return null;
}
if (!(status === 200) || !dataParsed.token) { if (status !== 200 || !dataParsed.token) {
logger.error("Failed to login to Crowdsec API, status: %d", status); logger.error("Failed to login to Crowdsec API, status: %d", status);
cache.del(`${sessionTokenCacheKey}.${service}`); cache.del(`${sessionTokenCacheKey}.${service}`);
return null;
} }
cache.put(`${sessionTokenCacheKey}.${service}`, dataParsed.token, new Date(dataParsed.expire) - new Date());
const ttl = Math.max(new Date(dataParsed.expire) - new Date(), 1);
cache.put(`${sessionTokenCacheKey}.${service}`, dataParsed.token, ttl);
return dataParsed.token;
} }
export default async function crowdsecProxyHandler(req, res) { export default async function crowdsecProxyHandler(req, res) {
@@ -48,11 +60,10 @@ export default async function crowdsecProxyHandler(req, res) {
return res.status(400).json({ error: "Invalid widget configuration" }); return res.status(400).json({ error: "Invalid widget configuration" });
} }
if (!cache.get(`${sessionTokenCacheKey}.${service}`)) { let token = cache.get(`${sessionTokenCacheKey}.${service}`);
await login(widget, service); if (!token) {
token = await login(widget, service);
} }
const token = cache.get(`${sessionTokenCacheKey}.${service}`);
if (!token) { if (!token) {
return res.status(500).json({ error: "Failed to authenticate with Crowdsec" }); return res.status(500).json({ error: "Failed to authenticate with Crowdsec" });
} }
@@ -71,7 +82,20 @@ export default async function crowdsecProxyHandler(req, res) {
logger.debug("Calling Crowdsec API endpoint: %s", endpoint); logger.debug("Calling Crowdsec API endpoint: %s", endpoint);
const [status, , data] = await httpProxy(url, params); let [status, , data] = await httpProxy(url, params);
if (status === 401) {
logger.debug("Crowdsec API returned 401, refreshing token and retrying request");
cache.del(`${sessionTokenCacheKey}.${service}`);
const refreshedToken = await login(widget, service);
if (!refreshedToken) {
return res.status(500).json({ error: "Failed to authenticate with Crowdsec" });
}
params.headers.Authorization = `Bearer ${refreshedToken}`;
[status, , data] = await httpProxy(url, params);
}
if (status !== 200) { if (status !== 200) {
logger.error("Error calling Crowdsec API: %d. Data: %s", status, data); logger.error("Error calling Crowdsec API: %d. Data: %s", status, data);

View File

@@ -89,4 +89,76 @@ describe("widgets/crowdsec/proxy", () => {
expect(res.statusCode).toBe(500); expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Failed to authenticate with Crowdsec" }); expect(res.body).toEqual({ error: "Failed to authenticate with Crowdsec" });
}); });
it("re-authenticates and retries once when API returns 401", async () => {
getServiceWidget.mockResolvedValue({
type: "crowdsec",
url: "http://cs",
username: "machine",
password: "pw",
});
httpProxy
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ token: "tok-old", expire: new Date(Date.now() + 60_000).toISOString() }),
])
.mockResolvedValueOnce([401, "application/json", Buffer.from("bad token")])
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ token: "tok-new", expire: new Date(Date.now() + 60_000).toISOString() }),
])
.mockResolvedValueOnce([200, "application/json", Buffer.from("data")]);
const req = { query: { group: "g", service: "svc", endpoint: "alerts", index: "0" } };
const res = createMockRes();
await crowdsecProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(4);
expect(httpProxy.mock.calls[3][1].headers.Authorization).toBe("Bearer tok-new");
expect(res.statusCode).toBe(200);
expect(res.body).toEqual(Buffer.from("data"));
});
it("returns 500 when 401 refresh fails to get a new token", async () => {
getServiceWidget.mockResolvedValue({
type: "crowdsec",
url: "http://cs",
username: "machine",
password: "pw",
});
httpProxy
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ token: "tok-old", expire: new Date(Date.now() + 60_000).toISOString() }),
])
.mockResolvedValueOnce([401, "application/json", Buffer.from("bad token")])
.mockResolvedValueOnce([500, "application/json", JSON.stringify({ error: "no token" })]);
const req = { query: { group: "g", service: "svc", endpoint: "alerts", index: "0" } };
const res = createMockRes();
await crowdsecProxyHandler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Failed to authenticate with Crowdsec" });
});
it("returns 500 when login response is not JSON", async () => {
getServiceWidget.mockResolvedValue({ type: "crowdsec", url: "http://cs", username: "machine", password: "pw" });
httpProxy.mockResolvedValueOnce([200, "text/plain", "not-json"]);
const req = { query: { group: "g", service: "svc", endpoint: "alerts", index: "0" } };
const res = createMockRes();
await crowdsecProxyHandler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Failed to authenticate with Crowdsec" });
});
}); });

View File

@@ -4,7 +4,7 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
@@ -16,12 +16,6 @@ vi.mock("../../components/widgets/queue/queueEntry", () => ({
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/deluge/component", () => { describe("widgets/deluge/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/diskstation/component", () => { describe("widgets/diskstation/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/dispatcharr/component", () => { describe("widgets/dispatcharr/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/dockhand/component", () => { describe("widgets/dockhand/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/downloadstation/component", () => { describe("widgets/downloadstation/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/esphome/component", () => { describe("widgets/esphome/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/evcc/component", () => { describe("widgets/evcc/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/filebrowser/component", () => { describe("widgets/filebrowser/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/fileflows/component", () => { describe("widgets/fileflows/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/flood/component", () => { describe("widgets/flood/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/freshrss/component", () => { describe("widgets/freshrss/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/frigate/component", () => { describe("widgets/frigate/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component, { fritzboxDefaultFields } from "./component"; import Component, { fritzboxDefaultFields } from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/fritzbox/component", () => { describe("widgets/fritzbox/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/gamedig/component", () => { describe("widgets/gamedig/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/gatus/component", () => { describe("widgets/gatus/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/ghostfolio/component", () => { describe("widgets/ghostfolio/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/gitea/component", () => { describe("widgets/gitea/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/gitlab/component", () => { describe("widgets/gitlab/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -8,7 +8,9 @@ import useWidgetAPI from "utils/proxy/use-widget-api";
const statusMap = { const statusMap = {
running: <ResolvedIcon icon="mdi-circle" width={32} height={32} />, running: <ResolvedIcon icon="mdi-circle" width={32} height={32} />,
healthy: <ResolvedIcon icon="mdi-circle" width={32} height={32} />,
paused: <ResolvedIcon icon="mdi-circle-outline" width={32} height={32} />, paused: <ResolvedIcon icon="mdi-circle-outline" width={32} height={32} />,
stopped: <ResolvedIcon icon="mdi-circle-double" width={32} height={32} />,
}; };
const defaultInterval = 1000; const defaultInterval = 1000;

View File

@@ -11,6 +11,15 @@ vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
// Avoid pulling Next/Image + ThemeContext requirements into these unit tests. // Avoid pulling Next/Image + ThemeContext requirements into these unit tests.
vi.mock("components/resolvedicon", () => ({ default: () => <span data-testid="resolvedicon" /> })); vi.mock("components/resolvedicon", () => ({ default: () => <span data-testid="resolvedicon" /> }));
vi.mock("next-i18next", () => ({
useTranslation: () => ({
t: (key, opts) => (key === "common.bytes" ? `${key}:${opts?.value}` : key),
}),
}));
// Avoid pulling Next/Image + ThemeContext requirements into these unit tests.
vi.mock("components/resolvedicon", () => ({ default: () => <span data-testid="resolvedicon" /> }));
import Component from "./containers"; import Component from "./containers";
describe("widgets/glances/metrics/containers", () => { describe("widgets/glances/metrics/containers", () => {
@@ -21,4 +30,78 @@ describe("widgets/glances/metrics/containers", () => {
}); });
expect(screen.getByText("-")).toBeInTheDocument(); expect(screen.getByText("-")).toBeInTheDocument();
}); });
it("renders a placeholder while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<Component service={{ widget: { chart: false, version: 3 } }} />, {
settings: { hideErrors: false },
});
expect(screen.getByText("-")).toBeInTheDocument();
});
it("renders nothing when there is an error", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: new Error("fail") });
renderWithProviders(<Component service={{ widget: { chart: false, version: 3 } }} />, {
settings: { hideErrors: false },
});
expect(screen.queryByText("resources.cpu")).not.toBeInTheDocument();
expect(screen.queryByText("-")).not.toBeInTheDocument();
});
it("renders container rows using v3 keys and formats values", () => {
useWidgetAPI.mockReturnValue({
data: [
{
Id: "one",
Status: "running",
name: "alpha",
cpu_percent: 12.34,
memory: { usage: 1000, inactive_file: 400 },
},
{
Id: "two",
Status: "paused",
name: "beta",
cpu_percent: 99.99,
memory: { usage: 2000, inactive_file: 1000 },
},
],
error: undefined,
});
renderWithProviders(<Component service={{ widget: { chart: false, version: 3 } }} />, {
settings: { hideErrors: false },
});
// data.splice(1) keeps only one item when chart is false
expect(screen.getByText("resources.cpu")).toBeInTheDocument();
expect(screen.getByText("resources.mem")).toBeInTheDocument();
expect(screen.getByText("alpha")).toBeInTheDocument();
expect(screen.queryByText("beta")).not.toBeInTheDocument();
expect(screen.getByText("12.3%")).toBeInTheDocument();
expect(screen.getByText("common.bytes:600")).toBeInTheDocument();
expect(screen.getAllByTestId("resolvedicon")).toHaveLength(1);
});
it("limits rows to 5 when chart is enabled", () => {
const data = Array.from({ length: 6 }).map((_, index) => ({
Id: `id-${index}`,
Status: "healthy",
name: `item-${index}`,
cpu_percent: index + 0.1,
memory: { usage: 100 * (index + 1), inactive_file: 0 },
}));
useWidgetAPI.mockReturnValue({ data, error: undefined });
renderWithProviders(<Component service={{ widget: { chart: true, version: 3 } }} />, {
settings: { hideErrors: false },
});
expect(screen.getByText("item-0")).toBeInTheDocument();
expect(screen.getByText("item-4")).toBeInTheDocument();
expect(screen.queryByText("item-5")).not.toBeInTheDocument();
});
}); });

View File

@@ -46,7 +46,7 @@ export default function Component({ service }) {
let listYPosition = "bottom-4"; let listYPosition = "bottom-4";
if (chart) { if (chart) {
headerYPosition = "-top-6"; headerYPosition = "-top-6";
listYPosition = "-top-3"; listYPosition = "-top-2";
} }
return ( return (

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/gluetun/component", () => { describe("widgets/gluetun/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/gotify/component", () => { describe("widgets/gotify/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/grafana/component", () => { describe("widgets/grafana/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/hdhomerun/component", () => { describe("widgets/hdhomerun/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/headscale/component", () => { describe("widgets/headscale/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/healthchecks/component", () => { describe("widgets/healthchecks/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component, { homeboxDefaultFields } from "./component"; import Component, { homeboxDefaultFields } from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/homebox/component", () => { describe("widgets/homebox/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/immich/component", () => { describe("widgets/immich/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/jackett/component", () => { describe("widgets/jackett/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/jdownloader/component", () => { describe("widgets/jdownloader/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -1,39 +0,0 @@
import Block from "components/services/widget/block";
import Container from "components/services/widget/container";
import useWidgetAPI from "utils/proxy/use-widget-api";
export const jellyseerrDefaultFields = ["pending", "approved", "available"];
export default function Component({ service }) {
const { widget } = service;
widget.fields = widget?.fields?.length ? widget.fields : jellyseerrDefaultFields;
const isIssueEnabled = widget.fields.includes("issues");
const { data: statsData, error: statsError } = useWidgetAPI(widget, "request/count");
const { data: issueData, error: issueError } = useWidgetAPI(widget, isIssueEnabled ? "issue/count" : "");
if (statsError || (isIssueEnabled && issueError)) {
return <Container service={service} error={statsError ? statsError : issueError} />;
}
if (!statsData || (isIssueEnabled && !issueData)) {
return (
<Container service={service}>
<Block label="jellyseerr.pending" />
<Block label="jellyseerr.approved" />
<Block label="jellyseerr.available" />
<Block label="jellyseerr.issues" />
</Container>
);
}
return (
<Container service={service}>
<Block label="jellyseerr.pending" value={statsData.pending} />
<Block label="jellyseerr.approved" value={statsData.approved} />
<Block label="jellyseerr.available" value={statsData.available} />
<Block label="jellyseerr.issues" value={`${issueData?.open} / ${issueData?.total}`} />
</Container>
);
}

View File

@@ -1,63 +0,0 @@
// @vitest-environment jsdom
import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component, { jellyseerrDefaultFields } from "./component";
describe("widgets/jellyseerr/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("defaults fields and filters to 3 blocks while loading when issues are not enabled", () => {
useWidgetAPI
.mockReturnValueOnce({ data: undefined, error: undefined }) // request/count
.mockReturnValueOnce({ data: undefined, error: undefined }); // issue/count disabled (endpoint = "")
const service = { widget: { type: "jellyseerr", url: "http://x" } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(service.widget.fields).toEqual(jellyseerrDefaultFields);
expect(useWidgetAPI.mock.calls[1][1]).toBe("");
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
expect(screen.getByText("jellyseerr.pending")).toBeInTheDocument();
expect(screen.getByText("jellyseerr.approved")).toBeInTheDocument();
expect(screen.getByText("jellyseerr.available")).toBeInTheDocument();
expect(screen.queryByText("jellyseerr.issues")).toBeNull();
});
it("renders issues when enabled (and calls the issue/count endpoint)", () => {
useWidgetAPI
.mockReturnValueOnce({ data: { pending: 1, approved: 2, available: 3 }, error: undefined })
.mockReturnValueOnce({ data: { open: 1, total: 2 }, error: undefined });
const service = {
widget: { type: "jellyseerr", url: "http://x", fields: ["pending", "approved", "available", "issues"] },
};
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(useWidgetAPI.mock.calls[1][1]).toBe("issue/count");
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("1 / 2")).toBeInTheDocument();
});
it("renders error UI when issues are enabled and issue/count errors", () => {
useWidgetAPI
.mockReturnValueOnce({ data: { pending: 0, approved: 0, available: 0 }, error: undefined })
.mockReturnValueOnce({ data: undefined, error: { message: "nope" } });
renderWithProviders(
<Component service={{ widget: { type: "jellyseerr", url: "http://x", fields: ["issues"] } }} />,
{ settings: { hideErrors: false } },
);
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
expect(screen.getByText("nope")).toBeInTheDocument();
});
});

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component, { karakeepDefaultFields } from "./component"; import Component, { karakeepDefaultFields } from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/karakeep/component", () => { describe("widgets/karakeep/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/komga/component", () => { describe("widgets/komga/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/kopia/component", () => { describe("widgets/kopia/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/lidarr/component", () => { describe("widgets/lidarr/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/linkwarden/component", () => { describe("widgets/linkwarden/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/lubelogger/component", () => { describe("widgets/lubelogger/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/mailcow/component", () => { describe("widgets/mailcow/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/mastodon/component", () => { describe("widgets/mastodon/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/mealie/component", () => { describe("widgets/mealie/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/medusa/component", () => { describe("widgets/medusa/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/mikrotik/component", () => { describe("widgets/mikrotik/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -4,19 +4,13 @@ import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers"; import { renderWithProviders } from "test-utils/render-with-providers";
import { findServiceBlockByLabel } from "test-utils/widget-assertions"; import { expectBlockValue } from "test-utils/widget-assertions";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() })); const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI })); vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Component from "./component"; import Component from "./component";
function expectBlockValue(container, label, value) {
const block = findServiceBlockByLabel(container, label);
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/minecraft/component", () => { describe("widgets/minecraft/component", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

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