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 } // Prune old test runs, keeping only the most recent N. 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) }