diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..b3764fc --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,55 @@ +# Gitea Actions CI — 合并门禁(与 GitHub Actions 语法兼容) +# 后端: gofmt/vet/test;前端: lint/build/test。全部通过才允许合并。 +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + backend: + name: Backend (go vet + test) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: backend/go.mod + cache-dependency-path: backend/go.sum + - name: gofmt check + working-directory: backend + run: | + unformatted=$(gofmt -l .) + if [ -n "$unformatted" ]; then + echo "::error::以下文件未通过 gofmt:"; echo "$unformatted"; exit 1 + fi + - name: go vet + working-directory: backend + run: go vet ./... + - name: go test + working-directory: backend + run: go test ./... + + frontend: + name: Frontend (lint + build + test) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: npm ci + working-directory: frontend + run: npm ci + - name: lint + working-directory: frontend + run: npm run lint + - name: test + working-directory: frontend + run: npm test + - name: build + working-directory: frontend + run: npm run build diff --git a/Dockerfile b/Dockerfile index ad1e4c3..891df36 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,7 +46,7 @@ COPY backend/ ./ RUN CGO_ENABLED=0 GOOS=linux go build \ -trimpath \ -a -installsuffix cgo \ - -ldflags="-s -w -extldflags '-static'" \ + -ldflags="-s -w -X main.version=${APP_VERSION:-docker} -extldflags '-static'" \ -o bin/lokistack . # --------------------------------------------------------------- diff --git a/backend/dto/image_registry.go b/backend/dto/image_registry.go index 6df61d7..63db81d 100644 --- a/backend/dto/image_registry.go +++ b/backend/dto/image_registry.go @@ -3,7 +3,7 @@ package dto import "lokistack/services" type ImageRegistryRequest struct { - Mode string `json:"mode" binding:"required,oneof=overseas cn custom"` - CustomHost string `json:"customHost"` + Mode string `json:"mode" binding:"required,oneof=overseas cn custom"` + CustomHost string `json:"customHost"` Images map[string]services.ComponentImage `json:"images"` } diff --git a/backend/dto/ldap.go b/backend/dto/ldap.go index ec5848b..a7536a0 100644 --- a/backend/dto/ldap.go +++ b/backend/dto/ldap.go @@ -2,19 +2,19 @@ package dto // LDAP 域 type LdapConfigRequest struct { - Enabled bool `json:"enabled"` - Host string `json:"host"` - Port int `json:"port"` - UseSSL bool `json:"useSSL"` - BindDN string `json:"bindDN"` - BindPassword string `json:"bindPassword"` - BaseDN string `json:"baseDN"` - UserFilter string `json:"userFilter"` - UsernameAttr string `json:"usernameAttr"` - NameAttr string `json:"nameAttr"` - EmailAttr string `json:"emailAttr"` - TestUsername string `json:"testUsername"` - TestPassword string `json:"testPassword"` + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int `json:"port"` + UseSSL bool `json:"useSSL"` + BindDN string `json:"bindDN"` + BindPassword string `json:"bindPassword"` + BaseDN string `json:"baseDN"` + UserFilter string `json:"userFilter"` + UsernameAttr string `json:"usernameAttr"` + NameAttr string `json:"nameAttr"` + EmailAttr string `json:"emailAttr"` + TestUsername string `json:"testUsername"` + TestPassword string `json:"testPassword"` } type LdapUsersRequest struct { diff --git a/backend/main.go b/backend/main.go index 4693898..57c00e0 100644 --- a/backend/main.go +++ b/backend/main.go @@ -23,8 +23,13 @@ import ( "lokistack/services" ) -// version 由构建注入(-ldflags "-X main.version=..."),未注入时为 dev -var version = "2.0.0" +// version / buildTime / gitCommit 由构建注入(build.sh / Dockerfile 的 -ldflags -X main.*), +// 未注入时为 dev,避免与前端版本号互相矛盾 +var ( + version = "dev" + buildTime = "unknown" + gitCommit = "unknown" +) // fileExists 判断文件是否存在(用于"是否首次部署"这类轻量判断)。 func fileExists(path string) bool { @@ -97,7 +102,7 @@ func runCLI(args []string) { fmt.Println(string(b)) case "version": - fmt.Println(version) + fmt.Printf("%s (commit %s, built %s)\n", version, gitCommit, buildTime) default: fmt.Fprintf(os.Stderr, "未知命令 %q。可用: backup / restore / self-check / version\n", args[0]) @@ -127,6 +132,7 @@ func main() { } // 指标注册要早于任何可能埋点的组件(middleware / alert engine) + routers.Version = version services.InitMetrics(version) log.Println("Metrics enabled at /api/v1/metrics") diff --git a/backend/middleware/audit_test.go b/backend/middleware/audit_test.go index 960df3a..1ec95fb 100644 --- a/backend/middleware/audit_test.go +++ b/backend/middleware/audit_test.go @@ -67,13 +67,13 @@ func TestInferAuditFieldsFiveSegments(t *testing.T) { // 语义化动作不能被位置解析覆盖 func TestInferAuditFieldsSemanticActions(t *testing.T) { cases := map[string]string{ - "/api/v1/deployments/1/deploy": "deploy", - "/api/v1/deployments/1/stop": "stop", - "/api/v1/collectors/2/install": "install", - "/api/v1/collectors/2/uninstall": "uninstall", - "/api/v1/users/3/reset-password": "reset-password", - "/api/v1/hosts/2/validate": "validate", - "/api/v1/storage/1/test": "test", + "/api/v1/deployments/1/deploy": "deploy", + "/api/v1/deployments/1/stop": "stop", + "/api/v1/collectors/2/install": "install", + "/api/v1/collectors/2/uninstall": "uninstall", + "/api/v1/users/3/reset-password": "reset-password", + "/api/v1/hosts/2/validate": "validate", + "/api/v1/storage/1/test": "test", } for path, want := range cases { if _, action, _, _ := inferAuditFields(http.MethodPost, path); action != want { diff --git a/backend/middleware/ratelimit.go b/backend/middleware/ratelimit.go index d79077b..c708e24 100644 --- a/backend/middleware/ratelimit.go +++ b/backend/middleware/ratelimit.go @@ -13,18 +13,18 @@ import ( // ipLimiter 内存滑动窗口 IP 限流(简化实现)。 // 用于登录防爆破:超限返回 429。单实例部署下内存实现即可,无需外部存储。 type ipLimiter struct { - mu sync.Mutex - window time.Duration - max int - hits map[string][]time.Time + mu sync.Mutex + window time.Duration + max int + hits map[string][]time.Time lastSweep time.Time } func newIPLimiter(max int, window time.Duration) *ipLimiter { return &ipLimiter{ - window: window, - max: max, - hits: make(map[string][]time.Time), + window: window, + max: max, + hits: make(map[string][]time.Time), } } diff --git a/backend/models/daily_volume.go b/backend/models/daily_volume.go index 2e9f126..fbfc1f9 100644 --- a/backend/models/daily_volume.go +++ b/backend/models/daily_volume.go @@ -5,10 +5,11 @@ import "time" // DailyVolumeSnapshot 每日日志量快照(平台自己留存的历史)。 // // 为什么必须落库: -// Loki 的 retention_period 是 24 小时(运维可改,但默认就该短), -// 意味着「近 7 天每日日志量」直接查 Loki 时,只有最近 1 天有数据, -// 其余 6 天是空的 —— 界面上看着像坏了,实际是数据已经不在了。 -// 想要 30 天 / 1 年趋势,只能在数据被删前每天抓一次存进平台库。 +// +// Loki 的 retention_period 是 24 小时(运维可改,但默认就该短), +// 意味着「近 7 天每日日志量」直接查 Loki 时,只有最近 1 天有数据, +// 其余 6 天是空的 —— 界面上看着像坏了,实际是数据已经不在了。 +// 想要 30 天 / 1 年趋势,只能在数据被删前每天抓一次存进平台库。 // // 粒度说明: // - 只按「数据源 + 命名空间」存。index/volume 系列接口只能按 selector 标签聚合, @@ -16,12 +17,12 @@ import "time" // 所以按 app 的历史量暂时无法落库,应用维度请看实时排行(有 20~30s 成本)。 // - 每天一条,日期用**本地时区**的 YYYY-MM-DD,避免跨时区对不上。 type DailyVolumeSnapshot struct { - ID uint `json:"id"` - Date string `json:"date"` // YYYY-MM-DD(本地时区) - SourceID uint `json:"sourceId"` // 数据源 - Namespace string `json:"namespace"` // "" 表示该数据源全量 - Bytes int64 `json:"bytes"` // 日志体积 - Entries int64 `json:"entries"` // 日志条数 + ID uint `json:"id"` + Date string `json:"date"` // YYYY-MM-DD(本地时区) + SourceID uint `json:"sourceId"` // 数据源 + Namespace string `json:"namespace"` // "" 表示该数据源全量 + Bytes int64 `json:"bytes"` // 日志体积 + Entries int64 `json:"entries"` // 日志条数 // Partial 该日期是否为"不完整的一天"(当天抓取时还没过完)。 // 前端用它区分"真实低峰"和"只统计到一半"。 Partial bool `json:"partial"` diff --git a/backend/models/storage.go b/backend/models/storage.go index 9859bee..7c951bd 100644 --- a/backend/models/storage.go +++ b/backend/models/storage.go @@ -34,19 +34,19 @@ func (s *StorageConfig) Sanitized() map[string]interface{} { plainSK := crypto.DecryptField(s.SecretKey) return map[string]interface{}{ - "id": s.ID, - "name": s.Name, - "storageType": s.StorageType, - "endpoint": s.Endpoint, - "bucket": s.Bucket, - "region": s.Region, - "accessKey": plainAK, - "secretKey": "", - "pathPrefix": s.PathPrefix, - "insecure": s.Insecure, - "isDefault": s.IsDefault, + "id": s.ID, + "name": s.Name, + "storageType": s.StorageType, + "endpoint": s.Endpoint, + "bucket": s.Bucket, + "region": s.Region, + "accessKey": plainAK, + "secretKey": "", + "pathPrefix": s.PathPrefix, + "insecure": s.Insecure, + "isDefault": s.IsDefault, "hasSecretKey": plainSK != "", - "createdAt": s.CreatedAt, - "updatedAt": s.UpdatedAt, + "createdAt": s.CreatedAt, + "updatedAt": s.UpdatedAt, } } diff --git a/backend/models/user.go b/backend/models/user.go index a2409f2..d0e6e1c 100644 --- a/backend/models/user.go +++ b/backend/models/user.go @@ -18,12 +18,12 @@ type User struct { func (u *User) Sanitized() map[string]interface{} { return map[string]interface{}{ - "id": u.ID, - "username": u.Username, - "nickname": u.Nickname, - "role": u.Role, + "id": u.ID, + "username": u.Username, + "nickname": u.Nickname, + "role": u.Role, "authSource": u.AuthSource, - "createdAt": u.CreatedAt, - "updatedAt": u.UpdatedAt, + "createdAt": u.CreatedAt, + "updatedAt": u.UpdatedAt, } } diff --git a/backend/routers/router.go b/backend/routers/router.go index 452be82..52d05a2 100644 --- a/backend/routers/router.go +++ b/backend/routers/router.go @@ -15,6 +15,10 @@ import ( "github.com/gin-gonic/gin" ) +// Version 由 main 启动时注入(构建期 -ldflags -X main.version,再传到这里), +// 保持 /api/v1/health 与关于页版本一致 +var Version = "dev" + func SetupRouter() *gin.Engine { // 用 gin.New() 而不是 gin.Default():Default 自带文本格式的 gin.Logger(), // 这里换成结构化 JSON 访问日志(middleware.AccessLog),便于 Promtail→Loki @@ -55,7 +59,7 @@ func SetupRouter() *gin.Engine { c.JSON(http.StatusOK, gin.H{ "status": "ok", "timestamp": time.Now().Unix(), - "version": "2.0.0", + "version": Version, }) }) diff --git a/backend/routers/router_test.go b/backend/routers/router_test.go index 1ef4864..9075874 100644 --- a/backend/routers/router_test.go +++ b/backend/routers/router_test.go @@ -9,11 +9,14 @@ import ( // 回归:接口的 **HTTP 方法与路径** 必须和前端调用一致。 // // 线上事故:一次模糊匹配的 patch 把 -// logs.POST("/histogram", ...) → logs.GET("/histogram", ...) +// +// logs.POST("/histogram", ...) → logs.GET("/histogram", ...) +// // 前端 logsApi.histogram 用的是 POST(body 里带 sourceId/query/start/end), // 于是所有直方图请求 404: // - 日志查询页:直方图不显示 // - 日志分析页:直接报 "API endpoint not found",整页不可用 +// // 这类"方法写错"编译期完全查不出来,只能靠路由表断言。 func TestCriticalRoutes(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/build.sh b/build.sh index baa1878..fde9a09 100755 --- a/build.sh +++ b/build.sh @@ -105,7 +105,7 @@ BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S') GIT_COMMIT=$(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown") $GO_CMD build -trimpath \ - -ldflags="-s -w -X main.buildTime=${BUILD_TIME} -X main.gitCommit=${GIT_COMMIT} -extldflags '-static'" \ + -ldflags="-s -w -X main.version=${APP_VERSION:-dev} -X main.buildTime=${BUILD_TIME} -X main.gitCommit=${GIT_COMMIT} -extldflags '-static'" \ -o bin/lokistack . echo " Backend built: backend/bin/lokistack" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3b1a2e1..56f733b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "lokistack-frontend", - "version": "1.0.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lokistack-frontend", - "version": "1.0.0", + "version": "2.0.0", "dependencies": { "@hookform/resolvers": "^5.2.2", "@radix-ui/react-accordion": "^1.2.12", @@ -60,6 +60,9 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.8", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -69,15 +72,24 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jsdom": "^30.1.3", "postcss": "^8.5.6", "tailwindcss": "^3.4.19", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^5.0.5" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "dev": true, @@ -89,6 +101,59 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.9.tgz", + "integrity": "sha512-vZ6JQVq8Ivgz6b/IBlAf90wZviXdluY13cQOPO+nJHbmXwGrPl2GcpqUY7a55/8ZVdfONXpCFMpRijOBCHNlcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.3", + "@csstools/css-color-parser": "^4.2.6", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-10.0.5.tgz", + "integrity": "sha512-EgmJ7oEaGQECTn3H3wmnQpEy4hKY66VvM7VQndwPIQob3p877BgbyOUE3EX0LgXQCciF2H8KMdR+RznqjMNOLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "dev": true, @@ -354,6 +419,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@date-fns/tz": { "version": "1.4.1", "license": "MIT" @@ -933,6 +1151,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "license": "MIT", @@ -1044,7 +1280,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -2944,6 +3182,113 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.8", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz", + "integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "dev": true, @@ -2981,6 +3326,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -3044,6 +3400,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "dev": true, @@ -3338,6 +3701,44 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/mocker": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "dev": true, @@ -3372,6 +3773,17 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "dev": true, @@ -3434,6 +3846,26 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/autoprefixer": { "version": "10.4.23", "dev": true, @@ -3482,6 +3914,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "dev": true, @@ -3580,6 +4022,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "dev": true, @@ -3718,6 +4170,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "dev": true, @@ -3854,6 +4327,20 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.2" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/date-fns": { "version": "4.1.0", "license": "MIT", @@ -3882,6 +4369,13 @@ } } }, + "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/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -3893,6 +4387,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "license": "MIT" @@ -3907,6 +4411,14 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -3944,6 +4456,26 @@ "embla-carousel": "8.6.0" } }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.2", "dev": true, @@ -4159,6 +4691,16 @@ "node": ">=4.0" } }, + "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/esutils": { "version": "2.0.3", "dev": true, @@ -4173,6 +4715,16 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "dev": true, @@ -4402,6 +4954,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, "node_modules/html-parse-stringify": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", @@ -4470,6 +5035,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/input-otp": { "version": "1.4.2", "license": "MIT", @@ -4539,6 +5114,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "dev": true, @@ -4567,6 +5149,56 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "30.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.3.tgz", + "integrity": "sha512-hQMUPvRgN7KxsfIytDQji7bimuJeRQa6Qo5+iuiZQk5CafeRxLIA10/cG5YCBFNpD9RO3oqIiN4CO1Y44kxqXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^10.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", + "css-tree": "^3.2.1", + "data-urls": "^8.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.12.1", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.2.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "dev": true, @@ -4692,6 +5324,34 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.5.tgz", + "integrity": "sha512-oLLC2+YvQUfAa+vxoufE/yc1icb87M9iXIRqAoR/2EhE/8cb3SF/+Gi0c2By1B+5Vz2e2vQtUvHiu1+63Znh+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "dev": true, @@ -4723,6 +5383,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "dev": true, @@ -4807,6 +5477,20 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "dev": true, @@ -4862,6 +5546,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "dev": true, @@ -4889,7 +5586,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -5072,6 +5771,44 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5394,6 +6131,30 @@ "decimal.js-light": "^2.4.1" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "dev": true, @@ -5495,6 +6256,19 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "license": "MIT" @@ -5546,6 +6320,26 @@ "node": ">=0.10.0" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "dev": true, @@ -5677,13 +6471,35 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.15", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -5692,6 +6508,26 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.4.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz", + "integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.20", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz", + "integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "dev": true, @@ -5703,6 +6539,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "dev": true, @@ -5776,6 +6638,16 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici": { + "version": "8.12.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz", + "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "7.16.0", "dev": true, @@ -5975,6 +6847,137 @@ } } }, + "node_modules/vitest": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.3.0.tgz", + "integrity": "sha512-KwOAJrpf+GTgBvcBQJcP38sMF0DsQKdwtG+TxM2pW44P6E5k0hDbWrDmyuJDIF0Uef0aQq0ZoJs+5oI+k8ddhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "dev": true, @@ -5989,6 +6992,19 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "dev": true, @@ -5997,6 +7013,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index 8f9a30e..2da1300 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,13 +1,14 @@ { "name": "lokistack-frontend", "private": true, - "version": "1.0.0", + "version": "2.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "@hookform/resolvers": "^5.2.2", @@ -62,6 +63,9 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.8", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -71,12 +75,14 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jsdom": "^30.1.3", "postcss": "^8.5.6", "tailwindcss": "^3.4.19", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^5.0.5" } } diff --git a/frontend/src/api/http.test.ts b/frontend/src/api/http.test.ts new file mode 100644 index 0000000..70ff70d --- /dev/null +++ b/frontend/src/api/http.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +/** 登录态命脉:401 单飞刷新 + 队列重放。改坏它 = 全站不可用。 + * http.ts 有模块级 isRefreshing 状态,每个用例 resetModules 重新加载。 */ +async function freshHttp(tokenState: { access: string | null; refresh: string | null }) { + vi.resetModules() + vi.doMock('@/lib/auth-storage', () => ({ + getAccessToken: () => tokenState.access, + getRefreshToken: () => tokenState.refresh, + saveTokenPair: vi.fn((pair: { accessToken: string }) => { tokenState.access = pair.accessToken }), + clearAuth: vi.fn(), + })) + const mod = await import('./http') + return { http: mod.http, tokenState } +} + +function jsonResponse(body: unknown, status = 200) { + return { + status, + ok: status < 400, + headers: { get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json' : null) }, + text: async () => JSON.stringify(body), + json: async () => body, + } as unknown as Response +} + +describe('http 401 单飞刷新', () => { + const fakeLocation = { href: 'http://localhost/logs', pathname: '/logs', search: '' } + beforeEach(() => { + Object.defineProperty(window, 'location', { value: fakeLocation, writable: true, configurable: true }) + vi.stubGlobal('fetch', vi.fn()) + }) + afterEach(() => { + vi.unstubAllGlobals() + vi.doUnmock('@/lib/auth-storage') + }) + + it('三个并发 401 只刷新一次,全部以新 token 重放成功', async () => { + const state = { access: 'old', refresh: 'r1' } + const { http } = await freshHttp(state) + const fetchMock = vi.mocked(fetch) + fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.includes('/auth/refresh')) return jsonResponse({ code: 0, data: { accessToken: 'new', refreshToken: 'r2', expiresAt: 1 } }) + const auth = (init?.headers as Record)?.Authorization ?? '' + if (auth.includes('old')) return jsonResponse({ code: 401, message: 'expired' }, 401) + return jsonResponse({ code: 0, message: 'ok', data: { fine: auth.includes('new') } }) + }) + const results = await Promise.all([http.get('/x1'), http.get('/x2'), http.get('/x3')]) + const refreshCalls = fetchMock.mock.calls.filter(([u]) => String(u).includes('/auth/refresh')) + expect(refreshCalls).toHaveLength(1) + expect(results.map(r => (r as { fine: boolean }).fine)).toEqual([true, true, true]) + }) + + it('refresh 失败:清凭证并跳转登录', async () => { + const state = { access: 'old', refresh: 'bad' } + const { http } = await freshHttp(state) + vi.mocked(fetch).mockImplementation(async (input: RequestInfo | URL) => { + if (String(input).includes('/auth/refresh')) return jsonResponse({ code: 401, message: 'no' }, 401) + return jsonResponse({ code: 401, message: 'expired' }, 401) + }) + await expect(http.get('/x')).rejects.toThrow() + expect(fakeLocation.href).toContain('/login?redirect=') + }) + + it('silent 请求 401 直接失败,不触发刷新', async () => { + const state = { access: 'old', refresh: 'r1' } + const { http } = await freshHttp(state) + vi.mocked(fetch).mockImplementation(async () => jsonResponse({ code: 401, message: 'expired' }, 401)) + await expect(http.get('/x', { silent: true })).rejects.toThrow() + expect(vi.mocked(fetch).mock.calls.filter(([u]) => String(u).includes('/auth/refresh'))).toHaveLength(0) + }) +}) diff --git a/frontend/src/hooks/useDebouncedValue.test.tsx b/frontend/src/hooks/useDebouncedValue.test.tsx new file mode 100644 index 0000000..8ddd3d3 --- /dev/null +++ b/frontend/src/hooks/useDebouncedValue.test.tsx @@ -0,0 +1,18 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useDebouncedValue } from './useDebouncedValue' + +describe('useDebouncedValue 防抖', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('快速连续输入只会在静默后更新一次', async () => { + const { result, rerender } = renderHook(({ value }) => useDebouncedValue(value, 300), { initialProps: { value: 'a' } }) + rerender({ value: 'ab' }) + act(() => vi.advanceTimersByTime(100)) + rerender({ value: 'abc' }) + expect(result.current).toBe('a') + act(() => vi.advanceTimersByTime(300)) + expect(result.current).toBe('abc') + }) +}) diff --git a/frontend/src/hooks/useListQuery.test.tsx b/frontend/src/hooks/useListQuery.test.tsx new file mode 100644 index 0000000..ec8068c --- /dev/null +++ b/frontend/src/hooks/useListQuery.test.tsx @@ -0,0 +1,30 @@ +import { act, renderHook, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { useListQuery } from './useListQuery' + +/** 全站列表数据正确性都托付给这个 hook:乱序响应必须被丢弃,卸载必须中止在途请求 */ +describe('useListQuery 竞态防护', () => { + it('慢的旧页响应不得覆盖快的新页响应(版本号丢弃)', async () => { + let resolvePage1: ((v: { list: unknown[]; total: number }) => void) | undefined + const fetcher = vi.fn((params: { page: number }) => + params.page === 1 + ? new Promise(resolve => { resolvePage1 = resolve }) + : Promise.resolve({ list: ['page2'], total: 1 }), + ) + const { result } = renderHook(() => useListQuery(fetcher)) + // 翻到第 2 页(快响应),第 1 页的慢响应仍挂起 + await act(async () => { result.current.setPage(2) }) + await waitFor(() => expect(result.current.list).toEqual(['page2'])) + // 迟到的 page1 响应到达 + await act(async () => { resolvePage1?.({ list: ['stale-page1'], total: 1 }) }) + expect(result.current.list).toEqual(['page2']) + }) + + it('卸载后中止在途请求', async () => { + const fetcher = vi.fn(() => new Promise(() => {})) + const { unmount } = renderHook(() => useListQuery(fetcher)) + const signal = (fetcher.mock.calls[0] as unknown[])[1] as AbortSignal + unmount() + expect(signal.aborted).toBe(true) + }) +}) diff --git a/frontend/src/pages/logs/query-utils.test.ts b/frontend/src/pages/logs/query-utils.test.ts new file mode 100644 index 0000000..2221f79 --- /dev/null +++ b/frontend/src/pages/logs/query-utils.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { applyLabelFilter, extractSelector, hasLabelFilter, removeLabelFilter, replaceSelector } from './query-utils' + +/** 快捷查询「选 App/Pod 即插入执行」依赖这组纯函数正确合并/清理标签 */ +describe('query-utils', () => { + it('replaceSelector 保留管道过滤', () => { + expect(replaceSelector('{app="a"} |= "err"', '{app="b"}')).toBe('{app="b"} |= "err"') + }) + it('replaceSelector 无选择器时前置', () => { + expect(replaceSelector('', '{app="a"}')).toBe('{app="a"}') + }) + it('applyLabelFilter 同名标签替换旧值', () => { + expect(applyLabelFilter('{app="a", ns="x"}', 'app', 'b')).toBe('{ns="x", app="b"}') + }) + it('removeLabelFilter 删除指定标签并保留其余', () => { + expect(removeLabelFilter('{namespace="p", app="w", pod="p1"}', 'app')).toBe('{namespace="p", pod="p1"}') + expect(removeLabelFilter('{app="w"}', 'app')).toBe('{}') + }) + it('hasLabelFilter 识别已有标签', () => { + expect(hasLabelFilter('{namespace="p", app="w"}', 'namespace')).toBe(true) + expect(hasLabelFilter('{app="w"}', 'namespace')).toBe(false) + }) + it('extractSelector 取第一个 {...}', () => { + expect(extractSelector('{a="1"} |= "x"')).toBe('{a="1"}') + expect(extractSelector('plain')).toBe('') + }) +}) diff --git a/frontend/src/pages/wizard/components/StepDeploy.test.tsx b/frontend/src/pages/wizard/components/StepDeploy.test.tsx new file mode 100644 index 0000000..5e44c04 --- /dev/null +++ b/frontend/src/pages/wizard/components/StepDeploy.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import '@/i18n' + +/** P0 回归:创建后自动部署必须瞄准「刚创建的实例」, + * 绝不盲取 list[0] —— 并发创建/排序变化会对错误实例发起真实部署。 */ +interface FakeDeployment { id: number; name: string; status: string } +const { deployMock, listMock } = vi.hoisted(() => ({ + deployMock: vi.fn<(id: number) => Promise>(), + listMock: vi.fn<(params?: unknown) => Promise<{ list: FakeDeployment[]; total: number }>>(), +})) +listMock.mockImplementation(() => Promise.resolve({ list: [], total: 0 })) +deployMock.mockImplementation(() => Promise.resolve()) +vi.mock('@/api', () => ({ + deploymentApi: { list: (...args: unknown[]) => listMock(...(args as [unknown?])), deploy: deployMock }, +})) +vi.mock('./../../deployments/components/DeploymentFormDialog', () => ({ + DeploymentFormDialog: ({ onCreated }: { onCreated: (created?: { id: number; name: string }) => void }) => ( + + ), +})) + +import { StepDeploy } from './StepDeploy' + +const props = { + runningLoki: null, + clusters: [], + hosts: [], + storages: [], + onChanged: () => {}, +} as Parameters[0] + +describe('StepDeploy 自动部署目标', () => { + it('无 created 参数时按「id 最大且 pending」匹配,不取 list[0]', async () => { + listMock.mockResolvedValue({ + list: [ + { id: 7, name: 'older-but-first', status: 'pending' }, + { id: 3, name: 'running-old', status: 'running' }, + { id: 99, name: 'just-created', status: 'pending' }, + ], + total: 3, + }) + render() + await userEvent.click(screen.getByText('trigger-oncreated')) + await vi.waitFor(() => expect(deployMock).toHaveBeenCalled()) + expect(deployMock).toHaveBeenCalledWith(99) + }) + + it('list 拿不到 pending 实例时不误触发部署', async () => { + deployMock.mockClear() + listMock.mockResolvedValue({ list: [{ id: 1, name: 'r', status: 'running' }], total: 1 }) + render() + await userEvent.click(screen.getByText('trigger-oncreated')) + await vi.waitFor(() => expect(listMock).toHaveBeenCalled()) + expect(deployMock).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 0000000..a9d0dd3 --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest' diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json index c8935af..a0dbb1b 100644 --- a/frontend/tsconfig.app.json +++ b/frontend/tsconfig.app.json @@ -30,5 +30,6 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 1ef3b35..210a454 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,7 +1,7 @@ import path from "path" import { execSync } from "child_process" import react from "@vitejs/plugin-react" -import { defineConfig } from "vite" +import { defineConfig } from "vitest/config" import pkg from "./package.json" with { type: "json" } // 构建指纹:版本来自 package.json,commit 用 git rev-parse 兜底(Settings「关于」展示) @@ -15,6 +15,12 @@ function gitCommit(): string { // https://vite.dev/config/ export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + }, base: './', plugins: [react()], define: {