diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b286d20c..cd9c646f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -75,7 +75,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v6
with:
- python-version: "3.12"
+ python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@v7
@@ -101,7 +101,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v6
with:
- python-version: "3.12"
+ python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@v7
@@ -125,7 +125,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v6
with:
- python-version: "3.12"
+ python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@v7
@@ -148,6 +148,34 @@ jobs:
--ignore=tests/unittests/test_controller/test_scan/test_remote_scanner.py
--ignore=tests/unittests/test_common/test_multiprocessing_logger.py
+ python-integration-test:
+ name: Python Integration Tests
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.13"
+
+ - name: Set up uv
+ uses: astral-sh/setup-uv@v7
+
+ - name: Install dependencies
+ working-directory: src/python
+ run: uv pip install --system -r pyproject.toml --group test
+
+ - name: Run web handler integration tests
+ working-directory: src/python
+ env:
+ PYTHONPATH: .
+ run: >-
+ pytest tests/integration/test_web -v --tb=short
+ --timeout=30
+
angular-build:
name: Build Angular
if: startsWith(github.ref, 'refs/tags/v') || (github.ref == 'refs/heads/develop' && github.event_name == 'push') || github.event_name == 'workflow_dispatch'
@@ -180,7 +208,7 @@ jobs:
build-test:
name: Build and Test (amd64)
- if: ${{ !(startsWith(github.ref, 'refs/tags/v') || (github.ref == 'refs/heads/develop' && github.event_name == 'push') || github.event_name == 'workflow_dispatch') }}
+ if: ${{ !(startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') }}
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -211,20 +239,17 @@ jobs:
done
docker logs test-container
- - name: Set up Node.js
- uses: actions/setup-node@v6
- with:
- node-version: 22
-
- - name: Install Playwright
- working-directory: src/e2e-playwright
+ - name: Run E2E tests in Playwright container
run: |
- npm install
- npx playwright install --with-deps chromium
-
- - name: Run E2E tests
- working-directory: src/e2e-playwright
- run: npx playwright test
+ # Match the @playwright/test version pinned in the lockfile so the
+ # container's chromium build is the one the package expects.
+ PW_VERSION=$(jq -r '.packages["node_modules/@playwright/test"].version' src/e2e-playwright/package-lock.json)
+ docker run --rm \
+ --network host \
+ -v "$GITHUB_WORKSPACE":/workspace \
+ -w /workspace/src/e2e-playwright \
+ "mcr.microsoft.com/playwright:v${PW_VERSION}-noble" \
+ sh -c "npm ci && npx playwright test"
- name: Upload Playwright report
if: failure()
@@ -392,9 +417,10 @@ jobs:
&& needs.python-lint.result == 'success'
&& needs.python-typecheck.result == 'success'
&& needs.python-test.result == 'success'
+ && needs.python-integration-test.result == 'success'
&& needs.build-amd64.result == 'success'
&& needs.build-arm64.result == 'success'
- needs: [unit-test, angular-lint, python-lint, python-typecheck, python-test, build-amd64, build-arm64]
+ needs: [unit-test, angular-lint, python-lint, python-typecheck, python-test, python-integration-test, build-amd64, build-arm64]
runs-on: ubuntu-latest
permissions:
contents: read
diff --git a/.gitignore b/.gitignore
index b835059a..6d89bb3a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@
package-lock.json
!src/angular/package-lock.json
!website/package-lock.json
+!src/e2e-playwright/package-lock.json
src/python/build
src/python/site
dev-config/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c16a5692..de70995e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,49 @@
# Changelog
+## [0.18.0] - 2026-05-04
+
+### Changed
+
+- **Image size reduced from 114 MB to 64 MB** — `RUN --mount=from=ghcr.io/astral-sh/uv` keeps `uv` out of the runtime image; build artifacts no longer persist (#437)
+- **Dockerfile collapsed to 2 stages on Python 3.13-alpine** — Removes an intermediate stage and the legacy Buster build path (#436)
+- **Test image rewritten as Alpine with Python 3.13** (#435)
+- **Removed stale Docker test infrastructure** — Old Compose files and fixture data carried over from the Protractor era (#434)
+- **Playwright E2E migrated to the official `mcr.microsoft.com/playwright` container** — Eliminates host-side browser install and `apt install` of system deps; image tag auto-pinned to the `@playwright/test` version from `package-lock.json` (#456)
+- **Build and Test (amd64) runs on develop pushes** — Populates the GHA cache so PRs against develop hit a warm cache instead of paying the install cost on every run (#456)
+- **Playwright browser cache + npm cache wired through `actions/cache`** (#452, #453)
+- **Python 3.12 → 3.13** in CI and Dockerfile (#451)
+- **README refreshed** to cover features through v0.17.0 — Notifications, Sonarr/Radarr, integrity verification, staging, API key (#454)
+- **Test count badges** added to the README (#451)
+- **Dependency updates** — Angular group (10 packages), typescript-eslint, jsdom, eslint, Docusaurus 3.10.0 → 3.10.1 across 5 packages (#458, #459, #460, #461, #463, #464, #465, #466)
+
+### Added
+
+- **LFTP hot-reload** — Connection-related settings (parallel connections, max total connections, socket buffer size, bandwidth limit) apply without a container restart. Settings UI distinguishes between options that take effect immediately and those that require restart (#433)
+- **Atomic config writes** — `Config.to_file()` now flushes to disk via temp file + `os.rename` so a process kill mid-write can't truncate the config (#433)
+- **Expanded unit and E2E test coverage:**
+ - 47 security middleware unit tests (#439)
+ - 36 controller core unit tests (#444)
+ - 28 FileOptions / Integrations / Option component tests (#448)
+ - 25 AutoQueueService and PathPairsService tests (#445)
+ - 20 HeaderComponent and VersionCheckService tests (#443)
+ - 18 ViewFileFilterService tests (#440)
+ - E2E for integrations CRUD (#441)
+ - E2E for File Actions & Error States (#438)
+ - E2E Settings coverage expansion (#442)
+ - Web App Job & Context Python tests (#446)
+ - Handler integration test expansion (#447)
+ - Python integration tests added to CI (#449)
+
+### Fixed
+
+- **Hash Algorithm select test flake** — Test waited on the wrong config field; the algorithm select's disable gate is `validate.enabled`, not `validate.xfer_verify`. Test now sets the correct field and waits for the SSE-delivered model value before asserting (#455)
+- **StreamHandler leaks in test setUp methods** — Tests added a handler to the shared root logger but never removed it. Loggers are singletons, so by test N the logger had N handlers and each log line printed N times. Fixed via `self.addCleanup(logger.removeHandler, handler)` across 8 test files / 10 setUp methods (#450, #457)
+- **Multiprocessing resource leak in `test_active_scanner.py`** — `scanner.close()` now registered with `addCleanup` so resources release even if assertions raise
+
+### Security
+
+- **`apk` and its package database are stripped from the runtime image** — `/sbin/apk`, `/etc/apk`, `/var/cache/apk`, `/lib/apk`, and the `libapk` shared libraries are removed in the runtime stage to keep the image under 64 MB (#437). This means in-image vulnerability scanners (Trivy, Grype) can't enumerate Alpine packages directly from a running container; consumers should scan the published `ghcr.io/nitrobass24/seedsync` image via SBOM or image-history tooling. Affected runtime packages: `lftp`, `openssh-client`, `ca-certificates`, `setpriv`, `libstdc++`. Derived images that need `apk add` should base on Alpine and reinstall what they need rather than extending this image.
+
## [0.17.0] - 2026-04-30
### Added
diff --git a/README.md b/README.md
index 50efc94e..807e0d15 100644
--- a/README.md
+++ b/README.md
@@ -25,8 +25,12 @@
-
+
+
+
+
+
SeedSync is a tool to sync files from a remote Linux server (like your seedbox) to your local machine.
@@ -36,16 +40,19 @@ It uses LFTP to transfer files fast!
## Features
-* Built on top of [LFTP](http://lftp.tech/), the fastest file transfer program
-* Web UI — track and control your transfers from anywhere
-* **Multiple path pairs** — sync from multiple remote directories independently
-* **Exclude patterns** — filter out unwanted files with glob patterns
-* **Multi-select** — select multiple files for bulk queue/stop/delete
-* Auto-Queue — only sync the files you want based on pattern matching
-* Automatically extract your files after sync
-* **Webhook notifications** — HTTP POST on download/extract events
-* Delete local and remote files easily
-* Dark mode, staging directory, bandwidth limiting, and more
+* Built on top of [LFTP](http://lftp.tech/) — the fastest file transfer tool around
+* Web UI — track and control transfers from any browser
+* **Multiple path pairs** — sync from independent remote/local directory pairs, each with its own settings
+* **Auto-Queue** — automatically queue files matching your patterns
+* **Exclude patterns** — filter out unwanted files (`*.nfo`, `Sample/`, etc.) per path pair
+* **Multi-select bulk actions** — queue, stop, or delete multiple files at once
+* **Auto-extract** — unpack RAR, ZIP, 7z, tar, gz, bz2, xz archives after download
+* **File integrity verification** — inline checksum during download plus optional post-download validation
+* **Notifications** — Discord, Telegram, or generic webhook on download/extract events
+* **Sonarr/Radarr integration** — trigger imports with multiple named instances, attached per path pair
+* **Staging directory** — land downloads on fast storage, then move to the final location
+* **Optional API key auth** with CSRF protection and rate limiting
+* **Dark mode**, bandwidth limiting, virtual scrolling for large libraries, and more
* **Lightweight Docker image** — ~45 MB Alpine-based, multi-arch (amd64/arm64)
* Fully open source!
@@ -121,6 +128,20 @@ SeedSync is available as a Community Application on Unraid.
> **Note**: PUID/PGID default to `99`/`100` (Unraid's `nobody`/`users`), which is correct for most Unraid setups.
+## Recommended Workflow
+
+The best way to use SeedSync is with **hard links** and a dedicated completion directory:
+
+1. **Configure your torrent client** (qBittorrent, ruTorrent, etc.) to hard link completed downloads into a separate folder (e.g., `/downloads/complete`). Hard links don't use extra disk space — your originals stay intact for seeding.
+2. **Point SeedSync** at the completion directory.
+3. **Enable Auto-Queue** and turn on **"Delete remote file after syncing"** in Settings.
+
+This way, each file is downloaded exactly once. After SeedSync syncs it, the hard link is removed from the completion directory, so it's never re-downloaded — even after a container restart. Your originals remain untouched for seeding.
+
+> **Note**: Both directories must be on the same filesystem for hard links to work.
+
+See the [full setup guide](https://nitrobass24.github.io/seedsync/usage#recommended-setup) in the docs for directory layout examples and torrent client configuration.
+
## Configuration
On first run, access the web UI and configure:
@@ -149,19 +170,33 @@ You can limit download speed in Settings under the **Connections** section. The
- Values with suffixes: `K` for KB/s, `M` for MB/s (e.g., `500K`, `2M`)
- `0` or empty for unlimited
-## Recommended Workflow
+### Notifications
-The best way to use SeedSync is with **hard links** and a dedicated completion directory:
+SeedSync can notify you when downloads or extractions finish. Configure destinations in **Settings → Notifications**:
-1. **Configure your torrent client** (qBittorrent, ruTorrent, etc.) to hard link completed downloads into a separate folder (e.g., `/downloads/complete`). Hard links don't use extra disk space — your originals stay intact for seeding.
-2. **Point SeedSync** at the completion directory.
-3. **Enable Auto-Queue** and turn on **"Delete remote file after syncing"** in Settings.
+- **Discord** — paste a webhook URL; events arrive as color-coded embeds
+- **Telegram** — provide a bot token and chat ID
+- **Generic webhook** — POST a JSON payload to any HTTP(S) URL
-This way, each file is downloaded exactly once. After SeedSync syncs it, the hard link is removed from the completion directory, so it's never re-downloaded — even after a container restart. Your originals remain untouched for seeding.
+Each destination has a **Test** button to verify it's working.
-> **Note**: Both directories must be on the same filesystem for hard links to work.
+### Sonarr / Radarr
-See the [full setup guide](https://nitrobass24.github.io/seedsync/usage#recommended-setup) in the docs for directory layout examples and torrent client configuration.
+To trigger automatic imports after downloads complete, configure **Settings → Integrations**:
+
+1. Click **Add instance**, choose Sonarr or Radarr, and enter the base URL and API key
+2. Use **Test connection** to verify credentials
+3. In the **Path Pairs** card, attach instances to the path pair(s) where imports should fire
+
+You can mix and match — for example, attach a 4K path pair only to your 4K Radarr instance.
+
+### Staging Directory
+
+For setups with fast scratch storage (NVMe/SSD) and slower bulk storage, enable **Staging** in Settings. Downloads complete on the staging volume first, then move to the final location. Mount your staging path at `/staging` in the container.
+
+### API Key
+
+To require authentication on the web UI and API, set an **API Key** in **Settings → Other Settings**. When set, browser clients prompt for the key on first load and API clients send it via the `X-API-Key` header. Leave it blank to run without auth (fine behind a reverse proxy or on a trusted network).
## Building from Source
@@ -192,6 +227,7 @@ make logs
|------|-------------|
| `/config` | Configuration and state files |
| `/downloads` | Download destination directory |
+| `/staging` | Staging directory (optional, mount when staging is enabled) |
| `/home/seedsync/.ssh/id_rsa` | SSH private key (optional, for key-based auth) |
## Ports
diff --git a/src/angular/package-lock.json b/src/angular/package-lock.json
index d00736e5..c7b5700e 100644
--- a/src/angular/package-lock.json
+++ b/src/angular/package-lock.json
@@ -1,20 +1,20 @@
{
"name": "seedsync",
- "version": "0.14.4",
+ "version": "0.17.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "seedsync",
- "version": "0.14.4",
- "dependencies": {
- "@angular/cdk": "^21.2.8",
- "@angular/common": "^21.2.10",
- "@angular/compiler": "^21.2.10",
- "@angular/core": "^21.2.10",
- "@angular/forms": "^21.2.10",
- "@angular/platform-browser": "^21.2.10",
- "@angular/router": "^21.2.10",
+ "version": "0.17.0",
+ "dependencies": {
+ "@angular/cdk": "^21.2.9",
+ "@angular/common": "^21.2.11",
+ "@angular/compiler": "^21.2.11",
+ "@angular/core": "^21.2.11",
+ "@angular/forms": "^21.2.11",
+ "@angular/platform-browser": "^21.2.11",
+ "@angular/router": "^21.2.11",
"@fortawesome/fontawesome-free": "^7.1.0",
"bootstrap": "^5.3.8",
"compare-versions": "^6.1.1",
@@ -22,15 +22,15 @@
"tslib": "^2.3.0"
},
"devDependencies": {
- "@angular/build": "^21.2.8",
- "@angular/cli": "^21.2.8",
- "@angular/compiler-cli": "^21.2.10",
+ "@angular/build": "^21.2.9",
+ "@angular/cli": "^21.2.9",
+ "@angular/compiler-cli": "^21.2.11",
"@eslint/js": "^10.0.1",
"angular-eslint": "21.3.1",
- "eslint": "^10.0.3",
- "jsdom": "^29.0.1",
+ "eslint": "^10.3.0",
+ "jsdom": "^29.1.1",
"typescript": "~5.9.2",
- "typescript-eslint": "8.59.0",
+ "typescript-eslint": "8.59.1",
"vitest": "^4.1.5"
}
},
@@ -258,13 +258,13 @@
}
},
"node_modules/@angular-devkit/architect": {
- "version": "0.2102.8",
- "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.8.tgz",
- "integrity": "sha512-b7su7AHIO5F2I6InEu/Bx/oXvGjdCP7kos2tGX73he/lPrTuizooils62OgAzgJ2UeKscyRNUjBPieFCy6XvHQ==",
+ "version": "0.2102.9",
+ "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.9.tgz",
+ "integrity": "sha512-OlPEtd5pPZSFdkXEIyZ93jsfBrkvUrVPb3xs4z2WPRnBRk9jyey40eKnmql86KRHfdn4WjHpmde4NDgtDpZRxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@angular-devkit/core": "21.2.8",
+ "@angular-devkit/core": "21.2.9",
"rxjs": "7.8.2"
},
"bin": {
@@ -277,9 +277,9 @@
}
},
"node_modules/@angular-devkit/core": {
- "version": "21.2.8",
- "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.8.tgz",
- "integrity": "sha512-DyxCILaaic/hfcfiBjAC/SdKE1ybSQIrU62/K5Msn3gZtThZj/T7cG0VHfbmpEFcgYkrQ9caUt6MCg8OoOVDzw==",
+ "version": "21.2.9",
+ "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.9.tgz",
+ "integrity": "sha512-04rdOGEzjLWFHlyAwqtuikginFeQ2jfXS5HqqKNP0VtG6Uu9NUDAEW5UDvXgqkEMfCDwGZbmg2iRHxp3AmAKVw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -305,13 +305,13 @@
}
},
"node_modules/@angular-devkit/schematics": {
- "version": "21.2.8",
- "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.8.tgz",
- "integrity": "sha512-UTEMM1JXzzxufLsTGDsWth2E7+8e9PaFT7nbjUvJ2qevltACkiqAbHEpiD2ISzrSRIO3OirJ+cZtnzXO0FyoBQ==",
+ "version": "21.2.9",
+ "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.9.tgz",
+ "integrity": "sha512-Gyyuq2Vet70AMkbC+e0L6rjzjZWjSOyKTlOJvd99GjjyWQf6eezjd8IcF17ppKJsML6YUagO2I6AlWROq5yJmg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@angular-devkit/core": "21.2.8",
+ "@angular-devkit/core": "21.2.9",
"jsonc-parser": "3.3.1",
"magic-string": "0.30.21",
"ora": "9.3.0",
@@ -433,14 +433,14 @@
}
},
"node_modules/@angular/build": {
- "version": "21.2.8",
- "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.8.tgz",
- "integrity": "sha512-t0PHT7ONDMLwcjC9GaClNF+gsUKN78ofBikw4huiu6np5Rwmxp8KKCrdoRx20lOiibSolXgjZ2Ny0xxjNdNdQA==",
+ "version": "21.2.9",
+ "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.9.tgz",
+ "integrity": "sha512-XYP5ALB56NWvcQisznmvQdVU6WJdUCAuCAEN2eDZNVd9X1IqRNfewQfFH6FyHo7SrK4GHDReqm6xWW6rs0+weQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "2.3.0",
- "@angular-devkit/architect": "0.2102.8",
+ "@angular-devkit/architect": "0.2102.9",
"@babel/core": "7.29.0",
"@babel/helper-annotate-as-pure": "7.27.3",
"@babel/helper-split-export-declaration": "7.24.7",
@@ -483,7 +483,7 @@
"@angular/platform-browser": "^21.0.0",
"@angular/platform-server": "^21.0.0",
"@angular/service-worker": "^21.0.0",
- "@angular/ssr": "^21.2.8",
+ "@angular/ssr": "^21.2.9",
"karma": "^6.4.0",
"less": "^4.2.0",
"ng-packagr": "^21.0.0",
@@ -533,9 +533,9 @@
}
},
"node_modules/@angular/cdk": {
- "version": "21.2.8",
- "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.8.tgz",
- "integrity": "sha512-WdvMLpuFcRgDWLDyin3sw5a65PQYdI0Y+4BxiMxOkesoZ2RZTBAlLKIfQ9Nz5CY3LamUTO3Qel2T8ZhJ+Cqfuw==",
+ "version": "21.2.9",
+ "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.9.tgz",
+ "integrity": "sha512-0JXsr8f7xjV2815esTSq4+zGqWMa0CyNT/DV1F7lYS6qkYXcFdYUzGcd/WjNL05VKkajkSkWmTi6uyVsOpYdGA==",
"license": "MIT",
"dependencies": {
"parse5": "^8.0.0",
@@ -549,19 +549,19 @@
}
},
"node_modules/@angular/cli": {
- "version": "21.2.8",
- "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.8.tgz",
- "integrity": "sha512-Y+/US12o+7X2774oeKPsEfHeeYM2SxwnyoXfcaLR8vrMn0zxUrhHebmlz9h83th4EJEuex1Qk0JtF7j5vcwrqQ==",
+ "version": "21.2.9",
+ "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.9.tgz",
+ "integrity": "sha512-KldNb7vCEVOeyEUK57dguP3dTjYeikBmAohjAouu8JLtY8OOI+tf/TA31Gco/rxZ3nGqBwkvrqpD4rcDf5AhUA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@angular-devkit/architect": "0.2102.8",
- "@angular-devkit/core": "21.2.8",
- "@angular-devkit/schematics": "21.2.8",
+ "@angular-devkit/architect": "0.2102.9",
+ "@angular-devkit/core": "21.2.9",
+ "@angular-devkit/schematics": "21.2.9",
"@inquirer/prompts": "7.10.1",
"@listr2/prompt-adapter-inquirer": "3.0.5",
"@modelcontextprotocol/sdk": "1.26.0",
- "@schematics/angular": "21.2.8",
+ "@schematics/angular": "21.2.9",
"@yarnpkg/lockfile": "1.1.0",
"algoliasearch": "5.48.1",
"ini": "6.0.0",
@@ -584,9 +584,9 @@
}
},
"node_modules/@angular/common": {
- "version": "21.2.10",
- "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.10.tgz",
- "integrity": "sha512-WLyi/CRLtgALg2mmaqIuKuPnE4i+8PGt/uuz26pVqx+ASh28/TWr5KSCAMomgxEc8kt4OE7lopoQsTihrQCfEw==",
+ "version": "21.2.11",
+ "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.11.tgz",
+ "integrity": "sha512-3Z3SABXpzM6fkX21WCRP6IwrjxNQVHM/3Fk2OXScExOAzpaOpS2bDgS4NB6rtCbmzKL/NFSp7ZPIZigfdqnWGw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
@@ -595,14 +595,14 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
- "@angular/core": "21.2.10",
+ "@angular/core": "21.2.11",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/compiler": {
- "version": "21.2.10",
- "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.10.tgz",
- "integrity": "sha512-IrgdFuzzD7NTK3WQaSfowjAPxPbnTqsgR92NsOs5ZaWu3RgLl21dHThNc0BK1KwVwppLUSWmD4qePbcLW71VzQ==",
+ "version": "21.2.11",
+ "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.11.tgz",
+ "integrity": "sha512-/KdE0kPQr24K/aNsdIDS2or555+8CrQxyRB5MxPKy3/8d6EvilEY/UN7pB7A5xgRQtUPMea08ZzLFJVp1qNbDA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
@@ -612,9 +612,9 @@
}
},
"node_modules/@angular/compiler-cli": {
- "version": "21.2.10",
- "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.10.tgz",
- "integrity": "sha512-FDcnj3ogRmnTca4m2GbKP2khFOCtoVvWDZyfw2ZCPAf+zsQlKTyscKvx4GpTFo+KHrYXpawUpDIWHORFpuqFEA==",
+ "version": "21.2.11",
+ "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.11.tgz",
+ "integrity": "sha512-qp/LgptDYJvpEHVVdwBEtkcbybre/ftanu0qJMpH3mu5FC4HEEOChl+9m7UVrmL4jC1ZkoZcgtzsGKAQr8mw2g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -635,7 +635,7 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
- "@angular/compiler": "21.2.10",
+ "@angular/compiler": "21.2.11",
"typescript": ">=5.9 <6.1"
},
"peerDependenciesMeta": {
@@ -645,9 +645,9 @@
}
},
"node_modules/@angular/core": {
- "version": "21.2.10",
- "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.10.tgz",
- "integrity": "sha512-uxH+mbPiCE7rInWKYOPe9Ytas97+mFM6FhFORoN234yBK3b8he+iDuxX6dsbhEFCxhRmfS6hLxe7BdLY6U6kIA==",
+ "version": "21.2.11",
+ "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.11.tgz",
+ "integrity": "sha512-EULAfQ0m/I9hZJes74OFlrnfDWqlfV0esE0CkHehO5IEF9rd769+dfuGEAJAzrz+/6Q3PhS0bWDYiT68z1H8Ag==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
@@ -656,7 +656,7 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
- "@angular/compiler": "21.2.10",
+ "@angular/compiler": "21.2.11",
"rxjs": "^6.5.3 || ^7.4.0",
"zone.js": "~0.15.0 || ~0.16.0"
},
@@ -670,9 +670,9 @@
}
},
"node_modules/@angular/forms": {
- "version": "21.2.10",
- "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.10.tgz",
- "integrity": "sha512-XOo9qkuBqCLzSBXmyga9ke2tSulxWl+E7Y9Uwqgz8sJtQUlyP/0GYJfu60jiC3NAYobk9K/6h6MsU8zftQKdaA==",
+ "version": "21.2.11",
+ "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.11.tgz",
+ "integrity": "sha512-F67V612wHxPXHrbp825VirYfGPKBUM8PvL9atN2Ku1fsdGSFPU3hTxu1HU8fKYLLBpKYVVuqFqzaU/qIpTXGYA==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@@ -682,16 +682,16 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
- "@angular/common": "21.2.10",
- "@angular/core": "21.2.10",
- "@angular/platform-browser": "21.2.10",
+ "@angular/common": "21.2.11",
+ "@angular/core": "21.2.11",
+ "@angular/platform-browser": "21.2.11",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/platform-browser": {
- "version": "21.2.10",
- "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.10.tgz",
- "integrity": "sha512-5WMoHGU8BOV3eO9h3vGMIUDPf+3SHis7+X2dHKMtKfFBUtiO8m/lq2x3PzkkKj1782i7KYt92EqPHuADd/eWOw==",
+ "version": "21.2.11",
+ "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.11.tgz",
+ "integrity": "sha512-Uz/KwGjSEvbE8J9kNSSetzxhBWjCXv9OuxH1w2WkW6jLNU3vgvzuKX7SXDyUys6KJv5TqkClJ9BLeU11QbmJdw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
@@ -700,9 +700,9 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
- "@angular/animations": "21.2.10",
- "@angular/common": "21.2.10",
- "@angular/core": "21.2.10"
+ "@angular/animations": "21.2.11",
+ "@angular/common": "21.2.11",
+ "@angular/core": "21.2.11"
},
"peerDependenciesMeta": {
"@angular/animations": {
@@ -711,9 +711,9 @@
}
},
"node_modules/@angular/router": {
- "version": "21.2.10",
- "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.10.tgz",
- "integrity": "sha512-4cHHwewIhFEAAaRgJ80371EOtNlydFHbjj/UENLZitjU0azal0mfFCBdkaEdVehd7+mH5xO7MRjy6eFTcTYR5Q==",
+ "version": "21.2.11",
+ "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.11.tgz",
+ "integrity": "sha512-IB7/KuRDsxAjCOxYNccq2LdCTKuu59cx5MmOhrt+TarvkNE/xdlFkP7vtrCl44DJt0q7/tveWvsn5oqTw7rN7A==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
@@ -722,21 +722,22 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
- "@angular/common": "21.2.10",
- "@angular/core": "21.2.10",
- "@angular/platform-browser": "21.2.10",
+ "@angular/common": "21.2.11",
+ "@angular/core": "21.2.11",
+ "@angular/platform-browser": "21.2.11",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@asamuzakjp/css-color": {
- "version": "5.1.8",
- "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.8.tgz",
- "integrity": "sha512-OISPR9c2uPo23rUdvfEQiLPjoMLOpEeLNnP5iGkxr6tDDxJd3NjD+6fxY0mdaMbIPUjFGL4HFOJqLvow5q4aqQ==",
+ "version": "5.1.11",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
+ "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@csstools/css-calc": "^3.1.1",
- "@csstools/css-color-parser": "^4.0.2",
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@csstools/css-calc": "^3.2.0",
+ "@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
@@ -745,12 +746,13 @@
}
},
"node_modules/@asamuzakjp/dom-selector": {
- "version": "7.0.8",
- "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.8.tgz",
- "integrity": "sha512-erMO6FgtM02dC24NGm0xufMzWz5OF0wXKR7BpvGD973bq/GbmR8/DbxNZbj0YevQ5hlToJaWSVK/G9/NDgGEVw==",
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
+ "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
@@ -760,6 +762,16 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
+ "node_modules/@asamuzakjp/generational-cache": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
+ "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
@@ -1094,9 +1106,9 @@
}
},
"node_modules/@csstools/css-calc": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
- "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
+ "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
"dev": true,
"funding": [
{
@@ -1118,9 +1130,9 @@
}
},
"node_modules/@csstools/css-color-parser": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz",
- "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==",
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
+ "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
"dev": true,
"funding": [
{
@@ -1135,7 +1147,7 @@
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.0.2",
- "@csstools/css-calc": "^3.1.1"
+ "@csstools/css-calc": "^3.2.0"
},
"engines": {
"node": ">=20.19.0"
@@ -1169,9 +1181,9 @@
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz",
- "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==",
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz",
+ "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==",
"dev": true,
"funding": [
{
@@ -4060,14 +4072,14 @@
]
},
"node_modules/@schematics/angular": {
- "version": "21.2.8",
- "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.8.tgz",
- "integrity": "sha512-Kx3PmuZIXhwQqAqoERAXqDCORHFbKTMd+eflXwZfpKkrbWJTVPqKpL4R9RVdEr2E6/VEXDFrdL1whIvGd1xmDg==",
+ "version": "21.2.9",
+ "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.9.tgz",
+ "integrity": "sha512-1renEbBZz9Yw3A0GUOJ6x6E1jd2Vu/fX5tEGiFNbIoWaNwa71SlFTvKKqaYxiYQkrpc7oexVJ2ymuvOfgTbI1w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@angular-devkit/core": "21.2.8",
- "@angular-devkit/schematics": "21.2.8",
+ "@angular-devkit/core": "21.2.9",
+ "@angular-devkit/schematics": "21.2.9",
"jsonc-parser": "3.3.1"
},
"engines": {
@@ -4237,17 +4249,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
- "version": "8.59.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz",
- "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==",
+ "version": "8.59.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
+ "integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
- "@typescript-eslint/scope-manager": "8.59.0",
- "@typescript-eslint/type-utils": "8.59.0",
- "@typescript-eslint/utils": "8.59.0",
- "@typescript-eslint/visitor-keys": "8.59.0",
+ "@typescript-eslint/scope-manager": "8.59.1",
+ "@typescript-eslint/type-utils": "8.59.1",
+ "@typescript-eslint/utils": "8.59.1",
+ "@typescript-eslint/visitor-keys": "8.59.1",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -4260,22 +4272,22 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
- "@typescript-eslint/parser": "^8.59.0",
+ "@typescript-eslint/parser": "^8.59.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser": {
- "version": "8.59.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz",
- "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==",
+ "version": "8.59.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz",
+ "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/scope-manager": "8.59.0",
- "@typescript-eslint/types": "8.59.0",
- "@typescript-eslint/typescript-estree": "8.59.0",
- "@typescript-eslint/visitor-keys": "8.59.0",
+ "@typescript-eslint/scope-manager": "8.59.1",
+ "@typescript-eslint/types": "8.59.1",
+ "@typescript-eslint/typescript-estree": "8.59.1",
+ "@typescript-eslint/visitor-keys": "8.59.1",
"debug": "^4.4.3"
},
"engines": {
@@ -4291,14 +4303,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
- "version": "8.59.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz",
- "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==",
+ "version": "8.59.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz",
+ "integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/tsconfig-utils": "^8.59.0",
- "@typescript-eslint/types": "^8.59.0",
+ "@typescript-eslint/tsconfig-utils": "^8.59.1",
+ "@typescript-eslint/types": "^8.59.1",
"debug": "^4.4.3"
},
"engines": {
@@ -4313,14 +4325,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
- "version": "8.59.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz",
- "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==",
+ "version": "8.59.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz",
+ "integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.59.0",
- "@typescript-eslint/visitor-keys": "8.59.0"
+ "@typescript-eslint/types": "8.59.1",
+ "@typescript-eslint/visitor-keys": "8.59.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4331,9 +4343,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
- "version": "8.59.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz",
- "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==",
+ "version": "8.59.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz",
+ "integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4348,15 +4360,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
- "version": "8.59.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz",
- "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==",
+ "version": "8.59.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz",
+ "integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.59.0",
- "@typescript-eslint/typescript-estree": "8.59.0",
- "@typescript-eslint/utils": "8.59.0",
+ "@typescript-eslint/types": "8.59.1",
+ "@typescript-eslint/typescript-estree": "8.59.1",
+ "@typescript-eslint/utils": "8.59.1",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -4373,9 +4385,9 @@
}
},
"node_modules/@typescript-eslint/types": {
- "version": "8.59.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz",
- "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==",
+ "version": "8.59.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz",
+ "integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4387,16 +4399,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
- "version": "8.59.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz",
- "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==",
+ "version": "8.59.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz",
+ "integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/project-service": "8.59.0",
- "@typescript-eslint/tsconfig-utils": "8.59.0",
- "@typescript-eslint/types": "8.59.0",
- "@typescript-eslint/visitor-keys": "8.59.0",
+ "@typescript-eslint/project-service": "8.59.1",
+ "@typescript-eslint/tsconfig-utils": "8.59.1",
+ "@typescript-eslint/types": "8.59.1",
+ "@typescript-eslint/visitor-keys": "8.59.1",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -4415,16 +4427,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "8.59.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz",
- "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==",
+ "version": "8.59.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz",
+ "integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
- "@typescript-eslint/scope-manager": "8.59.0",
- "@typescript-eslint/types": "8.59.0",
- "@typescript-eslint/typescript-estree": "8.59.0"
+ "@typescript-eslint/scope-manager": "8.59.1",
+ "@typescript-eslint/types": "8.59.1",
+ "@typescript-eslint/typescript-estree": "8.59.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -4439,13 +4451,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
- "version": "8.59.0",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz",
- "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==",
+ "version": "8.59.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz",
+ "integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/types": "8.59.0",
+ "@typescript-eslint/types": "8.59.1",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -5729,9 +5741,9 @@
}
},
"node_modules/eslint": {
- "version": "10.2.1",
- "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz",
- "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==",
+ "version": "10.3.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz",
+ "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6773,28 +6785,28 @@
"license": "MIT"
},
"node_modules/jsdom": {
- "version": "29.0.2",
- "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz",
- "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==",
+ "version": "29.1.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
+ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@asamuzakjp/css-color": "^5.1.5",
- "@asamuzakjp/dom-selector": "^7.0.6",
+ "@asamuzakjp/css-color": "^5.1.11",
+ "@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
- "@csstools/css-syntax-patches-for-csstree": "^1.1.1",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
- "lru-cache": "^11.2.7",
- "parse5": "^8.0.0",
+ "lru-cache": "^11.3.5",
+ "parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
- "undici": "^7.24.5",
+ "undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
@@ -6814,9 +6826,9 @@
}
},
"node_modules/jsdom/node_modules/lru-cache": {
- "version": "11.2.7",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
- "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
+ "version": "11.3.5",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz",
+ "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
@@ -6824,9 +6836,9 @@
}
},
"node_modules/jsdom/node_modules/undici": {
- "version": "7.24.5",
- "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz",
- "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==",
+ "version": "7.25.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
+ "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7892,12 +7904,12 @@
}
},
"node_modules/parse5": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
- "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"license": "MIT",
"dependencies": {
- "entities": "^6.0.0"
+ "entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
@@ -7945,12 +7957,12 @@
}
},
"node_modules/parse5/node_modules/entities": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
- "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"license": "BSD-2-Clause",
"engines": {
- "node": ">=0.12"
+ "node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
@@ -9121,16 +9133,16 @@
}
},
"node_modules/typescript-eslint": {
- "version": "8.59.0",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz",
- "integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==",
+ "version": "8.59.1",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz",
+ "integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/eslint-plugin": "8.59.0",
- "@typescript-eslint/parser": "8.59.0",
- "@typescript-eslint/typescript-estree": "8.59.0",
- "@typescript-eslint/utils": "8.59.0"
+ "@typescript-eslint/eslint-plugin": "8.59.1",
+ "@typescript-eslint/parser": "8.59.1",
+ "@typescript-eslint/typescript-estree": "8.59.1",
+ "@typescript-eslint/utils": "8.59.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
diff --git a/src/angular/package.json b/src/angular/package.json
index 68b044e4..1020f5f8 100644
--- a/src/angular/package.json
+++ b/src/angular/package.json
@@ -1,6 +1,6 @@
{
"name": "seedsync",
- "version": "0.17.0",
+ "version": "0.18.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
@@ -24,13 +24,13 @@
"private": true,
"packageManager": "npm@10.9.2",
"dependencies": {
- "@angular/cdk": "^21.2.8",
- "@angular/common": "^21.2.10",
- "@angular/compiler": "^21.2.10",
- "@angular/core": "^21.2.10",
- "@angular/forms": "^21.2.10",
- "@angular/platform-browser": "^21.2.10",
- "@angular/router": "^21.2.10",
+ "@angular/cdk": "^21.2.9",
+ "@angular/common": "^21.2.11",
+ "@angular/compiler": "^21.2.11",
+ "@angular/core": "^21.2.11",
+ "@angular/forms": "^21.2.11",
+ "@angular/platform-browser": "^21.2.11",
+ "@angular/router": "^21.2.11",
"@fortawesome/fontawesome-free": "^7.1.0",
"bootstrap": "^5.3.8",
"compare-versions": "^6.1.1",
@@ -38,15 +38,15 @@
"tslib": "^2.3.0"
},
"devDependencies": {
- "@angular/build": "^21.2.8",
- "@angular/cli": "^21.2.8",
- "@angular/compiler-cli": "^21.2.10",
+ "@angular/build": "^21.2.9",
+ "@angular/cli": "^21.2.9",
+ "@angular/compiler-cli": "^21.2.11",
"@eslint/js": "^10.0.1",
"angular-eslint": "21.3.1",
- "eslint": "^10.0.3",
- "jsdom": "^29.0.1",
+ "eslint": "^10.3.0",
+ "jsdom": "^29.1.1",
"typescript": "~5.9.2",
- "typescript-eslint": "8.59.0",
+ "typescript-eslint": "8.59.1",
"vitest": "^4.1.5"
}
}
diff --git a/src/angular/src/app/pages/files/file-options.component.spec.ts b/src/angular/src/app/pages/files/file-options.component.spec.ts
new file mode 100644
index 00000000..ff1139fa
--- /dev/null
+++ b/src/angular/src/app/pages/files/file-options.component.spec.ts
@@ -0,0 +1,156 @@
+import '@angular/compiler';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { TestBed } from '@angular/core/testing';
+import { BehaviorSubject, of } from 'rxjs';
+
+import { FileOptionsComponent } from './file-options.component';
+import { ViewFileOptionsService } from '../../services/files/view-file-options.service';
+import { ViewFileService } from '../../services/files/view-file.service';
+import { DomService } from '../../services/utils/dom.service';
+import { ViewFile, ViewFileStatus } from '../../models/view-file';
+import { ViewFileOptions, SortMethod } from '../../models/view-file-options';
+
+function makeViewFile(overrides: Partial = {}): ViewFile {
+ return {
+ name: 'TestFile.txt',
+ pairId: null,
+ pairName: null,
+ isDir: false,
+ localSize: 0,
+ remoteSize: 100,
+ percentDownloaded: 0,
+ status: ViewFileStatus.DEFAULT,
+ downloadingSpeed: 0,
+ eta: 0,
+ fullPath: '/TestFile.txt',
+ isArchive: false,
+ isSelected: false,
+ isChecked: false,
+ isQueueable: true,
+ isStoppable: false,
+ isExtractable: false,
+ isLocallyDeletable: false,
+ isRemotelyDeletable: true,
+ isValidatable: false,
+ validateTooltip: null,
+ localCreatedTimestamp: null,
+ localModifiedTimestamp: null,
+ remoteCreatedTimestamp: null,
+ remoteModifiedTimestamp: null,
+ ...overrides,
+ };
+}
+
+function makeOptions(overrides: Partial = {}): ViewFileOptions {
+ return {
+ showDetails: false,
+ sortMethod: SortMethod.STATUS,
+ selectedStatusFilter: null,
+ nameFilter: '',
+ pinFilter: false,
+ ...overrides,
+ };
+}
+
+describe('FileOptionsComponent', () => {
+ let component: FileOptionsComponent;
+ let filesSubject: BehaviorSubject;
+ let optionsSubject: BehaviorSubject;
+ let mockViewFileOptionsService: {
+ options$: ReturnType;
+ setNameFilter: ReturnType;
+ setSelectedStatusFilter: ReturnType;
+ setSortMethod: ReturnType;
+ setShowDetails: ReturnType;
+ setPinFilter: ReturnType;
+ };
+
+ beforeEach(() => {
+ filesSubject = new BehaviorSubject([]);
+ optionsSubject = new BehaviorSubject(makeOptions());
+
+ mockViewFileOptionsService = {
+ options$: optionsSubject.asObservable(),
+ setNameFilter: vi.fn(),
+ setSelectedStatusFilter: vi.fn(),
+ setSortMethod: vi.fn(),
+ setShowDetails: vi.fn(),
+ setPinFilter: vi.fn(),
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ { provide: ViewFileOptionsService, useValue: mockViewFileOptionsService },
+ { provide: ViewFileService, useValue: { files$: filesSubject.asObservable() } },
+ { provide: DomService, useValue: { headerHeight$: of(0) } },
+ ],
+ });
+
+ const fixture = TestBed.createComponent(FileOptionsComponent);
+ component = fixture.componentInstance;
+ component.ngOnInit();
+ });
+
+ // --- Status filter enablement ---
+
+ it('should disable all status filters when file list is empty', () => {
+ filesSubject.next([]);
+ expect(component.isDownloadingStatusEnabled).toBe(false);
+ expect(component.isDownloadedStatusEnabled).toBe(false);
+ expect(component.isQueuedStatusEnabled).toBe(false);
+ expect(component.isStoppedStatusEnabled).toBe(false);
+ expect(component.isExtractedStatusEnabled).toBe(false);
+ expect(component.isExtractingStatusEnabled).toBe(false);
+ });
+
+ it('should enable DOWNLOADING status filter when a downloading file exists', () => {
+ filesSubject.next([makeViewFile({ status: ViewFileStatus.DOWNLOADING })]);
+ expect(component.isDownloadingStatusEnabled).toBe(true);
+ expect(component.isDownloadedStatusEnabled).toBe(false);
+ });
+
+ it('should enable multiple status filters based on file list contents', () => {
+ filesSubject.next([
+ makeViewFile({ name: 'a', status: ViewFileStatus.DOWNLOADING }),
+ makeViewFile({ name: 'b', status: ViewFileStatus.QUEUED }),
+ makeViewFile({ name: 'c', status: ViewFileStatus.EXTRACTED }),
+ ]);
+ expect(component.isDownloadingStatusEnabled).toBe(true);
+ expect(component.isQueuedStatusEnabled).toBe(true);
+ expect(component.isExtractedStatusEnabled).toBe(true);
+ expect(component.isDownloadedStatusEnabled).toBe(false);
+ expect(component.isStoppedStatusEnabled).toBe(false);
+ });
+
+ it('should update enablement reactively when file list changes', () => {
+ filesSubject.next([makeViewFile({ status: ViewFileStatus.DOWNLOADING })]);
+ expect(component.isDownloadingStatusEnabled).toBe(true);
+
+ filesSubject.next([makeViewFile({ status: ViewFileStatus.QUEUED })]);
+ expect(component.isDownloadingStatusEnabled).toBe(false);
+ expect(component.isQueuedStatusEnabled).toBe(true);
+ });
+
+ // --- Delegation to ViewFileOptionsService ---
+
+ it('should delegate name filter changes to ViewFileOptionsService', () => {
+ component.onFilterByName('test');
+ expect(mockViewFileOptionsService.setNameFilter).toHaveBeenCalledWith('test');
+ });
+
+ it('should delegate status filter changes to ViewFileOptionsService', () => {
+ component.onFilterByStatus(ViewFileStatus.DOWNLOADING);
+ expect(mockViewFileOptionsService.setSelectedStatusFilter).toHaveBeenCalledWith(ViewFileStatus.DOWNLOADING);
+ });
+
+ it('should delegate sort method changes to ViewFileOptionsService', () => {
+ component.onSort(SortMethod.NAME_ASC);
+ expect(mockViewFileOptionsService.setSortMethod).toHaveBeenCalledWith(SortMethod.NAME_ASC);
+ });
+
+ it('should toggle showDetails via ViewFileOptionsService', () => {
+ // Initial showDetails is false (from makeOptions default)
+ component.onToggleShowDetails();
+ expect(mockViewFileOptionsService.setShowDetails).toHaveBeenCalledWith(true);
+ });
+});
diff --git a/src/angular/src/app/pages/main/header.component.spec.ts b/src/angular/src/app/pages/main/header.component.spec.ts
new file mode 100644
index 00000000..0954a3a9
--- /dev/null
+++ b/src/angular/src/app/pages/main/header.component.spec.ts
@@ -0,0 +1,242 @@
+import '@angular/compiler';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { TestBed } from '@angular/core/testing';
+import { BehaviorSubject } from 'rxjs';
+
+import { HeaderComponent } from './header.component';
+import { ServerStatusService } from '../../services/server/server-status.service';
+import { NotificationService } from '../../services/utils/notification.service';
+import { LoggerService } from '../../services/utils/logger.service';
+import { ServerStatus } from '../../models/server-status';
+import { Notification, NotificationLevel } from '../../models/notification';
+import { Localization } from '../../models/localization';
+
+function makeStatus(overrides: Partial<{
+ server: Partial;
+ controller: Partial;
+}> = {}): ServerStatus {
+ return {
+ server: {
+ up: true,
+ errorMessage: null,
+ ...overrides.server,
+ },
+ controller: {
+ latestLocalScanTime: null,
+ latestRemoteScanTime: new Date(),
+ latestRemoteScanFailed: false,
+ latestRemoteScanError: null,
+ noEnabledPairs: false,
+ ...overrides.controller,
+ },
+ };
+}
+
+describe('HeaderComponent', () => {
+ let component: HeaderComponent;
+ let statusSubject: BehaviorSubject;
+ let notificationService: NotificationService;
+
+ beforeEach(() => {
+ statusSubject = new BehaviorSubject(makeStatus());
+
+ TestBed.configureTestingModule({
+ providers: [
+ { provide: ServerStatusService, useValue: { status$: statusSubject.asObservable() } },
+ NotificationService,
+ { provide: LoggerService, useValue: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } },
+ ],
+ });
+
+ notificationService = TestBed.inject(NotificationService);
+ const fixture = TestBed.createComponent(HeaderComponent);
+ component = fixture.componentInstance;
+ component.ngOnInit();
+ });
+
+ // --- Server down ---
+
+ it('should show DANGER notification when server is down', () => {
+ let notifications: Notification[] = [];
+ notificationService.notifications$.subscribe(n => notifications = n);
+
+ statusSubject.next(makeStatus({ server: { up: false, errorMessage: 'Connection refused' } }));
+
+ expect(notifications.length).toBeGreaterThanOrEqual(1);
+ const danger = notifications.find(n => n.level === NotificationLevel.DANGER);
+ expect(danger).toBeDefined();
+ expect(danger!.text).toBe('Connection refused');
+ });
+
+ it('should hide server notification when server recovers', () => {
+ let notifications: Notification[] = [];
+ notificationService.notifications$.subscribe(n => notifications = n);
+
+ statusSubject.next(makeStatus({ server: { up: false, errorMessage: 'Down' } }));
+ expect(notifications.some(n => n.level === NotificationLevel.DANGER)).toBe(true);
+
+ statusSubject.next(makeStatus({ server: { up: true, errorMessage: null } }));
+ expect(notifications.some(n => n.level === NotificationLevel.DANGER)).toBe(false);
+ });
+
+ // --- Waiting for remote scan ---
+
+ it('should show INFO notification when no remote scan yet and pairs exist', () => {
+ let notifications: Notification[] = [];
+ notificationService.notifications$.subscribe(n => notifications = n);
+
+ statusSubject.next(makeStatus({
+ server: { up: true },
+ controller: { latestRemoteScanTime: null, noEnabledPairs: false },
+ }));
+
+ const info = notifications.find(n => n.level === NotificationLevel.INFO);
+ expect(info).toBeDefined();
+ expect(info!.text).toBe(Localization.Notification.STATUS_REMOTE_SCAN_WAITING);
+ });
+
+ // --- Remote scan failed ---
+
+ it('should show WARNING notification when remote scan fails', () => {
+ let notifications: Notification[] = [];
+ notificationService.notifications$.subscribe(n => notifications = n);
+
+ statusSubject.next(makeStatus({
+ server: { up: true },
+ controller: {
+ latestRemoteScanFailed: true,
+ latestRemoteScanError: 'Timeout',
+ noEnabledPairs: false,
+ },
+ }));
+
+ const warning = notifications.find(n => n.level === NotificationLevel.WARNING);
+ expect(warning).toBeDefined();
+ expect(warning!.text).toContain('Timeout');
+ });
+
+ // --- No enabled pairs ---
+
+ it('should show WARNING notification when no pairs are enabled', () => {
+ let notifications: Notification[] = [];
+ notificationService.notifications$.subscribe(n => notifications = n);
+
+ statusSubject.next(makeStatus({
+ server: { up: true },
+ controller: { noEnabledPairs: true },
+ }));
+
+ const warning = notifications.find(n => n.text === Localization.Notification.STATUS_NO_ENABLED_PAIRS);
+ expect(warning).toBeDefined();
+ expect(warning!.level).toBe(NotificationLevel.WARNING);
+ });
+
+ // --- Precedence / coexistence ---
+
+ it('should show server-down notification with highest priority (DANGER sorts first)', () => {
+ let notifications: Notification[] = [];
+ notificationService.notifications$.subscribe(n => notifications = n);
+
+ statusSubject.next(makeStatus({ server: { up: false, errorMessage: 'Server down' } }));
+
+ // DANGER should be first in the sorted list
+ expect(notifications[0].level).toBe(NotificationLevel.DANGER);
+ });
+
+ it('should replace old notification text when status text changes', () => {
+ let notifications: Notification[] = [];
+ notificationService.notifications$.subscribe(n => notifications = n);
+
+ statusSubject.next(makeStatus({ server: { up: false, errorMessage: 'Error A' } }));
+ expect(notifications.some(n => n.text === 'Error A')).toBe(true);
+
+ statusSubject.next(makeStatus({ server: { up: false, errorMessage: 'Error B' } }));
+ expect(notifications.some(n => n.text === 'Error B')).toBe(true);
+ expect(notifications.some(n => n.text === 'Error A')).toBe(false);
+ });
+
+ it('should not create duplicate notification when same text is emitted again', () => {
+ let notifications: Notification[] = [];
+ notificationService.notifications$.subscribe(n => notifications = n);
+
+ statusSubject.next(makeStatus({ server: { up: false, errorMessage: 'Same error' } }));
+ const countBefore = notifications.length;
+
+ statusSubject.next(makeStatus({ server: { up: false, errorMessage: 'Same error' } }));
+ expect(notifications.length).toBe(countBefore);
+ });
+
+ it('should handle multiple rules coexisting independently', () => {
+ let notifications: Notification[] = [];
+ notificationService.notifications$.subscribe(n => notifications = n);
+
+ // Trigger both "waiting for scan" and "no enabled pairs" are mutually exclusive
+ // (noEnabledPairs suppresses waitingForRemoteScan), but remoteServerError + noEnabledPairs
+ // are also exclusive. Let's verify server-down + noEnabledPairs can't coexist
+ // because server.up is false suppresses the noEnabledPairs check.
+ // Instead, let's verify that changing from one rule to another cleans up properly.
+
+ // First: no enabled pairs
+ statusSubject.next(makeStatus({
+ server: { up: true },
+ controller: { noEnabledPairs: true, latestRemoteScanTime: new Date() },
+ }));
+ expect(notifications.some(n => n.text === Localization.Notification.STATUS_NO_ENABLED_PAIRS)).toBe(true);
+
+ // Then: server goes down
+ statusSubject.next(makeStatus({
+ server: { up: false, errorMessage: 'Down' },
+ controller: { noEnabledPairs: true },
+ }));
+ // noEnabledPairs rule should be hidden (server.up is false)
+ expect(notifications.some(n => n.text === Localization.Notification.STATUS_NO_ENABLED_PAIRS)).toBe(false);
+ expect(notifications.some(n => n.level === NotificationLevel.DANGER)).toBe(true);
+ });
+
+ it('should not show "waiting for remote scan" when no pairs are enabled', () => {
+ let notifications: Notification[] = [];
+ notificationService.notifications$.subscribe(n => notifications = n);
+
+ statusSubject.next(makeStatus({
+ server: { up: true },
+ controller: { latestRemoteScanTime: null, noEnabledPairs: true },
+ }));
+
+ // "waitingForRemoteScan" should NOT show because noEnabledPairs is true
+ expect(notifications.some(n => n.text === Localization.Notification.STATUS_REMOTE_SCAN_WAITING)).toBe(false);
+ // But "noEnabledPairs" should show
+ expect(notifications.some(n => n.text === Localization.Notification.STATUS_NO_ENABLED_PAIRS)).toBe(true);
+ });
+
+ it('should not show remote scan error when no pairs are enabled', () => {
+ let notifications: Notification[] = [];
+ notificationService.notifications$.subscribe(n => notifications = n);
+
+ statusSubject.next(makeStatus({
+ server: { up: true },
+ controller: {
+ latestRemoteScanFailed: true,
+ latestRemoteScanError: 'Timeout',
+ noEnabledPairs: true,
+ },
+ }));
+
+ expect(notifications.some(n => n.text.includes('Timeout'))).toBe(false);
+ });
+
+ it('should clean up all notifications when server recovers from complex state', () => {
+ let notifications: Notification[] = [];
+ notificationService.notifications$.subscribe(n => notifications = n);
+
+ // Server down
+ statusSubject.next(makeStatus({ server: { up: false, errorMessage: 'Error' } }));
+ expect(notifications.length).toBeGreaterThan(0);
+
+ // Server recovers to healthy state
+ statusSubject.next(makeStatus({
+ server: { up: true },
+ controller: { latestRemoteScanTime: new Date(), noEnabledPairs: false },
+ }));
+ expect(notifications.length).toBe(0);
+ });
+});
diff --git a/src/angular/src/app/pages/settings/integrations.component.spec.ts b/src/angular/src/app/pages/settings/integrations.component.spec.ts
new file mode 100644
index 00000000..3ed64af9
--- /dev/null
+++ b/src/angular/src/app/pages/settings/integrations.component.spec.ts
@@ -0,0 +1,182 @@
+import '@angular/compiler';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { TestBed } from '@angular/core/testing';
+import { BehaviorSubject, of, throwError } from 'rxjs';
+
+import { IntegrationsComponent } from './integrations.component';
+import { IntegrationsService } from '../../services/settings/integrations.service';
+import { ArrInstance } from '../../models/arr-instance';
+
+function makeInstance(overrides: Partial = {}): ArrInstance {
+ return {
+ id: 'inst-1',
+ name: 'Sonarr — TV',
+ kind: 'sonarr',
+ url: 'http://sonarr:8989',
+ api_key: '********',
+ enabled: true,
+ ...overrides,
+ };
+}
+
+describe('IntegrationsComponent', () => {
+ let component: IntegrationsComponent;
+ let instancesSubject: BehaviorSubject;
+ let mockIntegrationsService: {
+ instances$: ReturnType;
+ create: ReturnType;
+ update: ReturnType;
+ remove: ReturnType;
+ refresh: ReturnType;
+ test: ReturnType;
+ };
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ instancesSubject = new BehaviorSubject([]);
+
+ mockIntegrationsService = {
+ instances$: instancesSubject.asObservable(),
+ create: vi.fn(),
+ update: vi.fn(),
+ remove: vi.fn(),
+ refresh: vi.fn(),
+ test: vi.fn(),
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ { provide: IntegrationsService, useValue: mockIntegrationsService },
+ ],
+ });
+
+ const fixture = TestBed.createComponent(IntegrationsComponent);
+ component = fixture.componentInstance;
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ // --- Add form ---
+
+ it('should open add form with kind pre-selected and cancel should reset', () => {
+ component.onStartAdd('radarr');
+ expect(component.adding).toBe(true);
+ expect(component.addForm.kind).toBe('radarr');
+ expect(component.addForm.name).toBe('');
+
+ component.onCancelAdd();
+ expect(component.adding).toBe(false);
+ expect(component.addForm.name).toBe('');
+ });
+
+ it('should set error when saving add with empty name', () => {
+ component.onStartAdd('sonarr');
+ component.addForm.name = ' ';
+ component.onSaveAdd();
+
+ expect(component.errorMessage).toBe('Name is required.');
+ expect(mockIntegrationsService.create).not.toHaveBeenCalled();
+ });
+
+ it('should call service create with valid data', () => {
+ const created = makeInstance({ id: 'new-id', name: 'My Sonarr' });
+ mockIntegrationsService.create.mockReturnValue(of(created));
+
+ component.onStartAdd('sonarr');
+ component.addForm.name = 'My Sonarr';
+ component.addForm.url = 'http://sonarr:8989';
+ component.addForm.api_key = 'key123';
+ component.onSaveAdd();
+
+ expect(mockIntegrationsService.create).toHaveBeenCalledWith({
+ name: 'My Sonarr',
+ kind: 'sonarr',
+ url: 'http://sonarr:8989',
+ api_key: 'key123',
+ enabled: true,
+ });
+ expect(component.adding).toBe(false);
+ });
+
+ // --- Edit form ---
+
+ it('should populate edit form from instance', () => {
+ const inst = makeInstance({ name: 'Radarr — Movies', kind: 'radarr' });
+ component.onStartEdit(inst);
+
+ expect(component.editingId).toBe('inst-1');
+ expect(component.editForm.name).toBe('Radarr — Movies');
+ expect(component.editForm.kind).toBe('radarr');
+ });
+
+ it('should cancel edit and clear form', () => {
+ component.onStartEdit(makeInstance());
+ expect(component.editingId).not.toBeNull();
+
+ component.onCancelEdit();
+ expect(component.editingId).toBeNull();
+ expect(component.editForm.name).toBe('');
+ });
+
+ // --- Delete double-click ---
+
+ it('should enter confirmation state on first delete click', () => {
+ component.onDelete('inst-1');
+ expect(component.confirmingDeleteId).toBe('inst-1');
+ expect(mockIntegrationsService.remove).not.toHaveBeenCalled();
+ });
+
+ it('should call service remove on second delete click (confirmation)', () => {
+ mockIntegrationsService.remove.mockReturnValue(of(true));
+
+ component.onDelete('inst-1'); // first click -> confirm
+ component.onDelete('inst-1'); // second click -> delete
+
+ expect(mockIntegrationsService.remove).toHaveBeenCalledWith('inst-1');
+ expect(component.confirmingDeleteId).toBeNull();
+ });
+
+ it('should reset confirmation state after 3 seconds', () => {
+ component.onDelete('inst-1');
+ expect(component.confirmingDeleteId).toBe('inst-1');
+
+ vi.advanceTimersByTime(3000);
+ expect(component.confirmingDeleteId).toBeNull();
+ });
+
+ it('should clear confirmation timer on destroy', () => {
+ component.onDelete('inst-1');
+ expect(component.confirmingDeleteId).toBe('inst-1');
+
+ component.ngOnDestroy();
+ // Advancing timers after destroy should not cause issues
+ vi.advanceTimersByTime(5000);
+ // Timer was cleared, so it doesn't auto-reset (already cleared in ngOnDestroy)
+ });
+
+ // --- Error handling ---
+
+ it('should set error when create returns null (server error)', () => {
+ mockIntegrationsService.create.mockReturnValue(of(null));
+
+ component.onStartAdd('sonarr');
+ component.addForm.name = 'Test';
+ component.onSaveAdd();
+
+ expect(component.errorMessage).toContain('Failed to create');
+ });
+
+ it('should set error when create throws (409 conflict)', () => {
+ mockIntegrationsService.create.mockReturnValue(
+ throwError(() => ({ status: 409 })),
+ );
+
+ component.onStartAdd('sonarr');
+ component.addForm.name = 'Duplicate';
+ component.onSaveAdd();
+
+ expect(component.errorMessage).toContain('already exists');
+ });
+});
diff --git a/src/angular/src/app/pages/settings/option.component.spec.ts b/src/angular/src/app/pages/settings/option.component.spec.ts
new file mode 100644
index 00000000..e5373ece
--- /dev/null
+++ b/src/angular/src/app/pages/settings/option.component.spec.ts
@@ -0,0 +1,141 @@
+import '@angular/compiler';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { Subject, Subscription } from 'rxjs';
+import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
+import { TestBed } from '@angular/core/testing';
+import { ComponentFixture } from '@angular/core/testing';
+
+import { OptionComponent, OptionType, OptionValue, DEBOUNCE_TIME_MS } from './option.component';
+import { REDACTED_SENTINEL } from '../../models/config';
+
+/**
+ * Test the debounce/distinctUntilChanged pipe logic directly using the same
+ * operators as OptionComponent, avoiding Angular component lifecycle timing issues.
+ */
+describe('OptionComponent — debounce pipe logic', () => {
+ let newValue: Subject;
+ let emitted: OptionValue[];
+ let subscription: Subscription;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ newValue = new Subject();
+ emitted = [];
+ subscription = newValue
+ .pipe(debounceTime(DEBOUNCE_TIME_MS), distinctUntilChanged())
+ .subscribe((val) => emitted.push(val));
+ });
+
+ afterEach(() => {
+ subscription.unsubscribe();
+ vi.useRealTimers();
+ });
+
+ it('should emit value after the full debounce window, not immediately', () => {
+ newValue.next('hello');
+ expect(emitted).toEqual([]);
+
+ vi.advanceTimersByTime(DEBOUNCE_TIME_MS);
+ expect(emitted).toEqual(['hello']);
+ });
+
+ it('should emit only the final value when rapid changes occur', () => {
+ newValue.next('a');
+ vi.advanceTimersByTime(200);
+ newValue.next('b');
+ vi.advanceTimersByTime(200);
+ newValue.next('c');
+
+ expect(emitted).toEqual([]);
+
+ vi.advanceTimersByTime(DEBOUNCE_TIME_MS);
+ expect(emitted).toEqual(['c']);
+ });
+
+ it('should not re-emit same value after full debounce period (distinctUntilChanged)', () => {
+ newValue.next('same');
+ vi.advanceTimersByTime(DEBOUNCE_TIME_MS);
+ expect(emitted).toEqual(['same']);
+
+ newValue.next('same');
+ vi.advanceTimersByTime(DEBOUNCE_TIME_MS);
+ expect(emitted).toEqual(['same']);
+ });
+
+ it('should emit different values after each debounce period', () => {
+ newValue.next('first');
+ vi.advanceTimersByTime(DEBOUNCE_TIME_MS);
+ newValue.next('second');
+ vi.advanceTimersByTime(DEBOUNCE_TIME_MS);
+
+ expect(emitted).toEqual(['first', 'second']);
+ });
+});
+
+/**
+ * Test the onChange guard logic (password REDACTED_SENTINEL suppression)
+ * and effectiveChoices computed signal.
+ */
+describe('OptionComponent — onChange and effectiveChoices', () => {
+ let component: OptionComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ fixture = TestBed.createComponent(OptionComponent);
+ component = fixture.componentInstance;
+ });
+
+ // Restore real timers after every test so a failing assertion inside a
+ // useFakeTimers() block can't leak fake timers into the next test.
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('should suppress REDACTED_SENTINEL for password type', () => {
+ fixture.componentRef.setInput('type', OptionType.Password);
+ fixture.detectChanges();
+
+ // Spy on the internal Subject to verify nothing gets pushed.
+ // No fake timers needed — onChange() pushes to the Subject synchronously
+ // before any debounceTime delay; the assertion observes the next() call,
+ // not the downstream debounced emission.
+ const nextSpy = vi.spyOn((component as unknown as { newValue: Subject }).newValue, 'next');
+ component.onChange(REDACTED_SENTINEL);
+ expect(nextSpy).not.toHaveBeenCalled();
+ });
+
+ it('should pass real password value through to Subject', () => {
+ fixture.componentRef.setInput('type', OptionType.Password);
+ fixture.detectChanges();
+
+ const nextSpy = vi.spyOn((component as unknown as { newValue: Subject }).newValue, 'next');
+ component.onChange('real-password');
+ expect(nextSpy).toHaveBeenCalledWith('real-password');
+ });
+
+ it('should not suppress REDACTED_SENTINEL for non-password types', () => {
+ fixture.componentRef.setInput('type', OptionType.Text);
+ fixture.detectChanges();
+
+ const nextSpy = vi.spyOn((component as unknown as { newValue: Subject }).newValue, 'next');
+ component.onChange(REDACTED_SENTINEL);
+ expect(nextSpy).toHaveBeenCalledWith(REDACTED_SENTINEL);
+ });
+
+ it('should include current value in choices if not in predefined list', () => {
+ fixture.componentRef.setInput('choices', ['opt1', 'opt2']);
+ fixture.componentRef.setInput('value', 'custom');
+ fixture.detectChanges();
+
+ expect(component.effectiveChoices()).toEqual(['custom', 'opt1', 'opt2']);
+ });
+
+ it('should not duplicate current value if already in choices', () => {
+ fixture.componentRef.setInput('choices', ['opt1', 'opt2']);
+ fixture.componentRef.setInput('value', 'opt1');
+ fixture.detectChanges();
+
+ expect(component.effectiveChoices()).toEqual(['opt1', 'opt2']);
+ });
+});
diff --git a/src/angular/src/app/pages/settings/option.component.ts b/src/angular/src/app/pages/settings/option.component.ts
index 2bcc3ce1..f2e76b93 100644
--- a/src/angular/src/app/pages/settings/option.component.ts
+++ b/src/angular/src/app/pages/settings/option.component.ts
@@ -13,6 +13,12 @@ export enum OptionType {
/** Value emitted by OptionComponent — matches the possible config value types. */
export type OptionValue = string | number | boolean | null;
+/**
+ * Debounce window applied to onChange before the change event is emitted.
+ * Exported so tests can reference the same value without re-declaring it.
+ */
+export const DEBOUNCE_TIME_MS = 1000;
+
@Component({
selector: 'app-option',
standalone: true,
@@ -43,13 +49,12 @@ export class OptionComponent implements OnInit, OnDestroy {
return c;
});
- private readonly DEBOUNCE_TIME_MS = 1000;
private readonly newValue = new Subject();
private subscription?: Subscription;
ngOnInit(): void {
this.subscription = this.newValue
- .pipe(debounceTime(this.DEBOUNCE_TIME_MS), distinctUntilChanged())
+ .pipe(debounceTime(DEBOUNCE_TIME_MS), distinctUntilChanged())
.subscribe({ next: (val) => this.changeEvent.emit(val) });
}
diff --git a/src/angular/src/app/pages/settings/options-list.ts b/src/angular/src/app/pages/settings/options-list.ts
index 2bc8cecd..c53cf1fc 100644
--- a/src/angular/src/app/pages/settings/options-list.ts
+++ b/src/angular/src/app/pages/settings/options-list.ts
@@ -7,6 +7,7 @@ export interface IOption {
description: string | null;
disabled?: boolean;
choices?: string[];
+ requiresRestart?: boolean;
}
export interface IOptionsContext {
@@ -24,48 +25,56 @@ export const OPTIONS_CONTEXT_SERVER: IOptionsContext = {
label: 'Server Address',
valuePath: ['lftp', 'remote_address'],
description: null,
+ requiresRestart: true,
},
{
type: OptionType.Text,
label: 'Server User',
valuePath: ['lftp', 'remote_username'],
description: null,
+ requiresRestart: true,
},
{
type: OptionType.Password,
label: 'Server Password',
valuePath: ['lftp', 'remote_password'],
description: 'Required unless SSH key authentication is enabled',
+ requiresRestart: true,
},
{
type: OptionType.Checkbox,
label: 'Use password-less key-based authentication',
valuePath: ['lftp', 'use_ssh_key'],
description: null,
+ requiresRestart: true,
},
{
type: OptionType.Text,
label: 'Server Directory',
valuePath: ['lftp', 'remote_path'],
description: 'Path to your files on the remote server',
+ requiresRestart: true,
},
{
type: OptionType.Text,
label: 'Local Directory',
valuePath: ['lftp', 'local_path'],
description: 'Downloaded files are placed here',
+ requiresRestart: true,
},
{
type: OptionType.Text,
label: 'Remote SSH Port',
valuePath: ['lftp', 'remote_port'],
description: null,
+ requiresRestart: true,
},
{
type: OptionType.Text,
label: 'Server Script Path',
valuePath: ['lftp', 'remote_path_to_scan_script'],
description: 'Where to install scanner script on remote server',
+ requiresRestart: true,
},
{
type: OptionType.Text,
@@ -74,6 +83,7 @@ export const OPTIONS_CONTEXT_SERVER: IOptionsContext = {
description:
'Path to Python 3 on the remote server. Leave empty to use the default "python3". ' +
'Set this if your seedbox has a custom Python install (e.g. "~/python3/bin/python3").',
+ requiresRestart: true,
},
],
};
@@ -95,18 +105,21 @@ export const OPTIONS_CONTEXT_DISCOVERY: IOptionsContext = {
label: 'Remote Scan Interval (ms)',
valuePath: ['controller', 'interval_ms_remote_scan'],
description: 'How often the remote server is scanned for new files',
+ requiresRestart: true,
},
{
type: OptionType.Text,
label: 'Local Scan Interval (ms)',
valuePath: ['controller', 'interval_ms_local_scan'],
description: 'How often the local directory is scanned',
+ requiresRestart: true,
},
{
type: OptionType.Text,
label: 'Downloading Scan Interval (ms)',
valuePath: ['controller', 'interval_ms_downloading_scan'],
description: 'How often the downloading information is updated',
+ requiresRestart: true,
},
],
};
@@ -180,12 +193,14 @@ export const OPTIONS_CONTEXT_OTHER: IOptionsContext = {
label: 'Web GUI Port',
valuePath: ['web', 'port'],
description: null,
+ requiresRestart: true,
},
{
type: OptionType.Password,
label: 'API Key',
valuePath: ['web', 'api_key'],
description: 'Require this key for API access. Leave empty to disable.',
+ requiresRestart: true,
},
],
};
@@ -199,18 +214,21 @@ export const OPTIONS_CONTEXT_AUTOQUEUE: IOptionsContext = {
label: 'Enable AutoQueue',
valuePath: ['autoqueue', 'enabled'],
description: null,
+ requiresRestart: true,
},
{
type: OptionType.Checkbox,
label: 'Restrict to patterns',
valuePath: ['autoqueue', 'patterns_only'],
description: 'Only autoqueue files that match a pattern',
+ requiresRestart: true,
},
{
type: OptionType.Checkbox,
label: 'Enable auto extraction',
valuePath: ['autoqueue', 'auto_extract'],
description: 'Automatically extract files',
+ requiresRestart: true,
},
{
type: OptionType.Checkbox,
@@ -231,12 +249,14 @@ export const OPTIONS_CONTEXT_STAGING: IOptionsContext = {
label: 'Use staging directory',
valuePath: ['controller', 'use_staging'],
description: 'Download files to a staging directory before moving to the final location',
+ requiresRestart: true,
},
{
type: OptionType.Text,
label: 'Staging Path',
valuePath: ['controller', 'staging_path'],
description: 'Temporary directory where files are downloaded before being moved',
+ requiresRestart: true,
},
],
};
@@ -315,6 +335,7 @@ export const OPTIONS_CONTEXT_LOGGING: IOptionsContext = {
valuePath: ['general', 'log_level'],
description: 'Controls which log messages are recorded',
choices: ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
+ requiresRestart: true,
},
{
type: OptionType.Checkbox,
@@ -328,6 +349,7 @@ export const OPTIONS_CONTEXT_LOGGING: IOptionsContext = {
valuePath: ['logging', 'log_format'],
description: 'Log output format',
choices: ['standard', 'json'],
+ requiresRestart: true,
},
],
};
@@ -433,6 +455,7 @@ export const OPTIONS_CONTEXT_EXTRACT: IOptionsContext = {
label: 'Extract archives in the downloads directory',
valuePath: ['controller', 'use_local_path_as_extract_path'],
description: null,
+ requiresRestart: true,
},
{
type: OptionType.Text,
@@ -440,6 +463,7 @@ export const OPTIONS_CONTEXT_EXTRACT: IOptionsContext = {
valuePath: ['controller', 'extract_path'],
description:
'When option above is disabled, extract archives to this directory',
+ requiresRestart: true,
},
],
};
diff --git a/src/angular/src/app/pages/settings/settings-page.component.html b/src/angular/src/app/pages/settings/settings-page.component.html
index cf846026..a8742843 100644
--- a/src/angular/src/app/pages/settings/settings-page.component.html
+++ b/src/angular/src/app/pages/settings/settings-page.component.html
@@ -55,7 +55,7 @@
[description]="option.description"
[choices]="option.choices || []"
[value]="getOptionValue(config$ | async, option.valuePath)"
- (changeEvent)="onSetConfig(option.valuePath[0], option.valuePath[1], $event)">
+ (changeEvent)="onSetConfig(option.valuePath[0], option.valuePath[1], $event, option.requiresRestart)">
}
@@ -83,7 +83,7 @@
[disabled]="!!option.disabled"
[choices]="option.choices || []"
[value]="getOptionValue(config$ | async, option.valuePath)"
- (changeEvent)="onSetConfig(option.valuePath[0], option.valuePath[1], $event)">
+ (changeEvent)="onSetConfig(option.valuePath[0], option.valuePath[1], $event, option.requiresRestart)">
}
@@ -152,7 +152,7 @@
[disabled]="!!option.disabled"
[choices]="option.choices || []"
[value]="getOptionValue(config$ | async, option.valuePath)"
- (changeEvent)="onSetConfig(option.valuePath[0], option.valuePath[1], $event)">
+ (changeEvent)="onSetConfig(option.valuePath[0], option.valuePath[1], $event, option.requiresRestart)">
}
diff --git a/src/angular/src/app/pages/settings/settings-page.component.ts b/src/angular/src/app/pages/settings/settings-page.component.ts
index a79721b9..2363b8c2 100644
--- a/src/angular/src/app/pages/settings/settings-page.component.ts
+++ b/src/angular/src/app/pages/settings/settings-page.component.ts
@@ -164,7 +164,7 @@ export class SettingsPageComponent implements OnInit {
return section[valuePath[1]] ?? null;
}
- onSetConfig(section: string, option: string, value: OptionValue): void {
+ onSetConfig(section: string, option: string, value: OptionValue, requiresRestart?: boolean): void {
this.configService.set(section, option, value).subscribe({
next: (reaction) => {
const notifKey = section + '.' + option;
@@ -176,7 +176,9 @@ export class SettingsPageComponent implements OnInit {
this.badValueNotifs.delete(notifKey);
}
- this.notifService.show(this.configRestartNotif);
+ if (requiresRestart) {
+ this.notifService.show(this.configRestartNotif);
+ }
} else {
const notif = createNotification(
NotificationLevel.DANGER,
diff --git a/src/angular/src/app/services/autoqueue/autoqueue.service.spec.ts b/src/angular/src/app/services/autoqueue/autoqueue.service.spec.ts
new file mode 100644
index 00000000..7b1eb48e
--- /dev/null
+++ b/src/angular/src/app/services/autoqueue/autoqueue.service.spec.ts
@@ -0,0 +1,216 @@
+import '@angular/compiler';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { TestBed } from '@angular/core/testing';
+import { BehaviorSubject, of } from 'rxjs';
+
+import { AutoQueueService } from './autoqueue.service';
+import { ConnectedService } from '../utils/connected.service';
+import { RestService, WebReaction } from '../utils/rest.service';
+import { LoggerService } from '../utils/logger.service';
+import { AutoQueuePattern } from '../../models/autoqueue-pattern';
+import { Localization } from '../../models/localization';
+
+function makeReaction(overrides: Partial = {}): WebReaction {
+ return { success: true, data: null, errorMessage: null, ...overrides };
+}
+
+describe('AutoQueueService', () => {
+ let service: AutoQueueService;
+ let connectedSubject: BehaviorSubject;
+ let mockRestService: { sendRequest: ReturnType };
+
+ function snapshot(): AutoQueuePattern[] {
+ let result: AutoQueuePattern[] = [];
+ service.patterns$.subscribe(p => result = p);
+ return result;
+ }
+
+ beforeEach(() => {
+ connectedSubject = new BehaviorSubject(false);
+ mockRestService = { sendRequest: vi.fn() };
+
+ TestBed.configureTestingModule({
+ providers: [
+ AutoQueueService,
+ { provide: ConnectedService, useValue: { connected$: connectedSubject.asObservable() } },
+ { provide: RestService, useValue: mockRestService },
+ { provide: LoggerService, useValue: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } },
+ ],
+ });
+ service = TestBed.inject(AutoQueueService);
+ });
+
+ // --- Connect / disconnect ---
+
+ it('should fetch patterns when connected', () => {
+ mockRestService.sendRequest.mockReturnValue(
+ of(makeReaction({ success: true, data: JSON.stringify([{ pattern: '*.mkv' }]) })),
+ );
+
+ connectedSubject.next(true);
+
+ expect(mockRestService.sendRequest).toHaveBeenCalledWith('/server/autoqueue/get');
+ expect(snapshot()).toEqual([{ pattern: '*.mkv' }]);
+ });
+
+ it('should clear patterns when disconnected', () => {
+ mockRestService.sendRequest.mockReturnValue(
+ of(makeReaction({ success: true, data: JSON.stringify([{ pattern: '*.mkv' }]) })),
+ );
+ connectedSubject.next(true);
+ expect(snapshot().length).toBe(1);
+
+ connectedSubject.next(false);
+ expect(snapshot()).toEqual([]);
+ });
+
+ // --- add() ---
+
+ it('should reject empty pattern with error message', () => {
+ let result: WebReaction | undefined;
+ service.add('').subscribe(r => result = r);
+
+ expect(result!.success).toBe(false);
+ expect(result!.errorMessage).toBe(Localization.Notification.AUTOQUEUE_PATTERN_EMPTY);
+ });
+
+ it('should reject whitespace-only pattern', () => {
+ let result: WebReaction | undefined;
+ service.add(' ').subscribe(r => result = r);
+
+ expect(result!.success).toBe(false);
+ expect(result!.errorMessage).toBe(Localization.Notification.AUTOQUEUE_PATTERN_EMPTY);
+ });
+
+ it('should reject duplicate pattern locally', () => {
+ // Pre-load a pattern
+ mockRestService.sendRequest.mockReturnValue(
+ of(makeReaction({ success: true, data: JSON.stringify([{ pattern: '*.mkv' }]) })),
+ );
+ connectedSubject.next(true);
+
+ let result: WebReaction | undefined;
+ service.add('*.mkv').subscribe(r => result = r);
+
+ expect(result!.success).toBe(false);
+ expect(result!.errorMessage).toContain('already exists');
+ });
+
+ it('should append pattern to local list on successful add', () => {
+ // Start with empty connected state
+ mockRestService.sendRequest.mockReturnValueOnce(
+ of(makeReaction({ success: true, data: JSON.stringify([]) })),
+ );
+ connectedSubject.next(true);
+ expect(snapshot()).toEqual([]);
+
+ // Add returns success
+ mockRestService.sendRequest.mockReturnValue(
+ of(makeReaction({ success: true })),
+ );
+
+ service.add('*.mkv');
+ expect(snapshot()).toEqual([{ pattern: '*.mkv' }]);
+ });
+
+ it('should send double-encoded URL for add', () => {
+ mockRestService.sendRequest.mockReturnValueOnce(
+ of(makeReaction({ success: true, data: JSON.stringify([]) })),
+ );
+ connectedSubject.next(true);
+
+ mockRestService.sendRequest.mockReturnValue(
+ of(makeReaction({ success: true })),
+ );
+
+ service.add('my pattern');
+
+ const encoded = encodeURIComponent(encodeURIComponent('my pattern'));
+ expect(mockRestService.sendRequest).toHaveBeenCalledWith(`/server/autoqueue/add/${encoded}`);
+ });
+
+ it('should not update local state when server rejects add', () => {
+ mockRestService.sendRequest.mockReturnValueOnce(
+ of(makeReaction({ success: true, data: JSON.stringify([]) })),
+ );
+ connectedSubject.next(true);
+
+ mockRestService.sendRequest.mockReturnValue(
+ of(makeReaction({ success: false, errorMessage: 'Server error' })),
+ );
+
+ service.add('*.mkv');
+ expect(snapshot()).toEqual([]);
+ });
+
+ it('should double-encode special characters in pattern', () => {
+ mockRestService.sendRequest.mockReturnValueOnce(
+ of(makeReaction({ success: true, data: JSON.stringify([]) })),
+ );
+ connectedSubject.next(true);
+
+ mockRestService.sendRequest.mockReturnValue(
+ of(makeReaction({ success: true })),
+ );
+
+ service.add('test/path&name');
+
+ const encoded = encodeURIComponent(encodeURIComponent('test/path&name'));
+ expect(mockRestService.sendRequest).toHaveBeenCalledWith(`/server/autoqueue/add/${encoded}`);
+ });
+
+ // --- remove() ---
+
+ it('should filter pattern from local list on successful remove', () => {
+ mockRestService.sendRequest.mockReturnValueOnce(
+ of(makeReaction({ success: true, data: JSON.stringify([{ pattern: '*.mkv' }, { pattern: '*.avi' }]) })),
+ );
+ connectedSubject.next(true);
+ expect(snapshot().length).toBe(2);
+
+ mockRestService.sendRequest.mockReturnValue(
+ of(makeReaction({ success: true })),
+ );
+
+ service.remove('*.mkv');
+ expect(snapshot()).toEqual([{ pattern: '*.avi' }]);
+ });
+
+ it('should not update local state when server rejects remove', () => {
+ mockRestService.sendRequest.mockReturnValueOnce(
+ of(makeReaction({ success: true, data: JSON.stringify([{ pattern: '*.mkv' }]) })),
+ );
+ connectedSubject.next(true);
+
+ mockRestService.sendRequest.mockReturnValue(
+ of(makeReaction({ success: false, errorMessage: 'Server error' })),
+ );
+
+ service.remove('*.mkv');
+ expect(snapshot()).toEqual([{ pattern: '*.mkv' }]);
+ });
+
+ it('should return error when removing a pattern that does not exist', () => {
+ mockRestService.sendRequest.mockReturnValueOnce(
+ of(makeReaction({ success: true, data: JSON.stringify([]) })),
+ );
+ connectedSubject.next(true);
+
+ let result: WebReaction | undefined;
+ service.remove('nonexistent').subscribe(r => result = r);
+
+ expect(result!.success).toBe(false);
+ expect(result!.errorMessage).toContain('not found');
+ });
+
+ // --- GET failure ---
+
+ it('should set empty array when GET fails (graceful degradation)', () => {
+ mockRestService.sendRequest.mockReturnValue(
+ of(makeReaction({ success: false, errorMessage: 'Network error' })),
+ );
+
+ connectedSubject.next(true);
+ expect(snapshot()).toEqual([]);
+ });
+});
diff --git a/src/angular/src/app/services/files/view-file-filter.service.spec.ts b/src/angular/src/app/services/files/view-file-filter.service.spec.ts
new file mode 100644
index 00000000..72e68c4a
--- /dev/null
+++ b/src/angular/src/app/services/files/view-file-filter.service.spec.ts
@@ -0,0 +1,275 @@
+import '@angular/compiler';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { TestBed } from '@angular/core/testing';
+import { BehaviorSubject } from 'rxjs';
+
+import { ViewFileFilterService } from './view-file-filter.service';
+import { ViewFileService, ViewFileFilterCriteria } from './view-file.service';
+import { ViewFileOptionsService } from './view-file-options.service';
+import { LoggerService } from '../utils/logger.service';
+import { ViewFile, ViewFileStatus } from '../../models/view-file';
+import { ViewFileOptions, SortMethod } from '../../models/view-file-options';
+
+function makeViewFile(overrides: Partial = {}): ViewFile {
+ return {
+ name: 'TestFile.txt',
+ pairId: null,
+ pairName: null,
+ isDir: false,
+ localSize: 0,
+ remoteSize: 100,
+ percentDownloaded: 0,
+ status: ViewFileStatus.DEFAULT,
+ downloadingSpeed: 0,
+ eta: 0,
+ fullPath: '/TestFile.txt',
+ isArchive: false,
+ isSelected: false,
+ isChecked: false,
+ isQueueable: true,
+ isStoppable: false,
+ isExtractable: false,
+ isLocallyDeletable: false,
+ isRemotelyDeletable: true,
+ isValidatable: false,
+ validateTooltip: null,
+ localCreatedTimestamp: null,
+ localModifiedTimestamp: null,
+ remoteCreatedTimestamp: null,
+ remoteModifiedTimestamp: null,
+ ...overrides,
+ };
+}
+
+function makeOptions(overrides: Partial = {}): ViewFileOptions {
+ return {
+ showDetails: false,
+ sortMethod: SortMethod.STATUS,
+ selectedStatusFilter: null,
+ nameFilter: '',
+ pinFilter: false,
+ ...overrides,
+ };
+}
+
+// ─── NameFilterCriteria (tested via service) ──────────────────────────
+
+describe('ViewFileFilterService — NameFilterCriteria', () => {
+ let optionsSubject: BehaviorSubject;
+ let capturedCriteria: ViewFileFilterCriteria | null;
+ let mockViewFileService: { setFilterCriteria: ReturnType };
+
+ beforeEach(() => {
+ capturedCriteria = null;
+ optionsSubject = new BehaviorSubject(makeOptions());
+ mockViewFileService = {
+ setFilterCriteria: vi.fn((c: ViewFileFilterCriteria | null) => {
+ capturedCriteria = c;
+ }),
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ ViewFileFilterService,
+ { provide: ViewFileOptionsService, useValue: { options$: optionsSubject.asObservable() } },
+ { provide: ViewFileService, useValue: mockViewFileService },
+ { provide: LoggerService, useValue: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } },
+ ],
+ });
+ // Instantiate to trigger the constructor subscription
+ TestBed.inject(ViewFileFilterService);
+ });
+
+ it('should match all files when name filter is null', () => {
+ optionsSubject.next(makeOptions({ nameFilter: null as unknown as string }));
+ const file = makeViewFile({ name: 'Anything.mkv' });
+ expect(capturedCriteria!.meetsCriteria(file)).toBe(true);
+ });
+
+ it('should match all files when name filter is empty string', () => {
+ optionsSubject.next(makeOptions({ nameFilter: '' }));
+ const file = makeViewFile({ name: 'Anything.mkv' });
+ expect(capturedCriteria!.meetsCriteria(file)).toBe(true);
+ });
+
+ it('should match substring case-insensitively', () => {
+ optionsSubject.next(makeOptions({ nameFilter: 'show' }));
+ expect(capturedCriteria!.meetsCriteria(makeViewFile({ name: 'My.Show.S01E01.mkv' }))).toBe(true);
+ expect(capturedCriteria!.meetsCriteria(makeViewFile({ name: 'MY.SHOW.S01E01.mkv' }))).toBe(true);
+ expect(capturedCriteria!.meetsCriteria(makeViewFile({ name: 'other-file.txt' }))).toBe(false);
+ });
+
+ it('should treat spaces as dots for fuzzy matching ("my show" matches "My.Show.S01E01")', () => {
+ optionsSubject.next(makeOptions({ nameFilter: 'my show' }));
+ expect(capturedCriteria!.meetsCriteria(makeViewFile({ name: 'My.Show.S01E01.mkv' }))).toBe(true);
+ });
+
+ it('should treat dots as spaces for fuzzy matching ("my.show" matches "My Show S01E01")', () => {
+ optionsSubject.next(makeOptions({ nameFilter: 'my.show' }));
+ expect(capturedCriteria!.meetsCriteria(makeViewFile({ name: 'My Show S01E01.mkv' }))).toBe(true);
+ });
+
+ it('should return false for all files when no name matches', () => {
+ optionsSubject.next(makeOptions({ nameFilter: 'nonexistent' }));
+ expect(capturedCriteria!.meetsCriteria(makeViewFile({ name: 'Something.Else.mkv' }))).toBe(false);
+ expect(capturedCriteria!.meetsCriteria(makeViewFile({ name: 'Another.File.txt' }))).toBe(false);
+ });
+
+ it('should match when query contains mixed dots and spaces', () => {
+ optionsSubject.next(makeOptions({ nameFilter: 'my.show s01' }));
+ // The original query lowercased: "my.show s01"
+ // space→dot variant: "my.show.s01"
+ // dot→space variant: "my show s01"
+ expect(capturedCriteria!.meetsCriteria(makeViewFile({ name: 'My.Show.S01E01.mkv' }))).toBe(true);
+ });
+
+ it('should match only on the file name, not other fields', () => {
+ optionsSubject.next(makeOptions({ nameFilter: 'download' }));
+ // Name doesn't contain "download" but status is DOWNLOADING
+ const file = makeViewFile({ name: 'SomeFile.txt', status: ViewFileStatus.DOWNLOADING });
+ expect(capturedCriteria!.meetsCriteria(file)).toBe(false);
+ });
+});
+
+// ─── StatusFilterCriteria (tested via service) ────────────────────────
+
+describe('ViewFileFilterService — StatusFilterCriteria', () => {
+ let optionsSubject: BehaviorSubject;
+ let capturedCriteria: ViewFileFilterCriteria | null;
+ let mockViewFileService: { setFilterCriteria: ReturnType };
+
+ beforeEach(() => {
+ capturedCriteria = null;
+ optionsSubject = new BehaviorSubject(makeOptions());
+ mockViewFileService = {
+ setFilterCriteria: vi.fn((c: ViewFileFilterCriteria | null) => {
+ capturedCriteria = c;
+ }),
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ ViewFileFilterService,
+ { provide: ViewFileOptionsService, useValue: { options$: optionsSubject.asObservable() } },
+ { provide: ViewFileService, useValue: mockViewFileService },
+ { provide: LoggerService, useValue: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } },
+ ],
+ });
+ TestBed.inject(ViewFileFilterService);
+ });
+
+ it('should match all files when status filter is null', () => {
+ optionsSubject.next(makeOptions({ selectedStatusFilter: null }));
+ expect(capturedCriteria!.meetsCriteria(makeViewFile({ status: ViewFileStatus.DEFAULT }))).toBe(true);
+ expect(capturedCriteria!.meetsCriteria(makeViewFile({ status: ViewFileStatus.DOWNLOADING }))).toBe(true);
+ expect(capturedCriteria!.meetsCriteria(makeViewFile({ status: ViewFileStatus.QUEUED }))).toBe(true);
+ });
+
+ it('should match only files with the specified status', () => {
+ optionsSubject.next(makeOptions({ selectedStatusFilter: ViewFileStatus.DOWNLOADING }));
+ expect(capturedCriteria!.meetsCriteria(makeViewFile({ status: ViewFileStatus.DOWNLOADING }))).toBe(true);
+ expect(capturedCriteria!.meetsCriteria(makeViewFile({ status: ViewFileStatus.DEFAULT }))).toBe(false);
+ expect(capturedCriteria!.meetsCriteria(makeViewFile({ status: ViewFileStatus.QUEUED }))).toBe(false);
+ });
+
+ it('should match each status enum value correctly', () => {
+ optionsSubject.next(makeOptions({ selectedStatusFilter: ViewFileStatus.EXTRACTED }));
+ expect(capturedCriteria!.meetsCriteria(makeViewFile({ status: ViewFileStatus.EXTRACTED }))).toBe(true);
+ expect(capturedCriteria!.meetsCriteria(makeViewFile({ status: ViewFileStatus.EXTRACTING }))).toBe(false);
+ });
+});
+
+// ─── AndFilterCriteria (combined name + status) ───────────────────────
+
+describe('ViewFileFilterService — AndFilterCriteria', () => {
+ let optionsSubject: BehaviorSubject;
+ let capturedCriteria: ViewFileFilterCriteria | null;
+ let mockViewFileService: { setFilterCriteria: ReturnType };
+
+ beforeEach(() => {
+ capturedCriteria = null;
+ optionsSubject = new BehaviorSubject(makeOptions());
+ mockViewFileService = {
+ setFilterCriteria: vi.fn((c: ViewFileFilterCriteria | null) => {
+ capturedCriteria = c;
+ }),
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ ViewFileFilterService,
+ { provide: ViewFileOptionsService, useValue: { options$: optionsSubject.asObservable() } },
+ { provide: ViewFileService, useValue: mockViewFileService },
+ { provide: LoggerService, useValue: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } },
+ ],
+ });
+ TestBed.inject(ViewFileFilterService);
+ });
+
+ it('should pass when both name and status match', () => {
+ optionsSubject.next(makeOptions({ nameFilter: 'show', selectedStatusFilter: ViewFileStatus.DOWNLOADING }));
+ const file = makeViewFile({ name: 'My.Show.S01E01.mkv', status: ViewFileStatus.DOWNLOADING });
+ expect(capturedCriteria!.meetsCriteria(file)).toBe(true);
+ });
+
+ it('should reject when name matches but status does not', () => {
+ optionsSubject.next(makeOptions({ nameFilter: 'show', selectedStatusFilter: ViewFileStatus.DOWNLOADING }));
+ const file = makeViewFile({ name: 'My.Show.S01E01.mkv', status: ViewFileStatus.DEFAULT });
+ expect(capturedCriteria!.meetsCriteria(file)).toBe(false);
+ });
+
+ it('should reject when status matches but name does not', () => {
+ optionsSubject.next(makeOptions({ nameFilter: 'show', selectedStatusFilter: ViewFileStatus.DOWNLOADING }));
+ const file = makeViewFile({ name: 'Other.File.txt', status: ViewFileStatus.DOWNLOADING });
+ expect(capturedCriteria!.meetsCriteria(file)).toBe(false);
+ });
+});
+
+// ─── Service integration ──────────────────────────────────────────────
+
+describe('ViewFileFilterService — service integration', () => {
+ let optionsSubject: BehaviorSubject;
+ let mockViewFileService: { setFilterCriteria: ReturnType };
+
+ beforeEach(() => {
+ optionsSubject = new BehaviorSubject(makeOptions());
+ mockViewFileService = { setFilterCriteria: vi.fn() };
+
+ TestBed.configureTestingModule({
+ providers: [
+ ViewFileFilterService,
+ { provide: ViewFileOptionsService, useValue: { options$: optionsSubject.asObservable() } },
+ { provide: ViewFileService, useValue: mockViewFileService },
+ { provide: LoggerService, useValue: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } },
+ ],
+ });
+ TestBed.inject(ViewFileFilterService);
+ });
+
+ it('should call setFilterCriteria when status filter changes', () => {
+ mockViewFileService.setFilterCriteria.mockClear();
+ optionsSubject.next(makeOptions({ selectedStatusFilter: ViewFileStatus.QUEUED }));
+ expect(mockViewFileService.setFilterCriteria).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call setFilterCriteria when name filter changes', () => {
+ mockViewFileService.setFilterCriteria.mockClear();
+ optionsSubject.next(makeOptions({ nameFilter: 'test' }));
+ expect(mockViewFileService.setFilterCriteria).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not call setFilterCriteria when options re-emit with same filter values', () => {
+ // Initial emission happened during construction with defaults (null status, '' name)
+ mockViewFileService.setFilterCriteria.mockClear();
+
+ // Re-emit with identical values — should NOT trigger a redundant update
+ optionsSubject.next(makeOptions({ selectedStatusFilter: null, nameFilter: '' }));
+ expect(mockViewFileService.setFilterCriteria).not.toHaveBeenCalled();
+ });
+
+ it('should call setFilterCriteria only once when both filters change simultaneously', () => {
+ mockViewFileService.setFilterCriteria.mockClear();
+ optionsSubject.next(makeOptions({ selectedStatusFilter: ViewFileStatus.DOWNLOADED, nameFilter: 'movie' }));
+ expect(mockViewFileService.setFilterCriteria).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/angular/src/app/services/settings/path-pairs.service.spec.ts b/src/angular/src/app/services/settings/path-pairs.service.spec.ts
new file mode 100644
index 00000000..f4d0d183
--- /dev/null
+++ b/src/angular/src/app/services/settings/path-pairs.service.spec.ts
@@ -0,0 +1,230 @@
+import '@angular/compiler';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { TestBed } from '@angular/core/testing';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
+import { provideHttpClient } from '@angular/common/http';
+import { BehaviorSubject } from 'rxjs';
+import { take } from 'rxjs/operators';
+
+import { PathPairsService } from './path-pairs.service';
+import { ConnectedService } from '../utils/connected.service';
+import { LoggerService } from '../utils/logger.service';
+import { PathPair } from '../../models/path-pair';
+
+function makePair(overrides: Partial = {}): PathPair {
+ return {
+ id: 'pair-1',
+ name: 'Default',
+ remote_path: '/remote',
+ local_path: '/local',
+ enabled: true,
+ auto_queue: false,
+ arr_target_ids: [],
+ ...overrides,
+ };
+}
+
+describe('PathPairsService', () => {
+ let service: PathPairsService;
+ let httpMock: HttpTestingController;
+ let connectedSubject: BehaviorSubject;
+
+ function snapshot(): PathPair[] {
+ // take(1) auto-completes the observable after the first emission, so
+ // each snapshot() call doesn't accumulate a lingering subscription
+ // on service.pairs$.
+ let result: PathPair[] = [];
+ service.pairs$.pipe(take(1)).subscribe(p => result = p);
+ return result;
+ }
+
+ beforeEach(() => {
+ connectedSubject = new BehaviorSubject(false);
+
+ TestBed.configureTestingModule({
+ providers: [
+ provideHttpClient(),
+ provideHttpClientTesting(),
+ PathPairsService,
+ { provide: ConnectedService, useValue: { connected$: connectedSubject.asObservable() } },
+ { provide: LoggerService, useValue: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } },
+ ],
+ });
+
+ service = TestBed.inject(PathPairsService);
+ httpMock = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpMock.verify();
+ });
+
+ // --- Connect / disconnect ---
+
+ it('should fetch pairs when connected', () => {
+ connectedSubject.next(true);
+ const req = httpMock.expectOne('/server/pathpairs');
+ expect(req.request.method).toBe('GET');
+ req.flush([makePair()]);
+
+ expect(snapshot().length).toBe(1);
+ expect(snapshot()[0].name).toBe('Default');
+ });
+
+ it('should clear pairs when disconnected', () => {
+ connectedSubject.next(true);
+ httpMock.expectOne('/server/pathpairs').flush([makePair()]);
+ expect(snapshot().length).toBe(1);
+
+ connectedSubject.next(false);
+ expect(snapshot()).toEqual([]);
+ });
+
+ // --- create() ---
+
+ it('should send POST and append returned pair', () => {
+ const created = makePair({ id: 'new-pair', name: 'New' });
+
+ let result: PathPair | null | undefined;
+ service.create({
+ name: 'New',
+ remote_path: '/r',
+ local_path: '/l',
+ enabled: true,
+ auto_queue: false,
+ arr_target_ids: [],
+ }).subscribe(r => result = r);
+
+ const req = httpMock.expectOne('/server/pathpairs');
+ expect(req.request.method).toBe('POST');
+ req.flush(created);
+
+ expect(result!.id).toBe('new-pair');
+ expect(snapshot().map(p => p.id)).toEqual(['new-pair']);
+ });
+
+ it('should rethrow 409 conflict from create()', () => {
+ let result: PathPair | null | undefined;
+ let errorStatus: number | undefined;
+ service.create({
+ name: 'dup',
+ remote_path: '/r',
+ local_path: '/l',
+ enabled: true,
+ auto_queue: false,
+ arr_target_ids: [],
+ }).subscribe({
+ next: r => result = r,
+ error: err => errorStatus = err.status,
+ });
+
+ httpMock.expectOne('/server/pathpairs').flush('conflict', { status: 409, statusText: 'Conflict' });
+ expect(result).toBeUndefined();
+ expect(errorStatus).toBe(409);
+ });
+
+ it('should return null on non-409 create errors', () => {
+ let result: PathPair | null | undefined;
+ service.create({
+ name: 'X',
+ remote_path: '/r',
+ local_path: '/l',
+ enabled: true,
+ auto_queue: false,
+ arr_target_ids: [],
+ }).subscribe(r => result = r);
+
+ httpMock.expectOne('/server/pathpairs').flush('error', { status: 500, statusText: 'Error' });
+ expect(result).toBeNull();
+ });
+
+ // --- update() ---
+
+ it('should send PUT and replace pair in list by ID', () => {
+ connectedSubject.next(true);
+ httpMock.expectOne('/server/pathpairs').flush([makePair()]);
+
+ const updated = makePair({ name: 'Renamed' });
+
+ let result: PathPair | null | undefined;
+ service.update(updated).subscribe(r => result = r);
+
+ const req = httpMock.expectOne('/server/pathpairs/pair-1');
+ expect(req.request.method).toBe('PUT');
+ req.flush(updated);
+
+ expect(result!.name).toBe('Renamed');
+ expect(snapshot()[0].name).toBe('Renamed');
+ });
+
+ it('should rethrow 409 conflict from update()', () => {
+ connectedSubject.next(true);
+ httpMock.expectOne('/server/pathpairs').flush([makePair()]);
+
+ let errorStatus: number | undefined;
+ service.update(makePair({ name: 'dup' })).subscribe({
+ error: err => errorStatus = err.status,
+ });
+
+ httpMock.expectOne('/server/pathpairs/pair-1').flush('conflict', { status: 409, statusText: 'Conflict' });
+ expect(errorStatus).toBe(409);
+ });
+
+ it('should return null on non-409 error from update()', () => {
+ // Mirrors the create() non-409 coverage. update() rethrows 409 (caller
+ // displays the conflict message) but falls back to of(null) on any other
+ // HTTP error so subscribers can detect failure without an unhandled throw.
+ connectedSubject.next(true);
+ httpMock.expectOne('/server/pathpairs').flush([makePair()]);
+
+ let result: PathPair | null | undefined;
+ service.update(makePair({ name: 'other' })).subscribe(r => result = r);
+
+ httpMock.expectOne('/server/pathpairs/pair-1').flush('error', { status: 500, statusText: 'Server Error' });
+ expect(result).toBeNull();
+ });
+
+ // --- remove() ---
+
+ it('should send DELETE and filter pair out of list', () => {
+ connectedSubject.next(true);
+ httpMock.expectOne('/server/pathpairs').flush([makePair()]);
+ expect(snapshot().length).toBe(1);
+
+ let success: boolean | undefined;
+ service.remove('pair-1').subscribe(r => success = r);
+
+ const req = httpMock.expectOne('/server/pathpairs/pair-1');
+ expect(req.request.method).toBe('DELETE');
+ req.flush('', { status: 204, statusText: 'No Content' });
+
+ expect(success).toBe(true);
+ expect(snapshot()).toEqual([]);
+ });
+
+ it('should return false on remove() error', () => {
+ let success: boolean | undefined;
+ service.remove('pair-1').subscribe(r => success = r);
+
+ httpMock.expectOne('/server/pathpairs/pair-1').flush('error', { status: 500, statusText: 'Error' });
+ expect(success).toBe(false);
+ });
+
+ // --- Failed refresh ---
+
+ it('should clear pairs when refresh fails (catchError emits empty array)', () => {
+ connectedSubject.next(true);
+ httpMock.expectOne('/server/pathpairs').flush([makePair()]);
+ expect(snapshot().length).toBe(1);
+
+ // Trigger another refresh that fails. The service's catchError(of([]))
+ // turns the error into an empty list, which pairsSubject.next([]) emits
+ // to subscribers — the previously cached list is dropped.
+ service.refresh();
+ httpMock.expectOne('/server/pathpairs').error(new ProgressEvent('error'));
+
+ // Use the snapshot() helper (which pipes through take(1)) so this
+ // assertion doesn't leave a live subscriber on service.pairs$.
+ expect(snapshot()).toEqual([]);
+ });
+});
diff --git a/src/angular/src/app/services/utils/version-check.service.spec.ts b/src/angular/src/app/services/utils/version-check.service.spec.ts
new file mode 100644
index 00000000..6af87b00
--- /dev/null
+++ b/src/angular/src/app/services/utils/version-check.service.spec.ts
@@ -0,0 +1,188 @@
+import '@angular/compiler';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { TestBed } from '@angular/core/testing';
+import { of } from 'rxjs';
+
+import { VersionCheckService } from './version-check.service';
+import { RestService, WebReaction } from './rest.service';
+import { NotificationService } from './notification.service';
+import { LoggerService } from './logger.service';
+import { Notification, NotificationLevel } from '../../models/notification';
+import packageJson from '../../../../package.json';
+
+function makeReaction(overrides: Partial = {}): WebReaction {
+ return {
+ success: true,
+ data: null,
+ errorMessage: null,
+ ...overrides,
+ };
+}
+
+function makeGithubResponse(tagName: string, htmlUrl = 'https://github.com/nitrobass24/seedsync/releases/latest'): string {
+ return JSON.stringify({ tag_name: tagName, html_url: htmlUrl });
+}
+
+describe('VersionCheckService', () => {
+ let mockRestService: { sendRequest: ReturnType };
+ let notificationService: NotificationService;
+ let mockLogger: { debug: ReturnType; info: ReturnType; warn: ReturnType; error: ReturnType };
+
+ function createService(): VersionCheckService {
+ return TestBed.inject(VersionCheckService);
+ }
+
+ beforeEach(() => {
+ mockRestService = { sendRequest: vi.fn() };
+ mockLogger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
+
+ TestBed.configureTestingModule({
+ providers: [
+ VersionCheckService,
+ NotificationService,
+ { provide: RestService, useValue: mockRestService },
+ { provide: LoggerService, useValue: mockLogger },
+ ],
+ });
+
+ notificationService = TestBed.inject(NotificationService);
+ });
+
+ it('should show notification when a newer release is available', () => {
+ // 99.0.0 is chosen because it's always newer than the current package.json
+ // version, regardless of how that version evolves.
+ mockRestService.sendRequest.mockReturnValue(
+ of(makeReaction({ success: true, data: makeGithubResponse('v99.0.0') })),
+ );
+
+ let notifications: Notification[] = [];
+ notificationService.notifications$.subscribe(n => notifications = n);
+
+ createService();
+
+ expect(notifications.length).toBe(1);
+ expect(notifications[0].level).toBe(NotificationLevel.INFO);
+ expect(notifications[0].text).toContain('new version');
+ });
+
+ it('should not show notification when release is same version', () => {
+ // Use the live package.json version so this test stays correct
+ // across version bumps.
+ mockRestService.sendRequest.mockReturnValue(
+ of(makeReaction({ success: true, data: makeGithubResponse('v' + packageJson.version) })),
+ );
+
+ let notifications: Notification[] = [];
+ notificationService.notifications$.subscribe(n => notifications = n);
+
+ createService();
+
+ expect(notifications.length).toBe(0);
+ });
+
+ it('should not show notification when release is older', () => {
+ mockRestService.sendRequest.mockReturnValue(
+ of(makeReaction({ success: true, data: makeGithubResponse('v0.1.0') })),
+ );
+
+ let notifications: Notification[] = [];
+ notificationService.notifications$.subscribe(n => notifications = n);
+
+ createService();
+
+ expect(notifications.length).toBe(0);
+ });
+
+ it('should treat prefixed and non-prefixed releases identically', () => {
+ // Verify v-prefix stripping by running the same scenario twice — once
+ // with "v99.0.0", once with "99.0.0" — and asserting identical results.
+ // The TestBed has to be reset between runs because VersionCheckService
+ // is a singleton and the version check fires on construction.
+ function notificationsFor(tag: string): Notification[] {
+ TestBed.resetTestingModule();
+ mockRestService = { sendRequest: vi.fn() };
+ mockLogger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
+ mockRestService.sendRequest.mockReturnValue(
+ of(makeReaction({ success: true, data: makeGithubResponse(tag) })),
+ );
+ TestBed.configureTestingModule({
+ providers: [
+ VersionCheckService,
+ NotificationService,
+ { provide: RestService, useValue: mockRestService },
+ { provide: LoggerService, useValue: mockLogger },
+ ],
+ });
+ const ns = TestBed.inject(NotificationService);
+ let captured: Notification[] = [];
+ ns.notifications$.subscribe(n => captured = n);
+ createService();
+ return captured;
+ }
+
+ const withPrefix = notificationsFor('v99.0.0');
+ const withoutPrefix = notificationsFor('99.0.0');
+
+ expect(withPrefix).toHaveLength(1);
+ expect(withoutPrefix).toHaveLength(1);
+ expect(withoutPrefix[0].text).toBe(withPrefix[0].text);
+ expect(withoutPrefix[0].level).toBe(withPrefix[0].level);
+ });
+
+ it('should log warning and not show notification on network error', () => {
+ mockRestService.sendRequest.mockReturnValue(
+ of(makeReaction({ success: false, errorMessage: 'Network error' })),
+ );
+
+ let notifications: Notification[] = [];
+ notificationService.notifications$.subscribe(n => notifications = n);
+
+ createService();
+
+ expect(notifications.length).toBe(0);
+ expect(mockLogger.warn).toHaveBeenCalled();
+ });
+
+ it('should log error and not crash on malformed JSON response', () => {
+ mockRestService.sendRequest.mockReturnValue(
+ of(makeReaction({ success: true, data: 'not valid json {{{' })),
+ );
+
+ let notifications: Notification[] = [];
+ notificationService.notifications$.subscribe(n => notifications = n);
+
+ createService();
+
+ expect(notifications.length).toBe(0);
+ expect(mockLogger.error).toHaveBeenCalled();
+ });
+
+ it('should log error and not crash on unexpected JSON structure', () => {
+ mockRestService.sendRequest.mockReturnValue(
+ of(makeReaction({ success: true, data: JSON.stringify({ foo: 'bar' }) })),
+ );
+
+ let notifications: Notification[] = [];
+ notificationService.notifications$.subscribe(n => notifications = n);
+
+ createService();
+
+ expect(notifications.length).toBe(0);
+ expect(mockLogger.error).toHaveBeenCalled();
+ });
+
+ it('should include the release URL in the notification text', () => {
+ const url = 'https://github.com/nitrobass24/seedsync/releases/tag/v99.0.0';
+ mockRestService.sendRequest.mockReturnValue(
+ of(makeReaction({ success: true, data: makeGithubResponse('v99.0.0', url) })),
+ );
+
+ let notifications: Notification[] = [];
+ notificationService.notifications$.subscribe(n => notifications = n);
+
+ createService();
+
+ expect(notifications).toHaveLength(1);
+ expect(notifications[0].text).toContain(url);
+ });
+});
diff --git a/src/docker/build/docker-image/Dockerfile b/src/docker/build/docker-image/Dockerfile
index ddc3e5f8..f4049747 100644
--- a/src/docker/build/docker-image/Dockerfile
+++ b/src/docker/build/docker-image/Dockerfile
@@ -12,56 +12,80 @@ COPY src/angular/ ./
RUN npx ng build --configuration=production --output-path /build
# ==============================================================================
-# Stage 2: Prepare Python runtime (install deps)
+# Stage 2: Prepare Python runtime (install deps, strip stdlib)
# ==============================================================================
-FROM python:3.12-alpine AS python-deps
-
-COPY --from=ghcr.io/astral-sh/uv:0.11 /uv /usr/local/bin/uv
+# Pin to the same Alpine minor as the runtime stage (FROM alpine:3.23
+# below) so Python + C extensions in site-packages are compiled against
+# the same musl ABI they'll run on. The floating "python:3.13-alpine"
+# tag could otherwise resolve to a newer Alpine and silently introduce
+# a musl mismatch when /usr/local is copied into the runtime stage.
+FROM python:3.13-alpine3.23 AS python-deps
COPY src/python/pyproject.toml src/python/uv.lock /tmp/
-RUN uv pip install --system --no-cache --strict \
- -r /tmp/pyproject.toml \
- && rm -f /usr/local/bin/uv \
- && pip uninstall -y pip setuptools \
- && rm -rf /root/.cache /tmp/pyproject.toml /tmp/uv.lock \
+# `uv pip install -r pyproject.toml` re-resolves dependencies on every build
+# and ignores uv.lock — transitive versions can drift between identical
+# rebuilds. Export the lockfile to a frozen requirements list first so we
+# install the exact versions in uv.lock.
+WORKDIR /tmp
+RUN --mount=from=ghcr.io/astral-sh/uv:0.11,source=/uv,target=/tmp/uv \
+ /tmp/uv export --frozen --no-dev --no-emit-project -o /tmp/requirements.txt \
+ && /tmp/uv pip install --system --no-cache --strict \
+ -r /tmp/requirements.txt \
+ && rm -rf /usr/local/lib/python3.13/site-packages/pip* \
+ /usr/local/lib/python3.13/site-packages/setuptools* \
+ /usr/local/bin/pip* \
+ /usr/local/bin/wheel* \
+ && rm -rf /root/.cache /tmp/pyproject.toml /tmp/uv.lock /tmp/requirements.txt \
&& rm -rf \
- /usr/local/lib/python3.12/ensurepip \
- /usr/local/lib/python3.12/idlelib \
- /usr/local/lib/python3.12/turtle.py \
- /usr/local/lib/python3.12/turtledemo \
- /usr/local/lib/python3.12/tkinter \
- /usr/local/lib/python3.12/test \
- /usr/local/lib/python3.12/unittest \
- /usr/local/lib/python3.12/pydoc.py \
- /usr/local/lib/python3.12/pydoc_data \
- /usr/local/lib/python3.12/doctest.py \
- /usr/local/lib/python3.12/lib2to3 \
- /usr/local/lib/python3.12/distutils \
- /usr/local/lib/python3.12/lib-dynload/_test*.so \
- /usr/local/lib/python3.12/lib-dynload/_codecs_jp*.so \
- /usr/local/lib/python3.12/lib-dynload/_codecs_kr*.so \
- /usr/local/lib/python3.12/lib-dynload/_codecs_hk*.so \
- /usr/local/lib/python3.12/lib-dynload/_codecs_cn*.so \
- /usr/local/lib/python3.12/lib-dynload/_codecs_tw*.so \
- /usr/local/lib/python3.12/lib-dynload/_codecs_iso*.so \
- /usr/local/lib/python3.12/lib-dynload/_curses*.so \
- /usr/local/lib/python3.12/lib-dynload/_sqlite3*.so \
- /usr/local/lib/python3.12/lib-dynload/_lzma*.so \
- /usr/local/lib/python3.12/lib-dynload/_bz2*.so \
- /usr/local/lib/python3.12/lib-dynload/_crypt*.so \
- /usr/local/lib/python3.12/lib-dynload/_dbm*.so \
- /usr/local/lib/python3.12/lib-dynload/_gdbm*.so \
- /usr/local/lib/python3.12/lib-dynload/audioop*.so \
- /usr/local/lib/python3.12/lib-dynload/ossaudiodev*.so \
- /usr/local/lib/python3.12/lib-dynload/nis*.so \
- /usr/local/lib/python3.12/lib-dynload/readline*.so \
- /usr/local/lib/python3.12/lib-dynload/_ctypes_test*.so \
- && find /usr/local/lib/python3.12 -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
+ /usr/local/lib/python3.13/ensurepip \
+ /usr/local/lib/python3.13/idlelib \
+ /usr/local/lib/python3.13/turtle.py \
+ /usr/local/lib/python3.13/turtledemo \
+ /usr/local/lib/python3.13/tkinter \
+ /usr/local/lib/python3.13/test \
+ /usr/local/lib/python3.13/unittest \
+ /usr/local/lib/python3.13/pydoc.py \
+ /usr/local/lib/python3.13/pydoc_data \
+ /usr/local/lib/python3.13/doctest.py \
+ /usr/local/lib/python3.13/lib-dynload/_test*.so \
+ /usr/local/lib/python3.13/lib-dynload/_codecs_jp*.so \
+ /usr/local/lib/python3.13/lib-dynload/_codecs_kr*.so \
+ /usr/local/lib/python3.13/lib-dynload/_codecs_hk*.so \
+ /usr/local/lib/python3.13/lib-dynload/_codecs_cn*.so \
+ /usr/local/lib/python3.13/lib-dynload/_codecs_tw*.so \
+ /usr/local/lib/python3.13/lib-dynload/_codecs_iso*.so \
+ /usr/local/lib/python3.13/lib-dynload/_curses*.so \
+ /usr/local/lib/python3.13/lib-dynload/_sqlite3*.so \
+ /usr/local/lib/python3.13/lib-dynload/_lzma*.so \
+ /usr/local/lib/python3.13/lib-dynload/_bz2*.so \
+ /usr/local/lib/python3.13/lib-dynload/_dbm*.so \
+ /usr/local/lib/python3.13/lib-dynload/_gdbm*.so \
+ /usr/local/lib/python3.13/lib-dynload/readline*.so \
+ /usr/local/lib/python3.13/lib-dynload/_ctypes_test*.so \
+ /usr/local/lib/python3.13/lib-dynload/xx*.so \
+ /usr/local/lib/python3.13/lib-dynload/_tkinter*.so \
+ /usr/local/lib/python3.13/lib-dynload/_xxtestfuzz*.so \
+ /usr/local/lib/python3.13/lib-dynload/_lsprof*.so \
+ /usr/local/lib/python3.13/lib-dynload/_interp*.so \
+ /usr/local/lib/python3.13/_pyrepl \
+ /usr/local/lib/python3.13/_pydecimal.py \
+ /usr/local/lib/python3.13/venv \
+ /usr/local/lib/python3.13/config-3.13-* \
+ /usr/local/include \
+ && find /usr/local/lib/python3.13/encodings -name '*.py' \
+ ! -name '__init__.py' ! -name 'aliases.py' \
+ ! -name 'ascii.py' ! -name 'latin_1.py' \
+ ! -name 'utf_8.py' ! -name 'utf_8_sig.py' \
+ ! -name 'utf_16*.py' ! -name 'utf_32*.py' \
+ ! -name 'unicode_escape.py' ! -name 'raw_unicode_escape.py' \
+ ! -name 'charmap.py' ! -name 'idna.py' \
+ -delete \
+ && find /usr/local/lib/python3.13 -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
# ==============================================================================
# Stage 3: Final Runtime Image
# ==============================================================================
-FROM alpine:3.21 AS runtime
+FROM alpine:3.23 AS runtime
LABEL maintainer="nitrobass24" \
description="SeedSync - Seedbox file synchronization tool"
@@ -82,7 +106,14 @@ RUN apk add --no-cache \
openssh-client \
ca-certificates \
setpriv \
- libstdc++
+ libstdc++ \
+ && rm -rf /sbin/apk /etc/apk /usr/share/apk /lib/apk /var/cache/apk \
+ /lib/libapk.so* /usr/lib/libapk.so* /usr/bin/scanelf \
+ /usr/bin/ssh-keyscan /usr/bin/ssh-add /usr/bin/ssh-agent \
+ /usr/bin/ssh-pkcs11-helper /usr/bin/ssh-copy-id \
+ /usr/bin/findssl.sh /usr/bin/c_rehash \
+ /usr/bin/iconv /usr/bin/getconf /usr/bin/ssl_client \
+ /etc/ssh/moduli
ENV LC_ALL=C.UTF-8 \
LANG=C.UTF-8 \
@@ -92,8 +123,9 @@ ENV LC_ALL=C.UTF-8 \
# Create directory structure
RUN mkdir -p /app/python /app/html /config /downloads /staging
-# Copy application source
+# Copy application source (remove dev config files not needed at runtime)
COPY src/python/ /app/python/
+RUN rm -f /app/python/pyproject.toml /app/python/uv.lock
# Copy built artifacts from previous stages
COPY --from=angular-builder /build/browser /app/html
diff --git a/src/docker/build/docker-image/Dockerfile.dockerignore b/src/docker/build/docker-image/Dockerfile.dockerignore
index bff773b7..67f23b6e 100644
--- a/src/docker/build/docker-image/Dockerfile.dockerignore
+++ b/src/docker/build/docker-image/Dockerfile.dockerignore
@@ -2,9 +2,13 @@
**/__pycache__
**/node_modules
**/.venv
+**/.ruff_cache
+**/.pytest_cache
+**/pyrightconfig.json
.git
.idea
build
src/angular/dist
src/python/tests
src/python/build
+src/python/typings
diff --git a/src/docker/build/test-image/Dockerfile b/src/docker/build/test-image/Dockerfile
index 65fa7d4e..385475da 100644
--- a/src/docker/build/test-image/Dockerfile
+++ b/src/docker/build/test-image/Dockerfile
@@ -1,16 +1,16 @@
-FROM python:3.12-slim-bookworm
-RUN apt-get update && apt-get install -y --no-install-recommends lftp openssh-client \
- && rm -rf /var/lib/apt/lists/*
+FROM python:3.13-alpine
-COPY --from=ghcr.io/astral-sh/uv:0.11 /uv /usr/local/bin/uv
+RUN apk add --no-cache lftp openssh-client
COPY src/python/pyproject.toml src/python/uv.lock /tmp/
-RUN uv pip install --system --no-cache --strict \
+RUN --mount=from=ghcr.io/astral-sh/uv:0.11,source=/uv,target=/tmp/uv \
+ /tmp/uv pip install --system --no-cache --strict \
-r /tmp/pyproject.toml --group test \
- && rm -f /usr/local/bin/uv \
&& rm /tmp/pyproject.toml /tmp/uv.lock
-RUN groupadd -r testuser && useradd -r -g testuser -d /app/python -s /sbin/nologin testuser \
+
+RUN addgroup -S testuser && adduser -S -G testuser -h /app/python -s /sbin/nologin testuser \
&& mkdir -p /app/python && chown testuser:testuser /app/python
+
WORKDIR /app/python
ENV PYTHONPATH=/app/python
USER testuser
diff --git a/src/docker/test/angular/Dockerfile b/src/docker/test/angular/Dockerfile
deleted file mode 100644
index 0f1442e5..00000000
--- a/src/docker/test/angular/Dockerfile
+++ /dev/null
@@ -1,27 +0,0 @@
-FROM node:20-bookworm-slim AS seedsync_test_angular
-
-RUN apt-get update && apt-get install -y --no-install-recommends \
- wget \
- gnupg \
- && wget -nv -O /tmp/chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \
- && dpkg -i /tmp/chrome.deb || apt-get -fy install > /dev/null \
- && rm -rf /var/lib/apt/lists/*
-
-WORKDIR /app
-
-# Copy Angular 17 configuration files
-COPY src/angular/package*.json ./
-RUN npm install --include=dev --silent
-
-COPY \
- src/angular/tsconfig.json \
- src/angular/tsconfig.app.json \
- src/angular/tsconfig.spec.json \
- src/angular/angular.json \
- /app/
-
-COPY src/angular/src /app/src
-
-CMD ["./node_modules/.bin/ng", "test", \
- "--browsers", "ChromeHeadless", \
- "--watch=false"]
diff --git a/src/docker/test/angular/compose.yml b/src/docker/test/angular/compose.yml
deleted file mode 100644
index 17f79f6f..00000000
--- a/src/docker/test/angular/compose.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-version: "3.4"
-services:
- tests:
- image: seedsync/test/angular
- container_name: seedsync_test_angular
- tty: true
- build:
- context: ../../../../
- dockerfile: src/docker/test/angular/Dockerfile
- target: seedsync_test_angular
- volumes:
- - type: bind
- source: ../../../angular/src
- target: /app/src
- read_only: true
diff --git a/src/docker/test/python/Dockerfile b/src/docker/test/python/Dockerfile
deleted file mode 100644
index c207663b..00000000
--- a/src/docker/test/python/Dockerfile
+++ /dev/null
@@ -1,40 +0,0 @@
-FROM seedsync/run/python/devenv as seedsync_test_python
-
-RUN ls -l /app/python
-
-# Install dependencies
-RUN apt-get install -y software-properties-common && \
- apt-add-repository non-free && \
- apt-get update && \
- apt-get install -y \
- openssh-server \
- rar
-
-ADD src/docker/test/python/entrypoint.sh /app/
-
-# setup sshd
-RUN mkdir /var/run/sshd
-RUN ssh-keygen -t rsa -N "" -f /root/.ssh/id_rsa && \
- cat /root/.ssh/id_rsa.pub >> /root/.ssh/authorized_keys
-# Disable the known hosts prompt
-RUN echo "StrictHostKeyChecking no\nUserKnownHostsFile /dev/null\nLogLevel=quiet" > /root/.ssh/config
-
-# create the seedsynctest user, add root's public key to seedsynctest
-RUN useradd --create-home -s /bin/bash seedsynctest && \
- echo "seedsynctest:seedsyncpass" | chpasswd
-RUN usermod -a -G root seedsynctest
-RUN cp /root/.ssh/id_rsa.pub /home/seedsynctest/ && \
- chown seedsynctest:seedsynctest /home/seedsynctest/id_rsa.pub
-USER seedsynctest
-RUN mkdir -p /home/seedsynctest/.ssh && \
- cat /home/seedsynctest/id_rsa.pub >> /home/seedsynctest/.ssh/authorized_keys
-USER root
-
-EXPOSE 22
-
-# src needs to be mounted on /src/
-WORKDIR /src/
-ENV PYTHONPATH=/src
-
-ENTRYPOINT ["/app/entrypoint.sh"]
-CMD ["pytest", "-v"]
diff --git a/src/docker/test/python/compose.yml b/src/docker/test/python/compose.yml
deleted file mode 100644
index f2eb014a..00000000
--- a/src/docker/test/python/compose.yml
+++ /dev/null
@@ -1,14 +0,0 @@
-version: "3.4"
-services:
- tests:
- image: seedsync/test/python
- container_name: seedsync_test_python
- build:
- context: ../../../../
- dockerfile: src/docker/test/python/Dockerfile
- target: seedsync_test_python
- volumes:
- - type: bind
- source: ../../../python
- target: /src
- read_only: true
diff --git a/src/docker/test/python/entrypoint.sh b/src/docker/test/python/entrypoint.sh
deleted file mode 100755
index c49b5bff..00000000
--- a/src/docker/test/python/entrypoint.sh
+++ /dev/null
@@ -1,11 +0,0 @@
-#!/bin/bash
-
-# exit on first error
-set -e
-
-echo "Running sshd"
-/usr/sbin/sshd -D &
-
-echo "Continuing entrypoint"
-echo "$@"
-exec $@
diff --git a/src/e2e-playwright/package-lock.json b/src/e2e-playwright/package-lock.json
new file mode 100644
index 00000000..f190de2e
--- /dev/null
+++ b/src/e2e-playwright/package-lock.json
@@ -0,0 +1,76 @@
+{
+ "name": "seedsync-e2e",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "seedsync-e2e",
+ "devDependencies": {
+ "@playwright/test": "^1.52.0"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
+ "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/src/e2e-playwright/playwright.config.ts b/src/e2e-playwright/playwright.config.ts
index 91a67c63..b22cf169 100644
--- a/src/e2e-playwright/playwright.config.ts
+++ b/src/e2e-playwright/playwright.config.ts
@@ -5,6 +5,11 @@ export default defineConfig({
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: false, // tests share a Docker container per file
+ // ubuntu-latest has 4 vCPUs. Each worker runs its own chromium and
+ // shares the host's CPU pool with the SeedSync container and runner
+ // overhead — pushing past 4 oversubscribes and makes the suite
+ // slower overall.
+ workers: 4,
retries: 0,
reporter: [["html", { open: "never" }], ["list"]],
use: {
diff --git a/src/e2e-playwright/tests/error-states.spec.ts b/src/e2e-playwright/tests/error-states.spec.ts
new file mode 100644
index 00000000..2d111c03
--- /dev/null
+++ b/src/e2e-playwright/tests/error-states.spec.ts
@@ -0,0 +1,268 @@
+import { test, expect } from "./fixtures";
+
+test.describe("Error States — Header Notifications", () => {
+ // The noEnabledPairs notification only appears when the server is fully
+ // configured and running (status.server.up === true). CI containers start
+ // with incomplete config so the server reports "not up" and the controller
+ // never evaluates pair state. Skip these tests when the server is down.
+
+ /**
+ * Helper: disable all path pairs and ensure at least one disabled pair exists.
+ *
+ * The backend only sets `no_enabled_pairs = true` when there are path pairs
+ * configured but none are enabled. If no pairs exist at all, it falls through
+ * to the legacy single-path mode. So every test that expects the
+ * "no enabled pairs" notification must guarantee a disabled pair exists.
+ *
+ * Returns cleanup info so the caller can restore state in a `finally` block.
+ */
+ async function disableAllPairs(apiFetch: (path: string, init?: RequestInit) => Promise) {
+ const res = await apiFetch("/server/pathpairs");
+ expect(res.ok, `GET /server/pathpairs failed: ${res.status}`).toBe(true);
+ const pairs = await res.json();
+ const originalStates: { id: string; enabled: boolean }[] = [];
+
+ for (const pair of pairs) {
+ originalStates.push({ id: pair.id, enabled: pair.enabled });
+ if (pair.enabled) {
+ const updateRes = await apiFetch(`/server/pathpairs/${pair.id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled: false }),
+ });
+ expect(updateRes.ok, `PUT pathpairs/${pair.id} failed: ${updateRes.status}`).toBe(true);
+ }
+ }
+
+ // If no pairs exist at all, create a disabled one so the backend reports
+ // noEnabledPairs=true rather than falling through to legacy single-path mode.
+ let tempPairId: string | null = null;
+ if (pairs.length === 0) {
+ const createRes = await apiFetch("/server/pathpairs", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: "e2e-disabled-pair",
+ remote_path: "/remote/e2e",
+ local_path: "/local/e2e",
+ enabled: false,
+ }),
+ });
+ expect(createRes.ok, `POST /server/pathpairs failed: ${createRes.status}`).toBe(true);
+ const created = await createRes.json();
+ tempPairId = created.id;
+ }
+
+ return { originalStates, tempPairId };
+ }
+
+ /**
+ * Helper: restore original pair states and clean up any temp pair.
+ */
+ async function restorePairs(
+ apiFetch: (path: string, init?: RequestInit) => Promise,
+ originalStates: { id: string; enabled: boolean }[],
+ tempPairId: string | null,
+ ) {
+ if (tempPairId) {
+ await apiFetch(`/server/pathpairs/${tempPairId}`, { method: "DELETE" });
+ }
+ for (const state of originalStates) {
+ if (state.enabled) {
+ await apiFetch(`/server/pathpairs/${state.id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled: true }),
+ });
+ }
+ }
+ }
+
+ test("no enabled pairs shows warning notification", async ({
+ page,
+ apiGet,
+ apiFetch,
+ waitForStream,
+ }) => {
+ const status = await apiGet("/server/status");
+ test.skip(!status.server.up, "Server is not fully configured — noEnabledPairs notification requires server.up");
+
+ const { originalStates, tempPairId } = await disableAllPairs(apiFetch);
+
+ try {
+ await page.goto("/dashboard");
+ await waitForStream(page);
+
+ const notification = page.locator("#header .alert", {
+ hasText: /path pairs are disabled/i,
+ });
+ await expect(notification).toBeVisible({ timeout: 15_000 });
+ await expect(notification).toHaveClass(/alert-warning/);
+ } finally {
+ await restorePairs(apiFetch, originalStates, tempPairId);
+ }
+ });
+
+ test("no enabled pairs notification text matches expected string", async ({
+ page,
+ apiGet,
+ apiFetch,
+ waitForStream,
+ }) => {
+ const status = await apiGet("/server/status");
+ test.skip(!status.server.up, "Server is not fully configured — noEnabledPairs notification requires server.up");
+
+ const { originalStates, tempPairId } = await disableAllPairs(apiFetch);
+
+ try {
+ await page.goto("/dashboard");
+ await waitForStream(page);
+
+ const notification = page.locator("#header .alert", {
+ hasText: /path pairs are disabled/i,
+ });
+ await expect(notification).toBeVisible({ timeout: 15_000 });
+ await expect(notification).toContainText(
+ "Enable a pair in Settings to start syncing"
+ );
+ } finally {
+ await restorePairs(apiFetch, originalStates, tempPairId);
+ }
+ });
+
+ test("restart notification appears after config change on settings page", async ({
+ page,
+ waitForStream,
+ apiGet,
+ apiSetConfig,
+ }) => {
+ await page.goto("/settings");
+ await waitForStream(page);
+
+ const configBefore = await apiGet("/server/config/get");
+ const originalAddress = configBefore.lftp.remote_address;
+
+ try {
+ const field = page
+ .locator("app-option", { hasText: "Server Address" })
+ .locator("input[type='text'], input[type='password']");
+ await field.clear();
+ await field.fill("trigger-restart-" + Date.now());
+
+ // The restart notification should appear in the header
+ const notification = page.locator("#header .alert", {
+ hasText: /restart/i,
+ });
+ await expect(notification).toBeVisible({ timeout: 5000 });
+ } finally {
+ await apiSetConfig("lftp", "remote_address", originalAddress ?? "");
+ }
+ });
+
+ test("notification has correct alert level styling", async ({
+ page,
+ apiGet,
+ apiFetch,
+ waitForStream,
+ }) => {
+ const status = await apiGet("/server/status");
+ test.skip(!status.server.up, "Server is not fully configured — noEnabledPairs notification requires server.up");
+
+ const { originalStates, tempPairId } = await disableAllPairs(apiFetch);
+
+ try {
+ await page.goto("/dashboard");
+ await waitForStream(page);
+
+ // The no-enabled-pairs notification should be a warning (not danger or info)
+ const notification = page.locator("#header .alert", {
+ hasText: /path pairs are disabled/i,
+ });
+ await expect(notification).toBeVisible({ timeout: 10_000 });
+ await expect(notification).toHaveClass(/alert-warning/);
+ await expect(notification).not.toHaveClass(/alert-danger/);
+ await expect(notification).not.toHaveClass(/alert-info/);
+ } finally {
+ await restorePairs(apiFetch, originalStates, tempPairId);
+ }
+ });
+
+ test("re-enabling a pair clears the no-enabled-pairs warning", async ({
+ page,
+ apiGet,
+ apiFetch,
+ waitForStream,
+ }) => {
+ const status = await apiGet("/server/status");
+ test.skip(!status.server.up, "Server is not fully configured — noEnabledPairs notification requires server.up");
+ // Create a disabled pair for this test
+ const pairName = `e2e-reenable-${Date.now()}`;
+ const createRes = await apiFetch("/server/pathpairs", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: pairName,
+ remote_path: "/remote/reenable",
+ local_path: "/local/reenable",
+ enabled: false,
+ }),
+ });
+ expect(createRes.ok, `POST /server/pathpairs failed: ${createRes.status}`).toBe(true);
+ const createdPair = await createRes.json();
+
+ // Also disable any other enabled pairs
+ const listRes = await apiFetch("/server/pathpairs");
+ expect(listRes.ok, `GET /server/pathpairs failed: ${listRes.status}`).toBe(true);
+ const allPairs = await listRes.json();
+ const originalStates: { id: string; enabled: boolean }[] = [];
+ for (const pair of allPairs) {
+ if (pair.id !== createdPair.id) {
+ originalStates.push({ id: pair.id, enabled: pair.enabled });
+ if (pair.enabled) {
+ const updateRes = await apiFetch(`/server/pathpairs/${pair.id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled: false }),
+ });
+ expect(updateRes.ok, `PUT pathpairs/${pair.id} failed: ${updateRes.status}`).toBe(true);
+ }
+ }
+ }
+
+ try {
+ await page.goto("/dashboard");
+ await waitForStream(page);
+
+ // Warning should be visible
+ const notification = page.locator("#header .alert", {
+ hasText: /path pairs are disabled/i,
+ });
+ await expect(notification).toBeVisible({ timeout: 10_000 });
+
+ // Re-enable the pair via API
+ const enableRes = await apiFetch(`/server/pathpairs/${createdPair.id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled: true }),
+ });
+ expect(enableRes.ok, `PUT pathpairs/${createdPair.id} failed: ${enableRes.status}`).toBe(true);
+
+ // The warning should disappear (SSE pushes status updates)
+ await expect(notification).not.toBeVisible({ timeout: 10_000 });
+ } finally {
+ // Clean up: delete the test pair and restore others
+ await apiFetch(`/server/pathpairs/${createdPair.id}`, {
+ method: "DELETE",
+ });
+ for (const state of originalStates) {
+ if (state.enabled) {
+ await apiFetch(`/server/pathpairs/${state.id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled: true }),
+ });
+ }
+ }
+ }
+ });
+});
diff --git a/src/e2e-playwright/tests/file-actions.spec.ts b/src/e2e-playwright/tests/file-actions.spec.ts
new file mode 100644
index 00000000..71fb9b9a
--- /dev/null
+++ b/src/e2e-playwright/tests/file-actions.spec.ts
@@ -0,0 +1,164 @@
+import { test, expect } from "./fixtures";
+import { DashboardPage } from "./pages/dashboard.page";
+
+test.describe("File Actions", () => {
+ let dashboard: DashboardPage;
+ let fileCount: number;
+
+ test.beforeEach(async ({ page, waitForStream }) => {
+ dashboard = new DashboardPage(page);
+ await dashboard.goto();
+ await waitForStream(page);
+ fileCount = await dashboard.getFileRows().count();
+ });
+
+ test("file row click reveals action buttons with correct labels", async () => {
+ test.skip(fileCount === 0, "No files present on the remote seedbox");
+
+ const row = dashboard.getFileRows().first();
+ await row.click();
+
+ // All 6 action buttons should be present (some may be disabled)
+ await expect(dashboard.getActionButton(row, "Queue")).toBeVisible();
+ await expect(dashboard.getActionButton(row, "Stop")).toBeVisible();
+ await expect(dashboard.getActionButton(row, "Extract")).toBeVisible();
+ await expect(dashboard.getActionButton(row, "Validate")).toBeVisible();
+ await expect(dashboard.getActionButton(row, "Delete Local")).toBeVisible();
+ await expect(dashboard.getActionButton(row, "Delete Remote")).toBeVisible();
+ });
+
+ test("action buttons have correct disabled state based on file status", async () => {
+ test.skip(fileCount === 0, "No files present on the remote seedbox");
+
+ const row = dashboard.getFileRows().first();
+ await row.click();
+
+ // At least one action button should exist and have a disabled attribute (either true or false)
+ const buttons = row.locator(".actions button");
+ const count = await buttons.count();
+ expect(count).toBe(6);
+
+ // Each button should have a deterministic disabled state (not missing the attribute)
+ for (let i = 0; i < count; i++) {
+ const btn = buttons.nth(i);
+ const isDisabled = await btn.isDisabled();
+ expect(typeof isDisabled).toBe("boolean");
+ }
+ });
+
+ test("Delete Local button requires confirmation (double-click pattern)", async () => {
+ test.skip(fileCount === 0, "No files present on the remote seedbox");
+
+ const row = dashboard.getFileRows().first();
+ await row.click();
+
+ const deleteLocalBtn = dashboard.getActionButton(row, "Delete Local");
+ const isDisabled = await deleteLocalBtn.isDisabled();
+ test.skip(isDisabled, "Delete Local is disabled for this file (no local copy)");
+
+ // First click should change text to "Confirm?"
+ await deleteLocalBtn.click();
+ await expect(deleteLocalBtn).toContainText("Confirm?");
+ });
+
+ test("Delete Remote button requires confirmation (double-click pattern)", async () => {
+ test.skip(fileCount === 0, "No files present on the remote seedbox");
+
+ const row = dashboard.getFileRows().first();
+ await row.click();
+
+ const deleteRemoteBtn = dashboard.getActionButton(row, "Delete Remote");
+ const isDisabled = await deleteRemoteBtn.isDisabled();
+ test.skip(isDisabled, "Delete Remote is disabled for this file");
+
+ // First click should change text to "Confirm?"
+ await deleteRemoteBtn.click();
+ await expect(deleteRemoteBtn).toContainText("Confirm?");
+ });
+
+ test("clicking a different file row deselects the previous one", async () => {
+ test.skip(fileCount < 2, "Need at least 2 files to test selection switching");
+
+ const rows = dashboard.getFileRows();
+ const firstRow = rows.nth(0);
+ const secondRow = rows.nth(1);
+
+ // Click first row to select it
+ await firstRow.click();
+ await expect(firstRow).toHaveClass(/selected/);
+
+ // Click second row — first should deselect
+ await secondRow.click();
+ await expect(secondRow).toHaveClass(/selected/);
+ await expect(firstRow).not.toHaveClass(/selected/);
+ });
+
+ test("re-clicking selected file row deselects it", async () => {
+ test.skip(fileCount === 0, "No files present on the remote seedbox");
+
+ const row = dashboard.getFileRows().first();
+
+ // Click to select
+ await row.click();
+ await expect(row.locator(".actions")).toBeVisible();
+
+ // Click again to deselect (actions should hide)
+ await row.click();
+ // After deselecting, the actions div should not be visible
+ // (actions are only shown when selected via CSS)
+ await expect(row).not.toHaveClass(/selected/);
+ await expect(row.locator(".actions")).not.toBeVisible();
+ });
+});
+
+test.describe("File Actions — Bulk operations", () => {
+ let dashboard: DashboardPage;
+ let fileCount: number;
+
+ test.beforeEach(async ({ page, waitForStream }) => {
+ dashboard = new DashboardPage(page);
+ await dashboard.goto();
+ await waitForStream(page);
+ fileCount = await dashboard.getFileRows().count();
+ });
+
+ test("bulk Queue button is present when files are checked", async () => {
+ test.skip(fileCount < 1, "Need at least 1 file for bulk action");
+
+ const rows = dashboard.getFileRows();
+ await dashboard.getCheckbox(rows.first()).check();
+ await expect(dashboard.bulkActionBar).toBeVisible();
+ await expect(dashboard.getBulkButton("Queue")).toBeVisible();
+ });
+
+ test("bulk Stop button is present when files are checked", async () => {
+ test.skip(fileCount < 1, "Need at least 1 file for bulk action");
+
+ const rows = dashboard.getFileRows();
+ await dashboard.getCheckbox(rows.first()).check();
+ await expect(dashboard.bulkActionBar).toBeVisible();
+ await expect(dashboard.getBulkButton("Stop")).toBeVisible();
+ });
+
+ test("bulk Delete Local and Delete Remote buttons are present", async () => {
+ test.skip(fileCount < 1, "Need at least 1 file for bulk action");
+
+ const rows = dashboard.getFileRows();
+ await dashboard.getCheckbox(rows.first()).check();
+ await expect(dashboard.getBulkButton("Delete Local")).toBeVisible();
+ await expect(dashboard.getBulkButton("Delete Remote")).toBeVisible();
+ });
+
+ test("unchecking all files hides the bulk action bar", async () => {
+ test.skip(fileCount < 1, "Need at least 1 file for this test");
+
+ const rows = dashboard.getFileRows();
+ const checkbox = dashboard.getCheckbox(rows.first());
+
+ await checkbox.check();
+ await expect(dashboard.bulkActionBar).toBeVisible();
+
+ await checkbox.uncheck();
+ await expect(dashboard.bulkActionBar).not.toBeVisible();
+ });
+});
diff --git a/src/e2e-playwright/tests/integrations.spec.ts b/src/e2e-playwright/tests/integrations.spec.ts
new file mode 100644
index 00000000..fe2109f7
--- /dev/null
+++ b/src/e2e-playwright/tests/integrations.spec.ts
@@ -0,0 +1,383 @@
+import { test, expect } from "./fixtures";
+import { IntegrationsPage } from "./pages/integrations.page";
+
+// Wrap a fixture-setup API call so failures surface as "fixture creation
+// failed" instead of cascading into confusing UI assertion failures.
+async function expectFixtureOk(res: Response, label: string): Promise {
+ if (!res.ok) {
+ throw new Error(`fixture creation failed: ${label} → ${res.status} ${res.statusText}`);
+ }
+}
+
+test.describe("Integrations CRUD", () => {
+ let integrations: IntegrationsPage;
+
+ // Clean up all integrations before each test for a clean slate. Fail fast
+ // on any API error so the test stops with a clear message instead of
+ // proceeding against stale or unknown state.
+ test.beforeEach(async ({ page, apiFetch }) => {
+ const res = await apiFetch("/server/integrations");
+ if (!res.ok) {
+ throw new Error(`GET /server/integrations failed: ${res.status} ${res.statusText}`);
+ }
+ const instances = await res.json();
+ for (const inst of instances) {
+ const del = await apiFetch(`/server/integrations/${inst.id}`, {
+ method: "DELETE",
+ });
+ if (!del.ok) {
+ throw new Error(
+ `DELETE /server/integrations/${inst.id} failed: ${del.status} ${del.statusText}`,
+ );
+ }
+ }
+
+ integrations = new IntegrationsPage(page);
+ await integrations.goto();
+ });
+
+ // Clean up after each test. Same fail-fast contract as beforeEach so
+ // teardown failures surface as test failures instead of being swallowed.
+ test.afterEach(async ({ apiFetch }) => {
+ const res = await apiFetch("/server/integrations");
+ if (!res.ok) {
+ throw new Error(`GET /server/integrations failed during cleanup: ${res.status} ${res.statusText}`);
+ }
+ const instances = await res.json();
+ for (const inst of instances) {
+ const del = await apiFetch(`/server/integrations/${inst.id}`, {
+ method: "DELETE",
+ });
+ if (!del.ok) {
+ throw new Error(
+ `DELETE /server/integrations/${inst.id} failed during cleanup: ${del.status} ${del.statusText}`,
+ );
+ }
+ }
+ });
+
+ test("empty state shows 'No integrations' message and add buttons", async () => {
+ await expect(integrations.emptyState).toBeVisible();
+ await expect(integrations.emptyState).toContainText("No integrations");
+ await expect(integrations.addSonarrButton).toBeVisible();
+ await expect(integrations.addRadarrButton).toBeVisible();
+ });
+
+ test("clicking + Sonarr opens add form with Sonarr pre-selected", async () => {
+ await integrations.addSonarrButton.click();
+ await expect(integrations.instanceForm).toBeVisible();
+
+ const kindSelect = integrations.instanceForm.locator("select");
+ await expect(kindSelect).toHaveValue("sonarr");
+ });
+
+ test("clicking + Radarr opens add form with Radarr pre-selected", async () => {
+ await integrations.addRadarrButton.click();
+ await expect(integrations.instanceForm).toBeVisible();
+
+ const kindSelect = integrations.instanceForm.locator("select");
+ await expect(kindSelect).toHaveValue("radarr");
+ });
+
+ test("save with empty name shows validation error", async () => {
+ await integrations.addSonarrButton.click();
+ await integrations.fillForm({
+ url: "http://localhost:8989",
+ apiKey: "test-key",
+ });
+ await integrations.clickSave();
+
+ await expect(integrations.errorMessage).toBeVisible();
+ await expect(integrations.errorMessage).toContainText("Name is required");
+ });
+
+ test("fill and save creates integration visible in list and API", async ({
+ apiFetch,
+ }) => {
+ await integrations.addSonarrButton.click();
+ await integrations.fillForm({
+ name: "E2E Sonarr",
+ url: "http://localhost:8989",
+ apiKey: "test-api-key-123",
+ });
+ await integrations.clickSave();
+
+ // Form should close
+ await expect(integrations.instanceForm).not.toBeVisible({
+ timeout: 10_000,
+ });
+
+ // Instance should appear in the list
+ const row = integrations.getInstanceByName("E2E Sonarr");
+ await expect(row).toBeVisible({ timeout: 10_000 });
+
+ // Verify via API
+ await expect
+ .poll(
+ async () => {
+ const res = await apiFetch("/server/integrations");
+ if (!res.ok) {
+ throw new Error(`GET /server/integrations failed: ${res.status} ${res.statusText}`);
+ }
+ const instances = await res.json();
+ return instances.find(
+ (i: { name: string }) => i.name === "E2E Sonarr"
+ );
+ },
+ { timeout: 5000 }
+ )
+ .toEqual(
+ expect.objectContaining({
+ name: "E2E Sonarr",
+ kind: "sonarr",
+ url: "http://localhost:8989",
+ })
+ );
+ });
+
+ test("edit populates form and save updates the instance", async ({
+ apiFetch,
+ }) => {
+ // Create an integration via API
+ const created = await apiFetch("/server/integrations", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: "Edit Me",
+ kind: "sonarr",
+ url: "http://localhost:8989",
+ api_key: "old-key",
+ enabled: true,
+ }),
+ });
+ await expectFixtureOk(created, 'POST /server/integrations (Edit Me)');
+
+ await integrations.goto();
+
+ const row = integrations.getInstanceByName("Edit Me");
+ await row.waitFor({ timeout: 10_000 });
+ await integrations.getEditButton(row).click();
+
+ // Form should be visible with existing values
+ await expect(integrations.instanceForm).toBeVisible();
+ const nameInput = integrations.instanceForm.locator(
+ 'label:has-text("Name") input'
+ );
+ await expect(nameInput).toHaveValue("Edit Me");
+
+ // Update the URL
+ await integrations.fillForm({
+ url: "http://localhost:7878",
+ });
+ await integrations.clickSave();
+
+ // Verify via API
+ await expect
+ .poll(
+ async () => {
+ const res = await apiFetch("/server/integrations");
+ if (!res.ok) {
+ throw new Error(`GET /server/integrations failed: ${res.status} ${res.statusText}`);
+ }
+ const instances = await res.json();
+ return instances.find(
+ (i: { name: string }) => i.name === "Edit Me"
+ );
+ },
+ { timeout: 5000 }
+ )
+ .toEqual(
+ expect.objectContaining({
+ url: "http://localhost:7878",
+ })
+ );
+ });
+
+ test("single-click delete shows confirmation state", async ({
+ apiFetch,
+ }) => {
+ // Create an integration via API
+ const created = await apiFetch("/server/integrations", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: "Confirm Delete",
+ kind: "radarr",
+ url: "http://localhost:7878",
+ api_key: "key",
+ enabled: true,
+ }),
+ });
+ await expectFixtureOk(created, 'POST /server/integrations (Confirm Delete)');
+
+ await integrations.goto();
+
+ const row = integrations.getInstanceByName("Confirm Delete");
+ await row.waitFor({ timeout: 10_000 });
+ const deleteBtn = integrations.getDeleteButton(row);
+
+ // First click — should enter confirmation state
+ await deleteBtn.click();
+ await expect(deleteBtn).toContainText("Confirm?");
+ await expect(deleteBtn).toHaveClass(/confirming/);
+ });
+
+ test("double-click delete removes the integration", async ({
+ apiFetch,
+ }) => {
+ // Create an integration via API
+ const created = await apiFetch("/server/integrations", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: "Delete Me",
+ kind: "sonarr",
+ url: "http://localhost:8989",
+ api_key: "key",
+ enabled: true,
+ }),
+ });
+ await expectFixtureOk(created, 'POST /server/integrations (Delete Me)');
+
+ await integrations.goto();
+
+ const row = integrations.getInstanceByName("Delete Me");
+ await row.waitFor({ timeout: 10_000 });
+ const deleteBtn = integrations.getDeleteButton(row);
+
+ // First click — confirmation
+ await deleteBtn.click();
+ await expect(deleteBtn).toContainText("Confirm?");
+
+ // Second click — actual delete
+ await deleteBtn.click();
+
+ // Row should disappear
+ await expect(row).not.toBeVisible({ timeout: 5000 });
+
+ // Verify via API. Throw on a non-OK response so the poll keeps
+ // retrying — returning undefined here would falsely satisfy
+ // toBeUndefined() and mask a real API failure.
+ await expect
+ .poll(
+ async () => {
+ const res = await apiFetch("/server/integrations");
+ if (!res.ok) {
+ throw new Error(`GET /server/integrations failed: ${res.status} ${res.statusText}`);
+ }
+ const instances = await res.json();
+ return instances.find(
+ (i: { name: string }) => i.name === "Delete Me"
+ );
+ },
+ { timeout: 5000 }
+ )
+ .toBeUndefined();
+ });
+
+ test("Test Connection button shows result", async ({ apiFetch }) => {
+ // Create an integration with a URL that will likely fail connection
+ const created = await apiFetch("/server/integrations", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: "Test Conn",
+ kind: "sonarr",
+ url: "http://localhost:19999",
+ api_key: "bad-key",
+ enabled: true,
+ }),
+ });
+ await expectFixtureOk(created, 'POST /server/integrations (Test Conn)');
+
+ await integrations.goto();
+
+ const row = integrations.getInstanceByName("Test Conn");
+ await row.waitFor({ timeout: 10_000 });
+
+ const testBtn = integrations.getTestButton(row);
+ await testBtn.click();
+
+ // Button should show "Testing..." while in progress
+ // Then a result should appear (success or failure)
+ const result = integrations.getTestResult(row);
+ await expect(result).toBeVisible({ timeout: 15_000 });
+ // The result should have either success or failure styling
+ const resultText = await result.textContent();
+ expect(resultText?.trim().length).toBeGreaterThan(0);
+ });
+
+ test("enable/disable toggle updates via API", async ({ apiFetch }) => {
+ // Create an enabled integration
+ const created = await apiFetch("/server/integrations", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: "Toggle Me",
+ kind: "sonarr",
+ url: "http://localhost:8989",
+ api_key: "key",
+ enabled: true,
+ }),
+ });
+ await expectFixtureOk(created, 'POST /server/integrations (Toggle Me)');
+
+ await integrations.goto();
+
+ const row = integrations.getInstanceByName("Toggle Me");
+ await row.waitFor({ timeout: 10_000 });
+ const toggle = integrations.getEnabledToggle(row);
+
+ // Should start checked (enabled)
+ await expect(toggle).toBeChecked();
+
+ // Toggle off
+ await toggle.click();
+
+ // Poll API until disabled
+ await expect
+ .poll(
+ async () => {
+ const res = await apiFetch("/server/integrations");
+ if (!res.ok) {
+ throw new Error(`GET /server/integrations failed: ${res.status} ${res.statusText}`);
+ }
+ const instances = await res.json();
+ const inst = instances.find(
+ (i: { name: string }) => i.name === "Toggle Me"
+ );
+ return inst?.enabled;
+ },
+ { timeout: 5000 }
+ )
+ .toBe(false);
+ });
+
+ test("cancel button closes the add form without creating", async ({
+ apiFetch,
+ }) => {
+ await integrations.addSonarrButton.click();
+ await expect(integrations.instanceForm).toBeVisible();
+
+ await integrations.fillForm({
+ name: "Should Not Exist",
+ url: "http://localhost:8989",
+ });
+ await integrations.clickCancel();
+
+ // Form should close
+ await expect(integrations.instanceForm).not.toBeVisible();
+
+ // Verify nothing was created. Fail loudly if the GET itself fails so
+ // we don't get a false-positive (an empty list always satisfies the
+ // "Should Not Exist" check).
+ const res = await apiFetch("/server/integrations");
+ if (!res.ok) {
+ throw new Error(`GET /server/integrations failed: ${res.status} ${res.statusText}`);
+ }
+ const instances = await res.json();
+ expect(
+ instances.find((i: { name: string }) => i.name === "Should Not Exist")
+ ).toBeUndefined();
+ });
+});
diff --git a/src/e2e-playwright/tests/pages/integrations.page.ts b/src/e2e-playwright/tests/pages/integrations.page.ts
new file mode 100644
index 00000000..a924e5f5
--- /dev/null
+++ b/src/e2e-playwright/tests/pages/integrations.page.ts
@@ -0,0 +1,101 @@
+import { type Page, type Locator } from "@playwright/test";
+
+export class IntegrationsPage {
+ readonly page: Page;
+ readonly container: Locator;
+ readonly addSonarrButton: Locator;
+ readonly addRadarrButton: Locator;
+ readonly emptyState: Locator;
+ readonly instanceForm: Locator;
+ readonly errorMessage: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+ // Scope all selectors to .integrations to avoid collisions with .path-pairs
+ this.container = page.locator(".integrations");
+ this.addSonarrButton = this.container.locator("button.btn-add", {
+ hasText: "Sonarr",
+ });
+ this.addRadarrButton = this.container.locator("button.btn-add", {
+ hasText: "Radarr",
+ });
+ this.emptyState = this.container.locator(".empty-state");
+ this.instanceForm = this.container.locator(".instance-form");
+ this.errorMessage = this.container.locator(".error-message");
+ }
+
+ async goto() {
+ await this.page.goto("/settings");
+ await this.page.waitForURL("**/settings", { timeout: 10_000 });
+ await this.page.waitForSelector('a[href="/dashboard"]', {
+ timeout: 10_000,
+ });
+ }
+
+ /** Fill the add/edit instance form */
+ async fillForm(fields: {
+ name?: string;
+ url?: string;
+ apiKey?: string;
+ kind?: string;
+ }) {
+ const form = this.instanceForm;
+ if (fields.kind !== undefined) {
+ const select = form.locator("select");
+ await select.selectOption(fields.kind);
+ }
+ if (fields.name !== undefined) {
+ const nameInput = form.locator('label:has-text("Name") input');
+ await nameInput.fill(fields.name);
+ }
+ if (fields.url !== undefined) {
+ const urlInput = form.locator('label:has-text("URL") input');
+ await urlInput.fill(fields.url);
+ }
+ if (fields.apiKey !== undefined) {
+ const apiKeyInput = form.locator('label:has-text("API Key") input');
+ await apiKeyInput.fill(fields.apiKey);
+ }
+ }
+
+ async clickSave() {
+ await this.instanceForm.locator("button.btn-save").click();
+ }
+
+ async clickCancel() {
+ await this.instanceForm.locator("button.btn-cancel").click();
+ }
+
+ getInstanceRows() {
+ return this.container.locator(".instance-row");
+ }
+
+ getInstanceByName(name: string) {
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ return this.container.locator(".instance-row").filter({
+ has: this.page.locator(".instance-name", {
+ hasText: new RegExp(escaped),
+ }),
+ });
+ }
+
+ getEditButton(row: Locator) {
+ return row.locator("button.btn-edit");
+ }
+
+ getDeleteButton(row: Locator) {
+ return row.locator("button.btn-delete");
+ }
+
+ getTestButton(row: Locator) {
+ return row.locator("button", { hasText: /Test Connection|Testing/i });
+ }
+
+ getTestResult(row: Locator) {
+ return row.locator(".test-result");
+ }
+
+ getEnabledToggle(row: Locator) {
+ return row.locator('input[type="checkbox"]');
+ }
+}
diff --git a/src/e2e-playwright/tests/pages/settings.page.ts b/src/e2e-playwright/tests/pages/settings.page.ts
index aac4253f..fa80645a 100644
--- a/src/e2e-playwright/tests/pages/settings.page.ts
+++ b/src/e2e-playwright/tests/pages/settings.page.ts
@@ -60,9 +60,9 @@ export class SettingsPage {
}
}
- /** Get the restart notification if visible */
+ /** Get the restart notification if visible (scoped to header to avoid matching unrelated alerts) */
getRestartNotification() {
- return this.page.locator(".alert", {
+ return this.page.locator("#header .alert", {
hasText: /restart/i,
});
}
diff --git a/src/e2e-playwright/tests/path-pairs.spec.ts b/src/e2e-playwright/tests/path-pairs.spec.ts
index a7411170..b2ab1bf5 100644
--- a/src/e2e-playwright/tests/path-pairs.spec.ts
+++ b/src/e2e-playwright/tests/path-pairs.spec.ts
@@ -1,5 +1,6 @@
import { test, expect } from "./fixtures";
import { PathPairsPage } from "./pages/path-pairs.page";
+import { SettingsPage } from "./pages/settings.page";
test.describe("Path Pairs", () => {
let pathPairs: PathPairsPage;
@@ -306,4 +307,30 @@ test.describe("Path Pairs", () => {
)
.toBe(false);
});
+
+ test("Server Directory field on Settings page is disabled when a path pair exists", async ({
+ page,
+ apiFetch,
+ }) => {
+ // Lives in this file (not settings.spec.ts) so it shares the same
+ // beforeEach/afterEach pair cleanup and doesn't race with other specs
+ // running in parallel — pair creation/deletion across files is the
+ // root cause of the cross-file races we saw at workers > 2.
+ const res = await apiFetch("/server/pathpairs", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name: "e2e-server-dir-test",
+ remote_path: "/remote/test",
+ local_path: "/local/test",
+ enabled: true,
+ }),
+ });
+ expect(res.ok).toBe(true);
+
+ const settings = new SettingsPage(page);
+ await settings.goto();
+ const serverDir = settings.getTextInput("Server Directory");
+ await expect(serverDir).toBeDisabled();
+ });
});
diff --git a/src/e2e-playwright/tests/settings.spec.ts b/src/e2e-playwright/tests/settings.spec.ts
index 51245d80..df2ed166 100644
--- a/src/e2e-playwright/tests/settings.spec.ts
+++ b/src/e2e-playwright/tests/settings.spec.ts
@@ -39,18 +39,25 @@ test.describe("Settings Page", () => {
try {
const field = settings.getTextInput("Server Address");
- await field.clear();
+ // Wait for the field to be enabled (config loaded via SSE)
+ await expect(field).toBeEnabled({ timeout: 5000 });
const testValue = "e2e-test-server";
+ // fill() already clears the input before typing — calling clear()
+ // separately can race with Angular's signal-driven re-render of the
+ // ngModel binding and reset the field before fill() runs.
await field.fill(testValue);
+ // Blur triggers any pending change events and ensures Angular
+ // processes the final value through the debounce pipeline.
+ await field.blur();
- // Poll the API until the value is saved
+ // Poll the API until the value is saved (debounce is 1 s)
await expect
.poll(
async () => {
const config = await apiGet("/server/config/get");
return config.lftp.remote_address;
},
- { timeout: 5000 }
+ { timeout: 8000 }
)
.toBe(testValue);
} finally {
@@ -105,6 +112,8 @@ test.describe("Settings Page", () => {
const originalFormat = configBefore.logging.log_format;
const select = settings.getSelect("Log Format");
+ // Wait for SSE-delivered config — same gate as the other select tests.
+ await expect(select).toBeEnabled({ timeout: 10_000 });
try {
await select.selectOption("json");
@@ -166,45 +175,18 @@ test.describe("Settings Page", () => {
await expect(options).toHaveCount(7);
});
- test("Server Directory field is disabled when path pairs exist", async ({
- apiFetch,
- }) => {
- // Create a path pair via API
- const pairName = `temp-pair-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
- const res = await apiFetch("/server/pathpairs", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- name: pairName,
- remote_path: "/remote/test",
- local_path: "/local/test",
- enabled: true,
- }),
- });
- expect(res.ok).toBe(true);
- const pair = await res.json();
-
- try {
- await settings.goto();
- const serverDir = settings.getTextInput("Server Directory");
- await expect(serverDir).toBeDisabled();
- } finally {
- // Always clean up the pair
- if (pair?.id) {
- const del = await apiFetch(`/server/pathpairs/${pair.id}`, { method: "DELETE" });
- expect(del.ok, `Failed to delete temp pair ${pair.id}: ${del.status}`).toBe(true);
- }
- }
- });
-
test("restart notification appears after config change", async ({ apiGet, apiSetConfig }) => {
const configBefore = await apiGet("/server/config/get");
const originalAddress = configBefore.lftp.remote_address;
try {
const field = settings.getTextInput("Server Address");
- await field.clear();
+ // fill() already clears before typing — calling clear() separately
+ // races with Angular's signal-driven re-render and can reset the
+ // field before fill() runs. Wait for SSE-delivered config first.
+ await expect(field).toBeEnabled({ timeout: 5000 });
await field.fill("trigger-restart-notice-" + Date.now());
+ await field.blur();
const notification = settings.getRestartNotification();
await expect(notification).toBeVisible({ timeout: 5000 });
@@ -228,3 +210,328 @@ test.describe("Settings Page", () => {
await expect(field).toHaveAttribute("type", "password");
});
});
+
+test.describe("Settings — Staging Directory", () => {
+ let settings: SettingsPage;
+
+ test.beforeEach(async ({ page }) => {
+ settings = new SettingsPage(page);
+ await settings.goto();
+ });
+
+ test("staging path text field saves to backend", async ({
+ apiGet,
+ apiSetConfig,
+ }) => {
+ const configBefore = await apiGet("/server/config/get");
+ const originalPath = configBefore.controller.staging_path;
+
+ try {
+ const field = settings.getTextInput("Staging Path");
+ await expect(field).toBeEnabled({ timeout: 5000 });
+ const testValue = "/tmp/e2e-staging-" + Date.now();
+ await field.fill(testValue);
+ await field.blur();
+
+ await expect
+ .poll(
+ async () => {
+ const config = await apiGet("/server/config/get");
+ return config.controller.staging_path;
+ },
+ { timeout: 5000 }
+ )
+ .toBe(testValue);
+ } finally {
+ await apiSetConfig("controller", "staging_path", originalPath ?? "");
+ }
+ });
+
+ test("use staging directory checkbox toggles and saves", async ({
+ apiGet,
+ }) => {
+ const checkbox = settings.getCheckbox("Use staging directory");
+ const wasBefore = await checkbox.isChecked();
+ const expected = !wasBefore;
+
+ await checkbox.click();
+
+ try {
+ await expect
+ .poll(
+ async () => {
+ const config = await apiGet("/server/config/get");
+ return config.controller.use_staging;
+ },
+ { timeout: 5000 }
+ )
+ .toBe(expected);
+ } finally {
+ await checkbox.click();
+ await expect
+ .poll(
+ async () => {
+ const config = await apiGet("/server/config/get");
+ return config.controller.use_staging;
+ },
+ { timeout: 5000 }
+ )
+ .toBe(wasBefore);
+ }
+ });
+});
+
+test.describe("Settings — Archive Extraction", () => {
+ let settings: SettingsPage;
+
+ test.beforeEach(async ({ page }) => {
+ settings = new SettingsPage(page);
+ await settings.goto();
+ });
+
+ test("extract in downloads directory checkbox toggles and saves", async ({
+ apiGet,
+ }) => {
+ const checkbox = settings.getCheckbox(
+ "Extract archives in the downloads directory"
+ );
+ const wasBefore = await checkbox.isChecked();
+ const expected = !wasBefore;
+
+ await checkbox.click();
+
+ try {
+ await expect
+ .poll(
+ async () => {
+ const config = await apiGet("/server/config/get");
+ return config.controller.use_local_path_as_extract_path;
+ },
+ { timeout: 5000 }
+ )
+ .toBe(expected);
+ } finally {
+ await checkbox.click();
+ await expect
+ .poll(
+ async () => {
+ const config = await apiGet("/server/config/get");
+ return config.controller.use_local_path_as_extract_path;
+ },
+ { timeout: 5000 }
+ )
+ .toBe(wasBefore);
+ }
+ });
+});
+
+test.describe("Settings — Integrity Check", () => {
+ let settings: SettingsPage;
+
+ test.beforeEach(async ({ page }) => {
+ settings = new SettingsPage(page);
+ await settings.goto();
+ });
+
+ test("verify transfers checkbox and algorithm select are present", async () => {
+ const section = settings.getSection("Integrity Check");
+ await expect(section).toBeVisible();
+
+ const verifyCheckbox = settings.getCheckbox(
+ "Verify transfers inline"
+ );
+ await expect(verifyCheckbox).toBeVisible();
+
+ const algorithmSelect = settings.getSelect("Hash Algorithm");
+ await expect(algorithmSelect).toBeVisible();
+ });
+
+ test("hash algorithm select changes and saves to backend", async ({
+ page,
+ apiGet,
+ apiSetConfig,
+ waitForStream,
+ }) => {
+ // The Hash Algorithm select is disabled by buildValidateContext() unless
+ // validate.enabled is true (see settings-page.component.ts). Enable
+ // post-download validation here, then restore in the finally block.
+ const configBefore = await apiGet("/server/config/get");
+ const originalAlgorithm = configBefore.validate.algorithm;
+ const originalValidateEnabled = configBefore.validate.enabled;
+
+ try {
+ // All state mutation lives inside try so the finally block always
+ // restores it — even if the apiSetConfig itself or any subsequent
+ // setup step throws.
+ if (!originalValidateEnabled) {
+ await apiSetConfig("validate", "enabled", "True");
+ }
+
+ // beforeEach already constructed `settings` and navigated to /settings,
+ // but the page may have loaded before validate.enabled was applied.
+ // Reload so the buildValidateContext() rebuild observes the new value,
+ // then wait for the SSE stream before interacting with the select.
+ await settings.goto();
+ await waitForStream(page);
+
+ const select = settings.getSelect("Hash Algorithm");
+ // Wait for the SSE config to arrive AND for buildValidateContext to
+ // unlock the select (the disable rule needs validate.enabled === true).
+ await expect(select).toBeEnabled({ timeout: 10_000 });
+ await expect(select).toHaveValue(/^(md5|sha1|sha256)$/);
+
+ // Pick a different algorithm than current
+ const newValue = originalAlgorithm === "sha256" ? "md5" : "sha256";
+
+ await select.selectOption(newValue);
+
+ await expect
+ .poll(
+ async () => {
+ const config = await apiGet("/server/config/get");
+ return config.validate.algorithm;
+ },
+ { timeout: 5000 }
+ )
+ .toBe(newValue);
+ } finally {
+ await apiSetConfig("validate", "algorithm", String(originalAlgorithm));
+ if (!originalValidateEnabled) {
+ await apiSetConfig("validate", "enabled", "False");
+ }
+ }
+ });
+});
+
+test.describe("Settings — Connections", () => {
+ let settings: SettingsPage;
+
+ test.beforeEach(async ({ page }) => {
+ settings = new SettingsPage(page);
+ await settings.goto();
+ });
+
+ test("Max Parallel Downloads field saves to backend", async ({
+ apiGet,
+ apiSetConfig,
+ }) => {
+ const configBefore = await apiGet("/server/config/get");
+ const originalValue = configBefore.lftp.num_max_parallel_downloads;
+
+ try {
+ const field = settings.getTextInput("Max Parallel Downloads");
+ await expect(field).toBeEnabled({ timeout: 5000 });
+ await field.fill("7");
+ await field.blur();
+
+ await expect
+ .poll(
+ async () => {
+ const config = await apiGet("/server/config/get");
+ return String(config.lftp.num_max_parallel_downloads);
+ },
+ { timeout: 5000 }
+ )
+ .toBe("7");
+ } finally {
+ await apiSetConfig(
+ "lftp",
+ "num_max_parallel_downloads",
+ String(originalValue)
+ );
+ }
+ });
+});
+
+test.describe("Settings — Notifications", () => {
+ let settings: SettingsPage;
+
+ test.beforeEach(async ({ page }) => {
+ settings = new SettingsPage(page);
+ await settings.goto();
+ });
+
+ test("Discord and Telegram webhook fields are present", async () => {
+ const section = settings.getSection("Notifications");
+ await expect(section).toBeVisible();
+
+ // Discord Webhook URL is a password field — assert masked input type,
+ // not just visibility, so an accidental switch to type="text" is caught.
+ const discordField = section
+ .locator("app-option", { hasText: "Discord Webhook URL" })
+ .locator("input[type='text'], input[type='password']");
+ await expect(discordField).toBeVisible();
+ await expect(discordField).toHaveAttribute("type", "password");
+
+ // Telegram Bot Token is a password field — same masking assertion.
+ const telegramTokenField = section
+ .locator("app-option", { hasText: "Telegram Bot Token" })
+ .locator("input[type='text'], input[type='password']");
+ await expect(telegramTokenField).toBeVisible();
+ await expect(telegramTokenField).toHaveAttribute("type", "password");
+
+ // Telegram Chat ID is a text field
+ const telegramChatField = section
+ .locator("app-option", { hasText: "Telegram Chat ID" })
+ .locator("input[type='text'], input[type='password']");
+ await expect(telegramChatField).toBeVisible();
+ });
+});
+
+test.describe("Settings — Logging", () => {
+ let settings: SettingsPage;
+
+ test.beforeEach(async ({ page }) => {
+ settings = new SettingsPage(page);
+ await settings.goto();
+ });
+
+ test("Log Level dropdown persists selected value", async ({
+ apiGet,
+ }) => {
+ const configBefore = await apiGet("/server/config/get");
+ const originalLevel = configBefore.general.log_level;
+ const select = settings.getSelect("Log Level");
+
+ try {
+ // Wait for SSE-delivered config and pick the new value inside the
+ // try so any throw before the mutation still hits the finally —
+ // same pattern as the Hash Algorithm test.
+ await expect(select).toBeEnabled({ timeout: 10_000 });
+ const newValue = originalLevel === "DEBUG" ? "WARNING" : "DEBUG";
+
+ await select.selectOption(newValue);
+
+ await expect
+ .poll(
+ async () => {
+ const config = await apiGet("/server/config/get");
+ return config.general.log_level;
+ },
+ { timeout: 5000 }
+ )
+ .toBe(newValue);
+
+ // Reload the page and verify the value persists. settings.goto()
+ // only waits for Angular bootstrap (sidebar render), not for SSE
+ // config delivery, so wait for the select to become enabled — its
+ // disabled gate is `value() === null || value() === undefined`, so
+ // an enabled select means the model has received the SSE value.
+ await settings.goto();
+ const reloadedSelect = settings.getSelect("Log Level");
+ await expect(reloadedSelect).toBeEnabled({ timeout: 10_000 });
+ await expect(reloadedSelect).toHaveValue(newValue);
+ } finally {
+ await select.selectOption(String(originalLevel));
+ await expect
+ .poll(
+ async () => {
+ const config = await apiGet("/server/config/get");
+ return config.general.log_level;
+ },
+ { timeout: 5000 }
+ )
+ .toBe(originalLevel);
+ }
+ });
+});
diff --git a/src/python/common/context.py b/src/python/common/context.py
index 5fdc30f1..545d8990 100644
--- a/src/python/common/context.py
+++ b/src/python/common/context.py
@@ -49,6 +49,11 @@ def __init__(
status: Status,
path_pairs_config: PathPairsConfig | None = None,
integrations_config: IntegrationsConfig | None = None,
+ config_path: str | None = None,
+ path_pairs_path: str | None = None,
+ integrations_path: str | None = None,
+ auto_queue_persist_path: str | None = None,
+ controller_persist_path: str | None = None,
):
"""
Primary constructor to construct the top-level context
@@ -62,6 +67,13 @@ def __init__(
self.path_pairs_config = path_pairs_config or PathPairsConfig()
self.integrations_config = integrations_config or IntegrationsConfig()
+ # File paths for flush-on-write
+ self.config_path = config_path
+ self.path_pairs_path = path_pairs_path
+ self.integrations_path = integrations_path
+ self.auto_queue_persist_path = auto_queue_persist_path
+ self.controller_persist_path = controller_persist_path
+
def create_child_context(self, context_name: str) -> "Context":
child_context = copy.copy(self)
child_context.logger = self.logger.getChild(context_name)
diff --git a/src/python/controller/controller.py b/src/python/controller/controller.py
index 32d17288..975e25c9 100644
--- a/src/python/controller/controller.py
+++ b/src/python/controller/controller.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import os
+import threading
from abc import ABC, abstractmethod
from collections.abc import Callable
from enum import Enum
@@ -146,6 +147,9 @@ def __init__(self, context: Context, persist: ControllerPersist):
# Seed each builder with filtered persist state
self.__updater.sync_persist_to_all_builders()
+ # Flag for hot-reloading LFTP tuning settings (set from REST thread)
+ self.__needs_lftp_reconfigure = threading.Event()
+
self.__started = False
def _validate_config(self) -> None:
@@ -314,6 +318,13 @@ def start(self):
self.__mp_logger.start()
self.__started = True
+ def request_lftp_reconfigure(self) -> None:
+ """Signal that LFTP tuning settings have changed and should be reapplied.
+
+ Thread-safe: called from the REST handler thread.
+ """
+ self.__needs_lftp_reconfigure.set()
+
def process(self):
"""
Advance the controller state
@@ -322,6 +333,11 @@ def process(self):
"""
if not self.__started:
raise ControllerError("Cannot process, controller is not started")
+ if self.__needs_lftp_reconfigure.is_set():
+ self.__needs_lftp_reconfigure.clear()
+ for pc in self.__pair_contexts:
+ self._configure_lftp(pc.lftp)
+ self.logger.info("Reapplied LFTP tuning settings")
self.__pipeline.propagate_exceptions()
self.__pipeline.cleanup()
self.__pipeline.step()
diff --git a/src/python/seedsync.py b/src/python/seedsync.py
index 28d6d4e7..cdf180eb 100644
--- a/src/python/seedsync.py
+++ b/src/python/seedsync.py
@@ -148,6 +148,9 @@ def __init__(self):
status=status,
path_pairs_config=path_pairs_config,
integrations_config=integrations_config,
+ config_path=self.config_path,
+ path_pairs_path=self.path_pairs_path,
+ integrations_path=self.integrations_path,
)
# Register the signal handlers
@@ -164,6 +167,10 @@ def __init__(self):
self.auto_queue_persist_path = os.path.join(args.config_dir, Seedsync.__FILE_AUTO_QUEUE_PERSIST)
self.auto_queue_persist = self._load_persist(AutoQueuePersist, self.auto_queue_persist_path)
+ # Set persist paths on context (these are determined after context creation)
+ self.context.controller_persist_path = self.controller_persist_path
+ self.context.auto_queue_persist_path = self.auto_queue_persist_path
+
def run(self):
self.context.logger.info("Starting SeedSync")
self.context.logger.info(f"Platform: {platform.machine()}")
diff --git a/src/python/tests/integration/test_controller/test_controller.py b/src/python/tests/integration/test_controller/test_controller.py
index 9da2bb49..56815496 100644
--- a/src/python/tests/integration/test_controller/test_controller.py
+++ b/src/python/tests/integration/test_controller/test_controller.py
@@ -316,6 +316,7 @@ def setUp(self):
logger = logging.getLogger(TestController.__name__)
handler = logging.StreamHandler(sys.stdout)
logger.addHandler(handler)
+ self.addCleanup(logger.removeHandler, handler)
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s")
handler.setFormatter(formatter)
diff --git a/src/python/tests/integration/test_lftp/test_lftp.py b/src/python/tests/integration/test_lftp/test_lftp.py
index b964e3c2..43edca0d 100644
--- a/src/python/tests/integration/test_lftp/test_lftp.py
+++ b/src/python/tests/integration/test_lftp/test_lftp.py
@@ -44,6 +44,7 @@ def setUp(self):
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s")
handler.setFormatter(formatter)
logger.addHandler(handler)
+ self.addCleanup(logger.removeHandler, handler)
self.lftp.set_base_logger(logger)
# Verbose logging
diff --git a/src/python/tests/integration/test_web/test_handler/test_auto_queue.py b/src/python/tests/integration/test_web/test_handler/test_auto_queue.py
index c8493d22..a387ec39 100644
--- a/src/python/tests/integration/test_web/test_handler/test_auto_queue.py
+++ b/src/python/tests/integration/test_web/test_handler/test_auto_queue.py
@@ -1,6 +1,5 @@
# Copyright 2017, Inderpreet Singh, All rights reserved.
-import json
from urllib.parse import quote
from controller import AutoQueuePattern
@@ -16,7 +15,7 @@ def test_get(self):
self.auto_queue_persist.add_pattern(AutoQueuePattern(pattern="fi%ve"))
resp = self.test_app.get("/server/autoqueue/get")
self.assertEqual(200, resp.status_int)
- json_list = json.loads(str(resp.html))
+ json_list = resp.json
self.assertEqual(5, len(json_list))
self.assertIn({"pattern": "one"}, json_list)
self.assertIn({"pattern": "t wo"}, json_list)
@@ -32,7 +31,7 @@ def test_get_is_ordered(self):
self.auto_queue_persist.add_pattern(AutoQueuePattern(pattern="e"))
resp = self.test_app.get("/server/autoqueue/get")
self.assertEqual(200, resp.status_int)
- json_list = json.loads(str(resp.html))
+ json_list = resp.json
self.assertEqual(5, len(json_list))
self.assertEqual(
[{"pattern": "a"}, {"pattern": "b"}, {"pattern": "c"}, {"pattern": "d"}, {"pattern": "e"}], json_list
@@ -73,7 +72,7 @@ def test_add_double(self):
self.assertEqual(200, resp.status_int)
resp = self.test_app.get("/server/autoqueue/add/one", expect_errors=True)
self.assertEqual(400, resp.status_int)
- self.assertEqual("Auto-queue pattern 'one' already exists.", str(resp.html))
+ self.assertEqual("Auto-queue pattern 'one' already exists.", resp.text)
def test_add_empty_value(self):
uri = quote(quote(" ", safe=""), safe="")
@@ -124,13 +123,13 @@ def test_remove_good(self):
def test_remove_non_existing(self):
resp = self.test_app.get("/server/autoqueue/remove/one", expect_errors=True)
self.assertEqual(400, resp.status_int)
- self.assertEqual("Auto-queue pattern 'one' doesn't exist.", str(resp.html))
+ self.assertEqual("Auto-queue pattern 'one' doesn't exist.", resp.text)
def test_remove_empty_value(self):
uri = quote(quote(" ", safe=""), safe="")
resp = self.test_app.get("/server/autoqueue/remove/" + uri, expect_errors=True)
self.assertEqual(400, resp.status_int)
- self.assertEqual("Auto-queue pattern ' ' doesn't exist.", str(resp.html))
+ self.assertEqual("Auto-queue pattern ' ' doesn't exist.", resp.text)
self.assertEqual(0, len(self.auto_queue_persist.patterns))
resp = self.test_app.get("/server/autoqueue/remove/", expect_errors=True)
diff --git a/src/python/tests/integration/test_web/test_handler/test_server.py b/src/python/tests/integration/test_web/test_handler/test_server.py
index cdfd5c22..59c7da75 100644
--- a/src/python/tests/integration/test_web/test_handler/test_server.py
+++ b/src/python/tests/integration/test_web/test_handler/test_server.py
@@ -10,3 +10,35 @@ def test_restart(self):
self.assertTrue(self.web_app_builder.server_handler.is_restart_requested())
print(self.test_app.get("/server/command/restart"))
self.assertTrue(self.web_app_builder.server_handler.is_restart_requested())
+
+ def test_restart_response_body(self):
+ """Response body is 'Requested restart'."""
+ resp = self.test_app.get("/server/command/restart")
+ self.assertIn("Requested restart", resp.text)
+
+ def test_restart_response_is_200(self):
+ """Restart endpoint returns 200."""
+ resp = self.test_app.get("/server/command/restart")
+ self.assertEqual(200, resp.status_int)
+
+ def test_state_transition_false_to_true(self):
+ """is_restart_requested() transitions from False to True after restart call."""
+ handler = self.web_app_builder.server_handler
+ self.assertFalse(handler.is_restart_requested())
+ self.test_app.get("/server/command/restart")
+ self.assertTrue(handler.is_restart_requested())
+
+ def test_restart_idempotent(self):
+ """Calling restart twice still returns 200 (idempotent)."""
+ resp1 = self.test_app.get("/server/command/restart")
+ resp2 = self.test_app.get("/server/command/restart")
+ self.assertEqual(200, resp1.status_int)
+ self.assertEqual(200, resp2.status_int)
+
+ def test_restart_state_stays_true(self):
+ """Once restart is requested, state remains True."""
+ handler = self.web_app_builder.server_handler
+ self.test_app.get("/server/command/restart")
+ self.assertTrue(handler.is_restart_requested())
+ self.test_app.get("/server/command/restart")
+ self.assertTrue(handler.is_restart_requested())
diff --git a/src/python/tests/integration/test_web/test_handler/test_status.py b/src/python/tests/integration/test_web/test_handler/test_status.py
index eafa323d..bd704804 100644
--- a/src/python/tests/integration/test_web/test_handler/test_status.py
+++ b/src/python/tests/integration/test_web/test_handler/test_status.py
@@ -11,3 +11,45 @@ def test_status(self):
self.assertEqual(200, resp.status_int)
json_dict = json.loads(str(resp.html))
self.assertEqual(True, json_dict["server"]["up"])
+
+ def test_full_response_structure(self):
+ """Response body contains both 'server' and 'controller' sections."""
+ resp = self.test_app.get("/server/status")
+ json_dict = json.loads(str(resp.html))
+ self.assertIn("server", json_dict)
+ self.assertIn("controller", json_dict)
+ # Server section
+ self.assertIn("up", json_dict["server"])
+ self.assertIn("error_msg", json_dict["server"])
+ # Controller section
+ self.assertIn("latest_local_scan_time", json_dict["controller"])
+ self.assertIn("latest_remote_scan_time", json_dict["controller"])
+ self.assertIn("no_enabled_pairs", json_dict["controller"])
+ self.assertIn("latest_remote_scan_failed", json_dict["controller"])
+ self.assertIn("latest_remote_scan_error", json_dict["controller"])
+
+ def test_remote_scan_time_null_initially(self):
+ """controller.latest_remote_scan_time is null initially."""
+ resp = self.test_app.get("/server/status")
+ json_dict = json.loads(str(resp.html))
+ self.assertIsNone(json_dict["controller"]["latest_remote_scan_time"])
+
+ def test_local_scan_time_null_initially(self):
+ """controller.latest_local_scan_time is null initially."""
+ resp = self.test_app.get("/server/status")
+ json_dict = json.loads(str(resp.html))
+ self.assertIsNone(json_dict["controller"]["latest_local_scan_time"])
+
+ def test_no_enabled_pairs_reflects_state(self):
+ """controller.no_enabled_pairs reflects actual state."""
+ resp = self.test_app.get("/server/status")
+ json_dict = json.loads(str(resp.html))
+ # Default status has no_enabled_pairs = False
+ self.assertFalse(json_dict["controller"]["no_enabled_pairs"])
+
+ def test_no_enabled_pairs_true_when_set(self):
+ """controller.no_enabled_pairs reflects True when set."""
+ self.context.status.controller.no_enabled_pairs = True
+ resp = self.test_app.get("/server/status")
+ json_dict = json.loads(str(resp.html))
+ self.assertTrue(json_dict["controller"]["no_enabled_pairs"])
diff --git a/src/python/tests/integration/test_web/test_handler/test_stream_log.py b/src/python/tests/integration/test_web/test_handler/test_stream_log.py
index ef29d64e..25cd41b2 100644
--- a/src/python/tests/integration/test_web/test_handler/test_stream_log.py
+++ b/src/python/tests/integration/test_web/test_handler/test_stream_log.py
@@ -5,13 +5,14 @@
from unittest.mock import patch
from tests.integration.test_web.test_web_app import BaseTestWebApp
+from web.handler.stream_log import CachedQueueLogHandler, QueueLogHandler
class TestLogStreamHandler(BaseTestWebApp):
@patch("web.handler.stream_log.SerializeLogRecord")
def test_stream_log_serializes_record(self, mock_serialize_log_record_cls):
# Schedule server stop
- Timer(0.5, self.web_app.stop).start()
+ Timer(2.0, self.web_app.stop).start()
# Schedule status update
def issue_logs():
@@ -41,3 +42,78 @@ def issue_logs():
record4 = call4[0][0]
self.assertEqual("Error msg", record4.msg)
self.assertEqual(logging.ERROR, record4.levelno)
+
+
+class TestLogStreamHandlerCleanup(BaseTestWebApp):
+ """Tests for handler attachment and cleanup."""
+
+ def test_queue_handler_attach_remove(self):
+ """QueueLogHandler can be attached and removed from logger."""
+ logger = self.context.logger
+ handler = QueueLogHandler()
+ initial_count = len(logger.handlers)
+
+ logger.addHandler(handler)
+ self.assertEqual(len(logger.handlers), initial_count + 1)
+
+ logger.removeHandler(handler)
+ self.assertEqual(len(logger.handlers), initial_count)
+
+ def test_multiple_handlers_independent(self):
+ """Multiple QueueLogHandlers attach/remove independently."""
+ logger = self.context.logger
+ h1 = QueueLogHandler()
+ h2 = QueueLogHandler()
+
+ logger.addHandler(h1)
+ logger.addHandler(h2)
+
+ logger.info("test message")
+
+ # Both handlers should have received the message
+ r1 = h1.get_next_event()
+ r2 = h2.get_next_event()
+ self.assertIsNotNone(r1)
+ self.assertIsNotNone(r2)
+ self.assertEqual(r1.msg, "test message")
+ self.assertEqual(r2.msg, "test message")
+
+ # Remove h1, h2 still works
+ logger.removeHandler(h1)
+ logger.info("second message")
+
+ r1 = h1.get_next_event()
+ r2 = h2.get_next_event()
+ self.assertIsNone(r1)
+ self.assertIsNotNone(r2)
+ self.assertEqual(r2.msg, "second message")
+
+ logger.removeHandler(h2)
+
+ def test_cached_handler_delivers_history(self):
+ """CachedQueueLogHandler stores records and returns them via get_cached_records()."""
+ logger = self.context.logger
+ cache = CachedQueueLogHandler(history_size_in_ms=5000)
+ logger.addHandler(cache)
+
+ # Log something before the queue handler connects
+ logger.info("before connection")
+
+ # Get cached records
+ cached = cache.get_cached_records()
+ self.assertTrue(any(r.msg == "before connection" for r in cached))
+
+ logger.removeHandler(cache)
+
+ def test_cache_zero_sends_no_history(self):
+ """CachedQueueLogHandler with history_size_in_ms=0 sends no historical records."""
+ logger = self.context.logger
+ cache = CachedQueueLogHandler(history_size_in_ms=0)
+ logger.addHandler(cache)
+
+ logger.info("should not be cached")
+
+ cached = cache.get_cached_records()
+ self.assertEqual(len(cached), 0)
+
+ logger.removeHandler(cache)
diff --git a/src/python/tests/integration/test_web/test_handler/test_stream_model.py b/src/python/tests/integration/test_web/test_handler/test_stream_model.py
index 92020266..85d34e8c 100644
--- a/src/python/tests/integration/test_web/test_handler/test_stream_model.py
+++ b/src/python/tests/integration/test_web/test_handler/test_stream_model.py
@@ -12,14 +12,14 @@
class TestModelStreamHandler(BaseTestWebApp):
def test_stream_model_fetches_model_and_adds_listener(self):
# Schedule server stop
- Timer(0.5, self.web_app.stop).start()
+ Timer(2.0, self.web_app.stop).start()
self.test_app.get("/server/stream")
self.controller.get_model_files_and_add_listener.assert_called_once_with(unittest.mock.ANY)
def test_stream_model_removes_listener(self):
# Schedule server stop
- Timer(0.5, self.web_app.stop).start()
+ Timer(2.0, self.web_app.stop).start()
self.test_app.get("/server/stream")
self.controller.remove_model_listener.assert_called_once_with(self.model_listener)
@@ -27,7 +27,7 @@ def test_stream_model_removes_listener(self):
@patch("web.handler.stream_model.SerializeModel")
def test_stream_model_serializes_initial_model(self, mock_serialize_model_cls):
# Schedule server stop
- Timer(0.5, self.web_app.stop).start()
+ Timer(2.0, self.web_app.stop).start()
# Setup mock serialize instance
mock_serialize = mock_serialize_model_cls.return_value
diff --git a/src/python/tests/integration/test_web/test_handler/test_stream_status.py b/src/python/tests/integration/test_web/test_handler/test_stream_status.py
index ee1a2853..e3bbb368 100644
--- a/src/python/tests/integration/test_web/test_handler/test_stream_status.py
+++ b/src/python/tests/integration/test_web/test_handler/test_stream_status.py
@@ -10,7 +10,7 @@ class TestStatusStreamHandler(BaseTestWebApp):
@patch("web.handler.stream_status.SerializeStatus")
def test_stream_status_serializes_initial_status(self, mock_serialize_status_cls):
# Schedule server stop
- Timer(0.5, self.web_app.stop).start()
+ Timer(2.0, self.web_app.stop).start()
# Setup mock serialize instance
mock_serialize = mock_serialize_status_cls.return_value
@@ -26,7 +26,7 @@ def test_stream_status_serializes_initial_status(self, mock_serialize_status_cls
@patch("web.handler.stream_status.SerializeStatus")
def test_stream_status_serializes_new_status(self, mock_serialize_status_cls):
# Schedule server stop
- Timer(0.5, self.web_app.stop).start()
+ Timer(2.0, self.web_app.stop).start()
# Schedule status update
def update_status():
diff --git a/src/python/tests/integration/test_web/test_web_app.py b/src/python/tests/integration/test_web/test_web_app.py
index 368f3217..2bbd372c 100644
--- a/src/python/tests/integration/test_web/test_web_app.py
+++ b/src/python/tests/integration/test_web/test_web_app.py
@@ -1,7 +1,10 @@
# Copyright 2017, Inderpreet Singh, All rights reserved.
import logging
+import os
+import shutil
import sys
+import tempfile
import unittest
from unittest.mock import MagicMock
@@ -27,6 +30,7 @@ def setUp(self):
logger = logging.getLogger()
handler = logging.StreamHandler(sys.stdout)
logger.addHandler(handler)
+ self.addCleanup(logger.removeHandler, handler)
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s")
handler.setFormatter(formatter)
@@ -48,6 +52,14 @@ def setUp(self):
# Real auto-queue persist
self.auto_queue_persist = AutoQueuePersist()
+ # Temp directory for flush-on-write file paths
+ self._test_tmpdir = tempfile.mkdtemp()
+ self.context.config_path = os.path.join(self._test_tmpdir, "settings.cfg")
+ self.context.path_pairs_path = os.path.join(self._test_tmpdir, "path_pairs.json")
+ self.context.integrations_path = os.path.join(self._test_tmpdir, "integrations.json")
+ self.context.auto_queue_persist_path = os.path.join(self._test_tmpdir, "autoqueue.persist")
+ self.context.controller_persist_path = os.path.join(self._test_tmpdir, "controller.persist")
+
# Capture the model listener
def capture_listener(listener):
self.model_listener = listener
@@ -63,6 +75,10 @@ def capture_listener(listener):
self.web_app = self.web_app_builder.build()
self.test_app = TestApp(self.web_app)
+ @overrides(unittest.TestCase)
+ def tearDown(self):
+ shutil.rmtree(self._test_tmpdir, ignore_errors=True)
+
class TestWebApp(BaseTestWebApp):
def test_process(self):
diff --git a/src/python/tests/unittests/test_common/test_app_process.py b/src/python/tests/unittests/test_common/test_app_process.py
index ed8f33c5..ce3dad7d 100644
--- a/src/python/tests/unittests/test_common/test_app_process.py
+++ b/src/python/tests/unittests/test_common/test_app_process.py
@@ -99,6 +99,7 @@ def setUp(self):
logger = logging.getLogger()
handler = logging.StreamHandler(sys.stdout)
logger.addHandler(handler)
+ self.addCleanup(logger.removeHandler, handler)
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s")
handler.setFormatter(formatter)
@@ -172,6 +173,7 @@ def setUp(self):
logger = logging.getLogger()
handler = logging.StreamHandler(sys.stdout)
logger.addHandler(handler)
+ self.addCleanup(logger.removeHandler, handler)
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s")
handler.setFormatter(formatter)
diff --git a/src/python/tests/unittests/test_common/test_context.py b/src/python/tests/unittests/test_common/test_context.py
new file mode 100644
index 00000000..50545a9e
--- /dev/null
+++ b/src/python/tests/unittests/test_common/test_context.py
@@ -0,0 +1,111 @@
+# Copyright 2017, Inderpreet Singh, All rights reserved.
+
+import logging
+import unittest
+
+from common import Args, Config, Context, PathPairsConfig, Status
+from common.path_pairs_config import PathPair
+
+
+def _make_context():
+ """Create a basic Context for testing."""
+ # Tests use self.assertLogs(...) which installs its own capture handler,
+ # so we don't need to attach a StreamHandler here. Attaching one would
+ # leak across tests because loggers are singletons.
+ logger = logging.getLogger("test_context")
+ logger.setLevel(logging.DEBUG)
+ web_logger = logging.getLogger("test_context.web")
+ config = Config()
+ args = Args()
+ status = Status()
+ return Context(logger=logger, web_access_logger=web_logger, config=config, args=args, status=status)
+
+
+class TestCreateChildContext(unittest.TestCase):
+ """Tests for Context.create_child_context."""
+
+ def test_returns_copy_with_child_logger(self):
+ ctx = _make_context()
+ child = ctx.create_child_context("child1")
+ self.assertEqual(child.logger.name, "test_context.child1")
+ # Parent logger unchanged
+ self.assertEqual(ctx.logger.name, "test_context")
+
+ def test_child_shares_config_reference(self):
+ ctx = _make_context()
+ child = ctx.create_child_context("child2")
+ self.assertIs(child.config, ctx.config)
+ self.assertIs(child.status, ctx.status)
+ self.assertIs(child.args, ctx.args)
+
+
+class TestPrintToLog(unittest.TestCase):
+ """Tests for Context.print_to_log."""
+
+ def test_redacts_sensitive_fields(self):
+ """Sensitive fields (passwords) are redacted in log output."""
+ ctx = _make_context()
+ ctx.config.lftp.remote_password = "supersecret"
+
+ with self.assertLogs("test_context", level="DEBUG") as log_ctx:
+ ctx.print_to_log()
+
+ all_output = "\n".join(log_ctx.output)
+ self.assertNotIn("supersecret", all_output)
+ self.assertIn("********", all_output)
+
+ def test_handles_no_path_pairs(self):
+ """print_to_log handles absent path pairs without error."""
+ ctx = _make_context()
+ ctx.path_pairs_config = PathPairsConfig()
+
+ with self.assertLogs("test_context", level="DEBUG") as log_ctx:
+ ctx.print_to_log()
+
+ all_output = "\n".join(log_ctx.output)
+ self.assertIn("Path Pairs: (none)", all_output)
+
+ def test_handles_present_path_pairs(self):
+ """print_to_log logs path pair details when pairs are present."""
+ ctx = _make_context()
+ ppc = PathPairsConfig()
+ pair = PathPair(remote_path="/remote/test", local_path="/local/test")
+ pair.name = "TestPair"
+ pair.enabled = True
+ pair.auto_queue = True
+ # Use the public property setter so PathPairsConfig's lock is
+ # acquired, instead of touching the private _pairs attribute.
+ ppc.pairs = [pair]
+ ctx.path_pairs_config = ppc
+
+ with self.assertLogs("test_context", level="DEBUG") as log_ctx:
+ ctx.print_to_log()
+
+ all_output = "\n".join(log_ctx.output)
+ self.assertIn("TestPair", all_output)
+ self.assertIn("/remote/test", all_output)
+
+
+class TestArgsAsDict(unittest.TestCase):
+ """Tests for Args.as_dict."""
+
+ def test_serializes_all_fields(self):
+ args = Args()
+ args.local_path_to_scanfs = "/scan"
+ args.html_path = "/html"
+ args.debug = True
+ args.exit = False
+ args.logdir = "/logs"
+
+ d = args.as_dict()
+ self.assertEqual(d["local_path_to_scanfs"], "/scan")
+ self.assertEqual(d["html_path"], "/html")
+ self.assertEqual(d["debug"], "True")
+ self.assertEqual(d["exit"], "False")
+ self.assertEqual(d["logdir"], "/logs")
+
+ def test_none_values_serialized_as_string(self):
+ args = Args()
+ d = args.as_dict()
+ self.assertEqual(d["local_path_to_scanfs"], "None")
+ self.assertEqual(d["html_path"], "None")
diff --git a/src/python/tests/unittests/test_controller/test_delete/__init__.py b/src/python/tests/unittests/test_controller/test_delete/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/python/tests/unittests/test_controller/test_delete/test_delete_process.py b/src/python/tests/unittests/test_controller/test_delete/test_delete_process.py
new file mode 100644
index 00000000..47d6f512
--- /dev/null
+++ b/src/python/tests/unittests/test_controller/test_delete/test_delete_process.py
@@ -0,0 +1,167 @@
+# Copyright 2017, Inderpreet Singh, All rights reserved.
+
+import logging
+import sys
+import unittest
+from unittest.mock import patch
+
+from controller.delete.delete_process import DeleteLocalProcess, DeleteRemoteProcess
+
+
+class TestDeleteLocalProcess(unittest.TestCase):
+ """Tests for DeleteLocalProcess.run_once()."""
+
+ def setUp(self):
+ logger = logging.getLogger()
+ handler = logging.StreamHandler(sys.stdout)
+ logger.addHandler(handler)
+ self.addCleanup(logger.removeHandler, handler)
+ logger.setLevel(logging.DEBUG)
+
+ @patch("controller.delete.delete_process.shutil.rmtree")
+ @patch("controller.delete.delete_process.os.path.isfile", return_value=False)
+ @patch("controller.delete.delete_process.os.path.exists", return_value=True)
+ @patch("controller.delete.delete_process.os.path.realpath")
+ def test_directory_uses_rmtree(self, mock_realpath, mock_exists, mock_isfile, mock_rmtree):
+ """Directory deletion uses shutil.rmtree."""
+ mock_realpath.side_effect = lambda p: p
+ proc = DeleteLocalProcess("/base", "mydir")
+ proc.run_once()
+ mock_rmtree.assert_called_once_with("/base/mydir", ignore_errors=True)
+
+ @patch("controller.delete.delete_process.os.remove")
+ @patch("controller.delete.delete_process.os.path.isfile", return_value=True)
+ @patch("controller.delete.delete_process.os.path.exists", return_value=True)
+ @patch("controller.delete.delete_process.os.path.realpath")
+ def test_regular_file_uses_os_remove(self, mock_realpath, mock_exists, mock_isfile, mock_remove):
+ """Regular file deletion uses os.remove."""
+ mock_realpath.side_effect = lambda p: p
+ proc = DeleteLocalProcess("/base", "myfile.txt")
+ proc.run_once()
+ mock_remove.assert_called_once_with("/base/myfile.txt")
+
+ @patch("controller.delete.delete_process.os.path.realpath")
+ def test_symlink_escaping_base_blocked(self, mock_realpath):
+ """Symlink escaping base directory is blocked and logged."""
+ mock_realpath.side_effect = lambda p: "/etc/passwd" if "evil" in p else p
+ proc = DeleteLocalProcess("/base", "evil_symlink")
+
+ with self.assertLogs(level="ERROR") as log_ctx:
+ proc.run_once()
+
+ self.assertTrue(any("Path traversal blocked" in msg for msg in log_ctx.output))
+
+ @patch("controller.delete.delete_process.os.path.realpath")
+ def test_path_traversal_blocked(self, mock_realpath):
+ """../../etc/passwd style paths are blocked."""
+ mock_realpath.side_effect = lambda p: "/etc/passwd" if "etc" in p else "/base"
+ proc = DeleteLocalProcess("/base", "../../etc/passwd")
+
+ with self.assertLogs(level="ERROR") as log_ctx:
+ proc.run_once()
+
+ self.assertTrue(any("Path traversal blocked" in msg for msg in log_ctx.output))
+
+ @patch("controller.delete.delete_process.os.path.exists", return_value=False)
+ @patch("controller.delete.delete_process.os.path.realpath")
+ def test_nonexistent_file_logs_error(self, mock_realpath, mock_exists):
+ """Non-existing file logs error, no crash."""
+ mock_realpath.side_effect = lambda p: p
+ proc = DeleteLocalProcess("/base", "gone.txt")
+
+ with self.assertLogs(level="ERROR") as log_ctx:
+ proc.run_once()
+
+ self.assertTrue(any("non-existing" in msg for msg in log_ctx.output))
+
+
+class TestDeleteRemoteProcess(unittest.TestCase):
+ """Tests for DeleteRemoteProcess.run_once()."""
+
+ def setUp(self):
+ logger = logging.getLogger()
+ handler = logging.StreamHandler(sys.stdout)
+ logger.addHandler(handler)
+ self.addCleanup(logger.removeHandler, handler)
+ logger.setLevel(logging.DEBUG)
+
+ @patch("controller.delete.delete_process.Sshcp")
+ def test_constructs_correct_ssh_command(self, mock_sshcp_cls):
+ """Remote delete constructs correct SSH rm -rf command."""
+ mock_ssh = mock_sshcp_cls.return_value
+ mock_ssh.shell.return_value = b""
+
+ proc = DeleteRemoteProcess(
+ remote_address="host",
+ remote_username="user",
+ remote_password="pass",
+ remote_port=22,
+ remote_path="/remote",
+ file_name="myfile.txt",
+ )
+ proc.run_once()
+
+ mock_ssh.shell.assert_called_once()
+ cmd = mock_ssh.shell.call_args[0][0]
+ self.assertIn("rm -rf", cmd)
+ self.assertIn("myfile.txt", cmd)
+
+ @patch("controller.delete.delete_process.Sshcp")
+ def test_remote_path_starting_with_dotdot_blocked(self, mock_sshcp_cls):
+ """Remote paths starting with .. are blocked."""
+ mock_ssh = mock_sshcp_cls.return_value
+
+ proc = DeleteRemoteProcess(
+ remote_address="host",
+ remote_username="user",
+ remote_password="pass",
+ remote_port=22,
+ remote_path="/remote",
+ file_name="../etc/passwd",
+ )
+
+ with self.assertLogs(level="ERROR") as log_ctx:
+ proc.run_once()
+
+ self.assertTrue(any("Path traversal blocked" in msg for msg in log_ctx.output))
+ mock_ssh.shell.assert_not_called()
+
+ @patch("controller.delete.delete_process.Sshcp")
+ def test_remote_absolute_path_blocked(self, mock_sshcp_cls):
+ """Remote file names with absolute paths are blocked."""
+ mock_ssh = mock_sshcp_cls.return_value
+
+ proc = DeleteRemoteProcess(
+ remote_address="host",
+ remote_username="user",
+ remote_password="pass",
+ remote_port=22,
+ remote_path="/remote",
+ file_name="/etc/passwd",
+ )
+
+ with self.assertLogs(level="ERROR") as log_ctx:
+ proc.run_once()
+
+ self.assertTrue(any("Path traversal blocked" in msg for msg in log_ctx.output))
+ mock_ssh.shell.assert_not_called()
+
+ @patch("controller.delete.delete_process.Sshcp")
+ def test_tilde_path_uses_double_escape(self, mock_sshcp_cls):
+ """Paths starting with ~ use double-quote escaping."""
+ mock_ssh = mock_sshcp_cls.return_value
+ mock_ssh.shell.return_value = b""
+
+ proc = DeleteRemoteProcess(
+ remote_address="host",
+ remote_username="user",
+ remote_password="pass",
+ remote_port=22,
+ remote_path="~/downloads",
+ file_name="myfile.txt",
+ )
+ proc.run_once()
+
+ cmd = mock_ssh.shell.call_args[0][0]
+ # Tilde paths use double-quote escaping (escape_remote_path_double)
+ self.assertIn('"', cmd)
diff --git a/src/python/tests/unittests/test_controller/test_pair_context.py b/src/python/tests/unittests/test_controller/test_pair_context.py
new file mode 100644
index 00000000..a418707f
--- /dev/null
+++ b/src/python/tests/unittests/test_controller/test_pair_context.py
@@ -0,0 +1,272 @@
+# Copyright 2017, Inderpreet Singh, All rights reserved.
+
+import unittest
+from unittest.mock import MagicMock
+
+from common import Args, Config, Constants, Context, Status
+from controller.pair_context import (
+ ControllerError,
+ PairContext,
+ configure_lftp,
+ validate_config,
+)
+
+
+class TestPairContextInit(unittest.TestCase):
+ """PairContext initializes with empty tracking state."""
+
+ def test_tracking_state_initialized_empty(self):
+ pc = PairContext(
+ pair_id="test-id",
+ name="test-pair",
+ remote_path="/remote",
+ local_path="/local",
+ effective_local_path="/local",
+ lftp=MagicMock(),
+ active_scanner=MagicMock(),
+ local_scanner=MagicMock(),
+ remote_scanner=MagicMock(),
+ active_scan_process=MagicMock(),
+ local_scan_process=MagicMock(),
+ remote_scan_process=MagicMock(),
+ model_builder=MagicMock(),
+ )
+ self.assertEqual(pc.active_downloading_file_names, [])
+ self.assertEqual(pc.active_extracting_file_names, [])
+ self.assertEqual(pc.prev_downloading_file_names, set())
+ self.assertEqual(pc.pending_completion, set())
+ self.assertFalse(pc.remote_scan_received)
+ self.assertFalse(pc.local_scan_received)
+ self.assertIsNone(pc.latest_remote_scan)
+ self.assertIsNone(pc.latest_local_scan)
+
+ def test_stores_constructor_args(self):
+ mock_lftp = MagicMock()
+ pc = PairContext(
+ pair_id="id-1",
+ name="my-pair",
+ remote_path="/r",
+ local_path="/l",
+ effective_local_path="/eff",
+ lftp=mock_lftp,
+ active_scanner=MagicMock(),
+ local_scanner=MagicMock(),
+ remote_scanner=MagicMock(),
+ active_scan_process=MagicMock(),
+ local_scan_process=MagicMock(),
+ remote_scan_process=MagicMock(),
+ model_builder=MagicMock(),
+ )
+ self.assertEqual(pc.pair_id, "id-1")
+ self.assertEqual(pc.name, "my-pair")
+ self.assertEqual(pc.remote_path, "/r")
+ self.assertEqual(pc.local_path, "/l")
+ self.assertEqual(pc.effective_local_path, "/eff")
+ self.assertIs(pc.lftp, mock_lftp)
+
+
+def _make_valid_context() -> Context:
+ """Create a Context with all required fields populated."""
+ config = Config()
+ # Lftp required
+ config.lftp.remote_address = "host"
+ config.lftp.remote_username = "user"
+ config.lftp.remote_port = 22
+ config.lftp.remote_path_to_scan_script = "/scan"
+ config.lftp.use_ssh_key = True
+ config.lftp.use_temp_file = True
+ config.lftp.num_max_parallel_downloads = 2
+ config.lftp.num_max_parallel_files_per_download = 3
+ config.lftp.num_max_connections_per_root_file = 4
+ config.lftp.num_max_connections_per_dir_file = 5
+ config.lftp.num_max_total_connections = 10
+ # Controller required
+ config.controller.interval_ms_remote_scan = 5000
+ config.controller.interval_ms_local_scan = 3000
+ config.controller.interval_ms_downloading_scan = 1000
+ config.controller.use_local_path_as_extract_path = True
+ # General
+ config.general.verbose = True
+ # AutoQueue
+ config.autoqueue.auto_delete_remote = False
+
+ args = Args()
+ args.local_path_to_scanfs = "/scanfs"
+
+ import logging
+
+ logger = logging.getLogger("test")
+ web_logger = logging.getLogger("test.web")
+ status = Status()
+ return Context(logger=logger, web_access_logger=web_logger, config=config, args=args, status=status)
+
+
+class TestValidateConfig(unittest.TestCase):
+ """Tests for validate_config."""
+
+ def test_fully_populated_config_passes(self):
+ ctx = _make_valid_context()
+ # Should not raise
+ validate_config(ctx)
+
+ def test_missing_lftp_field_raises(self):
+ ctx = _make_valid_context()
+ # Bypass property checker by setting internal attribute directly
+ setattr(ctx.config.lftp, "__remote_address", None)
+ with self.assertRaises(ControllerError) as cm:
+ validate_config(ctx)
+ self.assertIn("Lftp.remote_address", str(cm.exception))
+
+ def test_missing_multiple_fields_lists_all(self):
+ ctx = _make_valid_context()
+ setattr(ctx.config.lftp, "__remote_address", None)
+ setattr(ctx.config.lftp, "__remote_port", None)
+ setattr(ctx.config.controller, "__interval_ms_remote_scan", None)
+ with self.assertRaises(ControllerError) as cm:
+ validate_config(ctx)
+ msg = str(cm.exception)
+ self.assertIn("Lftp.remote_address", msg)
+ self.assertIn("Lftp.remote_port", msg)
+ self.assertIn("Controller.interval_ms_remote_scan", msg)
+
+ def test_extract_path_required_when_not_using_local(self):
+ ctx = _make_valid_context()
+ ctx.config.controller.use_local_path_as_extract_path = False
+ ctx.config.controller.extract_path = None
+ with self.assertRaises(ControllerError) as cm:
+ validate_config(ctx)
+ self.assertIn("Controller.extract_path", str(cm.exception))
+
+ def test_extract_path_not_required_when_using_local(self):
+ ctx = _make_valid_context()
+ ctx.config.controller.use_local_path_as_extract_path = True
+ ctx.config.controller.extract_path = None
+ # Should not raise
+ validate_config(ctx)
+
+ def test_missing_scanfs_arg_raises(self):
+ ctx = _make_valid_context()
+ ctx.args.local_path_to_scanfs = None
+ with self.assertRaises(ControllerError) as cm:
+ validate_config(ctx)
+ self.assertIn("Args.local_path_to_scanfs", str(cm.exception))
+
+ def test_missing_verbose_raises(self):
+ ctx = _make_valid_context()
+ ctx.config.general.verbose = None
+ with self.assertRaises(ControllerError) as cm:
+ validate_config(ctx)
+ self.assertIn("General.verbose", str(cm.exception))
+
+ def test_missing_auto_delete_remote_raises(self):
+ ctx = _make_valid_context()
+ ctx.config.autoqueue.auto_delete_remote = None
+ with self.assertRaises(ControllerError) as cm:
+ validate_config(ctx)
+ self.assertIn("AutoQueue.auto_delete_remote", str(cm.exception))
+
+
+class TestConfigureLftp(unittest.TestCase):
+ """Tests for configure_lftp."""
+
+ def test_mandatory_settings_applied(self):
+ lftp = MagicMock()
+ config = Config()
+ config.lftp.num_max_parallel_downloads = 2
+ config.lftp.num_max_parallel_files_per_download = 3
+ config.lftp.num_max_connections_per_root_file = 4
+ config.lftp.num_max_connections_per_dir_file = 5
+ config.lftp.num_max_total_connections = 10
+ config.lftp.use_temp_file = True
+
+ configure_lftp(lftp, config)
+
+ self.assertEqual(lftp.num_parallel_jobs, 2)
+ self.assertEqual(lftp.num_parallel_files, 3)
+ self.assertEqual(lftp.num_connections_per_root_file, 4)
+ self.assertEqual(lftp.num_connections_per_dir_file, 5)
+ self.assertEqual(lftp.num_max_total_connections, 10)
+ self.assertTrue(lftp.use_temp_file)
+ self.assertEqual(lftp.temp_file_name, "*" + Constants.LFTP_TEMP_FILE_SUFFIX)
+
+ def test_optional_settings_applied_when_truthy(self):
+ lftp = MagicMock()
+ config = Config()
+ config.lftp.num_max_parallel_downloads = 1
+ config.lftp.num_max_parallel_files_per_download = 1
+ config.lftp.num_max_connections_per_root_file = 1
+ config.lftp.num_max_connections_per_dir_file = 1
+ config.lftp.num_max_total_connections = 1
+ config.lftp.use_temp_file = True
+ config.lftp.net_limit_rate = "1M"
+ config.lftp.net_socket_buffer = "65536"
+ config.lftp.pget_min_chunk_size = "100k"
+
+ configure_lftp(lftp, config)
+
+ self.assertEqual(lftp.rate_limit, "1M")
+ self.assertEqual(lftp.net_socket_buffer, "65536")
+ self.assertEqual(lftp.min_chunk_size, "100k")
+
+ def test_optional_settings_skipped_when_falsy(self):
+ """Optional LFTP settings are not applied when their config values are falsy."""
+
+ class TrackingMock:
+ """Mock that tracks which attributes were set."""
+
+ def __init__(self):
+ self._set_attrs: list[str] = []
+
+ def __setattr__(self, name, value):
+ if not name.startswith("_"):
+ self._set_attrs.append(name)
+ super().__setattr__(name, value)
+
+ lftp = TrackingMock()
+ config = Config()
+ config.lftp.num_max_parallel_downloads = 1
+ config.lftp.num_max_parallel_files_per_download = 1
+ config.lftp.num_max_connections_per_root_file = 1
+ config.lftp.num_max_connections_per_dir_file = 1
+ config.lftp.num_max_total_connections = 1
+ config.lftp.use_temp_file = True
+ config.lftp.net_limit_rate = ""
+ # net_socket_buffer and pget_min_chunk_size are None by default
+
+ configure_lftp(lftp, config)
+
+ self.assertNotIn("rate_limit", lftp._set_attrs)
+ self.assertNotIn("net_socket_buffer", lftp._set_attrs)
+ self.assertNotIn("min_chunk_size", lftp._set_attrs)
+
+ def test_xfer_verify_enabled(self):
+ lftp = MagicMock()
+ config = Config()
+ config.lftp.num_max_parallel_downloads = 1
+ config.lftp.num_max_parallel_files_per_download = 1
+ config.lftp.num_max_connections_per_root_file = 1
+ config.lftp.num_max_connections_per_dir_file = 1
+ config.lftp.num_max_total_connections = 1
+ config.lftp.use_temp_file = True
+ config.validate.xfer_verify = True
+ config.validate.algorithm = "sha256"
+
+ configure_lftp(lftp, config)
+
+ self.assertTrue(lftp.xfer_verify)
+ self.assertEqual(lftp.xfer_verify_command, "sha256sum")
+
+ def test_xfer_verify_disabled(self):
+ lftp = MagicMock()
+ config = Config()
+ config.lftp.num_max_parallel_downloads = 1
+ config.lftp.num_max_parallel_files_per_download = 1
+ config.lftp.num_max_connections_per_root_file = 1
+ config.lftp.num_max_connections_per_dir_file = 1
+ config.lftp.num_max_total_connections = 1
+ config.lftp.use_temp_file = True
+ config.validate.xfer_verify = False
+
+ configure_lftp(lftp, config)
+
+ self.assertFalse(lftp.xfer_verify)
diff --git a/src/python/tests/unittests/test_controller/test_scan/test_active_scanner.py b/src/python/tests/unittests/test_controller/test_scan/test_active_scanner.py
new file mode 100644
index 00000000..aed6bc6a
--- /dev/null
+++ b/src/python/tests/unittests/test_controller/test_scan/test_active_scanner.py
@@ -0,0 +1,126 @@
+# Copyright 2017, Inderpreet Singh, All rights reserved.
+
+import logging
+import sys
+import time
+import unittest
+from unittest.mock import patch
+
+from controller.scan.active_scanner import ActiveScanner
+from system import SystemFile, SystemScannerError
+
+
+class TestActiveScanner(unittest.TestCase):
+ """Tests for ActiveScanner."""
+
+ def setUp(self):
+ logger = logging.getLogger("test_active_scanner")
+ handler = logging.StreamHandler(sys.stdout)
+ logger.addHandler(handler)
+ self.addCleanup(logger.removeHandler, handler)
+ logger.setLevel(logging.DEBUG)
+
+ @patch("controller.scan.active_scanner.SystemScanner")
+ def test_empty_active_files_returns_empty(self, mock_scanner_cls):
+ """With no active files set, scan returns empty."""
+ scanner = ActiveScanner("/local")
+ self.addCleanup(scanner.close)
+ result = scanner.scan()
+ self.assertEqual(result, [])
+
+ @patch("controller.scan.active_scanner.SystemScanner")
+ def test_scan_returns_files_after_set_active(self, mock_scanner_cls):
+ """After set_active_files, scan returns SystemFile objects for those files."""
+ mock_scanner = mock_scanner_cls.return_value
+ file_a = SystemFile("fileA", 100, False)
+ file_b = SystemFile("fileB", 200, True)
+ mock_scanner.scan_single.side_effect = [file_a, file_b]
+
+ scanner = ActiveScanner("/local")
+ self.addCleanup(scanner.close)
+ scanner.set_active_files(["fileA", "fileB"])
+ # multiprocessing.Queue.put() uses a background thread; brief pause
+ # ensures the data is available for the non-blocking get() in scan()
+ time.sleep(0.05)
+ result = scanner.scan()
+
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result[0].name, "fileA")
+ self.assertEqual(result[1].name, "fileB")
+
+ @patch("controller.scan.active_scanner.SystemScanner")
+ def test_latest_list_wins_when_multiple_set_before_scan(self, mock_scanner_cls):
+ """Multiple set_active_files before scan: latest list wins (queue draining)."""
+ mock_scanner = mock_scanner_cls.return_value
+ file_c = SystemFile("fileC", 300, False)
+ mock_scanner.scan_single.return_value = file_c
+
+ scanner = ActiveScanner("/local")
+ self.addCleanup(scanner.close)
+ scanner.set_active_files(["fileA", "fileB"])
+ scanner.set_active_files(["fileC"])
+ time.sleep(0.05)
+ result = scanner.scan()
+
+ # Only the latest list should be used
+ self.assertEqual(len(result), 1)
+ self.assertEqual(result[0].name, "fileC")
+
+ @patch("controller.scan.active_scanner.SystemScanner")
+ def test_missing_file_logged_at_debug(self, mock_scanner_cls):
+ """Missing file during download logged at DEBUG, not ERROR."""
+ mock_scanner = mock_scanner_cls.return_value
+ mock_scanner.scan_single.side_effect = SystemScannerError("file does not exist: /local/missing")
+
+ scanner = ActiveScanner("/local")
+ self.addCleanup(scanner.close)
+ scanner.set_active_files(["missing"])
+ time.sleep(0.05)
+
+ with self.assertLogs("ActiveScanner", level="DEBUG") as log_ctx:
+ result = scanner.scan()
+
+ self.assertEqual(result, [])
+ # Enforce the DEBUG level explicitly — assertLogs(level="DEBUG") only
+ # captures records >= DEBUG, so a higher-level log would also satisfy
+ # a plain message-text check. log_ctx.output entries are formatted
+ # "LEVEL:logger:message", so the DEBUG: prefix pins the level.
+ self.assertTrue(any(o.startswith("DEBUG:ActiveScanner:") and "does not exist" in o for o in log_ctx.output))
+
+ @patch("controller.scan.active_scanner.SystemScanner")
+ def test_unexpected_error_logged_at_warning(self, mock_scanner_cls):
+ """Unexpected SystemScannerError logged at WARNING."""
+ mock_scanner = mock_scanner_cls.return_value
+ mock_scanner.scan_single.side_effect = SystemScannerError("permission denied")
+
+ scanner = ActiveScanner("/local")
+ self.addCleanup(scanner.close)
+ scanner.set_active_files(["restricted"])
+ time.sleep(0.05)
+
+ with self.assertLogs("ActiveScanner", level="WARNING") as log_ctx:
+ result = scanner.scan()
+
+ self.assertEqual(result, [])
+ # Pin the level explicitly via the "WARNING:ActiveScanner:" prefix —
+ # mirrors the DEBUG-level check in test_missing_file_logged_at_debug.
+ self.assertTrue(
+ any(o.startswith("WARNING:ActiveScanner:") and "Unexpected scan error" in o for o in log_ctx.output)
+ )
+
+ @patch("controller.scan.active_scanner.SystemScanner")
+ def test_set_base_logger(self, mock_scanner_cls):
+ """set_base_logger creates a child logger."""
+ scanner = ActiveScanner("/local")
+ self.addCleanup(scanner.close)
+ parent_logger = logging.getLogger("parent")
+ scanner.set_base_logger(parent_logger)
+ self.assertEqual(scanner.logger.name, "parent.ActiveScanner")
+
+ @patch("controller.scan.active_scanner.SystemScanner")
+ def test_lftp_temp_suffix_forwarded(self, mock_scanner_cls):
+ """lftp_temp_suffix is forwarded to SystemScanner."""
+ mock_scanner = mock_scanner_cls.return_value
+ scanner = ActiveScanner("/local", lftp_temp_suffix=".partial")
+ self.addCleanup(scanner.close)
+ mock_scanner.set_lftp_temp_suffix.assert_called_once_with(".partial")
diff --git a/src/python/tests/unittests/test_controller/test_scan/test_local_scanner.py b/src/python/tests/unittests/test_controller/test_scan/test_local_scanner.py
new file mode 100644
index 00000000..eb0ac2ce
--- /dev/null
+++ b/src/python/tests/unittests/test_controller/test_scan/test_local_scanner.py
@@ -0,0 +1,67 @@
+# Copyright 2017, Inderpreet Singh, All rights reserved.
+
+import logging
+import unittest
+from unittest.mock import patch
+
+from controller.scan.local_scanner import LocalScanner
+from controller.scan.scanner_process import ScannerError
+from system import SystemFile, SystemScannerError
+
+
+class TestLocalScanner(unittest.TestCase):
+ """Tests for LocalScanner."""
+
+ @patch("controller.scan.local_scanner.os.path.isdir", return_value=False)
+ @patch("controller.scan.local_scanner.SystemScanner")
+ def test_missing_path_returns_empty_with_warning(self, mock_scanner_cls, mock_isdir):
+ """Missing scan path returns empty list + warning log."""
+ scanner = LocalScanner("/nonexistent", use_temp_file=False)
+
+ with self.assertLogs("LocalScanner", level="WARNING") as log_ctx:
+ result = scanner.scan()
+
+ self.assertEqual(result, [])
+ self.assertTrue(any("does not exist" in msg for msg in log_ctx.output))
+
+ @patch("controller.scan.local_scanner.os.path.isdir", return_value=True)
+ @patch("controller.scan.local_scanner.SystemScanner")
+ def test_successful_scan_returns_results(self, mock_scanner_cls, mock_isdir):
+ """Successful scan returns SystemScanner results."""
+ mock_scanner = mock_scanner_cls.return_value
+ files = [SystemFile("a", 10, False), SystemFile("b", 20, True)]
+ mock_scanner.scan.return_value = files
+
+ scanner = LocalScanner("/exists", use_temp_file=False)
+ result = scanner.scan()
+
+ self.assertEqual(len(result), 2)
+ self.assertEqual(result[0].name, "a")
+ self.assertEqual(result[1].name, "b")
+
+ @patch("controller.scan.local_scanner.os.path.isdir", return_value=True)
+ @patch("controller.scan.local_scanner.SystemScanner")
+ def test_scanner_error_raises_localized(self, mock_scanner_cls, mock_isdir):
+ """SystemScannerError raises ScannerError with localized message."""
+ mock_scanner = mock_scanner_cls.return_value
+ mock_scanner.scan.side_effect = SystemScannerError("disk failure")
+
+ scanner = LocalScanner("/exists", use_temp_file=False)
+
+ with self.assertRaises(ScannerError):
+ scanner.scan()
+
+ @patch("controller.scan.local_scanner.SystemScanner")
+ def test_set_base_logger(self, mock_scanner_cls):
+ """set_base_logger creates a child logger."""
+ scanner = LocalScanner("/local", use_temp_file=False)
+ parent_logger = logging.getLogger("parent")
+ scanner.set_base_logger(parent_logger)
+ self.assertEqual(scanner.logger.name, "parent.LocalScanner")
+
+ @patch("controller.scan.local_scanner.SystemScanner")
+ def test_temp_file_suffix_set(self, mock_scanner_cls):
+ """When use_temp_file is True, lftp temp suffix is set on SystemScanner."""
+ mock_scanner = mock_scanner_cls.return_value
+ LocalScanner("/local", use_temp_file=True)
+ mock_scanner.set_lftp_temp_suffix.assert_called_once()
diff --git a/src/python/tests/unittests/test_lftp/test_lftp.py b/src/python/tests/unittests/test_lftp/test_lftp.py
index 744aab79..c5c12d3a 100644
--- a/src/python/tests/unittests/test_lftp/test_lftp.py
+++ b/src/python/tests/unittests/test_lftp/test_lftp.py
@@ -114,6 +114,7 @@ def setUp(self):
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s")
handler.setFormatter(formatter)
logger.addHandler(handler)
+ self.addCleanup(logger.removeHandler, handler)
def tearDown(self):
self.lftp.raise_pending_error()
diff --git a/src/python/tests/unittests/test_ssh/test_sshcp.py b/src/python/tests/unittests/test_ssh/test_sshcp.py
index 747c8ab5..2646fa83 100644
--- a/src/python/tests/unittests/test_ssh/test_sshcp.py
+++ b/src/python/tests/unittests/test_ssh/test_sshcp.py
@@ -45,6 +45,7 @@ def setUp(self):
logger = logging.getLogger()
handler = logging.StreamHandler(sys.stdout)
logger.addHandler(handler)
+ self.addCleanup(logger.removeHandler, handler)
logger.setLevel(logging.DEBUG)
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s")
handler.setFormatter(formatter)
diff --git a/src/python/tests/unittests/test_web/test_security.py b/src/python/tests/unittests/test_web/test_security.py
new file mode 100644
index 00000000..430324f6
--- /dev/null
+++ b/src/python/tests/unittests/test_web/test_security.py
@@ -0,0 +1,526 @@
+# Copyright 2017, Inderpreet Singh, All rights reserved.
+
+import unittest
+
+import bottle
+from webtest import TestApp
+
+from web.security import (
+ _origin_tuple,
+ _RateLimiter,
+ install_api_key_auth,
+ install_csrf_protection,
+ install_rate_limiting,
+ install_security_headers,
+)
+
+
+def _make_app_with_route():
+ """Create a minimal Bottle app with a single POST/GET route for testing."""
+ app = bottle.Bottle()
+
+ @app.route("/test", method=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
+ def test_route():
+ return "ok"
+
+ @app.route("/server/stream", method="GET")
+ def stream_route():
+ return "stream"
+
+ @app.route("/server/config/get", method=["GET", "POST"])
+ def config_get_route():
+ return "config"
+
+ @app.route("/server/protected", method=["GET", "POST"])
+ def protected_route():
+ return "protected"
+
+ return app
+
+
+# ---------------------------------------------------------------------------
+# CSRF Protection Tests
+# ---------------------------------------------------------------------------
+
+
+class TestCsrfSafeMethodsExempt(unittest.TestCase):
+ """Safe methods (GET, HEAD, OPTIONS) should pass without Origin check."""
+
+ def setUp(self):
+ self.app = _make_app_with_route()
+ install_csrf_protection(self.app)
+ self.test_app = TestApp(self.app)
+
+ def test_get_without_origin_passes(self):
+ resp = self.test_app.get("/test")
+ self.assertEqual(resp.status_int, 200)
+
+ def test_head_without_origin_passes(self):
+ resp = self.test_app.head("/test")
+ self.assertEqual(resp.status_int, 200)
+
+ def test_options_without_origin_passes(self):
+ resp = self.test_app.options("/test")
+ self.assertEqual(resp.status_int, 200)
+
+
+class TestCsrfLoopbackExemption(unittest.TestCase):
+ """POST from loopback without proxy headers is exempt."""
+
+ def setUp(self):
+ self.app = _make_app_with_route()
+ install_csrf_protection(self.app)
+ self.test_app = TestApp(self.app)
+
+ def test_post_from_localhost_without_proxy_headers_passes(self):
+ """Loopback POST without X-Forwarded-For is exempt."""
+ resp = self.test_app.post(
+ "/test",
+ extra_environ={"REMOTE_ADDR": "127.0.0.1"},
+ )
+ self.assertEqual(resp.status_int, 200)
+
+ def test_post_from_ipv6_loopback_without_proxy_headers_passes(self):
+ """IPv6 loopback POST is also exempt."""
+ resp = self.test_app.post(
+ "/test",
+ extra_environ={"REMOTE_ADDR": "::1"},
+ )
+ self.assertEqual(resp.status_int, 200)
+
+ def test_post_from_localhost_string_without_proxy_headers_passes(self):
+ """'localhost' string also counts as loopback."""
+ resp = self.test_app.post(
+ "/test",
+ extra_environ={"REMOTE_ADDR": "localhost"},
+ )
+ self.assertEqual(resp.status_int, 200)
+
+ def test_post_from_loopback_with_x_forwarded_for_is_checked(self):
+ """Proxied traffic through loopback IS checked for CSRF."""
+ resp = self.test_app.post(
+ "/test",
+ headers={"X-Forwarded-For": "10.0.0.1"},
+ extra_environ={"REMOTE_ADDR": "127.0.0.1"},
+ expect_errors=True,
+ )
+ self.assertEqual(resp.status_int, 403)
+
+ def test_post_from_loopback_with_forwarded_header_is_checked(self):
+ """Forwarded header also triggers CSRF check on loopback."""
+ resp = self.test_app.post(
+ "/test",
+ headers={"Forwarded": "for=10.0.0.1"},
+ extra_environ={"REMOTE_ADDR": "127.0.0.1"},
+ expect_errors=True,
+ )
+ self.assertEqual(resp.status_int, 403)
+
+
+class TestCsrfOriginMatching(unittest.TestCase):
+ """POST with matching/mismatched Origin."""
+
+ def setUp(self):
+ self.app = _make_app_with_route()
+ install_csrf_protection(self.app)
+ self.test_app = TestApp(self.app)
+
+ def test_matching_origin_passes(self):
+ resp = self.test_app.post(
+ "/test",
+ headers={
+ "Origin": "http://example.com",
+ "Host": "example.com",
+ },
+ extra_environ={"REMOTE_ADDR": "10.0.0.1"},
+ )
+ self.assertEqual(resp.status_int, 200)
+
+ def test_mismatched_origin_returns_403(self):
+ resp = self.test_app.post(
+ "/test",
+ headers={
+ "Origin": "http://evil.com",
+ "Host": "example.com",
+ },
+ extra_environ={"REMOTE_ADDR": "10.0.0.1"},
+ expect_errors=True,
+ )
+ self.assertEqual(resp.status_int, 403)
+
+ def test_referer_fallback_when_origin_absent(self):
+ """When Origin is missing, Referer is used for CSRF check."""
+ resp = self.test_app.post(
+ "/test",
+ headers={
+ "Referer": "http://example.com/page",
+ "Host": "example.com",
+ },
+ extra_environ={"REMOTE_ADDR": "10.0.0.1"},
+ )
+ self.assertEqual(resp.status_int, 200)
+
+ def test_mismatched_referer_returns_403(self):
+ resp = self.test_app.post(
+ "/test",
+ headers={
+ "Referer": "http://evil.com/page",
+ "Host": "example.com",
+ },
+ extra_environ={"REMOTE_ADDR": "10.0.0.1"},
+ expect_errors=True,
+ )
+ self.assertEqual(resp.status_int, 403)
+
+ def test_neither_origin_nor_referer_returns_403(self):
+ resp = self.test_app.post(
+ "/test",
+ headers={"Host": "example.com"},
+ extra_environ={"REMOTE_ADDR": "10.0.0.1"},
+ expect_errors=True,
+ )
+ self.assertEqual(resp.status_int, 403)
+ self.assertIn("missing Origin/Referer", resp.text)
+
+
+class TestCsrfPortNormalization(unittest.TestCase):
+ """Port normalization: default ports (80/443) are treated as equivalent."""
+
+ def setUp(self):
+ self.app = _make_app_with_route()
+ install_csrf_protection(self.app)
+ self.test_app = TestApp(self.app)
+
+ def test_explicit_port_80_matches_implicit(self):
+ """http://example.com:80 should match http://example.com."""
+ resp = self.test_app.post(
+ "/test",
+ headers={
+ "Origin": "http://example.com:80",
+ "Host": "example.com",
+ },
+ extra_environ={"REMOTE_ADDR": "10.0.0.1"},
+ )
+ self.assertEqual(resp.status_int, 200)
+
+ def test_explicit_port_443_matches_implicit_https(self):
+ """https://example.com:443 should match https://example.com."""
+ resp = self.test_app.post(
+ "/test",
+ headers={
+ "Origin": "https://example.com:443",
+ "Host": "example.com",
+ },
+ extra_environ={"REMOTE_ADDR": "10.0.0.1"},
+ )
+ self.assertEqual(resp.status_int, 200)
+
+
+class TestOriginTupleHelper(unittest.TestCase):
+ """Tests for the _origin_tuple parsing helper."""
+
+ def test_normal_url(self):
+ result = _origin_tuple("http://example.com")
+ self.assertEqual(result, ("http", "example.com", 80))
+
+ def test_url_with_port(self):
+ result = _origin_tuple("http://example.com:8080")
+ self.assertEqual(result, ("http", "example.com", 8080))
+
+ def test_https_default_port(self):
+ result = _origin_tuple("https://secure.example.com")
+ self.assertEqual(result, ("https", "secure.example.com", 443))
+
+ def test_none_input(self):
+ result = _origin_tuple(None)
+ self.assertIsNone(result)
+
+ def test_empty_string(self):
+ result = _origin_tuple("")
+ self.assertIsNone(result)
+
+ def test_malformed_url_returns_none(self):
+ result = _origin_tuple("not-a-url")
+ self.assertIsNone(result)
+
+ def test_scheme_only_returns_none(self):
+ result = _origin_tuple("http://")
+ self.assertIsNone(result)
+
+
+# ---------------------------------------------------------------------------
+# Rate Limiting Tests
+# ---------------------------------------------------------------------------
+
+
+class TestRateLimiterUnit(unittest.TestCase):
+ """Direct tests on the _RateLimiter class."""
+
+ def test_below_limit_requests_allowed(self):
+ limiter = _RateLimiter(max_requests=5, window_seconds=60)
+ for _ in range(5):
+ self.assertTrue(limiter.is_allowed("10.0.0.1"))
+
+ def test_exceeding_limit_rejected(self):
+ limiter = _RateLimiter(max_requests=3, window_seconds=60)
+ for _ in range(3):
+ self.assertTrue(limiter.is_allowed("10.0.0.1"))
+ self.assertFalse(limiter.is_allowed("10.0.0.1"))
+
+ def test_per_ip_independence(self):
+ limiter = _RateLimiter(max_requests=2, window_seconds=60)
+ self.assertTrue(limiter.is_allowed("10.0.0.1"))
+ self.assertTrue(limiter.is_allowed("10.0.0.1"))
+ self.assertFalse(limiter.is_allowed("10.0.0.1"))
+ # Different IP should still be allowed
+ self.assertTrue(limiter.is_allowed("10.0.0.2"))
+ self.assertTrue(limiter.is_allowed("10.0.0.2"))
+
+ def test_retry_after_returns_positive_value(self):
+ limiter = _RateLimiter(max_requests=1, window_seconds=60)
+ limiter.is_allowed("10.0.0.1")
+ limiter.is_allowed("10.0.0.1") # rejected
+ retry = limiter.retry_after("10.0.0.1")
+ self.assertGreater(retry, 0)
+
+ def test_retry_after_returns_zero_for_unknown_ip(self):
+ limiter = _RateLimiter(max_requests=5, window_seconds=60)
+ # No requests yet for this IP
+ retry = limiter.retry_after("10.0.0.99")
+ self.assertEqual(retry, 0)
+
+ def test_stale_entry_sweep(self):
+ """Stale entries should be swept after sweep_interval."""
+ limiter = _RateLimiter(max_requests=2, window_seconds=1, sweep_interval=0)
+ limiter.is_allowed("10.0.0.1")
+ limiter.is_allowed("10.0.0.1")
+ # Manually expire all timestamps
+ with limiter._lock:
+ limiter._hits["10.0.0.1"] = [0.0] # ancient timestamp
+ limiter._last_sweep = 0.0 # force sweep on next call
+ # Next call triggers sweep and allows the request
+ self.assertTrue(limiter.is_allowed("10.0.0.1"))
+
+
+class TestRateLimitingMiddleware(unittest.TestCase):
+ """Tests for the rate limiting Bottle middleware."""
+
+ def test_sse_stream_endpoint_exempt(self):
+ app = _make_app_with_route()
+ install_rate_limiting(app)
+ test_app = TestApp(app)
+ # SSE stream should never be rate limited
+ for _ in range(200):
+ resp = test_app.get("/server/stream")
+ self.assertEqual(resp.status_int, 200)
+
+ def test_disable_flag_skips_limiting(self):
+ app = _make_app_with_route()
+ install_rate_limiting(app, disable=True)
+ test_app = TestApp(app)
+ # With disable=True, no rate limiting should apply at all
+ for _ in range(200):
+ resp = test_app.get("/test")
+ self.assertEqual(resp.status_int, 200)
+
+ def test_rate_limit_returns_429_with_retry_after(self):
+ app = _make_app_with_route()
+ limiter = _RateLimiter(max_requests=2, window_seconds=60)
+
+ @app.hook("before_request")
+ def _rate_limit():
+ ip = bottle.request.environ.get("REMOTE_ADDR", "127.0.0.1")
+ if not limiter.is_allowed(ip):
+ retry = limiter.retry_after(ip)
+ resp = bottle.HTTPError(429, "Rate limit exceeded")
+ resp.headers["Retry-After"] = str(retry)
+ raise resp
+
+ test_app = TestApp(app)
+ test_app.get("/test", extra_environ={"REMOTE_ADDR": "10.0.0.1"})
+ test_app.get("/test", extra_environ={"REMOTE_ADDR": "10.0.0.1"})
+ resp = test_app.get(
+ "/test",
+ extra_environ={"REMOTE_ADDR": "10.0.0.1"},
+ expect_errors=True,
+ )
+ self.assertEqual(resp.status_int, 429)
+ self.assertIn("Retry-After", resp.headers)
+
+ def test_x_forwarded_for_used_when_trusted(self):
+ app = _make_app_with_route()
+ limiter = _RateLimiter(max_requests=2, window_seconds=60)
+
+ @app.hook("before_request")
+ def _rate_limit():
+ ip = None
+ forwarded = bottle.request.get_header("X-Forwarded-For")
+ if forwarded:
+ ip = forwarded.split(",")[0].strip()
+ if not ip:
+ ip = bottle.request.environ.get("REMOTE_ADDR", "127.0.0.1")
+ if not limiter.is_allowed(ip):
+ raise bottle.HTTPError(429, "Rate limit exceeded")
+
+ test_app = TestApp(app)
+
+ # Requests with X-Forwarded-For should use the forwarded IP
+ test_app.get(
+ "/test",
+ headers={"X-Forwarded-For": "192.168.1.100"},
+ extra_environ={"REMOTE_ADDR": "127.0.0.1"},
+ )
+ test_app.get(
+ "/test",
+ headers={"X-Forwarded-For": "192.168.1.100"},
+ extra_environ={"REMOTE_ADDR": "127.0.0.1"},
+ )
+ # Third request from same forwarded IP should be rejected
+ resp = test_app.get(
+ "/test",
+ headers={"X-Forwarded-For": "192.168.1.100"},
+ extra_environ={"REMOTE_ADDR": "127.0.0.1"},
+ expect_errors=True,
+ )
+ self.assertEqual(resp.status_int, 429)
+
+ # But a different forwarded IP should still be allowed
+ resp = test_app.get(
+ "/test",
+ headers={"X-Forwarded-For": "192.168.1.200"},
+ extra_environ={"REMOTE_ADDR": "127.0.0.1"},
+ )
+ self.assertEqual(resp.status_int, 200)
+
+
+# ---------------------------------------------------------------------------
+# Security Headers Tests
+# ---------------------------------------------------------------------------
+
+
+class TestSecurityHeaders(unittest.TestCase):
+ """Every response should include security headers."""
+
+ def setUp(self):
+ self.app = _make_app_with_route()
+ install_security_headers(self.app)
+ self.test_app = TestApp(self.app)
+
+ def test_nosniff_header_present(self):
+ resp = self.test_app.get("/test")
+ self.assertEqual(resp.headers["X-Content-Type-Options"], "nosniff")
+
+ def test_x_frame_options_header_present(self):
+ resp = self.test_app.get("/test")
+ self.assertEqual(resp.headers["X-Frame-Options"], "DENY")
+
+ def test_csp_header_present(self):
+ resp = self.test_app.get("/test")
+ csp = resp.headers["Content-Security-Policy"]
+ self.assertIn("default-src 'self'", csp)
+ self.assertIn("script-src 'self'", csp)
+
+ def test_referrer_policy_header_present(self):
+ resp = self.test_app.get("/test")
+ self.assertEqual(
+ resp.headers["Referrer-Policy"],
+ "strict-origin-when-cross-origin",
+ )
+
+ def test_headers_on_post_response(self):
+ """Security headers should be added to POST responses too."""
+ # Need CSRF to pass — use loopback
+ install_csrf_protection(self.app)
+ test_app = TestApp(self.app)
+ resp = test_app.post(
+ "/test",
+ extra_environ={"REMOTE_ADDR": "127.0.0.1"},
+ )
+ self.assertEqual(resp.headers["X-Content-Type-Options"], "nosniff")
+ self.assertEqual(resp.headers["X-Frame-Options"], "DENY")
+
+
+# ---------------------------------------------------------------------------
+# API Key Authentication Tests
+# ---------------------------------------------------------------------------
+
+
+class TestApiKeyAuthDisabled(unittest.TestCase):
+ """When API key is empty, auth is disabled."""
+
+ def setUp(self):
+ self.app = _make_app_with_route()
+ install_api_key_auth(self.app, get_api_key=lambda: "")
+ self.test_app = TestApp(self.app)
+
+ def test_empty_key_disables_auth(self):
+ resp = self.test_app.get("/server/protected")
+ self.assertEqual(resp.status_int, 200)
+
+ def test_empty_key_allows_any_server_route(self):
+ resp = self.test_app.get("/server/stream")
+ self.assertEqual(resp.status_int, 200)
+
+
+class TestApiKeyAuthEnabled(unittest.TestCase):
+ """When API key is set, protected routes require it."""
+
+ def setUp(self):
+ self.api_key = "test-secret-key-12345"
+ self.app = _make_app_with_route()
+ install_api_key_auth(self.app, get_api_key=lambda: self.api_key)
+ self.test_app = TestApp(self.app)
+
+ def test_valid_key_passes(self):
+ resp = self.test_app.get(
+ "/server/protected",
+ headers={"X-Api-Key": self.api_key},
+ )
+ self.assertEqual(resp.status_int, 200)
+
+ def test_invalid_key_returns_401(self):
+ resp = self.test_app.get(
+ "/server/protected",
+ headers={"X-Api-Key": "wrong-key"},
+ expect_errors=True,
+ )
+ self.assertEqual(resp.status_int, 401)
+ self.assertIn("Invalid API key", resp.text)
+
+ def test_missing_key_returns_401(self):
+ resp = self.test_app.get(
+ "/server/protected",
+ expect_errors=True,
+ )
+ self.assertEqual(resp.status_int, 401)
+ self.assertIn("API key required", resp.text)
+
+ def test_non_server_paths_unprotected(self):
+ """Paths not starting with /server/ don't require API key."""
+ resp = self.test_app.get("/test")
+ self.assertEqual(resp.status_int, 200)
+
+ def test_config_get_exempt(self):
+ """/server/config/get is exempt for frontend bootstrapping."""
+ resp = self.test_app.get("/server/config/get")
+ self.assertEqual(resp.status_int, 200)
+
+ def test_sse_accepts_query_param(self):
+ """SSE stream endpoint accepts api_key as query parameter."""
+ resp = self.test_app.get(f"/server/stream?api_key={self.api_key}")
+ self.assertEqual(resp.status_int, 200)
+
+ def test_sse_rejects_invalid_query_param(self):
+ resp = self.test_app.get(
+ "/server/stream?api_key=wrong",
+ expect_errors=True,
+ )
+ self.assertEqual(resp.status_int, 401)
+
+ def test_sse_header_also_works(self):
+ """SSE stream also accepts the key via X-Api-Key header."""
+ resp = self.test_app.get(
+ "/server/stream",
+ headers={"X-Api-Key": self.api_key},
+ )
+ self.assertEqual(resp.status_int, 200)
diff --git a/src/python/tests/unittests/test_web/test_web_app_job.py b/src/python/tests/unittests/test_web/test_web_app_job.py
new file mode 100644
index 00000000..8a2137d5
--- /dev/null
+++ b/src/python/tests/unittests/test_web/test_web_app_job.py
@@ -0,0 +1,152 @@
+# Copyright 2017, Inderpreet Singh, All rights reserved.
+
+import logging
+import unittest
+from unittest.mock import MagicMock, patch
+
+from web.web_app_job import MyWSGIRefServer, WebAppJob, _RequestLoggingMiddleware
+
+
+class _WebAppJobBase(unittest.TestCase):
+ """Shared context fixture for the lifecycle test classes below."""
+
+ def _make_context(self):
+ context = MagicMock()
+ # Don't attach a StreamHandler — assertLogs() in tests installs its
+ # own capture handler, and adding one here would leak across tests
+ # (loggers are singletons).
+ logger = logging.getLogger("test_web_app_job")
+ logger.setLevel(logging.DEBUG)
+ context.logger = logger
+ context.web_access_logger = logger
+ context.config.web.port = 8080
+ context.args.debug = False
+ return context
+
+
+class TestWebAppJobSetup(_WebAppJobBase):
+ """Tests for WebAppJob.setup() — server creation and thread start."""
+
+ @patch("web.web_app_job.Thread")
+ @patch("web.web_app_job.MyWSGIRefServer")
+ def test_setup_creates_server_and_starts_thread(self, mock_server_cls, mock_thread_cls):
+ """setup() creates server on configured port and starts thread."""
+ context = self._make_context()
+ web_app = MagicMock()
+
+ job = WebAppJob(context, web_app)
+ job.setup()
+
+ mock_server_cls.assert_called_once_with(context.web_access_logger, host="0.0.0.0", port=8080)
+ mock_thread_cls.assert_called_once()
+ mock_thread_cls.return_value.start.assert_called_once()
+
+
+class TestWebAppJobExecute(_WebAppJobBase):
+ """Tests for WebAppJob.execute() — invokes the web app's process loop."""
+
+ def test_execute_calls_process(self):
+ """execute() calls web_app.process()."""
+ context = self._make_context()
+ web_app = MagicMock()
+
+ job = WebAppJob(context, web_app)
+ job.execute()
+
+ web_app.process.assert_called_once()
+
+
+class TestWebAppJobCleanup(_WebAppJobBase):
+ """Tests for WebAppJob.cleanup() — server stop and thread join."""
+
+ @patch("web.web_app_job.Thread")
+ @patch("web.web_app_job.MyWSGIRefServer")
+ def test_cleanup_stops_server_and_joins_thread(self, mock_server_cls, mock_thread_cls):
+ """cleanup() stops server and joins thread without hanging."""
+ context = self._make_context()
+ web_app = MagicMock()
+ mock_thread = mock_thread_cls.return_value
+
+ job = WebAppJob(context, web_app)
+ job.setup()
+ job.cleanup()
+
+ web_app.stop.assert_called_once()
+ mock_server_cls.return_value.stop.assert_called_once()
+ mock_thread.join.assert_called_once()
+
+
+class TestMyWSGIRefServer(unittest.TestCase):
+ """Tests for MyWSGIRefServer."""
+
+ def test_stop_on_never_initialized_server_logs_warning(self):
+ """Stop on never-initialized server logs warning, doesn't crash."""
+ logger = logging.getLogger("test_wsgi_server")
+ server = MyWSGIRefServer(logger, host="0.0.0.0", port=8080)
+
+ with self.assertLogs("test_wsgi_server", level="WARNING") as log_ctx:
+ server.stop()
+
+ self.assertTrue(any("never initialized" in msg for msg in log_ctx.output))
+
+ def test_quiet_flag_is_true(self):
+ """Server has quiet=True to suppress stdout logging."""
+ logger = logging.getLogger("test_wsgi_server")
+ server = MyWSGIRefServer(logger, host="0.0.0.0", port=8080)
+ self.assertTrue(server.quiet)
+
+
+class TestRequestLoggingMiddleware(unittest.TestCase):
+ """Tests for _RequestLoggingMiddleware."""
+
+ def test_logs_method_path_status_duration(self):
+ """Middleware logs method, path, status, duration."""
+ logger = logging.getLogger("test_request_logging")
+
+ def mock_app(environ, start_response):
+ start_response("200 OK", [])
+ return [b"ok"]
+
+ middleware = _RequestLoggingMiddleware(mock_app, logger)
+
+ environ = {
+ "REQUEST_METHOD": "GET",
+ "PATH_INFO": "/test/path",
+ }
+
+ def start_response(status, headers, *args):
+ pass
+
+ with self.assertLogs("test_request_logging", level="DEBUG") as log_ctx:
+ result = list(middleware(environ, start_response))
+
+ self.assertEqual(result, [b"ok"])
+ # Some log line should contain method, path, and status. Don't index
+ # log_ctx.output[0] — other loggers might emit lines first under load.
+ self.assertTrue(any("GET" in o and "/test/path" in o and "200" in o for o in log_ctx.output))
+
+ def test_logs_even_on_app_error(self):
+ """Duration is logged even when the app raises."""
+ logger = logging.getLogger("test_request_logging_error")
+
+ def failing_app(environ, start_response):
+ start_response("500 Internal Server Error", [])
+ raise RuntimeError("boom")
+
+ middleware = _RequestLoggingMiddleware(failing_app, logger)
+
+ environ = {
+ "REQUEST_METHOD": "POST",
+ "PATH_INFO": "/fail",
+ }
+
+ def start_response(status, headers, *args):
+ pass
+
+ with self.assertLogs("test_request_logging_error", level="DEBUG") as log_ctx:
+ with self.assertRaises(RuntimeError):
+ list(middleware(environ, start_response))
+
+ # Mirror the happy-path assertion: the error branch should still log
+ # the method, path, and status that start_response saw before the raise.
+ self.assertTrue(any("POST" in o and "/fail" in o and "500" in o for o in log_ctx.output))
diff --git a/src/python/web/handler/auto_queue.py b/src/python/web/handler/auto_queue.py
index 478d6f3f..d9c105ee 100644
--- a/src/python/web/handler/auto_queue.py
+++ b/src/python/web/handler/auto_queue.py
@@ -14,8 +14,9 @@
class AutoQueueHandler(IHandler):
_NOSNIFF_HEADERS = {"X-Content-Type-Options": "nosniff"}
- def __init__(self, auto_queue_persist: AutoQueuePersist):
+ def __init__(self, auto_queue_persist: AutoQueuePersist, persist_path: str):
self.__auto_queue_persist = auto_queue_persist
+ self.__persist_path = persist_path
@overrides(IHandler)
def add_routes(self, web_app: WebApp):
@@ -44,6 +45,7 @@ def __handle_add_autoqueue(self, pattern: str):
)
try:
self.__auto_queue_persist.add_pattern(aqp)
+ self.__auto_queue_persist.to_file(self.__persist_path)
return HTTPResponse(
body=f"Added auto-queue pattern '{pattern}'.",
content_type="text/plain",
@@ -71,6 +73,7 @@ def __handle_remove_autoqueue(self, pattern: str):
headers=self._NOSNIFF_HEADERS,
)
self.__auto_queue_persist.remove_pattern(aqp)
+ self.__auto_queue_persist.to_file(self.__persist_path)
return HTTPResponse(
body=f"Removed auto-queue pattern '{pattern}'.",
content_type="text/plain",
diff --git a/src/python/web/handler/config.py b/src/python/web/handler/config.py
index 09bc6523..52171414 100644
--- a/src/python/web/handler/config.py
+++ b/src/python/web/handler/config.py
@@ -1,5 +1,6 @@
# Copyright 2017, Inderpreet Singh, All rights reserved.
+from collections.abc import Callable
from urllib.parse import unquote
from bottle import HTTPResponse
@@ -9,10 +10,41 @@
from ..serialize import SerializeConfig
from ..web_app import IHandler, WebApp
+# (section, key) pairs for settings that can be hot-reloaded into the
+# running LFTP process without a full restart.
+_LFTP_TUNING_KEYS: frozenset[tuple[str, str]] = frozenset(
+ {
+ ("lftp", "num_max_parallel_downloads"),
+ ("lftp", "num_max_parallel_files_per_download"),
+ ("lftp", "num_max_connections_per_root_file"),
+ ("lftp", "num_max_connections_per_dir_file"),
+ ("lftp", "num_max_total_connections"),
+ ("lftp", "use_temp_file"),
+ ("lftp", "net_limit_rate"),
+ ("lftp", "net_socket_buffer"),
+ ("lftp", "pget_min_chunk_size"),
+ ("lftp", "mirror_parallel_directories"),
+ ("lftp", "net_timeout"),
+ ("lftp", "net_max_retries"),
+ ("lftp", "net_reconnect_interval_base"),
+ ("lftp", "net_reconnect_interval_multiplier"),
+ ("general", "verbose"),
+ ("validate", "xfer_verify"),
+ ("validate", "algorithm"),
+ }
+)
+
class ConfigHandler(IHandler):
- def __init__(self, config: Config):
+ def __init__(
+ self,
+ config: Config,
+ config_path: str,
+ on_lftp_config_change: Callable[[], None] | None = None,
+ ):
self.__config = config
+ self.__config_path = config_path
+ self.__on_lftp_config_change = on_lftp_config_change
@overrides(IHandler)
def add_routes(self, web_app: WebApp):
@@ -42,6 +74,9 @@ def __handle_set_config(self, section: str, key: str, value: str):
return HTTPResponse(body="Cannot set sensitive field to redacted value", status=400)
try:
inner_config.set_property(key, value)
+ self.__config.to_file(self.__config_path)
+ if (section, key) in _LFTP_TUNING_KEYS and self.__on_lftp_config_change:
+ self.__on_lftp_config_change()
if Config.is_sensitive(section, key):
return HTTPResponse(body=f"{section}.{key} updated")
return HTTPResponse(body=f"{section}.{key} set to {value}")
diff --git a/src/python/web/handler/integrations.py b/src/python/web/handler/integrations.py
index 563ece36..c50bedef 100644
--- a/src/python/web/handler/integrations.py
+++ b/src/python/web/handler/integrations.py
@@ -17,9 +17,17 @@
class IntegrationsHandler(IHandler):
"""REST endpoints for managing *arr (Sonarr/Radarr) instances."""
- def __init__(self, integrations_config: IntegrationsConfig, path_pairs_config: PathPairsConfig):
+ def __init__(
+ self,
+ integrations_config: IntegrationsConfig,
+ path_pairs_config: PathPairsConfig,
+ integrations_path: str,
+ path_pairs_path: str,
+ ):
self.__config = integrations_config
self.__path_pairs_config = path_pairs_config
+ self.__integrations_path = integrations_path
+ self.__path_pairs_path = path_pairs_path
self._logger = logging.getLogger(self.__class__.__name__)
@overrides(IHandler)
@@ -49,6 +57,7 @@ def __handle_create(self):
self.__config.add_instance(instance)
except ValueError as e:
return HTTPResponse(body=str(e), status=409)
+ self.__config.to_file(self.__integrations_path)
return HTTPResponse(
body=json.dumps(self._redact(instance.to_dict())),
status=201,
@@ -90,6 +99,7 @@ def __handle_update(self, instance_id: str):
if "not found" in msg:
return HTTPResponse(body="Integration not found", status=404)
return HTTPResponse(body=msg, status=400)
+ self.__config.to_file(self.__integrations_path)
return HTTPResponse(
body=json.dumps(self._redact(updated.to_dict())),
headers={"Content-Type": "application/json"},
@@ -102,6 +112,8 @@ def __handle_delete(self, instance_id: str):
return HTTPResponse(body="Integration not found", status=404)
# Detach from any path pair that referenced it so we don't leave dangling pointers.
self.__path_pairs_config.detach_arr_target(instance_id)
+ self.__config.to_file(self.__integrations_path)
+ self.__path_pairs_config.to_file(self.__path_pairs_path)
return HTTPResponse(status=204)
def __handle_test(self, instance_id: str):
diff --git a/src/python/web/handler/path_pairs.py b/src/python/web/handler/path_pairs.py
index c798acb7..55d6028d 100644
--- a/src/python/web/handler/path_pairs.py
+++ b/src/python/web/handler/path_pairs.py
@@ -11,9 +11,12 @@
class PathPairsHandler(IHandler):
- def __init__(self, path_pairs_config: PathPairsConfig, integrations_config: IntegrationsConfig):
+ def __init__(
+ self, path_pairs_config: PathPairsConfig, integrations_config: IntegrationsConfig, path_pairs_path: str
+ ):
self.__config = path_pairs_config
self.__integrations_config = integrations_config
+ self.__path_pairs_path = path_pairs_path
@overrides(IHandler)
def add_routes(self, web_app: WebApp):
@@ -111,6 +114,7 @@ def __handle_create(self):
if "name" in str(e) and "already exists" in str(e):
return HTTPResponse(body=str(e), status=409)
raise
+ self.__config.to_file(self.__path_pairs_path)
return HTTPResponse(body=json.dumps(pair.to_dict()), status=201, headers={"Content-Type": "application/json"})
def __handle_update(self, pair_id: str):
@@ -150,6 +154,7 @@ def __handle_update(self, pair_id: str):
if "name" in str(e) and "already exists" in str(e):
return HTTPResponse(body=str(e), status=409)
return HTTPResponse(body="Path pair not found", status=404)
+ self.__config.to_file(self.__path_pairs_path)
return HTTPResponse(body=json.dumps(updated.to_dict()), headers={"Content-Type": "application/json"})
def __handle_delete(self, pair_id: str):
@@ -157,4 +162,5 @@ def __handle_delete(self, pair_id: str):
self.__config.remove_pair(pair_id)
except ValueError:
return HTTPResponse(body="Path pair not found", status=404)
+ self.__config.to_file(self.__path_pairs_path)
return HTTPResponse(status=204)
diff --git a/src/python/web/web_app_builder.py b/src/python/web/web_app_builder.py
index 80e5c065..f767044a 100644
--- a/src/python/web/web_app_builder.py
+++ b/src/python/web/web_app_builder.py
@@ -30,14 +30,29 @@ def __init__(self, context: Context, controller: Controller, auto_queue_persist:
self.__context = context
self.__controller = controller
+ if context.config_path is None:
+ raise RuntimeError("Context.config_path must be set before building WebApp")
+ if context.path_pairs_path is None:
+ raise RuntimeError("Context.path_pairs_path must be set before building WebApp")
+ if context.integrations_path is None:
+ raise RuntimeError("Context.integrations_path must be set before building WebApp")
+ if context.auto_queue_persist_path is None:
+ raise RuntimeError("Context.auto_queue_persist_path must be set before building WebApp")
+
self.controller_handler = ControllerHandler(controller)
self.server_handler = ServerHandler(context)
- self.config_handler = ConfigHandler(context.config)
- self.auto_queue_handler = AutoQueueHandler(auto_queue_persist)
+ self.config_handler = ConfigHandler(
+ context.config, context.config_path, on_lftp_config_change=controller.request_lftp_reconfigure
+ )
+ self.auto_queue_handler = AutoQueueHandler(auto_queue_persist, context.auto_queue_persist_path)
self.status_handler = StatusHandler(context.status)
self.logs_handler = LogsHandler(logdir=context.args.logdir, service_name=Constants.SERVICE_NAME)
- self.path_pairs_handler = PathPairsHandler(context.path_pairs_config, context.integrations_config)
- self.integrations_handler = IntegrationsHandler(context.integrations_config, context.path_pairs_config)
+ self.path_pairs_handler = PathPairsHandler(
+ context.path_pairs_config, context.integrations_config, context.path_pairs_path
+ )
+ self.integrations_handler = IntegrationsHandler(
+ context.integrations_config, context.path_pairs_config, context.integrations_path, context.path_pairs_path
+ )
self.notifications_handler = NotificationsHandler(context.config)
def build(self) -> WebApp:
diff --git a/website/package-lock.json b/website/package-lock.json
index 0d47ec19..6f738623 100644
--- a/website/package-lock.json
+++ b/website/package-lock.json
@@ -8,8 +8,8 @@
"name": "seedsync-website",
"version": "0.0.0",
"dependencies": {
- "@docusaurus/core": "3.10.0",
- "@docusaurus/preset-classic": "3.10.0",
+ "@docusaurus/core": "3.10.1",
+ "@docusaurus/preset-classic": "3.10.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.3.0",
@@ -17,24 +17,24 @@
"react-dom": "^19.2.5"
},
"devDependencies": {
- "@docusaurus/faster": "3.10.0",
- "@docusaurus/module-type-aliases": "3.10.0",
- "@docusaurus/types": "3.10.0"
+ "@docusaurus/faster": "3.10.1",
+ "@docusaurus/module-type-aliases": "3.10.1",
+ "@docusaurus/types": "3.10.1"
},
"engines": {
"node": ">=20.0"
}
},
"node_modules/@algolia/abtesting": {
- "version": "1.16.1",
- "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.16.1.tgz",
- "integrity": "sha512-Xxk4l00pYI+jE0PNw8y0MvsQWh5278WRtZQav8/BMMi3HKi2xmeuqe11WJ3y8/6nuBHdv39w76OpJb09TMfAVQ==",
+ "version": "1.18.0",
+ "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.18.0.tgz",
+ "integrity": "sha512-8siuLG+FIns1AjZ/g2SDVwHz9S+ObacDQISEJvS8XsNei1zl3FXqfqQrBpmrG7ACWCyesXHbicMJtvRbg00FEw==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.50.1",
- "@algolia/requester-browser-xhr": "5.50.1",
- "@algolia/requester-fetch": "5.50.1",
- "@algolia/requester-node-http": "5.50.1"
+ "@algolia/client-common": "5.52.0",
+ "@algolia/requester-browser-xhr": "5.52.0",
+ "@algolia/requester-fetch": "5.52.0",
+ "@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
@@ -73,99 +73,99 @@
}
},
"node_modules/@algolia/client-abtesting": {
- "version": "5.50.1",
- "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.50.1.tgz",
- "integrity": "sha512-4peZlPXMwTOey9q1rQKMdCnwZb/E95/1e+7KujXpLLSh0FawJzg//U2NM+r4AiJy4+naT2MTBhj0K30yshnVTA==",
+ "version": "5.52.0",
+ "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.52.0.tgz",
+ "integrity": "sha512-wtwPgyPmO7b7sQPVgoK29c1VpfS08DnnJCmxX/oU1pV2DlMRJCzQcLN7JSloYpodyKHwM8+9wOzlAM0co3TDmA==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.50.1",
- "@algolia/requester-browser-xhr": "5.50.1",
- "@algolia/requester-fetch": "5.50.1",
- "@algolia/requester-node-http": "5.50.1"
+ "@algolia/client-common": "5.52.0",
+ "@algolia/requester-browser-xhr": "5.52.0",
+ "@algolia/requester-fetch": "5.52.0",
+ "@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/client-analytics": {
- "version": "5.50.1",
- "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.50.1.tgz",
- "integrity": "sha512-i+aWHHG8NZvGFHtPeMZkxL2Loc6Fm7iaRo15lYSMx8gFL+at9vgdWxhka7mD1fqxkrxXsQstUBCIsSY8FvkEOw==",
+ "version": "5.52.0",
+ "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.52.0.tgz",
+ "integrity": "sha512-9KY36bRl4AH7RjqSeDDOKnjsz4IxQFBEOB8/fWmEbdQe+Isbs5jGzVJu9NEPQ1Tgwxlf8Uf07Swj3jZyMNUZ2g==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.50.1",
- "@algolia/requester-browser-xhr": "5.50.1",
- "@algolia/requester-fetch": "5.50.1",
- "@algolia/requester-node-http": "5.50.1"
+ "@algolia/client-common": "5.52.0",
+ "@algolia/requester-browser-xhr": "5.52.0",
+ "@algolia/requester-fetch": "5.52.0",
+ "@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/client-common": {
- "version": "5.50.1",
- "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.50.1.tgz",
- "integrity": "sha512-Hw52Fwapyk/7hMSV/fI4+s3H9MGZEUcRh4VphyXLAk2oLYdndVUkc6KBi0zwHSzwPAr+ZBwFPe2x6naUt9mZGw==",
+ "version": "5.52.0",
+ "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.52.0.tgz",
+ "integrity": "sha512-3a/qM3dzJqqfTx7Yrw7uGQ98I3Q0rDfb4Vkv0wEzko96l7YQMxfBVz/VbLq2N+c59GweYv6Vhp8mPeqnWJSITw==",
"license": "MIT",
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/client-insights": {
- "version": "5.50.1",
- "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.50.1.tgz",
- "integrity": "sha512-Bn/wtwhJ7p1OD/6pY+Zzn+zlu2N/SJnH46md/PAbvqIzmjVuwjNwD4y0vV5Ov8naeukXdd7UU9v550+v8+mtlg==",
+ "version": "5.52.0",
+ "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.52.0.tgz",
+ "integrity": "sha512-Rki7ACbMcvbQW0BuM84x9dkGHY47ABmv4jU6tYssat2k02p3mIUms2YOLUAMeknhmnFsj6lb6ZzOXdMWMyc1sA==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.50.1",
- "@algolia/requester-browser-xhr": "5.50.1",
- "@algolia/requester-fetch": "5.50.1",
- "@algolia/requester-node-http": "5.50.1"
+ "@algolia/client-common": "5.52.0",
+ "@algolia/requester-browser-xhr": "5.52.0",
+ "@algolia/requester-fetch": "5.52.0",
+ "@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/client-personalization": {
- "version": "5.50.1",
- "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.50.1.tgz",
- "integrity": "sha512-0V4Tu0RWR8YxkgI9EPVOZHGE4K5pEIhkLNN0CTkP/rnPsqaaSQpNMYW3/mGWdiKOWbX0iVmwLB9QESk3H0jS5g==",
+ "version": "5.52.0",
+ "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.52.0.tgz",
+ "integrity": "sha512-96s4Uzc3kk+/f4jJXIVVGWP5XlngOGNQ1x6hW9AT59pOixHlOs5tqJg+ZUS/GQ6h/iYP0ceQcmxDQeLyCLTaDQ==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.50.1",
- "@algolia/requester-browser-xhr": "5.50.1",
- "@algolia/requester-fetch": "5.50.1",
- "@algolia/requester-node-http": "5.50.1"
+ "@algolia/client-common": "5.52.0",
+ "@algolia/requester-browser-xhr": "5.52.0",
+ "@algolia/requester-fetch": "5.52.0",
+ "@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/client-query-suggestions": {
- "version": "5.50.1",
- "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.50.1.tgz",
- "integrity": "sha512-jofcWNYMXJDDr87Z2eivlWY6o71Zn7F7aOvQCXSDAo9QTlyf7BhXEsZymLUvF0O1yU9Q9wvrjAWn8uVHYnAvgw==",
+ "version": "5.52.0",
+ "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.52.0.tgz",
+ "integrity": "sha512-lqeycNpSPe5Qa0OUWpejVvYQjQWV5nQuLT0a4aq7XzRAvCxprV/6Lf841EygdD2nrFnuS58ok7Au1uOtXzpnkg==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.50.1",
- "@algolia/requester-browser-xhr": "5.50.1",
- "@algolia/requester-fetch": "5.50.1",
- "@algolia/requester-node-http": "5.50.1"
+ "@algolia/client-common": "5.52.0",
+ "@algolia/requester-browser-xhr": "5.52.0",
+ "@algolia/requester-fetch": "5.52.0",
+ "@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/client-search": {
- "version": "5.50.1",
- "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.50.1.tgz",
- "integrity": "sha512-OteRb8WubcmEvU0YlMJwCXs3Q6xrdkb0v50/qZBJP1TF0CvujFZQM++9BjEkTER/Jr9wbPHvjSFKnbMta0b4dQ==",
+ "version": "5.52.0",
+ "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.52.0.tgz",
+ "integrity": "sha512-ly1wETVGRo30cx61O7fetESN+ElL9c9K+bD/AVgnT1ar4c6v+/Yqjrhdtu6Fm4D0s4NZP081Isf6tunH1wUXHg==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.50.1",
- "@algolia/requester-browser-xhr": "5.50.1",
- "@algolia/requester-fetch": "5.50.1",
- "@algolia/requester-node-http": "5.50.1"
+ "@algolia/client-common": "5.52.0",
+ "@algolia/requester-browser-xhr": "5.52.0",
+ "@algolia/requester-fetch": "5.52.0",
+ "@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
@@ -178,81 +178,81 @@
"license": "MIT"
},
"node_modules/@algolia/ingestion": {
- "version": "1.50.1",
- "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.50.1.tgz",
- "integrity": "sha512-0GmfSgDQK6oiIVXnJvGxtNFOfosBspRTR7csCOYCTL1P8QtxX2vDCIKwTM7xdSAEbJaZ43QlWg25q0Qdsndz8Q==",
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.52.0.tgz",
+ "integrity": "sha512-U4EeTvgmluRjj39ykZSAd5X+a6LD5m7/mcOWDmB7hqm1R6QY0yT8jLxpNVEjYhzgEN5hcDGW6X67EWQY8KiYGQ==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.50.1",
- "@algolia/requester-browser-xhr": "5.50.1",
- "@algolia/requester-fetch": "5.50.1",
- "@algolia/requester-node-http": "5.50.1"
+ "@algolia/client-common": "5.52.0",
+ "@algolia/requester-browser-xhr": "5.52.0",
+ "@algolia/requester-fetch": "5.52.0",
+ "@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/monitoring": {
- "version": "1.50.1",
- "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.50.1.tgz",
- "integrity": "sha512-ySuigKEe4YjYV3si8NVk9BHQpFj/1B+ON7DhhvTvbrZJseHQQloxzq0yHwKmznSdlO6C956fx4pcfOKkZClsyg==",
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.52.0.tgz",
+ "integrity": "sha512-FCPnDcILfpTE94u7BVlV4DmnSV5wE3+j25EEF+3dYPrVzkVCSoAHs318oWDGxnxsAgiL4HpL12Jc4XHmw9shpA==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.50.1",
- "@algolia/requester-browser-xhr": "5.50.1",
- "@algolia/requester-fetch": "5.50.1",
- "@algolia/requester-node-http": "5.50.1"
+ "@algolia/client-common": "5.52.0",
+ "@algolia/requester-browser-xhr": "5.52.0",
+ "@algolia/requester-fetch": "5.52.0",
+ "@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/recommend": {
- "version": "5.50.1",
- "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.50.1.tgz",
- "integrity": "sha512-Cp8T/B0gVmjFlzzp6eP47hwKh5FGyeqQp1N48/ANDdvdiQkPqLyFHQVDwLBH0LddfIPQE+yqmZIgmKc82haF4A==",
+ "version": "5.52.0",
+ "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.52.0.tgz",
+ "integrity": "sha512-br3DO7n4N8CXwTRbZS0MnB4WQ9YHfNjCwkCEzVR/wek/qNTDQKDb0nROmkFaNZ8ucUqUVKZi074dbwMwRDlK8Q==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.50.1",
- "@algolia/requester-browser-xhr": "5.50.1",
- "@algolia/requester-fetch": "5.50.1",
- "@algolia/requester-node-http": "5.50.1"
+ "@algolia/client-common": "5.52.0",
+ "@algolia/requester-browser-xhr": "5.52.0",
+ "@algolia/requester-fetch": "5.52.0",
+ "@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/requester-browser-xhr": {
- "version": "5.50.1",
- "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.50.1.tgz",
- "integrity": "sha512-XKdGGLikfrlK66ZSXh/vWcXZZ8Vg3byDFbJD8pwEvN1FoBRGxhxya476IY2ohoTymLa4qB5LBRlIa+2TLHx3Uw==",
+ "version": "5.52.0",
+ "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.52.0.tgz",
+ "integrity": "sha512-b0T/Ca2c9KyEslKsVrGZvbe1UrrKKSdfXhBZ2pbpKahFUzJfziRZ0urbOm7V65O0tO/jwU+Lo/+bIiiyhzGt8w==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.50.1"
+ "@algolia/client-common": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/requester-fetch": {
- "version": "5.50.1",
- "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.50.1.tgz",
- "integrity": "sha512-mBAU6WyVsDwhHyGM+nodt1/oebHxgvuLlOAoMGbj/1i6LygDHZWDgL1t5JEs37x9Aywv7ZGhqbM1GsfZ54sU6g==",
+ "version": "5.52.0",
+ "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.52.0.tgz",
+ "integrity": "sha512-ozBT8J/mtD4H4IAojw8QPirlcL2gHrI1BGuZ4/ZXXO/rTE1yQ4VIPJj4mTTbwo4FbkS1MoJsD/DsrqLzhnc4/g==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.50.1"
+ "@algolia/client-common": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/@algolia/requester-node-http": {
- "version": "5.50.1",
- "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.50.1.tgz",
- "integrity": "sha512-qmo1LXrNKLHvJE6mdQbLnsZAoZvj7VyF2ft4xmbSGWI2WWm87fx/CjUX4kEExt4y0a6T6nEts6ofpUfH5TEE1A==",
+ "version": "5.52.0",
+ "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.52.0.tgz",
+ "integrity": "sha512-gyyWcLD22tnabmoit4iukCXuoRc5HYJuUjPSEa8a0D/f/NlRafpWi52AlAaa4Uu/rsl7saHsJFTNjTptWbu2+A==",
"license": "MIT",
"dependencies": {
- "@algolia/client-common": "5.50.1"
+ "@algolia/client-common": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
@@ -3295,9 +3295,9 @@
}
},
"node_modules/@docsearch/core": {
- "version": "4.6.2",
- "resolved": "https://registry.npmjs.org/@docsearch/core/-/core-4.6.2.tgz",
- "integrity": "sha512-/S0e6Dj7Zcm8m9Rru49YEX49dhU11be68c+S/BCyN8zQsTTgkKzXlhRbVL5mV6lOLC2+ZRRryaTdcm070Ug2oA==",
+ "version": "4.6.3",
+ "resolved": "https://registry.npmjs.org/@docsearch/core/-/core-4.6.3.tgz",
+ "integrity": "sha512-rUOujwIpxJRgD7+kicVsI3D5sqBvdiRTquzWBpTEXZs8ZXfGbfzpus5HqumaNYTppN2HvH8E2yNuRwYdHJeOlA==",
"license": "MIT",
"peerDependencies": {
"@types/react": ">= 16.8.0 < 20.0.0",
@@ -3317,20 +3317,20 @@
}
},
"node_modules/@docsearch/css": {
- "version": "4.6.2",
- "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.6.2.tgz",
- "integrity": "sha512-fH/cn8BjEEdM2nJdjNMHIvOVYupG6AIDtFVDgIZrNzdCSj4KXr9kd+hsehqsNGYjpUjObeKYKvgy/IwCb1jZYQ==",
+ "version": "4.6.3",
+ "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.6.3.tgz",
+ "integrity": "sha512-nlOwcXcsNAptQl4vlL4MA78qNJKO0Qlds5GuBjCoePgkebTXLSf8Qt1oyZ3YBshYupKXG9VRGEsk1zr23d+bzQ==",
"license": "MIT"
},
"node_modules/@docsearch/react": {
- "version": "4.6.2",
- "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-4.6.2.tgz",
- "integrity": "sha512-/BbtGFtqVOGwZx0dw/UfhN/0/DmMQYnulY4iv0tPRhC2JCXv0ka/+izwt3Jzo1ZxXS/2eMvv9zHsBJOK1I9f/w==",
+ "version": "4.6.3",
+ "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-4.6.3.tgz",
+ "integrity": "sha512-Bg2wdDsoQVlNCcEKuEJAU04tvHCqgx8rIu+uIoM4pRtcx3TBKJuXutJik3LTA8LRc9YEyHkrYUrmcC0D7BYf+g==",
"license": "MIT",
"dependencies": {
"@algolia/autocomplete-core": "1.19.2",
- "@docsearch/core": "4.6.2",
- "@docsearch/css": "4.6.2"
+ "@docsearch/core": "4.6.3",
+ "@docsearch/css": "4.6.3"
},
"peerDependencies": {
"@types/react": ">= 16.8.0 < 20.0.0",
@@ -3386,9 +3386,9 @@
}
},
"node_modules/@docusaurus/babel": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.10.0.tgz",
- "integrity": "sha512-mqCJhCZNZUDg0zgDEaPTM4DnRsisa24HdqTy/qn/MQlbwhTb4WVaZg6ZyX6yIVKqTz8fS1hBMgM+98z+BeJJDg==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.10.1.tgz",
+ "integrity": "sha512-DZzFO1K3v/GoEt1fx1DiYHF4en+PuhtQf1AkQJa5zu3CoeKSpr5cpQRUlz3jr0m44wyzmSXu9bVpfir+N4+8bg==",
"license": "MIT",
"dependencies": {
"@babel/core": "^7.25.9",
@@ -3400,8 +3400,8 @@
"@babel/preset-typescript": "^7.25.9",
"@babel/runtime": "^7.25.9",
"@babel/traverse": "^7.25.9",
- "@docusaurus/logger": "3.10.0",
- "@docusaurus/utils": "3.10.0",
+ "@docusaurus/logger": "3.10.1",
+ "@docusaurus/utils": "3.10.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"fs-extra": "^11.1.1",
"tslib": "^2.6.0"
@@ -3411,17 +3411,17 @@
}
},
"node_modules/@docusaurus/bundler": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.10.0.tgz",
- "integrity": "sha512-iONUGZGgp+lAkw/cJZH6irONcF4p8+278IsdRlq8lYhxGjkoNUs0w7F4gVXBYSNChq5KG5/JleTSsdJySShxow==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/bundler/-/bundler-3.10.1.tgz",
+ "integrity": "sha512-HIqQPvbqnnQRe4NsBd1774KRarjXqS6wHsWELtyuSs1gCfvixJO2jUGH/OEBtr1Gvzpw+ze5CjGMvSJ8UE1KUw==",
"license": "MIT",
"dependencies": {
"@babel/core": "^7.25.9",
- "@docusaurus/babel": "3.10.0",
- "@docusaurus/cssnano-preset": "3.10.0",
- "@docusaurus/logger": "3.10.0",
- "@docusaurus/types": "3.10.0",
- "@docusaurus/utils": "3.10.0",
+ "@docusaurus/babel": "3.10.1",
+ "@docusaurus/cssnano-preset": "3.10.1",
+ "@docusaurus/logger": "3.10.1",
+ "@docusaurus/types": "3.10.1",
+ "@docusaurus/utils": "3.10.1",
"babel-loader": "^9.2.1",
"clean-css": "^5.3.3",
"copy-webpack-plugin": "^11.0.0",
@@ -3439,7 +3439,7 @@
"tslib": "^2.6.0",
"url-loader": "^4.1.1",
"webpack": "^5.95.0",
- "webpackbar": "^6.0.1"
+ "webpackbar": "^7.0.0"
},
"engines": {
"node": ">=20.0"
@@ -3454,18 +3454,18 @@
}
},
"node_modules/@docusaurus/core": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.10.0.tgz",
- "integrity": "sha512-mgLdQsO8xppnQZc3LPi+Mf+PkPeyxJeIx11AXAq/14fsaMefInQiMEZUUmrc7J+956G/f7MwE7tn8KZgi3iRcA==",
- "license": "MIT",
- "dependencies": {
- "@docusaurus/babel": "3.10.0",
- "@docusaurus/bundler": "3.10.0",
- "@docusaurus/logger": "3.10.0",
- "@docusaurus/mdx-loader": "3.10.0",
- "@docusaurus/utils": "3.10.0",
- "@docusaurus/utils-common": "3.10.0",
- "@docusaurus/utils-validation": "3.10.0",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.10.1.tgz",
+ "integrity": "sha512-3pf2fXXw0eVk8WnC3T4LIigRDupcpvngpKo9Vy7mYyBhuddc0klDUuZAIfzMoK6z05pdlk6EFC/vBSX43+1O5w==",
+ "license": "MIT",
+ "dependencies": {
+ "@docusaurus/babel": "3.10.1",
+ "@docusaurus/bundler": "3.10.1",
+ "@docusaurus/logger": "3.10.1",
+ "@docusaurus/mdx-loader": "3.10.1",
+ "@docusaurus/utils": "3.10.1",
+ "@docusaurus/utils-common": "3.10.1",
+ "@docusaurus/utils-validation": "3.10.1",
"boxen": "^6.2.1",
"chalk": "^4.1.2",
"chokidar": "^3.5.3",
@@ -3521,9 +3521,9 @@
}
},
"node_modules/@docusaurus/cssnano-preset": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.10.0.tgz",
- "integrity": "sha512-qzSshTO1DB3TYW+dPUal5KHM7XPc5YQfzF3Kdb2NDACJUyGbNcFtw3tGkCJlYwhNCRKbZcmwraKUS1i5dcHdGg==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.10.1.tgz",
+ "integrity": "sha512-eNfHGcTKCSq6xmcavAkX3RRclHaE2xRCMParlDXLdXVP01/a2e/jKXMj/0ULnLFQSNwwuI62L0Ge8J+nZsR7UQ==",
"license": "MIT",
"dependencies": {
"cssnano-preset-advanced": "^6.1.2",
@@ -3536,13 +3536,13 @@
}
},
"node_modules/@docusaurus/faster": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/faster/-/faster-3.10.0.tgz",
- "integrity": "sha512-GNPtVH14ISjHfSwnHu3KiFGf86ICmJSQDeSv/QaanpBgiZGOtgZaslnC5q8WiguxM1EVkwcGxPuD8BXF4eggKw==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/faster/-/faster-3.10.1.tgz",
+ "integrity": "sha512-XTZhE5C1gZ/DaYYMlSk02dwP5vhpQON5QHVz1s3892mSESAywgWanURpXEDAvt4GvGuq7s+XP8rTWHZvfaJmdQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
- "@docusaurus/types": "3.10.0",
+ "@docusaurus/types": "3.10.1",
"@rspack/core": "^1.7.10",
"@swc/core": "^1.7.39",
"@swc/html": "^1.13.5",
@@ -3561,9 +3561,9 @@
}
},
"node_modules/@docusaurus/logger": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.10.0.tgz",
- "integrity": "sha512-9jrZzFuBH1LDRlZ7cznAhCLmAZ3HSDqgwdrSSZdGHq9SPUOQgXXu8mnxe2ZRB9NS1PCpMTIOVUqDtZPIhMafZg==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.10.1.tgz",
+ "integrity": "sha512-oPjNFnfJsRCkePVjkGrxWGq4MvJKRQT0r9jOP0eRBTZ7Wr9FAbzdP/Gjs0I2Ss6YRkPoEgygKG112OkE6skvJw==",
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2",
@@ -3574,14 +3574,14 @@
}
},
"node_modules/@docusaurus/mdx-loader": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.10.0.tgz",
- "integrity": "sha512-mQQV97080AH4PYNs087l202NMDqRopZA4mg5W76ZZyTFrmWhJ3mHg+8A+drJVENxw5/Q+wHMHLgsx+9z1nEs0A==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.10.1.tgz",
+ "integrity": "sha512-GRmeb/wQ+iXRrFwcHBfgQhrJxGElgCsoTWZYDhccjsZVne1p8MK/EpQVIloXttz76TCe78kKD5AEG9n1xc1oxQ==",
"license": "MIT",
"dependencies": {
- "@docusaurus/logger": "3.10.0",
- "@docusaurus/utils": "3.10.0",
- "@docusaurus/utils-validation": "3.10.0",
+ "@docusaurus/logger": "3.10.1",
+ "@docusaurus/utils": "3.10.1",
+ "@docusaurus/utils-validation": "3.10.1",
"@mdx-js/mdx": "^3.0.0",
"@slorber/remark-comment": "^1.0.0",
"escape-html": "^1.0.3",
@@ -3613,12 +3613,12 @@
}
},
"node_modules/@docusaurus/module-type-aliases": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.10.0.tgz",
- "integrity": "sha512-/1O0Zg8w3DFrYX/I6Fbss7OJrtZw1QoyjDhegiFNHVi9A9Y0gQ3jUAytVxF6ywpAWpLyLxch8nN8H/V3XfzdJQ==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.10.1.tgz",
+ "integrity": "sha512-YoOZKUdGlp8xSYhuAkGdSo5Ydkbq4V4eK3sD8v0a2hloxCWdQbNBhkc+Ko9QyjpESc0BYcIGM5iHVAy5hdFV6w==",
"license": "MIT",
"dependencies": {
- "@docusaurus/types": "3.10.0",
+ "@docusaurus/types": "3.10.1",
"@types/history": "^4.7.11",
"@types/react": "*",
"@types/react-router-config": "*",
@@ -3632,19 +3632,19 @@
}
},
"node_modules/@docusaurus/plugin-content-blog": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.10.0.tgz",
- "integrity": "sha512-RuTz68DhB7CL96QO5UsFbciD7GPYq6QV+YMfF9V0+N4ZgLhJIBgpVAr8GobrKF6NRe5cyWWETU5z5T834piG9g==",
- "license": "MIT",
- "dependencies": {
- "@docusaurus/core": "3.10.0",
- "@docusaurus/logger": "3.10.0",
- "@docusaurus/mdx-loader": "3.10.0",
- "@docusaurus/theme-common": "3.10.0",
- "@docusaurus/types": "3.10.0",
- "@docusaurus/utils": "3.10.0",
- "@docusaurus/utils-common": "3.10.0",
- "@docusaurus/utils-validation": "3.10.0",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.10.1.tgz",
+ "integrity": "sha512-mmkgE6Q2+K74tnkou7tXlpDLvoCU/qkSa2GSQ3XUiHWvcebCoDQzS670RR3tO8PmaWlIyWWISYWzZLuMfxunRA==",
+ "license": "MIT",
+ "dependencies": {
+ "@docusaurus/core": "3.10.1",
+ "@docusaurus/logger": "3.10.1",
+ "@docusaurus/mdx-loader": "3.10.1",
+ "@docusaurus/theme-common": "3.10.1",
+ "@docusaurus/types": "3.10.1",
+ "@docusaurus/utils": "3.10.1",
+ "@docusaurus/utils-common": "3.10.1",
+ "@docusaurus/utils-validation": "3.10.1",
"cheerio": "1.0.0-rc.12",
"combine-promises": "^1.1.0",
"feed": "^4.2.2",
@@ -3667,20 +3667,20 @@
}
},
"node_modules/@docusaurus/plugin-content-docs": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.10.0.tgz",
- "integrity": "sha512-9BjHhf15ct8Z7TThTC0xRndKDVvMKmVsAGAN7W9FpNRzfMdScOGcXtLmcCWtJGvAezjOJIm6CxOYCy3Io5+RnQ==",
- "license": "MIT",
- "dependencies": {
- "@docusaurus/core": "3.10.0",
- "@docusaurus/logger": "3.10.0",
- "@docusaurus/mdx-loader": "3.10.0",
- "@docusaurus/module-type-aliases": "3.10.0",
- "@docusaurus/theme-common": "3.10.0",
- "@docusaurus/types": "3.10.0",
- "@docusaurus/utils": "3.10.0",
- "@docusaurus/utils-common": "3.10.0",
- "@docusaurus/utils-validation": "3.10.0",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.10.1.tgz",
+ "integrity": "sha512-2jRVrtzjf8LClGTHQlwlwuD3wQXRx3WEoF7XUarJ8Ou+0onV+SLtejsyfY9JLpfUh9hPhXM4pbBGkyAY4Bi3HQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@docusaurus/core": "3.10.1",
+ "@docusaurus/logger": "3.10.1",
+ "@docusaurus/mdx-loader": "3.10.1",
+ "@docusaurus/module-type-aliases": "3.10.1",
+ "@docusaurus/theme-common": "3.10.1",
+ "@docusaurus/types": "3.10.1",
+ "@docusaurus/utils": "3.10.1",
+ "@docusaurus/utils-common": "3.10.1",
+ "@docusaurus/utils-validation": "3.10.1",
"@types/react-router-config": "^5.0.7",
"combine-promises": "^1.1.0",
"fs-extra": "^11.1.1",
@@ -3700,16 +3700,16 @@
}
},
"node_modules/@docusaurus/plugin-content-pages": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.10.0.tgz",
- "integrity": "sha512-5amX8kEJI+nIGtuLVjYk59Y5utEJ3CHETFOPEE4cooIRLA4xM4iBsA6zFgu4ljcopeYwvBzFEWf5g2I6Yb9SkA==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.10.1.tgz",
+ "integrity": "sha512-huJpaRPMl42nsFwuCXvV8bVDj2MazuwRJIUylI/RSlmZeJssVoZXeCjVf1y+1Drtpa9SKcdGn8yoJ76IRJijtw==",
"license": "MIT",
"dependencies": {
- "@docusaurus/core": "3.10.0",
- "@docusaurus/mdx-loader": "3.10.0",
- "@docusaurus/types": "3.10.0",
- "@docusaurus/utils": "3.10.0",
- "@docusaurus/utils-validation": "3.10.0",
+ "@docusaurus/core": "3.10.1",
+ "@docusaurus/mdx-loader": "3.10.1",
+ "@docusaurus/types": "3.10.1",
+ "@docusaurus/utils": "3.10.1",
+ "@docusaurus/utils-validation": "3.10.1",
"fs-extra": "^11.1.1",
"tslib": "^2.6.0",
"webpack": "^5.88.1"
@@ -3723,15 +3723,15 @@
}
},
"node_modules/@docusaurus/plugin-css-cascade-layers": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.10.0.tgz",
- "integrity": "sha512-6q1vtt5FJcg5osgkHeM1euErECNqEZ5Z1j69yiNx2luEBIso+nxCkS9nqj8w+MK5X7rvKEToGhFfOFWncs51pQ==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.10.1.tgz",
+ "integrity": "sha512-r//fn+MNHkE1wCof8T29VAQezt1enGCpsFxoziBbvLgBM4JfXN2P3rxrBaavHmvLvm7lYkpJeitcDthwnmWCTw==",
"license": "MIT",
"dependencies": {
- "@docusaurus/core": "3.10.0",
- "@docusaurus/types": "3.10.0",
- "@docusaurus/utils": "3.10.0",
- "@docusaurus/utils-validation": "3.10.0",
+ "@docusaurus/core": "3.10.1",
+ "@docusaurus/types": "3.10.1",
+ "@docusaurus/utils": "3.10.1",
+ "@docusaurus/utils-validation": "3.10.1",
"tslib": "^2.6.0"
},
"engines": {
@@ -3739,14 +3739,14 @@
}
},
"node_modules/@docusaurus/plugin-debug": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.10.0.tgz",
- "integrity": "sha512-XcljKN+G+nmmK69uQA1d9BlYU3ZftG3T3zpK8/7Hf/wrOlV7TA4Ampdrdwkg0jElKdKAoSnPhCO0/U3bQGsVQQ==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.10.1.tgz",
+ "integrity": "sha512-9KqOpKNfAyqGZykRb9LhIT/vyRF6sm/ykhjj/39JvaJahDS+jZJE0Z1Wfz9q3DUNDTMNN0Q7u/kk4rKKU+IJuA==",
"license": "MIT",
"dependencies": {
- "@docusaurus/core": "3.10.0",
- "@docusaurus/types": "3.10.0",
- "@docusaurus/utils": "3.10.0",
+ "@docusaurus/core": "3.10.1",
+ "@docusaurus/types": "3.10.1",
+ "@docusaurus/utils": "3.10.1",
"fs-extra": "^11.1.1",
"react-json-view-lite": "^2.3.0",
"tslib": "^2.6.0"
@@ -3760,14 +3760,14 @@
}
},
"node_modules/@docusaurus/plugin-google-analytics": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.10.0.tgz",
- "integrity": "sha512-hTEoodatpBZnUat5nFExbuTGA1lhWGy7vZGuTew5Q3QDtGKFpSJLYmZJhdTjvCFwv1+qQ67hgAVlKdJOB8TXow==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.10.1.tgz",
+ "integrity": "sha512-8o0P1KtmgdYQHH+oInitPpRWI0Of5XednAX4+DMhQNSmGSRNrsEEHg1ebv35m9AgRClfAytCJ5jA9KvcASTyuA==",
"license": "MIT",
"dependencies": {
- "@docusaurus/core": "3.10.0",
- "@docusaurus/types": "3.10.0",
- "@docusaurus/utils-validation": "3.10.0",
+ "@docusaurus/core": "3.10.1",
+ "@docusaurus/types": "3.10.1",
+ "@docusaurus/utils-validation": "3.10.1",
"tslib": "^2.6.0"
},
"engines": {
@@ -3779,14 +3779,14 @@
}
},
"node_modules/@docusaurus/plugin-google-gtag": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.10.0.tgz",
- "integrity": "sha512-iB/Zzjv/eelJRbdULZqzWCbgMgJ7ht4ONVjXtN3+BI/muil6S87gQ1OJyPwlXD+ELdKkitC7bWv5eJdYOZLhrQ==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.10.1.tgz",
+ "integrity": "sha512-pu3xIUo5o/zCMLfUY9BO5KOwSH0zIsAGyFRPvXHayFSA5XIhCU/SFuB0g0ZNjFn9niZLCaNvoeAuOGFJZq0fdw==",
"license": "MIT",
"dependencies": {
- "@docusaurus/core": "3.10.0",
- "@docusaurus/types": "3.10.0",
- "@docusaurus/utils-validation": "3.10.0",
+ "@docusaurus/core": "3.10.1",
+ "@docusaurus/types": "3.10.1",
+ "@docusaurus/utils-validation": "3.10.1",
"@types/gtag.js": "^0.0.20",
"tslib": "^2.6.0"
},
@@ -3799,14 +3799,14 @@
}
},
"node_modules/@docusaurus/plugin-google-tag-manager": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.10.0.tgz",
- "integrity": "sha512-FEjZxqKgLHa+Wez/EgKxRwvArNCWIScfyEQD95rot7jkxp6nonjI5XIbGfO/iYhM5Qinwe8aIEQHP2KZtpqVuA==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.10.1.tgz",
+ "integrity": "sha512-f6fyGHiCm7kJHBtAisGQS5oNBnpnMTYQZxDXeVrnw/3zWU+LMA22pr6UHGYkBKDbN+qPC5QHG3NuOfzQLq3+Lw==",
"license": "MIT",
"dependencies": {
- "@docusaurus/core": "3.10.0",
- "@docusaurus/types": "3.10.0",
- "@docusaurus/utils-validation": "3.10.0",
+ "@docusaurus/core": "3.10.1",
+ "@docusaurus/types": "3.10.1",
+ "@docusaurus/utils-validation": "3.10.1",
"tslib": "^2.6.0"
},
"engines": {
@@ -3818,17 +3818,17 @@
}
},
"node_modules/@docusaurus/plugin-sitemap": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.10.0.tgz",
- "integrity": "sha512-DVTSLjB97hIjmayGnGcBfognCeI7ZuUKgEnU7Oz81JYqXtVg94mVTthDjq3QHTylYNeCUbkaW8VF0FDLcc8pPw==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.10.1.tgz",
+ "integrity": "sha512-C26MbmmqgdjkDq1htaZ3aD7LzEDKFWXfpyQpt0EOUThuq5nV77zDaedV20yHcVo9p+3ey9aZ4pbHA0D3QcZTzg==",
"license": "MIT",
"dependencies": {
- "@docusaurus/core": "3.10.0",
- "@docusaurus/logger": "3.10.0",
- "@docusaurus/types": "3.10.0",
- "@docusaurus/utils": "3.10.0",
- "@docusaurus/utils-common": "3.10.0",
- "@docusaurus/utils-validation": "3.10.0",
+ "@docusaurus/core": "3.10.1",
+ "@docusaurus/logger": "3.10.1",
+ "@docusaurus/types": "3.10.1",
+ "@docusaurus/utils": "3.10.1",
+ "@docusaurus/utils-common": "3.10.1",
+ "@docusaurus/utils-validation": "3.10.1",
"fs-extra": "^11.1.1",
"sitemap": "^7.1.1",
"tslib": "^2.6.0"
@@ -3842,15 +3842,15 @@
}
},
"node_modules/@docusaurus/plugin-svgr": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/plugin-svgr/-/plugin-svgr-3.10.0.tgz",
- "integrity": "sha512-lNljBESaETZqVBMPqkrGchr+UPT1eZzEPLmJhz8I76BxbjqgsUnRvrq6lQJ9sYjgmgX52KB7kkgczqd2yzoswQ==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-svgr/-/plugin-svgr-3.10.1.tgz",
+ "integrity": "sha512-6SFxsmjWFkVLDmBUvFK6i72QjUwqyQFe4Ovz+SUJophJjOyVG3ZZG5IQpBC/kX/Gfv1yWeU9nWauH6F6Q7QX/Q==",
"license": "MIT",
"dependencies": {
- "@docusaurus/core": "3.10.0",
- "@docusaurus/types": "3.10.0",
- "@docusaurus/utils": "3.10.0",
- "@docusaurus/utils-validation": "3.10.0",
+ "@docusaurus/core": "3.10.1",
+ "@docusaurus/types": "3.10.1",
+ "@docusaurus/utils": "3.10.1",
+ "@docusaurus/utils-validation": "3.10.1",
"@svgr/core": "8.1.0",
"@svgr/webpack": "^8.1.0",
"tslib": "^2.6.0",
@@ -3865,26 +3865,26 @@
}
},
"node_modules/@docusaurus/preset-classic": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.10.0.tgz",
- "integrity": "sha512-kw/Ye02Hc6xP1OdTswy8yxQEHg0fdPpyWAQRxr5b2x3h7LlG2Zgbb5BDFROnXDDMpUxB7YejlocJIE5HIEfpNA==",
- "license": "MIT",
- "dependencies": {
- "@docusaurus/core": "3.10.0",
- "@docusaurus/plugin-content-blog": "3.10.0",
- "@docusaurus/plugin-content-docs": "3.10.0",
- "@docusaurus/plugin-content-pages": "3.10.0",
- "@docusaurus/plugin-css-cascade-layers": "3.10.0",
- "@docusaurus/plugin-debug": "3.10.0",
- "@docusaurus/plugin-google-analytics": "3.10.0",
- "@docusaurus/plugin-google-gtag": "3.10.0",
- "@docusaurus/plugin-google-tag-manager": "3.10.0",
- "@docusaurus/plugin-sitemap": "3.10.0",
- "@docusaurus/plugin-svgr": "3.10.0",
- "@docusaurus/theme-classic": "3.10.0",
- "@docusaurus/theme-common": "3.10.0",
- "@docusaurus/theme-search-algolia": "3.10.0",
- "@docusaurus/types": "3.10.0"
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.10.1.tgz",
+ "integrity": "sha512-YO/FL8v1zmbxoTso6mjMz/RDjhaTJxb1UpFFTDdY5847LLDCeyYiYlrhyTbgN1RIN3xnkLKZ9Lj1x8hUzI4JOg==",
+ "license": "MIT",
+ "dependencies": {
+ "@docusaurus/core": "3.10.1",
+ "@docusaurus/plugin-content-blog": "3.10.1",
+ "@docusaurus/plugin-content-docs": "3.10.1",
+ "@docusaurus/plugin-content-pages": "3.10.1",
+ "@docusaurus/plugin-css-cascade-layers": "3.10.1",
+ "@docusaurus/plugin-debug": "3.10.1",
+ "@docusaurus/plugin-google-analytics": "3.10.1",
+ "@docusaurus/plugin-google-gtag": "3.10.1",
+ "@docusaurus/plugin-google-tag-manager": "3.10.1",
+ "@docusaurus/plugin-sitemap": "3.10.1",
+ "@docusaurus/plugin-svgr": "3.10.1",
+ "@docusaurus/theme-classic": "3.10.1",
+ "@docusaurus/theme-common": "3.10.1",
+ "@docusaurus/theme-search-algolia": "3.10.1",
+ "@docusaurus/types": "3.10.1"
},
"engines": {
"node": ">=20.0"
@@ -3895,24 +3895,24 @@
}
},
"node_modules/@docusaurus/theme-classic": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.10.0.tgz",
- "integrity": "sha512-9msCAsRdN+UG+RwPwCFb0uKy4tGoPh5YfBozXeGUtIeAgsMdn6f3G/oY861luZ3t8S2ET8S9Y/1GnpJAGWytww==",
- "license": "MIT",
- "dependencies": {
- "@docusaurus/core": "3.10.0",
- "@docusaurus/logger": "3.10.0",
- "@docusaurus/mdx-loader": "3.10.0",
- "@docusaurus/module-type-aliases": "3.10.0",
- "@docusaurus/plugin-content-blog": "3.10.0",
- "@docusaurus/plugin-content-docs": "3.10.0",
- "@docusaurus/plugin-content-pages": "3.10.0",
- "@docusaurus/theme-common": "3.10.0",
- "@docusaurus/theme-translations": "3.10.0",
- "@docusaurus/types": "3.10.0",
- "@docusaurus/utils": "3.10.0",
- "@docusaurus/utils-common": "3.10.0",
- "@docusaurus/utils-validation": "3.10.0",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.10.1.tgz",
+ "integrity": "sha512-VU1RK0qb2pab0si4r7HFK37cYco8VzqLj3u1PspVipSr/z/GPVKHO4/HXbnePqHoWDk8urjyGSeatH0NIMBM1A==",
+ "license": "MIT",
+ "dependencies": {
+ "@docusaurus/core": "3.10.1",
+ "@docusaurus/logger": "3.10.1",
+ "@docusaurus/mdx-loader": "3.10.1",
+ "@docusaurus/module-type-aliases": "3.10.1",
+ "@docusaurus/plugin-content-blog": "3.10.1",
+ "@docusaurus/plugin-content-docs": "3.10.1",
+ "@docusaurus/plugin-content-pages": "3.10.1",
+ "@docusaurus/theme-common": "3.10.1",
+ "@docusaurus/theme-translations": "3.10.1",
+ "@docusaurus/types": "3.10.1",
+ "@docusaurus/utils": "3.10.1",
+ "@docusaurus/utils-common": "3.10.1",
+ "@docusaurus/utils-validation": "3.10.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"copy-text-to-clipboard": "^3.2.0",
@@ -3936,15 +3936,15 @@
}
},
"node_modules/@docusaurus/theme-common": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.10.0.tgz",
- "integrity": "sha512-Dkp1YXKn16ByCJAdIjbDIOpVb4Z66MsVD694/ilX1vAAHaVEMrVsf/NPd9VgreyFx08rJ9GqV1MtzsbTcU73Kg==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.10.1.tgz",
+ "integrity": "sha512-0YtmIeoNo1fIw65LO8+/1dPgmDV86UmhMkow37gzjytuiCSQm9xob6PJy0L4kuQEMTLfUOGvkXvZr7GPrHquMA==",
"license": "MIT",
"dependencies": {
- "@docusaurus/mdx-loader": "3.10.0",
- "@docusaurus/module-type-aliases": "3.10.0",
- "@docusaurus/utils": "3.10.0",
- "@docusaurus/utils-common": "3.10.0",
+ "@docusaurus/mdx-loader": "3.10.1",
+ "@docusaurus/module-type-aliases": "3.10.1",
+ "@docusaurus/utils": "3.10.1",
+ "@docusaurus/utils-common": "3.10.1",
"@types/history": "^4.7.11",
"@types/react": "*",
"@types/react-router-config": "*",
@@ -3964,20 +3964,20 @@
}
},
"node_modules/@docusaurus/theme-search-algolia": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.10.0.tgz",
- "integrity": "sha512-f5FPKI08e3JRG63vR/o4qeuUVHUHzFzM0nnF+AkB67soAZgNsKJRf2qmUZvlQkGwlV+QFkKe4D0ANMh1jToU3g==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.10.1.tgz",
+ "integrity": "sha512-OTaARARVZj2GvkJQjB+1jOIxntRaXea+G+fMsNqrZBAU1O1vJKDW22R7kECOHW27oJCLFN9HKaZeRrfAUyviug==",
"license": "MIT",
"dependencies": {
"@algolia/autocomplete-core": "^1.19.2",
"@docsearch/react": "^3.9.0 || ^4.3.2",
- "@docusaurus/core": "3.10.0",
- "@docusaurus/logger": "3.10.0",
- "@docusaurus/plugin-content-docs": "3.10.0",
- "@docusaurus/theme-common": "3.10.0",
- "@docusaurus/theme-translations": "3.10.0",
- "@docusaurus/utils": "3.10.0",
- "@docusaurus/utils-validation": "3.10.0",
+ "@docusaurus/core": "3.10.1",
+ "@docusaurus/logger": "3.10.1",
+ "@docusaurus/plugin-content-docs": "3.10.1",
+ "@docusaurus/theme-common": "3.10.1",
+ "@docusaurus/theme-translations": "3.10.1",
+ "@docusaurus/utils": "3.10.1",
+ "@docusaurus/utils-validation": "3.10.1",
"algoliasearch": "^5.37.0",
"algoliasearch-helper": "^3.26.0",
"clsx": "^2.0.0",
@@ -3996,9 +3996,9 @@
}
},
"node_modules/@docusaurus/theme-translations": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.10.0.tgz",
- "integrity": "sha512-L9IbFLwTc5+XdgH45iQYufLn0SVZd6BUNelDbKIFlH+E4hhjuj/XHWAFMX/w2K59rfy8wak9McOaei7BSUfRPA==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.10.1.tgz",
+ "integrity": "sha512-cLMyaKivjBVWKMJuWqyFVVgtqe8DPJNPkog0bn8W1MDVAKcPdxRFycBfC1We1RaNp7Rdk513bmtW78RR6OBxBw==",
"license": "MIT",
"dependencies": {
"fs-extra": "^11.1.1",
@@ -4009,9 +4009,9 @@
}
},
"node_modules/@docusaurus/types": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.10.0.tgz",
- "integrity": "sha512-F0dOt3FOoO20rRaFK7whGFQZ3ggyrWEdQc/c8/UiRuzhtg4y1w9FspXH5zpCT07uMnJKBPGh+qNazbNlCQqvSw==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.10.1.tgz",
+ "integrity": "sha512-XYMK8k1szDCFMw2V+Xyen0g7Kee1sP3dtFnl7vkGkZOkeAJ/oPDQPL8iz4HBKOo/cwU8QeV6onVjMqtP+tFzsw==",
"license": "MIT",
"dependencies": {
"@mdx-js/mdx": "^3.0.0",
@@ -4045,14 +4045,14 @@
}
},
"node_modules/@docusaurus/utils": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.10.0.tgz",
- "integrity": "sha512-T3B0WTigsIthe0D4LQa2k+7bJY+c3WS+Wq2JhcznOSpn1lSN64yNtHQXboCj3QnUs1EuAZszQG1SHKu5w5ZrlA==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.10.1.tgz",
+ "integrity": "sha512-3ojeJry9xBYdJO6qoyyzqeJFSJBVx2mXhyDzSdjwL2+URFQMf+h25gG38iswGImicK0ELjTd1EL2xzk8hf3QPw==",
"license": "MIT",
"dependencies": {
- "@docusaurus/logger": "3.10.0",
- "@docusaurus/types": "3.10.0",
- "@docusaurus/utils-common": "3.10.0",
+ "@docusaurus/logger": "3.10.1",
+ "@docusaurus/types": "3.10.1",
+ "@docusaurus/utils-common": "3.10.1",
"escape-string-regexp": "^4.0.0",
"execa": "^5.1.1",
"file-loader": "^6.2.0",
@@ -4077,12 +4077,12 @@
}
},
"node_modules/@docusaurus/utils-common": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.10.0.tgz",
- "integrity": "sha512-JyL7sb9QVDgYvudIS81Dv0lsWm7le0vGZSDwsztxWam1SPBqrnkvBy9UYL/amh6pbybkyYTd3CMTkO24oMlCSw==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.10.1.tgz",
+ "integrity": "sha512-5mFSgEADtnFxFH7RLw02QA5MpU5JVUCj0MPeIvi/aF4Fi45tQRIuTwXoXDqJ+1VfQJuYJGz3SI63wmGz4HvXzA==",
"license": "MIT",
"dependencies": {
- "@docusaurus/types": "3.10.0",
+ "@docusaurus/types": "3.10.1",
"tslib": "^2.6.0"
},
"engines": {
@@ -4090,14 +4090,14 @@
}
},
"node_modules/@docusaurus/utils-validation": {
- "version": "3.10.0",
- "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.10.0.tgz",
- "integrity": "sha512-c+6n2+ZPOJtWWc8Bb/EYdpSDfjYEScdCu9fB/SNjOmSCf1IdVnGf2T53o0tsz0gDRtCL90tifTL0JE/oMuP1Mw==",
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.10.1.tgz",
+ "integrity": "sha512-cRv1X69jwaWv47waglllgZVWzeBFLhl53XT/XED/83BerVBTC5FTP8WTcVl8Z6sZOegDSwitu/wpCSPCDOT6lg==",
"license": "MIT",
"dependencies": {
- "@docusaurus/logger": "3.10.0",
- "@docusaurus/utils": "3.10.0",
- "@docusaurus/utils-common": "3.10.0",
+ "@docusaurus/logger": "3.10.1",
+ "@docusaurus/utils": "3.10.1",
+ "@docusaurus/utils-common": "3.10.1",
"fs-extra": "^11.2.0",
"joi": "^17.9.2",
"js-yaml": "^4.1.0",
@@ -4112,7 +4112,6 @@
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
- "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -4124,7 +4123,6 @@
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
- "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -4135,7 +4133,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
- "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -4773,7 +4770,6 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz",
"integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==",
- "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -5050,7 +5046,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5064,7 +5059,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5078,7 +5072,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5092,7 +5085,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5106,7 +5098,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5120,7 +5111,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5134,7 +5124,6 @@
"cpu": [
"wasm32"
],
- "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -5148,7 +5137,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5162,7 +5150,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5176,7 +5163,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5568,7 +5554,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5585,7 +5570,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5602,7 +5586,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -5619,7 +5602,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5636,7 +5618,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5653,7 +5634,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5670,7 +5650,6 @@
"cpu": [
"s390x"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5687,7 +5666,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5704,7 +5682,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5721,7 +5698,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5738,7 +5714,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5755,7 +5730,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5806,7 +5780,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5823,7 +5796,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5840,7 +5812,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -5857,7 +5828,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5874,7 +5844,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5891,7 +5860,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5908,7 +5876,6 @@
"cpu": [
"s390x"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5925,7 +5892,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5942,7 +5908,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5959,7 +5924,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5976,7 +5940,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -5993,7 +5956,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -6029,7 +5991,6 @@
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
- "dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -6728,34 +6689,34 @@
}
},
"node_modules/algoliasearch": {
- "version": "5.50.1",
- "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.50.1.tgz",
- "integrity": "sha512-/bwdue1/8LWELn/DBalGRfuLsXBLXULJo/yOeavJtDu8rBwxIzC6/Rz9Jg19S21VkJvRuZO1k8CZXBMS73mYbA==",
- "license": "MIT",
- "dependencies": {
- "@algolia/abtesting": "1.16.1",
- "@algolia/client-abtesting": "5.50.1",
- "@algolia/client-analytics": "5.50.1",
- "@algolia/client-common": "5.50.1",
- "@algolia/client-insights": "5.50.1",
- "@algolia/client-personalization": "5.50.1",
- "@algolia/client-query-suggestions": "5.50.1",
- "@algolia/client-search": "5.50.1",
- "@algolia/ingestion": "1.50.1",
- "@algolia/monitoring": "1.50.1",
- "@algolia/recommend": "5.50.1",
- "@algolia/requester-browser-xhr": "5.50.1",
- "@algolia/requester-fetch": "5.50.1",
- "@algolia/requester-node-http": "5.50.1"
+ "version": "5.52.0",
+ "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.52.0.tgz",
+ "integrity": "sha512-0ZzY9mjqV7gop/AH8pIBiAS8giXP7WcSiUfoFYIzYAK9QC5c37E4SIVtJVBMwlURc0/uNt2o4RcNRvdHa4CJ5w==",
+ "license": "MIT",
+ "dependencies": {
+ "@algolia/abtesting": "1.18.0",
+ "@algolia/client-abtesting": "5.52.0",
+ "@algolia/client-analytics": "5.52.0",
+ "@algolia/client-common": "5.52.0",
+ "@algolia/client-insights": "5.52.0",
+ "@algolia/client-personalization": "5.52.0",
+ "@algolia/client-query-suggestions": "5.52.0",
+ "@algolia/client-search": "5.52.0",
+ "@algolia/ingestion": "1.52.0",
+ "@algolia/monitoring": "1.52.0",
+ "@algolia/recommend": "5.52.0",
+ "@algolia/requester-browser-xhr": "5.52.0",
+ "@algolia/requester-fetch": "5.52.0",
+ "@algolia/requester-node-http": "5.52.0"
},
"engines": {
"node": ">= 14.0.0"
}
},
"node_modules/algoliasearch-helper": {
- "version": "3.28.1",
- "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.28.1.tgz",
- "integrity": "sha512-6iXpbkkrAI5HFpCWXlNmIDSBuoN/U1XnEvb2yJAoWfqrZ+DrybI7MQ5P5mthFaprmocq+zbi6HxnR28xnZAYBw==",
+ "version": "3.28.2",
+ "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.28.2.tgz",
+ "integrity": "sha512-sexVcXLHrJN54+S0wXD52xV3ySeGZA5T6HMDkb84wT+3UcXCd8af/k2vU5qJTbHv7DoBb4mISJHdyQ2JOo3Aig==",
"license": "MIT",
"dependencies": {
"@algolia/events": "^4.0.1"
@@ -6793,33 +6754,6 @@
"node": ">=8"
}
},
- "node_modules/ansi-escapes": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
- "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
- "license": "MIT",
- "dependencies": {
- "type-fest": "^0.21.3"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/ansi-escapes/node_modules/type-fest": {
- "version": "0.21.3",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
- "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
- "license": "(MIT OR CC0-1.0)",
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/ansi-html-community": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz",
@@ -6856,6 +6790,15 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/ansis": {
+ "version": "3.17.0",
+ "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz",
+ "integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -6920,9 +6863,9 @@
}
},
"node_modules/autoprefixer": {
- "version": "10.4.27",
- "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
- "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==",
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
+ "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
"funding": [
{
"type": "opencollective",
@@ -6939,8 +6882,8 @@
],
"license": "MIT",
"dependencies": {
- "browserslist": "^4.28.1",
- "caniuse-lite": "^1.0.30001774",
+ "browserslist": "^4.28.2",
+ "caniuse-lite": "^1.0.30001787",
"fraction.js": "^5.3.4",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
@@ -7046,9 +6989,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
- "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
+ "version": "2.10.27",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz",
+ "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
@@ -7193,9 +7136,9 @@
}
},
"node_modules/browserslist": {
- "version": "4.28.1",
- "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
- "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
"funding": [
{
"type": "opencollective",
@@ -7212,11 +7155,11 @@
],
"license": "MIT",
"dependencies": {
- "baseline-browser-mapping": "^2.9.0",
- "caniuse-lite": "^1.0.30001759",
- "electron-to-chromium": "^1.5.263",
- "node-releases": "^2.0.27",
- "update-browserslist-db": "^1.2.0"
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
},
"bin": {
"browserslist": "cli.js"
@@ -7382,9 +7325,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001774",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
- "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
+ "version": "1.0.30001791",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
+ "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
"funding": [
{
"type": "opencollective",
@@ -8881,9 +8824,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
- "version": "1.5.302",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
- "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
+ "version": "1.5.349",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
+ "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==",
"license": "ISC"
},
"node_modules/emoji-regex": {
@@ -9502,30 +9445,6 @@
"node": ">=0.4.0"
}
},
- "node_modules/figures": {
- "version": "3.2.0",
- "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
- "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==",
- "license": "MIT",
- "dependencies": {
- "escape-string-regexp": "^1.0.5"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/figures/node_modules/escape-string-regexp": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
- "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
- "license": "MIT",
- "engines": {
- "node": ">=0.8.0"
- }
- },
"node_modules/file-loader": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz",
@@ -11306,7 +11225,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -11327,7 +11245,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -11348,7 +11265,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -11369,7 +11285,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -11390,7 +11305,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -11411,7 +11325,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -11432,7 +11345,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -11453,7 +11365,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -11474,7 +11385,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -11495,7 +11405,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -11516,7 +11425,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -14161,9 +14069,9 @@
}
},
"node_modules/node-releases": {
- "version": "2.0.27",
- "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
- "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+ "version": "2.0.38",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
+ "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
"license": "MIT"
},
"node_modules/normalize-path": {
@@ -14238,9 +14146,9 @@
}
},
"node_modules/null-loader/node_modules/ajv": {
- "version": "6.14.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
- "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
@@ -17068,15 +16976,6 @@
"entities": "^2.0.0"
}
},
- "node_modules/repeat-string": {
- "version": "1.6.1",
- "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz",
- "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==",
- "license": "MIT",
- "engines": {
- "node": ">=0.10"
- }
- },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -19250,75 +19149,30 @@
}
},
"node_modules/webpackbar": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/webpackbar/-/webpackbar-6.0.1.tgz",
- "integrity": "sha512-TnErZpmuKdwWBdMoexjio3KKX6ZtoKHRVvLIU0A47R0VVBDtx3ZyOJDktgYixhoJokZTYTt1Z37OkO9pnGJa9Q==",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webpackbar/-/webpackbar-7.0.0.tgz",
+ "integrity": "sha512-aS9soqSO2iCHgqHoCrj4LbfGQUboDCYJPSFOAchEK+9psIjNrfSWW4Y0YEz67MKURNvMmfo0ycOg9d/+OOf9/Q==",
"license": "MIT",
"dependencies": {
- "ansi-escapes": "^4.3.2",
- "chalk": "^4.1.2",
+ "ansis": "^3.2.0",
"consola": "^3.2.3",
- "figures": "^3.2.0",
- "markdown-table": "^2.0.0",
"pretty-time": "^1.1.0",
- "std-env": "^3.7.0",
- "wrap-ansi": "^7.0.0"
+ "std-env": "^3.7.0"
},
"engines": {
"node": ">=14.21.3"
},
"peerDependencies": {
+ "@rspack/core": "*",
"webpack": "3 || 4 || 5"
- }
- },
- "node_modules/webpackbar/node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "license": "MIT"
- },
- "node_modules/webpackbar/node_modules/markdown-table": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz",
- "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==",
- "license": "MIT",
- "dependencies": {
- "repeat-string": "^1.0.0"
},
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/wooorm"
- }
- },
- "node_modules/webpackbar/node_modules/string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "license": "MIT",
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/webpackbar/node_modules/wrap-ansi": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
- "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^4.0.0",
- "string-width": "^4.1.0",
- "strip-ansi": "^6.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ "peerDependenciesMeta": {
+ "@rspack/core": {
+ "optional": true
+ },
+ "webpack": {
+ "optional": true
+ }
}
},
"node_modules/websocket-driver": {
diff --git a/website/package.json b/website/package.json
index 4856d9d2..1caac683 100644
--- a/website/package.json
+++ b/website/package.json
@@ -14,8 +14,8 @@
"write-heading-ids": "docusaurus write-heading-ids"
},
"dependencies": {
- "@docusaurus/core": "3.10.0",
- "@docusaurus/preset-classic": "3.10.0",
+ "@docusaurus/core": "3.10.1",
+ "@docusaurus/preset-classic": "3.10.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.3.0",
@@ -23,9 +23,9 @@
"react-dom": "^19.2.5"
},
"devDependencies": {
- "@docusaurus/faster": "3.10.0",
- "@docusaurus/module-type-aliases": "3.10.0",
- "@docusaurus/types": "3.10.0"
+ "@docusaurus/faster": "3.10.1",
+ "@docusaurus/module-type-aliases": "3.10.1",
+ "@docusaurus/types": "3.10.1"
},
"overrides": {
"serialize-javascript": "^7.0.5"