2 Commits

Author SHA1 Message Date
7c246102ff add CI workflow and coverage-gap validation tests 2026-02-26 19:13:42 +04:00
4b3230bb27 Add e2e tests 2026-02-18 00:03:59 +04:00
47 changed files with 4769 additions and 995 deletions

1
.github/FUNDING.yml vendored
View File

@@ -1 +1,2 @@
buy_me_a_coffee: aykhan buy_me_a_coffee: aykhan
custom: https://commerce.coinbase.com/checkout/0f33d2fb-54a6-44f5-8783-006ebf70d1a0

24
.github/workflows/e2e.yaml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: e2e-tests
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
jobs:
e2e:
name: e2e
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: 1.26.0
cache: true
- name: run e2e tests
run: go test ./e2e/... -v -count=1

View File

@@ -16,7 +16,7 @@ jobs:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- uses: actions/setup-go@v6 - uses: actions/setup-go@v6
with: with:
go-version: 1.26.1 go-version: 1.26.0
- name: go fix - name: go fix
run: | run: |
go fix ./... go fix ./...
@@ -24,4 +24,4 @@ jobs:
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@v9 uses: golangci/golangci-lint-action@v9
with: with:
version: v2.11.4 version: v2.9.0

View File

@@ -35,7 +35,7 @@ jobs:
run: | run: |
echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV
echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
echo "GO_VERSION=1.26.1" >> $GITHUB_ENV echo "GO_VERSION=1.26.0" >> $GITHUB_ENV
- name: Set up Go - name: Set up Go
if: github.event_name == 'release' || inputs.build_binaries if: github.event_name == 'release' || inputs.build_binaries

View File

@@ -1,4 +1,4 @@
ARG GO_VERSION=1.26.1 ARG GO_VERSION=1.26.0
FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder

View File

@@ -20,17 +20,16 @@
## Overview ## Overview
Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicity and features like templating add zero overhead when unused. Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicityfeatures like templating add zero overhead when unused.
| ✅ Supported | ❌ Not Supported | | ✅ Supported | ❌ Not Supported |
| ---------------------------------------------------------- | ------------------------------- | | ---------------------------------------------------------- | ------------------------------- |
| High-performance with low memory footprint | Detailed response body analysis | | High-performance with low memory footprint | Detailed response body analysis |
| Long-running duration/count based tests | Extensive response statistics | | Long-running duration/count based tests | Extensive response statistics |
| Dynamic requests via 340+ template functions | Web UI or complex TUI | | Dynamic requests via 320+ template functions | Web UI or complex TUI |
| Request scripting with Lua and JavaScript | Distributed load testing | | Request scripting with Lua and JavaScript | Distributed load testing |
| Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | HTTP/2, HTTP/3, WebSocket, gRPC | | Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | HTTP/2, HTTP/3, WebSocket, gRPC |
| Captcha solving<br>(2Captcha, Anti-Captcha, CapSolver) | Plugins / extensions ecosystem | | Flexible config (CLI, ENV, YAML) | Plugins / extensions ecosystem |
| Flexible config (CLI, ENV, YAML) | |
## Installation ## Installation
@@ -106,7 +105,7 @@ For detailed documentation on all configuration options (URL, method, timeout, c
## Templating ## Templating
Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. Use the 340+ built-in functions to generate dynamic data for each request. Sarin supports Go templates in URL paths, methods, bodies, headers, params, cookies, and values. Use the 320+ built-in functions to generate dynamic data for each request.
**Example:** **Example:**

View File

@@ -3,7 +3,7 @@ version: "3"
vars: vars:
BIN_DIR: ./bin BIN_DIR: ./bin
GOLANGCI_LINT_VERSION: v2.11.4 GOLANGCI_LINT_VERSION: v2.9.0
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}" GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
tasks: tasks:
@@ -39,10 +39,10 @@ tasks:
cmds: cmds:
- "{{.GOLANGCI}} run" - "{{.GOLANGCI}} run"
test: e2e:
desc: Run Go tests. desc: Run e2e tests
cmds: cmds:
- go test ./... {{.CLI_ARGS}} - go test ./e2e/... -v -count=1 {{.CLI_ARGS}}
create-bin-dir: create-bin-dir:
desc: Create bin directory. desc: Create bin directory.

65
benchmark.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
RUNS=20
CMD="go run ./cmd/cli -U http://localhost:80 -r 1_000_000 -c 100"
declare -a times_default
declare -a times_gogcoff
echo "===== Benchmark: default GC ====="
for i in $(seq 1 $RUNS); do
echo "Run $i/$RUNS ..."
start=$(date +%s%N)
$CMD
end=$(date +%s%N)
elapsed=$(( (end - start) / 1000000 )) # milliseconds
times_default+=("$elapsed")
echo " -> ${elapsed} ms"
done
echo ""
echo "===== Benchmark: GOGC=off ====="
for i in $(seq 1 $RUNS); do
echo "Run $i/$RUNS ..."
start=$(date +%s%N)
GOGC=off $CMD
end=$(date +%s%N)
elapsed=$(( (end - start) / 1000000 ))
times_gogcoff+=("$elapsed")
echo " -> ${elapsed} ms"
done
echo ""
echo "============================================"
echo " RESULTS"
echo "============================================"
echo ""
echo "--- Default GC ---"
sum=0
for i in $(seq 0 $((RUNS - 1))); do
echo " Run $((i + 1)): ${times_default[$i]} ms"
sum=$((sum + times_default[$i]))
done
avg_default=$((sum / RUNS))
echo " Average: ${avg_default} ms"
echo ""
echo "--- GOGC=off ---"
sum=0
for i in $(seq 0 $((RUNS - 1))); do
echo " Run $((i + 1)): ${times_gogcoff[$i]} ms"
sum=$((sum + times_gogcoff[$i]))
done
avg_gogcoff=$((sum / RUNS))
echo " Average: ${avg_gogcoff} ms"
echo ""
echo "--- Comparison ---"
if [ "$avg_default" -gt 0 ]; then
diff=$((avg_default - avg_gogcoff))
echo " Difference: ${diff} ms (positive = GOGC=off is faster)"
fi
echo "============================================"

View File

@@ -1,6 +1,6 @@
# Configuration # Configuration
Sarin supports environment variables, CLI flags, and YAML files. However, they are not exactly equivalent: YAML files have the most configuration options, followed by CLI flags, and then environment variables. Sarin supports environment variables, CLI flags, and YAML files. However, they are not exactly equivalentYAML files have the most configuration options, followed by CLI flags, and then environment variables.
When the same option is specified in multiple sources, the following priority order applies: When the same option is specified in multiple sources, the following priority order applies:
@@ -107,9 +107,9 @@ If all four files define `url`, the value from `config3.yaml` wins.
**Merge behavior by field:** **Merge behavior by field:**
- **Scalar fields** (`url`, `requests`, `duration`, `timeout`, `concurrency`, etc.): higher priority overrides lower priority - **Scalar fields** (`url`, `requests`, `duration`, `timeout`, `concurrency`, etc.) higher priority overrides lower priority
- **Method and Body**: higher priority overrides lower priority (no merging) - **Method and Body** higher priority overrides lower priority (no merging)
- **Headers, Params, Cookies, Proxies, Values, Lua, and Js**: accumulated across all config files - **Headers, Params, Cookies, Proxies, Values, Lua, and Js** accumulated across all config files
## URL ## URL
@@ -408,7 +408,7 @@ SARIN_VALUES="key1=value1"
Lua script(s) for request transformation. Each script must define a global `transform` function that receives a request object and returns the modified request object. Scripts run after template rendering, before the request is sent. Lua script(s) for request transformation. Each script must define a global `transform` function that receives a request object and returns the modified request object. Scripts run after template rendering, before the request is sent.
If multiple Lua scripts are provided, they are chained in order-the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts. If multiple Lua scripts are provided, they are chained in orderthe output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts.
**Script sources:** **Script sources:**
@@ -473,7 +473,7 @@ SARIN_LUA='function transform(req) req.headers["X-Custom"] = "my-value" return r
JavaScript script(s) for request transformation. Each script must define a global `transform` function that receives a request object and returns the modified request object. Scripts run after template rendering, before the request is sent. JavaScript script(s) for request transformation. Each script must define a global `transform` function that receives a request object and returns the modified request object. Scripts run after template rendering, before the request is sent.
If multiple JavaScript scripts are provided, they are chained in order-the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts. If multiple JavaScript scripts are provided, they are chained in orderthe output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts.
**Script sources:** **Script sources:**

View File

@@ -8,7 +8,6 @@ This guide provides practical examples for common Sarin use cases.
- [Request-Based vs Duration-Based Tests](#request-based-vs-duration-based-tests) - [Request-Based vs Duration-Based Tests](#request-based-vs-duration-based-tests)
- [Headers, Cookies, and Parameters](#headers-cookies-and-parameters) - [Headers, Cookies, and Parameters](#headers-cookies-and-parameters)
- [Dynamic Requests with Templating](#dynamic-requests-with-templating) - [Dynamic Requests with Templating](#dynamic-requests-with-templating)
- [Solving Captchas](#solving-captchas)
- [Request Bodies](#request-bodies) - [Request Bodies](#request-bodies)
- [File Uploads](#file-uploads) - [File Uploads](#file-uploads)
- [Using Proxies](#using-proxies) - [Using Proxies](#using-proxies)
@@ -372,29 +371,7 @@ body: '{"ip": "{{ fakeit_IPv4Address }}", "timestamp": "{{ fakeit_Date }}", "act
</details> </details>
> For the complete list of 340+ template functions, see the **[Templating Guide](templating.md)**. > For the complete list of 320+ template functions, see the **[Templating Guide](templating.md)**.
## Solving Captchas
Sarin can solve captchas through third-party services and embed the resulting token into the request. Three services are supported via dedicated template functions: **2Captcha**, **Anti-Captcha**, and **CapSolver**.
**Solve a reCAPTCHA v2 and submit the token in the request body:**
```sh
sarin -U https://example.com/login -M POST -r 1 \
-B '{"g-recaptcha-response": "{{ twocaptcha_RecaptchaV2 "YOUR_API_KEY" "SITE_KEY" "https://example.com/login" }}"}'
```
**Reuse a single solved token across multiple requests via `values`:**
```sh
sarin -U https://example.com/api -M POST -r 5 \
-V 'TOKEN={{ anticaptcha_Turnstile "YOUR_API_KEY" "SITE_KEY" "https://example.com/api" }}' \
-H "X-Turnstile-Token: {{ .Values.TOKEN }}" \
-B '{"token": "{{ .Values.TOKEN }}"}'
```
> See the **[Templating Guide](templating.md#captcha-functions)** for the full list of captcha functions and per-service support.
## Request Bodies ## Request Bodies

View File

@@ -4,23 +4,14 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook
> **Note:** Templating in URL host and scheme is not supported. Only the path portion of the URL can contain templates. > **Note:** Templating in URL host and scheme is not supported. Only the path portion of the URL can contain templates.
> **Note:** Template rendering happens before the request is sent. The request timeout (`-T` / `timeout`) only governs the HTTP request itself and starts _after_ templates have finished rendering, so slow template functions (e.g. captcha solvers, remote `file_Read`) cannot cause a request timeout no matter how long they take.
## Table of Contents ## Table of Contents
- [Using Values](#using-values) - [Using Values](#using-values)
- [General Functions](#general-functions) - [General Functions](#general-functions)
- [String Functions](#string-functions) - [String Functions](#string-functions)
- [Collection Functions](#collection-functions) - [Collection Functions](#collection-functions)
- [JSON Functions](#json-functions)
- [Time Functions](#time-functions)
- [Crypto Functions](#crypto-functions)
- [Body Functions](#body-functions) - [Body Functions](#body-functions)
- [File Functions](#file-functions) - [File Functions](#file-functions)
- [Captcha Functions](#captcha-functions)
- [2Captcha](#2captcha)
- [Anti-Captcha](#anti-captcha)
- [CapSolver](#capsolver)
- [Fake Data Functions](#fake-data-functions) - [Fake Data Functions](#fake-data-functions)
- [File](#file) - [File](#file)
- [ID](#id) - [ID](#id)
@@ -118,51 +109,6 @@ sarin -U http://example.com/users \
| `slice_Int(values ...int)` | Create int slice | `{{ slice_Int 1 2 3 }}` | | `slice_Int(values ...int)` | Create int slice | `{{ slice_Int 1 2 3 }}` |
| `slice_Uint(values ...uint)` | Create uint slice | `{{ slice_Uint 1 2 3 }}` | | `slice_Uint(values ...uint)` | Create uint slice | `{{ slice_Uint 1 2 3 }}` |
### JSON Functions
Build JSON payloads programmatically without manual quoting or escaping. `json_Object` is the ergonomic shortcut for flat objects; `json_Encode` marshals any value (slice, map, etc.) to a JSON string.
| Function | Description | Example |
| --------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- |
| `json_Object(pairs ...any)` | Build an object from interleaved key-value pairs and return it as a JSON string. Keys must be strings. | `{{ json_Object "name" "Alice" "age" 30 }}` |
| `json_Encode(v any)` | Marshal any value (slice, map, etc.) to a JSON string. | `{{ json_Encode (slice_Str "a" "b") }}``["a","b"]` |
**Examples:**
```yaml
# Flat object with fake data
body: '{{ json_Object "name" (fakeit_FirstName) "email" (fakeit_Email) }}'
# Embed a solved captcha token
body: '{{ json_Object "g-recaptcha-response" (twocaptcha_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com") }}'
# Encode a slice as a JSON array
body: '{{ json_Encode (slice_Str "a" "b" "c") }}'
# Encode a string dictionary (map[string]string)
body: '{{ json_Encode (dict_Str "key1" "value1" "key2" "value2") }}'
```
> **Note:** Object keys are serialized in alphabetical order (Go's `encoding/json` default), not insertion order. For API payloads this is almost always fine because JSON key order is semantically irrelevant.
### Time Functions
| Function | Description | Example |
| ------------------------ | ------------------------------------------- | ------------------------------------------------------------------------------- |
| `time_NowUnix` | Current Unix timestamp (seconds) | `{{ time_NowUnix }}``1735689600` |
| `time_NowUnixMilli` | Current Unix timestamp (milliseconds) | `{{ time_NowUnixMilli }}``1735689600123` |
| `time_NowRFC3339` | Current time in RFC3339 format | `{{ time_NowRFC3339 }}``"2026-02-26T21:00:00Z"` |
| `time_Format(layout, t)` | Format a `time.Time` value with a Go layout | `{{ time_Format "2006-01-02" (strings_ToDate "2024-05-10") }}``"2024-05-10"` |
### Crypto Functions
| Function | Description | Example |
| ------------------------------------ | ------------------------------------------ | -------------------------------------------- |
| `crypto_SHA256(s string)` | SHA-256 hash (hex-encoded) | `{{ crypto_SHA256 "hello" }}` |
| `crypto_MD5(s string)` | MD5 hash (hex-encoded) | `{{ crypto_MD5 "hello" }}` |
| `crypto_HMACSHA256(key, msg string)` | HMAC-SHA256 signature (hex-encoded) | `{{ crypto_HMACSHA256 "secret" "payload" }}` |
| `crypto_Base64URL(s string)` | Base64 URL-safe encoding (without padding) | `{{ crypto_Base64URL "hello world" }}` |
### Body Functions ### Body Functions
| Function | Description | Example | | Function | Description | Example |
@@ -207,18 +153,11 @@ body: '{{ body_FormData "twitter" "@@username" }}'
| Function | Description | Example | | Function | Description | Example |
| ---------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------- | | ---------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------- |
| `file_Read(source string)` | Read a file (local path or URL) and return raw content as string. Files are cached after first read. | `{{ file_Read "/path/to/file.txt" }}` |
| `file_Base64(source string)` | Read a file (local path or URL) and return its Base64 encoded content. Files are cached after first read. | `{{ file_Base64 "/path/to/file.pdf" }}` | | `file_Base64(source string)` | Read a file (local path or URL) and return its Base64 encoded content. Files are cached after first read. | `{{ file_Base64 "/path/to/file.pdf" }}` |
**`file_Read` and `file_Base64` Details:** **`file_Base64` Details:**
```yaml ```yaml
# Local file as plain text
body: '{{ file_Read "/path/to/template.json" }}'
# Remote text file
body: '{{ file_Read "https://example.com/payload.txt" }}'
# Local file as Base64 in JSON body # Local file as Base64 in JSON body
body: '{"file": "{{ file_Base64 "/path/to/document.pdf" }}", "filename": "document.pdf"}' body: '{"file": "{{ file_Base64 "/path/to/document.pdf" }}", "filename": "document.pdf"}'
@@ -230,95 +169,6 @@ values: "FILE_DATA={{ file_Base64 \"/path/to/file.bin\" }}"
body: '{"data": "{{ .Values.FILE_DATA }}"}' body: '{"data": "{{ .Values.FILE_DATA }}"}'
``` ```
## Captcha Functions
Captcha functions solve a captcha challenge through a third-party solving service and return the resulting token, which can then be embedded directly into a request. They are intended for load testing endpoints protected by reCAPTCHA, hCaptcha, or Cloudflare Turnstile.
The functions are organized by service: `twocaptcha_*`, `anticaptcha_*`, and `capsolver_*`. Each accepts the API key as the first argument so no global configuration is required — bring your own key and use any of the supported services per template.
> **Important: performance and cost:**
>
> - **Each call is slow.** Solving typically takes ~560 seconds because the function blocks the template render until the third-party service returns a token. Internally the solver polls every 1s and gives up after 120s.
> - **Each call costs money.** Every successful solve is billed by the captcha service (typically $0.001$0.003 per solve). For high-volume tests, your captcha bill grows linearly with request count.
**Common parameters across all captcha functions:**
- `apiKey` - Your API key for the chosen captcha solving service
- `siteKey` - The captcha sitekey extracted from the target page (e.g. the `data-sitekey` attribute on a reCAPTCHA, hCaptcha, or Turnstile element)
- `pageURL` - The URL of the page where the captcha is hosted
### 2Captcha
Functions for the [2Captcha](https://2captcha.com) service. Note: 2Captcha **does not currently support hCaptcha** through their API.
| Function | Description |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
| `twocaptcha_RecaptchaV2(apiKey, siteKey, pageURL string)` | Solve a Google reCAPTCHA v2 challenge |
| `twocaptcha_RecaptchaV3(apiKey, siteKey, pageURL, pageAction string)` | Solve a Google reCAPTCHA v3 challenge. Pass `""` for `pageAction` to omit |
| `twocaptcha_Turnstile(apiKey, siteKey, pageURL string, cData ...string)` | Solve a Cloudflare Turnstile challenge. Optional `cData` argument |
### Anti-Captcha
Functions for the [Anti-Captcha](https://anti-captcha.com) service. This is currently the only service that supports all four captcha types end-to-end.
| Function | Description |
| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `anticaptcha_RecaptchaV2(apiKey, siteKey, pageURL string)` | Solve a Google reCAPTCHA v2 challenge |
| `anticaptcha_RecaptchaV3(apiKey, siteKey, pageURL, pageAction string)` | Solve a Google reCAPTCHA v3 challenge. `minScore` is hardcoded to `0.3` (Anti-Captcha rejects the request without it) |
| `anticaptcha_HCaptcha(apiKey, siteKey, pageURL string)` | Solve an hCaptcha challenge |
| `anticaptcha_Turnstile(apiKey, siteKey, pageURL string, cData ...string)` | Solve a Cloudflare Turnstile challenge. Optional `cData` argument |
### CapSolver
Functions for the [CapSolver](https://capsolver.com) service. Note: CapSolver no longer supports hCaptcha.
| Function | Description |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `capsolver_RecaptchaV2(apiKey, siteKey, pageURL string)` | Solve a Google reCAPTCHA v2 challenge |
| `capsolver_RecaptchaV3(apiKey, siteKey, pageURL, pageAction string)` | Solve a Google reCAPTCHA v3 challenge. Pass `""` for `pageAction` to omit |
| `capsolver_Turnstile(apiKey, siteKey, pageURL string, cData ...string)` | Solve a Cloudflare Turnstile challenge. Optional `cData` argument |
**Examples:**
```yaml
# reCAPTCHA v2 in a JSON body via 2Captcha
method: POST
url: https://example.com/login
body: |
{
"username": "test",
"g-recaptcha-response": "{{ twocaptcha_RecaptchaV2 "YOUR_API_KEY" "6LfD3PIb..." "https://example.com/login" }}"
}
```
```yaml
# Turnstile via Anti-Captcha with cData
method: POST
url: https://example.com/submit
body: |
{
"cf-turnstile-response": "{{ anticaptcha_Turnstile "YOUR_API_KEY" "0x4AAAAAAA..." "https://example.com/submit" "session-cdata" }}"
}
```
```yaml
# hCaptcha via Anti-Captcha (the only service that still supports it)
method: POST
url: https://example.com/protected
body: |
{
"h-captcha-response": "{{ anticaptcha_HCaptcha "YOUR_API_KEY" "338af34c-..." "https://example.com/protected" }}"
}
```
```yaml
# Share a single solved token across body and headers via values
values: 'TOKEN={{ capsolver_Turnstile "YOUR_API_KEY" "0x4AAAAAAA..." "https://example.com" }}'
headers:
X-Turnstile-Token: "{{ .Values.TOKEN }}"
body: '{"token": "{{ .Values.TOKEN }}"}'
```
## Fake Data Functions ## Fake Data Functions
These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit) library. These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit) library.

220
e2e/basic_test.go Normal file
View File

@@ -0,0 +1,220 @@
package e2e
import (
"strconv"
"testing"
)
func TestNoArgs(t *testing.T) {
t.Parallel()
res := run()
assertExitCode(t, res, 1)
// With no args and no env vars, validation should fail on required fields
assertContains(t, res.Stderr, "VALIDATION")
}
func TestHelp(t *testing.T) {
t.Parallel()
for _, flag := range []string{"-h", "-help"} {
t.Run(flag, func(t *testing.T) {
t.Parallel()
res := run(flag)
assertContains(t, res.Stdout, "Usage:")
assertContains(t, res.Stdout, "-url")
})
}
}
func TestVersion(t *testing.T) {
t.Parallel()
for _, flag := range []string{"-v", "-version"} {
t.Run(flag, func(t *testing.T) {
t.Parallel()
res := run(flag)
assertExitCode(t, res, 0)
assertContains(t, res.Stdout, "Version:")
assertContains(t, res.Stdout, "Git Commit:")
})
}
}
func TestUnexpectedArgs(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "unexpected")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "Unexpected CLI arguments")
}
func TestSimpleRequest(t *testing.T) {
t.Parallel()
srv := echoServer()
defer srv.Close()
res := run("-U", srv.URL, "-r", "3", "-q", "-o", "json")
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertHasResponseKey(t, out, "200")
assertResponseCount(t, out, 3)
}
func TestDryRun(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "5", "-q", "-o", "json", "-z")
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertHasResponseKey(t, out, "dry-run")
assertResponseCount(t, out, 5)
}
func TestDryRunDoesNotSendRequests(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "5", "-q", "-o", "json", "-z")
assertExitCode(t, res, 0)
if cs.requestCount() != 0 {
t.Errorf("dry-run should not send any requests, but server received %d", cs.requestCount())
}
}
func TestQuietMode(t *testing.T) {
t.Parallel()
srv := echoServer()
defer srv.Close()
res := run("-U", srv.URL, "-r", "1", "-q", "-o", "json")
assertExitCode(t, res, 0)
if res.Stderr != "" {
t.Errorf("expected empty stderr in quiet mode, got: %s", res.Stderr)
}
}
func TestOutputNone(t *testing.T) {
t.Parallel()
srv := echoServer()
defer srv.Close()
res := run("-U", srv.URL, "-r", "1", "-q", "-o", "none")
assertExitCode(t, res, 0)
if res.Stdout != "" {
t.Errorf("expected empty stdout with -o none, got: %s", res.Stdout)
}
}
func TestOutputJSON(t *testing.T) {
t.Parallel()
srv := echoServer()
defer srv.Close()
res := run("-U", srv.URL, "-r", "1", "-q", "-o", "json")
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
if out.Responses == nil {
t.Fatal("responses field is nil")
}
if out.Total.Min == "" || out.Total.Max == "" || out.Total.Average == "" {
t.Errorf("total stats are incomplete: %+v", out.Total)
}
if out.Total.P90 == "" || out.Total.P95 == "" || out.Total.P99 == "" {
t.Errorf("total percentiles are incomplete: %+v", out.Total)
}
}
func TestOutputYAML(t *testing.T) {
t.Parallel()
srv := echoServer()
defer srv.Close()
res := run("-U", srv.URL, "-r", "1", "-q", "-o", "yaml")
assertExitCode(t, res, 0)
assertContains(t, res.Stdout, "responses:")
assertContains(t, res.Stdout, "total:")
assertContains(t, res.Stdout, "count:")
}
func TestOutputTable(t *testing.T) {
t.Parallel()
srv := echoServer()
defer srv.Close()
res := run("-U", srv.URL, "-r", "1", "-q", "-o", "table")
assertExitCode(t, res, 0)
assertContains(t, res.Stdout, "Response")
assertContains(t, res.Stdout, "Count")
assertContains(t, res.Stdout, "Min")
assertContains(t, res.Stdout, "P99")
}
func TestInvalidOutputFormat(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-o", "invalid")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "Output")
}
func TestStatusCodes(t *testing.T) {
t.Parallel()
codes := []int{200, 201, 204, 301, 400, 404, 500, 502}
for _, code := range codes {
t.Run(strconv.Itoa(code), func(t *testing.T) {
t.Parallel()
srv := statusServer(code)
defer srv.Close()
res := run("-U", srv.URL, "-r", "1", "-q", "-o", "json")
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertHasResponseKey(t, out, strconv.Itoa(code))
})
}
}
func TestConcurrency(t *testing.T) {
t.Parallel()
srv := echoServer()
defer srv.Close()
res := run("-U", srv.URL, "-r", "10", "-c", "5", "-q", "-o", "json")
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertResponseCount(t, out, 10)
}
func TestDuration(t *testing.T) {
t.Parallel()
srv := echoServer()
defer srv.Close()
res := run("-U", srv.URL, "-d", "1s", "-q", "-o", "json")
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
count, _ := out.Total.Count.Int64()
if count < 1 {
t.Errorf("expected at least 1 request during 1s duration, got %d", count)
}
}
func TestRequestsAndDuration(t *testing.T) {
t.Parallel()
srv := echoServer()
defer srv.Close()
// Both -r and -d set: should stop at whichever comes first
res := run("-U", srv.URL, "-r", "3", "-d", "10s", "-q", "-o", "json")
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertResponseCount(t, out, 3)
}

401
e2e/config_file_test.go Normal file
View File

@@ -0,0 +1,401 @@
package e2e
import (
"net/http"
"testing"
)
func TestConfigFileBasic(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config := `
url: "` + cs.URL + `"
requests: 1
quiet: true
output: json
`
configPath := writeTemp(t, "config.yaml", config)
res := run("-f", configPath)
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertHasResponseKey(t, out, "200")
assertResponseCount(t, out, 1)
}
func TestConfigFileWithMethod(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config := `
url: "` + cs.URL + `"
method: POST
requests: 1
quiet: true
output: json
`
configPath := writeTemp(t, "config.yaml", config)
res := run("-f", configPath)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Method != http.MethodPost {
t.Errorf("expected method POST from config, got %s", req.Method)
}
}
func TestConfigFileWithHeaders(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config := `
url: "` + cs.URL + `"
requests: 1
quiet: true
output: json
headers:
- X-Config: config-value
`
configPath := writeTemp(t, "config.yaml", config)
res := run("-f", configPath)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-Config"]; len(v) == 0 || v[0] != "config-value" {
t.Errorf("expected X-Config: config-value, got %v", v)
}
}
func TestConfigFileWithParams(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config := `
url: "` + cs.URL + `"
requests: 1
quiet: true
output: json
params:
- key1: value1
`
configPath := writeTemp(t, "config.yaml", config)
res := run("-f", configPath)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Query["key1"]; len(v) == 0 || v[0] != "value1" {
t.Errorf("expected key1=value1, got %v", v)
}
}
func TestConfigFileWithCookies(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config := `
url: "` + cs.URL + `"
requests: 1
quiet: true
output: json
cookies:
- session: abc123
`
configPath := writeTemp(t, "config.yaml", config)
res := run("-f", configPath)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v, ok := req.Cookies["session"]; !ok || v != "abc123" {
t.Errorf("expected cookie session=abc123, got %v", req.Cookies)
}
}
func TestConfigFileWithBody(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config := `
url: "` + cs.URL + `"
method: POST
requests: 1
quiet: true
output: json
body: "hello from config"
`
configPath := writeTemp(t, "config.yaml", config)
res := run("-f", configPath)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Body != "hello from config" {
t.Errorf("expected body 'hello from config', got %q", req.Body)
}
}
func TestConfigFileCLIOverridesScalars(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config := `
url: "http://should-be-overridden.invalid"
requests: 1
quiet: true
output: json
`
configPath := writeTemp(t, "config.yaml", config)
// CLI -U should override the config file URL (scalar override)
res := run("-f", configPath, "-U", cs.URL)
assertExitCode(t, res, 0)
assertResponseCount(t, res.jsonOutput(t), 1)
// Verify it actually hit our server
if cs.requestCount() != 1 {
t.Errorf("expected 1 request to capture server, got %d", cs.requestCount())
}
}
func TestConfigFileCLIOverridesMethods(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config := `
url: "` + cs.URL + `"
method: GET
requests: 4
quiet: true
output: json
`
configPath := writeTemp(t, "config.yaml", config)
// CLI -M POST overrides config file's method: GET
res := run("-f", configPath, "-M", "POST")
assertExitCode(t, res, 0)
for _, r := range cs.allRequests() {
if r.Method != http.MethodPost {
t.Errorf("expected all requests to be POST (CLI overrides config), got %s", r.Method)
}
}
}
func TestConfigFileInvalidYAML(t *testing.T) {
t.Parallel()
configPath := writeTemp(t, "bad.yaml", `{{{not valid yaml`)
res := run("-f", configPath)
assertExitCode(t, res, 1)
}
func TestConfigFileNotFound(t *testing.T) {
t.Parallel()
res := run("-f", "/nonexistent/path/config.yaml")
assertExitCode(t, res, 1)
}
func TestConfigFileWithDryRun(t *testing.T) {
t.Parallel()
config := `
url: "http://example.com"
requests: 3
quiet: true
output: json
dryRun: true
`
configPath := writeTemp(t, "config.yaml", config)
res := run("-f", configPath)
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertHasResponseKey(t, out, "dry-run")
assertResponseCount(t, out, 3)
}
func TestConfigFileWithConcurrency(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config := `
url: "` + cs.URL + `"
requests: 6
concurrency: 3
quiet: true
output: json
`
configPath := writeTemp(t, "config.yaml", config)
res := run("-f", configPath)
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertResponseCount(t, out, 6)
}
func TestConfigFileNestedIncludes(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// Create inner config
innerConfig := `
headers:
- X-Inner: from-inner
`
innerPath := writeTemp(t, "inner.yaml", innerConfig)
// Create outer config that includes inner
outerConfig := `
configFile: "` + innerPath + `"
url: "` + cs.URL + `"
requests: 1
quiet: true
output: json
`
outerPath := writeTemp(t, "outer.yaml", outerConfig)
res := run("-f", outerPath)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-Inner"]; len(v) == 0 || v[0] != "from-inner" {
t.Errorf("expected X-Inner: from-inner from nested config, got %v", v)
}
}
func TestConfigFileFromHTTPURL(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config := `
url: "` + cs.URL + `"
requests: 1
quiet: true
output: json
headers:
- X-Remote-Config: yes
`
// Serve config via HTTP
configServer := statusServerWithBody(config)
defer configServer.Close()
res := run("-f", configServer.URL)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-Remote-Config"]; len(v) == 0 || v[0] != "yes" {
t.Errorf("expected X-Remote-Config: yes from HTTP config, got %v", v)
}
}
func TestConfigFileMultiValueHeaders(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config := `
url: "` + cs.URL + `"
requests: 1
quiet: true
output: json
headers:
- X-Multi:
- val1
- val2
`
configPath := writeTemp(t, "config.yaml", config)
// With multiple values, sarin cycles through them (random start).
// With -r 1, we should see exactly one of them.
res := run("-f", configPath)
assertExitCode(t, res, 0)
req := cs.lastRequest()
v, ok := req.Headers["X-Multi"]
if !ok || len(v) == 0 {
t.Fatalf("expected X-Multi header, got headers: %v", req.Headers)
}
if v[0] != "val1" && v[0] != "val2" {
t.Errorf("expected X-Multi to be val1 or val2, got %v", v)
}
}
func TestConfigFileWithTimeout(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config := `
url: "` + cs.URL + `"
requests: 1
timeout: 5s
quiet: true
output: json
`
configPath := writeTemp(t, "config.yaml", config)
res := run("-f", configPath)
assertExitCode(t, res, 0)
assertResponseCount(t, res.jsonOutput(t), 1)
}
func TestConfigFileWithInsecure(t *testing.T) {
t.Parallel()
config := `
url: "http://example.com"
requests: 1
insecure: true
quiet: true
output: json
dryRun: true
`
configPath := writeTemp(t, "config.yaml", config)
res := run("-f", configPath)
assertExitCode(t, res, 0)
}
func TestConfigFileWithLuaScript(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
scriptContent := `function transform(req) req.headers["X-Config-Lua"] = {"yes"} return req end`
scriptPath := writeTemp(t, "script.lua", scriptContent)
config := `
url: "` + cs.URL + `"
requests: 1
quiet: true
output: json
lua: "@` + scriptPath + `"
`
configPath := writeTemp(t, "config.yaml", config)
res := run("-f", configPath)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-Config-Lua"]; len(v) == 0 || v[0] != "yes" {
t.Errorf("expected X-Config-Lua: yes, got %v", v)
}
}

282
e2e/config_merge_test.go Normal file
View File

@@ -0,0 +1,282 @@
package e2e
import (
"net/http"
"testing"
)
// --- Multiple config files ---
func TestMultipleConfigFiles(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config1 := `
url: "` + cs.URL + `"
requests: 1
quiet: true
output: json
headers:
- X-From-File1: yes
`
config2 := `
headers:
- X-From-File2: yes
`
path1 := writeTemp(t, "merge1.yaml", config1)
path2 := writeTemp(t, "merge2.yaml", config2)
res := run("-f", path1, "-f", path2)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-From-File1"]; len(v) == 0 || v[0] != "yes" {
t.Errorf("expected X-From-File1: yes, got %v", v)
}
if v := req.Headers["X-From-File2"]; len(v) == 0 || v[0] != "yes" {
t.Errorf("expected X-From-File2: yes, got %v", v)
}
}
func TestMultipleConfigFilesScalarOverride(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// Second config file overrides URL from first
config1 := `
url: "http://should-be-overridden.invalid"
requests: 1
quiet: true
output: json
`
config2 := `
url: "` + cs.URL + `"
`
path1 := writeTemp(t, "merge_scalar1.yaml", config1)
path2 := writeTemp(t, "merge_scalar2.yaml", config2)
res := run("-f", path1, "-f", path2)
assertExitCode(t, res, 0)
if cs.requestCount() != 1 {
t.Errorf("expected request to go to second config's URL, got %d requests", cs.requestCount())
}
}
// --- Three-way merge: env + config file + CLI ---
func TestThreeWayMergePriority(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config := `
method: PUT
headers:
- X-From-Config: config-value
`
configPath := writeTemp(t, "three_way.yaml", config)
// ENV sets URL and header, config file sets method and header, CLI overrides URL
res := runWithEnv(map[string]string{
"SARIN_HEADER": "X-From-Env: env-value",
}, "-U", cs.URL, "-r", "1", "-q", "-o", "json", "-f", configPath)
assertExitCode(t, res, 0)
req := cs.lastRequest()
// Method should be PUT from config (not default GET)
if req.Method != http.MethodPut {
t.Errorf("expected method PUT from config, got %s", req.Method)
}
// Header from config file should be present
if v := req.Headers["X-From-Config"]; len(v) == 0 || v[0] != "config-value" {
t.Errorf("expected X-From-Config from config file, got %v", v)
}
// Header from env should be present
if v := req.Headers["X-From-Env"]; len(v) == 0 || v[0] != "env-value" {
t.Errorf("expected X-From-Env from env, got %v", v)
}
}
// --- Config file nesting depth ---
func TestConfigFileNestedMaxDepth(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// Create a chain of 12 config files (exceeds max depth of 10)
// The innermost file has the actual URL config
// When depth is exceeded, inner files are silently ignored
files := make([]string, 12)
// Innermost file (index 11) - has the real config
files[11] = writeTemp(t, "depth11.yaml", `
url: "`+cs.URL+`"
requests: 1
quiet: true
output: json
headers:
- X-Depth: deep
`)
// Chain each file to include the next one
for i := 10; i >= 0; i-- {
content := `configFile: "` + files[i+1] + `"`
files[i] = writeTemp(t, "depth"+string(rune('0'+i))+".yaml", content)
}
// The outermost file: this will recurse but max depth will prevent
// reaching the innermost file with the URL
res := run("-f", files[0], "-q")
// This should fail because URL is never reached (too deep)
assertExitCode(t, res, 1)
}
// --- YAML format flexibility ---
func TestConfigFileParamsMapFormat(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config := `
url: "` + cs.URL + `"
requests: 1
quiet: true
output: json
params:
key1: value1
key2: value2
`
configPath := writeTemp(t, "params_map.yaml", config)
res := run("-f", configPath)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Query["key1"]; len(v) == 0 || v[0] != "value1" {
t.Errorf("expected key1=value1, got %v", v)
}
if v := req.Query["key2"]; len(v) == 0 || v[0] != "value2" {
t.Errorf("expected key2=value2, got %v", v)
}
}
func TestConfigFileHeadersMapFormat(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config := `
url: "` + cs.URL + `"
requests: 1
quiet: true
output: json
headers:
X-Map-A: map-val-a
X-Map-B: map-val-b
`
configPath := writeTemp(t, "headers_map.yaml", config)
res := run("-f", configPath)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-Map-A"]; len(v) == 0 || v[0] != "map-val-a" {
t.Errorf("expected X-Map-A: map-val-a, got %v", v)
}
if v := req.Headers["X-Map-B"]; len(v) == 0 || v[0] != "map-val-b" {
t.Errorf("expected X-Map-B: map-val-b, got %v", v)
}
}
func TestConfigFileCookiesMapFormat(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config := `
url: "` + cs.URL + `"
requests: 1
quiet: true
output: json
cookies:
sess: abc
token: xyz
`
configPath := writeTemp(t, "cookies_map.yaml", config)
res := run("-f", configPath)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v, ok := req.Cookies["sess"]; !ok || v != "abc" {
t.Errorf("expected cookie sess=abc, got %v", req.Cookies)
}
if v, ok := req.Cookies["token"]; !ok || v != "xyz" {
t.Errorf("expected cookie token=xyz, got %v", req.Cookies)
}
}
func TestConfigFileMultipleBodies(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config := `
url: "` + cs.URL + `"
requests: 10
concurrency: 1
method: POST
quiet: true
output: json
body:
- "body-one"
- "body-two"
`
configPath := writeTemp(t, "multi_body.yaml", config)
res := run("-f", configPath)
assertExitCode(t, res, 0)
bodies := map[string]bool{}
for _, req := range cs.allRequests() {
bodies[req.Body] = true
}
if !bodies["body-one"] || !bodies["body-two"] {
t.Errorf("expected both body-one and body-two to appear, got %v", bodies)
}
}
func TestConfigFileMultipleMethods(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config := `
url: "` + cs.URL + `"
requests: 10
concurrency: 1
quiet: true
output: json
method:
- GET
- POST
`
configPath := writeTemp(t, "multi_method.yaml", config)
res := run("-f", configPath)
assertExitCode(t, res, 0)
methods := map[string]bool{}
for _, req := range cs.allRequests() {
methods[req.Method] = true
}
if !methods["GET"] || !methods["POST"] {
t.Errorf("expected both GET and POST, got %v", methods)
}
}

View File

@@ -0,0 +1,37 @@
package e2e
import (
"testing"
)
func TestConfigFileNestedHTTPInclude(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// Inner config served via HTTP
innerConfig := `
headers:
- X-From-HTTP-Nested: yes
`
innerServer := statusServerWithBody(innerConfig)
defer innerServer.Close()
// Outer config references the inner config via HTTP URL
outerConfig := `
url: "` + cs.URL + `"
requests: 1
quiet: true
output: json
configFile: "` + innerServer.URL + `"
`
outerPath := writeTemp(t, "outer_http.yaml", outerConfig)
res := run("-f", outerPath)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-From-Http-Nested"]; len(v) == 0 || v[0] != "yes" {
t.Errorf("expected X-From-Http-Nested: yes from nested HTTP config, got %v", v)
}
}

117
e2e/coverage_gaps_test.go Normal file
View File

@@ -0,0 +1,117 @@
package e2e
import "testing"
func TestValidation_InvalidTemplateInMethod(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-M", "{{ invalid_func }}")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "Method[0]")
}
func TestValidation_InvalidTemplateInParamKey(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-P", "{{ invalid_func }}=value")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "Param[0].Key")
}
func TestValidation_InvalidTemplateInCookieValue(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-C", "session={{ invalid_func }}")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "Cookie[0].Value[0]")
}
func TestValidation_InvalidTemplateInURLPath(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com/{{ invalid_func }}", "-r", "1")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "URL.Path")
}
func TestValidation_InvalidTemplateInValues(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-V", "A={{ invalid_func }}")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "Values[0]")
}
func TestValidation_ScriptURLWithoutHost(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-lua", "@http://")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "host")
}
func TestEnvInvalidURL(t *testing.T) {
t.Parallel()
res := runWithEnv(map[string]string{
"SARIN_URL": "://bad-url",
"SARIN_REQUESTS": "1",
}, "-q")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "SARIN_URL")
}
func TestEnvInvalidProxy(t *testing.T) {
t.Parallel()
res := runWithEnv(map[string]string{
"SARIN_URL": "http://example.com",
"SARIN_REQUESTS": "1",
"SARIN_PROXY": "://bad-proxy",
}, "-q")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "SARIN_PROXY")
}
func TestConfigFileInvalidURLParse(t *testing.T) {
t.Parallel()
configPath := writeTemp(t, "invalid_url.yaml", `
url: "://bad-url"
requests: 1
`)
res := run("-f", configPath)
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "Field 'url'")
}
func TestConfigFileInvalidProxyParse(t *testing.T) {
t.Parallel()
configPath := writeTemp(t, "invalid_proxy.yaml", `
url: "http://example.com"
requests: 1
proxy: "://bad-proxy"
`)
res := run("-f", configPath)
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "proxy[0]")
}
func TestConfigFileInvalidHeadersType(t *testing.T) {
t.Parallel()
configPath := writeTemp(t, "invalid_headers_type.yaml", `
url: "http://example.com"
requests: 1
headers:
- X-Test: value
- 42
`)
res := run("-f", configPath)
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "Failed to parse config file")
}

316
e2e/e2e_test.go Normal file
View 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
}

87
e2e/env_errors_test.go Normal file
View File

@@ -0,0 +1,87 @@
package e2e
import (
"testing"
)
func TestEnvInvalidConcurrency(t *testing.T) {
t.Parallel()
res := runWithEnv(map[string]string{
"SARIN_URL": "http://example.com",
"SARIN_REQUESTS": "1",
"SARIN_CONCURRENCY": "not-a-number",
}, "-q")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "invalid value for unsigned integer")
}
func TestEnvInvalidRequests(t *testing.T) {
t.Parallel()
res := runWithEnv(map[string]string{
"SARIN_URL": "http://example.com",
"SARIN_REQUESTS": "abc",
}, "-q")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "invalid value for unsigned integer")
}
func TestEnvInvalidDuration(t *testing.T) {
t.Parallel()
res := runWithEnv(map[string]string{
"SARIN_URL": "http://example.com",
"SARIN_DURATION": "not-a-duration",
}, "-q")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "invalid value duration")
}
func TestEnvInvalidTimeout(t *testing.T) {
t.Parallel()
res := runWithEnv(map[string]string{
"SARIN_URL": "http://example.com",
"SARIN_REQUESTS": "1",
"SARIN_TIMEOUT": "xyz",
}, "-q")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "invalid value duration")
}
func TestEnvInvalidInsecure(t *testing.T) {
t.Parallel()
res := runWithEnv(map[string]string{
"SARIN_URL": "http://example.com",
"SARIN_REQUESTS": "1",
"SARIN_INSECURE": "maybe",
}, "-q")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "invalid value for boolean")
}
func TestEnvInvalidDryRun(t *testing.T) {
t.Parallel()
res := runWithEnv(map[string]string{
"SARIN_URL": "http://example.com",
"SARIN_REQUESTS": "1",
"SARIN_DRY_RUN": "yes",
}, "-q")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "invalid value for boolean")
}
func TestEnvInvalidShowConfig(t *testing.T) {
t.Parallel()
res := runWithEnv(map[string]string{
"SARIN_URL": "http://example.com",
"SARIN_REQUESTS": "1",
"SARIN_SHOW_CONFIG": "nope",
}, "-q")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "invalid value for boolean")
}

348
e2e/env_test.go Normal file
View File

@@ -0,0 +1,348 @@
package e2e
import (
"net/http"
"testing"
)
func TestEnvURL(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := runWithEnv(map[string]string{
"SARIN_URL": cs.URL,
"SARIN_REQUESTS": "1",
"SARIN_QUIET": "true",
"SARIN_OUTPUT": "json",
})
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertHasResponseKey(t, out, "200")
assertResponseCount(t, out, 1)
}
func TestEnvMethod(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := runWithEnv(map[string]string{
"SARIN_URL": cs.URL,
"SARIN_METHOD": "POST",
"SARIN_REQUESTS": "1",
"SARIN_QUIET": "true",
"SARIN_OUTPUT": "json",
})
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Method != http.MethodPost {
t.Errorf("expected method POST from env, got %s", req.Method)
}
}
func TestEnvConcurrency(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := runWithEnv(map[string]string{
"SARIN_URL": cs.URL,
"SARIN_REQUESTS": "6",
"SARIN_CONCURRENCY": "3",
"SARIN_QUIET": "true",
"SARIN_OUTPUT": "json",
})
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertResponseCount(t, out, 6)
}
func TestEnvDuration(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := runWithEnv(map[string]string{
"SARIN_URL": cs.URL,
"SARIN_DURATION": "1s",
"SARIN_QUIET": "true",
"SARIN_OUTPUT": "json",
})
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
count, _ := out.Total.Count.Int64()
if count < 1 {
t.Errorf("expected at least 1 request during 1s, got %d", count)
}
}
func TestEnvTimeout(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := runWithEnv(map[string]string{
"SARIN_URL": cs.URL,
"SARIN_REQUESTS": "1",
"SARIN_TIMEOUT": "5s",
"SARIN_QUIET": "true",
"SARIN_OUTPUT": "json",
})
assertExitCode(t, res, 0)
assertResponseCount(t, res.jsonOutput(t), 1)
}
func TestEnvHeader(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := runWithEnv(map[string]string{
"SARIN_URL": cs.URL,
"SARIN_REQUESTS": "1",
"SARIN_HEADER": "X-From-Env: env-value",
"SARIN_QUIET": "true",
"SARIN_OUTPUT": "json",
})
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-From-Env"]; len(v) == 0 || v[0] != "env-value" {
t.Errorf("expected X-From-Env: env-value, got %v", v)
}
}
func TestEnvParam(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := runWithEnv(map[string]string{
"SARIN_URL": cs.URL,
"SARIN_REQUESTS": "1",
"SARIN_PARAM": "env_key=env_val",
"SARIN_QUIET": "true",
"SARIN_OUTPUT": "json",
})
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Query["env_key"]; len(v) == 0 || v[0] != "env_val" {
t.Errorf("expected env_key=env_val, got %v", v)
}
}
func TestEnvCookie(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := runWithEnv(map[string]string{
"SARIN_URL": cs.URL,
"SARIN_REQUESTS": "1",
"SARIN_COOKIE": "env_session=env_abc",
"SARIN_QUIET": "true",
"SARIN_OUTPUT": "json",
})
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v, ok := req.Cookies["env_session"]; !ok || v != "env_abc" {
t.Errorf("expected cookie env_session=env_abc, got %v", req.Cookies)
}
}
func TestEnvBody(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := runWithEnv(map[string]string{
"SARIN_URL": cs.URL,
"SARIN_METHOD": "POST",
"SARIN_REQUESTS": "1",
"SARIN_BODY": "env-body-content",
"SARIN_QUIET": "true",
"SARIN_OUTPUT": "json",
})
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Body != "env-body-content" {
t.Errorf("expected body 'env-body-content', got %q", req.Body)
}
}
func TestEnvDryRun(t *testing.T) {
t.Parallel()
res := runWithEnv(map[string]string{
"SARIN_URL": "http://example.com",
"SARIN_REQUESTS": "3",
"SARIN_DRY_RUN": "true",
"SARIN_QUIET": "true",
"SARIN_OUTPUT": "json",
})
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertHasResponseKey(t, out, "dry-run")
assertResponseCount(t, out, 3)
}
func TestEnvInsecure(t *testing.T) {
t.Parallel()
res := runWithEnv(map[string]string{
"SARIN_URL": "http://example.com",
"SARIN_REQUESTS": "1",
"SARIN_INSECURE": "true",
"SARIN_DRY_RUN": "true",
"SARIN_QUIET": "true",
"SARIN_OUTPUT": "json",
})
assertExitCode(t, res, 0)
}
func TestEnvOutputNone(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := runWithEnv(map[string]string{
"SARIN_URL": cs.URL,
"SARIN_REQUESTS": "1",
"SARIN_QUIET": "true",
"SARIN_OUTPUT": "none",
})
assertExitCode(t, res, 0)
if res.Stdout != "" {
t.Errorf("expected empty stdout with output=none, got: %s", res.Stdout)
}
}
func TestEnvConfigFile(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config := `
url: "` + cs.URL + `"
requests: 1
quiet: true
output: json
headers:
- X-From-Env-Config: yes
`
configPath := writeTemp(t, "env_config.yaml", config)
res := runWithEnv(map[string]string{
"SARIN_CONFIG_FILE": configPath,
})
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-From-Env-Config"]; len(v) == 0 || v[0] != "yes" {
t.Errorf("expected X-From-Env-Config: yes, got %v", v)
}
}
func TestEnvCLIOverridesEnv(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// CLI should take priority over env vars
res := runWithEnv(map[string]string{
"SARIN_URL": "http://should-be-overridden.invalid",
"SARIN_REQUESTS": "1",
"SARIN_QUIET": "true",
"SARIN_OUTPUT": "json",
}, "-U", cs.URL)
assertExitCode(t, res, 0)
if cs.requestCount() != 1 {
t.Errorf("expected CLI URL to override env, but server got %d requests", cs.requestCount())
}
}
func TestEnvInvalidBool(t *testing.T) {
t.Parallel()
res := runWithEnv(map[string]string{
"SARIN_URL": "http://example.com",
"SARIN_REQUESTS": "1",
"SARIN_QUIET": "not-a-bool",
})
assertExitCode(t, res, 1)
}
func TestEnvLuaScript(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
script := `function transform(req) req.headers["X-Env-Lua"] = {"yes"} return req end`
res := runWithEnv(map[string]string{
"SARIN_URL": cs.URL,
"SARIN_REQUESTS": "1",
"SARIN_QUIET": "true",
"SARIN_OUTPUT": "json",
"SARIN_LUA": script,
})
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-Env-Lua"]; len(v) == 0 || v[0] != "yes" {
t.Errorf("expected X-Env-Lua: yes, got %v", v)
}
}
func TestEnvJsScript(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
script := `function transform(req) { req.headers["X-Env-Js"] = ["yes"]; return req; }`
res := runWithEnv(map[string]string{
"SARIN_URL": cs.URL,
"SARIN_REQUESTS": "1",
"SARIN_QUIET": "true",
"SARIN_OUTPUT": "json",
"SARIN_JS": script,
})
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-Env-Js"]; len(v) == 0 || v[0] != "yes" {
t.Errorf("expected X-Env-Js: yes, got %v", v)
}
}
func TestEnvValues(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := runWithEnv(map[string]string{
"SARIN_URL": cs.URL,
"SARIN_REQUESTS": "1",
"SARIN_QUIET": "true",
"SARIN_OUTPUT": "json",
"SARIN_VALUES": "MY_KEY=my_val",
}, "-H", "X-Val: {{ .Values.MY_KEY }}")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-Val"]; len(v) == 0 || v[0] != "my_val" {
t.Errorf("expected X-Val: my_val, got %v", v)
}
}

149
e2e/formdata_test.go Normal file
View File

@@ -0,0 +1,149 @@
package e2e
import (
"encoding/base64"
"testing"
)
func TestBodyFormDataSimple(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json",
"-B", `{{ body_FormData "name" "John" "age" "30" }}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
// Body should contain multipart form data
assertContains(t, req.Body, "name")
assertContains(t, req.Body, "John")
assertContains(t, req.Body, "age")
assertContains(t, req.Body, "30")
// Content-Type should be multipart/form-data
ct := req.Headers["Content-Type"]
if len(ct) == 0 {
t.Fatal("expected Content-Type header for form data")
}
assertContains(t, ct[0], "multipart/form-data")
}
func TestBodyFormDataWithFileUpload(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// Create a temp file to upload
filePath := writeTemp(t, "upload.txt", "file content here")
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json",
"-B", `{{ body_FormData "description" "test file" "document" "@`+filePath+`" }}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
assertContains(t, req.Body, "description")
assertContains(t, req.Body, "test file")
assertContains(t, req.Body, "file content here")
assertContains(t, req.Body, "upload.txt")
}
func TestBodyFormDataWithRemoteFile(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// Serve a file via HTTP
fileServer := statusServerWithBody("remote file content")
defer fileServer.Close()
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json",
"-B", `{{ body_FormData "file" "@`+fileServer.URL+`" }}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
assertContains(t, req.Body, "remote file content")
}
func TestBodyFormDataEscapedAt(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// @@ should send literal @ prefixed value
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json",
"-B", `{{ body_FormData "email" "@@user@example.com" }}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
assertContains(t, req.Body, "@user@example.com")
}
func TestBodyFormDataOddArgsError(t *testing.T) {
t.Parallel()
// Odd number of args should cause an error
res := run("-U", "http://example.com", "-r", "1", "-M", "POST", "-q", "-o", "json",
"-B", `{{ body_FormData "key_only" }}`)
// This should either fail at validation or produce an error in output
// The template is valid syntax but body_FormData returns an error at runtime
if res.ExitCode == 0 {
out := res.jsonOutput(t)
// If it didn't exit 1, the error should show up as a response key
if _, ok := out.Responses["200"]; ok {
t.Error("expected error for odd form data args, but got 200")
}
}
}
func TestFileBase64(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
content := "hello base64 world"
filePath := writeTemp(t, "base64test.txt", content)
expected := base64.StdEncoding.EncodeToString([]byte(content))
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json",
"-B", `{{ file_Base64 "`+filePath+`" }}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Body != expected {
t.Errorf("expected base64 %q, got %q", expected, req.Body)
}
}
func TestFileBase64RemoteFile(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
content := "remote base64 content"
fileServer := statusServerWithBody(content)
defer fileServer.Close()
expected := base64.StdEncoding.EncodeToString([]byte(content))
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json",
"-B", `{{ file_Base64 "`+fileServer.URL+`" }}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Body != expected {
t.Errorf("expected base64 %q, got %q", expected, req.Body)
}
}
func TestBodyFormDataMultipleRequests(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "3", "-c", "1", "-M", "POST", "-q", "-o", "json",
"-B", `{{ body_FormData "id" "{{ fakeit_UUID }}" }}`)
assertExitCode(t, res, 0)
assertResponseCount(t, res.jsonOutput(t), 3)
}

226
e2e/multi_value_test.go Normal file
View File

@@ -0,0 +1,226 @@
package e2e
import (
"net/http"
"testing"
)
// --- CLI: multiple same-key values are all sent in every request ---
func TestMultipleHeadersSameKeyCLI(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-H", "X-Multi: value1", "-H", "X-Multi: value2")
assertExitCode(t, res, 0)
req := cs.lastRequest()
vals := req.Headers["X-Multi"]
if len(vals) < 2 {
t.Fatalf("expected 2 values for X-Multi, got %v", vals)
}
found := map[string]bool{}
for _, v := range vals {
found[v] = true
}
if !found["value1"] || !found["value2"] {
t.Errorf("expected both value1 and value2, got %v", vals)
}
}
func TestMultipleParamsSameKeyCLI(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-P", "color=red", "-P", "color=blue")
assertExitCode(t, res, 0)
req := cs.lastRequest()
vals := req.Query["color"]
if len(vals) < 2 {
t.Fatalf("expected 2 values for color param, got %v", vals)
}
found := map[string]bool{}
for _, v := range vals {
found[v] = true
}
if !found["red"] || !found["blue"] {
t.Errorf("expected both red and blue, got %v", vals)
}
}
func TestMultipleCookiesSameKeyCLI(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-C", "token=abc", "-C", "token=def")
assertExitCode(t, res, 0)
req := cs.lastRequest()
cookieHeader := ""
if v := req.Headers["Cookie"]; len(v) > 0 {
cookieHeader = v[0]
}
assertContains(t, cookieHeader, "token=abc")
assertContains(t, cookieHeader, "token=def")
}
// --- Config file: multiple values for same key cycle across requests ---
func TestMultipleHeadersSameKeyYAMLCycle(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config := `
url: "` + cs.URL + `"
requests: 20
concurrency: 1
quiet: true
output: json
headers:
- X-Multi: [val-a, val-b]
`
configPath := writeTemp(t, "multi_header.yaml", config)
res := run("-f", configPath)
assertExitCode(t, res, 0)
seen := map[string]bool{}
for _, req := range cs.allRequests() {
if vals := req.Headers["X-Multi"]; len(vals) > 0 {
seen[vals[0]] = true
}
}
if !seen["val-a"] {
t.Error("expected val-a to appear in some requests")
}
if !seen["val-b"] {
t.Error("expected val-b to appear in some requests")
}
}
func TestMultipleParamsSameKeyYAMLCycle(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
config := `
url: "` + cs.URL + `"
requests: 20
concurrency: 1
quiet: true
output: json
params:
- tag: [go, rust]
`
configPath := writeTemp(t, "multi_param.yaml", config)
res := run("-f", configPath)
assertExitCode(t, res, 0)
seen := map[string]bool{}
for _, req := range cs.allRequests() {
if vals := req.Query["tag"]; len(vals) > 0 {
seen[vals[0]] = true
}
}
if !seen["go"] {
t.Error("expected 'go' to appear in some requests")
}
if !seen["rust"] {
t.Error("expected 'rust' to appear in some requests")
}
}
// --- Multiple bodies cycle ---
func TestMultipleBodiesCycle(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "10", "-c", "1", "-M", "POST", "-q", "-o", "json",
"-B", "body-alpha", "-B", "body-beta")
assertExitCode(t, res, 0)
bodies := map[string]bool{}
for _, req := range cs.allRequests() {
bodies[req.Body] = true
}
if !bodies["body-alpha"] {
t.Error("expected body-alpha to appear in requests")
}
if !bodies["body-beta"] {
t.Error("expected body-beta to appear in requests")
}
}
// --- Multiple methods cycling ---
func TestMultipleMethodsCycleDistribution(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "20", "-c", "1", "-q", "-o", "json",
"-M", "GET", "-M", "POST", "-M", "PUT")
assertExitCode(t, res, 0)
methods := map[string]int{}
for _, req := range cs.allRequests() {
methods[req.Method]++
}
if methods["GET"] == 0 {
t.Error("expected GET to appear")
}
if methods["POST"] == 0 {
t.Error("expected POST to appear")
}
if methods["PUT"] == 0 {
t.Error("expected PUT to appear")
}
}
// --- Template in method ---
func TestTemplateInMethod(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-M", `{{ strings_ToUpper "post" }}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Method != http.MethodPost {
t.Errorf("expected method POST from template, got %s", req.Method)
}
}
// --- Template in cookie value ---
func TestTemplateInCookie(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-C", `session={{ fakeit_UUID }}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Cookies["session"] == "" {
t.Error("expected session cookie with UUID value, got empty")
}
if len(req.Cookies["session"]) < 10 {
t.Errorf("expected UUID-like session cookie, got %q", req.Cookies["session"])
}
}

198
e2e/output_test.go Normal file
View File

@@ -0,0 +1,198 @@
package e2e
import (
"encoding/json"
"strings"
"testing"
"go.yaml.in/yaml/v4"
)
// --- JSON output structure verification ---
func TestJSONOutputHasStatFields(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "3", "-q", "-o", "json")
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
// Verify total has all stat fields
if out.Total.Count.String() != "3" {
t.Errorf("expected count 3, got %s", out.Total.Count.String())
}
if out.Total.Min == "" {
t.Error("expected min to be non-empty")
}
if out.Total.Max == "" {
t.Error("expected max to be non-empty")
}
if out.Total.Average == "" {
t.Error("expected average to be non-empty")
}
if out.Total.P90 == "" {
t.Error("expected p90 to be non-empty")
}
if out.Total.P95 == "" {
t.Error("expected p95 to be non-empty")
}
if out.Total.P99 == "" {
t.Error("expected p99 to be non-empty")
}
}
func TestJSONOutputResponseStatFields(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "5", "-q", "-o", "json")
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
stat, ok := out.Responses["200"]
if !ok {
t.Fatal("expected 200 in responses")
}
if stat.Count.String() != "5" {
t.Errorf("expected response count 5, got %s", stat.Count.String())
}
if stat.Min == "" || stat.Max == "" || stat.Average == "" {
t.Error("expected min/max/average to be non-empty")
}
}
func TestJSONOutputMultipleStatusCodes(t *testing.T) {
t.Parallel()
// Create servers with different status codes
srv200 := statusServer(200)
defer srv200.Close()
srv404 := statusServer(404)
defer srv404.Close()
// We can only target one URL, so use a single server
// Instead, test that dry-run produces the expected structure
res := run("-U", "http://example.com", "-r", "3", "-z", "-q", "-o", "json")
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
// dry-run should have "dry-run" key
stat := out.Responses["dry-run"]
if stat.Count.String() != "3" {
t.Errorf("expected dry-run count 3, got %s", stat.Count.String())
}
}
func TestJSONOutputIsValidJSON(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json")
assertExitCode(t, res, 0)
// Verify it's valid JSON
var raw map[string]any
if err := json.Unmarshal([]byte(res.Stdout), &raw); err != nil {
t.Fatalf("stdout is not valid JSON: %v", err)
}
// Verify top-level structure
if _, ok := raw["responses"]; !ok {
t.Error("expected 'responses' key in JSON output")
}
if _, ok := raw["total"]; !ok {
t.Error("expected 'total' key in JSON output")
}
}
// --- YAML output structure verification ---
func TestYAMLOutputIsValidYAML(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "yaml")
assertExitCode(t, res, 0)
var raw map[string]any
if err := yaml.Unmarshal([]byte(res.Stdout), &raw); err != nil {
t.Fatalf("stdout is not valid YAML: %v", err)
}
if _, ok := raw["responses"]; !ok {
t.Error("expected 'responses' key in YAML output")
}
if _, ok := raw["total"]; !ok {
t.Error("expected 'total' key in YAML output")
}
}
func TestYAMLOutputHasStatFields(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "yaml")
assertExitCode(t, res, 0)
assertContains(t, res.Stdout, "count:")
assertContains(t, res.Stdout, "min:")
assertContains(t, res.Stdout, "max:")
assertContains(t, res.Stdout, "average:")
assertContains(t, res.Stdout, "p90:")
assertContains(t, res.Stdout, "p95:")
assertContains(t, res.Stdout, "p99:")
}
// --- Table output content verification ---
func TestTableOutputContainsHeaders(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "table")
assertExitCode(t, res, 0)
// Table should contain column headers
assertContains(t, res.Stdout, "Response")
assertContains(t, res.Stdout, "Count")
assertContains(t, res.Stdout, "Min")
assertContains(t, res.Stdout, "Max")
}
func TestTableOutputContainsStatusCode(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "table")
assertExitCode(t, res, 0)
assertContains(t, res.Stdout, "200")
}
// --- Version output format ---
func TestVersionOutputFormat(t *testing.T) {
t.Parallel()
res := run("-v")
assertExitCode(t, res, 0)
lines := strings.Split(strings.TrimSpace(res.Stdout), "\n")
if len(lines) < 4 {
t.Fatalf("expected at least 4 lines in version output, got %d: %s", len(lines), res.Stdout)
}
assertContains(t, lines[0], "Version:")
assertContains(t, lines[1], "Git Commit:")
assertContains(t, lines[2], "Build Date:")
assertContains(t, lines[3], "Go Version:")
}

103
e2e/proxy_test.go Normal file
View File

@@ -0,0 +1,103 @@
package e2e
import (
"testing"
)
// Note: We can't easily test actual proxy connections in E2E tests without
// setting up real proxy servers. These tests verify the validation and
// error handling around proxy configuration.
func TestProxyValidSchemes(t *testing.T) {
t.Parallel()
// Valid proxy scheme should not cause a validation error
// (will fail at connection time since no proxy is running, but should pass validation)
for _, scheme := range []string{"http", "https", "socks5", "socks5h"} {
t.Run(scheme, func(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-z", "-q", "-o", "json",
"-X", scheme+"://127.0.0.1:9999")
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertHasResponseKey(t, out, "dry-run")
})
}
}
func TestProxyInvalidScheme(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-q", "-o", "json",
"-X", "ftp://proxy.example.com:8080")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "VALIDATION")
}
func TestMultipleProxiesDryRun(t *testing.T) {
t.Parallel()
// Multiple proxies with dry-run to verify they're accepted
res := run("-U", "http://example.com", "-r", "3", "-z", "-q", "-o", "json",
"-X", "http://127.0.0.1:8080",
"-X", "http://127.0.0.1:8081")
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertResponseCount(t, out, 3)
}
func TestProxyConnectionFailure(t *testing.T) {
t.Parallel()
// Use a proxy that doesn't exist — should get a connection error
res := run("-U", "http://example.com", "-r", "1", "-q", "-o", "json",
"-X", "http://127.0.0.1:1")
// The process should still exit (may exit 0 with error in output or exit 1)
if res.ExitCode == 0 {
out := res.jsonOutput(t)
// Should NOT get a 200 — should have a proxy error
if _, ok := out.Responses["200"]; ok {
t.Error("expected proxy connection error, but got 200")
}
}
}
func TestProxyFromConfigFile(t *testing.T) {
t.Parallel()
config := `
url: "http://example.com"
requests: 1
quiet: true
output: json
dryRun: true
proxy:
- http://127.0.0.1:8080
`
configPath := writeTemp(t, "proxy_config.yaml", config)
res := run("-f", configPath)
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertHasResponseKey(t, out, "dry-run")
}
func TestProxyFromEnv(t *testing.T) {
t.Parallel()
res := runWithEnv(map[string]string{
"SARIN_URL": "http://example.com",
"SARIN_REQUESTS": "1",
"SARIN_DRY_RUN": "true",
"SARIN_OUTPUT": "json",
"SARIN_PROXY": "http://127.0.0.1:8080",
}, "-q")
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertHasResponseKey(t, out, "dry-run")
}

331
e2e/request_test.go Normal file
View File

@@ -0,0 +1,331 @@
package e2e
import (
"net/http"
"slices"
"testing"
)
func TestMethodGET(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Method != http.MethodGet {
t.Errorf("expected default method GET, got %s", req.Method)
}
}
func TestMethodPOST(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Method != http.MethodPost {
t.Errorf("expected method POST, got %s", req.Method)
}
}
func TestMethodExplicit(t *testing.T) {
t.Parallel()
methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}
for _, method := range methods {
t.Run(method, func(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-M", method, "-q", "-o", "json")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Method != method {
t.Errorf("expected method %s, got %s", method, req.Method)
}
})
}
}
func TestMultipleMethods(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// With multiple methods, sarin cycles through them
res := run("-U", cs.URL, "-r", "4", "-M", "GET", "-M", "POST", "-q", "-o", "json")
assertExitCode(t, res, 0)
reqs := cs.allRequests()
if len(reqs) != 4 {
t.Fatalf("expected 4 requests, got %d", len(reqs))
}
// Should see both GET and POST used
methods := make(map[string]bool)
for _, r := range reqs {
methods[r.Method] = true
}
if !methods["GET"] || !methods["POST"] {
t.Errorf("expected both GET and POST to be used, got methods: %v", methods)
}
}
func TestSingleHeader(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-H", "X-Custom: hello", "-q", "-o", "json")
assertExitCode(t, res, 0)
req := cs.lastRequest()
vals, ok := req.Headers["X-Custom"]
if !ok {
t.Fatalf("expected X-Custom header, got headers: %v", req.Headers)
}
if len(vals) != 1 || vals[0] != "hello" {
t.Errorf("expected X-Custom: [hello], got %v", vals)
}
}
func TestMultipleHeaders(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1",
"-H", "X-First: one",
"-H", "X-Second: two",
"-q", "-o", "json")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-First"]; len(v) == 0 || v[0] != "one" {
t.Errorf("expected X-First: one, got %v", v)
}
if v := req.Headers["X-Second"]; len(v) == 0 || v[0] != "two" {
t.Errorf("expected X-Second: two, got %v", v)
}
}
func TestHeaderWithEmptyValue(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// Header without ": " separator should have empty value
res := run("-U", cs.URL, "-r", "1", "-H", "X-Empty", "-q", "-o", "json")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if _, ok := req.Headers["X-Empty"]; !ok {
t.Errorf("expected X-Empty header to be present, got headers: %v", req.Headers)
}
}
func TestDefaultUserAgentHeader(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json")
assertExitCode(t, res, 0)
req := cs.lastRequest()
ua, ok := req.Headers["User-Agent"]
if !ok || len(ua) == 0 {
t.Fatalf("expected User-Agent header, got headers: %v", req.Headers)
}
assertContains(t, ua[0], "Sarin/")
}
func TestCustomUserAgentOverridesDefault(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-H", "User-Agent: MyAgent/1.0", "-q", "-o", "json")
assertExitCode(t, res, 0)
req := cs.lastRequest()
ua := req.Headers["User-Agent"]
if len(ua) == 0 {
t.Fatal("expected User-Agent header")
}
// When user sets User-Agent, the default should not be added
if slices.Contains(ua, "MyAgent/1.0") {
return // found the custom one
}
t.Errorf("expected custom User-Agent 'MyAgent/1.0', got %v", ua)
}
func TestSingleParam(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-P", "key1=value1", "-q", "-o", "json")
assertExitCode(t, res, 0)
req := cs.lastRequest()
vals, ok := req.Query["key1"]
if !ok {
t.Fatalf("expected key1 param, got query: %v", req.Query)
}
if len(vals) != 1 || vals[0] != "value1" {
t.Errorf("expected key1=[value1], got %v", vals)
}
}
func TestMultipleParams(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1",
"-P", "a=1",
"-P", "b=2",
"-q", "-o", "json")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Query["a"]; len(v) == 0 || v[0] != "1" {
t.Errorf("expected a=1, got %v", v)
}
if v := req.Query["b"]; len(v) == 0 || v[0] != "2" {
t.Errorf("expected b=2, got %v", v)
}
}
func TestParamsFromURL(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// Params in the URL itself should be extracted and sent
res := run("-U", cs.URL+"?fromurl=yes", "-r", "1", "-q", "-o", "json")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Query["fromurl"]; len(v) == 0 || v[0] != "yes" {
t.Errorf("expected fromurl=yes from URL query, got %v", v)
}
}
func TestParamsFromURLAndFlag(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// Both URL params and -P params should be sent
res := run("-U", cs.URL+"?fromurl=yes", "-r", "1", "-P", "fromflag=also", "-q", "-o", "json")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Query["fromurl"]; len(v) == 0 || v[0] != "yes" {
t.Errorf("expected fromurl=yes, got %v", v)
}
if v := req.Query["fromflag"]; len(v) == 0 || v[0] != "also" {
t.Errorf("expected fromflag=also, got %v", v)
}
}
func TestSingleCookie(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-C", "session=abc123", "-q", "-o", "json")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v, ok := req.Cookies["session"]; !ok || v != "abc123" {
t.Errorf("expected cookie session=abc123, got cookies: %v", req.Cookies)
}
}
func TestMultipleCookies(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1",
"-C", "session=abc",
"-C", "token=xyz",
"-q", "-o", "json")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v, ok := req.Cookies["session"]; !ok || v != "abc" {
t.Errorf("expected cookie session=abc, got %v", req.Cookies)
}
if v, ok := req.Cookies["token"]; !ok || v != "xyz" {
t.Errorf("expected cookie token=xyz, got %v", req.Cookies)
}
}
func TestBody(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-B", "hello world", "-q", "-o", "json")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Body != "hello world" {
t.Errorf("expected body 'hello world', got %q", req.Body)
}
}
func TestBodyJSON(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
jsonBody := `{"name":"test","value":42}`
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-B", jsonBody, "-q", "-o", "json")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Body != jsonBody {
t.Errorf("expected body %q, got %q", jsonBody, req.Body)
}
}
func TestURLPath(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL+"/api/v1/users", "-r", "1", "-q", "-o", "json")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Path != "/api/v1/users" {
t.Errorf("expected path /api/v1/users, got %s", req.Path)
}
}
func TestParamWithEmptyValue(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// Param without = value
res := run("-U", cs.URL, "-r", "1", "-P", "empty", "-q", "-o", "json")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if _, ok := req.Query["empty"]; !ok {
t.Errorf("expected 'empty' param to be present, got query: %v", req.Query)
}
}

137
e2e/script_errors_test.go Normal file
View File

@@ -0,0 +1,137 @@
package e2e
import (
"testing"
)
func TestJsScriptModifiesPath(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
script := `function transform(req) { req.path = "/js-modified"; return req; }`
scriptPath := writeTemp(t, "modify_path.js", script)
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", "-js", "@"+scriptPath)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Path != "/js-modified" {
t.Errorf("expected path /js-modified from JS script, got %s", req.Path)
}
}
func TestJsScriptRuntimeError(t *testing.T) {
t.Parallel()
// This script throws an error at runtime
script := `function transform(req) { throw new Error("runtime boom"); }`
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", "-js", script)
assertExitCode(t, res, 0)
// The request should fail with a script error, not a 200
out := res.jsonOutput(t)
if _, ok := out.Responses["200"]; ok {
t.Error("expected script runtime error, but got 200")
}
}
func TestLuaScriptRuntimeError(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// Script that will error at runtime
script := `function transform(req) error("lua runtime boom") end`
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", "-lua", script)
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
if _, ok := out.Responses["200"]; ok {
t.Error("expected script runtime error, but got 200")
}
}
func TestJsScriptReturnsNull(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// transform returns null instead of object
script := `function transform(req) { return null; }`
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", "-js", script)
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
if _, ok := out.Responses["200"]; ok {
t.Error("expected error for null return, but got 200")
}
}
func TestJsScriptReturnsUndefined(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// transform returns nothing (undefined)
script := `function transform(req) { }`
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", "-js", script)
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
if _, ok := out.Responses["200"]; ok {
t.Error("expected error for undefined return, but got 200")
}
}
func TestScriptFromNonexistentFile(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-q", "-o", "json",
"-lua", "@/nonexistent/path/script.lua")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "VALIDATION")
assertContains(t, res.Stderr, "failed to load script")
}
func TestScriptFromNonexistentURL(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-q", "-o", "json",
"-js", "@http://127.0.0.1:1/nonexistent.js")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "VALIDATION")
assertContains(t, res.Stderr, "failed to load script")
}
func TestMultipleLuaAndJsScripts(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
lua1 := `function transform(req) req.headers["X-Lua-1"] = {"yes"} return req end`
lua2 := `function transform(req) req.headers["X-Lua-2"] = {"yes"} return req end`
js1 := `function transform(req) { req.headers["X-Js-1"] = ["yes"]; return req; }`
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-lua", lua1, "-lua", lua2, "-js", js1)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-Lua-1"]; len(v) == 0 || v[0] != "yes" {
t.Errorf("expected X-Lua-1: yes, got %v", v)
}
if v := req.Headers["X-Lua-2"]; len(v) == 0 || v[0] != "yes" {
t.Errorf("expected X-Lua-2: yes, got %v", v)
}
if v := req.Headers["X-Js-1"]; len(v) == 0 || v[0] != "yes" {
t.Errorf("expected X-Js-1: yes, got %v", v)
}
}

392
e2e/script_test.go Normal file
View File

@@ -0,0 +1,392 @@
package e2e
import (
"net/http"
"testing"
)
func TestLuaScriptInline(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
script := `function transform(req) req.headers["X-Lua"] = {"from-lua"} return req end`
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-lua", script)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v, ok := req.Headers["X-Lua"]; !ok || len(v) == 0 || v[0] != "from-lua" {
t.Errorf("expected X-Lua: from-lua, got headers: %v", req.Headers)
}
}
func TestJsScriptInline(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
script := `function transform(req) { req.headers["X-Js"] = ["from-js"]; return req; }`
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-js", script)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v, ok := req.Headers["X-Js"]; !ok || len(v) == 0 || v[0] != "from-js" {
t.Errorf("expected X-Js: from-js, got headers: %v", req.Headers)
}
}
func TestLuaScriptFromFile(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
scriptContent := `function transform(req)
req.headers["X-From-File"] = {"yes"}
return req
end`
scriptPath := writeTemp(t, "test.lua", scriptContent)
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-lua", "@"+scriptPath)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v, ok := req.Headers["X-From-File"]; !ok || len(v) == 0 || v[0] != "yes" {
t.Errorf("expected X-From-File: yes, got headers: %v", req.Headers)
}
}
func TestJsScriptFromFile(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
scriptContent := `function transform(req) {
req.headers["X-From-File"] = ["yes"];
return req;
}`
scriptPath := writeTemp(t, "test.js", scriptContent)
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-js", "@"+scriptPath)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v, ok := req.Headers["X-From-File"]; !ok || len(v) == 0 || v[0] != "yes" {
t.Errorf("expected X-From-File: yes, got headers: %v", req.Headers)
}
}
func TestLuaScriptModifiesMethod(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
script := `function transform(req) req.method = "PUT" return req end`
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-lua", script)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Method != http.MethodPut {
t.Errorf("expected method PUT after Lua transform, got %s", req.Method)
}
}
func TestJsScriptModifiesMethod(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
script := `function transform(req) { req.method = "DELETE"; return req; }`
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-js", script)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Method != http.MethodDelete {
t.Errorf("expected method DELETE after JS transform, got %s", req.Method)
}
}
func TestLuaScriptModifiesPath(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
script := `function transform(req) req.path = "/modified" return req end`
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-lua", script)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Path != "/modified" {
t.Errorf("expected path /modified, got %s", req.Path)
}
}
func TestLuaScriptModifiesBody(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
script := `function transform(req) req.body = "lua-body" return req end`
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json",
"-lua", script)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Body != "lua-body" {
t.Errorf("expected body 'lua-body', got %q", req.Body)
}
}
func TestJsScriptModifiesBody(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
script := `function transform(req) { req.body = "js-body"; return req; }`
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json",
"-js", script)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Body != "js-body" {
t.Errorf("expected body 'js-body', got %q", req.Body)
}
}
func TestLuaScriptModifiesParams(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
script := `function transform(req) req.params["lua_param"] = {"lua_value"} return req end`
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-lua", script)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v, ok := req.Query["lua_param"]; !ok || len(v) == 0 || v[0] != "lua_value" {
t.Errorf("expected lua_param=lua_value, got query: %v", req.Query)
}
}
func TestJsScriptModifiesParams(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
script := `function transform(req) { req.params["js_param"] = ["js_value"]; return req; }`
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-js", script)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v, ok := req.Query["js_param"]; !ok || len(v) == 0 || v[0] != "js_value" {
t.Errorf("expected js_param=js_value, got query: %v", req.Query)
}
}
func TestLuaScriptModifiesCookies(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
script := `function transform(req) req.cookies["lua_cookie"] = {"lua_val"} return req end`
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-lua", script)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v, ok := req.Cookies["lua_cookie"]; !ok || v != "lua_val" {
t.Errorf("expected cookie lua_cookie=lua_val, got cookies: %v", req.Cookies)
}
}
func TestJsScriptModifiesCookies(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
script := `function transform(req) { req.cookies["js_cookie"] = ["js_val"]; return req; }`
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-js", script)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v, ok := req.Cookies["js_cookie"]; !ok || v != "js_val" {
t.Errorf("expected cookie js_cookie=js_val, got cookies: %v", req.Cookies)
}
}
func TestScriptChainLuaThenJs(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
luaScript := `function transform(req) req.headers["X-Step"] = {"lua"} return req end`
jsScript := `function transform(req) { req.headers["X-Js-Step"] = ["js"]; return req; }`
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-lua", luaScript,
"-js", jsScript)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v, ok := req.Headers["X-Step"]; !ok || len(v) == 0 || v[0] != "lua" {
t.Errorf("expected X-Step: lua from Lua script, got %v", req.Headers["X-Step"])
}
if v, ok := req.Headers["X-Js-Step"]; !ok || len(v) == 0 || v[0] != "js" {
t.Errorf("expected X-Js-Step: js from JS script, got %v", req.Headers["X-Js-Step"])
}
}
func TestMultipleLuaScriptsChained(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
lua1 := `function transform(req) req.headers["X-First"] = {"1"} return req end`
lua2 := `function transform(req) req.headers["X-Second"] = {"2"} return req end`
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-lua", lua1,
"-lua", lua2)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-First"]; len(v) == 0 || v[0] != "1" {
t.Errorf("expected X-First: 1, got %v", v)
}
if v := req.Headers["X-Second"]; len(v) == 0 || v[0] != "2" {
t.Errorf("expected X-Second: 2, got %v", v)
}
}
func TestScriptWithEscapedAt(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// @@ means the first @ is stripped, rest is treated as inline script
script := `@@function transform(req) req.headers["X-At"] = {"escaped"} return req end`
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-lua", script)
// The @@ prefix strips one @, leaving "@function transform..." which is valid Lua?
// Actually no — after stripping the first @, it becomes:
// "@function transform(req) ..." which would be interpreted as a file reference.
// Wait — the code says: strings starting with "@@" → content = source[1:] = "@function..."
// Then it's returned as inline content "@function transform..."
// Lua would fail because "@" is not valid Lua syntax.
// So this test just validates that the @@ mechanism doesn't crash.
// It should fail at the validation step since "@function..." is not valid Lua.
assertExitCode(t, res, 1)
}
func TestLuaScriptMultipleHeaderValues(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
script := `function transform(req) req.headers["X-Multi"] = {"val1", "val2"} return req end`
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-lua", script)
assertExitCode(t, res, 0)
req := cs.lastRequest()
vals, ok := req.Headers["X-Multi"]
if !ok {
t.Fatalf("expected X-Multi header, got headers: %v", req.Headers)
}
if len(vals) != 2 || vals[0] != "val1" || vals[1] != "val2" {
t.Errorf("expected X-Multi: [val1, val2], got %v", vals)
}
}
func TestJsScriptCanReadExistingHeaders(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// Set a header via CLI, then read it in JS and set a new one based on it
script := `function transform(req) {
var original = req.headers["X-Original"];
if (original && original.length > 0) {
req.headers["X-Copy"] = [original[0]];
}
return req;
}`
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-H", "X-Original: hello",
"-js", script)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-Copy"]; len(v) == 0 || v[0] != "hello" {
t.Errorf("expected X-Copy: hello (copied from X-Original), got %v", v)
}
}
func TestLuaScriptCanReadExistingParams(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// Set a param via CLI, then read it in Lua
script := `function transform(req)
local original = req.params["key1"]
if original and #original > 0 then
req.params["key1_copy"] = {original[1]}
end
return req
end`
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-P", "key1=val1",
"-lua", script)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Query["key1_copy"]; len(v) == 0 || v[0] != "val1" {
t.Errorf("expected key1_copy=val1 (copied from key1), got %v", v)
}
}
func TestScriptFromHTTPURL(t *testing.T) {
t.Parallel()
// Serve a Lua script via HTTP
scriptContent := `function transform(req) req.headers["X-Remote"] = {"yes"} return req end`
scriptServer := statusServerWithBody(scriptContent)
defer scriptServer.Close()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-lua", "@"+scriptServer.URL)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-Remote"]; len(v) == 0 || v[0] != "yes" {
t.Errorf("expected X-Remote: yes from remote script, got %v", req.Headers)
}
}

View File

@@ -0,0 +1,36 @@
package e2e
import (
"testing"
)
func TestShowConfigFromYAML(t *testing.T) {
t.Parallel()
config := `
url: "http://example.com"
requests: 1
showConfig: true
`
configPath := writeTemp(t, "show_config.yaml", config)
res := run("-f", configPath)
assertExitCode(t, res, 0)
// Non-TTY: should output raw YAML config
assertContains(t, res.Stdout, "url:")
assertContains(t, res.Stdout, "example.com")
}
func TestShowConfigFromEnv(t *testing.T) {
t.Parallel()
res := runWithEnv(map[string]string{
"SARIN_URL": "http://example.com",
"SARIN_REQUESTS": "1",
"SARIN_SHOW_CONFIG": "true",
}, "-q")
assertExitCode(t, res, 0)
assertContains(t, res.Stdout, "url:")
assertContains(t, res.Stdout, "example.com")
}

61
e2e/show_config_test.go Normal file
View File

@@ -0,0 +1,61 @@
package e2e
import (
"testing"
)
func TestShowConfigNonTTY(t *testing.T) {
t.Parallel()
// In non-TTY mode (like tests), -s should output raw YAML and exit
res := run("-U", "http://example.com", "-r", "1", "-s")
assertExitCode(t, res, 0)
// Should contain YAML-formatted config
assertContains(t, res.Stdout, "url:")
assertContains(t, res.Stdout, "example.com")
assertContains(t, res.Stdout, "requests:")
}
func TestShowConfigContainsMethod(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-M", "POST", "-s")
assertExitCode(t, res, 0)
assertContains(t, res.Stdout, "method:")
assertContains(t, res.Stdout, "POST")
}
func TestShowConfigContainsHeaders(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-s",
"-H", "X-Custom: test-value")
assertExitCode(t, res, 0)
assertContains(t, res.Stdout, "X-Custom")
assertContains(t, res.Stdout, "test-value")
}
func TestShowConfigContainsTimeout(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-T", "5s", "-s")
assertExitCode(t, res, 0)
assertContains(t, res.Stdout, "timeout:")
}
func TestShowConfigWithEnvVars(t *testing.T) {
t.Parallel()
res := runWithEnv(map[string]string{
"SARIN_URL": "http://example.com",
"SARIN_REQUESTS": "5",
}, "-s")
assertExitCode(t, res, 0)
assertContains(t, res.Stdout, "example.com")
assertContains(t, res.Stdout, "requests:")
}

116
e2e/signal_test.go Normal file
View File

@@ -0,0 +1,116 @@
package e2e
import (
"encoding/json"
"syscall"
"testing"
"time"
)
func TestSIGINTGracefulShutdown(t *testing.T) {
t.Parallel()
srv := slowServer(100 * time.Millisecond)
defer srv.Close()
// Start a duration-based test that would run for a long time
cmd, stdout := startProcess(
"-U", srv.URL, "-d", "30s", "-q", "-o", "json",
)
if err := cmd.Start(); err != nil {
t.Fatalf("failed to start process: %v", err)
}
// Let it run for a bit so some requests complete
time.Sleep(500 * time.Millisecond)
// Send SIGINT for graceful shutdown
if err := cmd.Process.Signal(syscall.SIGINT); err != nil {
t.Fatalf("failed to send SIGINT: %v", err)
}
// Wait for process to exit
err := cmd.Wait()
_ = err // May exit with 0 or non-zero depending on timing
// Should have produced valid JSON output with partial results
output := stdout.String()
if output == "" {
t.Fatal("expected JSON output after SIGINT, got empty stdout")
}
var out outputData
if err := json.Unmarshal([]byte(output), &out); err != nil {
t.Fatalf("expected valid JSON after graceful shutdown: %v\nstdout: %s", err, output)
}
count, _ := out.Total.Count.Int64()
if count < 1 {
t.Errorf("expected at least 1 request before shutdown, got %d", count)
}
}
func TestSIGTERMGracefulShutdown(t *testing.T) {
t.Parallel()
srv := slowServer(100 * time.Millisecond)
defer srv.Close()
cmd, stdout := startProcess(
"-U", srv.URL, "-d", "30s", "-q", "-o", "json",
)
if err := cmd.Start(); err != nil {
t.Fatalf("failed to start process: %v", err)
}
time.Sleep(500 * time.Millisecond)
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
t.Fatalf("failed to send SIGTERM: %v", err)
}
err := cmd.Wait()
_ = err
output := stdout.String()
if output == "" {
t.Fatal("expected JSON output after SIGTERM, got empty stdout")
}
var out outputData
if err := json.Unmarshal([]byte(output), &out); err != nil {
t.Fatalf("expected valid JSON after graceful shutdown: %v\nstdout: %s", err, output)
}
}
func TestSIGINTExitsInReasonableTime(t *testing.T) {
t.Parallel()
srv := slowServer(50 * time.Millisecond)
defer srv.Close()
cmd, _ := startProcess(
"-U", srv.URL, "-d", "60s", "-q", "-o", "none",
)
if err := cmd.Start(); err != nil {
t.Fatalf("failed to start process: %v", err)
}
time.Sleep(300 * time.Millisecond)
if err := cmd.Process.Signal(syscall.SIGINT); err != nil {
t.Fatalf("failed to send SIGINT: %v", err)
}
// Should exit within 5 seconds
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case <-done:
// Good — exited in time
case <-time.After(5 * time.Second):
cmd.Process.Kill()
t.Fatal("process did not exit within 5 seconds after SIGINT")
}
}

View File

@@ -0,0 +1,116 @@
package e2e
import (
"strings"
"testing"
)
func TestDictStr(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// dict_Str creates a map; use with index to retrieve a value
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json",
"-B", `{{ $d := dict_Str "name" "alice" "role" "admin" }}{{ index $d "name" }}-{{ index $d "role" }}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Body != "alice-admin" {
t.Errorf("expected body alice-admin, got %q", req.Body)
}
}
func TestStringsToDate(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// strings_ToDate parses a date string; verify it produces a non-empty result
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-H", `X-Date: {{ strings_ToDate "2024-06-15" }}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-Date"]; len(v) == 0 || v[0] == "" {
t.Error("expected X-Date to have a non-empty value")
} else {
assertContains(t, v[0], "2024")
}
}
func TestFileBase64NonexistentFile(t *testing.T) {
t.Parallel()
// file_Base64 errors at runtime, the error becomes the response key
res := run("-U", "http://example.com", "-r", "1", "-z", "-q", "-o", "json",
"-B", `{{ file_Base64 "/nonexistent/file.txt" }}`)
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
// Should have a template rendering error as response key, not "dry-run"
if _, ok := out.Responses["dry-run"]; ok {
t.Error("expected template error, but got dry-run response")
}
assertResponseCount(t, out, 1)
}
func TestFileBase64FailedHTTP(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-z", "-q", "-o", "json",
"-B", `{{ file_Base64 "http://127.0.0.1:1/nonexistent" }}`)
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
if _, ok := out.Responses["dry-run"]; ok {
t.Error("expected template error, but got dry-run response")
}
assertResponseCount(t, out, 1)
}
func TestMultipleValuesFlags(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-V", "KEY1=val1", "-V", "KEY2=val2",
"-H", "X-K1: {{ .Values.KEY1 }}",
"-H", "X-K2: {{ .Values.KEY2 }}")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-K1"]; len(v) == 0 || v[0] != "val1" {
t.Errorf("expected X-K1: val1, got %v", v)
}
if v := req.Headers["X-K2"]; len(v) == 0 || v[0] != "val2" {
t.Errorf("expected X-K2: val2, got %v", v)
}
}
func TestValuesUsedInBodyAndHeader(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// Same value used in both header and body within the same request
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json",
"-V", "ID={{ fakeit_UUID }}",
"-H", "X-Request-Id: {{ .Values.ID }}",
"-B", `{"id":"{{ .Values.ID }}"}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
headerID := ""
if v := req.Headers["X-Request-Id"]; len(v) > 0 {
headerID = v[0]
}
if headerID == "" {
t.Fatal("expected X-Request-Id to have a value")
}
// Body should contain the same UUID as the header
if !strings.Contains(req.Body, headerID) {
t.Errorf("expected body to contain same ID as header (%s), got body: %s", headerID, req.Body)
}
}

170
e2e/template_funcs_test.go Normal file
View File

@@ -0,0 +1,170 @@
package e2e
import (
"testing"
)
func TestStringToUpper(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-H", `X-Upper: {{ strings_ToUpper "hello" }}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-Upper"]; len(v) == 0 || v[0] != "HELLO" {
t.Errorf("expected X-Upper: HELLO, got %v", v)
}
}
func TestStringToLower(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-H", `X-Lower: {{ strings_ToLower "WORLD" }}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-Lower"]; len(v) == 0 || v[0] != "world" {
t.Errorf("expected X-Lower: world, got %v", v)
}
}
func TestStringReplace(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json",
"-B", `{{ strings_Replace "foo-bar-baz" "-" "_" -1 }}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Body != "foo_bar_baz" {
t.Errorf("expected body foo_bar_baz, got %q", req.Body)
}
}
func TestStringRemoveSpaces(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json",
"-B", `{{ strings_RemoveSpaces "hello world foo" }}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Body != "helloworldfoo" {
t.Errorf("expected body helloworldfoo, got %q", req.Body)
}
}
func TestStringTrimPrefix(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json",
"-B", `{{ strings_TrimPrefix "hello-world" "hello-" }}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Body != "world" {
t.Errorf("expected body world, got %q", req.Body)
}
}
func TestStringTrimSuffix(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json",
"-B", `{{ strings_TrimSuffix "hello-world" "-world" }}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Body != "hello" {
t.Errorf("expected body hello, got %q", req.Body)
}
}
func TestSliceJoin(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json",
"-B", `{{ slice_Join (slice_Str "a" "b" "c") ", " }}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Body != "a, b, c" {
t.Errorf("expected body 'a, b, c', got %q", req.Body)
}
}
func TestStringFirst(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json",
"-B", `{{ strings_First "abcdef" 3 }}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Body != "abc" {
t.Errorf("expected body abc, got %q", req.Body)
}
}
func TestStringLast(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json",
"-B", `{{ strings_Last "abcdef" 3 }}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Body != "def" {
t.Errorf("expected body def, got %q", req.Body)
}
}
func TestStringTruncate(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json",
"-B", `{{ strings_Truncate "hello world" 5 }}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Body != "hello..." {
t.Errorf("expected body 'hello...', got %q", req.Body)
}
}
func TestSliceStr(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json",
"-B", `{{ slice_Join (slice_Str "a" "b" "c") "-" }}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Body != "a-b-c" {
t.Errorf("expected body a-b-c, got %q", req.Body)
}
}

241
e2e/template_test.go Normal file
View File

@@ -0,0 +1,241 @@
package e2e
import (
"testing"
)
func TestTemplateInHeader(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// Use a template function that generates a UUID
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-H", "X-Request-Id: {{ fakeit_UUID }}")
assertExitCode(t, res, 0)
req := cs.lastRequest()
vals, ok := req.Headers["X-Request-Id"]
if !ok || len(vals) == 0 {
t.Fatalf("expected X-Request-Id header, got headers: %v", req.Headers)
}
// UUID format: 8-4-4-4-12
if len(vals[0]) != 36 {
t.Errorf("expected UUID (36 chars), got %q (%d chars)", vals[0], len(vals[0]))
}
}
func TestTemplateInParam(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-P", "id={{ fakeit_UUID }}")
assertExitCode(t, res, 0)
req := cs.lastRequest()
vals, ok := req.Query["id"]
if !ok || len(vals) == 0 {
t.Fatalf("expected 'id' param, got query: %v", req.Query)
}
if len(vals[0]) != 36 {
t.Errorf("expected UUID in param value, got %q", vals[0])
}
}
func TestTemplateInBody(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json",
"-B", `{"id":"{{ fakeit_UUID }}"}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if len(req.Body) < 36 {
t.Errorf("expected body to contain a UUID, got %q", req.Body)
}
assertContains(t, req.Body, `"id":"`)
}
func TestTemplateInURLPath(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL+"/api/{{ fakeit_UUID }}", "-r", "1", "-q", "-o", "json")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if len(req.Path) < 5+36 { // "/api/" + UUID
t.Errorf("expected path to contain a UUID, got %q", req.Path)
}
}
func TestValuesBasic(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-V", "MY_VAR=hello",
"-H", "X-Val: {{ .Values.MY_VAR }}")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-Val"]; len(v) == 0 || v[0] != "hello" {
t.Errorf("expected X-Val: hello from Values, got %v", v)
}
}
func TestValuesMultiple(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-V", "A=first",
"-V", "B=second",
"-H", "X-A: {{ .Values.A }}",
"-H", "X-B: {{ .Values.B }}")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-A"]; len(v) == 0 || v[0] != "first" {
t.Errorf("expected X-A: first, got %v", v)
}
if v := req.Headers["X-B"]; len(v) == 0 || v[0] != "second" {
t.Errorf("expected X-B: second, got %v", v)
}
}
func TestValuesWithTemplate(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
// Values themselves can contain templates
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-V", "REQ_ID={{ fakeit_UUID }}",
"-H", "X-Request-Id: {{ .Values.REQ_ID }}")
assertExitCode(t, res, 0)
req := cs.lastRequest()
vals, ok := req.Headers["X-Request-Id"]
if !ok || len(vals) == 0 {
t.Fatalf("expected X-Request-Id header, got %v", req.Headers)
}
if len(vals[0]) != 36 {
t.Errorf("expected UUID from value template, got %q", vals[0])
}
}
func TestValuesInParam(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-V", "TOKEN=abc123",
"-P", "token={{ .Values.TOKEN }}")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Query["token"]; len(v) == 0 || v[0] != "abc123" {
t.Errorf("expected token=abc123, got %v", v)
}
}
func TestValuesInBody(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json",
"-V", "NAME=test-user",
"-B", `{"name":"{{ .Values.NAME }}"}`)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Body != `{"name":"test-user"}` {
t.Errorf("expected body with interpolated value, got %q", req.Body)
}
}
func TestValuesInURLPath(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL+"/users/{{ .Values.USER_ID }}", "-r", "1", "-q", "-o", "json",
"-V", "USER_ID=42")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Path != "/users/42" {
t.Errorf("expected path /users/42, got %s", req.Path)
}
}
func TestTemplateGeneratesDifferentValues(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "5", "-c", "1", "-q", "-o", "json",
"-H", "X-Unique: {{ fakeit_UUID }}")
assertExitCode(t, res, 0)
reqs := cs.allRequests()
if len(reqs) < 5 {
t.Fatalf("expected 5 requests, got %d", len(reqs))
}
// UUIDs should be unique across requests
seen := make(map[string]bool)
for _, r := range reqs {
vals := r.Headers["X-Unique"]
if len(vals) > 0 {
seen[vals[0]] = true
}
}
if len(seen) < 2 {
t.Errorf("expected template to generate different UUIDs across requests, got %d unique values", len(seen))
}
}
func TestTemplateFunctionFakeit(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
t.Cleanup(cs.Close)
// Test various fakeit functions
tests := []struct {
name string
template string
}{
{"UUID", "{{ fakeit_UUID }}"},
{"Name", "{{ fakeit_Name }}"},
{"Email", "{{ fakeit_Email }}"},
{"Number", "{{ fakeit_Number 1 100 }}"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cs := newCaptureServer()
defer cs.Close()
res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json",
"-H", "X-Test: "+tt.template)
assertExitCode(t, res, 0)
req := cs.lastRequest()
if v := req.Headers["X-Test"]; len(v) == 0 || v[0] == "" {
t.Errorf("expected non-empty value from %s, got %v", tt.template, v)
}
})
}
}

110
e2e/timeout_test.go Normal file
View File

@@ -0,0 +1,110 @@
package e2e
import (
"testing"
"time"
)
func TestRequestTimeout(t *testing.T) {
t.Parallel()
// Server that takes 2 seconds to respond
srv := slowServer(2 * time.Second)
defer srv.Close()
// Timeout of 200ms — should fail with timeout error
res := run("-U", srv.URL, "-r", "1", "-T", "200ms", "-q", "-o", "json")
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
// Should NOT have "200" — should have a timeout error
if _, ok := out.Responses["200"]; ok {
t.Error("expected timeout error, but got 200")
}
// Total count should still be 1 (the timed-out request is counted)
assertResponseCount(t, out, 1)
}
func TestRequestTimeoutMultiple(t *testing.T) {
t.Parallel()
srv := slowServer(2 * time.Second)
defer srv.Close()
res := run("-U", srv.URL, "-r", "3", "-c", "3", "-T", "200ms", "-q", "-o", "json")
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertResponseCount(t, out, 3)
// None should be 200
if _, ok := out.Responses["200"]; ok {
t.Error("expected all requests to timeout, but got some 200s")
}
}
func TestTimeoutDoesNotAffectFastRequests(t *testing.T) {
t.Parallel()
srv := echoServer()
defer srv.Close()
// Short timeout but server responds instantly — should succeed
res := run("-U", srv.URL, "-r", "3", "-T", "5s", "-q", "-o", "json")
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertHasResponseKey(t, out, "200")
assertResponseCount(t, out, 3)
}
func TestDurationStopsAfterTime(t *testing.T) {
t.Parallel()
srv := echoServer()
defer srv.Close()
start := time.Now()
res := run("-U", srv.URL, "-d", "1s", "-q", "-o", "json")
elapsed := time.Since(start)
assertExitCode(t, res, 0)
// Should finish roughly around 1s (allow some tolerance)
if elapsed < 900*time.Millisecond {
t.Errorf("expected test to run ~1s, but finished in %v", elapsed)
}
if elapsed > 3*time.Second {
t.Errorf("expected test to finish around 1s, but took %v", elapsed)
}
}
func TestDurationWithRequestLimit(t *testing.T) {
t.Parallel()
srv := echoServer()
defer srv.Close()
// Request limit reached before duration — should stop early
res := run("-U", srv.URL, "-r", "2", "-d", "30s", "-q", "-o", "json")
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertResponseCount(t, out, 2)
}
func TestDurationWithSlowServerStopsAtDuration(t *testing.T) {
t.Parallel()
// Server delays 500ms per request
srv := slowServer(500 * time.Millisecond)
defer srv.Close()
start := time.Now()
res := run("-U", srv.URL, "-d", "1s", "-c", "1", "-q", "-o", "json")
elapsed := time.Since(start)
assertExitCode(t, res, 0)
// Should stop after ~1s even though requests are slow
if elapsed > 3*time.Second {
t.Errorf("expected to stop around 1s duration, took %v", elapsed)
}
}

164
e2e/tls_test.go Normal file
View File

@@ -0,0 +1,164 @@
package e2e
import (
"crypto/tls"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHTTPSWithInsecureFlag(t *testing.T) {
t.Parallel()
// Create a TLS server with a self-signed cert
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
// Without --insecure, it should fail (cert not trusted)
// With --insecure, it should succeed
res := run("-U", srv.URL, "-r", "1", "-q", "-o", "json", "-I")
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertHasResponseKey(t, out, "200")
}
func TestHTTPSWithoutInsecureFails(t *testing.T) {
t.Parallel()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
// Without --insecure, should get a TLS error (not a clean 200)
res := run("-U", srv.URL, "-r", "1", "-q", "-o", "json")
assertExitCode(t, res, 0) // Process still exits 0, but response key is an error
out := res.jsonOutput(t)
// Should NOT have a "200" key — should have a TLS error
if _, ok := out.Responses["200"]; ok {
t.Error("expected TLS error without --insecure, but got 200")
}
}
func TestHTTPSInsecureViaCLILongFlag(t *testing.T) {
t.Parallel()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
// Use the long form flag
res := run("-U", srv.URL, "-r", "1", "-q", "-o", "json", "-insecure")
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertHasResponseKey(t, out, "200")
}
func TestHTTPSInsecureViaConfigFile(t *testing.T) {
t.Parallel()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
config := `
url: "` + srv.URL + `"
requests: 1
insecure: true
quiet: true
output: json
`
configPath := writeTemp(t, "tls_config.yaml", config)
res := run("-f", configPath)
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertHasResponseKey(t, out, "200")
}
func TestHTTPSInsecureViaEnv(t *testing.T) {
t.Parallel()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
res := runWithEnv(map[string]string{
"SARIN_URL": srv.URL,
"SARIN_REQUESTS": "1",
"SARIN_INSECURE": "true",
"SARIN_QUIET": "true",
"SARIN_OUTPUT": "json",
})
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertHasResponseKey(t, out, "200")
}
func TestHTTPSEchoServer(t *testing.T) {
t.Parallel()
// TLS echo server that returns request details
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := map[string]any{
"method": r.Method,
"path": r.URL.Path,
"tls": r.TLS != nil,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
// Verify request was received over TLS
res := run("-U", srv.URL+"/secure-path", "-r", "1", "-q", "-o", "json", "-I")
assertExitCode(t, res, 0)
out := res.jsonOutput(t)
assertHasResponseKey(t, out, "200")
}
// tlsCaptureServer is like captureServer but with TLS
func tlsCaptureServer() *captureServer {
cs := &captureServer{}
cs.Server = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cs.mu.Lock()
cs.requests = append(cs.requests, echoResponse{
Method: r.Method,
Path: r.URL.Path,
})
cs.mu.Unlock()
w.WriteHeader(http.StatusOK)
}))
cs.TLS = &tls.Config{}
cs.StartTLS()
return cs
}
func TestHTTPSHeadersSentCorrectly(t *testing.T) {
t.Parallel()
cs := tlsCaptureServer()
defer cs.Close()
res := run("-U", cs.URL+"/api/test", "-r", "1", "-M", "POST", "-q", "-o", "json", "-I")
assertExitCode(t, res, 0)
req := cs.lastRequest()
if req.Method != http.MethodPost {
t.Errorf("expected POST over HTTPS, got %s", req.Method)
}
if req.Path != "/api/test" {
t.Errorf("expected path /api/test over HTTPS, got %s", req.Path)
}
}

View File

@@ -0,0 +1,13 @@
package e2e
import (
"testing"
)
func TestValidation_ConcurrencyExceedsMax(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-q", "-c", "200000000")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "concurrency must not exceed 100,000,000")
}

168
e2e/validation_test.go Normal file
View File

@@ -0,0 +1,168 @@
package e2e
import (
"testing"
)
func TestValidation_MissingURL(t *testing.T) {
t.Parallel()
res := run("-r", "1")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "URL")
assertContains(t, res.Stderr, "required")
}
func TestValidation_InvalidURLScheme(t *testing.T) {
t.Parallel()
res := run("-U", "ftp://example.com", "-r", "1")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "URL")
assertContains(t, res.Stderr, "scheme")
}
func TestValidation_URLWithoutHost(t *testing.T) {
t.Parallel()
res := run("-U", "http://", "-r", "1")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "URL")
}
func TestValidation_NoRequestsOrDuration(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "request count or duration")
}
func TestValidation_ZeroRequests(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "0")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "Requests")
}
func TestValidation_ZeroDuration(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-d", "0s")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "Duration")
}
func TestValidation_ZeroRequestsAndZeroDuration(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "0", "-d", "0s")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "VALIDATION")
}
func TestValidation_ConcurrencyZero(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-c", "0")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "concurrency")
}
func TestValidation_TimeoutZero(t *testing.T) {
t.Parallel()
// Timeout of 0 is invalid (must be > 0)
res := run("-U", "http://example.com", "-r", "1", "-T", "0s")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "timeout")
}
func TestValidation_InvalidOutputFormat(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-o", "xml")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "Output")
}
func TestValidation_InvalidProxyScheme(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-X", "ftp://proxy.example.com:8080")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "proxy")
}
func TestValidation_EmptyLuaScript(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-lua", "")
assertExitCode(t, res, 1)
}
func TestValidation_EmptyJsScript(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-js", "")
assertExitCode(t, res, 1)
}
func TestValidation_LuaScriptMissingTransform(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1",
"-lua", `print("hello")`)
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "VALIDATION")
}
func TestValidation_JsScriptMissingTransform(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1",
"-js", `console.log("hello")`)
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "VALIDATION")
}
func TestValidation_LuaScriptSyntaxError(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1",
"-lua", `function transform(req invalid syntax`)
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "VALIDATION")
}
func TestValidation_JsScriptSyntaxError(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1",
"-js", `function transform(req { invalid`)
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "VALIDATION")
}
func TestValidation_ScriptEmptyFileRef(t *testing.T) {
t.Parallel()
// "@" with nothing after it
res := run("-U", "http://example.com", "-r", "1", "-lua", "@")
assertExitCode(t, res, 1)
}
func TestValidation_ScriptNonexistentFile(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1",
"-lua", "@/nonexistent/path/script.lua")
assertExitCode(t, res, 1)
}
func TestValidation_InvalidTemplateInHeader(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1",
"-H", "X-Test: {{ invalid_func }}")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "VALIDATION")
}
func TestValidation_InvalidTemplateInBody(t *testing.T) {
t.Parallel()
// Use a template with invalid syntax (unclosed action)
res := run("-U", "http://example.com", "-r", "1",
"-B", "{{ invalid_func_xyz }}")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "VALIDATION")
}
func TestValidation_MultipleErrors(t *testing.T) {
t.Parallel()
// No URL, no requests/duration — should report multiple validation errors
res := run("-c", "1")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "URL")
}

47
go.mod
View File

@@ -1,46 +1,47 @@
module go.aykhans.me/sarin module go.aykhans.me/sarin
go 1.26.1 go 1.26.0
require ( require (
github.com/brianvoe/gofakeit/v7 v7.14.1 github.com/brianvoe/gofakeit/v7 v7.14.0
github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/x/term v0.2.2 github.com/charmbracelet/x/term v0.2.2
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/valyala/fasthttp v1.70.0 github.com/valyala/fasthttp v1.69.0
github.com/yuin/gopher-lua v1.1.2 github.com/yuin/gopher-lua v1.1.1
go.aykhans.me/utils v1.0.7 go.aykhans.me/utils v1.0.7
go.yaml.in/yaml/v4 v4.0.0-rc.4 go.yaml.in/yaml/v4 v4.0.0-rc.3
golang.org/x/net v0.53.0 golang.org/x/net v0.50.0
) )
require ( require (
github.com/alecthomas/chroma/v2 v2.23.1 // indirect github.com/alecthomas/chroma/v2 v2.21.1 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260329003944-7eda8903d971 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/compress v1.18.2 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
@@ -50,9 +51,9 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.8.2 // indirect github.com/yuin/goldmark v1.7.16 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.42.0 // indirect golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.34.0 // indirect
) )

90
go.sum
View File

@@ -2,28 +2,28 @@ github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/brianvoe/gofakeit/v7 v7.14.1 h1:a7fe3fonbj0cW3wgl5VwIKfZtiH9C3cLnwcIXWT7sow= github.com/brianvoe/gofakeit/v7 v7.14.0 h1:R8tmT/rTDJmD2ngpqBL9rAKydiL7Qr2u3CXPqRt59pk=
github.com/brianvoe/gofakeit/v7 v7.14.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= github.com/brianvoe/gofakeit/v7 v7.14.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
@@ -34,43 +34,45 @@ github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMx
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/slice v0.0.0-20260329003944-7eda8903d971 h1:wae/9jUCdhUiuyCcOzZZ+vJEB7uJx+IvtTnpCqcW1ZQ= github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f h1:kvAY8ffwhFuxWqtVI6+9E5vmgTApG96hswFLXJfsxHI=
github.com/charmbracelet/x/exp/slice v0.0.0-20260329003944-7eda8903d971/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk= github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw= github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
@@ -91,34 +93,34 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA= github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE= github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.aykhans.me/utils v1.0.7 h1:ClHXHlWmkjfFlD7+w5BQY29lKCEztxY/yCf543x4hZw= go.aykhans.me/utils v1.0.7 h1:ClHXHlWmkjfFlD7+w5BQY29lKCEztxY/yCf543x4hZw=
go.aykhans.me/utils v1.0.7/go.mod h1:0Jz8GlZLN35cCHLOLx39sazWwEe33bF6SYlSeqzEXoI= go.aykhans.me/utils v1.0.7/go.mod h1:0Jz8GlZLN35cCHLOLx39sazWwEe33bF6SYlSeqzEXoI=
go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U= go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -418,7 +418,7 @@ func (config Config) Validate() error {
validationErrors = append(validationErrors, types.NewFieldValidationError("Duration", "0", errors.New("duration must be greater than 0"))) validationErrors = append(validationErrors, types.NewFieldValidationError("Duration", "0", errors.New("duration must be greater than 0")))
} }
if config.Timeout == nil || *config.Timeout < 1 { if *config.Timeout < 1 {
validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0"))) validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0")))
} }

View File

@@ -157,7 +157,7 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
types.NewFieldParseError( types.NewFieldParseError(
parser.getFullEnvName("DURATION"), parser.getFullEnvName("DURATION"),
duration, duration,
errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"), errors.New("invalid value duration, expected a duration string (e.g., '10s', '1h30m')"),
), ),
) )
} else { } else {
@@ -173,7 +173,7 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
types.NewFieldParseError( types.NewFieldParseError(
parser.getFullEnvName("TIMEOUT"), parser.getFullEnvName("TIMEOUT"),
timeout, timeout,
errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"), errors.New("invalid value duration, expected a duration string (e.g., '10s', '1h30m')"),
), ),
) )
} else { } else {

View File

@@ -1,415 +0,0 @@
package sarin
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"go.aykhans.me/sarin/internal/types"
)
const (
captchaPollInterval = 1 * time.Second
captchaPollTimeout = 120 * time.Second
)
var captchaHTTPClient = &http.Client{Timeout: 5 * time.Second}
// solveCaptcha creates a task on the given captcha service and polls until it is solved,
// returning the extracted token from the solution object.
//
// baseURL is the service API base (e.g. "https://api.2captcha.com").
// task is the task payload the service expects (type + service-specific fields).
// solutionKey is the field name in the solution object that holds the token.
// taskIDIsString controls whether taskId is sent back as a string (CapSolver UUIDs)
// or a JSON number (2Captcha, Anti-Captcha).
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func solveCaptcha(baseURL, apiKey string, task map[string]any, solutionKey string, taskIDIsString bool) (string, error) {
if apiKey == "" {
return "", types.ErrCaptchaKeyEmpty
}
taskID, err := captchaCreateTask(baseURL, apiKey, task)
if err != nil {
return "", err
}
return captchaPollResult(baseURL, apiKey, taskID, solutionKey, taskIDIsString)
}
// captchaCreateTask submits a task to the captcha service and returns the assigned taskId.
// The taskId is normalized to a string: numeric IDs are preserved via json.RawMessage,
// and quoted string IDs (CapSolver UUIDs) have their surrounding quotes stripped.
//
// It can return the following errors:
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
func captchaCreateTask(baseURL, apiKey string, task map[string]any) (string, error) {
body := map[string]any{
"clientKey": apiKey,
"task": task,
}
data, err := json.Marshal(body)
if err != nil {
return "", types.NewCaptchaDecodeError("createTask", err)
}
resp, err := captchaHTTPClient.Post(
baseURL+"/createTask",
"application/json",
bytes.NewReader(data),
)
if err != nil {
return "", types.NewCaptchaRequestError("createTask", err)
}
defer resp.Body.Close() //nolint:errcheck
var result struct {
ErrorID int `json:"errorId"`
ErrorCode string `json:"errorCode"`
ErrorDescription string `json:"errorDescription"`
TaskID json.RawMessage `json:"taskId"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", types.NewCaptchaDecodeError("createTask", err)
}
if result.ErrorID != 0 {
return "", types.NewCaptchaAPIError("createTask", result.ErrorCode, result.ErrorDescription)
}
// taskId may be a JSON number (2captcha, anti-captcha) or a quoted string (capsolver UUIDs).
// Strip surrounding quotes if present so we always work with the underlying value.
taskID := strings.Trim(string(result.TaskID), `"`)
if taskID == "" {
return "", types.NewCaptchaAPIError("createTask", "EMPTY_TASK_ID", "service returned a successful response with no taskId")
}
return taskID, nil
}
// captchaPollResult polls the getTaskResult endpoint at captchaPollInterval until the task
// is solved, an error is returned by the service, or the overall captchaPollTimeout is hit.
//
// It can return the following errors:
// - types.CaptchaPollTimeoutError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaSolutionKeyError
func captchaPollResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsString bool) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), captchaPollTimeout)
defer cancel()
ticker := time.NewTicker(captchaPollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return "", types.NewCaptchaPollTimeoutError(taskID)
case <-ticker.C:
token, err := captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey, taskIDIsString)
if errors.Is(err, types.ErrCaptchaProcessing) {
continue
}
// Retry on transient HTTP errors (timeouts, connection resets, etc.)
// instead of failing the entire solve. The poll loop timeout will
// eventually catch permanently unreachable services.
if _, ok := errors.AsType[types.CaptchaRequestError](err); ok {
continue
}
if err != nil {
return "", err
}
return token, nil
}
}
}
// captchaGetTaskResult fetches a single task result from the captcha service.
//
// It can return the following errors:
// - types.ErrCaptchaProcessing
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaSolutionKeyError
func captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsString bool) (string, error) {
var bodyMap map[string]any
if taskIDIsString {
bodyMap = map[string]any{"clientKey": apiKey, "taskId": taskID}
} else {
bodyMap = map[string]any{"clientKey": apiKey, "taskId": json.Number(taskID)}
}
data, err := json.Marshal(bodyMap)
if err != nil {
return "", types.NewCaptchaDecodeError("getTaskResult", err)
}
resp, err := captchaHTTPClient.Post(
baseURL+"/getTaskResult",
"application/json",
bytes.NewReader(data),
)
if err != nil {
return "", types.NewCaptchaRequestError("getTaskResult", err)
}
defer resp.Body.Close() //nolint:errcheck
var result struct {
ErrorID int `json:"errorId"`
ErrorCode string `json:"errorCode"`
ErrorDescription string `json:"errorDescription"`
Status string `json:"status"`
Solution map[string]any `json:"solution"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", types.NewCaptchaDecodeError("getTaskResult", err)
}
if result.ErrorID != 0 {
return "", types.NewCaptchaAPIError("getTaskResult", result.ErrorCode, result.ErrorDescription)
}
if result.Status == "processing" || result.Status == "idle" {
return "", types.ErrCaptchaProcessing
}
token, ok := result.Solution[solutionKey]
if !ok {
return "", types.NewCaptchaSolutionKeyError(solutionKey)
}
tokenStr, ok := token.(string)
if !ok {
return "", types.NewCaptchaSolutionKeyError(solutionKey)
}
return tokenStr, nil
}
// ======================================== 2Captcha ========================================
const twoCaptchaBaseURL = "https://api.2captcha.com"
// twoCaptchaSolveRecaptchaV2 solves a Google reCAPTCHA v2 challenge via 2Captcha.
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) {
return solveCaptcha(twoCaptchaBaseURL, apiKey, map[string]any{
"type": "RecaptchaV2TaskProxyless",
"websiteURL": websiteURL,
"websiteKey": websiteKey,
}, "gRecaptchaResponse", false)
}
// twoCaptchaSolveRecaptchaV3 solves a Google reCAPTCHA v3 challenge via 2Captcha.
// pageAction may be empty.
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) {
task := map[string]any{
"type": "RecaptchaV3TaskProxyless",
"websiteURL": websiteURL,
"websiteKey": websiteKey,
}
if pageAction != "" {
task["pageAction"] = pageAction
}
return solveCaptcha(twoCaptchaBaseURL, apiKey, task, "gRecaptchaResponse", false)
}
// twoCaptchaSolveTurnstile solves a Cloudflare Turnstile challenge via 2Captcha.
// cData may be empty.
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func twoCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) {
task := map[string]any{
"type": "TurnstileTaskProxyless",
"websiteURL": websiteURL,
"websiteKey": websiteKey,
}
if cData != "" {
task["data"] = cData
}
return solveCaptcha(twoCaptchaBaseURL, apiKey, task, "token", false)
}
// ======================================== Anti-Captcha ========================================
const antiCaptchaBaseURL = "https://api.anti-captcha.com"
// antiCaptchaSolveRecaptchaV2 solves a Google reCAPTCHA v2 challenge via Anti-Captcha.
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) {
return solveCaptcha(antiCaptchaBaseURL, apiKey, map[string]any{
"type": "RecaptchaV2TaskProxyless",
"websiteURL": websiteURL,
"websiteKey": websiteKey,
}, "gRecaptchaResponse", false)
}
// antiCaptchaSolveRecaptchaV3 solves a Google reCAPTCHA v3 challenge via Anti-Captcha.
// pageAction may be empty. minScore is hardcoded to 0.3 (the loosest threshold) because
// Anti-Captcha rejects the request without it.
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) {
task := map[string]any{
"type": "RecaptchaV3TaskProxyless",
"websiteURL": websiteURL,
"websiteKey": websiteKey,
"minScore": 0.3,
}
if pageAction != "" {
task["pageAction"] = pageAction
}
return solveCaptcha(antiCaptchaBaseURL, apiKey, task, "gRecaptchaResponse", false)
}
// antiCaptchaSolveHCaptcha solves an hCaptcha challenge via Anti-Captcha.
// Anti-Captcha returns hCaptcha tokens under "gRecaptchaResponse" (not "token").
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey string) (string, error) {
return solveCaptcha(antiCaptchaBaseURL, apiKey, map[string]any{
"type": "HCaptchaTaskProxyless",
"websiteURL": websiteURL,
"websiteKey": websiteKey,
}, "gRecaptchaResponse", false)
}
// antiCaptchaSolveTurnstile solves a Cloudflare Turnstile challenge via Anti-Captcha.
// cData may be empty.
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func antiCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) {
task := map[string]any{
"type": "TurnstileTaskProxyless",
"websiteURL": websiteURL,
"websiteKey": websiteKey,
}
if cData != "" {
task["cData"] = cData
}
return solveCaptcha(antiCaptchaBaseURL, apiKey, task, "token", false)
}
// ======================================== CapSolver ========================================
const capSolverBaseURL = "https://api.capsolver.com"
// capSolverSolveRecaptchaV2 solves a Google reCAPTCHA v2 challenge via CapSolver.
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) {
return solveCaptcha(capSolverBaseURL, apiKey, map[string]any{
"type": "ReCaptchaV2TaskProxyLess",
"websiteURL": websiteURL,
"websiteKey": websiteKey,
}, "gRecaptchaResponse", true)
}
// capSolverSolveRecaptchaV3 solves a Google reCAPTCHA v3 challenge via CapSolver.
// pageAction may be empty.
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) {
task := map[string]any{
"type": "ReCaptchaV3TaskProxyLess",
"websiteURL": websiteURL,
"websiteKey": websiteKey,
}
if pageAction != "" {
task["pageAction"] = pageAction
}
return solveCaptcha(capSolverBaseURL, apiKey, task, "gRecaptchaResponse", true)
}
// capSolverSolveTurnstile solves a Cloudflare Turnstile challenge via CapSolver.
// cData may be empty. CapSolver nests cData under a "metadata" object.
//
// It can return the following errors:
// - types.ErrCaptchaKeyEmpty
// - types.CaptchaRequestError
// - types.CaptchaDecodeError
// - types.CaptchaAPIError
// - types.CaptchaPollTimeoutError
// - types.CaptchaSolutionKeyError
func capSolverSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) {
task := map[string]any{
"type": "AntiTurnstileTaskProxyLess",
"websiteURL": websiteURL,
"websiteKey": websiteKey,
}
if cData != "" {
task["metadata"] = map[string]any{"cdata": cData}
}
return solveCaptcha(capSolverBaseURL, apiKey, task, "token", true)
}

View File

@@ -172,6 +172,7 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
return nil, types.NewProxyDialError(proxyStr, err) return nil, types.NewProxyDialError(proxyStr, err)
} }
// Cap DNS resolution to half the timeout to reserve time for dial
dnsCtx, dnsCancel := context.WithTimeout(ctx, timeout) dnsCtx, dnsCancel := context.WithTimeout(ctx, timeout)
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host) ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
dnsCancel() dnsCancel()
@@ -243,7 +244,7 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
} }
// Upgrade to TLS // Upgrade to TLS
tlsConn := tls.Client(conn, &tls.Config{ tlsConn := tls.Client(conn, &tls.Config{ //nolint:gosec
ServerName: proxyURL.Hostname(), ServerName: proxyURL.Hostname(),
}) })
if err := tlsConn.Handshake(); err != nil { if err := tlsConn.Handshake(); err != nil {

View File

@@ -8,14 +8,7 @@ import (
func NewDefaultRandSource() rand.Source { func NewDefaultRandSource() rand.Source {
now := time.Now().UnixNano() now := time.Now().UnixNano()
return rand.NewPCG( return rand.NewPCG(
uint64(now), uint64(now), //nolint:gosec // G115: Safe conversion; UnixNano timestamp used as random seed, bit pattern is intentional
uint64(now>>32), uint64(now>>32), //nolint:gosec // G115: Safe conversion; right-shifted timestamp for seed entropy, overflow is acceptable
) )
} }
func firstOrEmpty(values []string) string {
if len(values) == 0 {
return ""
}
return values[0]
}

View File

@@ -43,34 +43,19 @@ func NewRequestGenerator(
randSource := NewDefaultRandSource() randSource := NewDefaultRandSource()
//nolint:gosec // G404: Using non-cryptographic rand for load testing, not security //nolint:gosec // G404: Using non-cryptographic rand for load testing, not security
localRand := rand.New(randSource) localRand := rand.New(randSource)
templateFuncMap := NewDefaultTemplateFuncMap(randSource, fileCache)
// Funcs() is only called if a value actually contains template syntax. pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, templateFuncMap)
// The root template is shared across all createTemplateFunc calls so Funcs() is called at most once. methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, templateFuncMap)
var templateRoot *template.Template paramsGenerator, isParamsGeneratorDynamic := NewParamsGeneratorFunc(localRand, params, templateFuncMap)
lazyTemplateRoot := func() *template.Template { headersGenerator, isHeadersGeneratorDynamic := NewHeadersGeneratorFunc(localRand, headers, templateFuncMap)
if templateRoot == nil { cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, templateFuncMap)
templateRoot = template.New("").Funcs(NewDefaultTemplateFuncMap(randSource, fileCache))
}
return templateRoot
}
pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, lazyTemplateRoot)
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, lazyTemplateRoot)
paramsGenerator, isParamsGeneratorDynamic := NewParamsGeneratorFunc(localRand, params, lazyTemplateRoot)
headersGenerator, isHeadersGeneratorDynamic := NewHeadersGeneratorFunc(localRand, headers, lazyTemplateRoot)
cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, lazyTemplateRoot)
bodyTemplateFuncMapData := &BodyTemplateFuncMapData{} bodyTemplateFuncMapData := &BodyTemplateFuncMapData{}
var bodyTemplateRoot *template.Template bodyTemplateFuncMap := NewDefaultBodyTemplateFuncMap(randSource, bodyTemplateFuncMapData, fileCache)
lazyBodyTemplateRoot := func() *template.Template { bodyGenerator, isBodyGeneratorDynamic := NewBodyGeneratorFunc(localRand, bodies, bodyTemplateFuncMap)
if bodyTemplateRoot == nil {
bodyTemplateRoot = template.New("").Funcs(NewDefaultBodyTemplateFuncMap(randSource, bodyTemplateFuncMapData, fileCache))
}
return bodyTemplateRoot
}
bodyGenerator, isBodyGeneratorDynamic := NewBodyGeneratorFunc(localRand, bodies, lazyBodyTemplateRoot)
valuesGenerator := NewValuesGeneratorFunc(values, lazyTemplateRoot) valuesGenerator := NewValuesGeneratorFunc(values, templateFuncMap)
hasScripts := scriptTransformer != nil && !scriptTransformer.IsEmpty() hasScripts := scriptTransformer != nil && !scriptTransformer.IsEmpty()
@@ -106,7 +91,7 @@ func NewRequestGenerator(
return err return err
} }
bodyTemplateFuncMapData.ClearFormDataContentType() bodyTemplateFuncMapData.ClearFormDataContenType()
if err = bodyGenerator(reqData, data); err != nil { if err = bodyGenerator(reqData, data); err != nil {
return err return err
} }
@@ -114,8 +99,8 @@ func NewRequestGenerator(
if err = headersGenerator(reqData, data); err != nil { if err = headersGenerator(reqData, data); err != nil {
return err return err
} }
if bodyTemplateFuncMapData.GetFormDataContentType() != "" { if bodyTemplateFuncMapData.GetFormDataContenType() != "" {
reqData.Headers["Content-Type"] = append(reqData.Headers["Content-Type"], bodyTemplateFuncMapData.GetFormDataContentType()) reqData.Headers["Content-Type"] = append(reqData.Headers["Content-Type"], bodyTemplateFuncMapData.GetFormDataContenType())
} }
if err = paramsGenerator(reqData, data); err != nil { if err = paramsGenerator(reqData, data); err != nil {
@@ -185,8 +170,8 @@ func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Reque
} }
} }
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, lazyRoot func() *template.Template) (requestDataGenerator, bool) { func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, lazyRoot) methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, templateFunctions)
var ( var (
method string method string
@@ -203,8 +188,8 @@ func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, lazyRoot fun
}, isDynamic }, isDynamic
} }
func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, lazyRoot func() *template.Template) (requestDataGenerator, bool) { func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
bodyGenerator, isDynamic := buildStringSliceGenerator(localRand, bodies, lazyRoot) bodyGenerator, isDynamic := buildStringSliceGenerator(localRand, bodies, templateFunctions)
var ( var (
body string body string
@@ -221,8 +206,8 @@ func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, lazyRoot func()
}, isDynamic }, isDynamic
} }
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot func() *template.Template) (requestDataGenerator, bool) { func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
generators, isDynamic := buildKeyValueGenerators(localRand, params, lazyRoot) generators, isDynamic := buildKeyValueGenerators(localRand, params, templateFunctions)
var ( var (
key, value string key, value string
@@ -246,8 +231,8 @@ func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot
}, isDynamic }, isDynamic
} }
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRoot func() *template.Template) (requestDataGenerator, bool) { func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
generators, isDynamic := buildKeyValueGenerators(localRand, headers, lazyRoot) generators, isDynamic := buildKeyValueGenerators(localRand, headers, templateFunctions)
var ( var (
key, value string key, value string
@@ -271,8 +256,8 @@ func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRo
}, isDynamic }, isDynamic
} }
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRoot func() *template.Template) (requestDataGenerator, bool) { func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
generators, isDynamic := buildKeyValueGenerators(localRand, cookies, lazyRoot) generators, isDynamic := buildKeyValueGenerators(localRand, cookies, templateFunctions)
var ( var (
key, value string key, value string
@@ -296,11 +281,11 @@ func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRo
}, isDynamic }, isDynamic
} }
func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template) func() (valuesData, error) { func NewValuesGeneratorFunc(values []string, templateFunctions template.FuncMap) func() (valuesData, error) {
generators := make([]func(_ any) (string, error), len(values)) generators := make([]func(_ any) (string, error), len(values))
for i, v := range values { for i, v := range values {
generators[i], _ = createTemplateFunc(v, lazyRoot) generators[i], _ = createTemplateFunc(v, templateFunctions)
} }
var ( var (
@@ -328,12 +313,8 @@ func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template)
} }
} }
func createTemplateFunc(value string, lazyRoot func() *template.Template) (func(data any) (string, error), bool) { func createTemplateFunc(value string, templateFunctions template.FuncMap) (func(data any) (string, error), bool) {
if !strings.Contains(value, "{{") { tmpl, err := template.New("").Funcs(templateFunctions).Parse(value)
return func(_ any) (string, error) { return value, nil }, false
}
tmpl, err := lazyRoot().New("").Parse(value)
if err == nil && hasTemplateActions(tmpl) { if err == nil && hasTemplateActions(tmpl) {
var err error var err error
return func(data any) (string, error) { return func(data any) (string, error) {
@@ -359,7 +340,7 @@ type keyValueItem interface {
func buildKeyValueGenerators[T keyValueItem]( func buildKeyValueGenerators[T keyValueItem](
localRand *rand.Rand, localRand *rand.Rand,
items []T, items []T,
lazyRoot func() *template.Template, templateFunctions template.FuncMap,
) ([]keyValueGenerator, bool) { ) ([]keyValueGenerator, bool) {
isDynamic := false isDynamic := false
generators := make([]keyValueGenerator, len(items)) generators := make([]keyValueGenerator, len(items))
@@ -369,7 +350,7 @@ func buildKeyValueGenerators[T keyValueItem](
keyValue := types.KeyValue[string, []string](item) keyValue := types.KeyValue[string, []string](item)
// Generate key function // Generate key function
keyFunc, keyIsDynamic := createTemplateFunc(keyValue.Key, lazyRoot) keyFunc, keyIsDynamic := createTemplateFunc(keyValue.Key, templateFunctions)
if keyIsDynamic { if keyIsDynamic {
isDynamic = true isDynamic = true
} }
@@ -377,7 +358,7 @@ func buildKeyValueGenerators[T keyValueItem](
// Generate value functions // Generate value functions
valueFuncs := make([]func(data any) (string, error), len(keyValue.Value)) valueFuncs := make([]func(data any) (string, error), len(keyValue.Value))
for j, v := range keyValue.Value { for j, v := range keyValue.Value {
valueFunc, valueIsDynamic := createTemplateFunc(v, lazyRoot) valueFunc, valueIsDynamic := createTemplateFunc(v, templateFunctions)
if valueIsDynamic { if valueIsDynamic {
isDynamic = true isDynamic = true
} }
@@ -400,7 +381,7 @@ func buildKeyValueGenerators[T keyValueItem](
func buildStringSliceGenerator( func buildStringSliceGenerator(
localRand *rand.Rand, localRand *rand.Rand,
values []string, values []string,
lazyRoot func() *template.Template, templateFunctions template.FuncMap,
) (func() func(data any) (string, error), bool) { ) (func() func(data any) (string, error), bool) {
// Return a function that returns an empty string generator if values is empty // Return a function that returns an empty string generator if values is empty
if len(values) == 0 { if len(values) == 0 {
@@ -412,7 +393,7 @@ func buildStringSliceGenerator(
valueFuncs := make([]func(data any) (string, error), len(values)) valueFuncs := make([]func(data any) (string, error), len(values))
for i, value := range values { for i, value := range values {
valueFunc, valueIsDynamic := createTemplateFunc(value, lazyRoot) valueFunc, valueIsDynamic := createTemplateFunc(value, templateFunctions)
if valueIsDynamic { if valueIsDynamic {
isDynamic = true isDynamic = true
} }

View File

@@ -484,11 +484,13 @@ func newHostClients(
proxiesRaw[i] = url.URL(proxy) proxiesRaw[i] = url.URL(proxy)
} }
maxConns := max(fasthttp.DefaultMaxConnsPerHost, workers)
maxConns = ((maxConns * 50 / 100) + maxConns)
return NewHostClients( return NewHostClients(
ctx, ctx,
timeout, timeout,
proxiesRaw, proxiesRaw,
workers, maxConns,
requestURL, requestURL,
skipCertVerify, skipCertVerify,
) )

View File

@@ -2,12 +2,7 @@ package sarin
import ( import (
"bytes" "bytes"
"crypto/hmac"
"crypto/md5" // #nosec G501 -- exposed intentionally as a template utility helper
"crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json"
"math/rand/v2" "math/rand/v2"
"mime/multipart" "mime/multipart"
"strings" "strings"
@@ -86,79 +81,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"slice_Uint": func(values ...uint) []uint { return values }, "slice_Uint": func(values ...uint) []uint { return values },
"slice_Join": strings.Join, "slice_Join": strings.Join,
// JSON
// json_Encode marshals any value to a JSON string.
// Usage: {{ json_Encode (dict_Str "key" "value") }}
"json_Encode": func(v any) (string, error) {
data, err := json.Marshal(v)
if err != nil {
return "", types.NewJSONEncodeError(err)
}
return string(data), nil
},
// json_Object builds a JSON object from interleaved key-value pairs and returns it
// as a JSON string. Keys must be strings; values may be any JSON-encodable type.
// Usage: {{ json_Object "name" "Alice" "age" 30 }}
"json_Object": func(pairs ...any) (string, error) {
if len(pairs)%2 != 0 {
return "", types.ErrJSONObjectOddArgs
}
obj := make(map[string]any, len(pairs)/2)
for i := 0; i < len(pairs); i += 2 {
key, ok := pairs[i].(string)
if !ok {
return "", types.NewJSONObjectKeyError(i, pairs[i])
}
obj[key] = pairs[i+1]
}
data, err := json.Marshal(obj)
if err != nil {
return "", types.NewJSONEncodeError(err)
}
return string(data), nil
},
// Time
"time_NowUnix": func() int64 { return time.Now().Unix() },
"time_NowUnixMilli": func() int64 { return time.Now().UnixMilli() },
"time_NowRFC3339": func() string { return time.Now().Format(time.RFC3339) },
"time_Format": func(layout string, t time.Time) string {
return t.Format(layout)
},
// Crypto
"crypto_SHA256": func(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
},
"crypto_MD5": func(s string) string {
sum := md5.Sum([]byte(s)) // #nosec G401 -- MD5 is intentionally provided as a non-security template helper
return hex.EncodeToString(sum[:])
},
"crypto_HMACSHA256": func(key string, msg string) string {
mac := hmac.New(sha256.New, []byte(key))
_, _ = mac.Write([]byte(msg))
return hex.EncodeToString(mac.Sum(nil))
},
"crypto_Base64URL": func(s string) string {
return base64.RawURLEncoding.EncodeToString([]byte(s))
},
// File // File
// file_Read reads a file (local or remote URL) and returns its content as a string.
// Usage: {{ file_Read "/path/to/file.txt" }}
// {{ file_Read "https://example.com/data.txt" }}
"file_Read": func(source string) (string, error) {
if fileCache == nil {
return "", types.ErrFileCacheNotInitialized
}
cached, err := fileCache.GetOrLoad(source)
if err != nil {
return "", err
}
return string(cached.Content), nil
},
// file_Base64 reads a file (local or remote URL) and returns its Base64 encoded content. // file_Base64 reads a file (local or remote URL) and returns its Base64 encoded content.
// Usage: {{ file_Base64 "/path/to/file.pdf" }} // Usage: {{ file_Base64 "/path/to/file.pdf" }}
// {{ file_Base64 "https://example.com/image.png" }} // {{ file_Base64 "https://example.com/image.png" }}
@@ -319,7 +242,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"fakeit_AdverbFrequencyDefinite": fakeit.AdverbFrequencyDefinite, "fakeit_AdverbFrequencyDefinite": fakeit.AdverbFrequencyDefinite,
"fakeit_AdverbFrequencyIndefinite": fakeit.AdverbFrequencyIndefinite, "fakeit_AdverbFrequencyIndefinite": fakeit.AdverbFrequencyIndefinite,
// Prepositions // Propositions
"fakeit_Preposition": fakeit.Preposition, "fakeit_Preposition": fakeit.Preposition,
"fakeit_PrepositionSimple": fakeit.PrepositionSimple, "fakeit_PrepositionSimple": fakeit.PrepositionSimple,
"fakeit_PrepositionDouble": fakeit.PrepositionDouble, "fakeit_PrepositionDouble": fakeit.PrepositionDouble,
@@ -607,7 +530,8 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"fakeit_ErrorHTTP": func() string { return fakeit.ErrorHTTP().Error() }, "fakeit_ErrorHTTP": func() string { return fakeit.ErrorHTTP().Error() },
"fakeit_ErrorHTTPClient": func() string { return fakeit.ErrorHTTPClient().Error() }, "fakeit_ErrorHTTPClient": func() string { return fakeit.ErrorHTTPClient().Error() },
"fakeit_ErrorHTTPServer": func() string { return fakeit.ErrorHTTPServer().Error() }, "fakeit_ErrorHTTPServer": func() string { return fakeit.ErrorHTTPServer().Error() },
"fakeit_ErrorRuntime": func() string { return fakeit.ErrorRuntime().Error() }, // "fakeit_ErrorInput": func() string { return fakeit.ErrorInput().Error() },
"fakeit_ErrorRuntime": func() string { return fakeit.ErrorRuntime().Error() },
// Fakeit / School // Fakeit / School
"fakeit_School": fakeit.School, "fakeit_School": fakeit.School,
@@ -617,68 +541,19 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
"fakeit_SongName": fakeit.SongName, "fakeit_SongName": fakeit.SongName,
"fakeit_SongArtist": fakeit.SongArtist, "fakeit_SongArtist": fakeit.SongArtist,
"fakeit_SongGenre": fakeit.SongGenre, "fakeit_SongGenre": fakeit.SongGenre,
// Captcha / 2Captcha
// Usage: {{ twocaptcha_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com" }}
"twocaptcha_RecaptchaV2": func(apiKey, websiteKey, websiteURL string) (string, error) {
return twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey)
},
// Usage: {{ twocaptcha_RecaptchaV3 "API_KEY" "SITE_KEY" "https://example.com" "action" }}
"twocaptcha_RecaptchaV3": func(apiKey, websiteKey, websiteURL, pageAction string) (string, error) {
return twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction)
},
// Usage: {{ twocaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" }}
// {{ twocaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" "cdata" }}
"twocaptcha_Turnstile": func(apiKey, websiteKey, websiteURL string, cData ...string) (string, error) {
return twoCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, firstOrEmpty(cData))
},
// Captcha / Anti-Captcha
// Usage: {{ anticaptcha_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com" }}
"anticaptcha_RecaptchaV2": func(apiKey, websiteKey, websiteURL string) (string, error) {
return antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey)
},
// Usage: {{ anticaptcha_RecaptchaV3 "API_KEY" "SITE_KEY" "https://example.com" "action" }}
"anticaptcha_RecaptchaV3": func(apiKey, websiteKey, websiteURL, pageAction string) (string, error) {
return antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction)
},
// Usage: {{ anticaptcha_HCaptcha "API_KEY" "SITE_KEY" "https://example.com" }}
"anticaptcha_HCaptcha": func(apiKey, websiteKey, websiteURL string) (string, error) {
return antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey)
},
// Usage: {{ anticaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" }}
// {{ anticaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" "cdata" }}
"anticaptcha_Turnstile": func(apiKey, websiteKey, websiteURL string, cData ...string) (string, error) {
return antiCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, firstOrEmpty(cData))
},
// Captcha / CapSolver
// Usage: {{ capsolver_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com" }}
"capsolver_RecaptchaV2": func(apiKey, websiteKey, websiteURL string) (string, error) {
return capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey)
},
// Usage: {{ capsolver_RecaptchaV3 "API_KEY" "SITE_KEY" "https://example.com" "action" }}
"capsolver_RecaptchaV3": func(apiKey, websiteKey, websiteURL, pageAction string) (string, error) {
return capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction)
},
// Usage: {{ capsolver_Turnstile "API_KEY" "SITE_KEY" "https://example.com" }}
// {{ capsolver_Turnstile "API_KEY" "SITE_KEY" "https://example.com" "cdata" }}
"capsolver_Turnstile": func(apiKey, websiteKey, websiteURL string, cData ...string) (string, error) {
return capSolverSolveTurnstile(apiKey, websiteURL, websiteKey, firstOrEmpty(cData))
},
} }
} }
type BodyTemplateFuncMapData struct { type BodyTemplateFuncMapData struct {
formDataContentType string formDataContenType string
} }
func (data BodyTemplateFuncMapData) GetFormDataContentType() string { func (data BodyTemplateFuncMapData) GetFormDataContenType() string {
return data.formDataContentType return data.formDataContenType
} }
func (data *BodyTemplateFuncMapData) ClearFormDataContentType() { func (data *BodyTemplateFuncMapData) ClearFormDataContenType() {
data.formDataContentType = "" data.formDataContenType = ""
} }
func NewDefaultBodyTemplateFuncMap( func NewDefaultBodyTemplateFuncMap(
@@ -709,7 +584,7 @@ func NewDefaultBodyTemplateFuncMap(
var multipartData bytes.Buffer var multipartData bytes.Buffer
writer := multipart.NewWriter(&multipartData) writer := multipart.NewWriter(&multipartData)
data.formDataContentType = writer.FormDataContentType() data.formDataContenType = writer.FormDataContentType()
for i := 0; i < len(pairs); i += 2 { for i := 0; i < len(pairs); i += 2 {
key := pairs[i] key := pairs[i]

View File

@@ -208,41 +208,8 @@ func (e URLParseError) Unwrap() error {
var ( var (
ErrFileCacheNotInitialized = errors.New("file cache is not initialized") ErrFileCacheNotInitialized = errors.New("file cache is not initialized")
ErrFormDataOddArgs = errors.New("body_FormData requires an even number of arguments (key-value pairs)") ErrFormDataOddArgs = errors.New("body_FormData requires an even number of arguments (key-value pairs)")
ErrJSONObjectOddArgs = errors.New("json_Object requires an even number of arguments (key-value pairs)")
) )
type JSONObjectKeyError struct {
Index int
Value any
}
func NewJSONObjectKeyError(index int, value any) JSONObjectKeyError {
return JSONObjectKeyError{Index: index, Value: value}
}
func (e JSONObjectKeyError) Error() string {
return fmt.Sprintf("json_Object key at index %d must be a string, got %T", e.Index, e.Value)
}
type JSONEncodeError struct {
Err error
}
func NewJSONEncodeError(err error) JSONEncodeError {
if err == nil {
err = errNoError
}
return JSONEncodeError{Err: err}
}
func (e JSONEncodeError) Error() string {
return "json_Encode failed: " + e.Err.Error()
}
func (e JSONEncodeError) Unwrap() error {
return e.Err
}
type TemplateParseError struct { type TemplateParseError struct {
Err error Err error
} }
@@ -475,91 +442,3 @@ func NewScriptUnknownEngineError(engineType string) ScriptUnknownEngineError {
func (e ScriptUnknownEngineError) Error() string { func (e ScriptUnknownEngineError) Error() string {
return "unknown engine type: " + e.EngineType return "unknown engine type: " + e.EngineType
} }
// ======================================== Captcha ========================================
var (
ErrCaptchaKeyEmpty = errors.New("captcha API key cannot be empty")
// ErrCaptchaProcessing is an internal sentinel returned by the captcha solver polling
// code to signal that a task is not yet solved and polling should continue.
// It should never be surfaced to callers outside of the captcha poll loop.
ErrCaptchaProcessing = errors.New("captcha task still processing")
)
type CaptchaAPIError struct {
Endpoint string
Code string
Description string
}
func NewCaptchaAPIError(endpoint, code, description string) CaptchaAPIError {
return CaptchaAPIError{Endpoint: endpoint, Code: code, Description: description}
}
func (e CaptchaAPIError) Error() string {
return fmt.Sprintf("captcha %s error: %s (%s)", e.Endpoint, e.Code, e.Description)
}
type CaptchaRequestError struct {
Endpoint string
Err error
}
func NewCaptchaRequestError(endpoint string, err error) CaptchaRequestError {
if err == nil {
err = errNoError
}
return CaptchaRequestError{Endpoint: endpoint, Err: err}
}
func (e CaptchaRequestError) Error() string {
return fmt.Sprintf("captcha %s request failed: %v", e.Endpoint, e.Err)
}
func (e CaptchaRequestError) Unwrap() error {
return e.Err
}
type CaptchaDecodeError struct {
Endpoint string
Err error
}
func NewCaptchaDecodeError(endpoint string, err error) CaptchaDecodeError {
if err == nil {
err = errNoError
}
return CaptchaDecodeError{Endpoint: endpoint, Err: err}
}
func (e CaptchaDecodeError) Error() string {
return fmt.Sprintf("captcha %s decode failed: %v", e.Endpoint, e.Err)
}
func (e CaptchaDecodeError) Unwrap() error {
return e.Err
}
type CaptchaPollTimeoutError struct {
TaskID string
}
func NewCaptchaPollTimeoutError(taskID string) CaptchaPollTimeoutError {
return CaptchaPollTimeoutError{TaskID: taskID}
}
func (e CaptchaPollTimeoutError) Error() string {
return fmt.Sprintf("captcha solving timed out (taskId: %s)", e.TaskID)
}
type CaptchaSolutionKeyError struct {
Key string
}
func NewCaptchaSolutionKeyError(key string) CaptchaSolutionKeyError {
return CaptchaSolutionKeyError{Key: key}
}
func (e CaptchaSolutionKeyError) Error() string {
return fmt.Sprintf("captcha solution missing expected key %q", e.Key)
}