From 02d533e949818cd31de3b7a3e3a96d5ccabd1e44 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Mon, 11 May 2026 12:25:48 +0200 Subject: [PATCH] test(frontend): vitest framework bootstrap + hot-path coverage gate (#536) * test(frontend): vitest framework bootstrap + hot-path file coverage gate Adds the harness for component, hook and store tests on top of the existing util tests in src/utils/. Mirrors the backend rust-tests rollout: jsdom env, v8 coverage, soft hot-path file gate, dedicated CI workflow. What's in: - vitest.config.ts: jsdom environment, v8 coverage, alias @ -> src - src/test/setup.ts: jest-dom, @testing-library cleanup, vi.mock for @tauri-apps/api/{core,event} + plugin-shell, Map-backed Storage polyfill for Node 25 + jsdom 26 (both ship a broken native localStorage) - src/test/mocks/tauri.ts: programmable onInvoke() / emitTauriEvent() helpers, auto-reset between tests - src/test/helpers/factories.ts: makeTrack / makeTracks - src/test/helpers/renderWithProviders.tsx: render() wrapped with MemoryRouter + I18nextProvider - src/test/README.md: conventions doc (where tests go, how to mock Tauri, what to never mock) Sample tests showing the patterns: - src/components/CoverLightbox.test.tsx: component, queries by role - src/store/previewStore.test.ts: store characterization, event handlers + stopPreview (startPreview deferred until the cross-store provider strategy is decided) CI: - .github/workflows/frontend-tests.yml: jobs for vitest, tsc, coverage + hot-path gate. coverage job carries continue-on-error: true (soft). - .github/frontend-hot-path-files.txt: initial list (3 utils at >=70%). playerStore + the unfinished half of previewStore are deferred until Phase 1 coverage work lands. - scripts/check-frontend-hot-path-coverage.sh: mirror of the rust gate. npm scripts: - test: one-shot run (unchanged) - test:watch: vitest in watch mode - test:coverage: v8 coverage + html / lcov / json-summary reports 57 / 57 tests pass; tsc --noEmit clean. * chore(nix): sync npmDepsHash with package-lock.json --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .github/frontend-hot-path-files.txt | 28 + .github/workflows/frontend-tests.yml | 85 ++ .gitignore | 3 + nix/upstream-sources.json | 2 +- package-lock.json | 1047 +++++++++++++++++++ package.json | 9 +- scripts/check-frontend-hot-path-coverage.sh | 107 ++ src/components/CoverLightbox.test.tsx | 61 ++ src/store/previewStore.test.ts | 140 +++ src/test/README.md | 125 +++ src/test/helpers/factories.ts | 33 + src/test/helpers/renderWithProviders.tsx | 35 + src/test/mocks/tauri.ts | 82 ++ src/test/setup.ts | 100 ++ vitest.config.ts | 40 +- 15 files changed, 1893 insertions(+), 4 deletions(-) create mode 100644 .github/frontend-hot-path-files.txt create mode 100644 .github/workflows/frontend-tests.yml create mode 100755 scripts/check-frontend-hot-path-coverage.sh create mode 100644 src/components/CoverLightbox.test.tsx create mode 100644 src/store/previewStore.test.ts create mode 100644 src/test/README.md create mode 100644 src/test/helpers/factories.ts create mode 100644 src/test/helpers/renderWithProviders.tsx create mode 100644 src/test/mocks/tauri.ts create mode 100644 src/test/setup.ts diff --git a/.github/frontend-hot-path-files.txt b/.github/frontend-hot-path-files.txt new file mode 100644 index 00000000..f5db2c71 --- /dev/null +++ b/.github/frontend-hot-path-files.txt @@ -0,0 +1,28 @@ +# Hot-path source files for the per-file ≥70 % coverage gate (frontend). +# +# Mirrors `.github/hot-path-files.txt` for the Rust crates. Each entry is a +# workspace-relative path; the gate script (`scripts/check-frontend-hot-path- +# coverage.sh`) reads `coverage/coverage-summary.json` produced by +# `vitest run --coverage` and warns when a listed file drops below the floor. +# +# Soft today (warnings only — the workflow carries `continue-on-error: true`). +# Flip to a hard PR-blocker by removing `continue-on-error` from the +# `frontend-tests` workflow once a few PRs have run cleanly. +# +# Curation rule (mirrors the backend list): a file belongs here when its +# hot-path code dominates the file and ≥70 % is a reasonable floor. Files +# that are partially covered today but contain large untested surface area +# (e.g. `playerStore.ts`, the unfinished half of `previewStore.ts`) live +# OUTSIDE the gate until their tests grow. Add them as Phase 1 coverage +# work lands. +# +# Deferred from the gate, with current coverage shown for reference: +# - src/store/previewStore.ts (33 % — only `_on*` + `stopPreview` covered) +# - src/store/playerStore.ts (0 % — 3300-LOC monolith, Phase 2 target) +# - src/utils/dynamicColors.ts (44 % — existing tests miss branches) +# - src/utils/shareLink.ts (69 % — borderline, ~5 lines short) + +# ── utils (already at or above threshold) ──────────────────────────── +src/utils/coverArtRegisteredSizes.ts +src/utils/serverDisplayName.ts +src/utils/serverMagicString.ts diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 00000000..46f799ba --- /dev/null +++ b/.github/workflows/frontend-tests.yml @@ -0,0 +1,85 @@ +name: frontend-tests + +on: + pull_request: + branches: [main] + paths: + - 'src/**' + - 'package.json' + - 'package-lock.json' + - 'vitest.config.ts' + - 'vite.config.ts' + - 'tsconfig.json' + - '.github/workflows/frontend-tests.yml' + - '.github/frontend-hot-path-files.txt' + - 'scripts/check-frontend-hot-path-coverage.sh' + push: + branches: [main] + paths: + - 'src/**' + - 'package.json' + - 'package-lock.json' + - 'vitest.config.ts' + - 'vite.config.ts' + - 'tsconfig.json' + - '.github/workflows/frontend-tests.yml' + - '.github/frontend-hot-path-files.txt' + - 'scripts/check-frontend-hot-path-coverage.sh' + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + name: vitest run + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - run: npm ci + - name: vitest + run: npm test + + typecheck: + name: tsc --noEmit + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - run: npm ci + - name: tsc + run: npx tsc --noEmit + + coverage: + name: vitest --coverage (baseline + hot-path file gate) + # Two-layer gate: the script exits 1 when any listed file drops below the + # threshold (warning annotations show in the PR checks panel), but + # `continue-on-error: true` keeps it from BLOCKING merges. Drop the flag + # to flip the gate hard once we've watched a few PRs run cleanly. + runs-on: ubuntu-24.04 + continue-on-error: true + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + - name: install jq + run: sudo apt-get update && sudo apt-get install -y jq + - run: npm ci + - name: vitest run --coverage + run: npx vitest run --coverage + - name: hot-path file coverage soft gate + run: bash scripts/check-frontend-hot-path-coverage.sh + - uses: actions/upload-artifact@v4 + with: + name: frontend-coverage-lcov + path: coverage/lcov.info + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 74b8c672..ae78786b 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ src-tauri/target/ src-tauri/gen/ src-tauri/lcov.info +# Frontend test coverage +coverage/ + # Documentation CLAUDE.md diff --git a/nix/upstream-sources.json b/nix/upstream-sources.json index 9c19a1f5..886dae75 100644 --- a/nix/upstream-sources.json +++ b/nix/upstream-sources.json @@ -1,3 +1,3 @@ { - "npmDepsHash": "sha256-5u9O1O7q936/Ik7/X++pK2Bg65+GYh0RdL4YVvasNSQ=" + "npmDepsHash": "sha256-zcd6mudbopF0hlcJnFxwUOKpPt6IamfLmabSZ5rN7HI=" } diff --git a/package-lock.json b/package-lock.json index 36221c48..6ffebf95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,18 +46,96 @@ }, "devDependencies": { "@tauri-apps/cli": "^2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/md5": "^2.3.6", "@types/node": "^25.6.0", "@types/papaparse": "^5.5.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.5", "esbuild": "^0.28.0", + "jsdom": "^26.1.0", "typescript": "^6.0.3", "vite": "^8.0.10", "vitest": "^4.1.5" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", @@ -67,6 +145,145 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -678,6 +895,16 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -685,6 +912,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -1311,6 +1549,96 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -1322,6 +1650,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1420,6 +1756,37 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", + "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.5", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", @@ -1533,6 +1900,51 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1543,6 +1955,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1633,6 +2064,27 @@ "node": "*" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1640,6 +2092,45 @@ "devOptional": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1649,6 +2140,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1659,6 +2160,14 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1673,6 +2182,19 @@ "node": ">= 0.4" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1914,6 +2436,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1953,6 +2485,26 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -1962,6 +2514,34 @@ "void-elements": "3.1.0" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/i18next": { "version": "26.0.8", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.8.tgz", @@ -1990,12 +2570,129 @@ } } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", "license": "MIT" }, + "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/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2257,6 +2954,13 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/lucide-react": { "version": "1.14.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz", @@ -2266,6 +2970,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2276,6 +2991,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2317,6 +3060,23 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -2336,6 +3096,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -2353,6 +3120,19 @@ "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", "license": "MIT" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.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", @@ -2409,6 +3189,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -2418,6 +3214,16 @@ "node": ">=10" } }, + "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/react": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", @@ -2466,6 +3272,14 @@ } } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-router": { "version": "7.15.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz", @@ -2504,6 +3318,20 @@ "react-dom": ">=18" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.17", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", @@ -2545,12 +3373,52 @@ "dev": true, "license": "MIT" }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "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/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -2588,6 +3456,39 @@ "dev": true, "license": "MIT" }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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", @@ -2632,6 +3533,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2847,6 +3794,67 @@ "node": ">=0.10.0" } }, + "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": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "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", @@ -2864,6 +3872,45 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "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" + }, "node_modules/zustand": { "version": "5.0.13", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", diff --git a/package.json b/package.json index acf8a184..cf724f1f 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "tauri": "tauri", "tauri:dev": "tauri dev", "tauri:build": "tauri build", - "test": "vitest run" + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@fontsource-variable/dm-sans": "^5.2.8", @@ -50,13 +52,18 @@ }, "devDependencies": { "@tauri-apps/cli": "^2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/md5": "^2.3.6", "@types/node": "^25.6.0", "@types/papaparse": "^5.5.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.5", "esbuild": "^0.28.0", + "jsdom": "^26.1.0", "typescript": "^6.0.3", "vite": "^8.0.10", "vitest": "^4.1.5" diff --git a/scripts/check-frontend-hot-path-coverage.sh b/scripts/check-frontend-hot-path-coverage.sh new file mode 100755 index 00000000..b9b2aabc --- /dev/null +++ b/scripts/check-frontend-hot-path-coverage.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# +# Hot-path file coverage gate — frontend, soft mode. +# +# Mirrors `scripts/check-hot-path-coverage.sh` for the Rust workspace. For +# each source file listed in `.github/frontend-hot-path-files.txt`, verifies +# that line coverage is at least $THRESHOLD %. Emits GitHub Actions warning +# annotations for files below the floor; exits 1 when any file is below, but +# the wrapping CI job carries `continue-on-error: true` so it doesn't block +# merges yet (drop that flag once we've watched a few PRs run cleanly). +# +# Why files instead of per-function: v8 coverage's per-function data is +# fragile under React Compiler / Vite minification — file-level line +# coverage tracks the underlying intent ("is the hot-path file thoroughly +# tested?") more robustly. +# +# Usage: +# scripts/check-frontend-hot-path-coverage.sh [] [] +# +# Defaults: +# summary.json — coverage/coverage-summary.json +# hot-path-list.txt — .github/frontend-hot-path-files.txt +# +# Requires: jq. + +set -euo pipefail +export LC_ALL=C + +JSON="${1:-coverage/coverage-summary.json}" +HOT_PATH_LIST="${2:-.github/frontend-hot-path-files.txt}" +THRESHOLD=70 + +if [[ ! -f "$JSON" ]]; then + echo "::error::Coverage summary not found at $JSON. Did you run 'npm run test:coverage' first?" + exit 2 +fi + +if [[ ! -f "$HOT_PATH_LIST" ]]; then + echo "::error::Hot-path file list not found at $HOT_PATH_LIST" + exit 2 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "::error::jq not found in PATH. Install via apt-get install jq / brew install jq / winget install jqlang.jq" + exit 2 +fi + +# The v8 / Istanbul coverage-summary.json has the form: +# { "total": {...}, "": { "lines": { "pct": 87.5 }, ... }, ... } +# We want each file path + its line pct as a TSV keyed by basename suffix. +ALL_FILES=$(mktemp) +trap 'rm -f "$ALL_FILES"' EXIT +jq -r 'to_entries[] | select(.key != "total") | [.key, .value.lines.pct] | @tsv' "$JSON" > "$ALL_FILES" + +TOTAL=0 +BELOW=0 +NOT_FOUND=0 + +echo "── Hot-path file coverage check, frontend (threshold: ≥${THRESHOLD}%) ──" + +while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do + line="${raw_line%%#*}" + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + [[ -z "$line" ]] && continue + TOTAL=$((TOTAL + 1)) + + # Match suffix — JSON paths are absolute, hot-path list is workspace-relative. + pct=$(awk -F'\t' -v target="$line" ' + { + path = $1 + gsub(/\\\\/, "/", path) + gsub(/\\/, "/", path) + n = length(path) + tlen = length(target) + if (n >= tlen && substr(path, n - tlen + 1) == target) { + printf "%s\n", $2 + exit + } + } + ' "$ALL_FILES") + + if [[ -z "$pct" ]]; then + echo "::warning::Hot-path file '$line' not found in coverage report (deleted? renamed? or no test imports it yet)" + NOT_FOUND=$((NOT_FOUND + 1)) + continue + fi + + pct_int=${pct%.*} + if [[ "$pct_int" -lt "$THRESHOLD" ]]; then + printf "::warning::Hot-path file '%s': %.1f%% — below %d%%\n" "$line" "$pct" "$THRESHOLD" + BELOW=$((BELOW + 1)) + else + printf " ok %s %.1f%%\n" "$line" "$pct" + fi +done < "$HOT_PATH_LIST" + +echo +echo "── Summary ─────────────────────────────────────────────────────────" +echo "Checked: $TOTAL hot-path file(s)" +echo "Below threshold: $BELOW" +echo "Not found: $NOT_FOUND" + +if [[ "$BELOW" -gt 0 ]]; then + exit 1 +fi +exit 0 diff --git a/src/components/CoverLightbox.test.tsx b/src/components/CoverLightbox.test.tsx new file mode 100644 index 00000000..5a0d88d2 --- /dev/null +++ b/src/components/CoverLightbox.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from 'vitest'; +import { screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '@/test/helpers/renderWithProviders'; +import CoverLightbox from './CoverLightbox'; + +describe('CoverLightbox', () => { + it('renders the cover image with the supplied src and alt', () => { + renderWithProviders( + , + ); + + const img = screen.getByRole('img', { name: 'Album cover' }); + expect(img).toHaveAttribute('src', 'https://example/cover.jpg'); + }); + + it('calls onClose when the overlay is clicked', async () => { + const onClose = vi.fn(); + renderWithProviders( + , + ); + + await userEvent.click(screen.getByRole('dialog')); + + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('does not call onClose when the image itself is clicked (stops propagation)', async () => { + const onClose = vi.fn(); + renderWithProviders( + , + ); + + await userEvent.click(screen.getByRole('img', { name: 'Album cover' })); + + expect(onClose).not.toHaveBeenCalled(); + }); + + it('calls onClose when Escape is pressed', () => { + const onClose = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.keyDown(window, { key: 'Escape' }); + + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('ignores other keys', () => { + const onClose = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.keyDown(window, { key: 'Enter' }); + fireEvent.keyDown(window, { key: 'a' }); + + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/src/store/previewStore.test.ts b/src/store/previewStore.test.ts new file mode 100644 index 00000000..1d5eae4f --- /dev/null +++ b/src/store/previewStore.test.ts @@ -0,0 +1,140 @@ +/** + * Characterization tests for `previewStore`. + * + * Pattern demonstration: drives the store through its public action surface + * with the real Zustand instance, and uses the `onInvoke` helper to stub the + * Tauri commands the actions call. Aims to lock current behaviour before + * playerStore-adjacent refactoring lands in Phase 2. + * + * Scope here is intentionally narrow — the internal `_on*` event handlers + * plus `stopPreview`. `startPreview` adds dependencies on authStore / + * orbitStore and is covered in its own follow-up suite once we settle on a + * provider strategy for cross-store reads. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { usePreviewStore } from './previewStore'; +import { onInvoke, invokeMock } from '@/test/mocks/tauri'; + +function resetStore() { + usePreviewStore.setState({ + previewingId: null, + previewingTrack: null, + elapsed: 0, + duration: 30, + audioStarted: false, + }); +} + +describe('previewStore — event handlers', () => { + beforeEach(resetStore); + + describe('_onStart', () => { + it('flips audioStarted to true when the id matches the active preview', () => { + usePreviewStore.setState({ + previewingId: 'song-1', + previewingTrack: { id: 'song-1', title: 't', artist: 'a' }, + }); + + usePreviewStore.getState()._onStart('song-1'); + + expect(usePreviewStore.getState().audioStarted).toBe(true); + expect(usePreviewStore.getState().previewingId).toBe('song-1'); + }); + + it('takes over the previewingId when the engine fires start for an unknown id', () => { + usePreviewStore.setState({ previewingId: null }); + + usePreviewStore.getState()._onStart('song-99'); + + const state = usePreviewStore.getState(); + expect(state.previewingId).toBe('song-99'); + expect(state.elapsed).toBe(0); + expect(state.audioStarted).toBe(true); + }); + }); + + describe('_onProgress', () => { + it('updates elapsed + duration when the id matches', () => { + usePreviewStore.setState({ previewingId: 'song-1' }); + + usePreviewStore.getState()._onProgress('song-1', 12.5, 30); + + const state = usePreviewStore.getState(); + expect(state.elapsed).toBe(12.5); + expect(state.duration).toBe(30); + }); + + it('ignores progress for a stale id', () => { + usePreviewStore.setState({ previewingId: 'song-1', elapsed: 5 }); + + usePreviewStore.getState()._onProgress('song-stale', 99, 30); + + expect(usePreviewStore.getState().elapsed).toBe(5); + }); + }); + + describe('_onEnd', () => { + it('clears state when the id matches', () => { + usePreviewStore.setState({ + previewingId: 'song-1', + previewingTrack: { id: 'song-1', title: 't', artist: 'a' }, + elapsed: 27, + audioStarted: true, + }); + + usePreviewStore.getState()._onEnd('song-1'); + + const state = usePreviewStore.getState(); + expect(state.previewingId).toBeNull(); + expect(state.previewingTrack).toBeNull(); + expect(state.elapsed).toBe(0); + expect(state.audioStarted).toBe(false); + }); + + it('ignores end events for a stale id', () => { + usePreviewStore.setState({ previewingId: 'song-1', elapsed: 5, audioStarted: true }); + + usePreviewStore.getState()._onEnd('song-stale'); + + expect(usePreviewStore.getState().previewingId).toBe('song-1'); + expect(usePreviewStore.getState().audioStarted).toBe(true); + }); + }); +}); + +describe('previewStore — stopPreview', () => { + beforeEach(resetStore); + + it('returns early without invoking when no preview is active', async () => { + await usePreviewStore.getState().stopPreview(); + + expect(invokeMock).not.toHaveBeenCalled(); + }); + + it('invokes audio_preview_stop when a preview is active', async () => { + usePreviewStore.setState({ previewingId: 'song-1' }); + onInvoke('audio_preview_stop', () => undefined); + + await usePreviewStore.getState().stopPreview(); + + expect(invokeMock).toHaveBeenCalledWith('audio_preview_stop'); + }); + + it('falls back to clearing state locally if invoke rejects', async () => { + usePreviewStore.setState({ + previewingId: 'song-1', + previewingTrack: { id: 'song-1', title: 't', artist: 'a' }, + audioStarted: true, + }); + onInvoke('audio_preview_stop', () => { + throw new Error('engine offline'); + }); + + await usePreviewStore.getState().stopPreview(); + + const state = usePreviewStore.getState(); + expect(state.previewingId).toBeNull(); + expect(state.previewingTrack).toBeNull(); + expect(state.audioStarted).toBe(false); + }); +}); diff --git a/src/test/README.md b/src/test/README.md new file mode 100644 index 00000000..1146d3e1 --- /dev/null +++ b/src/test/README.md @@ -0,0 +1,125 @@ +# Frontend test framework + +Vitest + jsdom + @testing-library/react. Existing util tests in +`src/utils/*.test.ts` keep working; this folder adds the harness for store, +hook, component and (eventually) integration tests. + +## Layout + +``` +src/test/ + setup.ts # global setup: jest-dom, @testing-library cleanup, + # vi.mock for @tauri-apps/api/* + mocks/ + tauri.ts # programmable invoke() + listen() helpers + helpers/ + factories.ts # makeTrack / makeTracks fixtures + renderWithProviders.tsx # render() wrapped with MemoryRouter + i18n + CLAUDE.md # this file +``` + +## Running tests + +```bash +npm test # one-shot run +npm run test:watch # watch mode +npm run test:coverage # with v8 coverage → ./coverage/ +``` + +## Where tests go + +- **Co-located with the unit under test**: `Foo.tsx` → `Foo.test.tsx`, + `barStore.ts` → `barStore.test.ts`. Mirrors the existing util test layout + and avoids a parallel directory tree. +- Vitest picks them up via `include: src/**/*.test.{ts,tsx}` in + `vitest.config.ts`. + +## Mocking Tauri + +`@tauri-apps/api/core` and `@tauri-apps/api/event` are mocked globally in +`setup.ts`. To configure per-test behaviour, import the helpers in +`mocks/tauri.ts`: + +```ts +import { onInvoke, emitTauriEvent, invokeMock } from '@/test/mocks/tauri'; + +beforeEach(() => { + onInvoke('audio_play', () => undefined); +}); + +it('responds to engine events', () => { + emitTauriEvent('audio:progress', { id: 't1', currentTime: 42 }); + expect(invokeMock).toHaveBeenCalledWith('audio_play', { id: 't1' }); +}); +``` + +Unhandled `invoke()` calls throw a descriptive error — tests are honest about +which commands they exercise. Handlers are auto-cleared between tests. + +## Mocking HTTP + +For Subsonic / Navidrome / Last.fm calls, prefer **module-level mocks** for +unit tests: + +```ts +vi.mock('@/api/subsonic', () => ({ + getAlbum: vi.fn(async () => ({ id: 'a1', tracks: [] })), + buildStreamUrl: (id: string) => `https://mock/stream/${id}`, +})); +``` + +For broader integration tests that touch many endpoints we can introduce +**MSW** later. The framework is intentionally MSW-free right now to keep +the dep surface small until we need it. + +## Patterns + +### Pure utilities + +Direct import + assert (see `src/utils/dynamicColors.test.ts`). No setup +needed beyond `import { describe, it, expect } from 'vitest'`. + +### Zustand stores + +- Import the hook, drive it via `useFooStore.getState()`. +- Reset state in a `beforeEach` — Zustand stores are module-level singletons + and leak across tests otherwise. +- Stub Tauri side effects via `onInvoke()`. +- Use `emitTauriEvent()` to drive event-driven state transitions. + +See `src/store/previewStore.test.ts` for the reference pattern. + +### Components + +- `renderWithProviders()` from `helpers/renderWithProviders`. +- Query by role / label / text first; fall back to `data-testid` only when + the DOM provides no semantic anchor. +- Use `userEvent` (not `fireEvent`) for click / type / keyboard, with the + exception of `keydown` on `window` for global shortcut paths. + +See `src/components/CoverLightbox.test.tsx`. + +### Hooks + +Wrap in `renderHook()` from `@testing-library/react`. Provide custom +wrappers when the hook reads from a provider. + +## What to NOT mock + +- **Real Zustand stores.** The whole point of characterization tests is to + exercise the actual state graph. Mock only at the system boundary + (Tauri / network / browser APIs). +- **The router.** `MemoryRouter` via `renderWithProviders` is fine — don't + stub `useNavigate` etc. unless a test specifically inspects navigation. +- **react-i18next.** `I18nextProvider` with the real `i18n.ts` instance is + cheap and avoids tests that lie about labels. + +## Coverage gates + +- `vitest run --coverage` writes `coverage/coverage-summary.json` which the + hot-path gate consumes. +- `.github/frontend-hot-path-files.txt` lists the files held to ≥70% line + coverage by `scripts/check-frontend-hot-path-coverage.sh`. +- CI runs both. The gate is currently soft (`continue-on-error: true`) — + flip to a hard PR-blocker once a few PRs run cleanly. Mirrors the backend + rust-tests rollout. diff --git a/src/test/helpers/factories.ts b/src/test/helpers/factories.ts new file mode 100644 index 00000000..ef141456 --- /dev/null +++ b/src/test/helpers/factories.ts @@ -0,0 +1,33 @@ +/** + * Test fixture factories. + * + * Build minimal but valid domain objects with sensible defaults; override only + * the fields the test cares about. Keeps tests focused on behaviour rather + * than on assembling boilerplate. + */ +import type { Track } from '@/store/playerStore'; + +let trackCounter = 0; + +export function makeTrack(overrides: Partial = {}): Track { + trackCounter += 1; + const id = overrides.id ?? `track-${trackCounter}`; + return { + id, + title: `Track ${trackCounter}`, + artist: 'Test Artist', + album: 'Test Album', + albumId: `album-${trackCounter}`, + duration: 180, + coverArt: id, + ...overrides, + } as Track; +} + +export function makeTracks(n: number, overridesFn?: (i: number) => Partial): Track[] { + return Array.from({ length: n }, (_, i) => makeTrack(overridesFn?.(i) ?? {})); +} + +export function resetFactoryCounters(): void { + trackCounter = 0; +} diff --git a/src/test/helpers/renderWithProviders.tsx b/src/test/helpers/renderWithProviders.tsx new file mode 100644 index 00000000..c7502324 --- /dev/null +++ b/src/test/helpers/renderWithProviders.tsx @@ -0,0 +1,35 @@ +/** + * Wraps `render()` from @testing-library/react with the providers most + * Psysonic components need: a router (for hooks like useNavigate / useParams) + * and i18n (for components that call `useTranslation`). + * + * Tests that don't need a specific route can call `renderWithProviders()`. + * Tests that need a specific URL pass `{ route: '/album/42' }`. + */ +import type { ReactElement, ReactNode } from 'react'; +import { render, type RenderOptions, type RenderResult } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nextProvider } from 'react-i18next'; +import i18n from '@/i18n'; + +interface WrapperOptions { + route?: string; +} + +function makeWrapper({ route = '/' }: WrapperOptions) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +export function renderWithProviders( + ui: ReactElement, + options: WrapperOptions & Omit = {}, +): RenderResult { + const { route, ...rest } = options; + return render(ui, { wrapper: makeWrapper({ route }), ...rest }); +} diff --git a/src/test/mocks/tauri.ts b/src/test/mocks/tauri.ts new file mode 100644 index 00000000..5621fe8f --- /dev/null +++ b/src/test/mocks/tauri.ts @@ -0,0 +1,82 @@ +/** + * Tauri test harness — programmable `invoke()` + `listen()` mocks. + * + * Usage in a test: + * + * import { onInvoke, emitTauriEvent } from '@/test/mocks/tauri'; + * + * beforeEach(() => { + * onInvoke('audio_play', () => undefined); + * onInvoke('audio_get_state', () => ({ playing: true })); + * }); + * + * it('emits progress', () => { + * emitTauriEvent('audio:progress', { id: 't1', currentTime: 42 }); + * }); + * + * Handlers are auto-cleared between tests (`beforeEach` hook below). + * Unhandled invokes throw — keeps tests honest about which commands they touch. + */ +import { beforeEach, vi } from 'vitest'; +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; + +export type InvokeHandler = (args: unknown) => unknown | Promise; +export type EventCallback = (payload: unknown) => void; + +const invokeHandlers = new Map(); +const eventListeners = new Map(); + +// Tauri's typed signatures are strict (InvokeArgs / Event). Tests don't +// need that level of precision — cast the mocks to `any` so the helpers +// accept simple `{ payload }` envelopes and plain object args. +const invokeMock = vi.mocked(invoke) as unknown as ReturnType; +const listenMock = vi.mocked(listen) as unknown as ReturnType; + +invokeMock.mockImplementation(async (cmd: string, args?: unknown) => { + const handler = invokeHandlers.get(cmd); + if (!handler) { + throw new Error( + `Unhandled invoke('${cmd}'). Register via onInvoke('${cmd}', …) in the test.`, + ); + } + return await handler(args); +}); + +listenMock.mockImplementation( + async (event: string, cb: (e: { payload: unknown }) => void) => { + const wrapped: EventCallback = (payload) => + cb({ payload } as { payload: unknown }); + const arr = eventListeners.get(event) ?? []; + arr.push(wrapped); + eventListeners.set(event, arr); + return () => { + const list = eventListeners.get(event); + if (!list) return; + const i = list.indexOf(wrapped); + if (i >= 0) list.splice(i, 1); + }; + }, +); + +/** Register a handler for `invoke('', …)`. Last-write-wins per command. */ +export function onInvoke(cmd: string, handler: InvokeHandler): void { + invokeHandlers.set(cmd, handler); +} + +/** Synchronously deliver an `` payload to every active listener. */ +export function emitTauriEvent(event: string, payload: unknown): void { + for (const cb of eventListeners.get(event) ?? []) cb(payload); +} + +/** Clear all handlers + listeners + call counts. Wired to `beforeEach` below. */ +export function resetTauriMocks(): void { + invokeHandlers.clear(); + eventListeners.clear(); + invokeMock.mockClear(); + listenMock.mockClear(); +} + +export { invokeMock, listenMock }; + +beforeEach(resetTauriMocks); diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 00000000..8eb23293 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,100 @@ +import '@testing-library/jest-dom/vitest'; +import { afterEach, vi } from 'vitest'; +import { cleanup } from '@testing-library/react'; + +// ───────────────────────────────────────────────────────────────────────────── +// Node 25 ships a native `localStorage` global that is broken on this +// platform — `typeof localStorage === 'object'` but `localStorage.getItem` +// is `undefined`. jsdom 26 simply forwards to it, so both globalThis and +// window expose a non-functional storage. Any code that runs +// `localStorage.getItem(...)` at module load (e.g. `i18n.ts`, `authStore.ts`) +// crashes before tests start. +// +// Fix: install a Map-backed polyfill that conforms to the DOM Storage +// interface, on both globalThis and window. Per-test isolation comes from +// the `afterEach(() => store.clear())` hook below. +// ───────────────────────────────────────────────────────────────────────────── +class MemoryStorage implements Storage { + private map = new Map(); + get length(): number { + return this.map.size; + } + key(index: number): string | null { + return Array.from(this.map.keys())[index] ?? null; + } + getItem(key: string): string | null { + return this.map.has(key) ? (this.map.get(key) as string) : null; + } + setItem(key: string, value: string): void { + this.map.set(String(key), String(value)); + } + removeItem(key: string): void { + this.map.delete(String(key)); + } + clear(): void { + this.map.clear(); + } +} + +const memLocal = new MemoryStorage(); +const memSession = new MemoryStorage(); + +function installStorage(globalKey: 'localStorage' | 'sessionStorage', store: Storage) { + try { + Object.defineProperty(globalThis, globalKey, { + configurable: true, + writable: true, + value: store, + }); + } catch { + /* ignore — non-configurable global */ + } + if (typeof window !== 'undefined') { + try { + Object.defineProperty(window, globalKey, { + configurable: true, + writable: true, + value: store, + }); + } catch { + /* ignore */ + } + } +} + +installStorage('localStorage', memLocal); +installStorage('sessionStorage', memSession); + +// ───────────────────────────────────────────────────────────────────────────── +// Global Tauri mocks. +// +// Every test file that imports `@tauri-apps/api/core` or `@tauri-apps/api/event` +// gets these stubs. They start as bare `vi.fn()`s; the helpers in +// `src/test/mocks/tauri.ts` attach programmable implementations the first time +// they are imported by a test file. Tests that don't need Tauri can ignore +// the helpers entirely. +// +// We mock here (in setupFiles) rather than per-test-file so individual tests +// don't have to repeat `vi.mock('@tauri-apps/api/core', …)` boilerplate. +// ───────────────────────────────────────────────────────────────────────────── +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), + convertFileSrc: vi.fn((p: string) => `tauri://localhost/${p}`), +})); + +vi.mock('@tauri-apps/api/event', () => ({ + listen: vi.fn(), + emit: vi.fn(), + once: vi.fn(), +})); + +// Linker for Tauri shell / dialog / store plugins — same idea. Extend as needed. +vi.mock('@tauri-apps/plugin-shell', () => ({ + open: vi.fn(async () => {}), +})); + +afterEach(() => { + cleanup(); + memLocal.clear(); + memSession.clear(); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 7eeb3f85..03dc5b21 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,8 +1,44 @@ import { defineConfig } from 'vitest/config'; +import path from 'node:path'; export default defineConfig({ + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, test: { - environment: 'node', - include: ['src/**/*.test.ts'], + // jsdom by default — most new tests touch the DOM or React state. Pure + // utility tests work fine in jsdom too, so we keep a single environment. + environment: 'jsdom', + globals: false, + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + exclude: [ + 'node_modules/**', + 'dist/**', + 'src-tauri/**', + ], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'json-summary', 'html', 'lcov'], + reportsDirectory: './coverage', + include: ['src/**/*.{ts,tsx}'], + exclude: [ + 'src/**/*.test.{ts,tsx}', + 'src/**/*.d.ts', + 'src/test/**', + 'src/main.tsx', + 'src/vite-env.d.ts', + ], + // Soft thresholds — Phase 0 is infra, not coverage push. Real numbers + // land in Phase 1 once cucadmuh and Frank lock the gate. + thresholds: { + lines: 0, + functions: 0, + branches: 0, + statements: 0, + }, + }, }, });