From 61a5a36c5838d1f1b20e8f24752447a5273eb78d Mon Sep 17 00:00:00 2001 From: phamnazage-jpg Date: Sat, 30 May 2026 15:28:32 +0800 Subject: [PATCH] feat(testing): add unified quality gates and coverage baseline --- docs/2026-05-30-TESTING_QUALITY_UPGRADE.md | 93 ++++++++++++++ internal/overlay/executor_test.go | 83 +++++++++++++ scripts/README.md | 15 +++ scripts/test/test_real_host_scripts.sh | 19 +++ scripts/test/verify_quality_gates.sh | 136 +++++++++++++++++++++ tests/quality/coverage_thresholds.tsv | 8 ++ 6 files changed, 354 insertions(+) create mode 100644 docs/2026-05-30-TESTING_QUALITY_UPGRADE.md create mode 100755 scripts/test/verify_quality_gates.sh create mode 100644 tests/quality/coverage_thresholds.tsv diff --git a/docs/2026-05-30-TESTING_QUALITY_UPGRADE.md b/docs/2026-05-30-TESTING_QUALITY_UPGRADE.md new file mode 100644 index 00000000..73b162f0 --- /dev/null +++ b/docs/2026-05-30-TESTING_QUALITY_UPGRADE.md @@ -0,0 +1,93 @@ +# 2026-05-30 测试能力与质量保障增强 + +## 背景 + +当前仓库虽然已经有: + +- 单元测试 +- SQLite 集成测试 +- portal 资产检查 +- real-host 验收脚本 + +但在测试治理上仍有两个明显短板: + +1. **缺统一入口** + - 质量门禁主要依赖开发者手动记忆若干命令 + - 缺少“一键执行 + 汇总结果 + 显式门槛”的脚本化入口 + +2. **缺覆盖率管理** + - 覆盖率门槛写在 `AGENTS.md` + - 但没有配置文件和脚本把门槛真正执行出来 + - 低覆盖包不够显性,容易在后续迭代中悄悄回落 + +## 本次增强 + +### 1. 新增统一质量门禁脚本 + +- [scripts/test/verify_quality_gates.sh](/home/long/project/sub2api-cn-relay-manager/scripts/test/verify_quality_gates.sh) + +职责: + +- 统一执行: + - `gofmt -l .` + - `go vet ./...` + - `go test -cover ./internal/...` + - `go test ./tests/integration/... -count=1` +- 生成 coverage gate 报告 +- 对低覆盖包给出显式 `warn` +- 对 core 包低于门槛时直接 `fail` + +### 2. 新增覆盖率阈值配置 + +- [tests/quality/coverage_thresholds.tsv](/home/long/project/sub2api-cn-relay-manager/tests/quality/coverage_thresholds.tsv) + +当前策略分两层: + +- `core` + - 当前必须硬门槛通过 + - 先覆盖最关键业务包: + - `internal/access` + - `internal/pack` + - `internal/provision` +- `watch` + - 先做显式非回归治理 + - 对已有低覆盖或大体量包给出可见阈值,避免继续恶化 + +### 3. 补充脚本自检 + +- `scripts/test/test_real_host_scripts.sh` 新增了对 `verify_quality_gates.sh` 和阈值配置文件的静态回归 + +### 4. 提升低覆盖工具包测试 + +- [internal/overlay/executor_test.go](/home/long/project/sub2api-cn-relay-manager/internal/overlay/executor_test.go) + +新增覆盖: + +- nested output dir 拒绝 +- missing patch 错误路径 +- metadata 写入内容 +- copyTree 对 `.git` 过滤 +- symlink 保留 + +## 建议执行方式 + +后续每次提交前,优先跑: + +```bash +bash ./scripts/test/verify_quality_gates.sh +``` + +如果本次还改了 portal 资产或 real-host 支撑脚本,再补: + +```bash +bash ./scripts/test/test_tksea_portal_assets.sh +bash ./scripts/test/test_real_host_scripts.sh +``` + +## 后续建议 + +下一阶段如果继续增强测试能力,优先做这三件事: + +1. 给 `internal/app` 增加不依赖本地监听端口的 handler 级测试,先把 coverage 稳定抬过 `70%` +2. 把 `watch` 包分批提升为 `core` +3. 给 `verify_quality_gates.sh` 增加机器可读输出,例如 JSON 汇总,方便未来接 CI 或日报 diff --git a/internal/overlay/executor_test.go b/internal/overlay/executor_test.go index ff1f1c59..31d8e695 100644 --- a/internal/overlay/executor_test.go +++ b/internal/overlay/executor_test.go @@ -2,6 +2,7 @@ package overlay import ( "context" + "encoding/json" "os" "path/filepath" "strings" @@ -125,3 +126,85 @@ func TestFilterOverlaysRejectsMissingOverlayID(t *testing.T) { t.Fatalf("FilterOverlays() error = %v, want missing overlay detail", err) } } + +func TestApplyRejectsNestedOutputDir(t *testing.T) { + sourceDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(sourceDir, "backend"), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + + _, err := Apply(context.Background(), ApplyRequest{ + PackDir: t.TempDir(), + SourceDir: sourceDir, + OutputDir: filepath.Join(sourceDir, "nested-output"), + Overlays: []pack.HostOverlay{{OverlayID: "sample", PatchPath: "overlays/sample.patch"}}, + }) + if err == nil || !strings.Contains(err.Error(), "must not be nested inside source dir") { + t.Fatalf("Apply() error = %v, want nested output rejection", err) + } +} + +func TestApplyPatchFileRejectsMissingPatch(t *testing.T) { + err := applyPatchFile(context.Background(), t.TempDir(), filepath.Join(t.TempDir(), "missing.patch")) + if err == nil || !strings.Contains(err.Error(), "stat patch file") { + t.Fatalf("applyPatchFile() error = %v, want missing patch stat error", err) + } +} + +func TestWriteMetadataIncludesSourceDirAndOverlays(t *testing.T) { + metadataPath := filepath.Join(t.TempDir(), metadataFileName) + overlays := []pack.HostOverlay{{OverlayID: "sample", PatchPath: "overlays/sample.patch"}} + if err := writeMetadata(metadataPath, "/tmp/source", overlays); err != nil { + t.Fatalf("writeMetadata() error = %v", err) + } + + body, err := os.ReadFile(metadataPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(body, &decoded); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if got, _ := decoded["source_dir"].(string); got != "/tmp/source" { + t.Fatalf("source_dir = %q, want %q", got, "/tmp/source") + } + applied, ok := decoded["applied_overlays"].([]any) + if !ok || len(applied) != 1 { + t.Fatalf("applied_overlays = %#v, want one overlay", decoded["applied_overlays"]) + } +} + +func TestCopyTreeSkipsGitAndPreservesSymlink(t *testing.T) { + sourceDir := t.TempDir() + outputDir := filepath.Join(t.TempDir(), "output") + if err := os.MkdirAll(filepath.Join(sourceDir, ".git", "objects"), 0o755); err != nil { + t.Fatalf("MkdirAll(.git) error = %v", err) + } + if err := os.MkdirAll(filepath.Join(sourceDir, "backend"), 0o755); err != nil { + t.Fatalf("MkdirAll(backend) error = %v", err) + } + if err := os.WriteFile(filepath.Join(sourceDir, ".git", "config"), []byte("ignored"), 0o644); err != nil { + t.Fatalf("WriteFile(.git/config) error = %v", err) + } + if err := os.WriteFile(filepath.Join(sourceDir, "backend", "hello.txt"), []byte("hello\n"), 0o644); err != nil { + t.Fatalf("WriteFile(hello.txt) error = %v", err) + } + if err := os.Symlink(filepath.Join("backend", "hello.txt"), filepath.Join(sourceDir, "hello-link")); err != nil { + t.Fatalf("Symlink() error = %v", err) + } + + if err := copyTree(sourceDir, outputDir); err != nil { + t.Fatalf("copyTree() error = %v", err) + } + if _, err := os.Stat(filepath.Join(outputDir, ".git", "config")); !os.IsNotExist(err) { + t.Fatalf("output .git/config error = %v, want not exist", err) + } + target, err := os.Readlink(filepath.Join(outputDir, "hello-link")) + if err != nil { + t.Fatalf("Readlink() error = %v", err) + } + if target != filepath.Join("backend", "hello.txt") { + t.Fatalf("symlink target = %q, want backend/hello.txt", target) + } +} diff --git a/scripts/README.md b/scripts/README.md index 97a10fd1..e5d16c67 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -26,6 +26,7 @@ - 例如: - `test_real_host_scripts.sh` - `test_tksea_portal_assets.sh` + - `verify_quality_gates.sh` ## 放置规则 @@ -38,6 +39,20 @@ ```bash bash ./scripts/test/test_real_host_scripts.sh bash ./scripts/test/test_tksea_portal_assets.sh +bash ./scripts/test/verify_quality_gates.sh scripts/deploy/build_local_image.sh bash ./scripts/acceptance/real_host_acceptance.sh ``` + +## 统一质量门禁 + +`scripts/test/verify_quality_gates.sh` 是当前推荐的一键测试入口,职责是: + +- 统一执行: + - `gofmt -l .` + - `go vet ./...` + - `go test -cover ./internal/...` + - `go test ./tests/integration/... -count=1` +- 读取 `tests/quality/coverage_thresholds.tsv` +- 输出 coverage gate 报告到临时目录 +- 对 core 包覆盖率做硬门槛,对 watch 包做显式告警 diff --git a/scripts/test/test_real_host_scripts.sh b/scripts/test/test_real_host_scripts.sh index 0018ff0c..cc66c561 100755 --- a/scripts/test/test_real_host_scripts.sh +++ b/scripts/test/test_real_host_scripts.sh @@ -262,6 +262,24 @@ EOF assert_not_contains "$upstream_headers" "Authorization:" } +run_test_verify_quality_gates_script() { + local script threshold_file script_contents + script="$ROOT_DIR/scripts/test/verify_quality_gates.sh" + threshold_file="$ROOT_DIR/tests/quality/coverage_thresholds.tsv" + + [[ -f "$script" ]] || fail "missing $script" + [[ -f "$threshold_file" ]] || fail "missing $threshold_file" + + script_contents="$(cat "$script")" + assert_contains "$script_contents" "gofmt -l ." + assert_contains "$script_contents" "go vet ./..." + assert_contains "$script_contents" "go test -cover ./internal/..." + assert_contains "$script_contents" "go test ./tests/integration/... -count=1" + assert_contains "$script_contents" "Coverage Gate Report" + assert_contains "$script_contents" "tests/quality/coverage_thresholds.tsv" + assert_contains "$script_contents" "tier_by_package" +} + run_test_import_remote43_provider_subscription_prep() { local tmpdir fakebin artifact_dir ssh_log summary_file pack_dir tmpdir="$(mktemp -d)" @@ -1041,5 +1059,6 @@ run_test_verify_route_data_plane_script run_test_verify_route_health_ui_script run_test_remote43_patched_stack_renderers run_test_setup_remote43_patched_stack_dry_run +run_test_verify_quality_gates_script echo "PASS: real host script regression checks" diff --git a/scripts/test/verify_quality_gates.sh b/scripts/test/verify_quality_gates.sh new file mode 100755 index 00000000..6d4970ab --- /dev/null +++ b/scripts/test/verify_quality_gates.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +THRESHOLD_FILE="${THRESHOLD_FILE:-$ROOT_DIR/tests/quality/coverage_thresholds.tsv}" +OUTPUT_DIR="${OUTPUT_DIR:-$(mktemp -d "/tmp/sub2api-cn-relay-manager-test-quality-XXXXXX")}" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +log() { + echo "==> $*" +} + +[[ -f "$THRESHOLD_FILE" ]] || fail "missing coverage threshold file: $THRESHOLD_FILE" +mkdir -p "$OUTPUT_DIR" + +GOFMT_LOG="$OUTPUT_DIR/gofmt.txt" +GOVET_LOG="$OUTPUT_DIR/govet.txt" +INTEGRATION_LOG="$OUTPUT_DIR/integration.txt" +COVERAGE_LOG="$OUTPUT_DIR/coverage.txt" +COVERAGE_REPORT="$OUTPUT_DIR/coverage-report.md" + +log "quality gate output dir: $OUTPUT_DIR" + +log "running gofmt check" +gofmt -l . | tee "$GOFMT_LOG" +if [[ -s "$GOFMT_LOG" ]]; then + fail "gofmt reported unformatted files" +fi + +log "running go vet" +go vet ./... 2>&1 | tee "$GOVET_LOG" + +log "running internal coverage" +go test -cover ./internal/... 2>&1 | tee "$COVERAGE_LOG" + +log "running integration tests" +set +e +go test ./tests/integration/... -count=1 2>&1 | tee "$INTEGRATION_LOG" +integration_status=${PIPESTATUS[0]} +set -e +if [[ $integration_status -ne 0 ]]; then + if grep -Eq 'socket: operation not permitted|failed to listen on a port' "$INTEGRATION_LOG"; then + if [[ "${ALLOW_BLOCKED_INTEGRATION:-0}" == "1" ]]; then + log "integration tests blocked by socket-restricted environment; continuing because ALLOW_BLOCKED_INTEGRATION=1" + else + fail "integration tests blocked by current environment socket restrictions; rerun in an unrestricted environment or set ALLOW_BLOCKED_INTEGRATION=1 for local triage" + fi + else + fail "integration tests failed" + fi +fi + +log "evaluating coverage thresholds" + +awk -v threshold_file="$THRESHOLD_FILE" -v report_file="$COVERAGE_REPORT" ' +BEGIN { + FS = "\t" + while ((getline line < threshold_file) > 0) { + if (line ~ /^#/ || line ~ /^[[:space:]]*$/) { + continue + } + split(line, fields, "\t") + package = fields[1] + tier = fields[2] + min_coverage = fields[3] + 0 + note = fields[4] + expected[package] = min_coverage + tier_by_package[package] = tier + note_by_package[package] = note + } + close(threshold_file) +} +/^ok[[:space:]]+sub2api-cn-relay-manager\/internal\// { + package = $2 + sub(/^sub2api-cn-relay-manager\//, "", package) + pct = -1 + if (match($0, /coverage: [0-9.]+%/)) { + pct_text = substr($0, RSTART + 10, RLENGTH - 11) + pct = pct_text + 0 + } + coverage[package] = pct +} +/^\?[[:space:]]+sub2api-cn-relay-manager\/internal\// { + package = $2 + sub(/^sub2api-cn-relay-manager\//, "", package) + coverage[package] = -1 +} +END { + print "# Coverage Gate Report" > report_file + print "" >> report_file + print "| Package | Tier | Threshold | Actual | Result | Note |" >> report_file + print "|---|---|---:|---:|---|---|" >> report_file + + failures = 0 + warnings = 0 + + for (package in expected) { + actual = (package in coverage) ? coverage[package] : -1 + result = "missing" + if (actual >= 0 && actual + 1e-9 >= expected[package]) { + result = "pass" + } else if (tier_by_package[package] == "watch") { + result = "warn" + warnings++ + } else { + result = "fail" + failures++ + } + + actual_text = (actual >= 0) ? sprintf("%.1f", actual) : "n/a" + print "| " package " | " tier_by_package[package] " | " sprintf("%.1f", expected[package]) " | " actual_text " | " result " | " note_by_package[package] " |" >> report_file + + if (result == "fail") { + printf("FAIL coverage: %s actual=%s threshold=%.1f\n", package, actual_text, expected[package]) > "/dev/stderr" + } else if (result == "warn") { + printf("WARN coverage: %s actual=%s threshold=%.1f\n", package, actual_text, expected[package]) > "/dev/stderr" + } + } + + print "" >> report_file + print "- Warnings: " warnings >> report_file + print "- Failures: " failures >> report_file + + if (failures > 0) { + exit 2 + } +} +' "$COVERAGE_LOG" + +log "coverage report: $COVERAGE_REPORT" +cat "$COVERAGE_REPORT" +log "quality gates passed" diff --git a/tests/quality/coverage_thresholds.tsv b/tests/quality/coverage_thresholds.tsv new file mode 100644 index 00000000..86c7b8e6 --- /dev/null +++ b/tests/quality/coverage_thresholds.tsv @@ -0,0 +1,8 @@ +# package tier min_coverage note +internal/access core 70.0 core access closure logic +internal/pack core 70.0 pack loading and validation +internal/provision core 70.0 provider import orchestration +internal/app watch 69.5 large HTTP surface; keep explicit non-regression until more handler tests land +internal/overlay watch 70.0 utility package should stay above a healthy baseline +internal/routing watch 70.0 route resolve and sticky runtime should not regress +internal/store/sqlite watch 75.0 sqlite repo layer is high leverage and should stay well-covered