Files
ds2api/internal/testsuite/runner.go

1587 lines
42 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package testsuite
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
)
type Options struct {
ConfigPath string
AdminKey string
OutputDir string
Port int
Timeout time.Duration
Retries int
NoPreflight bool
}
type runSummary struct {
RunID string `json:"run_id"`
StartedAt string `json:"started_at"`
EndedAt string `json:"ended_at"`
DurationMS int64 `json:"duration_ms"`
Stats map[string]any `json:"stats"`
Environment map[string]any `json:"environment"`
Cases []caseResult `json:"cases"`
Warnings []string `json:"warnings,omitempty"`
}
type caseResult struct {
CaseID string `json:"case_id"`
Passed bool `json:"passed"`
DurationMS int64 `json:"duration_ms"`
TraceIDs []string `json:"trace_ids"`
StatusCodes []int `json:"status_codes"`
Error string `json:"error,omitempty"`
ArtifactPath string `json:"artifact_path"`
Assertions []assertionResult `json:"assertions"`
}
type assertionResult struct {
Name string `json:"name"`
Passed bool `json:"passed"`
Detail string `json:"detail,omitempty"`
}
type requestLog struct {
Seq int `json:"seq"`
Attempt int `json:"attempt"`
TraceID string `json:"trace_id"`
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
Body any `json:"body,omitempty"`
Timestamp string `json:"timestamp"`
}
type responseLog struct {
Seq int `json:"seq"`
Attempt int `json:"attempt"`
TraceID string `json:"trace_id"`
StatusCode int `json:"status_code"`
Headers map[string][]string `json:"headers"`
BodyText string `json:"body_text"`
DurationMS int64 `json:"duration_ms"`
NetworkErr string `json:"network_error,omitempty"`
ReceivedAt string `json:"received_at"`
}
type caseContext struct {
runner *Runner
id string
dir string
startedAt time.Time
mu sync.Mutex
seq int
assertions []assertionResult
requests []requestLog
responses []responseLog
streamRaw strings.Builder
traceIDsSet map[string]struct{}
}
type requestSpec struct {
Method string
Path string
Headers map[string]string
Body any
Stream bool
Retryable bool
}
type responseResult struct {
StatusCode int
Headers http.Header
Body []byte
TraceID string
URL string
}
type Runner struct {
opts Options
runID string
runDir string
serverLog string
preflightLog string
baseURL string
httpClient *http.Client
serverCmd *exec.Cmd
serverLogFd *os.File
configCopyPath string
originalConfigPath string
originalConfigHash string
configRaw runConfig
apiKey string
adminKey string
adminJWT string
accountID string
warnings []string
results []caseResult
}
type runConfig struct {
Keys []string `json:"keys"`
Accounts []struct {
Email string `json:"email,omitempty"`
Mobile string `json:"mobile,omitempty"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
} `json:"accounts"`
}
func DefaultOptions() Options {
return Options{
ConfigPath: "config.json",
AdminKey: strings.TrimSpace(os.Getenv("DS2API_ADMIN_KEY")),
OutputDir: "artifacts/testsuite",
Port: 0,
Timeout: 120 * time.Second,
Retries: 2,
NoPreflight: false,
}
}
func Run(ctx context.Context, opts Options) error {
r, err := newRunner(opts)
if err != nil {
return err
}
start := time.Now()
defer func() {
_ = r.stopServer()
}()
if err := r.prepareRunDir(); err != nil {
return err
}
if !r.opts.NoPreflight {
if err := r.runPreflight(ctx); err != nil {
_ = r.writeSummary(start, time.Now())
return err
}
}
if err := r.prepareConfigIsolation(); err != nil {
_ = r.writeSummary(start, time.Now())
return err
}
if err := r.startServer(ctx); err != nil {
_ = r.writeSummary(start, time.Now())
return err
}
if err := r.prepareAuth(ctx); err != nil {
r.warnings = append(r.warnings, "auth prepare failed: "+err.Error())
}
for _, c := range r.cases() {
r.runCase(ctx, c)
}
if err := r.ensureOriginalConfigUntouched(); err != nil {
r.warnings = append(r.warnings, err.Error())
}
end := time.Now()
if err := r.writeSummary(start, end); err != nil {
return err
}
failed := 0
for _, cs := range r.results {
if !cs.Passed {
failed++
}
}
if failed > 0 {
return fmt.Errorf("testsuite failed: %d case(s) failed, see %s", failed, filepath.Join(r.runDir, "summary.md"))
}
return nil
}
func newRunner(opts Options) (*Runner, error) {
if strings.TrimSpace(opts.ConfigPath) == "" {
opts.ConfigPath = "config.json"
}
if strings.TrimSpace(opts.OutputDir) == "" {
opts.OutputDir = "artifacts/testsuite"
}
if opts.Timeout <= 0 {
opts.Timeout = 120 * time.Second
}
if opts.Retries < 0 {
opts.Retries = 0
}
adminKey := strings.TrimSpace(opts.AdminKey)
if adminKey == "" {
adminKey = strings.TrimSpace(os.Getenv("DS2API_ADMIN_KEY"))
}
if adminKey == "" {
adminKey = "admin"
}
opts.AdminKey = adminKey
return &Runner{
opts: opts,
httpClient: &http.Client{
Timeout: 0,
},
runID: time.Now().UTC().Format("20060102T150405Z"),
adminKey: adminKey,
}, nil
}
func (r *Runner) prepareRunDir() error {
r.runDir = filepath.Join(r.opts.OutputDir, r.runID)
if err := os.MkdirAll(r.runDir, 0o755); err != nil {
return err
}
if err := os.MkdirAll(filepath.Join(r.runDir, "cases"), 0o755); err != nil {
return err
}
r.serverLog = filepath.Join(r.runDir, "server.log")
r.preflightLog = filepath.Join(r.runDir, "preflight.log")
return nil
}
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"},
{"npm", "run", "build", "--prefix", "webui"},
}
f, err := os.OpenFile(r.preflightLog, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
defer f.Close()
for _, step := range steps {
if _, err := fmt.Fprintf(f, "\n$ %s\n", strings.Join(step, " ")); err != nil {
return err
}
cmd := exec.CommandContext(ctx, step[0], step[1:]...)
cmd.Stdout = f
cmd.Stderr = f
if err := cmd.Run(); err != nil {
return fmt.Errorf("preflight failed at `%s`: %w", strings.Join(step, " "), err)
}
}
return nil
}
func (r *Runner) prepareConfigIsolation() error {
abs, err := filepath.Abs(r.opts.ConfigPath)
if err != nil {
return err
}
r.originalConfigPath = abs
raw, err := os.ReadFile(abs)
if err != nil {
return err
}
sum := sha256.Sum256(raw)
r.originalConfigHash = hex.EncodeToString(sum[:])
tmpDir := filepath.Join(r.runDir, "tmp")
if err := os.MkdirAll(tmpDir, 0o755); err != nil {
return err
}
r.configCopyPath = filepath.Join(tmpDir, "config.json")
if err := os.WriteFile(r.configCopyPath, raw, 0o644); err != nil {
return err
}
var cfg runConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return fmt.Errorf("parse config failed: %w", err)
}
r.configRaw = cfg
if len(cfg.Keys) > 0 {
r.apiKey = strings.TrimSpace(cfg.Keys[0])
}
for _, acc := range cfg.Accounts {
id := strings.TrimSpace(acc.Email)
if id == "" {
id = strings.TrimSpace(acc.Mobile)
}
if id != "" {
r.accountID = id
break
}
}
return nil
}
func (r *Runner) startServer(ctx context.Context) error {
port := r.opts.Port
if port <= 0 {
p, err := findFreePort()
if err != nil {
return err
}
port = p
}
r.baseURL = "http://127.0.0.1:" + strconv.Itoa(port)
logFd, err := os.OpenFile(r.serverLog, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
r.serverLogFd = logFd
cmd := exec.CommandContext(ctx, "go", "run", "./cmd/ds2api")
cmd.Stdout = logFd
cmd.Stderr = logFd
cmd.Env = prepareServerEnv(os.Environ(), map[string]string{
"PORT": strconv.Itoa(port),
"DS2API_CONFIG_PATH": r.configCopyPath,
"DS2API_AUTO_BUILD_WEBUI": "false",
"DS2API_CONFIG_JSON": "",
"CONFIG_JSON": "",
})
if err := cmd.Start(); err != nil {
_ = logFd.Close()
return err
}
r.serverCmd = cmd
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
if r.ping("/healthz") == nil && r.ping("/readyz") == nil {
return nil
}
time.Sleep(500 * time.Millisecond)
}
return errors.New("server readiness timeout")
}
func (r *Runner) stopServer() error {
var errs []string
if r.serverCmd != nil && r.serverCmd.Process != nil {
_ = r.serverCmd.Process.Signal(os.Interrupt)
done := make(chan error, 1)
go func() { done <- r.serverCmd.Wait() }()
select {
case <-time.After(5 * time.Second):
_ = r.serverCmd.Process.Kill()
<-done
case <-done:
}
}
if r.serverLogFd != nil {
if err := r.serverLogFd.Close(); err != nil {
errs = append(errs, err.Error())
}
}
if len(errs) > 0 {
return errors.New(strings.Join(errs, "; "))
}
return nil
}
func (r *Runner) ping(path string) error {
resp, err := r.httpClient.Get(r.baseURL + path)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status=%d", resp.StatusCode)
}
return nil
}
func (r *Runner) prepareAuth(ctx context.Context) error {
reqBody := map[string]any{
"admin_key": r.adminKey,
"expire_hours": 24,
}
resp, err := r.doSimpleJSON(ctx, http.MethodPost, "/admin/login", nil, reqBody)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("admin login status=%d body=%s", resp.StatusCode, string(resp.Body))
}
var m map[string]any
if err := json.Unmarshal(resp.Body, &m); err != nil {
return err
}
token, _ := m["token"].(string)
if strings.TrimSpace(token) == "" {
return errors.New("empty admin jwt token")
}
r.adminJWT = token
return nil
}
func (r *Runner) ensureOriginalConfigUntouched() error {
raw, err := os.ReadFile(r.originalConfigPath)
if err != nil {
return err
}
sum := sha256.Sum256(raw)
current := hex.EncodeToString(sum[:])
if current != r.originalConfigHash {
return fmt.Errorf("original config changed unexpectedly: %s", r.originalConfigPath)
}
return nil
}
func (r *Runner) runCase(ctx context.Context, c caseDef) {
caseDir := filepath.Join(r.runDir, "cases", c.ID)
_ = os.MkdirAll(caseDir, 0o755)
cc := &caseContext{
runner: r,
id: c.ID,
dir: caseDir,
startedAt: time.Now(),
traceIDsSet: map[string]struct{}{},
}
err := c.Run(ctx, cc)
duration := time.Since(cc.startedAt).Milliseconds()
if err != nil {
cc.assertions = append(cc.assertions, assertionResult{
Name: "case_error",
Passed: false,
Detail: err.Error(),
})
}
passed := err == nil
for _, a := range cc.assertions {
if !a.Passed {
passed = false
break
}
}
traceIDs := make([]string, 0, len(cc.traceIDsSet))
for t := range cc.traceIDsSet {
traceIDs = append(traceIDs, t)
}
sort.Strings(traceIDs)
statuses := uniqueStatusCodes(cc.responses)
cs := caseResult{
CaseID: c.ID,
Passed: passed,
DurationMS: duration,
TraceIDs: traceIDs,
StatusCodes: statuses,
ArtifactPath: caseDir,
Assertions: cc.assertions,
}
if err != nil {
cs.Error = err.Error()
}
_ = cc.flushArtifacts(cs)
r.results = append(r.results, cs)
}
func (cc *caseContext) assert(name string, ok bool, detail string) {
cc.mu.Lock()
defer cc.mu.Unlock()
cc.assertions = append(cc.assertions, assertionResult{
Name: name,
Passed: ok,
Detail: detail,
})
}
func (cc *caseContext) request(ctx context.Context, spec requestSpec) (*responseResult, error) {
retries := cc.runner.opts.Retries
if !spec.Retryable {
retries = 0
}
var lastErr error
for attempt := 1; attempt <= retries+1; attempt++ {
resp, err := cc.requestOnce(ctx, spec, attempt)
if err == nil && resp.StatusCode < 500 {
return resp, nil
}
if err != nil {
lastErr = err
} else if resp.StatusCode >= 500 {
lastErr = fmt.Errorf("status=%d", resp.StatusCode)
}
if attempt <= retries {
sleep := time.Duration(300*(1<<(attempt-1))) * time.Millisecond
time.Sleep(sleep)
}
}
return nil, lastErr
}
func (cc *caseContext) requestOnce(ctx context.Context, spec requestSpec, attempt int) (*responseResult, error) {
cc.mu.Lock()
cc.seq++
seq := cc.seq
traceID := fmt.Sprintf("ts_%s_%s_%03d", cc.runner.runID, sanitizeID(cc.id), seq)
cc.traceIDsSet[traceID] = struct{}{}
cc.mu.Unlock()
fullURL, err := withTraceQuery(cc.runner.baseURL+spec.Path, traceID)
if err != nil {
return nil, err
}
headers := map[string]string{}
for k, v := range spec.Headers {
headers[k] = v
}
headers["X-Ds2-Test-Trace"] = traceID
var bodyBytes []byte
var bodyAny any
if spec.Body != nil {
b, err := json.Marshal(spec.Body)
if err != nil {
return nil, err
}
bodyBytes = b
bodyAny = spec.Body
headers["Content-Type"] = "application/json"
}
cc.mu.Lock()
cc.requests = append(cc.requests, requestLog{
Seq: seq,
Attempt: attempt,
TraceID: traceID,
Method: spec.Method,
URL: fullURL,
Headers: headers,
Body: bodyAny,
Timestamp: time.Now().Format(time.RFC3339Nano),
})
cc.mu.Unlock()
reqCtx, cancel := context.WithTimeout(ctx, cc.runner.opts.Timeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, spec.Method, fullURL, bytes.NewReader(bodyBytes))
if err != nil {
return nil, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
start := time.Now()
resp, err := cc.runner.httpClient.Do(req)
if err != nil {
cc.mu.Lock()
cc.responses = append(cc.responses, responseLog{
Seq: seq,
Attempt: attempt,
TraceID: traceID,
StatusCode: 0,
DurationMS: time.Since(start).Milliseconds(),
NetworkErr: err.Error(),
ReceivedAt: time.Now().Format(time.RFC3339Nano),
})
cc.mu.Unlock()
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
cc.mu.Lock()
cc.responses = append(cc.responses, responseLog{
Seq: seq,
Attempt: attempt,
TraceID: traceID,
StatusCode: resp.StatusCode,
Headers: resp.Header,
BodyText: string(body),
DurationMS: time.Since(start).Milliseconds(),
ReceivedAt: time.Now().Format(time.RFC3339Nano),
})
if spec.Stream {
cc.streamRaw.WriteString(fmt.Sprintf("### trace=%s url=%s\n", traceID, fullURL))
cc.streamRaw.Write(body)
cc.streamRaw.WriteString("\n\n")
}
cc.mu.Unlock()
return &responseResult{
StatusCode: resp.StatusCode,
Headers: resp.Header,
Body: body,
TraceID: traceID,
URL: fullURL,
}, nil
}
func (cc *caseContext) flushArtifacts(cs caseResult) error {
requestPath := filepath.Join(cc.dir, "request.json")
headersPath := filepath.Join(cc.dir, "response.headers")
bodyPath := filepath.Join(cc.dir, "response.body")
streamPath := filepath.Join(cc.dir, "stream.raw")
assertPath := filepath.Join(cc.dir, "assertions.json")
metaPath := filepath.Join(cc.dir, "meta.json")
if err := writeJSONFile(requestPath, cc.requests); err != nil {
return err
}
respHeaders := make([]map[string]any, 0, len(cc.responses))
respBodies := make([]map[string]any, 0, len(cc.responses))
for _, r := range cc.responses {
respHeaders = append(respHeaders, map[string]any{
"seq": r.Seq,
"attempt": r.Attempt,
"trace_id": r.TraceID,
"status_code": r.StatusCode,
"headers": r.Headers,
})
respBodies = append(respBodies, map[string]any{
"seq": r.Seq,
"attempt": r.Attempt,
"trace_id": r.TraceID,
"status_code": r.StatusCode,
"body_text": r.BodyText,
"network_error": r.NetworkErr,
"duration_ms": r.DurationMS,
})
}
if err := writeJSONFile(headersPath, respHeaders); err != nil {
return err
}
if err := writeJSONFile(bodyPath, respBodies); err != nil {
return err
}
if err := os.WriteFile(streamPath, []byte(cc.streamRaw.String()), 0o644); err != nil {
return err
}
if err := writeJSONFile(assertPath, cc.assertions); err != nil {
return err
}
meta := map[string]any{
"case_id": cs.CaseID,
"trace_id": strings.Join(cs.TraceIDs, ","),
"attempt": len(cc.responses),
"duration_ms": cs.DurationMS,
"status": map[bool]string{true: "passed", false: "failed"}[cs.Passed],
"status_codes": cs.StatusCodes,
"assertions": cs.Assertions,
"artifact_path": cs.ArtifactPath,
}
return writeJSONFile(metaPath, meta)
}
type caseDef struct {
ID string
Run func(context.Context, *caseContext) error
}
func (r *Runner) cases() []caseDef {
return []caseDef{
{ID: "healthz_ok", Run: r.caseHealthz},
{ID: "readyz_ok", Run: r.caseReadyz},
{ID: "models_openai", Run: r.caseModelsOpenAI},
{ID: "models_claude", Run: r.caseModelsClaude},
{ID: "admin_login_verify", Run: r.caseAdminLoginVerify},
{ID: "admin_queue_status", Run: r.caseAdminQueueStatus},
{ID: "chat_nonstream_basic", Run: r.caseChatNonstream},
{ID: "chat_stream_basic", Run: r.caseChatStream},
{ID: "reasoner_stream", Run: r.caseReasonerStream},
{ID: "toolcall_nonstream", Run: r.caseToolcallNonstream},
{ID: "toolcall_stream", Run: r.caseToolcallStream},
{ID: "anthropic_messages_nonstream", Run: r.caseAnthropicNonstream},
{ID: "anthropic_messages_stream", Run: r.caseAnthropicStream},
{ID: "anthropic_count_tokens", Run: r.caseAnthropicCountTokens},
{ID: "admin_account_test_single", Run: r.caseAdminAccountTest},
{ID: "concurrency_burst", Run: r.caseConcurrencyBurst},
{ID: "concurrency_threshold_limit", Run: r.caseConcurrencyThresholdLimit},
{ID: "stream_abort_release", Run: r.caseStreamAbortRelease},
{ID: "toolcall_stream_mixed", Run: r.caseToolcallStreamMixed},
{ID: "sse_json_integrity", Run: r.caseSSEJSONIntegrity},
{ID: "error_contract_invalid_model", Run: r.caseInvalidModel},
{ID: "error_contract_missing_messages", Run: r.caseMissingMessages},
{ID: "admin_unauthorized_contract", Run: r.caseAdminUnauthorized},
{ID: "config_write_isolated", Run: r.caseConfigWriteIsolated},
{ID: "token_refresh_managed_account", Run: r.caseTokenRefreshManagedAccount},
{ID: "error_contract_invalid_key", Run: r.caseInvalidKey},
}
}
func (r *Runner) caseHealthz(ctx context.Context, cc *caseContext) error {
resp, err := cc.request(ctx, requestSpec{Method: http.MethodGet, Path: "/healthz", Retryable: true})
if err != nil {
return err
}
cc.assert("status_200", resp.StatusCode == http.StatusOK, fmt.Sprintf("status=%d", resp.StatusCode))
var m map[string]any
_ = json.Unmarshal(resp.Body, &m)
cc.assert("status_ok", asString(m["status"]) == "ok", fmt.Sprintf("body=%s", string(resp.Body)))
return nil
}
func (r *Runner) caseReadyz(ctx context.Context, cc *caseContext) error {
resp, err := cc.request(ctx, requestSpec{Method: http.MethodGet, Path: "/readyz", Retryable: true})
if err != nil {
return err
}
cc.assert("status_200", resp.StatusCode == http.StatusOK, fmt.Sprintf("status=%d", resp.StatusCode))
var m map[string]any
_ = json.Unmarshal(resp.Body, &m)
cc.assert("status_ready", asString(m["status"]) == "ready", fmt.Sprintf("body=%s", string(resp.Body)))
return nil
}
func (r *Runner) caseModelsOpenAI(ctx context.Context, cc *caseContext) error {
resp, err := cc.request(ctx, requestSpec{Method: http.MethodGet, Path: "/v1/models", Retryable: true})
if err != nil {
return err
}
cc.assert("status_200", resp.StatusCode == http.StatusOK, fmt.Sprintf("status=%d", resp.StatusCode))
ids := extractModelIDs(resp.Body)
cc.assert("has_deepseek_chat", contains(ids, "deepseek-chat"), strings.Join(ids, ","))
cc.assert("has_deepseek_reasoner", contains(ids, "deepseek-reasoner"), strings.Join(ids, ","))
return nil
}
func (r *Runner) caseModelsClaude(ctx context.Context, cc *caseContext) error {
resp, err := cc.request(ctx, requestSpec{Method: http.MethodGet, Path: "/anthropic/v1/models", Retryable: true})
if err != nil {
return err
}
cc.assert("status_200", resp.StatusCode == http.StatusOK, fmt.Sprintf("status=%d", resp.StatusCode))
ids := extractModelIDs(resp.Body)
cc.assert("non_empty", len(ids) > 0, fmt.Sprintf("models=%v", ids))
return nil
}
func (r *Runner) caseAdminLoginVerify(ctx context.Context, cc *caseContext) error {
loginResp, err := cc.request(ctx, requestSpec{
Method: http.MethodPost,
Path: "/admin/login",
Body: map[string]any{"admin_key": r.adminKey, "expire_hours": 24},
Retryable: true,
})
if err != nil {
return err
}
cc.assert("login_status_200", loginResp.StatusCode == http.StatusOK, fmt.Sprintf("status=%d", loginResp.StatusCode))
var payload map[string]any
_ = json.Unmarshal(loginResp.Body, &payload)
token := asString(payload["token"])
cc.assert("token_exists", token != "", fmt.Sprintf("body=%s", string(loginResp.Body)))
if token == "" {
return nil
}
verifyResp, err := cc.request(ctx, requestSpec{
Method: http.MethodGet,
Path: "/admin/verify",
Headers: map[string]string{
"Authorization": "Bearer " + token,
},
Retryable: true,
})
if err != nil {
return err
}
cc.assert("verify_status_200", verifyResp.StatusCode == http.StatusOK, fmt.Sprintf("status=%d", verifyResp.StatusCode))
var v map[string]any
_ = json.Unmarshal(verifyResp.Body, &v)
valid, _ := v["valid"].(bool)
cc.assert("verify_valid_true", valid, fmt.Sprintf("body=%s", string(verifyResp.Body)))
return nil
}
func (r *Runner) caseAdminQueueStatus(ctx context.Context, cc *caseContext) error {
resp, err := cc.request(ctx, requestSpec{
Method: http.MethodGet,
Path: "/admin/queue/status",
Headers: map[string]string{
"Authorization": "Bearer " + r.adminJWT,
},
Retryable: true,
})
if err != nil {
return err
}
cc.assert("status_200", resp.StatusCode == http.StatusOK, fmt.Sprintf("status=%d", resp.StatusCode))
var m map[string]any
_ = json.Unmarshal(resp.Body, &m)
_, hasRec := m["recommended_concurrency"]
_, hasQueue := m["max_queue_size"]
cc.assert("has_recommended_concurrency", hasRec, fmt.Sprintf("body=%s", string(resp.Body)))
cc.assert("has_max_queue_size", hasQueue, fmt.Sprintf("body=%s", string(resp.Body)))
return nil
}
func (r *Runner) caseChatNonstream(ctx context.Context, cc *caseContext) error {
resp, err := cc.request(ctx, requestSpec{
Method: http.MethodPost,
Path: "/v1/chat/completions",
Headers: map[string]string{
"Authorization": "Bearer " + r.apiKey,
},
Body: map[string]any{
"model": "deepseek-chat",
"messages": []map[string]any{
{"role": "user", "content": "请简单回复一句话"},
},
"stream": false,
},
Retryable: true,
})
if err != nil {
return err
}
cc.assert("status_200", resp.StatusCode == http.StatusOK, fmt.Sprintf("status=%d", resp.StatusCode))
var m map[string]any
_ = json.Unmarshal(resp.Body, &m)
cc.assert("object_chat_completion", asString(m["object"]) == "chat.completion", fmt.Sprintf("body=%s", string(resp.Body)))
choices, _ := m["choices"].([]any)
cc.assert("choices_non_empty", len(choices) > 0, fmt.Sprintf("body=%s", string(resp.Body)))
return nil
}
func (r *Runner) caseChatStream(ctx context.Context, cc *caseContext) error {
resp, err := cc.request(ctx, requestSpec{
Method: http.MethodPost,
Path: "/v1/chat/completions",
Headers: map[string]string{
"Authorization": "Bearer " + r.apiKey,
},
Body: map[string]any{
"model": "deepseek-chat",
"messages": []map[string]any{
{"role": "user", "content": "请流式回复一句话"},
},
"stream": true,
},
Stream: true,
Retryable: true,
})
if err != nil {
return err
}
cc.assert("status_200", resp.StatusCode == http.StatusOK, fmt.Sprintf("status=%d", resp.StatusCode))
frames, done := parseSSEFrames(resp.Body)
cc.assert("frames_non_empty", len(frames) > 0, fmt.Sprintf("len=%d", len(frames)))
cc.assert("done_terminated", done, "expected [DONE]")
return nil
}
func (r *Runner) caseReasonerStream(ctx context.Context, cc *caseContext) error {
resp, err := cc.request(ctx, requestSpec{
Method: http.MethodPost,
Path: "/v1/chat/completions",
Headers: map[string]string{
"Authorization": "Bearer " + r.apiKey,
},
Body: map[string]any{
"model": "deepseek-reasoner",
"messages": []map[string]any{
{"role": "user", "content": "先思考后回答1+1"},
},
"stream": true,
},
Stream: true,
Retryable: true,
})
if err != nil {
return err
}
cc.assert("status_200", resp.StatusCode == http.StatusOK, fmt.Sprintf("status=%d", resp.StatusCode))
frames, done := parseSSEFrames(resp.Body)
hasReasoning := false
for _, f := range frames {
choices, _ := f["choices"].([]any)
for _, c := range choices {
ch, _ := c.(map[string]any)
delta, _ := ch["delta"].(map[string]any)
if asString(delta["reasoning_content"]) != "" {
hasReasoning = true
}
}
}
cc.assert("has_reasoning_content", hasReasoning, "reasoning_content not found")
cc.assert("done_terminated", done, "expected [DONE]")
return nil
}
func (r *Runner) caseToolcallNonstream(ctx context.Context, cc *caseContext) error {
resp, err := cc.request(ctx, requestSpec{
Method: http.MethodPost,
Path: "/v1/chat/completions",
Headers: map[string]string{
"Authorization": "Bearer " + r.apiKey,
},
Body: toolcallPayload(false),
Retryable: true,
})
if err != nil {
return err
}
cc.assert("status_200", resp.StatusCode == http.StatusOK, fmt.Sprintf("status=%d", resp.StatusCode))
var m map[string]any
_ = json.Unmarshal(resp.Body, &m)
choices, _ := m["choices"].([]any)
if len(choices) == 0 {
cc.assert("choices_non_empty", false, fmt.Sprintf("body=%s", string(resp.Body)))
return nil
}
c0, _ := choices[0].(map[string]any)
cc.assert("finish_reason_tool_calls", asString(c0["finish_reason"]) == "tool_calls", fmt.Sprintf("body=%s", string(resp.Body)))
msg, _ := c0["message"].(map[string]any)
tc, _ := msg["tool_calls"].([]any)
cc.assert("tool_calls_present", len(tc) > 0, fmt.Sprintf("body=%s", string(resp.Body)))
return nil
}
func (r *Runner) caseToolcallStream(ctx context.Context, cc *caseContext) error {
resp, err := cc.request(ctx, requestSpec{
Method: http.MethodPost,
Path: "/v1/chat/completions",
Headers: map[string]string{
"Authorization": "Bearer " + r.apiKey,
},
Body: toolcallPayload(true),
Stream: true,
Retryable: true,
})
if err != nil {
return err
}
cc.assert("status_200", resp.StatusCode == http.StatusOK, fmt.Sprintf("status=%d", resp.StatusCode))
frames, done := parseSSEFrames(resp.Body)
hasTool := false
rawLeak := false
for _, f := range frames {
choices, _ := f["choices"].([]any)
for _, c := range choices {
ch, _ := c.(map[string]any)
delta, _ := ch["delta"].(map[string]any)
if _, ok := delta["tool_calls"]; ok {
hasTool = true
}
content := asString(delta["content"])
if strings.Contains(strings.ToLower(content), `"tool_calls"`) {
rawLeak = true
}
}
}
cc.assert("tool_calls_delta_present", hasTool, "tool_calls delta missing")
cc.assert("no_raw_tool_json_leak", !rawLeak, "raw tool_calls JSON leaked in content")
cc.assert("done_terminated", done, "expected [DONE]")
return nil
}
func (r *Runner) caseAnthropicNonstream(ctx context.Context, cc *caseContext) error {
resp, err := cc.request(ctx, requestSpec{
Method: http.MethodPost,
Path: "/anthropic/v1/messages",
Headers: map[string]string{
"Authorization": "Bearer " + r.apiKey,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
Body: map[string]any{
"model": "claude-sonnet-4-20250514",
"messages": []map[string]any{
{"role": "user", "content": "hello"},
},
"stream": false,
},
Retryable: true,
})
if err != nil {
return err
}
cc.assert("status_200", resp.StatusCode == http.StatusOK, fmt.Sprintf("status=%d", resp.StatusCode))
var m map[string]any
_ = json.Unmarshal(resp.Body, &m)
cc.assert("type_message", asString(m["type"]) == "message", fmt.Sprintf("body=%s", string(resp.Body)))
return nil
}
func (r *Runner) caseAnthropicStream(ctx context.Context, cc *caseContext) error {
resp, err := cc.request(ctx, requestSpec{
Method: http.MethodPost,
Path: "/anthropic/v1/messages",
Headers: map[string]string{
"Authorization": "Bearer " + r.apiKey,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
Body: map[string]any{
"model": "claude-sonnet-4-20250514",
"messages": []map[string]any{
{"role": "user", "content": "stream hello"},
},
"stream": true,
},
Stream: true,
Retryable: true,
})
if err != nil {
return err
}
cc.assert("status_200", resp.StatusCode == http.StatusOK, fmt.Sprintf("status=%d", resp.StatusCode))
events := parseClaudeStreamEvents(resp.Body)
cc.assert("has_message_start", contains(events, "message_start"), fmt.Sprintf("events=%v", events))
cc.assert("has_message_stop", contains(events, "message_stop"), fmt.Sprintf("events=%v", events))
return nil
}
func (r *Runner) caseAnthropicCountTokens(ctx context.Context, cc *caseContext) error {
resp, err := cc.request(ctx, requestSpec{
Method: http.MethodPost,
Path: "/anthropic/v1/messages/count_tokens",
Headers: map[string]string{
"Authorization": "Bearer " + r.apiKey,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
Body: map[string]any{
"model": "claude-sonnet-4-20250514",
"messages": []map[string]any{
{"role": "user", "content": "count me"},
},
},
Retryable: true,
})
if err != nil {
return err
}
cc.assert("status_200", resp.StatusCode == http.StatusOK, fmt.Sprintf("status=%d", resp.StatusCode))
var m map[string]any
_ = json.Unmarshal(resp.Body, &m)
v := toInt(m["input_tokens"])
cc.assert("input_tokens_gt_zero", v > 0, fmt.Sprintf("body=%s", string(resp.Body)))
return nil
}
func (r *Runner) caseAdminAccountTest(ctx context.Context, cc *caseContext) error {
if strings.TrimSpace(r.accountID) == "" {
cc.assert("account_present", false, "no account in config")
return nil
}
resp, err := cc.request(ctx, requestSpec{
Method: http.MethodPost,
Path: "/admin/accounts/test",
Headers: map[string]string{
"Authorization": "Bearer " + r.adminJWT,
},
Body: map[string]any{
"identifier": r.accountID,
"model": "deepseek-chat",
"message": "ping",
},
Retryable: true,
})
if err != nil {
return err
}
cc.assert("status_200", resp.StatusCode == http.StatusOK, fmt.Sprintf("status=%d", resp.StatusCode))
var m map[string]any
_ = json.Unmarshal(resp.Body, &m)
ok, _ := m["success"].(bool)
cc.assert("success_true", ok, fmt.Sprintf("body=%s", string(resp.Body)))
return nil
}
func (r *Runner) caseConcurrencyBurst(ctx context.Context, cc *caseContext) error {
accountCount := len(r.configRaw.Accounts)
n := accountCount*2 + 2
if n < 2 {
n = 2
}
type one struct {
Status int
Err string
}
results := make([]one, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
resp, err := cc.request(ctx, requestSpec{
Method: http.MethodPost,
Path: "/v1/chat/completions",
Headers: map[string]string{
"Authorization": "Bearer " + r.apiKey,
},
Body: map[string]any{
"model": "deepseek-chat",
"messages": []map[string]any{
{"role": "user", "content": fmt.Sprintf("并发请求 #%d请回复ok", idx)},
},
"stream": true,
},
Stream: true,
Retryable: true,
})
if err != nil {
results[idx] = one{Err: err.Error()}
return
}
results[idx] = one{Status: resp.StatusCode}
}(i)
}
wg.Wait()
dist := map[int]int{}
success := 0
for _, it := range results {
if it.Status > 0 {
dist[it.Status]++
if it.Status == http.StatusOK {
success++
}
}
}
cc.assert("success_gt_zero", success > 0, fmt.Sprintf("distribution=%v", dist))
_, has5xx := has5xx(dist)
cc.assert("no_5xx", !has5xx, fmt.Sprintf("distribution=%v", dist))
if err := r.ping("/healthz"); err != nil {
cc.assert("server_alive", false, err.Error())
} else {
cc.assert("server_alive", true, "")
}
return nil
}
func (r *Runner) caseConfigWriteIsolated(ctx context.Context, cc *caseContext) error {
k := "testsuite-temp-" + sanitizeID(r.runID)
add, err := cc.request(ctx, requestSpec{
Method: http.MethodPost,
Path: "/admin/keys",
Headers: map[string]string{
"Authorization": "Bearer " + r.adminJWT,
},
Body: map[string]any{"key": k},
Retryable: true,
})
if err != nil {
return err
}
cc.assert("add_key_status_200", add.StatusCode == http.StatusOK, fmt.Sprintf("status=%d", add.StatusCode))
cfg1, err := cc.request(ctx, requestSpec{
Method: http.MethodGet,
Path: "/admin/config",
Headers: map[string]string{
"Authorization": "Bearer " + r.adminJWT,
},
Retryable: true,
})
if err != nil {
return err
}
containsAdded := strings.Contains(string(cfg1.Body), k)
cc.assert("key_present_in_isolated_config", containsAdded, "added key not found in isolated config")
delPath := "/admin/keys/" + url.PathEscape(k)
del, err := cc.request(ctx, requestSpec{
Method: http.MethodDelete,
Path: delPath,
Headers: map[string]string{
"Authorization": "Bearer " + r.adminJWT,
},
Retryable: true,
})
if err != nil {
return err
}
cc.assert("delete_key_status_200", del.StatusCode == http.StatusOK, fmt.Sprintf("status=%d", del.StatusCode))
cfg2, err := cc.request(ctx, requestSpec{
Method: http.MethodGet,
Path: "/admin/config",
Headers: map[string]string{
"Authorization": "Bearer " + r.adminJWT,
},
Retryable: true,
})
if err != nil {
return err
}
cc.assert("key_removed_in_isolated_config", !strings.Contains(string(cfg2.Body), k), "temporary key still present")
if err := r.ensureOriginalConfigUntouched(); err != nil {
cc.assert("original_config_unchanged", false, err.Error())
} else {
cc.assert("original_config_unchanged", true, "")
}
return nil
}
func (r *Runner) caseInvalidKey(ctx context.Context, cc *caseContext) error {
resp, err := cc.request(ctx, requestSpec{
Method: http.MethodPost,
Path: "/v1/chat/completions",
Headers: map[string]string{
"Authorization": "Bearer invalid-testsuite-key-" + sanitizeID(r.runID),
},
Body: map[string]any{
"model": "deepseek-chat",
"messages": []map[string]any{
{"role": "user", "content": "hi"},
},
"stream": false,
},
Retryable: true,
})
if err != nil {
return err
}
cc.assert("status_401", resp.StatusCode == http.StatusUnauthorized, fmt.Sprintf("status=%d", resp.StatusCode))
var m map[string]any
_ = json.Unmarshal(resp.Body, &m)
e, _ := m["error"].(map[string]any)
cc.assert("error_object_present", len(e) > 0, fmt.Sprintf("body=%s", string(resp.Body)))
cc.assert("error_message_present", asString(e["message"]) != "", fmt.Sprintf("body=%s", string(resp.Body)))
return nil
}
func (r *Runner) doSimpleJSON(ctx context.Context, method, path string, headers map[string]string, body any) (*responseResult, error) {
cc := &caseContext{
runner: r,
id: "auth_prepare",
traceIDsSet: map[string]struct{}{},
}
return cc.request(ctx, requestSpec{
Method: method,
Path: path,
Headers: headers,
Body: body,
Retryable: true,
})
}
func (r *Runner) writeSummary(start, end time.Time) error {
passed := 0
failed := 0
for _, cs := range r.results {
if cs.Passed {
passed++
} else {
failed++
}
}
summary := runSummary{
RunID: r.runID,
StartedAt: start.Format(time.RFC3339Nano),
EndedAt: end.Format(time.RFC3339Nano),
DurationMS: end.Sub(start).Milliseconds(),
Stats: map[string]any{
"total": len(r.results),
"passed": passed,
"failed": failed,
},
Environment: map[string]any{
"go_version": runtime.Version(),
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"base_url": r.baseURL,
"config_source": r.originalConfigPath,
"config_isolated": r.configCopyPath,
"server_log": r.serverLog,
"preflight_log": r.preflightLog,
"retries": r.opts.Retries,
"timeout_seconds": int(r.opts.Timeout.Seconds()),
},
Cases: r.results,
Warnings: r.warnings,
}
if err := writeJSONFile(filepath.Join(r.runDir, "summary.json"), summary); err != nil {
return err
}
return os.WriteFile(filepath.Join(r.runDir, "summary.md"), []byte(r.summaryMarkdown(summary)), 0o644)
}
func (r *Runner) summaryMarkdown(s runSummary) string {
var b strings.Builder
b.WriteString("# DS2API Live Testsuite Summary\n\n")
b.WriteString("**Sensitive Notice:** this run stores full raw request/response logs. Do not share artifacts publicly.\n\n")
fmt.Fprintf(&b, "- Run ID: `%s`\n", s.RunID)
fmt.Fprintf(&b, "- Started: `%s`\n", s.StartedAt)
fmt.Fprintf(&b, "- Ended: `%s`\n", s.EndedAt)
fmt.Fprintf(&b, "- Duration: `%d ms`\n", s.DurationMS)
fmt.Fprintf(&b, "- Passed/Failed: `%d/%d`\n\n", s.Stats["passed"], s.Stats["failed"])
if len(s.Warnings) > 0 {
b.WriteString("## Warnings\n\n")
for _, w := range s.Warnings {
fmt.Fprintf(&b, "- %s\n", w)
}
b.WriteString("\n")
}
b.WriteString("## Failed Cases\n\n")
hasFailed := false
for _, c := range s.Cases {
if c.Passed {
continue
}
hasFailed = true
fmt.Fprintf(&b, "- `%s`: %s\n", c.CaseID, c.Error)
if len(c.TraceIDs) > 0 {
fmt.Fprintf(&b, " - trace_ids: `%s`\n", strings.Join(c.TraceIDs, ", "))
fmt.Fprintf(&b, " - grep: `rg \"%s\" %s`\n", c.TraceIDs[0], filepath.Join(r.runDir, "server.log"))
}
fmt.Fprintf(&b, " - artifact: `%s`\n", c.ArtifactPath)
}
if !hasFailed {
b.WriteString("- none\n")
}
b.WriteString("\n## Case Table\n\n")
b.WriteString("| case_id | status | duration_ms | statuses | artifact |\n")
b.WriteString("|---|---:|---:|---|---|\n")
for _, c := range s.Cases {
status := "PASS"
if !c.Passed {
status = "FAIL"
}
fmt.Fprintf(&b, "| %s | %s | %d | %v | `%s` |\n", c.CaseID, status, c.DurationMS, c.StatusCodes, c.ArtifactPath)
}
return b.String()
}
func toolcallPayload(stream bool) map[string]any {
return map[string]any{
"model": "deepseek-chat",
"messages": []map[string]any{
{
"role": "user",
"content": "你必须调用工具 search 查询 golang并仅返回工具调用。",
},
},
"tools": []map[string]any{
{
"type": "function",
"function": map[string]any{
"name": "search",
"description": "search documents",
"parameters": map[string]any{
"type": "object",
"properties": map[string]any{
"q": map[string]any{
"type": "string",
},
},
"required": []string{"q"},
},
},
},
},
"stream": stream,
}
}
func parseSSEFrames(body []byte) ([]map[string]any, bool) {
lines := strings.Split(string(body), "\n")
frames := make([]map[string]any, 0, len(lines))
done := false
for _, line := range lines {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "" {
continue
}
if payload == "[DONE]" {
done = true
continue
}
var m map[string]any
if err := json.Unmarshal([]byte(payload), &m); err == nil {
frames = append(frames, m)
}
}
return frames, done
}
func parseClaudeStreamEvents(body []byte) []string {
events := []string{}
seen := map[string]bool{}
lines := strings.Split(string(body), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "" {
continue
}
var m map[string]any
if err := json.Unmarshal([]byte(payload), &m); err != nil {
continue
}
t := asString(m["type"])
if t == "" || seen[t] {
continue
}
seen[t] = true
events = append(events, t)
}
return events
}
func extractModelIDs(body []byte) []string {
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
return nil
}
out := []string{}
data, _ := m["data"].([]any)
for _, it := range data {
item, _ := it.(map[string]any)
id := asString(item["id"])
if id != "" {
out = append(out, id)
}
}
return out
}
func withTraceQuery(rawURL, traceID string) (string, error) {
u, err := url.Parse(rawURL)
if err != nil {
return "", err
}
q := u.Query()
q.Set("__trace_id", traceID)
u.RawQuery = q.Encode()
return u.String(), nil
}
func writeJSONFile(path string, v any) error {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, b, 0o644)
}
func prepareServerEnv(base []string, overrides map[string]string) []string {
out := make([]string, 0, len(base)+len(overrides))
skip := map[string]struct{}{}
for k := range overrides {
skip[k] = struct{}{}
}
for _, e := range base {
parts := strings.SplitN(e, "=", 2)
if len(parts) != 2 {
continue
}
if _, ok := skip[parts[0]]; ok {
continue
}
out = append(out, e)
}
for k, v := range overrides {
out = append(out, k+"="+v)
}
return out
}
func findFreePort() (int, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer ln.Close()
addr, ok := ln.Addr().(*net.TCPAddr)
if !ok {
return 0, errors.New("failed to detect tcp port")
}
return addr.Port, nil
}
func uniqueStatusCodes(in []responseLog) []int {
set := map[int]struct{}{}
for _, it := range in {
if it.StatusCode > 0 {
set[it.StatusCode] = struct{}{}
}
}
out := make([]int, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Ints(out)
return out
}
func has5xx(dist map[int]int) (int, bool) {
for k := range dist {
if k >= 500 {
return k, true
}
}
return 0, false
}
func sanitizeID(s string) string {
s = strings.ReplaceAll(s, ":", "_")
s = strings.ReplaceAll(s, "/", "_")
s = strings.ReplaceAll(s, " ", "_")
return s
}
func asString(v any) string {
if v == nil {
return ""
}
switch x := v.(type) {
case string:
return strings.TrimSpace(x)
default:
return strings.TrimSpace(fmt.Sprintf("%v", v))
}
}
func toInt(v any) int {
switch x := v.(type) {
case float64:
return int(x)
case float32:
return int(x)
case int:
return x
case int64:
return int(x)
default:
return 0
}
}
func contains(xs []string, target string) bool {
for _, x := range xs {
if x == target {
return true
}
}
return false
}