From acf39f28230d032e6a2257045fe78fcde825c455 Mon Sep 17 00:00:00 2001 From: CJACK Date: Sun, 22 Feb 2026 18:33:30 +0800 Subject: [PATCH] feat: introduce new quality gates, Node.js syntax checks, and manual smoke test status validation --- .github/workflows/quality-gates.yml | 40 ++++++++++ .github/workflows/release-artifacts.yml | 6 ++ README.MD | 8 ++ README.en.md | 8 ++ TESTING.md | 12 ++- internal/testsuite/runner_env.go | 17 +++-- internal/testsuite/runner_env_test.go | 20 +++++ internal/testsuite/runner_registry_test.go | 85 ++++++++++++++++++++++ plans/node-syntax-gate-targets.txt | 22 ++++++ plans/stage6-manual-smoke.md | 7 ++ tests/scripts/check-node-split-syntax.sh | 38 ++++++++++ tests/scripts/check-stage6-manual-smoke.sh | 52 +++++++++++++ tests/scripts/run-unit-node.sh | 1 + 13 files changed, 307 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/quality-gates.yml create mode 100644 internal/testsuite/runner_env_test.go create mode 100644 internal/testsuite/runner_registry_test.go create mode 100644 plans/node-syntax-gate-targets.txt create mode 100755 tests/scripts/check-node-split-syntax.sh create mode 100755 tests/scripts/check-stage6-manual-smoke.sh diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml new file mode 100644 index 0000000..3d7c9a1 --- /dev/null +++ b/.github/workflows/quality-gates.yml @@ -0,0 +1,40 @@ +name: Quality Gates + +on: + pull_request: + push: + branches: + - dev + +permissions: + contents: read + +jobs: + quality-gates: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.24.x" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: webui/package-lock.json + + - name: Refactor Line Gate + run: ./tests/scripts/check-refactor-line-gate.sh + + - name: Unit Gates (Go + Node) + run: ./tests/scripts/run-unit-all.sh + + - name: WebUI Build Gate + run: | + npm ci --prefix webui + npm run build --prefix webui diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 00cecee..051117d 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -31,6 +31,12 @@ jobs: cache: "npm" cache-dependency-path: webui/package-lock.json + - name: Release Blocking Gates + run: | + ./tests/scripts/check-stage6-manual-smoke.sh + ./tests/scripts/check-refactor-line-gate.sh + ./tests/scripts/run-unit-all.sh + - name: Build WebUI run: | npm ci --prefix webui diff --git a/README.MD b/README.MD index b26cc72..a0a1932 100644 --- a/README.MD +++ b/README.MD @@ -422,6 +422,14 @@ go run ./cmd/ds2api-tests \ --retries 2 ``` +```bash +# 发布前阻断门禁 +./tests/scripts/check-stage6-manual-smoke.sh +./tests/scripts/check-refactor-line-gate.sh +./tests/scripts/run-unit-all.sh +npm ci --prefix webui && npm run build --prefix webui +``` + ## Release 自动构建(GitHub Actions) 工作流文件:`.github/workflows/release-artifacts.yml` diff --git a/README.en.md b/README.en.md index 69b47bd..1445db6 100644 --- a/README.en.md +++ b/README.en.md @@ -422,6 +422,14 @@ go run ./cmd/ds2api-tests \ --retries 2 ``` +```bash +# Release-blocking gates +./tests/scripts/check-stage6-manual-smoke.sh +./tests/scripts/check-refactor-line-gate.sh +./tests/scripts/run-unit-all.sh +npm ci --prefix webui && npm run build --prefix webui +``` + ## Release Artifact Automation (GitHub Actions) Workflow: `.github/workflows/release-artifacts.yml` diff --git a/TESTING.md b/TESTING.md index f8e532a..e617181 100644 --- a/TESTING.md +++ b/TESTING.md @@ -31,6 +31,15 @@ DS2API 提供两个层级的测试: ./tests/scripts/run-unit-node.sh ``` +```bash +# 结构与流程门禁 +./tests/scripts/check-refactor-line-gate.sh +./tests/scripts/check-node-split-syntax.sh + +# 发布阻断:阶段 6 手工烟测签字检查(默认读取 plans/stage6-manual-smoke.md) +./tests/scripts/check-stage6-manual-smoke.sh +``` + ### 端到端测试 | End-to-End Tests ```bash @@ -41,8 +50,7 @@ DS2API 提供两个层级的测试: 1. **Preflight 检查**: - `go test ./... -count=1`(单元测试) - - `node --check api/chat-stream.js`(语法检查) - - `node --check api/helpers/stream-tool-sieve.js`(语法检查) + - `./tests/scripts/check-node-split-syntax.sh`(Node 拆分模块语法门禁) - `node --test api/helpers/stream-tool-sieve.test.js api/chat-stream.test.js api/compat/js_compat_test.js`(Node 流式拦截 + compat 单测) - `npm run build --prefix webui`(WebUI 构建检查) diff --git a/internal/testsuite/runner_env.go b/internal/testsuite/runner_env.go index 3ae0ba4..1ec6744 100644 --- a/internal/testsuite/runner_env.go +++ b/internal/testsuite/runner_env.go @@ -77,13 +77,7 @@ func (r *Runner) pruneOldRuns() error { } func (r *Runner) runPreflight(ctx context.Context) error { - steps := [][]string{ - {"go", "test", "./...", "-count=1"}, - {"node", "--check", "api/chat-stream.js"}, - {"node", "--check", "api/helpers/stream-tool-sieve.js"}, - {"node", "--test", "api/helpers/stream-tool-sieve.test.js", "api/chat-stream.test.js", "api/compat/js_compat_test.js"}, - {"npm", "run", "build", "--prefix", "webui"}, - } + steps := preflightSteps() f, err := os.OpenFile(r.preflightLog, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return err @@ -103,6 +97,15 @@ func (r *Runner) runPreflight(ctx context.Context) error { return nil } +func preflightSteps() [][]string { + return [][]string{ + {"go", "test", "./...", "-count=1"}, + {"./tests/scripts/check-node-split-syntax.sh"}, + {"node", "--test", "api/helpers/stream-tool-sieve.test.js", "api/chat-stream.test.js", "api/compat/js_compat_test.js"}, + {"npm", "run", "build", "--prefix", "webui"}, + } +} + func (r *Runner) prepareConfigIsolation() error { abs, err := filepath.Abs(r.opts.ConfigPath) if err != nil { diff --git a/internal/testsuite/runner_env_test.go b/internal/testsuite/runner_env_test.go new file mode 100644 index 0000000..0c28b37 --- /dev/null +++ b/internal/testsuite/runner_env_test.go @@ -0,0 +1,20 @@ +package testsuite + +import ( + "reflect" + "testing" +) + +func TestPreflightStepsExactSequence(t *testing.T) { + want := [][]string{ + {"go", "test", "./...", "-count=1"}, + {"./tests/scripts/check-node-split-syntax.sh"}, + {"node", "--test", "api/helpers/stream-tool-sieve.test.js", "api/chat-stream.test.js", "api/compat/js_compat_test.js"}, + {"npm", "run", "build", "--prefix", "webui"}, + } + + got := preflightSteps() + if !reflect.DeepEqual(got, want) { + t.Fatalf("preflight steps mismatch\nwant=%v\ngot=%v", want, got) + } +} diff --git a/internal/testsuite/runner_registry_test.go b/internal/testsuite/runner_registry_test.go new file mode 100644 index 0000000..5e5cd7e --- /dev/null +++ b/internal/testsuite/runner_registry_test.go @@ -0,0 +1,85 @@ +package testsuite + +import ( + "sort" + "testing" +) + +func TestRunnerCasesRegistryExactSet(t *testing.T) { + r := &Runner{} + got := r.cases() + wantIDs := []string{ + "healthz_ok", + "readyz_ok", + "models_openai", + "model_openai_by_id", + "models_claude", + "admin_login_verify", + "admin_queue_status", + "chat_nonstream_basic", + "chat_stream_basic", + "responses_nonstream_basic", + "responses_stream_basic", + "embeddings_contract", + "reasoner_stream", + "toolcall_nonstream", + "toolcall_stream", + "anthropic_messages_nonstream", + "anthropic_messages_stream", + "anthropic_count_tokens", + "admin_account_test_single", + "concurrency_burst", + "concurrency_threshold_limit", + "stream_abort_release", + "toolcall_stream_mixed", + "sse_json_integrity", + "error_contract_invalid_model", + "error_contract_missing_messages", + "admin_unauthorized_contract", + "config_write_isolated", + "token_refresh_managed_account", + "error_contract_invalid_key", + } + + if len(got) != len(wantIDs) { + t.Fatalf("unexpected case count: got=%d want=%d", len(got), len(wantIDs)) + } + + wantSet := map[string]struct{}{} + for _, id := range wantIDs { + wantSet[id] = struct{}{} + } + + gotSet := map[string]struct{}{} + for i, cs := range got { + if cs.ID == "" { + t.Fatalf("case[%d] has empty ID", i) + } + if cs.Run == nil { + t.Fatalf("case[%d] (%s) has nil Run", i, cs.ID) + } + if _, exists := gotSet[cs.ID]; exists { + t.Fatalf("duplicate case ID: %s", cs.ID) + } + gotSet[cs.ID] = struct{}{} + } + + var missing []string + for id := range wantSet { + if _, ok := gotSet[id]; !ok { + missing = append(missing, id) + } + } + var extra []string + for id := range gotSet { + if _, ok := wantSet[id]; !ok { + extra = append(extra, id) + } + } + sort.Strings(missing) + sort.Strings(extra) + + if len(missing) > 0 || len(extra) > 0 { + t.Fatalf("registry mismatch: missing=%v extra=%v", missing, extra) + } +} diff --git a/plans/node-syntax-gate-targets.txt b/plans/node-syntax-gate-targets.txt new file mode 100644 index 0000000..3d30111 --- /dev/null +++ b/plans/node-syntax-gate-targets.txt @@ -0,0 +1,22 @@ +# Node split syntax gate targets +# Keep this list in sync with api/chat-stream and api/helpers/stream-tool-sieve split modules. + +api/chat-stream.js +api/chat-stream/index.js +api/chat-stream/error_shape.js +api/chat-stream/http_internal.js +api/chat-stream/proxy_go.js +api/chat-stream/sse_parse.js +api/chat-stream/stream_emitter.js +api/chat-stream/token_usage.js +api/chat-stream/toolcall_policy.js +api/chat-stream/vercel_stream.js + +api/helpers/stream-tool-sieve.js +api/helpers/stream-tool-sieve/index.js +api/helpers/stream-tool-sieve/state.js +api/helpers/stream-tool-sieve/sieve.js +api/helpers/stream-tool-sieve/incremental.js +api/helpers/stream-tool-sieve/jsonscan.js +api/helpers/stream-tool-sieve/parse.js +api/helpers/stream-tool-sieve/format.js diff --git a/plans/stage6-manual-smoke.md b/plans/stage6-manual-smoke.md index 8875b54..5463932 100644 --- a/plans/stage6-manual-smoke.md +++ b/plans/stage6-manual-smoke.md @@ -27,3 +27,10 @@ - Status: `PENDING` - Notes: +## PASS Example + +- Date: 2026-02-22 +- Tester: release-maintainer +- Environment: local macOS + latest Chrome +- Status: `PASS` +- Notes: login/account/api-tester/settings/vercel-sync smoke passed with no behavior regressions. diff --git a/tests/scripts/check-node-split-syntax.sh b/tests/scripts/check-node-split-syntax.sh new file mode 100755 index 0000000..e06cb47 --- /dev/null +++ b/tests/scripts/check-node-split-syntax.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +TARGETS_FILE="${1:-$ROOT_DIR/plans/node-syntax-gate-targets.txt}" + +if [[ ! -f "$TARGETS_FILE" ]]; then + echo "missing targets file: $TARGETS_FILE" >&2 + exit 1 +fi + +checked=0 +missing=0 +invalid=0 + +while IFS= read -r file; do + [[ -z "$file" ]] && continue + [[ "${file:0:1}" == "#" ]] && continue + + checked=$((checked + 1)) + abs="$ROOT_DIR/$file" + if [[ ! -f "$abs" ]]; then + echo "MISSING $file" + missing=$((missing + 1)) + continue + fi + + if ! node --check "$abs"; then + echo "INVALID $file" + invalid=$((invalid + 1)) + fi +done < "$TARGETS_FILE" + +echo "checked=$checked missing=$missing invalid=$invalid" + +if (( missing > 0 || invalid > 0 )); then + exit 1 +fi diff --git a/tests/scripts/check-stage6-manual-smoke.sh b/tests/scripts/check-stage6-manual-smoke.sh new file mode 100755 index 0000000..5e29aba --- /dev/null +++ b/tests/scripts/check-stage6-manual-smoke.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +SMOKE_FILE="${1:-$ROOT_DIR/plans/stage6-manual-smoke.md}" + +if [[ ! -f "$SMOKE_FILE" ]]; then + echo "missing smoke file: $SMOKE_FILE" >&2 + exit 1 +fi + +extract_field() { + local field="$1" + local line + line="$(grep -E "^[[:space:]]*-[[:space:]]*$field:" "$SMOKE_FILE" | head -n 1 || true)" + if [[ -z "$line" ]]; then + echo "" + return + fi + printf '%s' "$line" | sed -E "s/^[[:space:]]*-[[:space:]]*$field:[[:space:]]*//" | sed -E 's/`//g;s/^[[:space:]]+//;s/[[:space:]]+$//' +} + +date_value="$(extract_field "Date")" +tester_value="$(extract_field "Tester")" +env_value="$(extract_field "Environment")" +status_value="$(extract_field "Status")" +status_upper="$(printf '%s' "$status_value" | tr '[:lower:]' '[:upper:]')" + +failed=0 + +if [[ -z "$date_value" ]]; then + echo "invalid smoke file: Date is empty" + failed=1 +fi +if [[ -z "$tester_value" ]]; then + echo "invalid smoke file: Tester is empty" + failed=1 +fi +if [[ -z "$env_value" ]]; then + echo "invalid smoke file: Environment is empty" + failed=1 +fi +if [[ "$status_upper" != "PASS" ]]; then + echo "invalid smoke file: Status must be PASS (got: ${status_value:-})" + failed=1 +fi + +if (( failed != 0 )); then + exit 1 +fi + +echo "stage6_manual_smoke=PASS file=$SMOKE_FILE" diff --git a/tests/scripts/run-unit-node.sh b/tests/scripts/run-unit-node.sh index 95f11e0..0f11847 100755 --- a/tests/scripts/run-unit-node.sh +++ b/tests/scripts/run-unit-node.sh @@ -4,4 +4,5 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" cd "$ROOT_DIR" +./tests/scripts/check-node-split-syntax.sh node --test api/helpers/stream-tool-sieve.test.js api/chat-stream.test.js api/compat/js_compat_test.js "$@"