| package testsuite |
|
|
| import ( |
| "context" |
| "fmt" |
| "net/http" |
| "os" |
| "os/exec" |
| "path/filepath" |
| "sort" |
| "strings" |
| "sync" |
| "time" |
| ) |
|
|
| type Options struct { |
| ConfigPath string |
| AdminKey string |
| OutputDir string |
| Port int |
| Timeout time.Duration |
| Retries int |
| NoPreflight bool |
| MaxKeepRuns int |
| } |
|
|
| 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 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 |
| } |
|
|
| |
| if err := r.pruneOldRuns(); err != nil { |
| r.warnings = append(r.warnings, "prune old runs: "+err.Error()) |
| } |
|
|
| 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) 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) |
| } |
|
|