From 8efa361728cfcfc0cff0de2d15836de44003b4b8 Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Fri, 16 Jan 2026 21:43:39 +0800 Subject: [PATCH] =?UTF-8?q?perf(=E5=89=8D=E7=AB=AF):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=8A=A0=E8=BD=BD=E6=80=A7=E8=83=BD=E5=92=8C?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加路由预加载功能,使用 requestIdleCallback 在浏览器空闲时预加载 - 配置 Vite manualChunks 分离 vendor 库(vue/ui/chart/i18n/misc) - 新增 NavigationProgress 导航进度条组件,支持防闪烁和无障碍 - 集成 Vitest 测试框架,添加 40 个单元测试和集成测试 - 支持 prefers-reduced-motion 和暗色模式 Co-Authored-By: Claude Opus 4.5 --- frontend/package-lock.json | 1650 +++++++++++++++++ frontend/package.json | 13 +- frontend/src/App.vue | 2 + .../__tests__/integration/navigation.spec.ts | 478 +++++ frontend/src/__tests__/setup.ts | 45 + .../components/common/NavigationProgress.vue | 109 ++ .../__tests__/NavigationProgress.spec.ts | 83 + .../__tests__/useNavigationLoading.spec.ts | 176 ++ .../__tests__/useRoutePrefetch.spec.ts | 219 +++ .../src/composables/useNavigationLoading.ts | 132 ++ frontend/src/composables/useRoutePrefetch.ts | 304 +++ frontend/src/router/index.ts | 20 + frontend/vite.config.ts | 44 +- frontend/vitest.config.ts | 35 + 14 files changed, 3306 insertions(+), 4 deletions(-) create mode 100644 frontend/src/__tests__/integration/navigation.spec.ts create mode 100644 frontend/src/__tests__/setup.ts create mode 100644 frontend/src/components/common/NavigationProgress.vue create mode 100644 frontend/src/components/common/__tests__/NavigationProgress.spec.ts create mode 100644 frontend/src/composables/__tests__/useNavigationLoading.spec.ts create mode 100644 frontend/src/composables/__tests__/useRoutePrefetch.spec.ts create mode 100644 frontend/src/composables/useNavigationLoading.ts create mode 100644 frontend/src/composables/useRoutePrefetch.ts create mode 100644 frontend/vitest.config.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e7e1288d..e6c6144e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,14 +28,18 @@ "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "@vitejs/plugin-vue": "^5.2.3", + "@vitest/coverage-v8": "^2.1.9", + "@vue/test-utils": "^2.4.6", "autoprefixer": "^10.4.16", "eslint": "^8.57.0", "eslint-plugin-vue": "^9.25.0", + "jsdom": "^24.1.3", "postcss": "^8.4.32", "tailwindcss": "^3.4.0", "typescript": "~5.6.0", "vite": "^5.0.10", "vite-plugin-checker": "^0.9.1", + "vitest": "^2.1.9", "vue-tsc": "^2.2.0" } }, @@ -52,6 +56,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@ant-design/cssinjs": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-2.0.2.tgz", @@ -71,6 +89,20 @@ "react-dom": ">=16.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -210,6 +242,128 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -951,6 +1105,63 @@ "url": "https://github.com/sponsors/kazupon" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1050,6 +1261,24 @@ "node": ">= 8" } }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@rc-component/util": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.7.0.tgz", @@ -1671,6 +1900,162 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", + "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.9", + "vitest": "2.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.15", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", @@ -1842,6 +2227,17 @@ "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", "license": "MIT" }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } + }, "node_modules/@vueuse/core": { "version": "10.11.1", "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz", @@ -1878,6 +2274,16 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1910,6 +2316,16 @@ "node": ">=0.8" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2025,6 +2441,16 @@ "node": ">=8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2188,6 +2614,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2254,6 +2690,23 @@ "node": ">=0.8" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2283,6 +2736,16 @@ "pnpm": ">=8" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2388,6 +2851,17 @@ "dev": true, "license": "MIT" }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -2450,12 +2924,47 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", @@ -2480,6 +2989,23 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2556,6 +3082,58 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz", + "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", + "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -2563,6 +3141,13 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/entities": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", @@ -2602,6 +3187,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2894,6 +3486,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3070,6 +3672,23 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -3366,6 +3985,67 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3421,6 +4101,13 @@ "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -3465,6 +4152,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3504,6 +4201,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3511,6 +4215,76 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -3521,6 +4295,59 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-beautify/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3540,6 +4367,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "24.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", + "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3652,6 +4530,20 @@ "dev": true, "license": "MIT" }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/lucide-react": { "version": "0.469.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz", @@ -3670,6 +4562,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3740,6 +4660,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3797,6 +4727,22 @@ "dev": true, "license": "MIT" }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -3850,6 +4796,13 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3930,6 +4883,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3960,6 +4920,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -4003,6 +4989,23 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -4012,6 +5015,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4257,12 +5277,32 @@ "node": ">= 0.8.0" } }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4273,6 +5313,13 @@ "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4323,6 +5370,13 @@ "node": ">=8.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -4425,6 +5479,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -4449,6 +5510,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -4485,6 +5566,26 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -4525,6 +5626,90 @@ "node": ">=0.8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -4538,6 +5723,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4605,6 +5804,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -4643,6 +5849,42 @@ "node": ">=14.0.0" } }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -4680,6 +5922,20 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4710,6 +5966,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4723,6 +6009,35 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -4803,6 +6118,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4844,6 +6169,17 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/use-merge-value": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-merge-value/-/use-merge-value-1.2.0.tgz", @@ -4920,6 +6256,29 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite-plugin-checker": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.9.3.tgz", @@ -5054,6 +6413,72 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -5092,6 +6517,13 @@ "vue": "^3.0.0-0 || ^2.7.0" } }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", + "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", + "dev": true, + "license": "MIT" + }, "node_modules/vue-demi": { "version": "0.14.10", "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", @@ -5196,6 +6628,77 @@ "typescript": ">=5.0.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/w3c-xmlserializer/node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5212,6 +6715,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wmf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", @@ -5240,6 +6760,107 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -5247,6 +6868,28 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xlsx": { "version": "0.18.5", "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", @@ -5278,6 +6921,13 @@ "node": ">=12" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yaml": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2a85f585..c984cd96 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,10 @@ "preview": "vite preview", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", "lint:check": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts", - "typecheck": "vue-tsc --noEmit" + "typecheck": "vue-tsc --noEmit", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@lobehub/icons": "^4.0.2", @@ -29,17 +32,21 @@ "@types/file-saver": "^2.0.7", "@types/mdx": "^2.0.13", "@types/node": "^20.10.5", - "@vitejs/plugin-vue": "^5.2.3", - "autoprefixer": "^10.4.16", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", + "@vitejs/plugin-vue": "^5.2.3", + "@vitest/coverage-v8": "^2.1.9", + "@vue/test-utils": "^2.4.6", + "autoprefixer": "^10.4.16", "eslint": "^8.57.0", "eslint-plugin-vue": "^9.25.0", + "jsdom": "^24.1.3", "postcss": "^8.4.32", "tailwindcss": "^3.4.0", "typescript": "~5.6.0", "vite": "^5.0.10", "vite-plugin-checker": "^0.9.1", + "vitest": "^2.1.9", "vue-tsc": "^2.2.0" } } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 8bae7b74..7b847c1b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,6 +2,7 @@ import { RouterView, useRouter, useRoute } from 'vue-router' import { onMounted, watch } from 'vue' import Toast from '@/components/common/Toast.vue' +import NavigationProgress from '@/components/common/NavigationProgress.vue' import { useAppStore, useAuthStore, useSubscriptionStore } from '@/stores' import { getSetupStatus } from '@/api/setup' @@ -84,6 +85,7 @@ onMounted(async () => { diff --git a/frontend/src/__tests__/integration/navigation.spec.ts b/frontend/src/__tests__/integration/navigation.spec.ts new file mode 100644 index 00000000..54e3e1c0 --- /dev/null +++ b/frontend/src/__tests__/integration/navigation.spec.ts @@ -0,0 +1,478 @@ +/** + * 导航集成测试 + * 测试完整的页面导航流程、预加载和错误恢复机制 + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { createRouter, createWebHistory, type Router } from 'vue-router' +import { createPinia, setActivePinia } from 'pinia' +import { mount, flushPromises } from '@vue/test-utils' +import { defineComponent, h, nextTick } from 'vue' +import { useNavigationLoadingState, _resetNavigationLoadingInstance } from '@/composables/useNavigationLoading' +import { useRoutePrefetch } from '@/composables/useRoutePrefetch' + +// Mock 视图组件 +const MockDashboard = defineComponent({ + name: 'MockDashboard', + render() { + return h('div', { class: 'dashboard' }, 'Dashboard') + } +}) + +const MockKeys = defineComponent({ + name: 'MockKeys', + render() { + return h('div', { class: 'keys' }, 'Keys') + } +}) + +const MockUsage = defineComponent({ + name: 'MockUsage', + render() { + return h('div', { class: 'usage' }, 'Usage') + } +}) + +// Mock stores +vi.mock('@/stores/auth', () => ({ + useAuthStore: () => ({ + isAuthenticated: true, + isAdmin: false, + isSimpleMode: false, + checkAuth: vi.fn() + }) +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ + siteName: 'Test Site' + }) +})) + +// 创建测试路由 +function createTestRouter(): Router { + return createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/', + redirect: '/dashboard' + }, + { + path: '/dashboard', + name: 'Dashboard', + component: MockDashboard, + meta: { requiresAuth: true, title: 'Dashboard' } + }, + { + path: '/keys', + name: 'Keys', + component: MockKeys, + meta: { requiresAuth: true, title: 'Keys' } + }, + { + path: '/usage', + name: 'Usage', + component: MockUsage, + meta: { requiresAuth: true, title: 'Usage' } + } + ] + }) +} + +// 测试用 App 组件 +const TestApp = defineComponent({ + name: 'TestApp', + setup() { + return () => h('div', { id: 'app' }, [h('router-view')]) + } +}) + +describe('Navigation Integration Tests', () => { + let router: Router + let originalRequestIdleCallback: typeof window.requestIdleCallback + let originalCancelIdleCallback: typeof window.cancelIdleCallback + + beforeEach(() => { + // 设置 Pinia + setActivePinia(createPinia()) + + // 重置导航加载状态 + _resetNavigationLoadingInstance() + + // 创建新的路由实例 + router = createTestRouter() + + // Mock requestIdleCallback + originalRequestIdleCallback = window.requestIdleCallback + originalCancelIdleCallback = window.cancelIdleCallback + + vi.stubGlobal('requestIdleCallback', (cb: IdleRequestCallback) => { + const id = setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0) + return id + }) + vi.stubGlobal('cancelIdleCallback', (id: number) => clearTimeout(id)) + }) + + afterEach(() => { + vi.restoreAllMocks() + window.requestIdleCallback = originalRequestIdleCallback + window.cancelIdleCallback = originalCancelIdleCallback + }) + + describe('完整页面导航流程', () => { + it('导航时应该触发加载状态变化', async () => { + const navigationLoading = useNavigationLoadingState() + + // 初始状态 + expect(navigationLoading.isLoading.value).toBe(false) + + // 挂载应用 + const wrapper = mount(TestApp, { + global: { + plugins: [router] + } + }) + + // 等待路由初始化 + await router.isReady() + await flushPromises() + + // 导航到 /dashboard + await router.push('/dashboard') + await flushPromises() + await nextTick() + + // 导航结束后状态应该重置 + expect(navigationLoading.isLoading.value).toBe(false) + + wrapper.unmount() + }) + + it('导航到新页面应该正确渲染组件', async () => { + const wrapper = mount(TestApp, { + global: { + plugins: [router] + } + }) + + await router.isReady() + await router.push('/dashboard') + await flushPromises() + await nextTick() + + // 检查当前路由 + expect(router.currentRoute.value.path).toBe('/dashboard') + + wrapper.unmount() + }) + + it('连续快速导航应该正确处理路由状态', async () => { + const wrapper = mount(TestApp, { + global: { + plugins: [router] + } + }) + + await router.isReady() + await router.push('/dashboard') + + // 快速连续导航 + router.push('/keys') + router.push('/usage') + router.push('/dashboard') + + await flushPromises() + await nextTick() + + // 应该最终停在 /dashboard + expect(router.currentRoute.value.path).toBe('/dashboard') + + wrapper.unmount() + }) + }) + + describe('路由预加载', () => { + it('导航后应该触发相关路由预加载', async () => { + const routePrefetch = useRoutePrefetch() + const triggerSpy = vi.spyOn(routePrefetch, 'triggerPrefetch') + + // 设置 afterEach 守卫 + router.afterEach((to) => { + routePrefetch.triggerPrefetch(to) + }) + + const wrapper = mount(TestApp, { + global: { + plugins: [router] + } + }) + + await router.isReady() + await router.push('/dashboard') + await flushPromises() + + // 应该触发预加载 + expect(triggerSpy).toHaveBeenCalled() + + wrapper.unmount() + }) + + it('已预加载的路由不应重复预加载', async () => { + const routePrefetch = useRoutePrefetch() + + const wrapper = mount(TestApp, { + global: { + plugins: [router] + } + }) + + await router.isReady() + await router.push('/dashboard') + await flushPromises() + + // 手动触发预加载 + routePrefetch.triggerPrefetch(router.currentRoute.value) + await new Promise((resolve) => setTimeout(resolve, 100)) + + const prefetchedCount = routePrefetch.prefetchedRoutes.value.size + + // 再次触发相同路由预加载 + routePrefetch.triggerPrefetch(router.currentRoute.value) + await new Promise((resolve) => setTimeout(resolve, 100)) + + // 预加载数量不应增加 + expect(routePrefetch.prefetchedRoutes.value.size).toBe(prefetchedCount) + + wrapper.unmount() + }) + + it('路由变化时应取消之前的预加载任务', async () => { + const routePrefetch = useRoutePrefetch() + + const wrapper = mount(TestApp, { + global: { + plugins: [router] + } + }) + + await router.isReady() + + // 触发预加载 + routePrefetch.triggerPrefetch(router.currentRoute.value) + + // 立即导航到新路由(这会在内部调用 cancelPendingPrefetch) + routePrefetch.triggerPrefetch({ path: '/keys' } as any) + + // 由于 triggerPrefetch 内部调用 cancelPendingPrefetch,检查是否有预加载被正确管理 + expect(routePrefetch.prefetchedRoutes.value.size).toBeLessThanOrEqual(2) + + wrapper.unmount() + }) + }) + + describe('Chunk 加载错误恢复', () => { + it('chunk 加载失败应该被正确捕获', async () => { + const errorHandler = vi.fn() + + // 创建带错误处理的路由 + const errorRouter = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/dashboard', + name: 'Dashboard', + component: MockDashboard + }, + { + path: '/error-page', + name: 'ErrorPage', + // 模拟加载失败的组件 + component: () => Promise.reject(new Error('Failed to fetch dynamically imported module')) + } + ] + }) + + errorRouter.onError(errorHandler) + + const wrapper = mount(TestApp, { + global: { + plugins: [errorRouter] + } + }) + + await errorRouter.isReady() + await errorRouter.push('/dashboard') + await flushPromises() + + // 尝试导航到会失败的页面 + try { + await errorRouter.push('/error-page') + } catch { + // 预期会失败 + } + + await flushPromises() + + // 错误处理器应该被调用 + expect(errorHandler).toHaveBeenCalled() + + wrapper.unmount() + }) + + it('chunk 加载错误应该包含正确的错误信息', async () => { + let capturedError: Error | null = null + + const errorRouter = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/dashboard', + name: 'Dashboard', + component: MockDashboard + }, + { + path: '/chunk-error', + name: 'ChunkError', + component: () => { + const error = new Error('Loading chunk failed') + error.name = 'ChunkLoadError' + return Promise.reject(error) + } + } + ] + }) + + errorRouter.onError((error) => { + capturedError = error + }) + + const wrapper = mount(TestApp, { + global: { + plugins: [errorRouter] + } + }) + + await errorRouter.isReady() + + try { + await errorRouter.push('/chunk-error') + } catch { + // 预期会失败 + } + + await flushPromises() + + expect(capturedError).not.toBeNull() + expect(capturedError!.name).toBe('ChunkLoadError') + + wrapper.unmount() + }) + }) + + describe('导航状态管理', () => { + it('导航开始时 isLoading 应该变为 true', async () => { + const navigationLoading = useNavigationLoadingState() + + // 创建一个延迟加载的组件来模拟真实场景 + const DelayedComponent = defineComponent({ + name: 'DelayedComponent', + async setup() { + await new Promise((resolve) => setTimeout(resolve, 50)) + return () => h('div', 'Delayed') + } + }) + + const delayRouter = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/dashboard', + name: 'Dashboard', + component: MockDashboard + }, + { + path: '/delayed', + name: 'Delayed', + component: DelayedComponent + } + ] + }) + + // 设置导航守卫 + delayRouter.beforeEach(() => { + navigationLoading.startNavigation() + }) + + delayRouter.afterEach(() => { + navigationLoading.endNavigation() + }) + + const wrapper = mount(TestApp, { + global: { + plugins: [delayRouter] + } + }) + + await delayRouter.isReady() + await delayRouter.push('/dashboard') + await flushPromises() + + // 导航结束后 isLoading 应该为 false + expect(navigationLoading.isLoading.value).toBe(false) + + wrapper.unmount() + }) + + it('导航取消时应该正确重置状态', async () => { + const navigationLoading = useNavigationLoadingState() + + const testRouter = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/dashboard', + name: 'Dashboard', + component: MockDashboard + }, + { + path: '/keys', + name: 'Keys', + component: MockKeys, + beforeEnter: (_to, _from, next) => { + // 模拟导航取消 + next(false) + } + } + ] + }) + + testRouter.beforeEach(() => { + navigationLoading.startNavigation() + }) + + testRouter.afterEach(() => { + navigationLoading.endNavigation() + }) + + const wrapper = mount(TestApp, { + global: { + plugins: [testRouter] + } + }) + + await testRouter.isReady() + await testRouter.push('/dashboard') + await flushPromises() + + // 尝试导航到被取消的路由 + await testRouter.push('/keys').catch(() => {}) + await flushPromises() + + // 导航被取消后,状态应该被重置 + // 注意:由于 afterEach 仍然会被调用,isLoading 应该为 false + expect(navigationLoading.isLoading.value).toBe(false) + + wrapper.unmount() + }) + }) +}) diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts new file mode 100644 index 00000000..decb2a37 --- /dev/null +++ b/frontend/src/__tests__/setup.ts @@ -0,0 +1,45 @@ +/** + * Vitest 测试环境设置 + * 提供全局 mock 和测试工具 + */ +import { config } from '@vue/test-utils' +import { vi } from 'vitest' + +// Mock requestIdleCallback (Safari < 15 不支持) +if (typeof globalThis.requestIdleCallback === 'undefined') { + globalThis.requestIdleCallback = ((callback: IdleRequestCallback) => { + return window.setTimeout(() => callback({ didTimeout: false, timeRemaining: () => 50 }), 1) + }) as unknown as typeof requestIdleCallback +} + +if (typeof globalThis.cancelIdleCallback === 'undefined') { + globalThis.cancelIdleCallback = ((id: number) => { + window.clearTimeout(id) + }) as unknown as typeof cancelIdleCallback +} + +// Mock IntersectionObserver +class MockIntersectionObserver { + observe = vi.fn() + disconnect = vi.fn() + unobserve = vi.fn() +} + +globalThis.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver + +// Mock ResizeObserver +class MockResizeObserver { + observe = vi.fn() + disconnect = vi.fn() + unobserve = vi.fn() +} + +globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver + +// Vue Test Utils 全局配置 +config.global.stubs = { + // 可以在这里添加全局 stub +} + +// 设置全局测试超时 +vi.setConfig({ testTimeout: 10000 }) diff --git a/frontend/src/components/common/NavigationProgress.vue b/frontend/src/components/common/NavigationProgress.vue new file mode 100644 index 00000000..d6ea47ee --- /dev/null +++ b/frontend/src/components/common/NavigationProgress.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/frontend/src/components/common/__tests__/NavigationProgress.spec.ts b/frontend/src/components/common/__tests__/NavigationProgress.spec.ts new file mode 100644 index 00000000..ca8b0229 --- /dev/null +++ b/frontend/src/components/common/__tests__/NavigationProgress.spec.ts @@ -0,0 +1,83 @@ +/** + * NavigationProgress 组件单元测试 + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount } from '@vue/test-utils' +import { ref } from 'vue' +import NavigationProgress from '../../common/NavigationProgress.vue' + +// Mock useNavigationLoadingState +const mockIsLoading = ref(false) + +vi.mock('@/composables/useNavigationLoading', () => ({ + useNavigationLoadingState: () => ({ + isLoading: mockIsLoading + }) +})) + +describe('NavigationProgress', () => { + beforeEach(() => { + mockIsLoading.value = false + }) + + it('isLoading=false 时进度条应该隐藏', () => { + mockIsLoading.value = false + const wrapper = mount(NavigationProgress) + + const progressBar = wrapper.find('.navigation-progress') + // v-show 会设置 display: none + expect(progressBar.isVisible()).toBe(false) + }) + + it('isLoading=true 时进度条应该可见', async () => { + mockIsLoading.value = true + const wrapper = mount(NavigationProgress) + + await wrapper.vm.$nextTick() + + const progressBar = wrapper.find('.navigation-progress') + expect(progressBar.exists()).toBe(true) + expect(progressBar.isVisible()).toBe(true) + }) + + it('应该有正确的 ARIA 属性', () => { + mockIsLoading.value = true + const wrapper = mount(NavigationProgress) + + const progressBar = wrapper.find('.navigation-progress') + expect(progressBar.attributes('role')).toBe('progressbar') + expect(progressBar.attributes('aria-label')).toBe('Loading') + expect(progressBar.attributes('aria-valuemin')).toBe('0') + expect(progressBar.attributes('aria-valuemax')).toBe('100') + }) + + it('进度条应该有动画 class', () => { + mockIsLoading.value = true + const wrapper = mount(NavigationProgress) + + const bar = wrapper.find('.navigation-progress-bar') + expect(bar.exists()).toBe(true) + }) + + it('应该正确响应 isLoading 状态变化', async () => { + // 测试初始状态为 false + mockIsLoading.value = false + const wrapper = mount(NavigationProgress) + await wrapper.vm.$nextTick() + + // 初始状态隐藏 + expect(wrapper.find('.navigation-progress').isVisible()).toBe(false) + + // 卸载后重新挂载以测试 true 状态 + wrapper.unmount() + + // 改变为 true 后重新挂载 + mockIsLoading.value = true + const wrapper2 = mount(NavigationProgress) + await wrapper2.vm.$nextTick() + expect(wrapper2.find('.navigation-progress').isVisible()).toBe(true) + + // 清理 + wrapper2.unmount() + }) +}) diff --git a/frontend/src/composables/__tests__/useNavigationLoading.spec.ts b/frontend/src/composables/__tests__/useNavigationLoading.spec.ts new file mode 100644 index 00000000..624890ab --- /dev/null +++ b/frontend/src/composables/__tests__/useNavigationLoading.spec.ts @@ -0,0 +1,176 @@ +/** + * useNavigationLoading 组合式函数单元测试 + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + useNavigationLoading, + _resetNavigationLoadingInstance +} from '../useNavigationLoading' + +describe('useNavigationLoading', () => { + beforeEach(() => { + vi.useFakeTimers() + _resetNavigationLoadingInstance() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('startNavigation', () => { + it('导航开始时 isNavigating 应变为 true', () => { + const { isNavigating, startNavigation } = useNavigationLoading() + + expect(isNavigating.value).toBe(false) + + startNavigation() + + expect(isNavigating.value).toBe(true) + }) + + it('导航开始后延迟显示加载指示器(防闪烁)', () => { + const { isLoading, startNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading() + + startNavigation() + + // 立即检查,不应该显示 + expect(isLoading.value).toBe(false) + + // 经过防闪烁延迟后应该显示 + vi.advanceTimersByTime(ANTI_FLICKER_DELAY) + expect(isLoading.value).toBe(true) + }) + }) + + describe('endNavigation', () => { + it('导航结束时 isLoading 应变为 false', () => { + const { isLoading, startNavigation, endNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading() + + startNavigation() + vi.advanceTimersByTime(ANTI_FLICKER_DELAY) + expect(isLoading.value).toBe(true) + + endNavigation() + expect(isLoading.value).toBe(false) + }) + + it('导航结束时 isNavigating 应变为 false', () => { + const { isNavigating, startNavigation, endNavigation } = useNavigationLoading() + + startNavigation() + expect(isNavigating.value).toBe(true) + + endNavigation() + expect(isNavigating.value).toBe(false) + }) + }) + + describe('快速导航(< 100ms)防闪烁', () => { + it('快速导航不应触发显示加载指示器', () => { + const { isLoading, startNavigation, endNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading() + + startNavigation() + + // 在防闪烁延迟之前结束导航 + vi.advanceTimersByTime(ANTI_FLICKER_DELAY - 50) + endNavigation() + + // 不应该显示加载指示器 + expect(isLoading.value).toBe(false) + + // 即使继续等待也不应该显示 + vi.advanceTimersByTime(100) + expect(isLoading.value).toBe(false) + }) + }) + + describe('cancelNavigation', () => { + it('导航取消时应正确重置状态', () => { + const { isLoading, startNavigation, cancelNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading() + + startNavigation() + vi.advanceTimersByTime(ANTI_FLICKER_DELAY / 2) + + cancelNavigation() + + // 取消后不应该触发显示 + vi.advanceTimersByTime(ANTI_FLICKER_DELAY) + expect(isLoading.value).toBe(false) + }) + }) + + describe('getNavigationDuration', () => { + it('应该返回正确的导航持续时间', () => { + const { startNavigation, getNavigationDuration } = useNavigationLoading() + + expect(getNavigationDuration()).toBeNull() + + startNavigation() + vi.advanceTimersByTime(500) + + const duration = getNavigationDuration() + expect(duration).toBe(500) + }) + + it('导航结束后应返回 null', () => { + const { startNavigation, endNavigation, getNavigationDuration } = useNavigationLoading() + + startNavigation() + vi.advanceTimersByTime(500) + endNavigation() + + expect(getNavigationDuration()).toBeNull() + }) + }) + + describe('resetState', () => { + it('应该重置所有状态', () => { + const { isLoading, isNavigating, startNavigation, resetState, ANTI_FLICKER_DELAY } = useNavigationLoading() + + startNavigation() + vi.advanceTimersByTime(ANTI_FLICKER_DELAY) + + expect(isLoading.value).toBe(true) + expect(isNavigating.value).toBe(true) + + resetState() + + expect(isLoading.value).toBe(false) + expect(isNavigating.value).toBe(false) + }) + }) + + describe('连续导航场景', () => { + it('连续快速导航应正确处理状态', () => { + const { isLoading, startNavigation, cancelNavigation, endNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading() + + // 第一次导航 + startNavigation() + vi.advanceTimersByTime(30) + + // 第二次导航(取消第一次) + cancelNavigation() + startNavigation() + vi.advanceTimersByTime(30) + + // 第三次导航(取消第二次) + cancelNavigation() + startNavigation() + + // 这次等待足够长时间 + vi.advanceTimersByTime(ANTI_FLICKER_DELAY) + expect(isLoading.value).toBe(true) + + // 结束导航 + endNavigation() + expect(isLoading.value).toBe(false) + }) + }) + + describe('ANTI_FLICKER_DELAY 常量', () => { + it('应该为 100ms', () => { + const { ANTI_FLICKER_DELAY } = useNavigationLoading() + expect(ANTI_FLICKER_DELAY).toBe(100) + }) + }) +}) diff --git a/frontend/src/composables/__tests__/useRoutePrefetch.spec.ts b/frontend/src/composables/__tests__/useRoutePrefetch.spec.ts new file mode 100644 index 00000000..7c556bec --- /dev/null +++ b/frontend/src/composables/__tests__/useRoutePrefetch.spec.ts @@ -0,0 +1,219 @@ +/** + * useRoutePrefetch 组合式函数单元测试 + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { RouteLocationNormalized } from 'vue-router' + +// Mock 所有动态 import +vi.mock('@/views/admin/AccountsView.vue', () => ({ default: {} })) +vi.mock('@/views/admin/UsersView.vue', () => ({ default: {} })) +vi.mock('@/views/admin/DashboardView.vue', () => ({ default: {} })) +vi.mock('@/views/admin/GroupsView.vue', () => ({ default: {} })) +vi.mock('@/views/admin/SubscriptionsView.vue', () => ({ default: {} })) +vi.mock('@/views/admin/RedeemView.vue', () => ({ default: {} })) +vi.mock('@/views/user/KeysView.vue', () => ({ default: {} })) +vi.mock('@/views/user/UsageView.vue', () => ({ default: {} })) +vi.mock('@/views/user/DashboardView.vue', () => ({ default: {} })) +vi.mock('@/views/user/RedeemView.vue', () => ({ default: {} })) +vi.mock('@/views/user/ProfileView.vue', () => ({ default: {} })) + +import { useRoutePrefetch, _adminPrefetchMap, _userPrefetchMap } from '../useRoutePrefetch' + +// Mock 路由对象 +const createMockRoute = (path: string): RouteLocationNormalized => ({ + path, + name: undefined, + params: {}, + query: {}, + hash: '', + fullPath: path, + matched: [], + meta: {}, + redirectedFrom: undefined +}) + +describe('useRoutePrefetch', () => { + let originalRequestIdleCallback: typeof window.requestIdleCallback + let originalCancelIdleCallback: typeof window.cancelIdleCallback + + beforeEach(() => { + // 保存原始函数 + originalRequestIdleCallback = window.requestIdleCallback + originalCancelIdleCallback = window.cancelIdleCallback + + // Mock requestIdleCallback 立即执行 + vi.stubGlobal('requestIdleCallback', (cb: IdleRequestCallback) => { + const id = setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0) + return id + }) + vi.stubGlobal('cancelIdleCallback', (id: number) => clearTimeout(id)) + }) + + afterEach(() => { + vi.restoreAllMocks() + // 恢复原始函数 + window.requestIdleCallback = originalRequestIdleCallback + window.cancelIdleCallback = originalCancelIdleCallback + }) + + describe('_isAdminRoute', () => { + it('应该正确识别管理员路由', () => { + const { _isAdminRoute } = useRoutePrefetch() + expect(_isAdminRoute('/admin/dashboard')).toBe(true) + expect(_isAdminRoute('/admin/users')).toBe(true) + expect(_isAdminRoute('/admin/accounts')).toBe(true) + }) + + it('应该正确识别非管理员路由', () => { + const { _isAdminRoute } = useRoutePrefetch() + expect(_isAdminRoute('/dashboard')).toBe(false) + expect(_isAdminRoute('/keys')).toBe(false) + expect(_isAdminRoute('/usage')).toBe(false) + }) + }) + + describe('_getPrefetchConfig', () => { + it('管理员 dashboard 应该返回正确的预加载配置', () => { + const { _getPrefetchConfig } = useRoutePrefetch() + const route = createMockRoute('/admin/dashboard') + const config = _getPrefetchConfig(route) + + expect(config).toHaveLength(2) + }) + + it('普通用户 dashboard 应该返回正确的预加载配置', () => { + const { _getPrefetchConfig } = useRoutePrefetch() + const route = createMockRoute('/dashboard') + const config = _getPrefetchConfig(route) + + expect(config).toHaveLength(2) + }) + + it('未定义的路由应该返回空数组', () => { + const { _getPrefetchConfig } = useRoutePrefetch() + const route = createMockRoute('/unknown-route') + const config = _getPrefetchConfig(route) + + expect(config).toHaveLength(0) + }) + }) + + describe('triggerPrefetch', () => { + it('应该在浏览器空闲时触发预加载', async () => { + const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch() + const route = createMockRoute('/admin/dashboard') + + triggerPrefetch(route) + + // 等待 requestIdleCallback 执行 + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(prefetchedRoutes.value.has('/admin/dashboard')).toBe(true) + }) + + it('应该避免重复预加载同一路由', async () => { + const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch() + const route = createMockRoute('/admin/dashboard') + + triggerPrefetch(route) + await new Promise((resolve) => setTimeout(resolve, 100)) + + // 第二次触发 + triggerPrefetch(route) + await new Promise((resolve) => setTimeout(resolve, 100)) + + // 只应该预加载一次 + expect(prefetchedRoutes.value.size).toBe(1) + }) + }) + + describe('cancelPendingPrefetch', () => { + it('应该取消挂起的预加载任务', () => { + const { triggerPrefetch, cancelPendingPrefetch, prefetchedRoutes } = useRoutePrefetch() + const route = createMockRoute('/admin/dashboard') + + triggerPrefetch(route) + cancelPendingPrefetch() + + // 不应该有预加载完成 + expect(prefetchedRoutes.value.size).toBe(0) + }) + }) + + describe('路由变化时取消之前的预加载', () => { + it('应该在路由变化时取消之前的预加载任务', async () => { + const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch() + + // 触发第一个路由的预加载 + triggerPrefetch(createMockRoute('/admin/dashboard')) + + // 立即切换到另一个路由 + triggerPrefetch(createMockRoute('/admin/users')) + + // 等待执行 + await new Promise((resolve) => setTimeout(resolve, 100)) + + // 只有最后一个路由应该被预加载 + expect(prefetchedRoutes.value.has('/admin/users')).toBe(true) + }) + }) + + describe('resetPrefetchState', () => { + it('应该重置所有预加载状态', async () => { + const { triggerPrefetch, resetPrefetchState, prefetchedRoutes } = useRoutePrefetch() + const route = createMockRoute('/admin/dashboard') + + triggerPrefetch(route) + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(prefetchedRoutes.value.size).toBeGreaterThan(0) + + resetPrefetchState() + + expect(prefetchedRoutes.value.size).toBe(0) + }) + }) + + describe('预加载映射表', () => { + it('管理员预加载映射表应该包含正确的路由', () => { + expect(_adminPrefetchMap).toHaveProperty('/admin/dashboard') + expect(_adminPrefetchMap['/admin/dashboard']).toHaveLength(2) + }) + + it('用户预加载映射表应该包含正确的路由', () => { + expect(_userPrefetchMap).toHaveProperty('/dashboard') + expect(_userPrefetchMap['/dashboard']).toHaveLength(2) + }) + }) + + describe('requestIdleCallback 超时处理', () => { + it('超时后仍能正常执行预加载', async () => { + // 模拟超时情况 + vi.stubGlobal('requestIdleCallback', (cb: IdleRequestCallback, options?: IdleRequestOptions) => { + const timeout = options?.timeout || 2000 + return setTimeout(() => cb({ didTimeout: true, timeRemaining: () => 0 }), timeout) + }) + + const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch() + const route = createMockRoute('/dashboard') + + triggerPrefetch(route) + + // 等待超时执行 + await new Promise((resolve) => setTimeout(resolve, 2100)) + + expect(prefetchedRoutes.value.has('/dashboard')).toBe(true) + }) + }) + + describe('预加载失败处理', () => { + it('预加载失败时应该静默处理不影响页面功能', async () => { + // 这个测试验证预加载失败不会抛出异常 + const { triggerPrefetch } = useRoutePrefetch() + const route = createMockRoute('/admin/dashboard') + + // 不应该抛出异常 + expect(() => triggerPrefetch(route)).not.toThrow() + }) + }) +}) diff --git a/frontend/src/composables/useNavigationLoading.ts b/frontend/src/composables/useNavigationLoading.ts new file mode 100644 index 00000000..172029c3 --- /dev/null +++ b/frontend/src/composables/useNavigationLoading.ts @@ -0,0 +1,132 @@ +/** + * 导航加载状态组合式函数 + * 管理路由切换时的加载状态,支持防闪烁逻辑 + */ +import { ref, readonly, computed } from 'vue' + +/** + * 导航加载状态管理 + * + * 功能: + * 1. 在路由切换时显示加载状态 + * 2. 快速导航(< 100ms)不显示加载指示器(防闪烁) + * 3. 导航取消时正确重置状态 + */ +export function useNavigationLoading() { + // 内部加载状态 + const _isLoading = ref(false) + + // 导航开始时间(用于防闪烁计算) + let navigationStartTime: number | null = null + + // 防闪烁延迟计时器 + let showLoadingTimer: ReturnType | null = null + + // 是否应该显示加载指示器(考虑防闪烁逻辑) + const shouldShowLoading = ref(false) + + // 防闪烁延迟时间(毫秒) + const ANTI_FLICKER_DELAY = 100 + + /** + * 清理计时器 + */ + const clearTimer = (): void => { + if (showLoadingTimer !== null) { + clearTimeout(showLoadingTimer) + showLoadingTimer = null + } + } + + /** + * 导航开始时调用 + */ + const startNavigation = (): void => { + navigationStartTime = Date.now() + _isLoading.value = true + + // 延迟显示加载指示器,实现防闪烁 + clearTimer() + showLoadingTimer = setTimeout(() => { + if (_isLoading.value) { + shouldShowLoading.value = true + } + }, ANTI_FLICKER_DELAY) + } + + /** + * 导航结束时调用 + */ + const endNavigation = (): void => { + clearTimer() + _isLoading.value = false + shouldShowLoading.value = false + navigationStartTime = null + } + + /** + * 导航取消时调用(比如快速连续点击不同链接) + */ + const cancelNavigation = (): void => { + clearTimer() + // 保持加载状态,因为新的导航会立即开始 + // 但重置导航开始时间 + navigationStartTime = null + } + + /** + * 重置所有状态(用于测试) + */ + const resetState = (): void => { + clearTimer() + _isLoading.value = false + shouldShowLoading.value = false + navigationStartTime = null + } + + /** + * 获取导航持续时间(毫秒) + */ + const getNavigationDuration = (): number | null => { + if (navigationStartTime === null) { + return null + } + return Date.now() - navigationStartTime + } + + // 公开的加载状态(只读) + const isLoading = computed(() => shouldShowLoading.value) + + // 内部加载状态(用于测试,不考虑防闪烁) + const isNavigating = readonly(_isLoading) + + return { + isLoading, + isNavigating, + startNavigation, + endNavigation, + cancelNavigation, + resetState, + getNavigationDuration, + // 导出常量用于测试 + ANTI_FLICKER_DELAY + } +} + +// 创建单例实例,供全局使用 +let navigationLoadingInstance: ReturnType | null = null + +export function useNavigationLoadingState() { + if (!navigationLoadingInstance) { + navigationLoadingInstance = useNavigationLoading() + } + return navigationLoadingInstance +} + +// 导出重置函数(用于测试) +export function _resetNavigationLoadingInstance(): void { + if (navigationLoadingInstance) { + navigationLoadingInstance.resetState() + } + navigationLoadingInstance = null +} diff --git a/frontend/src/composables/useRoutePrefetch.ts b/frontend/src/composables/useRoutePrefetch.ts new file mode 100644 index 00000000..854305ce --- /dev/null +++ b/frontend/src/composables/useRoutePrefetch.ts @@ -0,0 +1,304 @@ +/** + * 路由预加载组合式函数 + * 在浏览器空闲时预加载可能访问的下一个页面,提升导航体验 + */ +import { ref, readonly } from 'vue' +import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router' + +/** + * 组件导入函数类型 + */ +type ComponentImportFn = () => Promise + +/** + * 预加载配置类型 + */ +interface PrefetchConfig { + [path: string]: ComponentImportFn[] +} + +/** + * 路由预加载元数据扩展 + * 在路由 meta 中可以指定 prefetch 配置 + */ +declare module 'vue-router' { + interface RouteMeta { + /** 需要预加载的路由路径列表 */ + prefetch?: string[] + } +} + +/** + * requestIdleCallback 的返回类型 + * 在支持的浏览器中返回 number,polyfill 中使用 ReturnType + */ +type IdleCallbackHandle = number | ReturnType + +/** + * requestIdleCallback polyfill + * Safari < 15 不支持 requestIdleCallback + */ +const scheduleIdleCallback = ( + callback: IdleRequestCallback, + options?: IdleRequestOptions +): IdleCallbackHandle => { + if (typeof window.requestIdleCallback === 'function') { + return window.requestIdleCallback(callback, options) + } + // Fallback: 使用 setTimeout 模拟,延迟 1 秒执行 + return setTimeout(() => { + callback({ + didTimeout: false, + timeRemaining: () => 50 + }) + }, 1000) +} + +const cancelScheduledCallback = (handle: IdleCallbackHandle): void => { + if (typeof window.cancelIdleCallback === 'function' && typeof handle === 'number') { + window.cancelIdleCallback(handle) + } else { + clearTimeout(handle) + } +} + +/** + * 从路由配置自动生成预加载映射表 + * 根据路由的 meta.prefetch 配置和同级路由自动生成 + * + * @param routes - 路由配置数组 + * @returns 预加载映射表 + */ +export function generatePrefetchMap(routes: RouteRecordRaw[]): PrefetchConfig { + const prefetchMap: PrefetchConfig = {} + const routeComponentMap = new Map() + + // 第一遍:收集所有路由的组件导入函数 + const collectComponents = (routeList: RouteRecordRaw[], prefix = '') => { + for (const route of routeList) { + if (route.redirect) continue + + const fullPath = prefix + route.path + if (route.component && typeof route.component === 'function') { + routeComponentMap.set(fullPath, route.component as ComponentImportFn) + } + + // 递归处理子路由 + if (route.children) { + collectComponents(route.children, fullPath) + } + } + } + + collectComponents(routes) + + // 第二遍:根据 meta.prefetch 或同级路由生成预加载映射 + const generateMapping = (routeList: RouteRecordRaw[], siblings: RouteRecordRaw[] = []) => { + for (let i = 0; i < routeList.length; i++) { + const route = routeList[i] + if (route.redirect || !route.component) continue + + const path = route.path + const prefetchPaths: string[] = [] + + // 优先使用 meta.prefetch 配置 + if (route.meta?.prefetch && Array.isArray(route.meta.prefetch)) { + prefetchPaths.push(...route.meta.prefetch) + } else { + // 自动预加载相邻的同级路由(前后各一个) + const siblingRoutes = siblings.length > 0 ? siblings : routeList + const currentIndex = siblingRoutes.findIndex((r) => r.path === path) + + if (currentIndex > 0) { + const prev = siblingRoutes[currentIndex - 1] + if (prev && !prev.redirect && prev.component) { + prefetchPaths.push(prev.path) + } + } + if (currentIndex < siblingRoutes.length - 1) { + const next = siblingRoutes[currentIndex + 1] + if (next && !next.redirect && next.component) { + prefetchPaths.push(next.path) + } + } + } + + // 转换为组件导入函数 + const importFns: ComponentImportFn[] = [] + for (const prefetchPath of prefetchPaths) { + const importFn = routeComponentMap.get(prefetchPath) + if (importFn) { + importFns.push(importFn) + } + } + + if (importFns.length > 0) { + prefetchMap[path] = importFns + } + + // 递归处理子路由 + if (route.children) { + generateMapping(route.children, route.children) + } + } + } + + // 分别处理用户路由和管理员路由 + const userRoutes = routes.filter( + (r) => !r.path.startsWith('/admin') && !r.path.startsWith('/auth') && !r.path.startsWith('/setup') + ) + const adminRoutes = routes.filter((r) => r.path.startsWith('/admin')) + + generateMapping(userRoutes, userRoutes) + generateMapping(adminRoutes, adminRoutes) + + return prefetchMap +} + +/** + * 默认预加载映射表(手动配置,优先级更高) + * 可以覆盖自动生成的映射 + */ +const defaultAdminPrefetchMap: PrefetchConfig = { + '/admin/dashboard': [ + () => import('@/views/admin/AccountsView.vue'), + () => import('@/views/admin/UsersView.vue') + ], + '/admin/accounts': [ + () => import('@/views/admin/DashboardView.vue'), + () => import('@/views/admin/UsersView.vue') + ], + '/admin/users': [ + () => import('@/views/admin/GroupsView.vue'), + () => import('@/views/admin/DashboardView.vue') + ] +} + +const defaultUserPrefetchMap: PrefetchConfig = { + '/dashboard': [ + () => import('@/views/user/KeysView.vue'), + () => import('@/views/user/UsageView.vue') + ], + '/keys': [ + () => import('@/views/user/DashboardView.vue'), + () => import('@/views/user/UsageView.vue') + ], + '/usage': [ + () => import('@/views/user/KeysView.vue'), + () => import('@/views/user/RedeemView.vue') + ] +} + +/** + * 路由预加载组合式函数 + * + * @param customPrefetchMap - 自定义预加载映射表(可选) + */ +export function useRoutePrefetch(customPrefetchMap?: PrefetchConfig) { + // 合并预加载映射表:自定义 > 默认管理员 > 默认用户 + const prefetchMap: PrefetchConfig = { + ...defaultUserPrefetchMap, + ...defaultAdminPrefetchMap, + ...customPrefetchMap + } + + // 当前挂起的预加载任务句柄 + const pendingPrefetchHandle = ref(null) + + // 已预加载的路由集合(避免重复预加载) + const prefetchedRoutes = ref>(new Set()) + + /** + * 判断是否为管理员路由 + */ + const isAdminRoute = (path: string): boolean => { + return path.startsWith('/admin') + } + + /** + * 获取当前路由对应的预加载配置 + */ + const getPrefetchConfig = (route: RouteLocationNormalized): ComponentImportFn[] => { + return prefetchMap[route.path] || [] + } + + /** + * 执行单个组件的预加载 + * 静默处理错误,不影响页面功能 + */ + const prefetchComponent = async (importFn: ComponentImportFn): Promise => { + try { + await importFn() + } catch (error) { + // 静默处理预加载错误 + if (import.meta.env.DEV) { + console.debug('[Prefetch] Failed to prefetch component:', error) + } + } + } + + /** + * 取消挂起的预加载任务 + */ + const cancelPendingPrefetch = (): void => { + if (pendingPrefetchHandle.value !== null) { + cancelScheduledCallback(pendingPrefetchHandle.value) + pendingPrefetchHandle.value = null + } + } + + /** + * 触发路由预加载 + * 在浏览器空闲时执行,超时 2 秒后强制执行 + */ + const triggerPrefetch = (route: RouteLocationNormalized): void => { + // 取消之前的预加载任务 + cancelPendingPrefetch() + + const prefetchList = getPrefetchConfig(route) + if (prefetchList.length === 0) { + return + } + + // 在浏览器空闲时执行预加载 + pendingPrefetchHandle.value = scheduleIdleCallback( + () => { + pendingPrefetchHandle.value = null + + // 过滤掉已预加载的组件 + const routePath = route.path + if (prefetchedRoutes.value.has(routePath)) { + return + } + + // 执行预加载 + Promise.all(prefetchList.map(prefetchComponent)).then(() => { + prefetchedRoutes.value.add(routePath) + }) + }, + { timeout: 2000 } // 2 秒超时 + ) + } + + /** + * 重置预加载状态(用于测试) + */ + const resetPrefetchState = (): void => { + cancelPendingPrefetch() + prefetchedRoutes.value.clear() + } + + return { + prefetchedRoutes: readonly(prefetchedRoutes), + triggerPrefetch, + cancelPendingPrefetch, + resetPrefetchState, + // 导出用于测试 + _getPrefetchConfig: getPrefetchConfig, + _isAdminRoute: isAdminRoute + } +} + +// 导出预加载映射表(用于测试) +export const _adminPrefetchMap = defaultAdminPrefetchMap +export const _userPrefetchMap = defaultUserPrefetchMap diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 9c507797..bdfa1fec 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -6,6 +6,8 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import { useAuthStore } from '@/stores/auth' import { useAppStore } from '@/stores/app' +import { useNavigationLoadingState } from '@/composables/useNavigationLoading' +import { useRoutePrefetch } from '@/composables/useRoutePrefetch' /** * Route definitions with lazy loading @@ -326,7 +328,14 @@ const router = createRouter({ */ let authInitialized = false +// 初始化导航加载状态和预加载 +const navigationLoading = useNavigationLoadingState() +const routePrefetch = useRoutePrefetch() + router.beforeEach((to, _from, next) => { + // 开始导航加载状态 + navigationLoading.startNavigation() + const authStore = useAuthStore() // Restore auth state from localStorage on first navigation (page refresh) @@ -398,6 +407,17 @@ router.beforeEach((to, _from, next) => { next() }) +/** + * Navigation guard: End loading and trigger prefetch + */ +router.afterEach((to) => { + // 结束导航加载状态 + navigationLoading.endNavigation() + + // 触发路由预加载(在浏览器空闲时执行) + routePrefetch.triggerPrefetch(to) +}) + /** * Navigation guard: Error handling * Handles dynamic import failures caused by deployment updates diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 8376ce61..267158ea 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -58,7 +58,49 @@ export default defineConfig({ }, build: { outDir: '../backend/internal/web/dist', - emptyOutDir: true + emptyOutDir: true, + rollupOptions: { + output: { + /** + * 手动分包配置 + * 分离第三方库并按功能合并应用代码,避免循环依赖 + */ + manualChunks(id: string) { + if (id.includes('node_modules')) { + // Vue 核心库 + if ( + id.includes('/vue/') || + id.includes('/vue-router/') || + id.includes('/pinia/') || + id.includes('/@vue/') + ) { + return 'vendor-vue' + } + + // UI 工具库(较大,单独分离) + if (id.includes('/@vueuse/') || id.includes('/xlsx/')) { + return 'vendor-ui' + } + + // 图表库 + if (id.includes('/chart.js/') || id.includes('/vue-chartjs/')) { + return 'vendor-chart' + } + + // 国际化 + if (id.includes('/vue-i18n/') || id.includes('/@intlify/')) { + return 'vendor-i18n' + } + + // 其他小型第三方库合并 + return 'vendor-misc' + } + + // 应用代码:按入口点自动分包,不手动干预 + // 这样可以避免循环依赖,同时保持合理的 chunk 数量 + } + } + } }, server: { host: '0.0.0.0', diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 00000000..0b20cb60 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import viteConfig from './vite.config' + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + globals: true, + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'], + exclude: ['node_modules', 'dist'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.{js,ts,vue}'], + exclude: [ + 'node_modules', + 'src/**/*.d.ts', + 'src/**/*.spec.ts', + 'src/**/*.test.ts', + 'src/main.ts' + ], + thresholds: { + global: { + statements: 80, + branches: 80, + functions: 80, + lines: 80 + } + } + }, + setupFiles: ['./src/__tests__/setup.ts'] + } + }) +)