package testsuite import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" ) 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 func() { _ = 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 { _, _ = fmt.Fprintf(&cc.streamRaw, "### 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) } 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, }) }