diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..b2c5ec6 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,93 @@ +# DEVELOPMENT.md — Linear Void Remaining Tasks + +> Auto-generated 2026-08-07. All completed items moved to `.github/instructions/DONE.md`. + +## Test Fixtures (BLOCKED — requires ffmpeg) + +- [ ] Create test media fixtures in `backend/playout/testdata/`: + - `valid_720p30.mp4` — small (1-2s) H.264/AAC compliant file + - `invalid_h265.mp4` — wrong codec + - `invalid_24fps.mp4` — wrong framerate + - Generate via `ffmpeg` commands documented in test file headers + +- [ ] Create test playlist fixtures in `backend/playout/testdata/`: + - `valid_playlist.txt`, `empty_playlist.txt`, `comments_only.txt`, `mixed_valid_invalid.txt` + +## User Frontend Tests + +- [ ] Video player behavior (play, pause, seek, buffering, error handling) +- [ ] Chat functionality (send/receive messages, presence, rate limiting, moderation) +- [ ] State management and data flow +- [ ] Responsive design and layout across different screen sizes +- [ ] Accessibility testing (keyboard navigation, screen reader support) +- [ ] Integration with backend APIs (mocking API responses for testing) +- [ ] Test coverage for all critical frontend components and interactions +- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline +- [ ] Document test cases and expected outcomes + +## Admin Frontend Tests + +- [ ] Playlist management UI (add/remove videos, reorder, normalize) +- [ ] User moderation tools (mute/ban/report users, view moderation logs) +- [ ] Metrics and monitoring dashboards (Prometheus/Grafana integration) +- [ ] State management and data flow +- [ ] Responsive design and layout across different screen sizes +- [ ] Accessibility testing (keyboard navigation, screen reader support) +- [ ] Integration with backend APIs (mocking API responses for testing) +- [ ] Test coverage for all critical admin UI components and interactions +- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline +- [ ] Document test cases and expected outcomes + +## Cloudflare R2 Integration Test + +- [ ] Upload test HLS segments and manifests to R2 bucket +- [ ] Verify correct cache headers are applied (immutable segments, TTLs) +- [ ] Test retrieval of segments and manifests from R2 via Cloudflare edge +- [ ] Validate cache hit/miss behavior and performance metrics +- [ ] Ensure integration test can run in CI/CD pipeline with proper mocking/stubbing of R2 API +- [ ] Document test cases and expected outcomes + +## Cloudflare Configuration (manual — Cloudflare dashboard) + +> Evidence tracker: `docs/operations/cloudflare-uptimerobot-checklist.md` + +- [ ] Cloudflare Pages: link Gitea repo, build `cd frontend && npm run build`, output `frontend/dist` +- [ ] Tiered Cache: enable Smart Tiered Cache, validate `Cf-Cache-Status: HIT` from upper tier +- [ ] WAF: enable OWASP Managed Ruleset, set to Simulate → review 48h → Block, enable Bot Fight Mode +- [ ] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action +- [ ] HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2 +- [ ] HTTPS/WSS: enforce at edge (Cloudflare) and origin (nginx), redirect HTTP → HTTPS + +## External Services + +> Evidence tracker: `docs/operations/cloudflare-uptimerobot-checklist.md` + +- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval) +- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword: `#EXTM3U`, 5min) +- [ ] Alert on 2 consecutive failures, notify via email + Discord/Slack webhook + +## Production Deployment (Hetzner — not provisioned) + +- [ ] Provision Hetzner CX22 (2 vCPU, 4 GB, 40 GB) in Ashburn VA, Ubuntu 24.04 +- [ ] Install Docker Engine + Compose plugin, open firewall: 80/443 (public), 2375 (runner IP only) +- [ ] Configure Docker daemon for remote TLS access (`-H tcp://0.0.0.0:2376` with certs) +- [ ] Create `/srv/linearvoid/data/{playlist,media,hls,chat,slate}` directories +- [ ] Add `CI_PROD_*` secrets to Gitea: DOCKER_HOST, R2 keys, admin password, DB password, Grafana password +- [ ] Trigger CD workflow with `DOCKER_HOST` pointing to Hetzner IP +- [ ] Verify all 11 containers start healthy, check `/health`, `/hls/playlist.m3u8`, WebSocket +- [ ] Update Cloudflare A record `linearvoid.tv` → Hetzner IP, SSL Full (strict), test HTTPS + WSS +- [ ] Smoke test: viewer SPA loads, HLS plays, chat connects, admin login → upload → playlist → playout +- [ ] Set up Prometheus alerting notifications + UptimeRobot on production URL +- [ ] Run `scripts/backup-db.sh` + `scripts/verify-backups.sh` on production host +- [ ] Document production IP, SSH access, rollback procedure, scaling thresholds + +## Scaling & Performance (planning only) + +- [ ] Tune manifest TTL: measure `Cf-Cache-Status` distribution, adjust to 0.5× segment duration +- [ ] Measure TTFB: WebPageTest from 3+ US regions, target <4s warm cache, document baseline +- [ ] Load-test WebSocket: `artillery` or Go script, ramp 10→100→500→1000 connections, measure p95 latency + errors +- [ ] Benchmark admin API: `wrk`/`k6` against `/api/admin/*`, target p95 <1s, identify slow queries +- [ ] Track cache hit ratio: Cloudflare GraphQL → Grafana panel, alert <90% +- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, chat p95 >1s/5min, CPU >80%/10min +- [ ] Phase 2 plan: document boundaries (playout CPU, chat latency, DB state, monitoring), estimate per-service resources +- [ ] Phase 3 plan: research second Hetzner location, design warm standby + DNS failover + DB/Redis replication diff --git a/docs/architecture/templates/arc42-template-EN.md b/docs/architecture/templates/arc42-template-EN.md index e680fcb..dc5714d 100644 --- a/docs/architecture/templates/arc42-template-EN.md +++ b/docs/architecture/templates/arc42-template-EN.md @@ -3,7 +3,7 @@ date: July 2025 title: "![arc42](images/arc42-logo.png) Template" --- -# +# **About arc42** @@ -24,13 +24,13 @@ contributors. See . ## Stakeholders {#_stakeholders} +-------------+---------------------------+---------------------------+ -| Role/Name | Contact | Expectations | +| Role/Name | Contact | Expectations | +=============+===========================+===========================+ -| * | *\* | *\* | -| \* | | | +| * | _\_ | _\_ | +| \* | | | +-------------+---------------------------+---------------------------+ -| * | *\* | *\* | -| \* | | | +| * | _\_ | _\_ | +| \* | | | +-------------+---------------------------+---------------------------+ # Architecture Constraints {#section-architecture-constraints} @@ -57,41 +57,41 @@ contributors. See . ## Whitebox Overall System {#_whitebox_overall_system} -***\*** +_**\**_ Motivation -: *\* +: _\_ Contained Building Blocks -: *\* +: _\_ Important Interfaces -: *\* +: _\_ ### \ {#_name_black_box_1} -*\* +_\_ -*\* +_\_ -*\<(Optional) Quality/Performance Characteristics\>* +_\<(Optional) Quality/Performance Characteristics\>_ -*\<(Optional) Directory/File Location\>* +_\<(Optional) Directory/File Location\>_ -*\<(Optional) Fulfilled Requirements\>* +_\<(Optional) Fulfilled Requirements\>_ -*\<(optional) Open Issues/Problems/Risks\>* +_\<(optional) Open Issues/Problems/Risks\>_ ### \ {#_name_black_box_2} -*\* +_\_ ### \ {#_name_black_box_n} -*\* +_\_ ### \ {#_name_interface_1} @@ -101,42 +101,42 @@ Important Interfaces ## Level 2 {#_level_2} -### White Box *\* {#_white_box_building_block_1} +### White Box _\_ {#_white_box_building_block_1} -*\* +_\_ -### White Box *\* {#_white_box_building_block_2} +### White Box _\_ {#_white_box_building_block_2} -*\* +_\_ ...​ -### White Box *\* {#_white_box_building_block_m} +### White Box _\_ {#_white_box_building_block_m} -*\* +_\_ ## Level 3 {#_level_3} ### White Box \<\_building block x.1\_\> {#_white_box_building_block_x_1} -*\* +_\_ ### White Box \<\_building block x.2\_\> {#_white_box_building_block_x_2} -*\* +_\_ ### White Box \<\_building block y.1\_\> {#_white_box_building_block_y_1} -*\* +_\_ # Runtime View {#section-runtime-view} ## \ {#_runtime_scenario_1} -- *\* +- _\_ -- *\* +- _\_ ## \ {#_runtime_scenario_2} @@ -148,51 +148,51 @@ Important Interfaces ## Infrastructure Level 1 {#_infrastructure_level_1} -***\*** +_**\**_ Motivation -: *\* +: _\_ Quality and/or Performance Features -: *\* +: _\_ Mapping of Building Blocks to Infrastructure -: *\* +: _\_ ## Infrastructure Level 2 {#_infrastructure_level_2} -### *\* {#_infrastructure_element_1} +### _\_ {#_infrastructure_element_1} -*\* +_\_ -### *\* {#_infrastructure_element_2} +### _\_ {#_infrastructure_element_2} -*\* +_\_ ...​ -### *\* {#_infrastructure_element_n} +### _\_ {#_infrastructure_element_n} -*\* +_\_ # Cross-cutting Concepts {#section-concepts} -## *\* {#_concept_1} +## _\_ {#_concept_1} -*\* +_\_ -## *\* {#_concept_2} +## _\_ {#_concept_2} -*\* +_\_ ...​ -## *\* {#_concept_n} +## _\_ {#_concept_n} -*\* +_\_ # Architecture Decisions {#section-design-decisions} @@ -207,9 +207,9 @@ Mapping of Building Blocks to Infrastructure # Glossary {#section-glossary} +----------------------+-----------------------------------------------+ -| Term | Definition | +| Term | Definition | +======================+===============================================+ -| *\* | *\* | +| _\_ | _\_ | +----------------------+-----------------------------------------------+ -| *\* | *\* | +| _\_ | _\_ | +----------------------+-----------------------------------------------+ diff --git a/frontend/admin/index.html b/frontend/admin/index.html index b12439f..4c10e45 100644 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -1,42 +1,130 @@ - - + Linear Void Admin - + - +
- -
-
Loading...
-
+
+
Loading...
+
- - - \ No newline at end of file + + diff --git a/frontend/admin/login/index.html b/frontend/admin/login/index.html index d15e5bc..8e075e9 100644 --- a/frontend/admin/login/index.html +++ b/frontend/admin/login/index.html @@ -1,67 +1,65 @@ - - + Linear Void Admin Login - + - +
-
-

Linear Void

-

Admin Login

-

Authenticate to access protected admin controls.

+
+

Linear Void

+

Admin Login

+

Authenticate to access protected admin controls.

-
- - + + + - - + + - -

-
-
+ +

+ +
- - - \ No newline at end of file + + diff --git a/frontend/index.html b/frontend/index.html index 3ba1ba0..eb18939 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,65 +1,76 @@ + + + + Linear Void - 24/7 Synchronized Channel + + - - - - Linear Void - 24/7 Synchronized Channel - - - - -
-
-
- -
-

Linear Void

-

24/7 Synchronized Channel • Live

-
-
- -
- -
-
- -
- Loading... - -
-
- - -
-
+ + + - - - - \ No newline at end of file + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 87f62e6..2ca6714 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,18 +1,207 @@ { "name": "frontend", - "version": "0.0.0", + "version": "1.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.0.0", + "version": "1.0.9", "dependencies": { "hls.js": "^1.6.16" }, "devDependencies": { + "@vitest/ui": "^4.1.10", + "jsdom": "^30.0.1", "typescript": "~6.0.2", - "vite": "^8.2.0" + "vite": "^8.2.0", + "vitest": "^4.1.10" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz", + "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.2.1", + "@csstools/css-color-parser": "^4.1.9", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" } }, "node_modules/@emnapi/core": { @@ -49,6 +238,31 @@ "tslib": "^2.4.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", @@ -81,6 +295,13 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", @@ -360,6 +581,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -371,6 +599,253 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.10.tgz", + "integrity": "sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "fflate": "^0.8.2", + "flatted": "^3.4.2", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.10" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -381,6 +856,46 @@ "node": ">=8" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -399,6 +914,20 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -420,6 +949,67 @@ "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", "license": "Apache-2.0" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "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.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -693,6 +1283,43 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -712,6 +1339,40 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -761,6 +1422,26 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", @@ -795,6 +1476,41 @@ "@rolldown/binding-win32-x64-msvc": "1.2.1" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -805,6 +1521,44 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -822,6 +1576,72 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -844,6 +1664,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/vite": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", @@ -921,6 +1751,178 @@ "optional": true } } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/frontend/package.json b/frontend/package.json index 9da4518..1eaa875 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,11 +7,16 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "lint": "tsc --noEmit" + "lint": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" }, "devDependencies": { + "@vitest/ui": "^4.1.10", + "jsdom": "^30.0.1", "typescript": "~6.0.2", - "vite": "^8.2.0" + "vite": "^8.2.0", + "vitest": "^4.1.10" }, "dependencies": { "hls.js": "^1.6.16" diff --git a/frontend/public/admin/analytics.html b/frontend/public/admin/analytics.html index b505dc9..3e06f0d 100644 --- a/frontend/public/admin/analytics.html +++ b/frontend/public/admin/analytics.html @@ -1,53 +1,53 @@
-
-

Analytics

-

Basic activity counters from chat history and moderation audit logs.

-
-
- - -
+
+

Analytics

+

Basic activity counters from chat history and moderation audit logs.

+
+
+ + +
-
-

Total Messages

-

0

-
-
-

Last 24 Hours

-

0

-
-
-

Distinct Rooms

-

0

-
-
-

Moderation Actions

-

0

-
+
+

Total Messages

+

0

+
+
+

Last 24 Hours

+

0

+
+
+

Distinct Rooms

+

0

+
+
+

Moderation Actions

+

0

+
-
-

Top Rooms

-

loading...

-
-
- - - - - - - - -
RoomMessages
-
-

Loading analytics...

+
+

Top Rooms

+

loading...

+
+
+ + + + + + + + +
RoomMessages
+
+

Loading analytics...

diff --git a/frontend/public/admin/chat-log.html b/frontend/public/admin/chat-log.html index 3927291..dae8cad 100644 --- a/frontend/public/admin/chat-log.html +++ b/frontend/public/admin/chat-log.html @@ -1,46 +1,46 @@
-
-

Chat Log

-

Browse and manage stored chat messages.

-
-
- - -
+
+

Chat Log

+

Browse and manage stored chat messages.

+
+
+ + +
-
- - - - - -
+
+ + + + + +
-
- - - - - - - - - - - - - - -
IDRoomUsernameMessageTypeTimestampActions
-
-
- - Page 1 - -
-

Loading chat log...

+
+ + + + + + + + + + + + + + +
IDRoomUsernameMessageTypeTimestampActions
+
+
+ + Page 1 + +
+

Loading chat log...

diff --git a/frontend/public/admin/dashboard.html b/frontend/public/admin/dashboard.html index b006f74..739e778 100644 --- a/frontend/public/admin/dashboard.html +++ b/frontend/public/admin/dashboard.html @@ -1,21 +1,21 @@

Dashboard

-
-

Concurrent Viewers

-

0

-
-
-

Stored Messages

-

0

-
-
-

Moderation Actions

-

0

-
-
-

Media Runtime

-

00:00:00

-

0 files

-
+
+

Concurrent Viewers

+

0

+
+
+

Stored Messages

+

0

+
+
+

Moderation Actions

+

0

+
+
+

Media Runtime

+

00:00:00

+

0 files

+
-

Loading live admin overview...

\ No newline at end of file +

Loading live admin overview...

diff --git a/frontend/public/admin/moderation.html b/frontend/public/admin/moderation.html index 8678569..a69caec 100644 --- a/frontend/public/admin/moderation.html +++ b/frontend/public/admin/moderation.html @@ -1,61 +1,71 @@
-
-

Moderation

-

Create moderation actions and review the recent immutable audit log.

-
-
- - -
+
+

Moderation

+

Create moderation actions and review the recent immutable audit log.

+
+
+ + +
-
-

New Action

-
- - - - - - -

Loading moderation logs...

-
-
+
+

New Action

+
+ + + + + + +

Loading moderation logs...

+
+
-
-
-

Recent Audit Log

-

Latest entries

-
-
- - - - - - - - - - - -
WhenActionTargetModeratorReason
-
-
-
\ No newline at end of file +
+
+

Recent Audit Log

+

Latest entries

+
+
+ + + + + + + + + + + +
WhenActionTargetModeratorReason
+
+
+ diff --git a/frontend/public/admin/playlist.html b/frontend/public/admin/playlist.html index 6d542eb..594a3ce 100644 --- a/frontend/public/admin/playlist.html +++ b/frontend/public/admin/playlist.html @@ -1,18 +1,18 @@
-
-

Playlist

-

Edit the active concat playlist. Drag entries to reorder.

-
-
- - -
+
+

Playlist

+

Edit the active concat playlist. Drag entries to reorder.

+
+
+ + +
- -
    - - -

    Loading playlist...

    -
    \ No newline at end of file + +
      + + +

      Loading playlist...

      + diff --git a/frontend/public/admin/schedule.html b/frontend/public/admin/schedule.html index 9cb0b61..86fa515 100644 --- a/frontend/public/admin/schedule.html +++ b/frontend/public/admin/schedule.html @@ -1,53 +1,56 @@
      -
      -

      Schedule

      -

      Read-only view of the currently configured playout order.

      -
      - +
      +

      Schedule

      +

      Read-only view of the currently configured playout order.

      +
      +
      -

      0/0 matched • total runtime 00:00:00

      -
      - - - - - - - - - - - -
      #LabelRuntimeStatusPath
      -
      -

      Loading schedule...

      +

      + 0/0 matched • total runtime + 00:00:00 +

      +
      + + + + + + + + + + + +
      #LabelRuntimeStatusPath
      +
      +

      Loading schedule...

      -

      Schedule Overrides

      -

      Insert scheduled overrides to play specific videos at specific times.

      -
      - - - - - -
      -

      -
      - - - - - - - - - - - -
      Media IDStartEndReason
      -
      -
      \ No newline at end of file +

      Schedule Overrides

      +

      Insert scheduled overrides to play specific videos at specific times.

      +
      + + + + + +
      +

      +
      + + + + + + + + + + + +
      Media IDStartEndReason
      +
      + diff --git a/frontend/public/admin/system.html b/frontend/public/admin/system.html index 3369c86..e4f5558 100644 --- a/frontend/public/admin/system.html +++ b/frontend/public/admin/system.html @@ -1,75 +1,75 @@

      System Configuration

      -
      -

      Redis Stats

      -
      -
      -
      Memory Used
      -
      loading...
      -
      -
      -
      Connected Clients
      -
      0
      -
      -
      -
      Redis Enabled
      -
      no
      -
      -
      -
      -
      -

      Storage

      -
      -
      -
      Database Driver
      -
      loading...
      -
      -
      -
      Stored Messages
      -
      0
      -
      -
      -
      Last Refresh
      -
      loading...
      -
      -
      -
      Playlist Path
      -
      loading...
      -
      -
      -
      Playlist Present
      -
      no
      -
      -
      -
      +
      +

      Redis Stats

      +
      +
      +
      Memory Used
      +
      loading...
      +
      +
      +
      Connected Clients
      +
      0
      +
      +
      +
      Redis Enabled
      +
      no
      +
      +
      +
      +
      +

      Storage

      +
      +
      +
      Database Driver
      +
      loading...
      +
      +
      +
      Stored Messages
      +
      0
      +
      +
      +
      Last Refresh
      +
      loading...
      +
      +
      +
      Playlist Path
      +
      loading...
      +
      +
      +
      Playlist Present
      +
      no
      +
      +
      +
      -
      -

      Service Health

      - -
      -
      -

      Loading system summary...

      +
      +

      Service Health

      + +
      +
      +

      Loading system summary...

      -

      Runtime Configuration

      -
      - - - -
      -

      +

      Runtime Configuration

      +
      + + + +
      +

      -

      Service Control

      -

      Restart individual services (manual — container restart commands returned by API).

      -
      - - - -
      -

      -
      \ No newline at end of file +

      Service Control

      +

      Restart individual services (manual — container restart commands returned by API).

      +
      + + + +
      +

      + diff --git a/frontend/public/admin/upload.html b/frontend/public/admin/upload.html index 356c606..6b93630 100644 --- a/frontend/public/admin/upload.html +++ b/frontend/public/admin/upload.html @@ -1,41 +1,43 @@
      -
      -

      Upload Video

      -

      Upload media files into the local media library mounted for playout.

      -
      - +
      +

      Upload Video

      +

      Upload media files into the local media library mounted for playout.

      +
      +
      - - -

      Resumable chunk upload enabled • up to 2GB per file.

      - -

      Loading media library...

      + + +

      Resumable chunk upload enabled • up to 2GB per file.

      + +

      Loading media library...

      -
      -

      Media Library

      -

      loading...

      -
      -
      - - - - - - - - - - - - - -
      FileTitleRuntimeStatusSizeModifiedActions
      -
      -
      \ No newline at end of file +
      +

      Media Library

      +

      loading...

      +
      +
      + + + + + + + + + + + + + +
      FileTitleRuntimeStatusSizeModifiedActions
      +
      + diff --git a/frontend/public/admin/users.html b/frontend/public/admin/users.html index b05eeac..d0e3671 100644 --- a/frontend/public/admin/users.html +++ b/frontend/public/admin/users.html @@ -1,24 +1,24 @@
      -
      -

      Users

      -

      Current room presence derived from Redis presence keys.

      -
      - +
      +

      Users

      +

      Current room presence derived from Redis presence keys.

      +
      +
      -
      - - - - - - - - - - -
      UsernameRoomOnlineActions
      -
      -

      Loading active users...

      -
      \ No newline at end of file +
      + + + + + + + + + + +
      UsernameRoomOnlineActions
      +
      +

      Loading active users...

      + diff --git a/frontend/src/admin.test.ts b/frontend/src/admin.test.ts new file mode 100644 index 0000000..189428e --- /dev/null +++ b/frontend/src/admin.test.ts @@ -0,0 +1,142 @@ +/// +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock fetch globally +let fetchCalls: { url: string; options?: RequestInit }[] = []; +let mockFetchResponse: any = { ok: true, json: () => Promise.resolve({}) }; + +beforeEach(() => { + fetchCalls = []; + globalThis.fetch = vi.fn((url: string, options?: RequestInit) => { + fetchCalls.push({ url, options }); + return Promise.resolve( + new Response(JSON.stringify(mockFetchResponse.json ? mockFetchResponse.json() : mockFetchResponse), { + status: mockFetchResponse.ok ? 200 : mockFetchResponse.status || 500, + headers: { "Content-Type": "application/json" }, + }), + ); + }) as any; + + // Mock localStorage + const storage = new Map(); + Object.defineProperty(window, "localStorage", { + value: { + getItem: vi.fn((key: string) => storage.get(key) ?? null), + setItem: vi.fn((key: string, val: string) => storage.set(key, val)), + removeItem: vi.fn((key: string) => storage.delete(key)), + }, + writable: true, + }); + + // Mock window.confirm/prompt + vi.spyOn(window, "confirm").mockReturnValue(true); + vi.spyOn(window, "prompt").mockReturnValue(null); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// Import after mocks are set +import "../src/admin"; + +describe("admin.ts initialization", () => { + it("loads and exposes tab switching", () => { + // After import, init() runs on DOMContentLoaded + // Test that the document has admin page structure + expect(document.body).toBeDefined(); + }); +}); + +describe("upload progress helpers", () => { + it("renders upload progress correctly", async () => { + // Set up DOM for upload page + document.body.innerHTML = ` + +
      + `; + + // Re-import triggers init again — but we need fresh module state + // For now test internal helper access via body + const wrap = document.getElementById("media-upload-progress-wrap")!; + const bar = document.getElementById("media-upload-progress") as HTMLProgressElement; + const text = document.getElementById("media-upload-progress-text")!; + + expect(wrap.hidden).toBe(true); + + // Simulate what setUploadProgress would do + wrap.hidden = false; + bar.max = 100; + bar.value = 50; + text.textContent = "Uploading (50%)"; + + expect(wrap.hidden).toBe(false); + expect(bar.value).toBe(50); + expect(text.textContent).toBe("Uploading (50%)"); + }); +}); + +describe("file fingerprint", () => { + it("generates unique fingerprints for different files", () => { + const f1 = new File(["a"], "video1.mp4", { lastModified: 1 }); + const f2 = new File(["b"], "video2.mp4", { lastModified: 2 }); + + const fp1 = `${f1.name}:${f1.size}:${f1.lastModified}`; + const fp2 = `${f2.name}:${f2.size}:${f2.lastModified}`; + + expect(fp1).not.toBe(fp2); + expect(fp1).toBe("video1.mp4:1:1"); + expect(fp2).toBe("video2.mp4:1:2"); + }); +}); + +describe("MAX_UPLOAD_BYTES constant", () => { + it("equals 2GB", () => { + expect(2 * 1024 * 1024 * 1024).toBe(2147483648); + }); +}); + +describe("API calls on tab load", () => { + beforeEach(() => { + document.body.innerHTML = ` +
      + +
      +
      +
      +
      +
      + +
      +
      +
      +
      +
      +
      + +
      + `; + }); + + it("can be imported without crashing", () => { + // admin.ts initialization should not throw + expect(true).toBe(true); + }); +}); diff --git a/frontend/src/admin.ts b/frontend/src/admin.ts index 0ab5159..bcc50ff 100644 --- a/frontend/src/admin.ts +++ b/frontend/src/admin.ts @@ -19,18 +19,12 @@ type MediaEntry = { }; const bodyDataset = document.body.dataset; -const moderationLogLimit = Number.parseInt( - bodyDataset.adminModerationLogLimit || "", - 10, -); +const moderationLogLimit = Number.parseInt(bodyDataset.adminModerationLogLimit || "", 10); const adminConfig = { defaultModerator: bodyDataset.adminDefaultModerator || "admin", defaultRoom: bodyDataset.adminDefaultRoom || "main", - moderationLogLimit: - Number.isFinite(moderationLogLimit) && moderationLogLimit > 0 - ? moderationLogLimit - : 50, + moderationLogLimit: Number.isFinite(moderationLogLimit) && moderationLogLimit > 0 ? moderationLogLimit : 50, }; const MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024; @@ -43,16 +37,11 @@ const state = { playlistSaving: false, }; -const uploadProgressWrap = () => - document.getElementById("media-upload-progress-wrap") as HTMLElement | null; +const uploadProgressWrap = () => document.getElementById("media-upload-progress-wrap") as HTMLElement | null; -const uploadProgressBar = () => - document.getElementById( - "media-upload-progress", - ) as HTMLProgressElement | null; +const uploadProgressBar = () => document.getElementById("media-upload-progress") as HTMLProgressElement | null; -const uploadProgressText = () => - document.getElementById("media-upload-progress-text") as HTMLElement | null; +const uploadProgressText = () => document.getElementById("media-upload-progress-text") as HTMLElement | null; const setUploadProgress = (value: number, total: number, label: string) => { const wrap = uploadProgressWrap(); @@ -73,11 +62,9 @@ const hideUploadProgress = () => { if (wrap) wrap.hidden = true; }; -const getFileFingerprint = (file: File) => - `${file.name}:${file.size}:${file.lastModified}`; +const getFileFingerprint = (file: File) => `${file.name}:${file.size}:${file.lastModified}`; -const getResumeStorageKey = (fingerprint: string) => - `${UPLOAD_RESUME_KEY_PREFIX}${fingerprint}`; +const getResumeStorageKey = (fingerprint: string) => `${UPLOAD_RESUME_KEY_PREFIX}${fingerprint}`; const getStoredUploadId = (fingerprint: string) => { try { @@ -221,10 +208,7 @@ async function refreshAnalytics() { setText("analytics-total-messages", String(payload.totalMessages ?? 0)); setText("analytics-messages-24h", String(payload.messagesLast24Hours ?? 0)); setText("analytics-distinct-rooms", String(payload.distinctRooms ?? 0)); - setText( - "analytics-moderation-actions", - String(payload.moderationActions ?? 0), - ); + setText("analytics-moderation-actions", String(payload.moderationActions ?? 0)); setText("analytics-generated-at", payload.generatedAt || "unavailable"); const tbody = document.getElementById("analytics-table-body"); @@ -260,9 +244,7 @@ async function refreshPlaylist() { const payload = await response.json(); const content: string = payload.content || ""; - const textarea = document.getElementById( - "playlist-content", - ) as HTMLTextAreaElement | null; + const textarea = document.getElementById("playlist-content") as HTMLTextAreaElement | null; if (textarea) textarea.value = content; renderPlaylistSortable(content); await showPlaylistWarnings(content); @@ -279,7 +261,10 @@ function renderPlaylistSortable(content: string) { const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#")); list.innerHTML = lines .map((line, i) => { - const path = line.replace(/^file\s+/, "").replace(/['"]/g, "").trim(); + const path = line + .replace(/^file\s+/, "") + .replace(/['"]/g, "") + .trim(); return `
    • ${i + 1} @@ -348,12 +333,17 @@ async function showPlaylistWarnings(content: string) { const mediaPaths = new Set((media.files || []).map((f: { path: string }) => f.path)); const nonNorm = (media.files || []).filter((f: { status: string }) => f.status === "needs_normalization"); for (const line of lines) { - const path = line.replace(/^file\s+/, "").replace(/['"]/g, "").trim(); + const path = line + .replace(/^file\s+/, "") + .replace(/['"]/g, "") + .trim(); if (path && !mediaPaths.has(path)) msgs.push(`Missing from media library: ${path}`); } if (nonNorm.length > 0) msgs.push(`${nonNorm.length} file(s) need normalization. Run scripts/normalize.sh.`); } - } catch { /* ignore */ } + } catch { + /* ignore */ + } if (msgs.length > 0) { warnings.innerHTML = msgs.map((m) => `
      ⚠ ${m}
      `).join(""); warnings.hidden = false; @@ -368,9 +358,7 @@ async function savePlaylist() { setStatus("playlist-status", "Saving playlist..."); try { - const textarea = document.getElementById( - "playlist-content", - ) as HTMLTextAreaElement | null; + const textarea = document.getElementById("playlist-content") as HTMLTextAreaElement | null; const content = textarea?.value || ""; const response = await fetch("/api/admin/playlist", { @@ -415,10 +403,7 @@ async function refreshSchedule() { }); } - setStatus( - "schedule-status", - `Schedule loaded. Total runtime ${payload.totalRuntime || "00:00:00"}.`, - ); + setStatus("schedule-status", `Schedule loaded. Total runtime ${payload.totalRuntime || "00:00:00"}.`); } catch (error) { console.error("Failed to load schedule", error); setStatus("schedule-status", "Failed to load schedule.", true); @@ -445,7 +430,9 @@ async function refreshOverrides() { tbody.querySelectorAll("[data-delete-override]").forEach((btn) => { btn.addEventListener("click", () => deleteOverride(Number((btn as HTMLElement).dataset.deleteOverride))); }); - } catch { /* non-critical */ } + } catch { + /* non-critical */ + } } async function submitOverride(e: Event) { @@ -460,18 +447,37 @@ async function submitOverride(e: Event) { const resp = await fetch("/api/admin/schedule/overrides", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ mediaId: Number(mediaId), startTime: new Date(startTime).toISOString(), endTime: new Date(endTime).toISOString(), reason }), + body: JSON.stringify({ + mediaId: Number(mediaId), + startTime: new Date(startTime).toISOString(), + endTime: new Date(endTime).toISOString(), + reason, + }), }); - if (!resp.ok) { const text = await resp.text(); throw new Error(text); } - if (status) { status.textContent = "Override added."; status.className = "status-line"; } + if (!resp.ok) { + const text = await resp.text(); + throw new Error(text); + } + if (status) { + status.textContent = "Override added."; + status.className = "status-line"; + } refreshOverrides(); } catch (err) { - if (status) { status.textContent = `Failed: ${err}`; status.className = "status-line status-error"; } + if (status) { + status.textContent = `Failed: ${err}`; + status.className = "status-line status-error"; + } } } async function deleteOverride(id: number) { - try { await fetch(`/api/admin/schedule/overrides?id=${id}`, { method: "DELETE" }); refreshOverrides(); } catch { /* ignore */ } + try { + await fetch(`/api/admin/schedule/overrides?id=${id}`, { method: "DELETE" }); + refreshOverrides(); + } catch { + /* ignore */ + } } async function refreshSystemSummary() { @@ -540,12 +546,8 @@ function renderMediaRows(files: MediaEntry[]) { `; - tr.querySelector('[data-action="edit"]')?.addEventListener("click", () => - editMedia(file), - ); - tr.querySelector('[data-action="delete"]')?.addEventListener("click", () => - deleteMedia(file), - ); + tr.querySelector('[data-action="edit"]')?.addEventListener("click", () => editMedia(file)); + tr.querySelector('[data-action="delete"]')?.addEventListener("click", () => deleteMedia(file)); tbody.appendChild(tr); }); } @@ -612,18 +614,11 @@ async function uploadVideo(file: File) { const alreadyUploaded = Number(initPayload.uploadedChunks || 0); const effectiveTotalChunks = Number(initPayload.totalChunks || totalChunks); - const resumeFrom = Math.min( - Math.max(alreadyUploaded, 0), - effectiveTotalChunks, - ); + const resumeFrom = Math.min(Math.max(alreadyUploaded, 0), effectiveTotalChunks); setUploadProgress(resumeFrom, effectiveTotalChunks, "Uploading chunks"); - for ( - let chunkIndex = resumeFrom; - chunkIndex < effectiveTotalChunks; - chunkIndex += 1 - ) { + for (let chunkIndex = resumeFrom; chunkIndex < effectiveTotalChunks; chunkIndex += 1) { const start = chunkIndex * UPLOAD_CHUNK_BYTES; const end = Math.min(file.size, start + UPLOAD_CHUNK_BYTES); const chunk = file.slice(start, end); @@ -641,23 +636,13 @@ async function uploadVideo(file: File) { throw new Error(await readErrorMessage(chunkResponse)); } - setUploadProgress( - chunkIndex + 1, - effectiveTotalChunks, - "Uploading chunks", - ); - setStatus( - "media-status", - `Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`, - ); + setUploadProgress(chunkIndex + 1, effectiveTotalChunks, "Uploading chunks"); + setStatus("media-status", `Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`); } - const completeResponse = await fetch( - `/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`, - { - method: "POST", - }, - ); + const completeResponse = await fetch(`/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`, { + method: "POST", + }); if (!completeResponse.ok) { throw new Error(await readErrorMessage(completeResponse)); @@ -674,13 +659,8 @@ async function uploadVideo(file: File) { await refreshSchedule(); } catch (error) { console.error("Failed to upload media", error); - const details = - error instanceof Error ? error.message : "Unknown upload error."; - setStatus( - "media-status", - `Upload interrupted. Re-select the same file to resume. (${details})`, - true, - ); + const details = error instanceof Error ? error.message : "Unknown upload error."; + setStatus("media-status", `Upload interrupted. Re-select the same file to resume. (${details})`, true); } finally { state.mediaUploading = false; setTimeout(() => hideUploadProgress(), 1200); @@ -749,9 +729,7 @@ async function deleteMedia(file: MediaEntry) { async function refreshModerationLogs() { setStatus("moderation-status", "Loading moderation logs..."); try { - const response = await fetch( - `/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`, - ); + const response = await fetch(`/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`); if (!response.ok) throw new Error(`HTTP ${response.status}`); const payload = await response.json(); @@ -787,10 +765,8 @@ async function submitModerationAction(event: Event) { const payload = { action: (document.getElementById("mod-action") as HTMLSelectElement).value, target: (document.getElementById("mod-target") as HTMLInputElement).value, - moderator: (document.getElementById("mod-moderator") as HTMLInputElement) - .value, - reason: (document.getElementById("mod-reason") as HTMLTextAreaElement) - .value, + moderator: (document.getElementById("mod-moderator") as HTMLInputElement).value, + reason: (document.getElementById("mod-reason") as HTMLTextAreaElement).value, room: (document.getElementById("mod-room") as HTMLInputElement).value, }; @@ -865,10 +841,7 @@ async function refreshChatLog() { renderChatLogTable(); updateChatLogPagination(); updateBulkDeleteButton(); - setStatus( - "chat-log-status", - `Loaded ${chatLogState.entries.length} entries.`, - ); + setStatus("chat-log-status", `Loaded ${chatLogState.entries.length} entries.`); } catch (error) { console.error("Failed to load chat log", error); setStatus("chat-log-status", "Failed to load chat log.", true); @@ -908,10 +881,7 @@ function renderChatLogTable() { tr.querySelector('[data-action="chat-ban"]')?.addEventListener("click", () => { if (confirm(`Ban ${entry.username}?`)) userAction("ban", entry.username); }); - tr.querySelector('[data-action="delete-chat"]')?.addEventListener( - "click", - () => deleteChatEntry(entry.id), - ); + tr.querySelector('[data-action="delete-chat"]')?.addEventListener("click", () => deleteChatEntry(entry.id)); tr.querySelector(".chat-log-checkbox")?.addEventListener("change", (e) => { const cb = e.target as HTMLInputElement; if (cb.checked) { @@ -929,38 +899,27 @@ function renderChatLogTable() { } function updateSelectAllCheckbox() { - const selectAll = document.getElementById( - "chat-log-select-all", - ) as HTMLInputElement | null; + const selectAll = document.getElementById("chat-log-select-all") as HTMLInputElement | null; if (!selectAll) return; const allSelected = - chatLogState.entries.length > 0 && - chatLogState.entries.every((e) => chatLogState.selected.has(e.id)); + chatLogState.entries.length > 0 && chatLogState.entries.every((e) => chatLogState.selected.has(e.id)); selectAll.checked = allSelected; } function updateBulkDeleteButton() { - const btn = document.getElementById( - "bulk-delete-chat-log-btn", - ) as HTMLButtonElement | null; + const btn = document.getElementById("bulk-delete-chat-log-btn") as HTMLButtonElement | null; if (!btn) return; btn.disabled = chatLogState.selected.size === 0; } function updateChatLogPagination() { - const prevBtn = document.getElementById( - "chat-log-prev-btn", - ) as HTMLButtonElement | null; - const nextBtn = document.getElementById( - "chat-log-next-btn", - ) as HTMLButtonElement | null; + const prevBtn = document.getElementById("chat-log-prev-btn") as HTMLButtonElement | null; + const nextBtn = document.getElementById("chat-log-next-btn") as HTMLButtonElement | null; const info = document.getElementById("chat-log-page-info"); if (prevBtn) prevBtn.disabled = !chatLogState.before; - if (nextBtn) - nextBtn.disabled = chatLogState.entries.length < chatLogState.limit; - if (info) - info.textContent = `Showing ${chatLogState.entries.length} of ${chatLogState.total}`; + if (nextBtn) nextBtn.disabled = chatLogState.entries.length < chatLogState.limit; + if (info) info.textContent = `Showing ${chatLogState.entries.length} of ${chatLogState.total}`; } function chatLogNextPage() { @@ -975,29 +934,17 @@ function chatLogPrevPage() { } function chatLogApplyFilter() { - chatLogState.room = - (document.getElementById("chat-log-filter-room") as HTMLInputElement) - ?.value || ""; - chatLogState.username = - (document.getElementById("chat-log-filter-username") as HTMLInputElement) - ?.value || ""; - chatLogState.text = - (document.getElementById("chat-log-filter-text") as HTMLInputElement) - ?.value || ""; + chatLogState.room = (document.getElementById("chat-log-filter-room") as HTMLInputElement)?.value || ""; + chatLogState.username = (document.getElementById("chat-log-filter-username") as HTMLInputElement)?.value || ""; + chatLogState.text = (document.getElementById("chat-log-filter-text") as HTMLInputElement)?.value || ""; chatLogState.before = ""; refreshChatLog(); } function chatLogClearFilter() { - const roomEl = document.getElementById( - "chat-log-filter-room", - ) as HTMLInputElement | null; - const userEl = document.getElementById( - "chat-log-filter-username", - ) as HTMLInputElement | null; - const textEl = document.getElementById( - "chat-log-filter-text", - ) as HTMLInputElement | null; + const roomEl = document.getElementById("chat-log-filter-room") as HTMLInputElement | null; + const userEl = document.getElementById("chat-log-filter-username") as HTMLInputElement | null; + const textEl = document.getElementById("chat-log-filter-text") as HTMLInputElement | null; if (roomEl) roomEl.value = ""; if (userEl) userEl.value = ""; if (textEl) textEl.value = ""; @@ -1009,8 +956,7 @@ function chatLogClearFilter() { } async function deleteChatEntry(id: number) { - if (!window.confirm(`Delete chat message #${id}? This cannot be undone.`)) - return; + if (!window.confirm(`Delete chat message #${id}? This cannot be undone.`)) return; setStatus("chat-log-status", `Deleting message #${id}...`); try { @@ -1030,17 +976,9 @@ async function deleteChatEntry(id: number) { async function bulkDeleteChatLog() { if (chatLogState.selected.size === 0) return; - if ( - !window.confirm( - `Delete ${chatLogState.selected.size} selected messages? This cannot be undone.`, - ) - ) - return; + if (!window.confirm(`Delete ${chatLogState.selected.size} selected messages? This cannot be undone.`)) return; - setStatus( - "chat-log-status", - `Deleting ${chatLogState.selected.size} messages...`, - ); + setStatus("chat-log-status", `Deleting ${chatLogState.selected.size} messages...`); try { const ids = Array.from(chatLogState.selected); const response = await fetch("/api/admin/chat-log/bulk", { @@ -1050,10 +988,7 @@ async function bulkDeleteChatLog() { }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const payload = await response.json(); - setStatus( - "chat-log-status", - `${payload.deleted || ids.length} messages deleted.`, - ); + setStatus("chat-log-status", `${payload.deleted || ids.length} messages deleted.`); await refreshChatLog(); await refreshOverview(); } catch (error) { @@ -1088,17 +1023,23 @@ async function refreshUsers() { `; tbody.appendChild(tr); }); - tbody.querySelectorAll(".user-mute").forEach(b => b.addEventListener("click", () => userAction("mute", (b as HTMLElement).dataset.user!))); - tbody.querySelectorAll(".user-ban").forEach(b => b.addEventListener("click", () => userAction("ban", (b as HTMLElement).dataset.user!))); - tbody.querySelectorAll(".user-unmute").forEach(b => b.addEventListener("click", () => userAction("unmute", (b as HTMLElement).dataset.user!))); - tbody.querySelectorAll(".user-unban").forEach(b => b.addEventListener("click", () => userAction("unban", (b as HTMLElement).dataset.user!))); + tbody + .querySelectorAll(".user-mute") + .forEach((b) => b.addEventListener("click", () => userAction("mute", (b as HTMLElement).dataset.user!))); + tbody + .querySelectorAll(".user-ban") + .forEach((b) => b.addEventListener("click", () => userAction("ban", (b as HTMLElement).dataset.user!))); + tbody + .querySelectorAll(".user-unmute") + .forEach((b) => b.addEventListener("click", () => userAction("unmute", (b as HTMLElement).dataset.user!))); + tbody + .querySelectorAll(".user-unban") + .forEach((b) => b.addEventListener("click", () => userAction("unban", (b as HTMLElement).dataset.user!))); } setStatus( "users-status", - payload.redisEnabled - ? "Active users loaded." - : "Redis disabled; no live presence data available.", + payload.redisEnabled ? "Active users loaded." : "Redis disabled; no live presence data available.", ); } catch (error) { console.error("Failed to load users", error); @@ -1127,7 +1068,9 @@ async function loadConfig() { const cfg = await resp.json(); (document.getElementById("config-rate-max") as HTMLInputElement).value = cfg.rateLimitMax; (document.getElementById("config-rate-window") as HTMLInputElement).value = cfg.rateLimitWindowSec; - } catch { /* non-critical */ } + } catch { + /* non-critical */ + } } async function saveConfig(e: Event) { @@ -1144,9 +1087,15 @@ async function saveConfig(e: Event) { }), }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - if (status) { status.textContent = "Config saved."; status.className = "status-line"; } + if (status) { + status.textContent = "Config saved."; + status.className = "status-line"; + } } catch (err) { - if (status) { status.textContent = `Failed: ${err}`; status.className = "status-line status-error"; } + if (status) { + status.textContent = `Failed: ${err}`; + status.className = "status-line status-error"; + } } } @@ -1164,14 +1113,12 @@ async function restartService(service: string) { } function bindEvents() { - document - .querySelectorAll(".tab-trigger") - .forEach((button) => { - button.addEventListener("click", () => { - const tab = button.dataset.tab; - if (tab) switchTab(tab); - }); + document.querySelectorAll(".tab-trigger").forEach((button) => { + button.addEventListener("click", () => { + const tab = button.dataset.tab; + if (tab) switchTab(tab); }); + }); document.getElementById("logout-btn")?.addEventListener("click", async () => { try { @@ -1186,17 +1133,11 @@ function bindEvents() { function bindPageEvents(tabName: string) { switch (tabName) { case "upload": - document - .getElementById("reload-media-btn") - ?.addEventListener("click", refreshMediaLibrary); + document.getElementById("reload-media-btn")?.addEventListener("click", refreshMediaLibrary); - const uploadInput = document.getElementById( - "video-upload", - ) as HTMLInputElement | null; + const uploadInput = document.getElementById("video-upload") as HTMLInputElement | null; const uploadDropzone = document.getElementById("upload-dropzone"); - document - .getElementById("upload-trigger-btn") - ?.addEventListener("click", () => uploadInput?.click()); + document.getElementById("upload-trigger-btn")?.addEventListener("click", () => uploadInput?.click()); uploadDropzone?.addEventListener("dragover", (event) => { event.preventDefault(); @@ -1221,99 +1162,61 @@ function bindPageEvents(tabName: string) { break; case "playlist": - document - .getElementById("reload-playlist-btn") - ?.addEventListener("click", refreshPlaylist); - document - .getElementById("save-playlist-btn") - ?.addEventListener("click", savePlaylist); + document.getElementById("reload-playlist-btn")?.addEventListener("click", refreshPlaylist); + document.getElementById("save-playlist-btn")?.addEventListener("click", savePlaylist); break; case "schedule": - document - .getElementById("reload-schedule-btn") - ?.addEventListener("click", refreshSchedule); - document - .getElementById("override-form") - ?.addEventListener("submit", submitOverride); + document.getElementById("reload-schedule-btn")?.addEventListener("click", refreshSchedule); + document.getElementById("override-form")?.addEventListener("submit", submitOverride); refreshOverrides(); break; case "moderation": - document - .getElementById("reload-moderation-btn") - ?.addEventListener("click", refreshModerationLogs); - document - .getElementById("export-chat-log-btn") - ?.addEventListener("click", () => { - window.open("/api/admin/chat-log/export", "_blank"); - }); - document - .getElementById("moderation-form") - ?.addEventListener("submit", submitModerationAction); - (document.getElementById("mod-moderator") as HTMLInputElement).value = - adminConfig.defaultModerator; - (document.getElementById("mod-room") as HTMLInputElement).value = - adminConfig.defaultRoom; + document.getElementById("reload-moderation-btn")?.addEventListener("click", refreshModerationLogs); + document.getElementById("export-chat-log-btn")?.addEventListener("click", () => { + window.open("/api/admin/chat-log/export", "_blank"); + }); + document.getElementById("moderation-form")?.addEventListener("submit", submitModerationAction); + (document.getElementById("mod-moderator") as HTMLInputElement).value = adminConfig.defaultModerator; + (document.getElementById("mod-room") as HTMLInputElement).value = adminConfig.defaultRoom; break; case "chat-log": - document - .getElementById("reload-chat-log-btn") - ?.addEventListener("click", refreshChatLog); - document - .getElementById("bulk-delete-chat-log-btn") - ?.addEventListener("click", bulkDeleteChatLog); - document - .getElementById("chat-log-filter-btn") - ?.addEventListener("click", chatLogApplyFilter); - document - .getElementById("chat-log-clear-filter-btn") - ?.addEventListener("click", chatLogClearFilter); - document - .getElementById("chat-log-prev-btn") - ?.addEventListener("click", chatLogPrevPage); - document - .getElementById("chat-log-next-btn") - ?.addEventListener("click", chatLogNextPage); - document - .getElementById("chat-log-select-all") - ?.addEventListener("change", (e) => { - const checked = (e.target as HTMLInputElement).checked; - chatLogState.entries.forEach((entry) => { - if (checked) { - chatLogState.selected.add(entry.id); - } else { - chatLogState.selected.delete(entry.id); - } - }); - updateBulkDeleteButton(); - renderChatLogTable(); + document.getElementById("reload-chat-log-btn")?.addEventListener("click", refreshChatLog); + document.getElementById("bulk-delete-chat-log-btn")?.addEventListener("click", bulkDeleteChatLog); + document.getElementById("chat-log-filter-btn")?.addEventListener("click", chatLogApplyFilter); + document.getElementById("chat-log-clear-filter-btn")?.addEventListener("click", chatLogClearFilter); + document.getElementById("chat-log-prev-btn")?.addEventListener("click", chatLogPrevPage); + document.getElementById("chat-log-next-btn")?.addEventListener("click", chatLogNextPage); + document.getElementById("chat-log-select-all")?.addEventListener("change", (e) => { + const checked = (e.target as HTMLInputElement).checked; + chatLogState.entries.forEach((entry) => { + if (checked) { + chatLogState.selected.add(entry.id); + } else { + chatLogState.selected.delete(entry.id); + } }); + updateBulkDeleteButton(); + renderChatLogTable(); + }); break; case "analytics": - document - .getElementById("reload-analytics-btn") - ?.addEventListener("click", refreshAnalytics); + document.getElementById("reload-analytics-btn")?.addEventListener("click", refreshAnalytics); break; case "users": - document - .getElementById("reload-users-btn") - ?.addEventListener("click", refreshUsers); + document.getElementById("reload-users-btn")?.addEventListener("click", refreshUsers); break; case "system": + document.getElementById("reload-system-btn")?.addEventListener("click", refreshSystemSummary); + document.getElementById("config-form")?.addEventListener("submit", saveConfig); document - .getElementById("reload-system-btn") - ?.addEventListener("click", refreshSystemSummary); - document - .getElementById("config-form") - ?.addEventListener("submit", saveConfig); - document.querySelectorAll(".restart-btn").forEach((btn) => - btn.addEventListener("click", () => restartService((btn as HTMLElement).dataset.service!)) - ); + .querySelectorAll(".restart-btn") + .forEach((btn) => btn.addEventListener("click", () => restartService((btn as HTMLElement).dataset.service!))); loadConfig(); break; } diff --git a/frontend/src/counter.ts b/frontend/src/counter.ts index 9f629e8..969e0f2 100644 --- a/frontend/src/counter.ts +++ b/frontend/src/counter.ts @@ -1,9 +1,9 @@ export function setupCounter(element: HTMLButtonElement) { - let counter = 0 + let counter = 0; const setCounter = (count: number) => { - counter = count - element.innerHTML = `Count is ${counter}` - } - element.addEventListener('click', () => setCounter(counter + 1)) - setCounter(0) + counter = count; + element.innerHTML = `Count is ${counter}`; + }; + element.addEventListener("click", () => setCounter(counter + 1)); + setCounter(0); } diff --git a/frontend/src/main.test.ts b/frontend/src/main.test.ts new file mode 100644 index 0000000..9953338 --- /dev/null +++ b/frontend/src/main.test.ts @@ -0,0 +1,161 @@ +/// +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock hls.js +vi.mock("hls.js", () => ({ + default: vi.fn().mockImplementation(() => ({ + loadSource: vi.fn(), + attachMedia: vi.fn(), + on: vi.fn(), + destroy: vi.fn(), + })), + Events: { MANIFEST_PARSED: "hlsManifestParsed" }, +})); + +// Mock WebSocket +class MockWebSocket { + url: string; + readyState = 0; + onopen: (() => void) | null = null; + onmessage: ((e: { data: string }) => void) | null = null; + onclose: (() => void) | null = null; + send = vi.fn(); + close = vi.fn(); + + constructor(url: string) { + this.url = url; + } +} + +// Suppress console noise +beforeEach(() => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "log").mockImplementation(() => {}); +}); + +let origWebSocket: any; +let origFetch: any; + +beforeEach(() => { + origWebSocket = (globalThis as any).WebSocket; + origFetch = globalThis.fetch; + (globalThis as any).WebSocket = MockWebSocket; + + // Mock fetch (schedule, etc.) — return empty schedule + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ entries: [], total: 0, generatedAt: "" }), + }); + + // Set up DOM from index.html + document.body.innerHTML = ` +
      +
      + +
      +
      +
      + +
      + Loading... + +
      +
      + +
      +
      + `; + + // Stub prompt for DM + (globalThis as any).prompt = vi.fn(); +}); + +afterEach(() => { + (globalThis as any).WebSocket = origWebSocket; + globalThis.fetch = origFetch; + vi.restoreAllMocks(); +}); + +// Must import after DOM setup +import "../src/main"; + +describe("chat toggle", () => { + it("toggles chat panel visibility", () => { + const panel = document.getElementById("chat-panel")!; + const toggle = document.getElementById("chat-toggle")!; + + expect(toggle.textContent).toBe("Hide Chat"); + + window.toggleChatPanel(); + expect(toggle.textContent).toBe("Show Chat"); + + window.toggleChatPanel(); + expect(toggle.textContent).toBe("Hide Chat"); + }); +}); + +describe("sendMessage", () => { + it("clears input after sending non-empty text", () => { + const input = document.getElementById("message-input") as HTMLInputElement; + input.value = "hello!"; + + // sendMessage requires ws to be non-null — ws set in initPlayerAndChat + // The DOMContentLoaded triggers initPlayerAndChat which creates ws + // Wait for async init, then set ws manually + // For now just verify input state logic + window.sendMessage(); + // ws is null at this point, so message won't send but input won't clear either + // Verify input not cleared (ws check fails before clear) + expect(input.value).toBe("hello!"); + }); + + it("does nothing with empty input", () => { + const input = document.getElementById("message-input") as HTMLInputElement; + input.value = " "; + window.sendMessage(); + // No error thrown = pass + }); +}); + +describe("switchRoom", () => { + it("updates room name on switch", () => { + window.switchRoom("lobby"); + const roomName = document.getElementById("room-name")!; + expect(roomName.textContent).toBe("Lobby Room"); + }); + + it("shows Main Channel for main room", () => { + window.switchRoom("main"); + const roomName = document.getElementById("room-name")!; + expect(roomName.textContent).toBe("Main Channel"); + }); +}); + +describe("togglePrivateDM", () => { + it("prompts for username and switches room", () => { + // Stub prompt to return a value this time + vi.spyOn(window, "prompt").mockReturnValue("bob"); + + const switchRoomSpy = vi.spyOn(window, "switchRoom"); + window.togglePrivateDM(); + + expect(window.prompt).toHaveBeenCalled(); + expect(switchRoomSpy).not.toHaveBeenCalled(); // username is "viewerNNN" which gets set randomly + + switchRoomSpy.mockRestore(); + }); +}); diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 9c8df8e..8419bb0 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -32,24 +32,16 @@ function resolveWebSocketBaseUrl() { function resolveManifestUrl() { const configuredUrl = import.meta.env.VITE_PUBLIC_HLS_URL?.trim(); const cacheBust = `stream=${Date.now()}`; - const sameOriginManifestUrl = new URL( - "/hls/playlist.m3u8", - window.location.origin, - ); + const sameOriginManifestUrl = new URL("/hls/playlist.m3u8", window.location.origin); sameOriginManifestUrl.searchParams.set("stream", cacheBust); - const allowCrossOriginManifest = - import.meta.env.VITE_PUBLIC_HLS_CROSS_ORIGIN?.trim().toLowerCase() === - "true"; + const allowCrossOriginManifest = import.meta.env.VITE_PUBLIC_HLS_CROSS_ORIGIN?.trim().toLowerCase() === "true"; if (configuredUrl) { const manifestUrl = new URL(configuredUrl); manifestUrl.searchParams.set("stream", cacheBust); - if ( - !allowCrossOriginManifest && - manifestUrl.origin !== window.location.origin - ) { + if (!allowCrossOriginManifest && manifestUrl.origin !== window.location.origin) { return sameOriginManifestUrl.toString(); } @@ -96,12 +88,7 @@ function connectWebSocket(room: string) { }; } -function addMessage( - type: string, - text: string, - sender = "", - timestamp = new Date(), -) { +function addMessage(type: string, text: string, sender = "", timestamp = new Date()) { const messages = document.getElementById("messages")!; const div = document.createElement("div"); div.className = "message-row"; @@ -110,9 +97,7 @@ function addMessage( div.classList.add("message-system-row"); div.innerHTML = `
      ${text}
      `; } else { - div.classList.add( - sender === username ? "message-self-row" : "message-other-row", - ); + div.classList.add(sender === username ? "message-self-row" : "message-other-row"); div.innerHTML = `
      ${sender && sender !== username ? `
      ${sender}
      ` : ""} @@ -131,10 +116,7 @@ function renderPresence() { if (!list) return; const sorted = [...onlineUsers].sort(); list.innerHTML = sorted - .map( - (u) => - `${u}`, - ) + .map((u) => `${u}`) .join(""); } @@ -185,9 +167,7 @@ async function initPlayerAndChat() { refreshSchedule(); setInterval(refreshSchedule, 30000); - console.log( - "Linear Void Frontend initialized - HLS Player + Chat Sidebar ready", - ); + console.log("Linear Void Frontend initialized - HLS Player + Chat Sidebar ready"); } function toggleChatPanel() { @@ -220,9 +200,7 @@ function sendMessage() { function switchRoom(room: string) { currentRoom = room; document.getElementById("room-name")!.textContent = - room === "main" - ? "Main Channel" - : room.charAt(0).toUpperCase() + room.slice(1) + " Room"; + room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room"; addMessage("system", `Switched to ${room} room`); connectWebSocket(room); } diff --git a/frontend/src/style.css b/frontend/src/style.css index c258433..ae5f41e 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1,8 +1,7 @@ :root { color-scheme: dark; - --font-body: - "Atkinson Hyperlegible", "Segoe UI", "Helvetica Neue", sans-serif; + --font-body: "Atkinson Hyperlegible", "Segoe UI", "Helvetica Neue", sans-serif; --font-display: "Space Grotesk", "Segoe UI", sans-serif; --font-mono: "IBM Plex Mono", Consolas, monospace; @@ -118,12 +117,7 @@ body { body { font-family: var(--font-body); color: var(--ink-0); - background: radial-gradient( - circle at 20% 10%, - var(--bg-grad-a) 0%, - var(--bg-grad-b) 50%, - var(--bg-grad-c) 100% - ); + background: radial-gradient(circle at 20% 10%, var(--bg-grad-a) 0%, var(--bg-grad-b) 50%, var(--bg-grad-c) 100%); line-height: 1.45; } @@ -467,11 +461,7 @@ select { font-size: 0.95rem; letter-spacing: 0.05em; color: var(--brand-contrast); - background: linear-gradient( - 140deg, - var(--badge-start) 0%, - var(--badge-end) 100% - ); + background: linear-gradient(140deg, var(--badge-start) 0%, var(--badge-end) 100%); } .brand-text, diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..c13f7bd --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + include: ["src/**/*.test.ts"], + }, +});