diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 7179d4a..386fa3a 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -14,4 +14,12 @@ updates:
groups:
all:
patterns:
- - "*"
\ No newline at end of file
+ - "*"
+ - package-ecosystem: "npm"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ groups:
+ all:
+ patterns:
+ - "*"
diff --git a/.github/workflows/auto-fix-lint-format-commit.yml b/.github/workflows/auto-fix-lint-format-commit.yml
index d1658c0..cc702d7 100644
--- a/.github/workflows/auto-fix-lint-format-commit.yml
+++ b/.github/workflows/auto-fix-lint-format-commit.yml
@@ -1,11 +1,16 @@
name: Auto Fix Lint and Format
on:
- pull_request_target:
+ pull_request:
types: [opened, synchronize]
+permissions:
+ contents: write
+ pull-requests: write
+
jobs:
auto-fix:
+ if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- name: Checkout code
@@ -13,29 +18,32 @@ jobs:
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
- repository: ${{ github.event.pull_request.head.repo.full_name }}
- - name: Set up Bun
- uses: oven-sh/setup-bun@v2
+ - name: Set up pnpm
+ uses: pnpm/action-setup@v6
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v6
with:
- bun-version: "latest"
+ node-version: "22"
+ cache: "pnpm"
- name: Install dependencies
- run: bun install
+ run: pnpm install --frozen-lockfile
- name: Run linter and fix issues
- run: bun run lint:fix
+ run: pnpm run lint:fix
- name: Run formatter
- run: bun run format
+ run: pnpm run format
- name: Check for changes
id: check_changes
run: |
- git diff --exit-code || echo "has_changes=true" >> $GITHUB_ENV
+ git diff --exit-code || echo "has_changes=true" >> "$GITHUB_OUTPUT"
- name: Commit and push changes
- if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
+ if: steps.check_changes.outputs.has_changes == 'true'
uses: stefanzweifel/git-auto-commit-action@v7
with:
commit_message: "chore: auto-fix linting and formatting issues"
@@ -43,10 +51,10 @@ jobs:
file_pattern: "."
- name: Add PR comment
- if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
+ if: steps.check_changes.outputs.has_changes == 'true'
uses: actions/github-script@v9
with:
- github-token: ${{secrets.GITHUB_TOKEN}}
+ github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 217dba5..8e8fd81 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -33,5 +33,11 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
+ - name: Run quality checks
+ run: pnpm run check
+
+ - name: Run tests
+ run: pnpm run test:coverage
+
- name: Run build
- run: pnpm run build
\ No newline at end of file
+ run: pnpm run build
diff --git a/.gitignore b/.gitignore
index c234903..7b043b9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@ package-lock.json
dist
dist-ssr
dev-dist
+coverage
*.local
# Editor directories and files
diff --git a/package.json b/package.json
index ac960c0..8b82095 100644
--- a/package.json
+++ b/package.json
@@ -3,14 +3,23 @@
"private": true,
"version": "2.0.2",
"type": "module",
+ "engines": {
+ "node": ">=22",
+ "pnpm": ">=11"
+ },
"scripts": {
"dev": "vite",
- "build": "tsc -b && vite build",
+ "typecheck": "tsc -b",
+ "build": "pnpm run typecheck && vite build",
"lint": "biome lint",
"lint:fix": "biome lint --fix",
"format": "biome format --write .",
"check": "biome check",
"check:fix": "biome check --fix --unsafe",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:coverage": "vitest run --coverage",
+ "verify": "pnpm run check && pnpm run test:coverage && pnpm run build",
"preview": "vite preview"
},
"dependencies": {
@@ -56,15 +65,21 @@
"devDependencies": {
"@biomejs/biome": "2.4.16",
"@tailwindcss/postcss": "^4.3.0",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/node": "25.9.2",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "^6.0.2",
+ "@vitest/coverage-v8": "^4.1.8",
"globals": "17.6.0",
+ "jsdom": "^29.1.1",
"postcss": "8.5.15",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.3",
- "vite": "8.0.16"
+ "vite": "8.0.16",
+ "vitest": "^4.1.8"
},
"packageManager": "pnpm@11.5.1+sha512.93f7b57422ea7068257235b4c16eb60762eb68e1dc23723199cc739043ea9be2c4143274a399d8c6defa2b1176226d9ca1c4b63482d6200c1a8fbaa78c1d1485"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a0b37fa..1266251 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -129,6 +129,15 @@ importers:
'@tailwindcss/postcss':
specifier: ^4.3.0
version: 4.3.0
+ '@testing-library/jest-dom':
+ specifier: ^6.9.1
+ version: 6.9.1
+ '@testing-library/react':
+ specifier: ^16.3.2
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@testing-library/user-event':
+ specifier: ^14.6.1
+ version: 14.6.1(@testing-library/dom@10.4.1)
'@types/node':
specifier: 25.9.2
version: 25.9.2
@@ -141,9 +150,15 @@ importers:
'@vitejs/plugin-react':
specifier: ^6.0.2
version: 6.0.2(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0))
+ '@vitest/coverage-v8':
+ specifier: ^4.1.8
+ version: 4.1.8(vitest@4.1.8)
globals:
specifier: 17.6.0
version: 17.6.0
+ jsdom:
+ specifier: ^29.1.1
+ version: 29.1.1
postcss:
specifier: 8.5.15
version: 8.5.15
@@ -156,17 +171,63 @@ importers:
vite:
specifier: 8.0.16
version: 8.0.16(@types/node@25.9.2)(jiti@2.7.0)
+ vitest:
+ specifier: ^4.1.8
+ version: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0))
packages:
+ '@adobe/css-tools@4.5.0':
+ resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
+
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
+ '@asamuzakjp/css-color@5.1.11':
+ resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/dom-selector@7.1.1':
+ resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/generational-cache@1.0.1':
+ resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/nwsapi@2.3.9':
+ resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
+
+ '@babel/code-frame@7.29.7':
+ resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-string-parser@7.29.7':
+ resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.29.7':
+ resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.7':
+ resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
'@babel/runtime@7.29.7':
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
+ '@babel/types@7.29.7':
+ resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
+ engines: {node: '>=6.9.0'}
+
+ '@bcoe/v8-coverage@1.0.2':
+ resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
+ engines: {node: '>=18'}
+
'@biomejs/biome@2.4.16':
resolution: {integrity: sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==}
engines: {node: '>=14.21.3'}
@@ -224,6 +285,46 @@ packages:
cpu: [x64]
os: [win32]
+ '@bramus/specificity@2.4.2':
+ resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
+ hasBin: true
+
+ '@csstools/color-helpers@6.0.2':
+ resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
+ engines: {node: '>=20.19.0'}
+
+ '@csstools/css-calc@3.2.1':
+ resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-color-parser@4.1.4':
+ resolution: {integrity: sha512-yI8kNhHiOrLb8Rlulsk07DeQz0PwyT69FX9dkz5rAp7p9RUwFKEXnZpBGzURiLHgi66YqIWxOHn1nij8Lrg27Q==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0':
+ resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.5':
+ resolution: {integrity: sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==}
+ peerDependencies:
+ css-tree: ^3.2.1
+ peerDependenciesMeta:
+ css-tree:
+ optional: true
+
+ '@csstools/css-tokenizer@4.0.0':
+ resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
+ engines: {node: '>=20.19.0'}
+
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
@@ -233,6 +334,15 @@ packages:
'@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
+ '@exodus/bytes@1.15.1':
+ resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
+ 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
+
'@floating-ui/core@1.7.5':
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
@@ -958,9 +1068,44 @@ packages:
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'}
+ '@testing-library/dom@10.4.1':
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
+ engines: {node: '>=18'}
+
+ '@testing-library/jest-dom@6.9.1':
+ resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
+ engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
+
+ '@testing-library/react@16.3.2':
+ resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
+ 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
+
+ '@testing-library/user-event@14.6.1':
+ resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
+ engines: {node: '>=12', npm: '>=6'}
+ peerDependencies:
+ '@testing-library/dom': '>=7.21.4'
+
'@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
+ '@types/aria-query@5.0.4':
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@@ -991,6 +1136,12 @@ packages:
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
@@ -1024,10 +1175,77 @@ packages:
babel-plugin-react-compiler:
optional: true
+ '@vitest/coverage-v8@4.1.8':
+ resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==}
+ peerDependencies:
+ '@vitest/browser': 4.1.8
+ vitest: 4.1.8
+ peerDependenciesMeta:
+ '@vitest/browser':
+ optional: true
+
+ '@vitest/expect@4.1.8':
+ resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==}
+
+ '@vitest/mocker@4.1.8':
+ resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
+ '@vitest/pretty-format@4.1.8':
+ resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==}
+
+ '@vitest/runner@4.1.8':
+ resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==}
+
+ '@vitest/snapshot@4.1.8':
+ resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==}
+
+ '@vitest/spy@4.1.8':
+ resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==}
+
+ '@vitest/utils@4.1.8':
+ resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==}
+
+ ansi-regex@5.0.1:
+ resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
+ engines: {node: '>=8'}
+
+ ansi-styles@5.2.0:
+ resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
+ engines: {node: '>=10'}
+
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
+ aria-query@5.3.0:
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+
+ aria-query@5.3.2:
+ resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
+ engines: {node: '>= 0.4'}
+
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
+ ast-v8-to-istanbul@1.0.4:
+ resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==}
+
+ bidi-js@1.0.3:
+ resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+
+ chai@6.2.2:
+ resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
+ engines: {node: '>=18'}
+
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
@@ -1041,6 +1259,9 @@ packages:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
@@ -1048,6 +1269,13 @@ packages:
country-flag-icons@1.6.17:
resolution: {integrity: sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw==}
+ css-tree@3.2.1:
+ resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+
+ css.escape@1.5.1:
+ resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
+
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -1099,12 +1327,23 @@ packages:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
+ data-urls@7.0.0:
+ resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
dayjs@1.11.21:
resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==}
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
+ decimal.js@10.6.0:
+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+
+ dequal@2.0.3:
+ resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
+ engines: {node: '>=6'}
+
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -1115,16 +1354,36 @@ packages:
diacritics@1.3.0:
resolution: {integrity: sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==}
+ dom-accessibility-api@0.5.16:
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+
+ dom-accessibility-api@0.6.3:
+ resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+
enhanced-resolve@5.23.0:
resolution: {integrity: sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==}
engines: {node: '>=10.13.0'}
+ entities@8.0.0:
+ resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
+ engines: {node: '>=20.19.0'}
+
+ es-module-lexer@2.1.0:
+ resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
+
es-toolkit@1.47.0:
resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==}
+ estree-walker@3.0.3:
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
+ expect-type@1.3.0:
+ resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
+ engines: {node: '>=12.0.0'}
+
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -1150,6 +1409,17 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+ has-flag@4.0.0:
+ resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
+ engines: {node: '>=8'}
+
+ html-encoding-sniffer@6.0.0:
+ resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ html-escaper@2.0.2:
+ resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+
html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
@@ -1171,14 +1441,48 @@ packages:
immer@11.1.8:
resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==}
+ indent-string@4.0.0:
+ resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
+ engines: {node: '>=8'}
+
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
+ is-potential-custom-element-name@1.0.1:
+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+
+ istanbul-lib-coverage@3.2.2:
+ resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
+ engines: {node: '>=8'}
+
+ istanbul-lib-report@3.0.1:
+ resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
+ engines: {node: '>=10'}
+
+ istanbul-reports@3.2.0:
+ resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
+ engines: {node: '>=8'}
+
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
+ js-tokens@10.0.0:
+ resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ jsdom@29.1.1:
+ resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
+ peerDependencies:
+ canvas: ^3.0.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
@@ -1253,6 +1557,10 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
+ lru-cache@11.5.1:
+ resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==}
+ engines: {node: 20 || >=22}
+
lucide-react@1.17.0:
resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==}
peerDependencies:
@@ -1262,14 +1570,42 @@ packages:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
+ lz-string@1.5.0:
+ resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
+ hasBin: true
+
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
+ magicast@0.5.3:
+ resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==}
+
+ make-dir@4.0.0:
+ resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
+ engines: {node: '>=10'}
+
+ mdn-data@2.27.1:
+ resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
+
+ min-indent@1.0.1:
+ resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
+ engines: {node: '>=4'}
+
nanoid@3.3.12:
resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ obug@2.1.3:
+ resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
+ engines: {node: '>=12.20.0'}
+
+ parse5@8.0.1:
+ resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
+
+ pathe@2.0.3:
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1281,6 +1617,14 @@ packages:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
+ pretty-format@27.5.1:
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+
+ punycode@2.3.1:
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+ engines: {node: '>=6'}
+
react-dom@19.2.7:
resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
peerDependencies:
@@ -1302,6 +1646,9 @@ packages:
typescript:
optional: true
+ react-is@17.0.2:
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
@@ -1376,6 +1723,10 @@ packages:
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ redent@3.0.0:
+ resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
+ engines: {node: '>=8'}
+
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
@@ -1384,6 +1735,10 @@ packages:
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
+ require-from-string@2.0.2:
+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+ engines: {node: '>=0.10.0'}
+
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
@@ -1392,12 +1747,24 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
+ saxes@6.0.0:
+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+ engines: {node: '>=v12.22.7'}
+
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+ semver@7.8.4:
+ resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
+ siginfo@2.0.0:
+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
@@ -1408,6 +1775,23 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
+ stackback@0.0.2:
+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
+ std-env@4.1.0:
+ resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
+
+ strip-indent@3.0.0:
+ resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
+ engines: {node: '>=8'}
+
+ supports-color@7.2.0:
+ resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
+ engines: {node: '>=8'}
+
+ symbol-tree@3.2.4:
+ resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+
tailwind-merge@3.6.0:
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
@@ -1426,10 +1810,36 @@ packages:
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
+ tinybench@2.9.0:
+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
+ tinyexec@1.2.4:
+ resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
+ engines: {node: '>=18'}
+
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
+ tinyrainbow@3.1.0:
+ resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
+ engines: {node: '>=14.0.0'}
+
+ tldts-core@7.4.2:
+ resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==}
+
+ tldts@7.4.2:
+ resolution: {integrity: sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==}
+ hasBin: true
+
+ tough-cookie@6.0.1:
+ resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
+ engines: {node: '>=16'}
+
+ tr46@6.0.0:
+ resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
+ engines: {node: '>=20'}
+
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -1441,6 +1851,10 @@ packages:
undici-types@7.24.6:
resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
+ undici@7.27.2:
+ resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==}
+ engines: {node: '>=20.18.1'}
+
use-callback-ref@1.3.3:
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
engines: {node: '>=10'}
@@ -1512,16 +1926,128 @@ packages:
yaml:
optional: true
+ vitest@4.1.8:
+ resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==}
+ engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
+ hasBin: true
+ 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.8
+ '@vitest/browser-preview': 4.1.8
+ '@vitest/browser-webdriverio': 4.1.8
+ '@vitest/coverage-istanbul': 4.1.8
+ '@vitest/coverage-v8': 4.1.8
+ '@vitest/ui': 4.1.8
+ 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
+
void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
+ w3c-xmlserializer@5.0.0:
+ resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
+ engines: {node: '>=18'}
+
+ webidl-conversions@8.0.1:
+ resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
+ engines: {node: '>=20'}
+
+ whatwg-mimetype@5.0.0:
+ resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
+ engines: {node: '>=20'}
+
+ whatwg-url@16.0.1:
+ resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ why-is-node-running@2.3.0:
+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+ engines: {node: '>=8'}
+ hasBin: true
+
+ xml-name-validator@5.0.0:
+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+ engines: {node: '>=18'}
+
+ xmlchars@2.2.0:
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
snapshots:
+ '@adobe/css-tools@4.5.0': {}
+
'@alloc/quick-lru@5.2.0': {}
+ '@asamuzakjp/css-color@5.1.11':
+ dependencies:
+ '@asamuzakjp/generational-cache': 1.0.1
+ '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-color-parser': 4.1.4(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@asamuzakjp/dom-selector@7.1.1':
+ dependencies:
+ '@asamuzakjp/generational-cache': 1.0.1
+ '@asamuzakjp/nwsapi': 2.3.9
+ bidi-js: 1.0.3
+ css-tree: 3.2.1
+ is-potential-custom-element-name: 1.0.1
+
+ '@asamuzakjp/generational-cache@1.0.1': {}
+
+ '@asamuzakjp/nwsapi@2.3.9': {}
+
+ '@babel/code-frame@7.29.7':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.29.7
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/helper-string-parser@7.29.7': {}
+
+ '@babel/helper-validator-identifier@7.29.7': {}
+
+ '@babel/parser@7.29.7':
+ dependencies:
+ '@babel/types': 7.29.7
+
'@babel/runtime@7.29.7': {}
+ '@babel/types@7.29.7':
+ dependencies:
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+
+ '@bcoe/v8-coverage@1.0.2': {}
+
'@biomejs/biome@2.4.16':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.4.16
@@ -1557,6 +2083,34 @@ snapshots:
'@biomejs/cli-win32-x64@2.4.16':
optional: true
+ '@bramus/specificity@2.4.2':
+ dependencies:
+ css-tree: 3.2.1
+
+ '@csstools/color-helpers@6.0.2': {}
+
+ '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-color-parser@4.1.4(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/color-helpers': 6.0.2
+ '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.5(css-tree@3.2.1)':
+ optionalDependencies:
+ css-tree: 3.2.1
+
+ '@csstools/css-tokenizer@4.0.0': {}
+
'@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1
@@ -1573,6 +2127,8 @@ snapshots:
tslib: 2.8.1
optional: true
+ '@exodus/bytes@1.15.1': {}
+
'@floating-ui/core@1.7.5':
dependencies:
'@floating-ui/utils': 0.2.11
@@ -2233,11 +2789,52 @@ snapshots:
'@tanstack/table-core@8.21.3': {}
+ '@testing-library/dom@10.4.1':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/runtime': 7.29.7
+ '@types/aria-query': 5.0.4
+ aria-query: 5.3.0
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ picocolors: 1.1.1
+ pretty-format: 27.5.1
+
+ '@testing-library/jest-dom@6.9.1':
+ dependencies:
+ '@adobe/css-tools': 4.5.0
+ aria-query: 5.3.2
+ css.escape: 1.5.1
+ dom-accessibility-api: 0.6.3
+ picocolors: 1.1.1
+ redent: 3.0.0
+
+ '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@testing-library/dom': 10.4.1
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ optionalDependencies:
+ '@types/react': 19.2.17
+ '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
+ '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
+ dependencies:
+ '@testing-library/dom': 10.4.1
+
'@tybys/wasm-util@0.10.2':
dependencies:
tslib: 2.8.1
optional: true
+ '@types/aria-query@5.0.4': {}
+
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
@@ -2266,6 +2863,10 @@ snapshots:
'@types/d3-timer@3.0.2': {}
+ '@types/deep-eql@4.0.2': {}
+
+ '@types/estree@1.0.9': {}
+
'@types/geojson@7946.0.16': {}
'@types/luxon@3.7.1': {}
@@ -2289,10 +2890,89 @@ snapshots:
'@rolldown/pluginutils': 1.0.1
vite: 8.0.16(@types/node@25.9.2)(jiti@2.7.0)
+ '@vitest/coverage-v8@4.1.8(vitest@4.1.8)':
+ dependencies:
+ '@bcoe/v8-coverage': 1.0.2
+ '@vitest/utils': 4.1.8
+ ast-v8-to-istanbul: 1.0.4
+ istanbul-lib-coverage: 3.2.2
+ istanbul-lib-report: 3.0.1
+ istanbul-reports: 3.2.0
+ magicast: 0.5.3
+ obug: 2.1.3
+ std-env: 4.1.0
+ tinyrainbow: 3.1.0
+ vitest: 4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0))
+
+ '@vitest/expect@4.1.8':
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ '@types/chai': 5.2.3
+ '@vitest/spy': 4.1.8
+ '@vitest/utils': 4.1.8
+ chai: 6.2.2
+ tinyrainbow: 3.1.0
+
+ '@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0))':
+ dependencies:
+ '@vitest/spy': 4.1.8
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 8.0.16(@types/node@25.9.2)(jiti@2.7.0)
+
+ '@vitest/pretty-format@4.1.8':
+ dependencies:
+ tinyrainbow: 3.1.0
+
+ '@vitest/runner@4.1.8':
+ dependencies:
+ '@vitest/utils': 4.1.8
+ pathe: 2.0.3
+
+ '@vitest/snapshot@4.1.8':
+ dependencies:
+ '@vitest/pretty-format': 4.1.8
+ '@vitest/utils': 4.1.8
+ magic-string: 0.30.21
+ pathe: 2.0.3
+
+ '@vitest/spy@4.1.8': {}
+
+ '@vitest/utils@4.1.8':
+ dependencies:
+ '@vitest/pretty-format': 4.1.8
+ convert-source-map: 2.0.0
+ tinyrainbow: 3.1.0
+
+ ansi-regex@5.0.1: {}
+
+ ansi-styles@5.2.0: {}
+
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
+ aria-query@5.3.0:
+ dependencies:
+ dequal: 2.0.3
+
+ aria-query@5.3.2: {}
+
+ assertion-error@2.0.1: {}
+
+ ast-v8-to-istanbul@1.0.4:
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.31
+ estree-walker: 3.0.3
+ js-tokens: 10.0.0
+
+ bidi-js@1.0.3:
+ dependencies:
+ require-from-string: 2.0.2
+
+ chai@6.2.2: {}
+
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
@@ -2311,10 +2991,19 @@ snapshots:
- '@types/react'
- '@types/react-dom'
+ convert-source-map@2.0.0: {}
+
cookie@1.1.1: {}
country-flag-icons@1.6.17: {}
+ css-tree@3.2.1:
+ dependencies:
+ mdn-data: 2.27.1
+ source-map-js: 1.2.1
+
+ css.escape@1.5.1: {}
+
csstype@3.2.3: {}
d3-array@3.2.4:
@@ -2359,25 +3048,50 @@ snapshots:
d3-timer@3.0.1: {}
+ data-urls@7.0.0:
+ dependencies:
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
dayjs@1.11.21: {}
decimal.js-light@2.5.1: {}
+ decimal.js@10.6.0: {}
+
+ dequal@2.0.3: {}
+
detect-libc@2.1.2: {}
detect-node-es@1.1.0: {}
diacritics@1.3.0: {}
+ dom-accessibility-api@0.5.16: {}
+
+ dom-accessibility-api@0.6.3: {}
+
enhanced-resolve@5.23.0:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.3
+ entities@8.0.0: {}
+
+ es-module-lexer@2.1.0: {}
+
es-toolkit@1.47.0: {}
+ estree-walker@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.9
+
eventemitter3@5.0.4: {}
+ expect-type@1.3.0: {}
+
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@@ -2391,6 +3105,16 @@ snapshots:
graceful-fs@4.2.11: {}
+ has-flag@4.0.0: {}
+
+ html-encoding-sniffer@6.0.0:
+ dependencies:
+ '@exodus/bytes': 1.15.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ html-escaper@2.0.2: {}
+
html-parse-stringify@3.0.1:
dependencies:
void-elements: 3.1.0
@@ -2407,10 +3131,57 @@ snapshots:
immer@11.1.8: {}
+ indent-string@4.0.0: {}
+
internmap@2.0.3: {}
+ is-potential-custom-element-name@1.0.1: {}
+
+ istanbul-lib-coverage@3.2.2: {}
+
+ istanbul-lib-report@3.0.1:
+ dependencies:
+ istanbul-lib-coverage: 3.2.2
+ make-dir: 4.0.0
+ supports-color: 7.2.0
+
+ istanbul-reports@3.2.0:
+ dependencies:
+ html-escaper: 2.0.2
+ istanbul-lib-report: 3.0.1
+
jiti@2.7.0: {}
+ js-tokens@10.0.0: {}
+
+ js-tokens@4.0.0: {}
+
+ jsdom@29.1.1:
+ dependencies:
+ '@asamuzakjp/css-color': 5.1.11
+ '@asamuzakjp/dom-selector': 7.1.1
+ '@bramus/specificity': 2.4.2
+ '@csstools/css-syntax-patches-for-csstree': 1.1.5(css-tree@3.2.1)
+ '@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.1
+ parse5: 8.0.1
+ saxes: 6.0.0
+ symbol-tree: 3.2.4
+ tough-cookie: 6.0.1
+ undici: 7.27.2
+ w3c-xmlserializer: 5.0.0
+ webidl-conversions: 8.0.1
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1
+ xml-name-validator: 5.0.0
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
lightningcss-android-arm64@1.32.0:
optional: true
@@ -2460,18 +3231,44 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
+ lru-cache@11.5.1: {}
+
lucide-react@1.17.0(react@19.2.7):
dependencies:
react: 19.2.7
luxon@3.7.2: {}
+ lz-string@1.5.0: {}
+
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+ magicast@0.5.3:
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+ source-map-js: 1.2.1
+
+ make-dir@4.0.0:
+ dependencies:
+ semver: 7.8.4
+
+ mdn-data@2.27.1: {}
+
+ min-indent@1.0.1: {}
+
nanoid@3.3.12: {}
+ obug@2.1.3: {}
+
+ parse5@8.0.1:
+ dependencies:
+ entities: 8.0.0
+
+ pathe@2.0.3: {}
+
picocolors@1.1.1: {}
picomatch@4.0.4: {}
@@ -2482,6 +3279,14 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ pretty-format@27.5.1:
+ dependencies:
+ ansi-regex: 5.0.1
+ ansi-styles: 5.2.0
+ react-is: 17.0.2
+
+ punycode@2.3.1: {}
+
react-dom@19.2.7(react@19.2.7):
dependencies:
react: 19.2.7
@@ -2498,6 +3303,8 @@ snapshots:
react-dom: 19.2.7(react@19.2.7)
typescript: 6.0.3
+ react-is@17.0.2: {}
+
react-is@18.3.1: {}
react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1):
@@ -2572,12 +3379,19 @@ snapshots:
- '@types/react'
- redux
+ redent@3.0.0:
+ dependencies:
+ indent-string: 4.0.0
+ strip-indent: 3.0.0
+
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
+ require-from-string@2.0.2: {}
+
reselect@5.1.1: {}
rolldown@1.0.3:
@@ -2601,10 +3415,18 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.0.3
'@rolldown/binding-win32-x64-msvc': 1.0.3
+ saxes@6.0.0:
+ dependencies:
+ xmlchars: 2.2.0
+
scheduler@0.27.0: {}
+ semver@7.8.4: {}
+
set-cookie-parser@2.7.2: {}
+ siginfo@2.0.0: {}
+
sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
react: 19.2.7
@@ -2612,6 +3434,20 @@ snapshots:
source-map-js@1.2.1: {}
+ stackback@0.0.2: {}
+
+ std-env@4.1.0: {}
+
+ strip-indent@3.0.0:
+ dependencies:
+ min-indent: 1.0.1
+
+ supports-color@7.2.0:
+ dependencies:
+ has-flag: 4.0.0
+
+ symbol-tree@3.2.4: {}
+
tailwind-merge@3.6.0: {}
tailwindcss-animate@1.0.7(tailwindcss@4.3.0):
@@ -2624,17 +3460,39 @@ snapshots:
tiny-invariant@1.3.3: {}
+ tinybench@2.9.0: {}
+
+ tinyexec@1.2.4: {}
+
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
+ tinyrainbow@3.1.0: {}
+
+ tldts-core@7.4.2: {}
+
+ tldts@7.4.2:
+ dependencies:
+ tldts-core: 7.4.2
+
+ tough-cookie@6.0.1:
+ dependencies:
+ tldts: 7.4.2
+
+ tr46@6.0.0:
+ dependencies:
+ punycode: 2.3.1
+
tslib@2.8.1: {}
typescript@6.0.3: {}
undici-types@7.24.6: {}
+ undici@7.27.2: {}
+
use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7):
dependencies:
react: 19.2.7
@@ -2683,4 +3541,58 @@ snapshots:
fsevents: 2.3.3
jiti: 2.7.0
+ vitest@4.1.8(@types/node@25.9.2)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0)):
+ dependencies:
+ '@vitest/expect': 4.1.8
+ '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.2)(jiti@2.7.0))
+ '@vitest/pretty-format': 4.1.8
+ '@vitest/runner': 4.1.8
+ '@vitest/snapshot': 4.1.8
+ '@vitest/spy': 4.1.8
+ '@vitest/utils': 4.1.8
+ es-module-lexer: 2.1.0
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ obug: 2.1.3
+ pathe: 2.0.3
+ picomatch: 4.0.4
+ std-env: 4.1.0
+ tinybench: 2.9.0
+ tinyexec: 1.2.4
+ tinyglobby: 0.2.17
+ tinyrainbow: 3.1.0
+ vite: 8.0.16(@types/node@25.9.2)(jiti@2.7.0)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 25.9.2
+ '@vitest/coverage-v8': 4.1.8(vitest@4.1.8)
+ jsdom: 29.1.1
+ transitivePeerDependencies:
+ - msw
+
void-elements@3.1.0: {}
+
+ w3c-xmlserializer@5.0.0:
+ dependencies:
+ xml-name-validator: 5.0.0
+
+ webidl-conversions@8.0.1: {}
+
+ whatwg-mimetype@5.0.0: {}
+
+ whatwg-url@16.0.1:
+ dependencies:
+ '@exodus/bytes': 1.15.1
+ tr46: 6.0.0
+ webidl-conversions: 8.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ why-is-node-running@2.3.0:
+ dependencies:
+ siginfo: 2.0.0
+ stackback: 0.0.2
+
+ xml-name-validator@5.0.0: {}
+
+ xmlchars@2.2.0: {}
diff --git a/src/pages/ServerDetail.tsx b/src/pages/ServerDetail.tsx
index 959b55e..eb1c238 100644
--- a/src/pages/ServerDetail.tsx
+++ b/src/pages/ServerDetail.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
-import { useNavigate, useParams } from "react-router-dom";
+import { Navigate, useParams } from "react-router-dom";
import { NetworkChart } from "@/components/NetworkChart";
import ServerDetailChart from "@/components/ServerDetailChart";
import ServerDetailOverview from "@/components/ServerDetailOverview";
@@ -7,8 +7,6 @@ import TabSwitch from "@/components/TabSwitch";
import { Separator } from "@/components/ui/separator";
export default function ServerDetail() {
- const navigate = useNavigate();
-
useEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
}, []);
@@ -19,8 +17,7 @@ export default function ServerDetail() {
const { id: server_id } = useParams();
if (!server_id) {
- navigate("/404");
- return null;
+ return ;
}
return (
diff --git a/src/test/app.test.tsx b/src/test/app.test.tsx
new file mode 100644
index 0000000..c036a2e
--- /dev/null
+++ b/src/test/app.test.tsx
@@ -0,0 +1,141 @@
+import { QueryClientProvider } from "@tanstack/react-query";
+import { render, screen, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import App from "@/App";
+import { createTestQueryClient } from "@/test/utils";
+
+const appMocks = vi.hoisted(() => ({
+ backgroundImage: undefined as string | undefined,
+ fetchSetting: vi.fn(),
+ injectContext: vi.fn(),
+ setTheme: vi.fn(),
+}));
+
+vi.mock("../components/DashCommand", () => ({
+ DashCommand: () =>
dash-command
,
+}));
+
+vi.mock("../components/Footer", () => ({
+ default: () => ,
+}));
+
+vi.mock("../components/Header", () => ({
+ default: () => ,
+ RefreshToast: () => refresh-toast
,
+}));
+
+vi.mock("../pages/Server", () => ({
+ default: () => server-page
,
+}));
+
+vi.mock("../pages/ServerDetail", () => ({
+ default: () => server-detail-page
,
+}));
+
+vi.mock("../hooks/use-background", () => ({
+ useBackground: () => ({ backgroundImage: appMocks.backgroundImage }),
+}));
+
+vi.mock("../hooks/use-theme", () => ({
+ useTheme: () => ({ setTheme: appMocks.setTheme }),
+}));
+
+vi.mock("../lib/inject", () => ({
+ InjectContext: appMocks.injectContext,
+}));
+
+vi.mock("../lib/nezha-api", () => ({
+ fetchSetting: appMocks.fetchSetting,
+}));
+
+function settingResponse(customCode = "") {
+ return {
+ success: true,
+ data: {
+ config: {
+ debug: false,
+ language: "zh-CN",
+ site_name: "Nezha",
+ user_template: "",
+ admin_template: "",
+ custom_code: customCode,
+ },
+ version: "1.0.0",
+ },
+ };
+}
+
+function renderApp(route = "/") {
+ window.history.pushState({}, "", route);
+ const queryClient = createTestQueryClient();
+
+ return render(
+
+
+ ,
+ );
+}
+
+describe("App", () => {
+ beforeEach(() => {
+ appMocks.backgroundImage = undefined;
+ appMocks.fetchSetting.mockResolvedValue(settingResponse());
+ appMocks.injectContext.mockResolvedValue(undefined);
+ });
+
+ it("renders the main shell after settings load and applies global theme/background settings", async () => {
+ Object.assign(window, {
+ ForceTheme: "dark",
+ CustomMobileBackgroundImage: "/mobile.png",
+ });
+ appMocks.backgroundImage = "/desktop.png";
+
+ const { container } = renderApp();
+
+ expect(await screen.findByText("server-page")).toBeInTheDocument();
+ expect(screen.getByText("refresh-toast")).toBeInTheDocument();
+ expect(screen.getByText("header")).toBeInTheDocument();
+ expect(screen.getByText("dash-command")).toBeInTheDocument();
+ expect(screen.getByText("footer")).toBeInTheDocument();
+ expect(appMocks.setTheme).toHaveBeenCalledWith("dark");
+ expect(
+ Array.from(container.querySelectorAll("[style]")).some(
+ (element) => element.style.backgroundImage.includes("/desktop.png"),
+ ),
+ ).toBe(true);
+ expect(
+ Array.from(container.querySelectorAll("[style]")).some(
+ (element) => element.style.backgroundImage.includes("/mobile.png"),
+ ),
+ ).toBe(true);
+ });
+
+ it("injects custom code before showing the app shell", async () => {
+ appMocks.fetchSetting.mockResolvedValue(
+ settingResponse(""),
+ );
+
+ renderApp();
+
+ await waitFor(() => {
+ expect(appMocks.injectContext).toHaveBeenCalledWith(
+ "",
+ );
+ });
+ expect(await screen.findByText("server-page")).toBeInTheDocument();
+ });
+
+ it("renders fetch errors through the error page", async () => {
+ appMocks.fetchSetting.mockRejectedValue(new Error("settings failed"));
+
+ renderApp();
+
+ expect(await screen.findByText("settings failed")).toBeInTheDocument();
+ });
+
+ it("routes server detail paths through the app router", async () => {
+ renderApp("/server/42");
+
+ expect(await screen.findByText("server-detail-page")).toBeInTheDocument();
+ });
+});
diff --git a/src/test/components/RemainPercentBar.test.tsx b/src/test/components/RemainPercentBar.test.tsx
new file mode 100644
index 0000000..3c04610
--- /dev/null
+++ b/src/test/components/RemainPercentBar.test.tsx
@@ -0,0 +1,22 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import RemainPercentBar from "@/components/RemainPercentBar";
+
+describe("RemainPercentBar", () => {
+ it("uses status colors for low, medium, and healthy remaining percentages", () => {
+ const { rerender } = render();
+ const progress = screen.getByRole("progressbar");
+ const indicator = progress.firstElementChild;
+
+ expect(progress).toHaveClass("w-[70px]");
+ expect(indicator).toHaveClass("bg-red-500");
+ expect(indicator).toHaveStyle({ transform: "translateX(-71%)" });
+
+ rerender();
+ expect(progress.firstElementChild).toHaveClass("bg-orange-400");
+
+ rerender();
+ expect(progress.firstElementChild).toHaveClass("bg-green-500");
+ });
+});
diff --git a/src/test/components/ThemeProvider.test.tsx b/src/test/components/ThemeProvider.test.tsx
new file mode 100644
index 0000000..3fbb659
--- /dev/null
+++ b/src/test/components/ThemeProvider.test.tsx
@@ -0,0 +1,144 @@
+import { act, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { ThemeColorManager } from "@/components/ThemeColorManager";
+import {
+ ThemeProvider,
+ ThemeProviderContext,
+} from "@/components/ThemeProvider";
+import { useTheme } from "@/hooks/use-theme";
+
+function ThemeProbe() {
+ const { setTheme, theme } = useTheme();
+
+ return (
+
+
{theme}
+
+
+
+ );
+}
+
+function InvalidThemeProbe() {
+ useTheme();
+ return null;
+}
+
+describe("ThemeProvider", () => {
+ it("applies theme classes, meta color, and localStorage state", async () => {
+ const user = userEvent.setup();
+ const meta = document.createElement("meta");
+ meta.name = "theme-color";
+ document.head.appendChild(meta);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("theme-state")).toHaveTextContent("system");
+ expect(document.documentElement).toHaveClass("light");
+
+ await user.click(screen.getByRole("button", { name: "dark" }));
+
+ expect(screen.getByTestId("theme-state")).toHaveTextContent("dark");
+ expect(localStorage.getItem("theme-test")).toBe("dark");
+ expect(document.documentElement).toHaveClass("dark");
+ expect(document.documentElement.style.colorScheme).toBe("dark");
+ expect(document.querySelector('meta[name="theme-color"]')).toHaveAttribute(
+ "content",
+ "hsl(30 15% 8%)",
+ );
+
+ await user.click(screen.getByRole("button", { name: "light" }));
+ expect(document.documentElement).toHaveClass("light");
+ });
+
+ it("uses stored theme value as the initial theme", () => {
+ localStorage.setItem("theme-test", "dark");
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("theme-state")).toHaveTextContent("dark");
+ expect(document.documentElement).toHaveClass("dark");
+ });
+
+ it("follows system color scheme changes and cleans up its listener", () => {
+ let systemDark = true;
+ let changeListener: (() => void) | undefined;
+ const addEventListener = vi.fn((_event: string, listener: () => void) => {
+ changeListener = listener;
+ });
+ const removeEventListener = vi.fn();
+
+ vi.mocked(window.matchMedia).mockReturnValue({
+ get matches() {
+ return systemDark;
+ },
+ media: "(prefers-color-scheme: dark)",
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener,
+ removeEventListener,
+ dispatchEvent: vi.fn(),
+ } as unknown as MediaQueryList);
+
+ const { unmount } = render(
+
+
+ ,
+ );
+
+ expect(document.documentElement).toHaveClass("dark");
+
+ act(() => {
+ systemDark = false;
+ changeListener?.();
+ });
+
+ expect(document.documentElement).toHaveClass("light");
+
+ unmount();
+
+ expect(removeEventListener).toHaveBeenCalledWith("change", changeListener);
+ });
+
+ it("keeps theme-color meta in sync through ThemeColorManager", () => {
+ render(
+
+
+
+ ,
+ );
+
+ act(() => {
+ screen.getByRole("button", { name: "dark" }).click();
+ });
+
+ expect(document.querySelector('meta[name="theme-color"]')).toHaveAttribute(
+ "content",
+ "hsl(30 15% 8%)",
+ );
+ });
+
+ it("throws when the theme context is explicitly unavailable", () => {
+ expect(() =>
+ render(
+
+
+ ,
+ ),
+ ).toThrow("useTheme must be used within a ThemeProvider");
+ });
+});
diff --git a/src/test/components/basic-components.test.tsx b/src/test/components/basic-components.test.tsx
new file mode 100644
index 0000000..6d16986
--- /dev/null
+++ b/src/test/components/basic-components.test.tsx
@@ -0,0 +1,190 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import AnimateCountClient, { AnimateCount } from "@/components/AnimatedCount";
+import ErrorBoundary from "@/components/ErrorBoundary";
+import ChartSkeleton from "@/components/loading/ChartSkeleton";
+import { Loader, LoadingSpinner } from "@/components/loading/Loader";
+import NetworkChartLoading from "@/components/NetworkChartLoading";
+import { SearchButton } from "@/components/SearchButton";
+import ServerOverview from "@/components/ServerOverview";
+import ServerUsageBar from "@/components/ServerUsageBar";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Separator } from "@/components/ui/separator";
+import { Skeleton } from "@/components/ui/skeleton";
+
+vi.mock("@numeric-text/react", () => ({
+ default: ({
+ className,
+ value,
+ }: {
+ className?: string;
+ value: string | number;
+ }) => {value},
+}));
+
+import { CommandProvider } from "@/context/command-provider";
+import { StatusProvider } from "@/context/status-provider";
+import { useCommand } from "@/hooks/use-command";
+import { useStatus } from "@/hooks/use-status";
+
+function BrokenComponent(): never {
+ throw new Error("render failed");
+}
+
+function CommandReadout() {
+ const { isOpen } = useCommand();
+ return {isOpen ? "command-open" : "command-closed"}
;
+}
+
+function StatusReadout() {
+ const { status } = useStatus();
+ return {`status:${status}`}
;
+}
+
+describe("basic display components", () => {
+ it("renders animated counts with padded digits", () => {
+ render({7});
+
+ expect(screen.getAllByText("0")).toHaveLength(4);
+ expect(screen.getAllByText("7")).toHaveLength(1);
+ });
+
+ it("keeps the animated count client stable across count changes", () => {
+ const { rerender } = render(
+ ,
+ );
+
+ expect(screen.getAllByText("1")).toHaveLength(2);
+ rerender();
+ expect(screen.getByText("2")).toBeInTheDocument();
+ });
+
+ it("renders server usage colors for healthy, warning, and critical values", () => {
+ const { rerender } = render();
+ expect(screen.getByRole("progressbar").firstElementChild).toHaveClass(
+ "bg-green-500",
+ );
+
+ rerender();
+ expect(screen.getByRole("progressbar").firstElementChild).toHaveClass(
+ "bg-orange-400",
+ );
+
+ rerender();
+ expect(screen.getByRole("progressbar").firstElementChild).toHaveClass(
+ "bg-red-500",
+ );
+ });
+
+ it("renders loading primitives", () => {
+ const { container } = render(
+ <>
+
+
+
+
+ >,
+ );
+
+ expect(
+ container.querySelector("[data-visible='true']"),
+ ).toBeInTheDocument();
+ expect(container.querySelectorAll(".hamster-loading-bar")).toHaveLength(16);
+ expect(container.querySelector(".animate-spin")).toBeInTheDocument();
+ expect(container.querySelector(".h-\\[250px\\]")).toBeInTheDocument();
+ });
+
+ it("catches render errors with ErrorBoundary", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("500")).toBeInTheDocument();
+ expect(screen.getByText("render failed")).toBeInTheDocument();
+ });
+});
+
+describe("interactive app controls", () => {
+ it("opens command search from the search button", async () => {
+ const user = userEvent.setup();
+ render(
+
+
+
+ ,
+ );
+
+ expect(screen.getByText("command-closed")).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Search" }));
+ expect(screen.getByText("command-open")).toBeInTheDocument();
+ });
+
+ it("lets overview cards update the global status filter", () => {
+ render(
+
+
+
+ ,
+ );
+
+ expect(screen.getByText("status:all")).toBeInTheDocument();
+ fireEvent.click(screen.getByText("serverOverview.onlineServers"));
+ expect(screen.getByText("status:online")).toBeInTheDocument();
+ fireEvent.click(screen.getByText("serverOverview.offlineServers"));
+ expect(screen.getByText("status:offline")).toBeInTheDocument();
+ fireEvent.click(screen.getByText("serverOverview.totalServers"));
+ expect(screen.getByText("status:all")).toBeInTheDocument();
+ });
+});
+
+describe("ui primitives", () => {
+ it("forwards classes and events through Button and Input", async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+ const onChange = vi.fn();
+ render(
+ <>
+
+
+ >,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ await user.type(screen.getByPlaceholderText("Name"), "edge");
+
+ expect(onClick).toHaveBeenCalledOnce();
+ expect(onChange).toHaveBeenCalled();
+ expect(screen.getByRole("button", { name: "Save" })).toHaveClass(
+ "bg-secondary",
+ );
+ });
+
+ it("renders skeletons and separators with expected orientation classes", () => {
+ const { container } = render(
+ <>
+
+
+
+ >,
+ );
+
+ expect(screen.getByTestId("skeleton")).toHaveClass("animate-pulse", "h-4");
+ expect(screen.getByTestId("horizontal")).toHaveClass("h-px", "w-full");
+ expect(screen.getByTestId("vertical")).toHaveClass("h-full", "w-px");
+ expect(container.querySelectorAll("[data-orientation]")).toHaveLength(2);
+ });
+});
diff --git a/src/test/components/chart-primitives.test.tsx b/src/test/components/chart-primitives.test.tsx
new file mode 100644
index 0000000..f7c85db
--- /dev/null
+++ b/src/test/components/chart-primitives.test.tsx
@@ -0,0 +1,136 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import {
+ ChartContainer,
+ ChartLegendContent,
+ ChartTooltipContent,
+} from "@/components/ui/chart";
+
+function CpuIcon() {
+ return icon;
+}
+
+const chartConfig = {
+ cpu: {
+ label: "CPU Usage",
+ icon: CpuIcon,
+ },
+ mem: {
+ label: "Memory",
+ color: "#00ff00",
+ },
+ themed: {
+ label: "Themed",
+ theme: {
+ light: "#ffffff",
+ dark: "#000000",
+ },
+ },
+};
+
+describe("chart primitives", () => {
+ it("renders tooltip rows with config labels, icons, and formatted values", () => {
+ const formatter = vi.fn((value: number, name: string) => (
+ {`${name}:${value.toFixed(1)}`}
+ ));
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("CPU Usage")).toBeInTheDocument();
+ expect(screen.getByText("cpu:12.3")).toBeInTheDocument();
+ expect(screen.getByText("mem:42.0")).toBeInTheDocument();
+ expect(formatter).toHaveBeenCalled();
+ });
+
+ it("renders nested labels for single line indicators and hides inactive tooltips", () => {
+ const { container, rerender } = render(
+
+
+ ,
+ );
+
+ expect(screen.getAllByText("CPU Usage")).toHaveLength(2);
+ expect(screen.getByText("12.00")).toBeInTheDocument();
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(container).not.toHaveTextContent("CPU Usage");
+ });
+
+ it("renders legend payloads and chart theme style blocks", () => {
+ const { container, rerender } = render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("cpu-icon")).toBeInTheDocument();
+ expect(screen.getByText("mem")).toBeInTheDocument();
+ expect(container.querySelector("style")?.textContent).toContain(
+ "--color-themed",
+ );
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(screen.queryByText("mem")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/test/components/dash-command.test.tsx b/src/test/components/dash-command.test.tsx
new file mode 100644
index 0000000..14d89e7
--- /dev/null
+++ b/src/test/components/dash-command.test.tsx
@@ -0,0 +1,92 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { DashCommand } from "@/components/DashCommand";
+import { createServer } from "@/test/fixtures";
+
+const dashMocks = vi.hoisted(() => ({
+ closeCommand: vi.fn(),
+ isOpen: true,
+ lastMessage: null as MessageEvent | null,
+ navigate: vi.fn(),
+ setTheme: vi.fn(),
+ toggleCommand: vi.fn(),
+ connected: true,
+}));
+
+vi.mock("@/hooks/use-command", () => ({
+ useCommand: () => ({
+ closeCommand: dashMocks.closeCommand,
+ isOpen: dashMocks.isOpen,
+ toggleCommand: dashMocks.toggleCommand,
+ }),
+}));
+
+vi.mock("@/hooks/use-theme", () => ({
+ useTheme: () => ({
+ setTheme: dashMocks.setTheme,
+ }),
+}));
+
+vi.mock("@/hooks/use-websocket-context", () => ({
+ useWebSocketContext: () => ({
+ connected: dashMocks.connected,
+ lastMessage: dashMocks.lastMessage,
+ }),
+}));
+
+vi.mock("react-router-dom", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useNavigate: () => dashMocks.navigate,
+ };
+});
+
+function seedWebSocketData() {
+ dashMocks.connected = true;
+ dashMocks.lastMessage = new MessageEvent("message", {
+ data: JSON.stringify({
+ now: Date.parse("2025-01-01T00:00:00.000Z") / 1000,
+ servers: [
+ createServer({
+ id: 7,
+ name: "edge-7",
+ last_active: "2025-01-01T00:00:00.000Z",
+ }),
+ ],
+ }),
+ });
+}
+
+describe("DashCommand", () => {
+ it("does not render until websocket data is available", () => {
+ dashMocks.connected = false;
+ dashMocks.lastMessage = null;
+
+ const { container } = render();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders server and shortcut commands, handles selection, and listens for keyboard toggle", async () => {
+ const user = userEvent.setup();
+ seedWebSocketData();
+
+ render();
+
+ fireEvent.keyDown(document, { key: "k", metaKey: true });
+ expect(dashMocks.toggleCommand).toHaveBeenCalledOnce();
+
+ expect(screen.getByPlaceholderText("TypeCommand")).toBeInTheDocument();
+ expect(screen.getByText("edge-7")).toBeInTheDocument();
+ expect(screen.getByText("ToggleDarkMode")).toBeInTheDocument();
+
+ await user.click(screen.getByText("edge-7"));
+ expect(dashMocks.navigate).toHaveBeenCalledWith("/server/7");
+ expect(dashMocks.closeCommand).toHaveBeenCalled();
+
+ await user.click(screen.getByText("ToggleDarkMode"));
+ expect(dashMocks.setTheme).toHaveBeenCalledWith("dark");
+ });
+});
diff --git a/src/test/components/global-map.test.tsx b/src/test/components/global-map.test.tsx
new file mode 100644
index 0000000..a5b8fdd
--- /dev/null
+++ b/src/test/components/global-map.test.tsx
@@ -0,0 +1,175 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { MemoryRouter, useLocation } from "react-router-dom";
+import { describe, expect, it } from "vitest";
+import GlobalMap, { InteractiveMap } from "@/components/GlobalMap";
+import { TooltipProvider } from "@/context/tooltip-provider";
+import { createServer } from "@/test/fixtures";
+
+type InteractiveMapProps = Parameters[0];
+
+const now = Date.parse("2025-01-01T00:00:20.000Z");
+
+function LocationProbe() {
+ const location = useLocation();
+ return {location.pathname}
;
+}
+
+function renderMap(ui: React.ReactElement) {
+ return render(
+
+
+ {ui}
+
+
+ ,
+ );
+}
+
+function mapFeature(code: string, name: string) {
+ return {
+ type: "Feature",
+ properties: {
+ iso_a2_eh: code,
+ iso_a3_eh: `${code}A`,
+ name,
+ },
+ geometry: {
+ type: "Polygon",
+ coordinates: [
+ [
+ [-100, 30],
+ [-90, 30],
+ [-90, 40],
+ [-100, 40],
+ [-100, 30],
+ ],
+ ],
+ },
+ };
+}
+
+describe("GlobalMap", () => {
+ it("counts unique countries and ignores servers without country codes", () => {
+ Object.assign(window, { CustomBackgroundImage: "/background.png" });
+
+ const { container } = renderMap(
+ ,
+ );
+
+ expect(
+ screen.getByText(/map\.Distributions 2 map\.Regions/),
+ ).toBeInTheDocument();
+ expect(container.querySelector("section")).toHaveClass("bg-card/70");
+ });
+});
+
+describe("InteractiveMap", () => {
+ it("shows country tooltips, navigates from servers, and clears tooltip state", async () => {
+ const user = userEvent.setup();
+ const filteredFeatures = [
+ mapFeature("US", "United States"),
+ mapFeature("DE", "Germany"),
+ ] as unknown as InteractiveMapProps["filteredFeatures"];
+
+ const { container } = renderMap(
+ ,
+ );
+
+ const highlightedCountry = container.querySelector("path.fill-green-700");
+ expect(highlightedCountry).toBeInTheDocument();
+
+ fireEvent.mouseEnter(highlightedCountry as SVGPathElement);
+
+ expect(await screen.findByText("United States")).toBeInTheDocument();
+ expect(screen.getByText("2 map.Servers")).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: /us-offline/ }));
+
+ expect(sessionStorage.getItem("fromMainPage")).toBe("true");
+ expect(screen.getByText("/server/12")).toBeInTheDocument();
+
+ const fallbackMarker = container.querySelector("circle.fill-sky-700");
+ expect(fallbackMarker).toBeInTheDocument();
+
+ fireEvent.mouseEnter(fallbackMarker as SVGCircleElement);
+
+ expect(await screen.findByText("Singapore")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /sg-edge/ })).toBeInTheDocument();
+
+ fireEvent.mouseLeave(container.querySelector(".relative") as HTMLElement);
+
+ expect(screen.queryByText("Singapore")).not.toBeInTheDocument();
+ });
+
+ it("clears tooltip state for non-highlighted countries and empty map areas", () => {
+ const filteredFeatures = [
+ mapFeature("US", "United States"),
+ mapFeature("DE", "Germany"),
+ ] as unknown as InteractiveMapProps["filteredFeatures"];
+
+ const { container } = renderMap(
+ ,
+ );
+
+ const [highlightedCountry, neutralCountry] = Array.from(
+ container.querySelectorAll("path"),
+ );
+ fireEvent.mouseEnter(highlightedCountry as SVGPathElement);
+ expect(screen.getByText("United States")).toBeInTheDocument();
+
+ fireEvent.mouseEnter(neutralCountry as SVGPathElement);
+ expect(screen.queryByText("United States")).not.toBeInTheDocument();
+
+ fireEvent.mouseEnter(container.querySelector("rect") as SVGRectElement);
+ expect(screen.queryByText("United States")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/test/components/header.test.tsx b/src/test/components/header.test.tsx
new file mode 100644
index 0000000..435c45e
--- /dev/null
+++ b/src/test/components/header.test.tsx
@@ -0,0 +1,243 @@
+import { QueryClientProvider } from "@tanstack/react-query";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { ReactElement } from "react";
+import { MemoryRouter, useLocation } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import Header, { RefreshToast } from "@/components/Header";
+import { ThemeProvider } from "@/components/ThemeProvider";
+import { CommandProvider } from "@/context/command-provider";
+import { createSettingResponse } from "@/test/fixtures";
+import { createTestQueryClient } from "@/test/utils";
+
+const headerMocks = vi.hoisted(() => ({
+ backgroundImage: undefined as string | undefined,
+ connected: true,
+ fetchLoginUser: vi.fn(),
+ fetchSetting: vi.fn(),
+ lastMessage: null as MessageEvent | null,
+ needReconnect: false,
+ setNeedReconnect: vi.fn(),
+ updateBackground: vi.fn(),
+}));
+
+vi.mock("@numeric-text/react", () => ({
+ default: ({ value }: { value: string | number }) => (
+ {value}
+ ),
+}));
+
+vi.mock("@/hooks/use-background", () => ({
+ useBackground: () => ({
+ backgroundImage: headerMocks.backgroundImage,
+ updateBackground: headerMocks.updateBackground,
+ }),
+}));
+
+vi.mock("@/hooks/use-websocket-context", () => ({
+ useWebSocketContext: () => ({
+ connected: headerMocks.connected,
+ lastMessage: headerMocks.lastMessage,
+ needReconnect: headerMocks.needReconnect,
+ setNeedReconnect: headerMocks.setNeedReconnect,
+ }),
+}));
+
+vi.mock("@/lib/nezha-api", () => ({
+ fetchLoginUser: headerMocks.fetchLoginUser,
+ fetchSetting: headerMocks.fetchSetting,
+}));
+
+function settingResponse(siteName = "Nezha") {
+ return {
+ ...createSettingResponse(),
+ data: {
+ ...createSettingResponse().data,
+ config: {
+ ...createSettingResponse().data.config,
+ site_name: siteName,
+ },
+ },
+ };
+}
+
+function loginResponse() {
+ return {
+ success: true,
+ data: {
+ id: 1,
+ username: "admin",
+ password: "",
+ created_at: "2025-01-01T00:00:00.000Z",
+ updated_at: "2025-01-01T00:00:00.000Z",
+ },
+ };
+}
+
+function LocationProbe() {
+ const location = useLocation();
+ return {location.pathname}
;
+}
+
+function renderHeader(route = "/server/1") {
+ return render(
+
+
+
+
+
+
+
+
+
+ ,
+ );
+}
+
+function renderInRouter(ui: ReactElement) {
+ return render({ui});
+}
+
+describe("Header", () => {
+ beforeEach(() => {
+ headerMocks.backgroundImage = undefined;
+ headerMocks.connected = true;
+ headerMocks.fetchLoginUser.mockReset();
+ headerMocks.fetchSetting.mockReset();
+ headerMocks.lastMessage = new MessageEvent("message", {
+ data: JSON.stringify({ online: 4 }),
+ });
+ headerMocks.needReconnect = false;
+ headerMocks.setNeedReconnect.mockReset();
+ headerMocks.updateBackground.mockReset();
+ Object.assign(window, {
+ CustomBackgroundImage: "",
+ CustomDesc: "",
+ CustomLinks: "",
+ CustomLogo: "",
+ CustomMobileBackgroundImage: "",
+ });
+ headerMocks.fetchSetting.mockResolvedValue(settingResponse());
+ headerMocks.fetchLoginUser.mockRejectedValue(new Error("anonymous"));
+ });
+
+ it("renders configured site identity, custom links, online count, and dashboard state", async () => {
+ const user = userEvent.setup();
+ Object.assign(window, {
+ CustomDesc: "edge status",
+ CustomLinks: JSON.stringify([
+ { link: "https://example.test", name: "Docs" },
+ ]),
+ CustomLogo: "/logo.png",
+ });
+ Object.defineProperty(document, "cookie", {
+ configurable: true,
+ value: "session=1",
+ });
+ sessionStorage.setItem("selectedGroup", "Edge");
+ headerMocks.fetchSetting.mockResolvedValue(settingResponse("Status Hub"));
+ headerMocks.fetchLoginUser.mockResolvedValue(loginResponse());
+
+ renderHeader();
+
+ const siteName = await screen.findByText("Status Hub");
+ expect(screen.getByText("edge status")).toBeInTheDocument();
+ expect(screen.getByAltText("apple-touch-icon")).toHaveAttribute(
+ "src",
+ "/logo.png",
+ );
+ expect(screen.getAllByRole("link", { name: "Docs" })).toHaveLength(2);
+ expect(await screen.findAllByText("dashboard")).toHaveLength(2);
+ expect(screen.getByTestId("numeric-text")).toHaveTextContent("4");
+ expect(screen.getByText("online")).toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(document.title).toBe("Status Hub");
+ });
+ expect(document.querySelector("link[rel='shortcut icon']")).toHaveAttribute(
+ "href",
+ "/logo.png",
+ );
+
+ await user.click(siteName);
+
+ expect(sessionStorage.getItem("selectedGroup")).toBeNull();
+ expect(screen.getByText("/")).toBeInTheDocument();
+ });
+
+ it("uses the offline display and login links when websocket and auth are unavailable", async () => {
+ headerMocks.connected = false;
+
+ const { container } = renderHeader();
+
+ expect(await screen.findAllByText("login")).toHaveLength(2);
+ expect(screen.getByText("offline")).toBeInTheDocument();
+ expect(
+ container.querySelector("[data-visible='true']"),
+ ).toBeInTheDocument();
+ });
+
+ it("stores and removes the active custom background", async () => {
+ const user = userEvent.setup();
+ headerMocks.backgroundImage = "/desktop.png";
+ Object.assign(window, {
+ CustomBackgroundImage: "/desktop.png",
+ CustomMobileBackgroundImage: "/mobile.png",
+ });
+
+ const { container } = renderHeader();
+ await screen.findByText("Nezha");
+
+ const toggleButton = container
+ .querySelector(".lucide-image-minus")
+ ?.closest("button");
+ expect(toggleButton).toBeInTheDocument();
+
+ await user.click(toggleButton as HTMLButtonElement);
+
+ expect(sessionStorage.getItem("savedBackgroundImage")).toBe("/desktop.png");
+ expect(headerMocks.updateBackground).toHaveBeenCalledWith(undefined);
+ });
+
+ it("restores the saved custom background", async () => {
+ const user = userEvent.setup();
+ sessionStorage.setItem("savedBackgroundImage", "/saved.png");
+
+ const { container } = renderHeader();
+ await screen.findByText("Nezha");
+
+ const toggleButton = container
+ .querySelector(".lucide-image-minus")
+ ?.closest("button");
+ expect(toggleButton).toBeInTheDocument();
+
+ await user.click(toggleButton as HTMLButtonElement);
+
+ expect(headerMocks.updateBackground).toHaveBeenCalledWith("/saved.png");
+ });
+});
+
+describe("RefreshToast", () => {
+ beforeEach(() => {
+ headerMocks.needReconnect = false;
+ });
+
+ it("renders only while reconnect refresh is needed", () => {
+ vi.useFakeTimers();
+ sessionStorage.setItem("needRefresh", "true");
+
+ const { container, rerender } = renderInRouter();
+
+ expect(container).toBeEmptyDOMElement();
+
+ headerMocks.needReconnect = true;
+ rerender(
+
+
+ ,
+ );
+
+ expect(screen.getByText("refreshing...")).toBeInTheDocument();
+ expect(sessionStorage.getItem("needRefresh")).toBeNull();
+ });
+});
diff --git a/src/test/components/info-components.test.tsx b/src/test/components/info-components.test.tsx
new file mode 100644
index 0000000..11036c6
--- /dev/null
+++ b/src/test/components/info-components.test.tsx
@@ -0,0 +1,218 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { useEffect } from "react";
+import { MemoryRouter, useLocation } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import BillingInfo from "@/components/billingInfo";
+import MapTooltip from "@/components/MapTooltip";
+import PlanInfo from "@/components/PlanInfo";
+import ServerFlag from "@/components/ServerFlag";
+import { TooltipProvider } from "@/context/tooltip-provider";
+import { useTooltip } from "@/hooks/use-tooltip";
+import type { PublicNoteData } from "@/lib/utils";
+
+const planData: PublicNoteData = {
+ planDataMod: {
+ bandwidth: "1Gbps",
+ trafficVol: "2TB",
+ trafficType: "monthly",
+ IPv4: "1",
+ IPv6: "1",
+ networkRoute: "CN2,CMI",
+ extra: "Premium,Backup",
+ },
+};
+
+function TooltipSeeder() {
+ const { setTooltipData } = useTooltip();
+
+ useEffect(() => {
+ setTooltipData({
+ centroid: [12, 34],
+ country: "China",
+ count: 2,
+ servers: [
+ { id: 1, name: "edge-1", status: true },
+ { id: 2, name: "edge-2", status: false },
+ ],
+ });
+ }, [setTooltipData]);
+
+ return null;
+}
+
+function LocationProbe() {
+ const location = useLocation();
+ return {location.pathname}
;
+}
+
+describe("PlanInfo and BillingInfo", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2025-01-15T00:00:00.000Z"));
+ });
+
+ it("renders plan badges for every supported public note field", () => {
+ render();
+
+ expect(screen.getByText("1Gbps")).toBeInTheDocument();
+ expect(screen.getByText("2TB")).toBeInTheDocument();
+ expect(screen.getByText("IPv4")).toBeInTheDocument();
+ expect(screen.getByText("IPv6")).toBeInTheDocument();
+ expect(screen.getByText("CN2|CMI")).toBeInTheDocument();
+ expect(screen.getByText("Premium")).toBeInTheDocument();
+ expect(screen.getByText("Backup")).toBeInTheDocument();
+ });
+
+ it("renders active, free, usage-based, indefinite, and expired billing states", () => {
+ const { rerender } = render(
+ ,
+ );
+
+ expect(
+ screen.getByText("billingInfo.price: 10/monthly"),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/billingInfo.remaining: 16/)).toBeInTheDocument();
+ expect(screen.getByRole("progressbar")).toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+ expect(screen.getByText("billingInfo.free")).toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+ expect(screen.getByText("billingInfo.usage-baseed")).toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+ expect(screen.getByText(/billingInfo.indefinite/)).toBeInTheDocument();
+
+ rerender(
+ ,
+ );
+ expect(screen.getByText(/billingInfo.expired/)).toBeInTheDocument();
+ });
+});
+
+describe("MapTooltip", () => {
+ it("renders tooltip data and navigates to selected servers", async () => {
+ const user = userEvent.setup();
+ render(
+
+
+
+
+
+
+ ,
+ );
+
+ expect(await screen.findByText("Mainland China")).toBeInTheDocument();
+ expect(screen.getByText("2 map.Servers")).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: /edge-2/ }));
+
+ expect(sessionStorage.getItem("fromMainPage")).toBe("true");
+ expect(screen.getByText("/server/2")).toBeInTheDocument();
+ });
+});
+
+describe("ServerFlag", () => {
+ it("uses SVG flag classes when emoji flags are forced off", () => {
+ Object.assign(window, { ForceUseSvgFlag: true });
+ const { container } = render();
+
+ expect(container.querySelector(".fi-us")).toBeInTheDocument();
+ });
+
+ it("uses emoji flags when the canvas probe detects support", async () => {
+ Object.assign(window, { ForceUseSvgFlag: false });
+ const originalCreateElement = document.createElement.bind(document);
+ const canvasContext = {
+ fillStyle: "",
+ textBaseline: "",
+ font: "",
+ fillText: vi.fn(),
+ getImageData: vi.fn(() => ({
+ data: new Uint8ClampedArray([0, 0, 0, 255]),
+ })),
+ } as unknown as CanvasRenderingContext2D;
+ vi.spyOn(document, "createElement").mockImplementation(
+ (tagName, options) => {
+ if (tagName === "canvas") {
+ return {
+ getContext: vi.fn(() => canvasContext),
+ } as unknown as HTMLCanvasElement;
+ }
+
+ return originalCreateElement(tagName, options);
+ },
+ );
+
+ const { container } = render();
+
+ await waitFor(() => {
+ expect(container).toHaveTextContent("🇺🇸");
+ });
+ expect(container.querySelector(".fi-US")).not.toBeInTheDocument();
+ });
+
+ it("renders nothing for missing country codes", () => {
+ Object.assign(window, { ForceUseSvgFlag: true });
+ const { container } = render();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/src/test/components/navigation-and-footer.test.tsx b/src/test/components/navigation-and-footer.test.tsx
new file mode 100644
index 0000000..ef18832
--- /dev/null
+++ b/src/test/components/navigation-and-footer.test.tsx
@@ -0,0 +1,158 @@
+import { QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { MemoryRouter, useLocation } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import Footer from "@/components/Footer";
+import GroupSwitch from "@/components/GroupSwitch";
+import {
+ ServerDetailChartLoading,
+ ServerDetailLoading,
+} from "@/components/loading/ServerDetailLoading";
+import TabSwitch from "@/components/TabSwitch";
+import { createTestQueryClient, renderWithProviders } from "@/test/utils";
+
+const apiMocks = vi.hoisted(() => ({
+ fetchSetting: vi.fn(),
+}));
+
+vi.mock("@/lib/nezha-api", () => apiMocks);
+
+function LocationProbe() {
+ const location = useLocation();
+ return {location.pathname}
;
+}
+
+describe("Footer", () => {
+ beforeEach(() => {
+ apiMocks.fetchSetting.mockResolvedValue({
+ success: true,
+ data: {
+ config: {
+ debug: false,
+ language: "en-US",
+ site_name: "Nezha",
+ user_template: "",
+ admin_template: "",
+ custom_code: "",
+ },
+ version: "9.9.9",
+ },
+ });
+ });
+
+ it("renders setting version and project attribution", async () => {
+ renderWithProviders();
+
+ expect(screen.getByText("footer.themeBy")).toBeInTheDocument();
+ expect(screen.getByText("nezha-dash")).toHaveAttribute(
+ "href",
+ "https://github.com/hamster1963/nezha-dash",
+ );
+ expect(await screen.findByText("9.9.9")).toBeInTheDocument();
+ });
+});
+
+describe("Tab and group switches", () => {
+ it("switches tabs and marks the active tab", async () => {
+ const user = userEvent.setup();
+ const setCurrentTab = vi.fn();
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByText("tabSwitch.Network"));
+
+ expect(setCurrentTab).toHaveBeenCalledWith("Network");
+ expect(
+ screen.getByText("tabSwitch.Detail").parentElement?.parentElement,
+ ).toHaveClass("text-black");
+ });
+
+ it("restores saved groups and hides itself when only All exists", async () => {
+ const setCurrentTab = vi.fn();
+ sessionStorage.setItem("selectedGroup", "Edge");
+
+ const { container, rerender } = render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(setCurrentTab).toHaveBeenCalledWith("Edge");
+ });
+
+ rerender(
+ ,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("scrolls overflowing group tabs and applies custom background styling", async () => {
+ const user = userEvent.setup();
+ const setCurrentTab = vi.fn();
+ vi.spyOn(HTMLElement.prototype, "scrollWidth", "get").mockReturnValue(320);
+ vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(120);
+ window.CustomBackgroundImage = "/background.jpg";
+
+ const { container } = render(
+ ,
+ );
+
+ const scrollContainer = container.querySelector(
+ ".scrollbar-hidden",
+ ) as HTMLElement;
+
+ fireEvent.wheel(scrollContainer, { deltaY: 42 });
+ await user.click(screen.getByText("Asia"));
+
+ expect(scrollContainer.scrollLeft).toBe(42);
+ expect(setCurrentTab).toHaveBeenCalledWith("Asia");
+ expect(
+ container.querySelector(".relative.flex.items-center")?.className,
+ ).toContain("bg-stone-100/70");
+ });
+});
+
+describe("server detail loading states", () => {
+ it("renders chart skeleton cards", () => {
+ const { container } = render();
+
+ expect(container.querySelectorAll(".h-\\[182px\\]")).toHaveLength(6);
+ });
+
+ it("navigates home from the detail loading back affordance", async () => {
+ const user = userEvent.setup();
+ const queryClient = createTestQueryClient();
+
+ render(
+
+
+
+
+
+ ,
+ );
+
+ await user.click(screen.getAllByAltText("BackIcon")[0]);
+
+ expect(screen.getByText("/")).toBeInTheDocument();
+ });
+});
diff --git a/src/test/components/network-chart.test.tsx b/src/test/components/network-chart.test.tsx
new file mode 100644
index 0000000..7c3c677
--- /dev/null
+++ b/src/test/components/network-chart.test.tsx
@@ -0,0 +1,287 @@
+import { QueryClientProvider } from "@tanstack/react-query";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { ReactElement, ReactNode } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { NetworkChart, NetworkChartClient } from "@/components/NetworkChart";
+import type { ChartConfig } from "@/components/ui/chart";
+import { createTestQueryClient } from "@/test/utils";
+import type { NezhaMonitor, ServerMonitorChart } from "@/types/nezha-api";
+
+const apiMocks = vi.hoisted(() => ({
+ fetchLoginUser: vi.fn(),
+ fetchMonitor: vi.fn(),
+}));
+
+vi.mock("@/lib/nezha-api", () => ({
+ fetchLoginUser: apiMocks.fetchLoginUser,
+ fetchMonitor: apiMocks.fetchMonitor,
+}));
+
+vi.mock("recharts", () => {
+ const createElement =
+ (testId: string) =>
+ ({
+ children,
+ data,
+ dataKey,
+ }: {
+ children?: ReactNode;
+ data?: unknown[];
+ dataKey?: string;
+ }) => (
+
+ {children}
+
+ );
+
+ const ComposedChart = createElement("composed-chart");
+ const genericChart = createElement("generic-chart");
+
+ return {
+ Area: createElement("area"),
+ AreaChart: genericChart,
+ BarChart: genericChart,
+ CartesianGrid: createElement("grid"),
+ ComposedChart,
+ FunnelChart: genericChart,
+ Legend: ({ content }: { content?: ReactNode }) => (
+ {content}
+ ),
+ Line: createElement("line"),
+ LineChart: genericChart,
+ PieChart: genericChart,
+ RadarChart: genericChart,
+ RadialBarChart: genericChart,
+ ResponsiveContainer: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
+ Sankey: genericChart,
+ ScatterChart: genericChart,
+ Tooltip: ({ content }: { content?: ReactNode }) => (
+ {content}
+ ),
+ Treemap: genericChart,
+ XAxis: createElement("x-axis"),
+ YAxis: createElement("y-axis"),
+ };
+});
+
+const times = Array.from(
+ { length: 12 },
+ (_, index) => Date.parse("2025-01-01T00:00:00.000Z") + index * 60 * 60 * 1000,
+);
+
+const monitorData: NezhaMonitor[] = [
+ {
+ monitor_id: 2,
+ monitor_name: "Beta",
+ display_index: 1,
+ server_id: 7,
+ server_name: "edge-chart",
+ created_at: times,
+ avg_delay: [15, 18, 0, 45, 48, 52, 4000, 65, 70, 75, 80, 85],
+ },
+ {
+ monitor_id: 1,
+ monitor_name: "Alpha",
+ display_index: 3,
+ server_id: 7,
+ server_name: "edge-chart",
+ created_at: times,
+ avg_delay: [30, 32, 35, 36, 38, 40, 42, 44, 46, 48, 50, 52],
+ packet_loss: [0, 0, 1, 1, 2, 2, 2, 3, 3, 4, 4, 5],
+ },
+];
+
+const clientChartData: ServerMonitorChart = {
+ Alpha: times.map((created_at, index) => ({
+ created_at,
+ avg_delay: 30 + index,
+ packet_loss: index,
+ })),
+ Beta: times.map((created_at, index) => ({
+ created_at,
+ avg_delay: 60 + index,
+ packet_loss: index % 2,
+ })),
+};
+
+const clientFormattedData = times.map((created_at, index) => ({
+ created_at,
+ Alpha: 30 + index,
+ Alpha_packet_loss: index,
+ Beta: 60 + index,
+ Beta_packet_loss: index % 2,
+}));
+
+const chartConfig = {
+ avg_delay: { label: "monitor.avgDelay" },
+ Alpha: { label: "Alpha" },
+ Beta: { label: "Beta" },
+} satisfies ChartConfig;
+
+function loginResponse() {
+ return {
+ success: true,
+ data: {
+ id: 1,
+ username: "admin",
+ password: "",
+ created_at: "2025-01-01T00:00:00.000Z",
+ updated_at: "2025-01-01T00:00:00.000Z",
+ },
+ };
+}
+
+function renderWithQuery(ui: ReactElement) {
+ return render(
+
+ {ui}
+ ,
+ );
+}
+
+describe("NetworkChart", () => {
+ beforeEach(() => {
+ apiMocks.fetchLoginUser.mockReset();
+ apiMocks.fetchMonitor.mockReset();
+ apiMocks.fetchLoginUser.mockRejectedValue(new Error("anonymous"));
+ Object.defineProperty(document, "cookie", {
+ configurable: true,
+ value: "",
+ });
+ });
+
+ it("renders the loading state while monitor data is unavailable", () => {
+ apiMocks.fetchMonitor.mockReturnValue(new Promise(() => undefined));
+
+ const { container } = renderWithQuery(
+ ,
+ );
+
+ expect(container.querySelector(".h-\\[250px\\]")).toBeInTheDocument();
+ expect(apiMocks.fetchMonitor).not.toHaveBeenCalled();
+ });
+
+ it("renders the no-data state from the monitor API", async () => {
+ apiMocks.fetchMonitor.mockResolvedValue({
+ success: true,
+ data: null,
+ });
+
+ renderWithQuery();
+
+ expect(await screen.findByText("monitor.noData")).toBeInTheDocument();
+ });
+
+ it("fetches monitor data, transforms chart series, and allows logged-in period changes", async () => {
+ const user = userEvent.setup();
+ Object.defineProperty(document, "cookie", {
+ configurable: true,
+ value: "session=1",
+ });
+ apiMocks.fetchLoginUser.mockResolvedValue(loginResponse());
+ apiMocks.fetchMonitor.mockResolvedValue({
+ success: true,
+ data: monitorData,
+ });
+
+ renderWithQuery();
+
+ expect(await screen.findByText("edge-chart")).toBeInTheDocument();
+ expect(apiMocks.fetchMonitor).toHaveBeenCalledWith(7, "1d");
+ expect(screen.getByText("2 monitor.monitorCount")).toBeInTheDocument();
+ expect(screen.getByText("Alpha")).toBeInTheDocument();
+ expect(screen.getByText("Beta")).toBeInTheDocument();
+ expect(screen.getByTestId("composed-chart")).toHaveAttribute(
+ "data-points",
+ "12",
+ );
+
+ await user.click(screen.getByText("monitor.period7d"));
+
+ await waitFor(() => {
+ expect(apiMocks.fetchMonitor).toHaveBeenCalledWith(7, "7d");
+ });
+ });
+});
+
+describe("NetworkChartClient", () => {
+ it("locks longer periods for anonymous users and manages chart selection state", async () => {
+ const user = userEvent.setup();
+ const onPeriodChange = vi.fn();
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText("edge-client")).toBeInTheDocument();
+ expect(screen.getByText("2 monitor.monitorCount")).toBeInTheDocument();
+
+ await user.click(screen.getByText("monitor.period7d"));
+ expect(onPeriodChange).not.toHaveBeenCalled();
+
+ await user.click(screen.getByText("Alpha"));
+ expect(
+ screen.getByRole("button", { name: /monitor.clearSelections/ }),
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("area")).toHaveAttribute(
+ "data-key",
+ "packet_loss",
+ );
+
+ await user.click(screen.getByText("Beta"));
+ expect(screen.queryByTestId("area")).not.toBeInTheDocument();
+
+ await user.click(
+ screen.getByRole("button", { name: /monitor.clearSelections/ }),
+ );
+ expect(
+ screen.queryByRole("button", { name: /monitor.clearSelections/ }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows period loading and honors the forced peak-cut global", async () => {
+ const user = userEvent.setup();
+ Object.assign(window, { ForcePeakCutEnabled: true });
+ const onPeriodChange = vi.fn();
+
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector(".opacity-60")).toBeInTheDocument();
+ expect(
+ screen.getByRole("switch", { name: "monitor.peakCut" }),
+ ).toHaveAttribute("data-state", "checked");
+
+ await user.click(screen.getByText("monitor.period30d"));
+ expect(onPeriodChange).toHaveBeenCalledWith("30d");
+
+ await user.click(screen.getByRole("switch", { name: "monitor.peakCut" }));
+ expect(
+ screen.getByRole("switch", { name: "monitor.peakCut" }),
+ ).toHaveAttribute("data-state", "unchecked");
+ });
+});
diff --git a/src/test/components/server-cards.test.tsx b/src/test/components/server-cards.test.tsx
new file mode 100644
index 0000000..45de958
--- /dev/null
+++ b/src/test/components/server-cards.test.tsx
@@ -0,0 +1,148 @@
+import { fireEvent, screen } from "@testing-library/react";
+import { useLocation } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import ServerCard from "@/components/ServerCard";
+import ServerCardInline from "@/components/ServerCardInline";
+import { createServer } from "@/test/fixtures";
+import { renderWithProviders } from "@/test/utils";
+
+const publicNote = JSON.stringify({
+ billingDataMod: {
+ startDate: "2025-01-01T00:00:00.000Z",
+ endDate: "2025-01-31T00:00:00.000Z",
+ autoRenewal: "0",
+ cycle: "monthly",
+ amount: "10",
+ },
+ planDataMod: {
+ bandwidth: "1Gbps",
+ trafficVol: "2TB",
+ trafficType: "monthly",
+ IPv4: "1",
+ IPv6: "1",
+ networkRoute: "CN2,CMI",
+ extra: "Premium",
+ },
+});
+
+function LocationProbe() {
+ const location = useLocation();
+ return {location.pathname}
;
+}
+
+describe("ServerCard", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2025-01-15T00:00:00.000Z"));
+ Object.assign(window, {
+ ForceUseSvgFlag: true,
+ FixedTopServerName: true,
+ ShowNetTransfer: true,
+ });
+ });
+
+ it("renders online server metrics, billing, plan data, and navigates on click", async () => {
+ const server = createServer({
+ id: 7,
+ name: "edge-online",
+ public_note: publicNote,
+ host: { platform: "Windows Server" },
+ });
+
+ renderWithProviders(
+ <>
+
+
+ >,
+ );
+
+ expect(screen.getByText("edge-online")).toBeInTheDocument();
+ expect(screen.getByText("Windows")).toBeInTheDocument();
+ expect(screen.getByText("12.00%")).toBeInTheDocument();
+ expect(screen.getAllByText("25.00%")).toHaveLength(2);
+ expect(screen.getByText("serverCard.upload:2.00 GiB")).toBeInTheDocument();
+ expect(
+ screen.getByText("serverCard.download:1.00 GiB"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("1Gbps")).toBeInTheDocument();
+ expect(
+ screen.getAllByText(/billingInfo.remaining: 16/).length,
+ ).toBeGreaterThan(0);
+
+ fireEvent.click(screen.getByText("edge-online"));
+
+ expect(sessionStorage.getItem("fromMainPage")).toBe("true");
+ expect(screen.getByText("/server/7")).toBeInTheDocument();
+ });
+
+ it("renders a compact offline card without live metric blocks", () => {
+ const server = createServer({
+ id: 8,
+ name: "edge-offline",
+ public_note: publicNote,
+ last_active: "2024-12-31T23:00:00.000Z",
+ });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("edge-offline")).toBeInTheDocument();
+ expect(screen.getByText("1Gbps")).toBeInTheDocument();
+ expect(screen.queryByText("CPU")).not.toBeInTheDocument();
+ });
+});
+
+describe("ServerCardInline", () => {
+ beforeEach(() => {
+ Object.assign(window, { ForceUseSvgFlag: true });
+ });
+
+ it("renders online inline server detail columns", () => {
+ const server = createServer({
+ id: 9,
+ name: "edge-inline",
+ public_note: publicNote,
+ state: { uptime: 2 * 86_400 },
+ });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("edge-inline")).toBeInTheDocument();
+ expect(screen.getByText("serverCard.system")).toBeInTheDocument();
+ expect(screen.getByText("2 serverCard.days")).toBeInTheDocument();
+ expect(screen.getByText("2.00 GiB")).toBeInTheDocument();
+ expect(screen.getByText("1.00 GiB")).toBeInTheDocument();
+ });
+
+ it("renders offline inline server cards with saved plan data", () => {
+ const server = createServer({
+ id: 10,
+ name: "edge-inline-offline",
+ public_note: publicNote,
+ last_active: "2024-12-31T23:00:00.000Z",
+ });
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("edge-inline-offline")).toBeInTheDocument();
+ expect(screen.getByText("1Gbps")).toBeInTheDocument();
+ expect(screen.queryByText("serverCard.system")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/test/components/server-detail-chart.test.tsx b/src/test/components/server-detail-chart.test.tsx
new file mode 100644
index 0000000..29b3bb0
--- /dev/null
+++ b/src/test/components/server-detail-chart.test.tsx
@@ -0,0 +1,290 @@
+import { QueryClientProvider } from "@tanstack/react-query";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { ReactNode } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import ServerDetailChart from "@/components/ServerDetailChart";
+import { createServer, createSettingResponse } from "@/test/fixtures";
+import { createTestQueryClient } from "@/test/utils";
+import type { NezhaServer } from "@/types/nezha-api";
+
+const detailChartMocks = vi.hoisted(() => ({
+ connected: true,
+ fetchLoginUser: vi.fn(),
+ fetchServerMetrics: vi.fn(),
+ fetchSetting: vi.fn(),
+ lastMessage: null as { data: string } | null,
+ messageHistory: [] as { data: string }[],
+}));
+
+vi.mock("recharts", () => {
+ const createElement =
+ (testId: string) =>
+ ({
+ children,
+ data,
+ dataKey,
+ }: {
+ children?: ReactNode;
+ data?: unknown[];
+ dataKey?: string;
+ }) => (
+
+ {children}
+
+ );
+
+ const AreaChart = createElement("area-chart");
+ const LineChart = createElement("line-chart");
+ const genericChart = createElement("generic-chart");
+
+ return {
+ Area: createElement("area"),
+ AreaChart,
+ BarChart: genericChart,
+ CartesianGrid: createElement("grid"),
+ ComposedChart: genericChart,
+ FunnelChart: genericChart,
+ Legend: ({ content }: { content?: ReactNode }) => (
+ {content}
+ ),
+ Line: createElement("line"),
+ LineChart,
+ PieChart: genericChart,
+ RadarChart: genericChart,
+ RadialBarChart: genericChart,
+ ResponsiveContainer: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
+ Sankey: genericChart,
+ ScatterChart: genericChart,
+ Tooltip: ({ content }: { content?: ReactNode }) => (
+ {content}
+ ),
+ Treemap: genericChart,
+ XAxis: createElement("x-axis"),
+ YAxis: createElement("y-axis"),
+ };
+});
+
+vi.mock("@/hooks/use-websocket-context", () => ({
+ useWebSocketContext: () => ({
+ connected: detailChartMocks.connected,
+ lastMessage: detailChartMocks.lastMessage,
+ messageHistory: detailChartMocks.messageHistory,
+ }),
+}));
+
+vi.mock("@/lib/nezha-api", () => ({
+ fetchLoginUser: detailChartMocks.fetchLoginUser,
+ fetchServerMetrics: detailChartMocks.fetchServerMetrics,
+ fetchSetting: detailChartMocks.fetchSetting,
+}));
+
+function settingResponse(tsdbEnabled = true) {
+ return {
+ ...createSettingResponse(),
+ data: {
+ ...createSettingResponse().data,
+ tsdb_enabled: tsdbEnabled,
+ },
+ };
+}
+
+function loginResponse() {
+ return {
+ success: true,
+ data: {
+ id: 1,
+ username: "admin",
+ password: "",
+ created_at: "2025-01-01T00:00:00.000Z",
+ updated_at: "2025-01-01T00:00:00.000Z",
+ },
+ };
+}
+
+function metricsResponse(metric: string) {
+ return {
+ success: true,
+ data: {
+ server_id: 7,
+ server_name: "edge-chart-detail",
+ metric,
+ data_points: [
+ { ts: Date.parse("2025-01-01T00:00:00.000Z"), value: 10 },
+ { ts: Date.parse("2025-01-01T01:00:00.000Z"), value: 20 },
+ { ts: Date.parse("2025-01-01T02:00:00.000Z"), value: 30 },
+ ],
+ },
+ };
+}
+
+function websocketPayload(server: NezhaServer, now: number) {
+ return {
+ data: JSON.stringify({
+ now,
+ servers: [server],
+ }),
+ };
+}
+
+function renderWithQuery(ui: React.ReactElement) {
+ return render(
+
+ {ui}
+ ,
+ );
+}
+
+function seedWebSocketData() {
+ const baseNow = Date.parse("2025-01-01T00:00:20.000Z");
+ const server = createServer({
+ id: 7,
+ name: "edge-chart-detail",
+ host: {
+ gpu: ["NVIDIA T4"],
+ },
+ state: {
+ cpu: 45,
+ disk_used: 180,
+ gpu: [33],
+ mem_used: 80,
+ net_in_speed: 4 * 1024 ** 2,
+ net_out_speed: 3 * 1024 ** 2,
+ process_count: 77,
+ swap_used: 25,
+ tcp_conn_count: 18,
+ udp_conn_count: 9,
+ },
+ });
+
+ detailChartMocks.connected = true;
+ detailChartMocks.lastMessage = websocketPayload(server, baseNow);
+ detailChartMocks.messageHistory = [0, 1, 2].map((index) =>
+ websocketPayload(
+ createServer({
+ id: 7,
+ host: {
+ gpu: ["NVIDIA T4"],
+ },
+ state: {
+ cpu: 30 + index,
+ disk_used: 100 + index * 10,
+ gpu: [20 + index],
+ mem_used: 50 + index * 5,
+ net_in_speed: (1 + index) * 1024 ** 2,
+ net_out_speed: (2 + index) * 1024 ** 2,
+ process_count: 60 + index,
+ swap_used: 10 + index,
+ tcp_conn_count: 10 + index,
+ udp_conn_count: 5 + index,
+ },
+ }),
+ baseNow - index * 1000,
+ ),
+ );
+}
+
+describe("ServerDetailChart", () => {
+ beforeEach(() => {
+ detailChartMocks.connected = true;
+ detailChartMocks.fetchLoginUser.mockReset();
+ detailChartMocks.fetchServerMetrics.mockReset();
+ detailChartMocks.fetchSetting.mockReset();
+ detailChartMocks.lastMessage = null;
+ detailChartMocks.messageHistory = [];
+ detailChartMocks.fetchLoginUser.mockRejectedValue(new Error("anonymous"));
+ detailChartMocks.fetchSetting.mockResolvedValue(settingResponse());
+ });
+
+ it("renders the loading grid without websocket data", () => {
+ detailChartMocks.connected = false;
+
+ const { container } = renderWithQuery();
+
+ expect(container.querySelectorAll(".h-\\[182px\\]")).toHaveLength(6);
+ });
+
+ it("renders realtime resource, network, connection, and GPU charts", async () => {
+ const user = userEvent.setup();
+ seedWebSocketData();
+
+ renderWithQuery();
+
+ expect(
+ await screen.findByText("serverDetailChart.realtime"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("serverDetailChart.period1d")).toBeInTheDocument();
+ expect(screen.getByText("serverDetailChart.period7d")).toBeInTheDocument();
+ expect(screen.getByText("CPU")).toBeInTheDocument();
+ expect(screen.getByText("GPU: NVIDIA T4")).toBeInTheDocument();
+ expect(screen.getByText("serverDetailChart.mem")).toBeInTheDocument();
+ expect(screen.getByText("serverDetailChart.swap")).toBeInTheDocument();
+ expect(screen.getByText("serverDetailChart.disk")).toBeInTheDocument();
+ expect(screen.getByText("serverDetailChart.process")).toBeInTheDocument();
+ expect(screen.getByText("serverDetailChart.upload")).toBeInTheDocument();
+ expect(screen.getByText("serverDetailChart.download")).toBeInTheDocument();
+ expect(screen.getByText("TCP")).toBeInTheDocument();
+ expect(screen.getByText("UDP")).toBeInTheDocument();
+ expect(screen.getAllByTestId("area-chart").length).toBeGreaterThan(0);
+ expect(screen.getAllByTestId("line-chart").length).toBeGreaterThan(0);
+
+ await user.click(screen.getByText("serverDetailChart.period7d"));
+
+ expect(detailChartMocks.fetchServerMetrics).not.toHaveBeenCalled();
+ });
+
+ it("prevents historical periods when TSDB is disabled", async () => {
+ const user = userEvent.setup();
+ seedWebSocketData();
+ detailChartMocks.fetchSetting.mockResolvedValue(settingResponse(false));
+
+ renderWithQuery();
+
+ await screen.findByText("serverDetailChart.realtime");
+ await user.click(screen.getByText("serverDetailChart.period1d"));
+
+ expect(detailChartMocks.fetchServerMetrics).not.toHaveBeenCalled();
+ });
+
+ it("fetches every historical metric group for the selected period", async () => {
+ const user = userEvent.setup();
+ seedWebSocketData();
+ Object.defineProperty(document, "cookie", {
+ configurable: true,
+ value: "session=1",
+ });
+ detailChartMocks.fetchLoginUser.mockResolvedValue(loginResponse());
+ detailChartMocks.fetchServerMetrics.mockImplementation(
+ (_serverId: number, metric: string) =>
+ Promise.resolve(metricsResponse(metric)),
+ );
+
+ renderWithQuery();
+
+ await screen.findByText("serverDetailChart.realtime");
+ await user.click(screen.getByText("serverDetailChart.period1d"));
+
+ for (const metric of [
+ "cpu",
+ "gpu",
+ "memory",
+ "swap",
+ "disk",
+ "process_count",
+ "net_out_speed",
+ "net_in_speed",
+ "tcp_conn",
+ "udp_conn",
+ ]) {
+ await waitFor(() => {
+ expect(detailChartMocks.fetchServerMetrics).toHaveBeenCalledWith(
+ 7,
+ metric,
+ "1d",
+ );
+ });
+ }
+ });
+});
diff --git a/src/test/components/server-detail-components.test.tsx b/src/test/components/server-detail-components.test.tsx
new file mode 100644
index 0000000..8b6d526
--- /dev/null
+++ b/src/test/components/server-detail-components.test.tsx
@@ -0,0 +1,138 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { MemoryRouter, useLocation } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import ServerDetailOverview from "@/components/ServerDetailOverview";
+import ServerDetailSummary from "@/components/ServerDetailSummary";
+import { createServer } from "@/test/fixtures";
+
+const websocketMocks = vi.hoisted(() => ({
+ connected: true,
+ lastMessage: null as MessageEvent | null,
+}));
+
+vi.mock("@/hooks/use-websocket-context", () => ({
+ useWebSocketContext: () => websocketMocks,
+}));
+
+function seedWebSocketData({
+ server = createServer(),
+ now = Date.parse("2025-01-01T00:00:20.000Z"),
+} = {}) {
+ websocketMocks.connected = true;
+ websocketMocks.lastMessage = new MessageEvent("message", {
+ data: JSON.stringify({
+ now,
+ servers: [server],
+ }),
+ });
+}
+
+function LocationProbe() {
+ const location = useLocation();
+ return {location.pathname}
;
+}
+
+describe("ServerDetailSummary", () => {
+ beforeEach(() => {
+ websocketMocks.connected = true;
+ websocketMocks.lastMessage = null;
+ });
+
+ it("renders nothing until websocket data exists", () => {
+ websocketMocks.connected = false;
+
+ const { container } = render();
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders resource, network, and connection summaries for the selected server", () => {
+ seedWebSocketData();
+
+ render();
+
+ expect(screen.getByText("12.00%")).toBeInTheDocument();
+ expect(screen.getAllByText("25.00%")).toHaveLength(2);
+ expect(screen.getByText("Process")).toBeInTheDocument();
+ expect(screen.getByText("88")).toBeInTheDocument();
+ expect(screen.getByText("TCP")).toBeInTheDocument();
+ expect(screen.getByText("8")).toBeInTheDocument();
+ expect(screen.getByText("UDP")).toBeInTheDocument();
+ expect(screen.getByText("4")).toBeInTheDocument();
+ expect(screen.getByText("1.00M/s")).toBeInTheDocument();
+ expect(screen.getByText("2.00M/s")).toBeInTheDocument();
+ });
+});
+
+describe("ServerDetailOverview", () => {
+ beforeEach(() => {
+ websocketMocks.connected = true;
+ websocketMocks.lastMessage = null;
+ Object.assign(window, { ForceUseSvgFlag: true });
+ });
+
+ it("shows the loading shell when websocket data or the selected server is missing", () => {
+ websocketMocks.connected = false;
+ const { rerender } = render(
+
+
+ ,
+ );
+
+ expect(screen.getAllByAltText("BackIcon").length).toBeGreaterThan(0);
+
+ seedWebSocketData();
+ rerender(
+
+
+ ,
+ );
+
+ expect(screen.getAllByAltText("BackIcon").length).toBeGreaterThan(0);
+ });
+
+ it("renders server identity, hardware, traffic, and temperature details", async () => {
+ const user = userEvent.setup();
+ seedWebSocketData({
+ server: createServer({
+ id: 7,
+ name: "edge-detail",
+ country_code: "us",
+ host: {
+ gpu: ["NVIDIA T4"],
+ },
+ state: {
+ temperatures: [{ Name: "CPU Core", Temperature: 55.5 }],
+ },
+ }),
+ });
+
+ render(
+
+
+
+ ,
+ );
+
+ expect(screen.getByText("edge-detail")).toBeInTheDocument();
+ expect(screen.getByText("serverDetail.online")).toBeInTheDocument();
+ expect(screen.getByText("1.0.0")).toBeInTheDocument();
+ expect(screen.getByText("amd64")).toBeInTheDocument();
+ expect(screen.getByText("US")).toBeInTheDocument();
+ expect(screen.getByText(/linux - 6.8/)).toBeInTheDocument();
+ expect(screen.getByText(/AMD EPYC/)).toBeInTheDocument();
+ expect(screen.getByText("NVIDIA T4")).toBeInTheDocument();
+ expect(screen.getByText("2.00 GiB")).toBeInTheDocument();
+ expect(screen.getByText("1.00 GiB")).toBeInTheDocument();
+
+ await user.click(
+ screen.getByRole("button", { name: /serverDetail.temperature/ }),
+ );
+ expect(screen.getByText("CPU Core")).toBeInTheDocument();
+ expect(screen.getByText(/55.50 °C/)).toBeInTheDocument();
+
+ await user.click(screen.getByText("edge-detail"));
+ expect(screen.getByText("/")).toBeInTheDocument();
+ });
+});
diff --git a/src/test/components/service-tracker.test.tsx b/src/test/components/service-tracker.test.tsx
new file mode 100644
index 0000000..87c8aee
--- /dev/null
+++ b/src/test/components/service-tracker.test.tsx
@@ -0,0 +1,249 @@
+import { QueryClientProvider } from "@tanstack/react-query";
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { CycleTransferStatsCard } from "@/components/CycleTransferStats";
+import CycleTransferStatsClient from "@/components/CycleTransferStatsClient";
+import { ServiceTracker } from "@/components/ServiceTracker";
+import ServiceTrackerClient from "@/components/ServiceTrackerClient";
+import { createServer } from "@/test/fixtures";
+import { createTestQueryClient } from "@/test/utils";
+
+const apiMocks = vi.hoisted(() => ({
+ fetchService: vi.fn(),
+}));
+
+vi.mock("@/lib/nezha-api", () => apiMocks);
+
+function renderWithQuery(ui: React.ReactElement) {
+ return render(
+
+ {ui}
+ ,
+ );
+}
+
+describe("ServiceTracker", () => {
+ it("shows the loading state while service data is pending", () => {
+ apiMocks.fetchService.mockReturnValue(new Promise(() => undefined));
+
+ renderWithQuery();
+
+ expect(screen.getByText("serviceTracker.loading")).toBeInTheDocument();
+ });
+
+ it("shows an empty state when there are no services or cycle stats", async () => {
+ apiMocks.fetchService.mockResolvedValue({
+ success: true,
+ data: {},
+ });
+
+ renderWithQuery();
+
+ expect(
+ await screen.findByText("serviceTracker.noService"),
+ ).toBeInTheDocument();
+ });
+
+ it("renders processed service uptime, delay, and matching cycle transfer stats", async () => {
+ apiMocks.fetchService.mockResolvedValue({
+ success: true,
+ data: {
+ services: {
+ http: {
+ service_name: "HTTP Ping",
+ current_up: 0,
+ current_down: 0,
+ total_up: 5,
+ total_down: 3,
+ delay: [80, 120, 450],
+ up: [3, 2, 0],
+ down: [0, 1, 2],
+ },
+ },
+ cycle_transfer_stats: {
+ monthly: {
+ name: "Monthly",
+ from: "2025-01-01T00:00:00.000Z",
+ to: "2025-02-01T00:00:00.000Z",
+ max: 10 * 1024,
+ min: 0,
+ server_name: {
+ "1": "edge-1",
+ "2": "hidden-server",
+ },
+ transfer: {
+ "1": 5 * 1024,
+ "2": 1024,
+ },
+ next_update: {
+ "1": "2025-01-15T12:00:00.000Z",
+ "2": "2025-01-15T12:00:00.000Z",
+ },
+ },
+ },
+ },
+ });
+
+ renderWithQuery();
+
+ expect(await screen.findByText("HTTP Ping")).toBeInTheDocument();
+ expect(screen.getByText("217ms")).toBeInTheDocument();
+ expect(screen.getByText("62.5% serviceTracker.uptime")).toBeInTheDocument();
+ expect(screen.getByText("edge-1")).toBeInTheDocument();
+ expect(screen.getByText("Monthly")).toBeInTheDocument();
+ expect(screen.queryByText("hidden-server")).not.toBeInTheDocument();
+ });
+});
+
+describe("CycleTransferStatsCard", () => {
+ it("renders nothing for empty server lists", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("supports per-server date and max maps while skipping incomplete entries", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("edge-7")).toBeInTheDocument();
+ expect(screen.getByText("Mapped")).toBeInTheDocument();
+ expect(screen.queryByText("edge-8")).not.toBeInTheDocument();
+ expect(screen.queryByText("missing-fields")).not.toBeInTheDocument();
+ });
+});
+
+describe("ServiceTrackerClient and CycleTransferStatsClient", () => {
+ it("uses uptime and delay severity colors for service summaries", () => {
+ const days = [
+ {
+ completed: true,
+ date: new Date("2025-01-01T00:00:00.000Z"),
+ uptime: 99.5,
+ delay: 80,
+ },
+ {
+ completed: false,
+ date: new Date("2025-01-02T00:00:00.000Z"),
+ uptime: 90,
+ delay: 320,
+ },
+ ];
+
+ const { rerender } = render(
+ ,
+ );
+
+ expect(screen.getByText("99.9% serviceTracker.uptime")).toHaveClass(
+ "text-emerald-500",
+ );
+ expect(screen.getByText("80ms")).toHaveClass("text-emerald-500");
+
+ rerender(
+ ,
+ );
+
+ expect(screen.getByText("97.0% serviceTracker.uptime")).toHaveClass(
+ "text-amber-500",
+ );
+ expect(screen.getByText("180ms")).toHaveClass("text-amber-500");
+
+ rerender(
+ ,
+ );
+
+ expect(screen.getByText("94.0% serviceTracker.uptime")).toHaveClass(
+ "text-rose-500",
+ );
+ expect(screen.getByText("320ms")).toHaveClass("text-rose-500");
+ });
+
+ it("caps cycle transfer progress at one hundred percent", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(screen.getByText("200.0%")).toBeInTheDocument();
+ expect(
+ container.querySelector('[style="width: 100%;"]'),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/test/components/switchers-and-primitives.test.tsx b/src/test/components/switchers-and-primitives.test.tsx
new file mode 100644
index 0000000..efeec97
--- /dev/null
+++ b/src/test/components/switchers-and-primitives.test.tsx
@@ -0,0 +1,291 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { LanguageSwitcher } from "@/components/LanguageSwitcher";
+import { ThemeProvider } from "@/components/ThemeProvider";
+import { ModeToggle } from "@/components/ThemeSwitcher";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Label } from "@/components/ui/label";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import {
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableFooter,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+describe("language and theme switchers", () => {
+ it("opens the language menu and marks the active locale", async () => {
+ const user = userEvent.setup();
+
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Change language" }));
+
+ expect(screen.getByText("language.zh-CN")).toBeInTheDocument();
+ expect(screen.getByText("language.en-US")).toHaveClass("font-semibold");
+ });
+
+ it("updates the theme through the mode menu", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Toggle theme" }));
+ await user.click(await screen.findByText("theme.dark"));
+
+ expect(document.documentElement).toHaveClass("dark");
+ expect(localStorage.getItem("theme-switcher-test")).toBe("dark");
+ });
+});
+
+describe("Radix UI wrappers", () => {
+ it("opens accordion, popover, tooltip, and dialog content", async () => {
+ const user = userEvent.setup();
+
+ render(
+ <>
+
+
+ Toggle status
+ Status body
+
+
+
+ Open popover
+ Popover body
+
+
+
+ Hover status
+ Tooltip body
+
+
+
+ >,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Toggle status" }));
+ expect(screen.getByText("Status body")).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "Open popover" }));
+ expect(screen.getByText("Popover body")).toBeInTheDocument();
+
+ await user.hover(screen.getByText("Hover status"));
+ expect(await screen.findByRole("tooltip")).toHaveTextContent(
+ "Tooltip body",
+ );
+
+ await user.click(screen.getByRole("button", { name: "Open dialog" }));
+ expect(screen.getByText("Confirm action")).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "Done" }));
+ await waitFor(() => {
+ expect(screen.queryByText("Confirm action")).not.toBeInTheDocument();
+ });
+ });
+
+ it("toggles checkbox and switch controls through labels", async () => {
+ const user = userEvent.setup();
+ const onCheckboxChange = vi.fn();
+ const onSwitchChange = vi.fn();
+
+ render(
+ <>
+
+
+
+ >,
+ );
+
+ await user.click(screen.getByText("Alerts"));
+ await user.click(screen.getByRole("switch", { name: "Compact mode" }));
+
+ expect(onCheckboxChange).toHaveBeenCalledWith(true);
+ expect(onSwitchChange).toHaveBeenCalledWith(true);
+ expect(screen.getByRole("checkbox")).toHaveAttribute(
+ "data-state",
+ "checked",
+ );
+ expect(
+ screen.getByRole("switch", { name: "Compact mode" }),
+ ).toHaveAttribute("data-state", "checked");
+ });
+
+ it("renders dropdown labels, indicators, shortcuts, and submenus", async () => {
+ render(
+
+ Open actions
+
+ Actions
+
+
+ Copy
+ Ctrl+C
+
+
+ Show hidden
+
+
+
+ Compact rows
+
+
+
+
+
+ More actions
+
+
+ Archive
+
+
+
+
+ ,
+ );
+
+ expect(await screen.findByText("Actions")).toHaveClass("pl-8");
+ expect(screen.getByText("Copy")).toHaveClass("pl-8");
+ expect(screen.getByText("Ctrl+C")).toHaveClass("tracking-widest");
+ expect(screen.getByText("Show hidden")).toHaveAttribute(
+ "data-state",
+ "checked",
+ );
+ expect(screen.getByText("Compact rows")).toHaveAttribute(
+ "data-state",
+ "checked",
+ );
+ expect(screen.getByText("More actions")).toHaveClass("pl-8");
+ expect(screen.getByText("Archive")).toBeInTheDocument();
+ });
+
+ it("selects values and renders table structure", async () => {
+ const user = userEvent.setup();
+ const onValueChange = vi.fn();
+
+ render(
+ <>
+
+
+ Server summary
+
+
+ Name
+ Status
+
+
+
+
+ edge-1
+ online
+
+
+
+
+ Total
+ 1
+
+
+
+ >,
+ );
+
+ await user.click(screen.getByRole("combobox", { name: "Region" }));
+ await user.click(await screen.findByRole("option", { name: "Asia" }));
+
+ expect(onValueChange).toHaveBeenCalledWith("asia");
+ expect(screen.getByText("Server summary")).toBeInTheDocument();
+ expect(
+ screen.getByRole("columnheader", { name: "Name" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("edge-1")).toBeInTheDocument();
+ });
+});
diff --git a/src/test/context/providers.test.tsx b/src/test/context/providers.test.tsx
new file mode 100644
index 0000000..5e54cfd
--- /dev/null
+++ b/src/test/context/providers.test.tsx
@@ -0,0 +1,289 @@
+import { act, render, renderHook, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { ReactNode } from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { CommandProvider } from "@/context/command-provider";
+import { SortProvider } from "@/context/sort-provider";
+import { StatusProvider } from "@/context/status-provider";
+import { TooltipProvider } from "@/context/tooltip-provider";
+import { WebSocketProvider } from "@/context/websocket-provider";
+import { useCommand } from "@/hooks/use-command";
+import { useSort } from "@/hooks/use-sort";
+import { useStatus } from "@/hooks/use-status";
+import { useTooltip } from "@/hooks/use-tooltip";
+import { useWebSocketContext } from "@/hooks/use-websocket-context";
+
+class FakeWebSocket {
+ static readonly CONNECTING = 0;
+ static readonly OPEN = 1;
+ static readonly CLOSING = 2;
+ static readonly CLOSED = 3;
+ static instances: FakeWebSocket[] = [];
+
+ readonly url: string;
+ readyState = FakeWebSocket.CONNECTING;
+ onopen: ((event: Event) => void) | null = null;
+ onclose: ((event: CloseEvent) => void) | null = null;
+ onmessage: ((event: MessageEvent) => void) | null = null;
+ onerror: ((event: Event) => void) | null = null;
+
+ constructor(url: string) {
+ this.url = url;
+ FakeWebSocket.instances.push(this);
+ }
+
+ open() {
+ this.readyState = FakeWebSocket.OPEN;
+ this.onopen?.(new Event("open"));
+ }
+
+ message(data: string) {
+ this.onmessage?.(new MessageEvent("message", { data }));
+ }
+
+ close() {
+ this.readyState = FakeWebSocket.CLOSED;
+ this.onclose?.(new CloseEvent("close"));
+ }
+}
+
+function CommandProbe() {
+ const { closeCommand, isOpen, openCommand, toggleCommand } = useCommand();
+
+ return (
+
+
{isOpen ? "open" : "closed"}
+
+
+
+
+ );
+}
+
+function SortProbe() {
+ const { setSortOrder, setSortType, sortOrder, sortType } = useSort();
+
+ return (
+
+
{`${sortType}:${sortOrder}`}
+
+
+
+ );
+}
+
+function StatusProbe() {
+ const { setStatus, status } = useStatus();
+
+ return (
+
+
{status}
+
+
+ );
+}
+
+function TooltipProbe() {
+ const { setTooltipData, tooltipData } = useTooltip();
+
+ return (
+
+
{tooltipData?.country ?? "empty"}
+
+
+ );
+}
+
+function WebSocketProbe() {
+ const {
+ connected,
+ lastMessage,
+ messageHistory,
+ needReconnect,
+ setNeedReconnect,
+ } = useWebSocketContext();
+
+ return (
+
+
{connected ? "connected" : "disconnected"}
+
{lastMessage?.data ?? "none"}
+
{messageHistory.length}
+
{needReconnect ? "needs-reconnect" : "stable"}
+
+
+ );
+}
+
+describe("state providers", () => {
+ it("manages command palette open state", async () => {
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("command-state")).toHaveTextContent("closed");
+ await user.click(screen.getByRole("button", { name: "open" }));
+ expect(screen.getByTestId("command-state")).toHaveTextContent("open");
+ await user.click(screen.getByRole("button", { name: "toggle" }));
+ expect(screen.getByTestId("command-state")).toHaveTextContent("closed");
+ await user.click(screen.getByRole("button", { name: "open" }));
+ await user.click(screen.getByRole("button", { name: "close" }));
+ expect(screen.getByTestId("command-state")).toHaveTextContent("closed");
+ });
+
+ it("uses forced sort globals when valid and still allows local updates", async () => {
+ window.ForceSortType = "mem";
+ window.ForceSortOrder = "asc";
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("mem:asc")).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "cpu" }));
+ expect(screen.getByText("cpu:asc")).toBeInTheDocument();
+ });
+
+ it("falls back to default sort values for invalid forced globals", () => {
+ window.ForceSortType = "invalid";
+ window.ForceSortOrder = "up";
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("default:desc")).toBeInTheDocument();
+ });
+
+ it("manages server status filters", async () => {
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("status-state")).toHaveTextContent("all");
+ await user.click(screen.getByRole("button", { name: "online" }));
+ expect(screen.getByTestId("status-state")).toHaveTextContent("online");
+ });
+
+ it("stores map tooltip data", async () => {
+ const user = userEvent.setup();
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("empty")).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "show" }));
+ expect(screen.getByText("China")).toBeInTheDocument();
+ });
+});
+
+describe("context hooks", () => {
+ it("throws helpful errors when strict hooks miss their providers", () => {
+ expect(() => renderHook(() => useCommand())).toThrow(
+ "useCommand must be used within a CommandProvider",
+ );
+ expect(() => renderHook(() => useSort())).toThrow(
+ "useStatus must be used within a SortProvider",
+ );
+ expect(() => renderHook(() => useStatus())).toThrow(
+ "useStatus must be used within a StatusProvider",
+ );
+ expect(() => renderHook(() => useTooltip())).toThrow(
+ "useTooltip must be used within a TooltipProvider",
+ );
+ });
+});
+
+describe("WebSocketProvider", () => {
+ beforeEach(() => {
+ FakeWebSocket.instances = [];
+ vi.stubGlobal("WebSocket", FakeWebSocket);
+ });
+
+ function renderWebSocketProvider(children: ReactNode) {
+ return render(
+ {children},
+ );
+ }
+
+ it("connects with a ws URL and records incoming messages", () => {
+ renderWebSocketProvider();
+
+ const socket = FakeWebSocket.instances[0];
+ expect(socket.url).toBe("wss://localhost/api/v1/ws/server");
+
+ act(() => {
+ socket.open();
+ });
+ expect(screen.getByText("connected")).toBeInTheDocument();
+
+ act(() => {
+ socket.message("first");
+ socket.message("second");
+ });
+
+ expect(screen.getByText("second")).toBeInTheDocument();
+ expect(screen.getByText("2")).toBeInTheDocument();
+ });
+
+ it("keeps only the latest thirty websocket messages", () => {
+ renderWebSocketProvider();
+ const socket = FakeWebSocket.instances[0];
+
+ act(() => {
+ socket.open();
+ for (let index = 0; index < 31; index += 1) {
+ socket.message(`message-${index}`);
+ }
+ });
+
+ expect(screen.getByText("message-30")).toBeInTheDocument();
+ expect(screen.getByText("30")).toBeInTheDocument();
+ });
+
+ it("exposes manual reconnect state separately from socket state", async () => {
+ const user = userEvent.setup();
+ renderWebSocketProvider();
+
+ expect(screen.getByText("stable")).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "mark" }));
+ expect(screen.getByText("needs-reconnect")).toBeInTheDocument();
+ });
+});
diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts
new file mode 100644
index 0000000..ca5e125
--- /dev/null
+++ b/src/test/fixtures.ts
@@ -0,0 +1,77 @@
+import type { NezhaServer } from "@/types/nezha-api";
+
+type NezhaServerOverrides = Omit, "host" | "state"> & {
+ host?: Partial;
+ state?: Partial;
+};
+
+const baseHost: NezhaServer["host"] = {
+ platform: "linux",
+ platform_version: "6.8",
+ cpu: ["AMD EPYC"],
+ gpu: [],
+ mem_total: 200,
+ disk_total: 400,
+ swap_total: 100,
+ arch: "amd64",
+ boot_time: 1_735_603_200,
+ version: "1.0.0",
+};
+
+const baseState: NezhaServer["state"] = {
+ cpu: 12,
+ mem_used: 50,
+ swap_used: 10,
+ disk_used: 100,
+ net_in_transfer: 1024 ** 3,
+ net_out_transfer: 2 * 1024 ** 3,
+ net_in_speed: 1024 ** 2,
+ net_out_speed: 2 * 1024 ** 2,
+ uptime: 86_400,
+ load_1: 0.12,
+ load_5: 0.45,
+ load_15: 0.78,
+ tcp_conn_count: 8,
+ udp_conn_count: 4,
+ process_count: 88,
+ temperatures: [],
+ gpu: [],
+};
+
+export function createServer(
+ overrides: NezhaServerOverrides = {},
+): NezhaServer {
+ return {
+ id: 1,
+ name: "edge-1",
+ public_note: "",
+ last_active: "2025-01-01T00:00:00.000Z",
+ country_code: "us",
+ ...overrides,
+ host: {
+ ...baseHost,
+ ...overrides.host,
+ },
+ state: {
+ ...baseState,
+ ...overrides.state,
+ },
+ };
+}
+
+export function createSettingResponse() {
+ return {
+ success: true,
+ data: {
+ config: {
+ debug: false,
+ language: "en-US",
+ site_name: "Nezha",
+ user_template: "",
+ admin_template: "",
+ custom_code: "",
+ },
+ version: "1.0.0",
+ },
+ };
+}
diff --git a/src/test/hooks/hooks.test.tsx b/src/test/hooks/hooks.test.tsx
new file mode 100644
index 0000000..60f9627
--- /dev/null
+++ b/src/test/hooks/hooks.test.tsx
@@ -0,0 +1,129 @@
+import { act, render, renderHook, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+
+import { useActiveIndicator } from "@/hooks/use-active-indicator";
+import { useBackground } from "@/hooks/use-background";
+import { useChartHistory } from "@/hooks/use-chart-history";
+
+function BackgroundProbe() {
+ const { backgroundImage, updateBackground } = useBackground();
+
+ return (
+
+
{backgroundImage ?? "empty"}
+
+
+
+ );
+}
+
+function ActiveIndicatorProbe({
+ active,
+ items,
+}: {
+ active: string;
+ items: string[];
+}) {
+ const { containerRef, enableIndicatorAnimation, indicator, setItemRef } =
+ useActiveIndicator(items, active);
+
+ return (
+
+
+ {items.map((item, index) => (
+
+ {item}
+
+ ))}
+
+
+ {indicator ? `${indicator.width}:${indicator.shouldAnimate}` : "none"}
+
+
+ );
+}
+
+describe("useBackground", () => {
+ it("updates the global background image and broadcasts changes", async () => {
+ const user = userEvent.setup();
+ render();
+
+ expect(screen.getByText("empty")).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "set" }));
+ expect(window.CustomBackgroundImage).toBe("/bg.png");
+ expect(screen.getByText("/bg.png")).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "clear" }));
+ expect(screen.getByText("empty")).toBeInTheDocument();
+ });
+
+ it("restores a saved background image during polling", async () => {
+ vi.useFakeTimers();
+ sessionStorage.setItem("savedBackgroundImage", "/saved.png");
+ render();
+
+ act(() => {
+ vi.advanceTimersByTime(100);
+ });
+
+ expect(screen.getByText("/saved.png")).toBeInTheDocument();
+ });
+});
+
+describe("useChartHistory", () => {
+ it("formats websocket message history once and keeps newest data last", () => {
+ const history = [
+ { data: JSON.stringify({ now: 2, servers: [{ id: 1 }] }) },
+ { data: JSON.stringify({ now: 1, servers: [{ id: 1 }] }) },
+ ];
+ const formatFn = vi.fn((wsData: { now: number }, serverId: number) =>
+ serverId === 1 ? wsData.now : null,
+ );
+
+ const { result, rerender } = renderHook(
+ ({ messages }) => useChartHistory(messages, 1, formatFn),
+ { initialProps: { messages: history } },
+ );
+
+ expect(result.current).toEqual([1, 2]);
+ expect(formatFn).toHaveBeenCalledTimes(2);
+
+ rerender({ messages: [...history, { data: JSON.stringify({ now: 3 }) }] });
+ expect(result.current).toEqual([1, 2]);
+ });
+});
+
+describe("useActiveIndicator", () => {
+ it("tracks the active item and clears when it is not available", async () => {
+ Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
+ configurable: true,
+ get() {
+ return 20;
+ },
+ });
+ Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
+ configurable: true,
+ get() {
+ return 10;
+ },
+ });
+
+ const { rerender } = render(
+ ,
+ );
+
+ expect(screen.getByText("20:false")).toBeInTheDocument();
+ rerender();
+ expect(screen.getByText("none")).toBeInTheDocument();
+ });
+});
diff --git a/src/test/lib/format.test.ts b/src/test/lib/format.test.ts
new file mode 100644
index 0000000..3b06c6a
--- /dev/null
+++ b/src/test/lib/format.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from "vitest";
+
+import { formatBytes } from "@/lib/format";
+
+describe("formatBytes", () => {
+ it("formats empty byte values as zero KiB", () => {
+ expect(formatBytes(0)).toBe("0 KiB");
+ expect(formatBytes(Number.NaN)).toBe("0 KiB");
+ });
+
+ it("keeps byte values in binary units", () => {
+ expect(formatBytes(512)).toBe("0.50 KiB");
+ expect(formatBytes(1024)).toBe("1.00 KiB");
+ expect(formatBytes(1024 ** 2)).toBe("1.00 MiB");
+ expect(formatBytes(1024 ** 3 * 2.5, 1)).toBe("2.5 GiB");
+ });
+
+ it("clamps negative decimal precision to an integer", () => {
+ expect(formatBytes(1536, -1)).toBe("2 KiB");
+ });
+});
diff --git a/src/test/lib/inject.test.ts b/src/test/lib/inject.test.ts
new file mode 100644
index 0000000..f59fec7
--- /dev/null
+++ b/src/test/lib/inject.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { InjectContext } from "@/lib/inject";
+
+describe("InjectContext", () => {
+ it("injects supported resource nodes and marks them for cleanup", async () => {
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
+
+ await InjectContext(`
+
+
+
+ custom content
+ plain text
+ `);
+
+ expect(document.querySelector('meta[name="x-test"]')).toHaveAttribute(
+ "data-injected",
+ "true",
+ );
+ expect(document.querySelector("style[data-injected]")).toHaveTextContent(
+ ".custom",
+ );
+ expect(document.querySelector("script[data-injected]")).toHaveTextContent(
+ "window.__injected = true;",
+ );
+ expect(document.querySelector("#custom-node")).toHaveAttribute(
+ "data-injected",
+ "true",
+ );
+ expect(document.body).toHaveTextContent("plain text");
+ });
+
+ it("cleans previous injected resources before applying new content", async () => {
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
+
+ await InjectContext(`first
`);
+ await InjectContext(`second
`);
+
+ expect(document.querySelector("#first")).not.toBeInTheDocument();
+ expect(document.querySelector("#second")).toBeInTheDocument();
+ });
+
+ it("logs external resource failures without throwing to callers", async () => {
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+
+ const appendChild = vi
+ .spyOn(document.head, "appendChild")
+ .mockImplementation((node) => {
+ if (node instanceof HTMLScriptElement) {
+ setTimeout(() => node.onerror?.(new Event("error")), 0);
+ }
+ return node;
+ });
+
+ await InjectContext(``);
+
+ expect(appendChild).toHaveBeenCalled();
+ expect(console.error).toHaveBeenCalledWith(
+ "Error during resource injection:",
+ expect.any(Error),
+ );
+ });
+});
diff --git a/src/test/lib/logo-class.test.tsx b/src/test/lib/logo-class.test.tsx
new file mode 100644
index 0000000..38d3129
--- /dev/null
+++ b/src/test/lib/logo-class.test.tsx
@@ -0,0 +1,39 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import {
+ GetFontLogoClass,
+ GetOsName,
+ MageMicrosoftWindows,
+} from "@/lib/logo-class";
+
+describe("logo-class helpers", () => {
+ it("maps known platform aliases to font logo classes", () => {
+ expect(GetFontLogoClass("ubuntu")).toBe("ubuntu");
+ expect(GetFontLogoClass("darwin")).toBe("apple");
+ expect(GetFontLogoClass("linux")).toBe("tux");
+ expect(GetFontLogoClass("amazon")).toBe("redhat");
+ expect(GetFontLogoClass("arch")).toBe("archlinux");
+ expect(GetFontLogoClass("opensuse-tumbleweed")).toBe("opensuse");
+ expect(GetFontLogoClass("unknown")).toBe("tux");
+ });
+
+ it("maps platform aliases to readable operating system names", () => {
+ expect(GetOsName("ubuntu")).toBe("Ubuntu");
+ expect(GetOsName("darwin")).toBe("macOS");
+ expect(GetOsName("linux")).toBe("Linux");
+ expect(GetOsName("amazon")).toBe("Redhat");
+ expect(GetOsName("arch")).toBe("Archlinux");
+ expect(GetOsName("opensuse-tumbleweed")).toBe("Opensuse");
+ expect(GetOsName("unknown")).toBe("Linux");
+ });
+
+ it("renders the Windows SVG icon", () => {
+ render();
+
+ expect(screen.getByTestId("windows-icon")).toHaveAttribute(
+ "viewBox",
+ "0 0 24 24",
+ );
+ });
+});
diff --git a/src/test/lib/nezha-api.test.ts b/src/test/lib/nezha-api.test.ts
new file mode 100644
index 0000000..dc1a069
--- /dev/null
+++ b/src/test/lib/nezha-api.test.ts
@@ -0,0 +1,107 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ fetchLoginUser,
+ fetchMonitor,
+ fetchServerMetrics,
+ fetchService,
+ fetchSetting,
+} from "@/lib/nezha-api";
+
+const jsonResponse = (body: unknown, init?: ResponseInit) =>
+ new Response(JSON.stringify(body), {
+ status: init?.status ?? 200,
+ statusText: init?.statusText,
+ headers: {
+ "Content-Type": "application/json",
+ ...init?.headers,
+ },
+ });
+
+describe("nezha api fetchers", () => {
+ beforeEach(() => {
+ vi.stubGlobal("fetch", vi.fn());
+ });
+
+ it("returns setting payloads", async () => {
+ const payload = {
+ success: true,
+ data: {
+ config: {
+ debug: false,
+ language: "en-US",
+ site_name: "Nezha",
+ user_template: "",
+ admin_template: "",
+ custom_code: "",
+ },
+ version: "1.0.0",
+ },
+ };
+ vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(payload));
+
+ await expect(fetchSetting()).resolves.toEqual(payload);
+ expect(fetch).toHaveBeenCalledWith("/api/v1/setting");
+ });
+
+ it("throws API error messages returned by service endpoints", async () => {
+ vi.mocked(fetch).mockResolvedValueOnce(
+ jsonResponse({ error: "service unavailable" }),
+ );
+
+ await expect(fetchService()).rejects.toThrow("service unavailable");
+ });
+
+ it("adds monitor and metrics query parameters when periods are provided", async () => {
+ vi.mocked(fetch)
+ .mockResolvedValueOnce(jsonResponse({ success: true, data: [] }))
+ .mockResolvedValueOnce(
+ jsonResponse({
+ success: true,
+ data: {
+ server_id: 7,
+ server_name: "edge",
+ metric: "cpu",
+ data_points: [],
+ },
+ }),
+ );
+
+ await fetchMonitor(7, "7d");
+ await fetchServerMetrics(7, "cpu", "30d");
+
+ expect(fetch).toHaveBeenNthCalledWith(
+ 1,
+ "/api/v1/server/7/service?period=7d",
+ );
+ expect(fetch).toHaveBeenNthCalledWith(
+ 2,
+ "/api/v1/server/7/metrics?metric=cpu&period=30d",
+ );
+ });
+
+ it("refreshes the token when a logged-in browser session has cookies", async () => {
+ Object.defineProperty(document, "cookie", {
+ configurable: true,
+ value: "nezha_token=token",
+ });
+ const payload = {
+ success: true,
+ data: {
+ id: 1,
+ username: "admin",
+ password: "",
+ created_at: "2025-01-01T00:00:00Z",
+ updated_at: "2025-01-01T00:00:00Z",
+ },
+ };
+ vi.mocked(fetch)
+ .mockResolvedValueOnce(jsonResponse(payload))
+ .mockResolvedValueOnce(jsonResponse({ success: true }));
+
+ await expect(fetchLoginUser()).resolves.toEqual(payload);
+
+ expect(fetch).toHaveBeenNthCalledWith(1, "/api/v1/profile");
+ expect(fetch).toHaveBeenNthCalledWith(2, "/api/v1/refresh-token");
+ });
+});
diff --git a/src/test/lib/static-data.test.ts b/src/test/lib/static-data.test.ts
new file mode 100644
index 0000000..5bfd495
--- /dev/null
+++ b/src/test/lib/static-data.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+
+import { geoJsonString } from "@/lib/geo-json-string";
+import { countryCoordinates } from "@/lib/geo-limit";
+
+describe("static geographic data", () => {
+ it("provides known country coordinate metadata", () => {
+ expect(countryCoordinates.CN).toMatchObject({
+ lat: 35,
+ lng: 105,
+ name: "China",
+ });
+ expect(countryCoordinates.US.name).toBe("United States");
+ });
+
+ it("ships parseable GeoJSON feature collection data", () => {
+ const geoJson = JSON.parse(geoJsonString) as {
+ type: string;
+ features: unknown[];
+ };
+
+ expect(geoJson.type).toBe("FeatureCollection");
+ expect(geoJson.features.length).toBeGreaterThan(0);
+ });
+});
diff --git a/src/test/lib/utils.test.ts b/src/test/lib/utils.test.ts
new file mode 100644
index 0000000..c31e07c
--- /dev/null
+++ b/src/test/lib/utils.test.ts
@@ -0,0 +1,346 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ fetcher,
+ formatNezhaInfo,
+ formatRelativeTime,
+ formatTime,
+ getDaysBetweenDates,
+ getDaysBetweenDatesWithAutoRenewal,
+ getNextCycleTime,
+ handlePublicNote,
+ nezhaFetcher,
+ parsePublicNote,
+} from "@/lib/utils";
+import type { NezhaServer } from "@/types/nezha-api";
+
+const serverFixture: NezhaServer = {
+ id: 1,
+ name: "edge",
+ public_note: '{"planDataMod":{"bandwidth":"1Gbps"}}',
+ last_active: "2025-01-01T00:00:00.000Z",
+ country_code: "US",
+ host: {
+ platform: "linux",
+ platform_version: "6.8",
+ cpu: ["AMD EPYC"],
+ gpu: [],
+ mem_total: 200,
+ disk_total: 400,
+ swap_total: 100,
+ arch: "amd64",
+ boot_time: 1_735_603_200,
+ version: "1.0.0",
+ },
+ state: {
+ cpu: 12,
+ mem_used: 50,
+ swap_used: 10,
+ disk_used: 100,
+ net_in_transfer: 1024,
+ net_out_transfer: 2048,
+ net_in_speed: 1024 * 1024,
+ net_out_speed: 2 * 1024 * 1024,
+ uptime: 3600,
+ load_1: 0.123,
+ load_5: 0.456,
+ load_15: 0.789,
+ tcp_conn_count: 8,
+ udp_conn_count: 4,
+ process_count: 88,
+ temperatures: [],
+ gpu: [],
+ },
+};
+
+describe("date and billing helpers", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2025-01-15T00:00:00.000Z"));
+ });
+
+ it("calculates the next renewal cycle after the specified date", () => {
+ const nextCycle = getNextCycleTime(
+ Date.UTC(2025, 0, 1),
+ 1,
+ Date.UTC(2025, 2, 15),
+ );
+
+ expect(new Date(nextCycle).toISOString()).toBe("2025-04-01T00:00:00.000Z");
+ });
+
+ it("normalizes common cycle labels and remaining package days", () => {
+ const result = getDaysBetweenDatesWithAutoRenewal({
+ startDate: "2025-01-01T00:00:00.000Z",
+ endDate: "2025-01-31T00:00:00.000Z",
+ autoRenewal: "0",
+ cycle: "monthly",
+ amount: "10",
+ });
+
+ expect(result.days).toBe(16);
+ expect(result.cycleLabel).toBe("月");
+ expect(result.remainingPercentage).toBeCloseTo(16 / 30);
+ });
+
+ it("handles auto-renewal before and after the current cycle end", () => {
+ expect(
+ getDaysBetweenDatesWithAutoRenewal({
+ startDate: "2025-01-01T00:00:00.000Z",
+ endDate: "2025-02-01T00:00:00.000Z",
+ autoRenewal: "1",
+ cycle: "quarterly",
+ amount: "10",
+ }),
+ ).toMatchObject({
+ days: 17,
+ cycleLabel: "季",
+ });
+
+ const renewed = getDaysBetweenDatesWithAutoRenewal({
+ startDate: "2024-01-01T00:00:00.000Z",
+ endDate: "2024-12-01T00:00:00.000Z",
+ autoRenewal: "1",
+ cycle: "annual",
+ amount: "10",
+ });
+
+ expect(renewed.days).toBeGreaterThan(300);
+ expect(renewed.cycleLabel).toBe("年");
+ expect(renewed.remainingPercentage).toBeLessThanOrEqual(1);
+ });
+
+ it("rejects invalid cycle calculations", () => {
+ expect(() => getNextCycleTime(Date.UTC(2025, 0, 1), 0, Date.now())).toThrow(
+ "参数无效",
+ );
+ });
+
+ it("formats absolute dates and day differences", () => {
+ expect(formatTime(new Date(2025, 0, 2, 3, 4, 5).getTime())).toBe(
+ "2025-1-2 03:04:05",
+ );
+ expect(getDaysBetweenDates("2025-01-20", "2025-01-15")).toBe(5);
+ });
+
+ it("formats relative timestamps using compact units", () => {
+ expect(formatRelativeTime(Date.now() - 45 * 1000)).toBe("45s");
+ expect(formatRelativeTime(Date.now() - 5 * 60 * 1000)).toBe("5m");
+ expect(formatRelativeTime(Date.now() - 2 * 60 * 60 * 1000)).toBe("2h");
+ expect(formatRelativeTime(Date.now() - 3 * 24 * 60 * 60 * 1000)).toBe("3d");
+ });
+});
+
+describe("public note helpers", () => {
+ it("parses supported public note blocks and defaults missing strings", () => {
+ expect(
+ parsePublicNote(
+ JSON.stringify({
+ billingDataMod: {
+ endDate: "2025-12-31",
+ cycle: "year",
+ },
+ planDataMod: {
+ bandwidth: "1Gbps",
+ IPv4: "1",
+ },
+ }),
+ ),
+ ).toEqual({
+ billingDataMod: {
+ startDate: "",
+ endDate: "2025-12-31",
+ autoRenewal: "",
+ cycle: "year",
+ amount: "",
+ },
+ planDataMod: {
+ bandwidth: "1Gbps",
+ trafficVol: "",
+ trafficType: "",
+ IPv4: "1",
+ IPv6: "",
+ networkRoute: "",
+ extra: "",
+ },
+ });
+ });
+
+ it("returns null for invalid public note JSON", () => {
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+
+ expect(parsePublicNote("{bad json")).toBeNull();
+ });
+
+ it("returns null or partial blocks for empty and single-section public notes", () => {
+ expect(parsePublicNote("")).toBeNull();
+ expect(parsePublicNote(JSON.stringify({ note: "plain" }))).toBeNull();
+ expect(
+ parsePublicNote(
+ JSON.stringify({
+ billingDataMod: {
+ endDate: "2025-12-31",
+ amount: "20",
+ },
+ }),
+ ),
+ ).toEqual({
+ billingDataMod: {
+ startDate: "",
+ endDate: "2025-12-31",
+ autoRenewal: "",
+ cycle: "",
+ amount: "20",
+ },
+ });
+ expect(
+ parsePublicNote(
+ JSON.stringify({
+ planDataMod: {
+ trafficVol: "1TB",
+ extra: "Backup",
+ },
+ }),
+ ),
+ ).toEqual({
+ planDataMod: {
+ bandwidth: "",
+ trafficVol: "1TB",
+ trafficType: "",
+ IPv4: "",
+ IPv6: "",
+ networkRoute: "",
+ extra: "Backup",
+ },
+ });
+ });
+
+ it("falls back to cached notes when websocket payloads are empty", () => {
+ expect(handlePublicNote(1, "live note")).toBe("live note");
+ expect(handlePublicNote(1, "")).toBe("live note");
+ expect(sessionStorage.getItem("server_1_public_note")).toBe("live note");
+ });
+
+ it("returns an empty public note when no live or cached note exists", () => {
+ expect(handlePublicNote(404, "")).toBe("");
+ });
+});
+
+describe("nezha data formatting", () => {
+ it("maps websocket server state into view-friendly metrics", () => {
+ const now = new Date("2025-01-01T00:00:20.000Z").getTime();
+
+ const result = formatNezhaInfo(now, serverFixture);
+
+ expect(result.online).toBe(true);
+ expect(result.up).toBe(2);
+ expect(result.down).toBe(1);
+ expect(result.mem).toBe(25);
+ expect(result.swap).toBe(10);
+ expect(result.disk).toBe(25);
+ expect(result.load_1).toBe("0.12");
+ expect(result.public_note).toBe(serverFixture.public_note);
+ });
+
+ it("falls back safely for offline timestamps and empty host totals", () => {
+ const result = formatNezhaInfo(Date.now(), {
+ ...serverFixture,
+ public_note: "",
+ last_active: "0001-01-01T00:00:00.000Z",
+ host: {
+ ...serverFixture.host,
+ boot_time: 0,
+ mem_total: 0,
+ swap_total: 0,
+ disk_total: 0,
+ version: "",
+ cpu: [],
+ },
+ state: {
+ ...serverFixture.state,
+ cpu: 0,
+ mem_used: 0,
+ swap_used: 0,
+ disk_used: 0,
+ net_in_speed: 0,
+ net_out_speed: 0,
+ process_count: 0,
+ },
+ });
+
+ expect(result.online).toBe(false);
+ expect(result.last_active_time_string).toBe("");
+ expect(result.boot_time_string).toBe("");
+ expect(result.version).toBeNull();
+ expect(result.mem).toBe(0);
+ expect(result.swap).toBe(0);
+ expect(result.disk).toBe(0);
+ expect(result.public_note).toBe("");
+ });
+});
+
+describe("fetcher", () => {
+ it("returns nested data for successful responses", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ data: { ok: true } }), {
+ headers: { "Content-Type": "application/json" },
+ }),
+ ),
+ );
+
+ await expect(fetcher("/api/v1/demo")).resolves.toEqual({ ok: true });
+ });
+
+ it("logs and rethrows failed responses", async () => {
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ error: "bad" }), {
+ status: 500,
+ statusText: "Server Error",
+ headers: { "Content-Type": "application/json" },
+ }),
+ ),
+ );
+
+ await expect(fetcher("/api/v1/demo")).rejects.toThrow("Server Error");
+ expect(console.error).toHaveBeenCalled();
+ });
+});
+
+describe("nezhaFetcher", () => {
+ it("returns JSON for successful responses", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ success: true }), {
+ headers: { "Content-Type": "application/json" },
+ }),
+ ),
+ );
+
+ await expect(nezhaFetcher("/api/v1/setting")).resolves.toEqual({
+ success: true,
+ });
+ });
+
+ it("throws status and response info for failed responses", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ error: "forbidden" }), {
+ status: 403,
+ statusText: "Forbidden",
+ headers: { "Content-Type": "application/json" },
+ }),
+ ),
+ );
+
+ await expect(nezhaFetcher("/api/v1/setting")).rejects.toMatchObject({
+ status: 403,
+ info: { error: "forbidden" },
+ });
+ });
+});
diff --git a/src/test/pages/Server.test.tsx b/src/test/pages/Server.test.tsx
new file mode 100644
index 0000000..bad679e
--- /dev/null
+++ b/src/test/pages/Server.test.tsx
@@ -0,0 +1,382 @@
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { SortProvider } from "@/context/sort-provider";
+import { StatusProvider } from "@/context/status-provider";
+import type { WebSocketContextType } from "@/context/websocket-context";
+import { WebSocketContext } from "@/context/websocket-context";
+import { useStatus } from "@/hooks/use-status";
+import Servers from "@/pages/Server";
+import { createServer } from "@/test/fixtures";
+import { renderWithProviders } from "@/test/utils";
+import type { NezhaServer } from "@/types/nezha-api";
+
+const apiMocks = vi.hoisted(() => ({
+ fetchServerGroup: vi.fn(),
+ fetchService: vi.fn(),
+}));
+
+vi.mock("@/lib/nezha-api", () => apiMocks);
+
+vi.mock("@/components/GlobalMap", () => ({
+ default: ({ serverList }: { serverList: NezhaServer[] }) => (
+ {serverList.length}
+ ),
+}));
+
+vi.mock("@/components/GroupSwitch", () => ({
+ default: ({
+ tabs,
+ setCurrentTab,
+ }: {
+ tabs: string[];
+ setCurrentTab: (tab: string) => void;
+ }) => (
+
+ {tabs.map((tab) => (
+
+ ))}
+
+ ),
+}));
+
+vi.mock("@/components/ServerOverview", () => ({
+ default: ({
+ offline,
+ online,
+ total,
+ }: {
+ offline: number;
+ online: number;
+ total: number;
+ }) => (
+ {`${total}:${online}:${offline}`}
+ ),
+}));
+
+vi.mock("@/components/ServerCard", () => ({
+ default: ({ serverInfo }: { serverInfo: NezhaServer }) => (
+ {serverInfo.name}
+ ),
+}));
+
+vi.mock("@/components/ServerCardInline", () => ({
+ default: ({ serverInfo }: { serverInfo: NezhaServer }) => (
+ {serverInfo.name}
+ ),
+}));
+
+vi.mock("@/components/ServiceTracker", () => ({
+ ServiceTracker: ({ serverList }: { serverList: NezhaServer[] }) => (
+ {serverList.length}
+ ),
+}));
+
+function StatusControl() {
+ const { setStatus } = useStatus();
+
+ return (
+
+ );
+}
+
+function renderServerPage(
+ websocketValue: Partial,
+ { withStatusControl = false } = {},
+) {
+ const defaultWebsocketValue: WebSocketContextType = {
+ lastMessage: null,
+ connected: false,
+ messageHistory: [],
+ reconnect: vi.fn(),
+ needReconnect: false,
+ setNeedReconnect: vi.fn(),
+ };
+
+ return renderWithProviders(
+
+
+
+ {withStatusControl && }
+
+
+
+ ,
+ );
+}
+
+function websocketPayload(servers: NezhaServer[]) {
+ return {
+ data: JSON.stringify({
+ now: Date.parse("2025-01-01T00:00:20.000Z"),
+ servers,
+ }),
+ };
+}
+
+describe("Servers page", () => {
+ beforeEach(() => {
+ apiMocks.fetchServerGroup.mockResolvedValue({
+ success: true,
+ data: [
+ {
+ group: {
+ id: 1,
+ created_at: "",
+ updated_at: "",
+ name: "Edge",
+ },
+ servers: [2],
+ },
+ ],
+ });
+ apiMocks.fetchService.mockResolvedValue({
+ success: true,
+ data: {
+ services: {},
+ cycle_transfer_stats: {},
+ },
+ });
+ });
+
+ it("renders websocket loading and processing states", () => {
+ const { rerender } = renderServerPage({
+ connected: false,
+ lastMessage: null,
+ });
+
+ expect(screen.getByText("info.websocketConnecting")).toBeInTheDocument();
+
+ rerender(
+
+
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByText("info.processing")).toBeInTheDocument();
+ });
+
+ it("summarizes online and offline servers from websocket data", async () => {
+ const online = createServer({ id: 1, name: "alpha" });
+ const offline = createServer({
+ id: 2,
+ name: "beta",
+ last_active: "2024-12-31T23:00:00.000Z",
+ });
+
+ renderServerPage({
+ connected: true,
+ lastMessage: websocketPayload([online, offline]),
+ });
+
+ expect(screen.getByTestId("server-overview")).toHaveTextContent("2:1:1");
+ expect(screen.getAllByTestId("server-card")).toHaveLength(2);
+ expect(screen.getByText("alpha")).toBeInTheDocument();
+ expect(screen.getByText("beta")).toBeInTheDocument();
+
+ await waitFor(() => {
+ expect(apiMocks.fetchServerGroup).toHaveBeenCalled();
+ expect(apiMocks.fetchService).toHaveBeenCalled();
+ });
+ });
+
+ it("filters servers by selected group", async () => {
+ const online = createServer({ id: 1, name: "alpha" });
+ const offline = createServer({
+ id: 2,
+ name: "beta",
+ last_active: "2024-12-31T23:00:00.000Z",
+ });
+ const user = userEvent.setup();
+
+ renderServerPage({
+ connected: true,
+ lastMessage: websocketPayload([online, offline]),
+ });
+
+ await user.click(await screen.findByTestId("group-Edge"));
+
+ expect(screen.queryByText("alpha")).not.toBeInTheDocument();
+ expect(screen.getByText("beta")).toBeInTheDocument();
+ expect(sessionStorage.getItem("selectedGroup")).toBe("Edge");
+ });
+
+ it("sorts server cards by selected metrics and direction", async () => {
+ const lowCpu = createServer({
+ id: 1,
+ name: "alpha",
+ state: { cpu: 10 },
+ });
+ const highCpu = createServer({
+ id: 2,
+ name: "beta",
+ state: { cpu: 90 },
+ });
+ const user = userEvent.setup();
+
+ renderServerPage({
+ connected: true,
+ lastMessage: websocketPayload([lowCpu, highCpu]),
+ });
+
+ await user.selectOptions(screen.getByLabelText("Sort metric"), "cpu");
+ expect(screen.getAllByTestId("server-card")[0]).toHaveTextContent("beta");
+
+ await user.click(screen.getByLabelText("Toggle sort direction"));
+ expect(screen.getAllByTestId("server-card")[0]).toHaveTextContent("alpha");
+ });
+
+ it("keeps name sorting independent from online status", async () => {
+ const onlineAlpha = createServer({
+ id: 1,
+ name: "alpha",
+ });
+ const offlineZeta = createServer({
+ id: 2,
+ name: "zeta",
+ last_active: "2024-12-31T23:00:00.000Z",
+ });
+ const user = userEvent.setup();
+
+ renderServerPage({
+ connected: true,
+ lastMessage: websocketPayload([onlineAlpha, offlineZeta]),
+ });
+
+ expect(screen.getByLabelText("Toggle sort direction")).toBeDisabled();
+
+ await user.selectOptions(screen.getByLabelText("Sort metric"), "name");
+
+ expect(screen.getAllByTestId("server-card")[0]).toHaveTextContent("zeta");
+
+ await user.click(screen.getByLabelText("Toggle sort direction"));
+ expect(screen.getAllByTestId("server-card")[0]).toHaveTextContent("alpha");
+ });
+
+ it("toggles map and service tracker controls when service data exists", async () => {
+ apiMocks.fetchService.mockResolvedValue({
+ success: true,
+ data: {
+ services: {
+ http: {
+ service_name: "HTTP",
+ current_up: 1,
+ current_down: 0,
+ total_up: 1,
+ total_down: 0,
+ delay: [10],
+ up: [1],
+ down: [0],
+ },
+ },
+ cycle_transfer_stats: {},
+ },
+ });
+ const user = userEvent.setup();
+ const online = createServer({ id: 1, name: "alpha" });
+ const offline = createServer({
+ id: 2,
+ name: "beta",
+ last_active: "2024-12-31T23:00:00.000Z",
+ });
+
+ const { container } = renderServerPage({
+ connected: true,
+ lastMessage: websocketPayload([online, offline]),
+ });
+
+ await waitFor(() => {
+ expect(apiMocks.fetchService).toHaveBeenCalled();
+ expect(
+ container.querySelectorAll(
+ ".server-overview-controls section > button",
+ ),
+ ).toHaveLength(3);
+ });
+
+ const controls = container.querySelectorAll(
+ ".server-overview-controls section > button",
+ );
+ await user.click(controls[0]);
+ expect(screen.getByTestId("global-map")).toHaveTextContent("2");
+ expect(localStorage.getItem("showMap")).toBe("1");
+
+ await user.click(controls[1]);
+ expect(screen.getByTestId("service-tracker")).toHaveTextContent("2");
+ expect(localStorage.getItem("showServices")).toBe("1");
+ });
+
+ it("does not enable inline cards from storage on mobile widths", () => {
+ localStorage.setItem("inline", "1");
+ Object.defineProperty(window, "innerWidth", {
+ configurable: true,
+ value: 500,
+ });
+ const online = createServer({ id: 1, name: "alpha" });
+
+ renderServerPage({
+ connected: true,
+ lastMessage: websocketPayload([online]),
+ });
+
+ expect(screen.getByTestId("server-card")).toHaveTextContent("alpha");
+ expect(screen.queryByTestId("server-card-inline")).not.toBeInTheDocument();
+ });
+
+ it("applies external status filters and inline card preferences", async () => {
+ localStorage.setItem("inline", "1");
+ Object.defineProperty(window, "innerWidth", {
+ configurable: true,
+ value: 1024,
+ });
+ const online = createServer({ id: 1, name: "alpha" });
+ const offline = createServer({
+ id: 2,
+ name: "beta",
+ last_active: "2024-12-31T23:00:00.000Z",
+ });
+ const user = userEvent.setup();
+
+ renderServerPage(
+ {
+ connected: true,
+ lastMessage: websocketPayload([online, offline]),
+ },
+ { withStatusControl: true },
+ );
+
+ await waitFor(() => {
+ expect(screen.getAllByTestId("server-card-inline")).toHaveLength(2);
+ });
+
+ await user.click(screen.getByRole("button", { name: "online-only" }));
+
+ expect(screen.getAllByTestId("server-card-inline")).toHaveLength(1);
+ expect(screen.getByText("alpha")).toBeInTheDocument();
+ expect(screen.queryByText("beta")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/test/pages/pages.test.tsx b/src/test/pages/pages.test.tsx
new file mode 100644
index 0000000..dcd2b77
--- /dev/null
+++ b/src/test/pages/pages.test.tsx
@@ -0,0 +1,130 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import ErrorPage from "@/pages/ErrorPage";
+import NotFound from "@/pages/NotFound";
+import ServerDetail from "@/pages/ServerDetail";
+
+vi.mock("@/components/NetworkChart", () => ({
+ NetworkChart: ({ server_id, show }: { server_id: number; show: boolean }) => (
+ {`${server_id}:${show}`}
+ ),
+}));
+
+vi.mock("@/components/ServerDetailChart", () => ({
+ default: ({ server_id }: { server_id: string }) => (
+ {server_id}
+ ),
+}));
+
+vi.mock("@/components/ServerDetailOverview", () => ({
+ default: ({ server_id }: { server_id: string }) => (
+ {server_id}
+ ),
+}));
+
+vi.mock("@/components/TabSwitch", () => ({
+ default: ({
+ tabs,
+ setCurrentTab,
+ }: {
+ tabs: string[];
+ setCurrentTab: (tab: string) => void;
+ }) => (
+
+ {tabs.map((tab) => (
+
+ ))}
+
+ ),
+}));
+
+function LocationProbe() {
+ const location = useLocation();
+ return {location.pathname}
;
+}
+
+describe("simple pages", () => {
+ it("renders explicit and translated error messages", () => {
+ const { rerender } = render();
+
+ expect(screen.getByText("418")).toBeInTheDocument();
+ expect(screen.getByText("short")).toBeInTheDocument();
+
+ rerender();
+ expect(screen.getByText("error.somethingWentWrong")).toBeInTheDocument();
+ });
+
+ it("navigates back home from the not found page", async () => {
+ const user = userEvent.setup();
+ render(
+
+
+
+
+
+ >
+ }
+ />
+
+ home
+
+ >
+ }
+ />
+
+ ,
+ );
+
+ expect(screen.getByText("404")).toBeInTheDocument();
+ await user.click(screen.getByRole("button", { name: "error.backToHome" }));
+ expect(screen.getByText("home")).toBeInTheDocument();
+ expect(screen.getByText("/")).toBeInTheDocument();
+ });
+});
+
+describe("ServerDetail", () => {
+ beforeEach(() => {
+ vi.stubGlobal("scrollTo", vi.fn());
+ });
+
+ it("renders detail tab by default and can switch to network tab", async () => {
+ const user = userEvent.setup();
+ render(
+
+
+ } />
+
+ ,
+ );
+
+ expect(screen.getByTestId("detail-overview")).toHaveTextContent("7");
+ expect(screen.getByTestId("detail-chart")).toHaveTextContent("7");
+ expect(screen.getByTestId("network-chart")).toHaveTextContent("7:false");
+
+ await user.click(screen.getByRole("button", { name: "Network" }));
+ expect(screen.getByTestId("network-chart")).toHaveTextContent("7:true");
+ });
+
+ it("redirects when route params are missing", async () => {
+ render(
+
+
+ } />
+ redirected} />
+
+ ,
+ );
+
+ expect(await screen.findByText("redirected")).toBeInTheDocument();
+ });
+});
diff --git a/src/test/setup.ts b/src/test/setup.ts
new file mode 100644
index 0000000..43ddc31
--- /dev/null
+++ b/src/test/setup.ts
@@ -0,0 +1,110 @@
+import "@testing-library/jest-dom/vitest";
+import { cleanup } from "@testing-library/react";
+import { afterEach, vi } from "vitest";
+
+vi.mock("react-i18next", () => ({
+ initReactI18next: {
+ type: "3rdParty",
+ init: vi.fn(),
+ },
+ useTranslation: () => ({
+ t: (key: string) => key,
+ i18n: {
+ language: "en-US",
+ languages: ["en-US"],
+ changeLanguage: vi.fn(),
+ },
+ }),
+}));
+
+Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+});
+
+class ResizeObserverMock {
+ observe = vi.fn();
+ unobserve = vi.fn();
+ disconnect = vi.fn();
+}
+
+class IntersectionObserverMock {
+ readonly root = null;
+ readonly rootMargin = "";
+ readonly thresholds = [];
+
+ observe = vi.fn();
+ unobserve = vi.fn();
+ disconnect = vi.fn();
+ takeRecords = vi.fn(() => []);
+}
+
+Object.defineProperty(globalThis, "ResizeObserver", {
+ writable: true,
+ value: ResizeObserverMock,
+});
+
+Object.defineProperty(globalThis, "IntersectionObserver", {
+ writable: true,
+ value: IntersectionObserverMock,
+});
+
+Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
+ configurable: true,
+ value: vi.fn(),
+});
+
+Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
+ configurable: true,
+ value: vi.fn(() => false),
+});
+
+Object.defineProperty(HTMLElement.prototype, "setPointerCapture", {
+ configurable: true,
+ value: vi.fn(),
+});
+
+Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
+ configurable: true,
+ value: vi.fn(),
+});
+
+afterEach(() => {
+ cleanup();
+ localStorage.clear();
+ sessionStorage.clear();
+ document.head
+ .querySelectorAll('meta[name="theme-color"], [data-injected]')
+ .forEach((node) => {
+ node.remove();
+ });
+ document.body.querySelectorAll("[data-injected]").forEach((node) => {
+ node.remove();
+ });
+ document.documentElement.className = "";
+ document.documentElement.removeAttribute("style");
+ Object.defineProperty(document, "cookie", {
+ configurable: true,
+ value: "",
+ });
+ window.CustomBackgroundImage = "";
+ window.CustomMobileBackgroundImage = "";
+ window.ForceShowServices = false;
+ window.ForceCardInline = false;
+ window.ForceShowMap = false;
+ window.ForcePeakCutEnabled = false;
+ window.ForceSortType = undefined;
+ window.ForceSortOrder = undefined;
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+});
diff --git a/src/test/utils.tsx b/src/test/utils.tsx
new file mode 100644
index 0000000..e2c465b
--- /dev/null
+++ b/src/test/utils.tsx
@@ -0,0 +1,42 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { type RenderOptions, render } from "@testing-library/react";
+import type { ReactElement, ReactNode } from "react";
+import { MemoryRouter } from "react-router-dom";
+
+export function createTestQueryClient() {
+ return new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ gcTime: 0,
+ },
+ },
+ });
+}
+
+type RenderWithProvidersOptions = RenderOptions & {
+ route?: string;
+ queryClient?: QueryClient;
+};
+
+export function renderWithProviders(
+ ui: ReactElement,
+ {
+ route = "/",
+ queryClient = createTestQueryClient(),
+ ...renderOptions
+ }: RenderWithProvidersOptions = {},
+) {
+ function Wrapper({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return {
+ queryClient,
+ ...render(ui, { wrapper: Wrapper, ...renderOptions }),
+ };
+}
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 2397ca5..d6337b6 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -14,7 +14,7 @@
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
- "types": ["node"],
+ "types": ["node", "vitest/globals", "@testing-library/jest-dom"],
"paths": {
"@/*": ["./src/*"]
},
diff --git a/vite.config.ts b/vite.config.ts
index f51d39d..02bda37 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -2,7 +2,7 @@ import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import react from "@vitejs/plugin-react";
-import { defineConfig } from "vite";
+import { defineConfig } from "vitest/config";
// Get git commit hash
const getGitHash = () => {
@@ -63,6 +63,39 @@ export default defineConfig({
Pragma: "no-cache",
},
},
+ test: {
+ environment: "jsdom",
+ environmentOptions: {
+ jsdom: {
+ url: "https://localhost/",
+ },
+ },
+ globals: true,
+ setupFiles: ["./src/test/setup.ts"],
+ css: true,
+ include: ["src/test/**/*.{test,spec}.{ts,tsx}"],
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "html", "lcov"],
+ reportsDirectory: "./coverage",
+ thresholds: {
+ statements: 86,
+ branches: 74,
+ functions: 86,
+ lines: 86,
+ },
+ include: ["src/**/*.{ts,tsx}"],
+ exclude: [
+ "src/**/*.test.{ts,tsx}",
+ "src/**/*.spec.{ts,tsx}",
+ "src/test/**",
+ "src/types/**",
+ "src/main.tsx",
+ "src/i18n.js",
+ "src/vite-env.d.ts",
+ ],
+ },
+ },
build: {
rolldownOptions: {
output: {