ci(engineering): Gitea Actions 门禁 + 前端测试设施 + 版本注入修复
- .gitea/workflows/ci.yml:后端 gofmt/vet/test + 前端 lint/test/build 双 job 门禁 - vitest + @testing-library + jsdom 落地,14 个命脉用例:useListQuery 乱序丢弃/卸载中止、 http.ts 401 单飞刷新+队列重放+失败登出+silent 直拒、审计防抖、StepDeploy 部署目标、query-utils - 版本注入断裂修复:main 补 buildTime/gitCommit 符号,build.sh/Dockerfile 注入 main.version, /health 返回真实版本,前端 package.json 版本对齐 2.0.0 - gofmt 清零(8 个存量未格式化文件)
This commit is contained in:
1 parent
2975452c21
commit
4f74fe6c3f
23 files changed
+1399
-77
No files matched your search
@@ -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
|
||||||
+1
-1
@@ -46,7 +46,7 @@ COPY backend/ ./
|
|||||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||||
-trimpath \
|
-trimpath \
|
||||||
-a -installsuffix cgo \
|
-a -installsuffix cgo \
|
||||||
-ldflags="-s -w -extldflags '-static'" \
|
-ldflags="-s -w -X main.version=${APP_VERSION:-docker} -extldflags '-static'" \
|
||||||
-o bin/lokistack .
|
-o bin/lokistack .
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package dto
|
|||||||
import "lokistack/services"
|
import "lokistack/services"
|
||||||
|
|
||||||
type ImageRegistryRequest struct {
|
type ImageRegistryRequest struct {
|
||||||
Mode string `json:"mode" binding:"required,oneof=overseas cn custom"`
|
Mode string `json:"mode" binding:"required,oneof=overseas cn custom"`
|
||||||
CustomHost string `json:"customHost"`
|
CustomHost string `json:"customHost"`
|
||||||
Images map[string]services.ComponentImage `json:"images"`
|
Images map[string]services.ComponentImage `json:"images"`
|
||||||
}
|
}
|
||||||
+13
-13
@@ -2,19 +2,19 @@ package dto
|
|||||||
|
|
||||||
// LDAP 域
|
// LDAP 域
|
||||||
type LdapConfigRequest struct {
|
type LdapConfigRequest struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
UseSSL bool `json:"useSSL"`
|
UseSSL bool `json:"useSSL"`
|
||||||
BindDN string `json:"bindDN"`
|
BindDN string `json:"bindDN"`
|
||||||
BindPassword string `json:"bindPassword"`
|
BindPassword string `json:"bindPassword"`
|
||||||
BaseDN string `json:"baseDN"`
|
BaseDN string `json:"baseDN"`
|
||||||
UserFilter string `json:"userFilter"`
|
UserFilter string `json:"userFilter"`
|
||||||
UsernameAttr string `json:"usernameAttr"`
|
UsernameAttr string `json:"usernameAttr"`
|
||||||
NameAttr string `json:"nameAttr"`
|
NameAttr string `json:"nameAttr"`
|
||||||
EmailAttr string `json:"emailAttr"`
|
EmailAttr string `json:"emailAttr"`
|
||||||
TestUsername string `json:"testUsername"`
|
TestUsername string `json:"testUsername"`
|
||||||
TestPassword string `json:"testPassword"`
|
TestPassword string `json:"testPassword"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LdapUsersRequest struct {
|
type LdapUsersRequest struct {
|
||||||
|
|||||||
+9
-3
@@ -23,8 +23,13 @@ import (
|
|||||||
"lokistack/services"
|
"lokistack/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
// version 由构建注入(-ldflags "-X main.version=..."),未注入时为 dev
|
// version / buildTime / gitCommit 由构建注入(build.sh / Dockerfile 的 -ldflags -X main.*),
|
||||||
var version = "2.0.0"
|
// 未注入时为 dev,避免与前端版本号互相矛盾
|
||||||
|
var (
|
||||||
|
version = "dev"
|
||||||
|
buildTime = "unknown"
|
||||||
|
gitCommit = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
// fileExists 判断文件是否存在(用于"是否首次部署"这类轻量判断)。
|
// fileExists 判断文件是否存在(用于"是否首次部署"这类轻量判断)。
|
||||||
func fileExists(path string) bool {
|
func fileExists(path string) bool {
|
||||||
@@ -97,7 +102,7 @@ func runCLI(args []string) {
|
|||||||
fmt.Println(string(b))
|
fmt.Println(string(b))
|
||||||
|
|
||||||
case "version":
|
case "version":
|
||||||
fmt.Println(version)
|
fmt.Printf("%s (commit %s, built %s)\n", version, gitCommit, buildTime)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "未知命令 %q。可用: backup / restore / self-check / version\n", args[0])
|
fmt.Fprintf(os.Stderr, "未知命令 %q。可用: backup / restore / self-check / version\n", args[0])
|
||||||
@@ -127,6 +132,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 指标注册要早于任何可能埋点的组件(middleware / alert engine)
|
// 指标注册要早于任何可能埋点的组件(middleware / alert engine)
|
||||||
|
routers.Version = version
|
||||||
services.InitMetrics(version)
|
services.InitMetrics(version)
|
||||||
log.Println("Metrics enabled at /api/v1/metrics")
|
log.Println("Metrics enabled at /api/v1/metrics")
|
||||||
|
|
||||||
|
|||||||
@@ -67,13 +67,13 @@ func TestInferAuditFieldsFiveSegments(t *testing.T) {
|
|||||||
// 语义化动作不能被位置解析覆盖
|
// 语义化动作不能被位置解析覆盖
|
||||||
func TestInferAuditFieldsSemanticActions(t *testing.T) {
|
func TestInferAuditFieldsSemanticActions(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"/api/v1/deployments/1/deploy": "deploy",
|
"/api/v1/deployments/1/deploy": "deploy",
|
||||||
"/api/v1/deployments/1/stop": "stop",
|
"/api/v1/deployments/1/stop": "stop",
|
||||||
"/api/v1/collectors/2/install": "install",
|
"/api/v1/collectors/2/install": "install",
|
||||||
"/api/v1/collectors/2/uninstall": "uninstall",
|
"/api/v1/collectors/2/uninstall": "uninstall",
|
||||||
"/api/v1/users/3/reset-password": "reset-password",
|
"/api/v1/users/3/reset-password": "reset-password",
|
||||||
"/api/v1/hosts/2/validate": "validate",
|
"/api/v1/hosts/2/validate": "validate",
|
||||||
"/api/v1/storage/1/test": "test",
|
"/api/v1/storage/1/test": "test",
|
||||||
}
|
}
|
||||||
for path, want := range cases {
|
for path, want := range cases {
|
||||||
if _, action, _, _ := inferAuditFields(http.MethodPost, path); action != want {
|
if _, action, _, _ := inferAuditFields(http.MethodPost, path); action != want {
|
||||||
|
|||||||
@@ -13,18 +13,18 @@ import (
|
|||||||
// ipLimiter 内存滑动窗口 IP 限流(简化实现)。
|
// ipLimiter 内存滑动窗口 IP 限流(简化实现)。
|
||||||
// 用于登录防爆破:超限返回 429。单实例部署下内存实现即可,无需外部存储。
|
// 用于登录防爆破:超限返回 429。单实例部署下内存实现即可,无需外部存储。
|
||||||
type ipLimiter struct {
|
type ipLimiter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
window time.Duration
|
window time.Duration
|
||||||
max int
|
max int
|
||||||
hits map[string][]time.Time
|
hits map[string][]time.Time
|
||||||
lastSweep time.Time
|
lastSweep time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func newIPLimiter(max int, window time.Duration) *ipLimiter {
|
func newIPLimiter(max int, window time.Duration) *ipLimiter {
|
||||||
return &ipLimiter{
|
return &ipLimiter{
|
||||||
window: window,
|
window: window,
|
||||||
max: max,
|
max: max,
|
||||||
hits: make(map[string][]time.Time),
|
hits: make(map[string][]time.Time),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import "time"
|
|||||||
// DailyVolumeSnapshot 每日日志量快照(平台自己留存的历史)。
|
// DailyVolumeSnapshot 每日日志量快照(平台自己留存的历史)。
|
||||||
//
|
//
|
||||||
// 为什么必须落库:
|
// 为什么必须落库:
|
||||||
// Loki 的 retention_period 是 24 小时(运维可改,但默认就该短),
|
//
|
||||||
// 意味着「近 7 天每日日志量」直接查 Loki 时,只有最近 1 天有数据,
|
// Loki 的 retention_period 是 24 小时(运维可改,但默认就该短),
|
||||||
// 其余 6 天是空的 —— 界面上看着像坏了,实际是数据已经不在了。
|
// 意味着「近 7 天每日日志量」直接查 Loki 时,只有最近 1 天有数据,
|
||||||
// 想要 30 天 / 1 年趋势,只能在数据被删前每天抓一次存进平台库。
|
// 其余 6 天是空的 —— 界面上看着像坏了,实际是数据已经不在了。
|
||||||
|
// 想要 30 天 / 1 年趋势,只能在数据被删前每天抓一次存进平台库。
|
||||||
//
|
//
|
||||||
// 粒度说明:
|
// 粒度说明:
|
||||||
// - 只按「数据源 + 命名空间」存。index/volume 系列接口只能按 selector 标签聚合,
|
// - 只按「数据源 + 命名空间」存。index/volume 系列接口只能按 selector 标签聚合,
|
||||||
@@ -16,12 +17,12 @@ import "time"
|
|||||||
// 所以按 app 的历史量暂时无法落库,应用维度请看实时排行(有 20~30s 成本)。
|
// 所以按 app 的历史量暂时无法落库,应用维度请看实时排行(有 20~30s 成本)。
|
||||||
// - 每天一条,日期用**本地时区**的 YYYY-MM-DD,避免跨时区对不上。
|
// - 每天一条,日期用**本地时区**的 YYYY-MM-DD,避免跨时区对不上。
|
||||||
type DailyVolumeSnapshot struct {
|
type DailyVolumeSnapshot struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Date string `json:"date"` // YYYY-MM-DD(本地时区)
|
Date string `json:"date"` // YYYY-MM-DD(本地时区)
|
||||||
SourceID uint `json:"sourceId"` // 数据源
|
SourceID uint `json:"sourceId"` // 数据源
|
||||||
Namespace string `json:"namespace"` // "" 表示该数据源全量
|
Namespace string `json:"namespace"` // "" 表示该数据源全量
|
||||||
Bytes int64 `json:"bytes"` // 日志体积
|
Bytes int64 `json:"bytes"` // 日志体积
|
||||||
Entries int64 `json:"entries"` // 日志条数
|
Entries int64 `json:"entries"` // 日志条数
|
||||||
// Partial 该日期是否为"不完整的一天"(当天抓取时还没过完)。
|
// Partial 该日期是否为"不完整的一天"(当天抓取时还没过完)。
|
||||||
// 前端用它区分"真实低峰"和"只统计到一半"。
|
// 前端用它区分"真实低峰"和"只统计到一半"。
|
||||||
Partial bool `json:"partial"`
|
Partial bool `json:"partial"`
|
||||||
|
|||||||
+13
-13
@@ -34,19 +34,19 @@ func (s *StorageConfig) Sanitized() map[string]interface{} {
|
|||||||
plainSK := crypto.DecryptField(s.SecretKey)
|
plainSK := crypto.DecryptField(s.SecretKey)
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"id": s.ID,
|
"id": s.ID,
|
||||||
"name": s.Name,
|
"name": s.Name,
|
||||||
"storageType": s.StorageType,
|
"storageType": s.StorageType,
|
||||||
"endpoint": s.Endpoint,
|
"endpoint": s.Endpoint,
|
||||||
"bucket": s.Bucket,
|
"bucket": s.Bucket,
|
||||||
"region": s.Region,
|
"region": s.Region,
|
||||||
"accessKey": plainAK,
|
"accessKey": plainAK,
|
||||||
"secretKey": "",
|
"secretKey": "",
|
||||||
"pathPrefix": s.PathPrefix,
|
"pathPrefix": s.PathPrefix,
|
||||||
"insecure": s.Insecure,
|
"insecure": s.Insecure,
|
||||||
"isDefault": s.IsDefault,
|
"isDefault": s.IsDefault,
|
||||||
"hasSecretKey": plainSK != "",
|
"hasSecretKey": plainSK != "",
|
||||||
"createdAt": s.CreatedAt,
|
"createdAt": s.CreatedAt,
|
||||||
"updatedAt": s.UpdatedAt,
|
"updatedAt": s.UpdatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,12 +18,12 @@ type User struct {
|
|||||||
|
|
||||||
func (u *User) Sanitized() map[string]interface{} {
|
func (u *User) Sanitized() map[string]interface{} {
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"id": u.ID,
|
"id": u.ID,
|
||||||
"username": u.Username,
|
"username": u.Username,
|
||||||
"nickname": u.Nickname,
|
"nickname": u.Nickname,
|
||||||
"role": u.Role,
|
"role": u.Role,
|
||||||
"authSource": u.AuthSource,
|
"authSource": u.AuthSource,
|
||||||
"createdAt": u.CreatedAt,
|
"createdAt": u.CreatedAt,
|
||||||
"updatedAt": u.UpdatedAt,
|
"updatedAt": u.UpdatedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,10 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Version 由 main 启动时注入(构建期 -ldflags -X main.version,再传到这里),
|
||||||
|
// 保持 /api/v1/health 与关于页版本一致
|
||||||
|
var Version = "dev"
|
||||||
|
|
||||||
func SetupRouter() *gin.Engine {
|
func SetupRouter() *gin.Engine {
|
||||||
// 用 gin.New() 而不是 gin.Default():Default 自带文本格式的 gin.Logger(),
|
// 用 gin.New() 而不是 gin.Default():Default 自带文本格式的 gin.Logger(),
|
||||||
// 这里换成结构化 JSON 访问日志(middleware.AccessLog),便于 Promtail→Loki
|
// 这里换成结构化 JSON 访问日志(middleware.AccessLog),便于 Promtail→Loki
|
||||||
@@ -55,7 +59,7 @@ func SetupRouter() *gin.Engine {
|
|||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"timestamp": time.Now().Unix(),
|
"timestamp": time.Now().Unix(),
|
||||||
"version": "2.0.0",
|
"version": Version,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,14 @@ import (
|
|||||||
// 回归:接口的 **HTTP 方法与路径** 必须和前端调用一致。
|
// 回归:接口的 **HTTP 方法与路径** 必须和前端调用一致。
|
||||||
//
|
//
|
||||||
// 线上事故:一次模糊匹配的 patch 把
|
// 线上事故:一次模糊匹配的 patch 把
|
||||||
// logs.POST("/histogram", ...) → logs.GET("/histogram", ...)
|
//
|
||||||
|
// logs.POST("/histogram", ...) → logs.GET("/histogram", ...)
|
||||||
|
//
|
||||||
// 前端 logsApi.histogram 用的是 POST(body 里带 sourceId/query/start/end),
|
// 前端 logsApi.histogram 用的是 POST(body 里带 sourceId/query/start/end),
|
||||||
// 于是所有直方图请求 404:
|
// 于是所有直方图请求 404:
|
||||||
// - 日志查询页:直方图不显示
|
// - 日志查询页:直方图不显示
|
||||||
// - 日志分析页:直接报 "API endpoint not found",整页不可用
|
// - 日志分析页:直接报 "API endpoint not found",整页不可用
|
||||||
|
//
|
||||||
// 这类"方法写错"编译期完全查不出来,只能靠路由表断言。
|
// 这类"方法写错"编译期完全查不出来,只能靠路由表断言。
|
||||||
func TestCriticalRoutes(t *testing.T) {
|
func TestCriticalRoutes(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|||||||
@@ -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")
|
GIT_COMMIT=$(git -C "$SCRIPT_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||||
|
|
||||||
$GO_CMD build -trimpath \
|
$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 .
|
-o bin/lokistack .
|
||||||
|
|
||||||
echo " Backend built: backend/bin/lokistack"
|
echo " Backend built: backend/bin/lokistack"
|
||||||
|
|||||||
Generated
+1040
-7
File diff suppressed because it is too large.
Load diff
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "lokistack-frontend",
|
"name": "lokistack-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
@@ -62,6 +63,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@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/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -71,12 +75,14 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"jsdom": "^30.1.3",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"typescript-eslint": "^8.46.4",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4",
|
||||||
|
"vitest": "^5.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<string, string>)?.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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<void>>(),
|
||||||
|
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 }) => (
|
||||||
|
<button type="button" onClick={() => onCreated()}>trigger-oncreated</button>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { StepDeploy } from './StepDeploy'
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
runningLoki: null,
|
||||||
|
clusters: [],
|
||||||
|
hosts: [],
|
||||||
|
storages: [],
|
||||||
|
onChanged: () => {},
|
||||||
|
} as Parameters<typeof StepDeploy>[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(<StepDeploy {...props} />)
|
||||||
|
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(<StepDeploy {...props} />)
|
||||||
|
await userEvent.click(screen.getByText('trigger-oncreated'))
|
||||||
|
await vi.waitFor(() => expect(listMock).toHaveBeenCalled())
|
||||||
|
expect(deployMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest'
|
||||||
@@ -30,5 +30,6 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { execSync } from "child_process"
|
import { execSync } from "child_process"
|
||||||
import react from "@vitejs/plugin-react"
|
import react from "@vitejs/plugin-react"
|
||||||
import { defineConfig } from "vite"
|
import { defineConfig } from "vitest/config"
|
||||||
import pkg from "./package.json" with { type: "json" }
|
import pkg from "./package.json" with { type: "json" }
|
||||||
|
|
||||||
// 构建指纹:版本来自 package.json,commit 用 git rev-parse 兜底(Settings「关于」展示)
|
// 构建指纹:版本来自 package.json,commit 用 git rev-parse 兜底(Settings「关于」展示)
|
||||||
@@ -15,6 +15,12 @@ function gitCommit(): string {
|
|||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
include: ['src/**/*.test.{ts,tsx}'],
|
||||||
|
},
|
||||||
base: './',
|
base: './',
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
define: {
|
define: {
|
||||||
|
|||||||
Reference in new issue
Block a user