mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-04 08:25:26 +08:00
feat: introduce new quality gates, Node.js syntax checks, and manual smoke test status validation
This commit is contained in:
40
.github/workflows/quality-gates.yml
vendored
Normal file
40
.github/workflows/quality-gates.yml
vendored
Normal file
@@ -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
|
||||
6
.github/workflows/release-artifacts.yml
vendored
6
.github/workflows/release-artifacts.yml
vendored
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
12
TESTING.md
12
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 构建检查)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
20
internal/testsuite/runner_env_test.go
Normal file
20
internal/testsuite/runner_env_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
85
internal/testsuite/runner_registry_test.go
Normal file
85
internal/testsuite/runner_registry_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
22
plans/node-syntax-gate-targets.txt
Normal file
22
plans/node-syntax-gate-targets.txt
Normal file
@@ -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
|
||||
@@ -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.
|
||||
|
||||
38
tests/scripts/check-node-split-syntax.sh
Executable file
38
tests/scripts/check-node-split-syntax.sh
Executable file
@@ -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
|
||||
52
tests/scripts/check-stage6-manual-smoke.sh
Executable file
52
tests/scripts/check-stage6-manual-smoke.sh
Executable file
@@ -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:-<empty>})"
|
||||
failed=1
|
||||
fi
|
||||
|
||||
if (( failed != 0 )); then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "stage6_manual_smoke=PASS file=$SMOKE_FILE"
|
||||
@@ -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 "$@"
|
||||
|
||||
Reference in New Issue
Block a user