mirror of
https://github.com/aykhans/sarin.git
synced 2026-02-28 14:59:14 +00:00
Add e2e tests
This commit is contained in:
316
e2e/e2e_test.go
Normal file
316
e2e/e2e_test.go
Normal file
@@ -0,0 +1,316 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var binaryPath string
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Build the binary once before all tests.
|
||||
tmpDir, err := os.MkdirTemp("", "sarin-e2e-*")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to create temp dir: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
binaryPath = filepath.Join(tmpDir, "sarin")
|
||||
if runtime.GOOS == "windows" {
|
||||
binaryPath += ".exe"
|
||||
}
|
||||
|
||||
cmd := exec.Command("go", "build", "-o", binaryPath, "../cmd/cli/main.go")
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to build binary: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
code := m.Run()
|
||||
os.RemoveAll(tmpDir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// --- Result type ---
|
||||
|
||||
// runResult holds the output of a sarin binary execution.
|
||||
type runResult struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
ExitCode int
|
||||
}
|
||||
|
||||
// jsonOutput parses the stdout as JSON output from sarin.
|
||||
// Fails the test if parsing fails.
|
||||
func (r runResult) jsonOutput(t *testing.T) outputData {
|
||||
t.Helper()
|
||||
var out outputData
|
||||
if err := json.Unmarshal([]byte(r.Stdout), &out); err != nil {
|
||||
t.Fatalf("failed to parse JSON output: %v\nstdout: %s", err, r.Stdout)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- JSON output structures ---
|
||||
|
||||
type responseStat struct {
|
||||
Count json.Number `json:"count"`
|
||||
Min string `json:"min"`
|
||||
Max string `json:"max"`
|
||||
Average string `json:"average"`
|
||||
P90 string `json:"p90"`
|
||||
P95 string `json:"p95"`
|
||||
P99 string `json:"p99"`
|
||||
}
|
||||
|
||||
type outputData struct {
|
||||
Responses map[string]responseStat `json:"responses"`
|
||||
Total responseStat `json:"total"`
|
||||
}
|
||||
|
||||
// --- echoResponse is the JSON structure returned by echoServer ---
|
||||
|
||||
type echoResponse struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Query map[string][]string `json:"query"`
|
||||
Headers map[string][]string `json:"headers"`
|
||||
Cookies map[string]string `json:"cookies"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
// run executes the sarin binary with the given args and returns the result.
|
||||
func run(args ...string) runResult {
|
||||
cmd := exec.Command(binaryPath, args...)
|
||||
var stdout, stderr strings.Builder
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else {
|
||||
exitCode = -1
|
||||
}
|
||||
}
|
||||
|
||||
return runResult{
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
ExitCode: exitCode,
|
||||
}
|
||||
}
|
||||
|
||||
// runWithEnv executes the sarin binary with the given args and environment variables.
|
||||
func runWithEnv(env map[string]string, args ...string) runResult {
|
||||
cmd := exec.Command(binaryPath, args...)
|
||||
var stdout, stderr strings.Builder
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
// Start with a clean env, then add the requested vars
|
||||
cmd.Env = os.Environ()
|
||||
for k, v := range env {
|
||||
cmd.Env = append(cmd.Env, k+"="+v)
|
||||
}
|
||||
|
||||
err := cmd.Run()
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exitCode = exitErr.ExitCode()
|
||||
} else {
|
||||
exitCode = -1
|
||||
}
|
||||
}
|
||||
|
||||
return runResult{
|
||||
Stdout: stdout.String(),
|
||||
Stderr: stderr.String(),
|
||||
ExitCode: exitCode,
|
||||
}
|
||||
}
|
||||
|
||||
// startProcess starts the sarin binary and returns the exec.Cmd without waiting.
|
||||
// The caller is responsible for managing the process lifecycle.
|
||||
func startProcess(args ...string) (*exec.Cmd, *strings.Builder) {
|
||||
cmd := exec.Command(binaryPath, args...)
|
||||
var stdout strings.Builder
|
||||
cmd.Stdout = &stdout
|
||||
return cmd, &stdout
|
||||
}
|
||||
|
||||
// slowServer returns a server that delays each response by the given duration.
|
||||
func slowServer(delay time.Duration) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(delay)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
}
|
||||
|
||||
// echoServer starts an HTTP test server that echoes request details back as JSON.
|
||||
// The response includes method, path, headers, query params, cookies, and body.
|
||||
func echoServer() *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
|
||||
cookies := make(map[string]string)
|
||||
for _, c := range r.Cookies() {
|
||||
cookies[c.Name] = c.Value
|
||||
}
|
||||
|
||||
resp := echoResponse{
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
Query: r.URL.Query(),
|
||||
Headers: r.Header,
|
||||
Cookies: cookies,
|
||||
Body: string(body),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
}
|
||||
|
||||
// captureServer records every request it receives and responds with 200.
|
||||
// Use lastRequest() to inspect the most recent request.
|
||||
type captureServer struct {
|
||||
*httptest.Server
|
||||
|
||||
mu sync.Mutex
|
||||
requests []echoResponse
|
||||
}
|
||||
|
||||
func newCaptureServer() *captureServer {
|
||||
cs := &captureServer{}
|
||||
cs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
|
||||
cookies := make(map[string]string)
|
||||
for _, c := range r.Cookies() {
|
||||
cookies[c.Name] = c.Value
|
||||
}
|
||||
|
||||
cs.mu.Lock()
|
||||
cs.requests = append(cs.requests, echoResponse{
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
Query: r.URL.Query(),
|
||||
Headers: r.Header,
|
||||
Cookies: cookies,
|
||||
Body: string(body),
|
||||
})
|
||||
cs.mu.Unlock()
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
return cs
|
||||
}
|
||||
|
||||
func (cs *captureServer) lastRequest() echoResponse {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
if len(cs.requests) == 0 {
|
||||
return echoResponse{}
|
||||
}
|
||||
return cs.requests[len(cs.requests)-1]
|
||||
}
|
||||
|
||||
func (cs *captureServer) allRequests() []echoResponse {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
copied := make([]echoResponse, len(cs.requests))
|
||||
copy(copied, cs.requests)
|
||||
return copied
|
||||
}
|
||||
|
||||
func (cs *captureServer) requestCount() int {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
return len(cs.requests)
|
||||
}
|
||||
|
||||
// statusServer returns a server that always responds with the given status code.
|
||||
func statusServer(code int) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(code)
|
||||
}))
|
||||
}
|
||||
|
||||
// statusServerWithBody returns a server that responds with 200 and the given body.
|
||||
func statusServerWithBody(body string) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(body))
|
||||
}))
|
||||
}
|
||||
|
||||
// writeTemp creates a temporary file with the given content and returns its path.
|
||||
// The file is automatically cleaned up when the test finishes.
|
||||
func writeTemp(t *testing.T, name, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to write temp file: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// --- Assertion helpers ---
|
||||
|
||||
func assertExitCode(t *testing.T, res runResult, want int) {
|
||||
t.Helper()
|
||||
if res.ExitCode != want {
|
||||
t.Errorf("expected exit code %d, got %d\nstdout: %s\nstderr: %s", want, res.ExitCode, res.Stdout, res.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func assertContains(t *testing.T, s, substr string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(s, substr) {
|
||||
t.Errorf("expected output to contain %q, got:\n%s", substr, s)
|
||||
}
|
||||
}
|
||||
|
||||
func assertResponseCount(t *testing.T, out outputData, wantTotal int) {
|
||||
t.Helper()
|
||||
got, err := out.Total.Count.Int64()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse total count: %v", err)
|
||||
}
|
||||
if got != int64(wantTotal) {
|
||||
t.Errorf("expected total count %d, got %d", wantTotal, got)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHasResponseKey(t *testing.T, out outputData, key string) {
|
||||
t.Helper()
|
||||
if _, ok := out.Responses[key]; !ok {
|
||||
t.Errorf("expected %q in responses, got keys: %v", key, responseKeys(out))
|
||||
}
|
||||
}
|
||||
|
||||
func responseKeys(out outputData) []string {
|
||||
keys := make([]string, 0, len(out.Responses))
|
||||
for k := range out.Responses {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
Reference in New Issue
Block a user