mirror of
https://github.com/aykhans/sarin.git
synced 2026-08-31 20:25:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fe0587823 | ||
|
|
5b301c42a9
|
||
|
|
d35295c1d9
|
||
|
|
a13d9d11f7
|
||
|
|
3393f9e767 | ||
|
|
2144f8e67d
|
||
|
|
9d1a73dd43 | ||
|
|
f4d13d83aa
|
||
|
|
07e9287c39
|
||
|
|
80a8ba1d59
|
||
|
|
31af2d0d3c | ||
|
|
02b28dba2f | ||
|
|
d7bc0706ca | ||
|
|
6e5c6eb0fe | ||
|
|
a349ffd6dd | ||
|
|
606aec693c | ||
|
|
99e442691d | ||
|
|
11ddabc7d4 | ||
|
|
dd23ea73fd | ||
|
|
c4faac9127 | ||
|
|
7a75a4e096 | ||
|
|
ac6f1a1a65
|
||
|
|
db19bee457 | ||
|
|
89c72e12e2
|
||
|
|
1c36e6fa70 | ||
|
|
b0af20ddf5 | ||
|
|
d23f1a7685 | ||
|
|
4ba612908c
|
||
|
|
f36d52a05d | ||
|
|
ce1f585342 | ||
|
|
a4b1c7c89c | ||
|
|
bdbf3b0b91
|
||
|
|
26f72a53f1 | ||
|
|
b628a999f0 | ||
|
|
b2e6e200bd | ||
|
|
edf82ec7c1
|
@@ -13,10 +13,10 @@ jobs:
|
|||||||
name: lint
|
name: lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: 1.26.2
|
go-version: 1.26.5
|
||||||
- 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.12.2
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.tag || github.ref }}
|
ref: ${{ inputs.tag || github.ref }}
|
||||||
|
|
||||||
@@ -35,11 +35,11 @@ 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.2" >> $GITHUB_ENV
|
echo "GO_VERSION=1.26.5" >> $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
|
||||||
uses: actions/setup-go@v6
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: ${{ env.GO_VERSION }}
|
go-version: ${{ env.GO_VERSION }}
|
||||||
cache: true
|
cache: true
|
||||||
@@ -55,6 +55,8 @@ jobs:
|
|||||||
|
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-amd64 ./cmd/cli/main.go
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-amd64 ./cmd/cli/main.go
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-arm64 ./cmd/cli/main.go
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-arm64 ./cmd/cli/main.go
|
||||||
|
CGO_ENABLED=0 GOOS=freebsd GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-freebsd-amd64 ./cmd/cli/main.go
|
||||||
|
CGO_ENABLED=0 GOOS=freebsd GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-freebsd-arm64 ./cmd/cli/main.go
|
||||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-amd64 ./cmd/cli/main.go
|
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-amd64 ./cmd/cli/main.go
|
||||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-arm64 ./cmd/cli/main.go
|
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-arm64 ./cmd/cli/main.go
|
||||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-amd64.exe ./cmd/cli/main.go
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-amd64.exe ./cmd/cli/main.go
|
||||||
@@ -62,29 +64,29 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload Release Assets
|
- name: Upload Release Assets
|
||||||
if: github.event_name == 'release' || inputs.build_binaries
|
if: github.event_name == 'release' || inputs.build_binaries
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ inputs.tag || github.ref_name }}
|
tag_name: ${{ inputs.tag || github.ref_name }}
|
||||||
files: ./sarin-*
|
files: ./sarin-*
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
if: github.event_name == 'release' || inputs.build_docker
|
if: github.event_name == 'release' || inputs.build_docker
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
if: github.event_name == 'release' || inputs.build_docker
|
if: github.event_name == 'release' || inputs.build_docker
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: github.event_name == 'release' || inputs.build_docker
|
if: github.event_name == 'release' || inputs.build_docker
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
if: github.event_name == 'release' || inputs.build_docker
|
if: github.event_name == 'release' || inputs.build_docker
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
bin/*
|
bin/*
|
||||||
|
|
||||||
|
# Nix build artifacts
|
||||||
|
result
|
||||||
|
result-*
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
ARG GO_VERSION=1.26.2
|
ARG GO_VERSION=1.26.5
|
||||||
|
|
||||||
FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder
|
FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
## Sarin is a high-performance HTTP load testing tool built with Go and fasthttp.
|
## Sarin is a high-performance HTTP load testing tool built with Go and fasthttp.
|
||||||
|
|
||||||
[](https://pkg.go.dev/go.aykhans.me/sarin)
|
[](https://pkg.go.dev/go.aykhans.me/sarin)
|
||||||
[](https://goreportcard.com/report/go.aykhans.me/sarin)
|
[](https://hub.docker.com/r/aykhans/sarin)
|
||||||
|
[](https://github.com/aykhans/sarin/releases)
|
||||||
|
[](https://github.com/aykhans/sarin/actions/workflows/lint.yaml)
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -34,10 +36,11 @@ Sarin is designed for efficient HTTP load testing with minimal resource consumpt
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Docker (Recommended)
|
<details open>
|
||||||
|
<summary><b>Docker</b></summary>
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker pull aykhans/sarin:latest
|
docker run -it aykhans/sarin:latest --version
|
||||||
```
|
```
|
||||||
|
|
||||||
With a local config file:
|
With a local config file:
|
||||||
@@ -52,11 +55,46 @@ With a remote config file:
|
|||||||
docker run --rm -it aykhans/sarin -f https://example.com/config.yaml
|
docker run --rm -it aykhans/sarin -f https://example.com/config.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
### Pre-built Binaries
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Nix</b></summary>
|
||||||
|
|
||||||
|
Run directly without installing (requires flakes enabled):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix run github:aykhans/sarin -- -U http://example.com -r 100 -c 10
|
||||||
|
```
|
||||||
|
|
||||||
|
Install into your profile:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix profile install github:aykhans/sarin
|
||||||
|
```
|
||||||
|
|
||||||
|
Or add it to your own flake via the overlay:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
{
|
||||||
|
inputs.sarin.url = "github:aykhans/sarin";
|
||||||
|
|
||||||
|
# In your outputs, apply the overlay to nixpkgs:
|
||||||
|
# nixpkgs.overlays = [ inputs.sarin.overlays.default ];
|
||||||
|
# then reference pkgs.sarin
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Pre-built Binaries</b></summary>
|
||||||
|
|
||||||
Download the latest binaries from the [releases](https://github.com/aykhans/sarin/releases) page.
|
Download the latest binaries from the [releases](https://github.com/aykhans/sarin/releases) page.
|
||||||
|
|
||||||
### Building from Source
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Building from Source</b></summary>
|
||||||
|
|
||||||
Requires [Go 1.26+](https://golang.org/dl/).
|
Requires [Go 1.26+](https://golang.org/dl/).
|
||||||
|
|
||||||
@@ -72,6 +110,8 @@ CGO_ENABLED=0 go build \
|
|||||||
-o sarin ./cmd/cli/main.go
|
-o sarin ./cmd/cli/main.go
|
||||||
```
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
Send 10,000 GET requests with 50 concurrent connections and a random User-Agent for each request:
|
Send 10,000 GET requests with 50 concurrent connections and a random User-Agent for each request:
|
||||||
|
|||||||
+2
-2
@@ -3,7 +3,7 @@ version: "3"
|
|||||||
|
|
||||||
vars:
|
vars:
|
||||||
BIN_DIR: ./bin
|
BIN_DIR: ./bin
|
||||||
GOLANGCI_LINT_VERSION: v2.11.4
|
GOLANGCI_LINT_VERSION: v2.12.2
|
||||||
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
|
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
@@ -74,7 +74,7 @@ tasks:
|
|||||||
- test -f {{.GOLANGCI}}
|
- test -f {{.GOLANGCI}}
|
||||||
cmds:
|
cmds:
|
||||||
- rm -f {{.GOLANGCI}}
|
- rm -f {{.GOLANGCI}}
|
||||||
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b {{.BIN_DIR}} {{.GOLANGCI_LINT_VERSION}}
|
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/main/install.sh | sh -s -- -b {{.BIN_DIR}} {{.GOLANGCI_LINT_VERSION}}
|
||||||
- mv {{.BIN_DIR}}/golangci-lint {{.GOLANGCI}}
|
- mv {{.BIN_DIR}}/golangci-lint {{.GOLANGCI}}
|
||||||
|
|
||||||
docker-build:
|
docker-build:
|
||||||
|
|||||||
+2
-2
@@ -50,10 +50,10 @@ func main() {
|
|||||||
ctx,
|
ctx,
|
||||||
combinedConfig.Methods, combinedConfig.URL, *combinedConfig.Timeout,
|
combinedConfig.Methods, combinedConfig.URL, *combinedConfig.Timeout,
|
||||||
*combinedConfig.Concurrency, combinedConfig.Requests, combinedConfig.Duration,
|
*combinedConfig.Concurrency, combinedConfig.Requests, combinedConfig.Duration,
|
||||||
*combinedConfig.Quiet, *combinedConfig.Insecure, combinedConfig.Params, combinedConfig.Headers,
|
*combinedConfig.Progress == config.ConfigProgressTypeBar, *combinedConfig.Insecure, combinedConfig.Params, combinedConfig.Headers,
|
||||||
combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values,
|
combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values,
|
||||||
*combinedConfig.Output != config.ConfigOutputTypeNone,
|
*combinedConfig.Output != config.ConfigOutputTypeNone,
|
||||||
*combinedConfig.DryRun,
|
*combinedConfig.DryRun, *combinedConfig.LogLevel, *combinedConfig.LogFile,
|
||||||
combinedConfig.Lua, combinedConfig.Js,
|
combinedConfig.Lua, combinedConfig.Js,
|
||||||
)
|
)
|
||||||
_ = utilsErr.MustHandle(err,
|
_ = utilsErr.MustHandle(err,
|
||||||
|
|||||||
+60
-39
@@ -26,7 +26,9 @@ Use `-s` or `--show-config` to see the final merged configuration before sending
|
|||||||
| [Concurrency](#concurrency) | `concurrency`<br>(number) | `-concurrency` / `-c`<br>(number) | `SARIN_CONCURRENCY`<br>(number) | `1` | Number of concurrent workers |
|
| [Concurrency](#concurrency) | `concurrency`<br>(number) | `-concurrency` / `-c`<br>(number) | `SARIN_CONCURRENCY`<br>(number) | `1` | Number of concurrent workers |
|
||||||
| [Requests](#requests) | `requests`<br>(number) | `-requests` / `-r`<br>(number) | `SARIN_REQUESTS`<br>(number) | - | Total requests to send |
|
| [Requests](#requests) | `requests`<br>(number) | `-requests` / `-r`<br>(number) | `SARIN_REQUESTS`<br>(number) | - | Total requests to send |
|
||||||
| [Duration](#duration) | `duration`<br>(duration) | `-duration` / `-d`<br>(duration) | `SARIN_DURATION`<br>(duration) | - | Test duration |
|
| [Duration](#duration) | `duration`<br>(duration) | `-duration` / `-d`<br>(duration) | `SARIN_DURATION`<br>(duration) | - | Test duration |
|
||||||
| [Quiet](#quiet) | `quiet`<br>(boolean) | `-quiet` / `-q`<br>(boolean) | `SARIN_QUIET`<br>(boolean) | `false` | Hide progress bar and logs |
|
| [Log Level](#log-level) | `logLevel`<br>(string) | `-log-level` / `-l`<br>(string) | `SARIN_LOG_LEVEL`<br>(string) | `error` | Runtime log levels to emit |
|
||||||
|
| [Log File](#log-file) | `logFile`<br>(string) | `-log-file` / `-w`<br>(string) | `SARIN_LOG_FILE`<br>(string) | - | Write runtime logs to a file |
|
||||||
|
| [Progress](#progress) | `progress`<br>(string) | `-progress` / `-p`<br>(string) | `SARIN_PROGRESS`<br>(string) | `bar` | Progress display (bar/none) |
|
||||||
| [Output](#output) | `output`<br>(string) | `-output` / `-o`<br>(string) | `SARIN_OUTPUT`<br>(string) | `table` | Output format for stats |
|
| [Output](#output) | `output`<br>(string) | `-output` / `-o`<br>(string) | `SARIN_OUTPUT`<br>(string) | `table` | Output format for stats |
|
||||||
| [Dry Run](#dry-run) | `dryRun`<br>(boolean) | `-dry-run` / `-z`<br>(boolean) | `SARIN_DRY_RUN`<br>(boolean) | `false` | Generate without sending |
|
| [Dry Run](#dry-run) | `dryRun`<br>(boolean) | `-dry-run` / `-z`<br>(boolean) | `SARIN_DRY_RUN`<br>(boolean) | `false` | Generate without sending |
|
||||||
| [Insecure](#insecure) | `insecure`<br>(boolean) | `-insecure` / `-I`<br>(boolean) | `SARIN_INSECURE`<br>(boolean) | `false` | Skip TLS verification |
|
| [Insecure](#insecure) | `insecure`<br>(boolean) | `-insecure` / `-I`<br>(boolean) | `SARIN_INSECURE`<br>(boolean) | `false` | Skip TLS verification |
|
||||||
@@ -131,7 +133,7 @@ sarin -U "http://example.com/users/{{ fakeit_UUID }}" -r 1000 -c 10
|
|||||||
|
|
||||||
## Method
|
## Method
|
||||||
|
|
||||||
HTTP method(s). If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
|
HTTP method(s). Defaults to `GET`. If multiple values are provided, Sarin starts at a random index and cycles through them in order. Once the cycle completes, it picks a new random starting point. Supports [templating](templating.md).
|
||||||
|
|
||||||
**YAML example:**
|
**YAML example:**
|
||||||
|
|
||||||
@@ -141,9 +143,9 @@ method: GET
|
|||||||
# OR
|
# OR
|
||||||
|
|
||||||
method:
|
method:
|
||||||
- GET
|
- GET
|
||||||
- POST
|
- POST
|
||||||
- PUT
|
- PUT
|
||||||
```
|
```
|
||||||
|
|
||||||
**CLI example:**
|
**CLI example:**
|
||||||
@@ -160,7 +162,7 @@ SARIN_METHOD=GET
|
|||||||
|
|
||||||
## Timeout
|
## Timeout
|
||||||
|
|
||||||
Request timeout. Must be greater than 0.
|
Request timeout. Must be greater than 0. Defaults to `10s`.
|
||||||
|
|
||||||
Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
|
Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
|
||||||
|
|
||||||
@@ -168,7 +170,7 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
|
|||||||
|
|
||||||
## Concurrency
|
## Concurrency
|
||||||
|
|
||||||
Number of concurrent workers. Must be between 1 and 100,000,000.
|
Number of concurrent workers. Must be between 1 and 100,000,000. Defaults to `1`.
|
||||||
|
|
||||||
## Requests
|
## Requests
|
||||||
|
|
||||||
@@ -182,15 +184,34 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
|
|||||||
|
|
||||||
**Examples:** `1m30s`, `25s`, `1h`
|
**Examples:** `1m30s`, `25s`, `1h`
|
||||||
|
|
||||||
## Quiet
|
## Log Level
|
||||||
|
|
||||||
Hide the progress bar and runtime logs.
|
Runtime log levels to emit, comma-separated. Valid levels: `info`, `error`. Defaults to `error`.
|
||||||
|
|
||||||
|
- `error`: errors that occur while generating or sending a request
|
||||||
|
- `info`: every completed response
|
||||||
|
|
||||||
|
Leave empty to disable logging entirely.
|
||||||
|
|
||||||
|
**Examples:** `error` (only errors), `info` (only responses), `info,error` (both)
|
||||||
|
|
||||||
|
## Log File
|
||||||
|
|
||||||
|
Write runtime logs to this file instead of the terminal or stderr. The parent directory must exist.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com -r 1000 --log-file ./run.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
Progress display. Valid values: `bar` (default), `none`. Use `none` to hide the progress bar.
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
Output format for response statistics.
|
Output format for response statistics.
|
||||||
|
|
||||||
Valid formats: `table`, `json`, `yaml`, `none`
|
Valid formats: `table` (default), `json`, `yaml`, `none`
|
||||||
|
|
||||||
Using `none` disables output and reduces memory usage since response statistics are not stored.
|
Using `none` disables output and reduces memory usage since response statistics are not stored.
|
||||||
|
|
||||||
@@ -204,7 +225,7 @@ Skip TLS certificate verification.
|
|||||||
|
|
||||||
## Body
|
## Body
|
||||||
|
|
||||||
Request body. If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
|
Request body. If multiple values are provided, Sarin starts at a random index and cycles through them in order. Once the cycle completes, it picks a new random starting point. Supports [templating](templating.md).
|
||||||
|
|
||||||
**YAML example:**
|
**YAML example:**
|
||||||
|
|
||||||
@@ -214,9 +235,9 @@ body: '{"product": "car"}'
|
|||||||
# OR
|
# OR
|
||||||
|
|
||||||
body:
|
body:
|
||||||
- '{"product": "car"}'
|
- '{"product": "car"}'
|
||||||
- '{"product": "phone"}'
|
- '{"product": "phone"}'
|
||||||
- '{"product": "watch"}'
|
- '{"product": "watch"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
**CLI example:**
|
**CLI example:**
|
||||||
@@ -241,19 +262,19 @@ When the same key appears as **separate entries** (in CLI or config file), all v
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
params:
|
params:
|
||||||
key1: value1
|
key1: value1
|
||||||
key2: [value2, value3] # cycles between value2 and value3
|
key2: [value2, value3] # cycles between value2 and value3
|
||||||
|
|
||||||
# OR
|
# OR
|
||||||
|
|
||||||
params:
|
params:
|
||||||
- key1: value1
|
- key1: value1
|
||||||
- key2: [value2, value3] # cycles between value2 and value3
|
- key2: [value2, value3] # cycles between value2 and value3
|
||||||
|
|
||||||
# To send both values in every request, use separate entries:
|
# To send both values in every request, use separate entries:
|
||||||
params:
|
params:
|
||||||
- key2: value2
|
- key2: value2
|
||||||
- key2: value3 # both sent in every request
|
- key2: value3 # both sent in every request
|
||||||
```
|
```
|
||||||
|
|
||||||
**CLI example:**
|
**CLI example:**
|
||||||
@@ -278,19 +299,19 @@ When the same key appears as **separate entries** (in CLI or config file), all v
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
headers:
|
headers:
|
||||||
key1: value1
|
key1: value1
|
||||||
key2: [value2, value3] # cycles between value2 and value3
|
key2: [value2, value3] # cycles between value2 and value3
|
||||||
|
|
||||||
# OR
|
# OR
|
||||||
|
|
||||||
headers:
|
headers:
|
||||||
- key1: value1
|
- key1: value1
|
||||||
- key2: [value2, value3] # cycles between value2 and value3
|
- key2: [value2, value3] # cycles between value2 and value3
|
||||||
|
|
||||||
# To send both values in every request, use separate entries:
|
# To send both values in every request, use separate entries:
|
||||||
headers:
|
headers:
|
||||||
- key2: value2
|
- key2: value2
|
||||||
- key2: value3 # both sent in every request
|
- key2: value3 # both sent in every request
|
||||||
```
|
```
|
||||||
|
|
||||||
**CLI example:**
|
**CLI example:**
|
||||||
@@ -315,19 +336,19 @@ When the same key appears as **separate entries** (in CLI or config file), all v
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
cookies:
|
cookies:
|
||||||
key1: value1
|
key1: value1
|
||||||
key2: [value2, value3] # cycles between value2 and value3
|
key2: [value2, value3] # cycles between value2 and value3
|
||||||
|
|
||||||
# OR
|
# OR
|
||||||
|
|
||||||
cookies:
|
cookies:
|
||||||
- key1: value1
|
- key1: value1
|
||||||
- key2: [value2, value3] # cycles between value2 and value3
|
- key2: [value2, value3] # cycles between value2 and value3
|
||||||
|
|
||||||
# To send both values in every request, use separate entries:
|
# To send both values in every request, use separate entries:
|
||||||
cookies:
|
cookies:
|
||||||
- key2: value2
|
- key2: value2
|
||||||
- key2: value3 # both sent in every request
|
- key2: value3 # both sent in every request
|
||||||
```
|
```
|
||||||
|
|
||||||
**CLI example:**
|
**CLI example:**
|
||||||
@@ -344,7 +365,7 @@ SARIN_COOKIE="key1=value1"
|
|||||||
|
|
||||||
## Proxy
|
## Proxy
|
||||||
|
|
||||||
Proxy URL(s). If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request.
|
Proxy URL(s). If multiple values are provided, Sarin starts at a random index and cycles through them in order. Once the cycle completes, it picks a new random starting point.
|
||||||
|
|
||||||
Supported protocols: `http`, `https`, `socks5`, `socks5h`
|
Supported protocols: `http`, `https`, `socks5`, `socks5h`
|
||||||
|
|
||||||
@@ -356,9 +377,9 @@ proxy: http://proxy1.com
|
|||||||
# OR
|
# OR
|
||||||
|
|
||||||
proxy:
|
proxy:
|
||||||
- http://proxy1.com
|
- http://proxy1.com
|
||||||
- socks5://proxy2.com
|
- socks5://proxy2.com
|
||||||
- socks5h://proxy3.com
|
- socks5h://proxy3.com
|
||||||
```
|
```
|
||||||
|
|
||||||
**CLI example:**
|
**CLI example:**
|
||||||
@@ -387,9 +408,9 @@ values: "key=value"
|
|||||||
# OR
|
# OR
|
||||||
|
|
||||||
values: |
|
values: |
|
||||||
key1=value1
|
key1=value1
|
||||||
key2=value2
|
key2=value2
|
||||||
key3=value3
|
key3=value3
|
||||||
```
|
```
|
||||||
|
|
||||||
**CLI example:**
|
**CLI example:**
|
||||||
|
|||||||
+40
-4
@@ -13,6 +13,7 @@ This guide provides practical examples for common Sarin use cases.
|
|||||||
- [File Uploads](#file-uploads)
|
- [File Uploads](#file-uploads)
|
||||||
- [Using Proxies](#using-proxies)
|
- [Using Proxies](#using-proxies)
|
||||||
- [Output Formats](#output-formats)
|
- [Output Formats](#output-formats)
|
||||||
|
- [Runtime Logging](#runtime-logging)
|
||||||
- [Docker Usage](#docker-usage)
|
- [Docker Usage](#docker-usage)
|
||||||
- [Dry Run Mode](#dry-run-mode)
|
- [Dry Run Mode](#dry-run-mode)
|
||||||
- [Show Configuration](#show-configuration)
|
- [Show Configuration](#show-configuration)
|
||||||
@@ -719,7 +720,7 @@ proxy: socks5://proxy.example.com:1080
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
**Multiple proxies (load balanced):**
|
**Multiple proxies (randomly cycled):**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sarin -U http://example.com -r 1000 -c 10 \
|
sarin -U http://example.com -r 1000 -c 10 \
|
||||||
@@ -836,10 +837,10 @@ output: none
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
**Quiet mode (hide progress bar):**
|
**Hide the progress bar:**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sarin -U http://example.com -r 1000 -c 10 -q
|
sarin -U http://example.com -r 1000 -c 10 -p none
|
||||||
```
|
```
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -849,7 +850,42 @@ sarin -U http://example.com -r 1000 -c 10 -q
|
|||||||
url: http://example.com
|
url: http://example.com
|
||||||
requests: 1000
|
requests: 1000
|
||||||
concurrency: 10
|
concurrency: 10
|
||||||
quiet: true
|
progress: none
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Runtime Logging
|
||||||
|
|
||||||
|
`--log-level` selects which runtime logs Sarin emits (comma-separated `info` and `error`, default `error`). `error` covers request and generation errors, `info` covers every completed response (status, duration, headers, body). Logs appear in the progress log box on an interactive terminal, go to stderr when piped, or go to a file with `--log-file`.
|
||||||
|
|
||||||
|
**Log responses and errors:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com -r 1000 -c 10 -l info,error
|
||||||
|
```
|
||||||
|
|
||||||
|
**Write logs to a file (the progress bar stays on screen):**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com -r 1000 -c 10 -l info --log-file ./run.log
|
||||||
|
```
|
||||||
|
|
||||||
|
**Capture logs while keeping results on stdout:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com -r 1000 -l info -o json > stats.json 2> run.log
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
logLevel: info,error
|
||||||
|
logFile: ./run.log
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
+12
-12
@@ -183,19 +183,19 @@ body: '{{ body_FormData "image" "@https://example.com/photo.jpg" }}'
|
|||||||
|
|
||||||
# Mixed text fields and files
|
# Mixed text fields and files
|
||||||
body: |
|
body: |
|
||||||
{{ body_FormData
|
{{ body_FormData
|
||||||
"title" "My Report"
|
"title" "My Report"
|
||||||
"author" "John Doe"
|
"author" "John Doe"
|
||||||
"cover" "@/path/to/cover.jpg"
|
"cover" "@/path/to/cover.jpg"
|
||||||
"document" "@/path/to/report.pdf"
|
"document" "@/path/to/report.pdf"
|
||||||
}}
|
}}
|
||||||
|
|
||||||
# Multiple files with same field name
|
# Multiple files with same field name
|
||||||
body: |
|
body: |
|
||||||
{{ body_FormData
|
{{ body_FormData
|
||||||
"files" "@/path/to/file1.pdf"
|
"files" "@/path/to/file1.pdf"
|
||||||
"files" "@/path/to/file2.pdf"
|
"files" "@/path/to/file2.pdf"
|
||||||
}}
|
}}
|
||||||
|
|
||||||
# Escape @ for literal value (sends "@username")
|
# Escape @ for literal value (sends "@username")
|
||||||
body: '{{ body_FormData "twitter" "@@username" }}'
|
body: '{{ body_FormData "twitter" "@@username" }}'
|
||||||
@@ -226,7 +226,7 @@ body: '{"file": "{{ file_Base64 "/path/to/document.pdf" }}", "filename": "docume
|
|||||||
body: '{"image": "{{ file_Base64 "https://example.com/photo.jpg" }}"}'
|
body: '{"image": "{{ file_Base64 "https://example.com/photo.jpg" }}"}'
|
||||||
|
|
||||||
# Combined with values for reuse
|
# Combined with values for reuse
|
||||||
values: "FILE_DATA={{ file_Base64 \"/path/to/file.bin\" }}"
|
values: 'FILE_DATA={{ file_Base64 "/path/to/file.bin" }}'
|
||||||
body: '{"data": "{{ .Values.FILE_DATA }}"}'
|
body: '{"data": "{{ .Values.FILE_DATA }}"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -234,7 +234,7 @@ body: '{"data": "{{ .Values.FILE_DATA }}"}'
|
|||||||
|
|
||||||
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.
|
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.
|
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:**
|
> **Important: performance and cost:**
|
||||||
>
|
>
|
||||||
|
|||||||
Generated
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1784796856,
|
||||||
|
"narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
description = "Sarin - high-performance HTTP load testing tool built with Go and fasthttp";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs }:
|
||||||
|
let
|
||||||
|
inherit (nixpkgs) lib;
|
||||||
|
|
||||||
|
systems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
"x86_64-darwin"
|
||||||
|
"aarch64-darwin"
|
||||||
|
];
|
||||||
|
|
||||||
|
forAllSystems = f: lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||||
|
|
||||||
|
rev = self.rev or self.dirtyRev or "unknown";
|
||||||
|
|
||||||
|
# self.lastModifiedDate is "YYYYMMDDHHMMSS"; reshape to RFC 3339 (UTC).
|
||||||
|
raw = self.lastModifiedDate or "19700101000000";
|
||||||
|
s = i: n: lib.substring i n raw;
|
||||||
|
buildDate = "${s 0 4}-${s 4 2}-${s 6 2}T${s 8 2}:${s 10 2}:${s 12 2}Z";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = forAllSystems (pkgs: {
|
||||||
|
default = pkgs.callPackage ./nix/package.nix {
|
||||||
|
inherit rev buildDate;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
# nix run github:aykhans/sarin -- <args>
|
||||||
|
apps = forAllSystems (pkgs: {
|
||||||
|
default = {
|
||||||
|
type = "app";
|
||||||
|
program = "${self.packages.${pkgs.system}.default}/bin/sarin";
|
||||||
|
meta.description = "High-performance HTTP load testing tool";
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
devShells = forAllSystems (pkgs: {
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
packages = [ pkgs.go_1_26 pkgs.golangci-lint pkgs.go-task ];
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
# For downstream flakes: inputs.sarin.overlays.default
|
||||||
|
overlays.default = final: _prev: {
|
||||||
|
sarin = final.callPackage ./nix/package.nix { };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,46 +1,46 @@
|
|||||||
module go.aykhans.me/sarin
|
module go.aykhans.me/sarin
|
||||||
|
|
||||||
go 1.26.2
|
go 1.26.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/brianvoe/gofakeit/v7 v7.14.1
|
github.com/brianvoe/gofakeit/v7 v7.15.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 v1.0.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-20260723142020-b4aef50fa347
|
||||||
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.72.0
|
||||||
github.com/yuin/gopher-lua v1.1.2
|
github.com/yuin/gopher-lua v1.1.2
|
||||||
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.6
|
||||||
golang.org/x/net v0.53.0
|
golang.org/x/net v0.57.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
|
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
github.com/andybalholm/brotli v1.2.2 // 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.3 // 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.7 // 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-20260726004341-482a56510f1b // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
github.com/clipperhouse/uax29/v2 v2.7.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/v2 v2.5.2 // 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.4+incompatible // indirect
|
||||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
|
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 // 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.19.1 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.24 // 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.27 // 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 +50,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.8.4 // 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.47.0 // indirect
|
||||||
golang.org/x/term v0.42.0 // indirect
|
golang.org/x/term v0.45.0 // indirect
|
||||||
golang.org/x/text v0.36.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
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.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||||
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||||
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.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.2/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.15.0 h1:kGLYAWN8tnmxq2PelKVK6zwpM7kMxdz9SGPH31mFkNs=
|
||||||
github.com/brianvoe/gofakeit/v7 v7.14.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
github.com/brianvoe/gofakeit/v7 v7.15.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=
|
||||||
@@ -28,14 +28,14 @@ github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG
|
|||||||
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=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
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-20260726004341-482a56510f1b h1:VKASlRztdxT172siF4nJCwD1C+3mbveX41BiA96fIpA=
|
||||||
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-20260726004341-482a56510f1b/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.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
@@ -44,33 +44,35 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
|
|||||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
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/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0=
|
||||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
|
github.com/dop251/goja v0.0.0-20260723142020-b4aef50fa347 h1:RZr+96+PKQjn444QL1K9MtncwJ/PwfE+3TJLCYJL8es=
|
||||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
github.com/dop251/goja v0.0.0-20260723142020-b4aef50fa347/go.mod h1:LiIEzozrcvNXorsG/3+ypGqdTUAqZryhzSsqi0oU/Qg=
|
||||||
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.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw=
|
||||||
|
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||||
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.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.4.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.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
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.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0=
|
||||||
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||||
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,35 +93,32 @@ 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.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M=
|
||||||
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
|
github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk=
|
||||||
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.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
|
||||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.8.4/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.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
||||||
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
||||||
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.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||||
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.6/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.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||||
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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
|
||||||
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.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
+22
-7
@@ -27,7 +27,9 @@ Flags:
|
|||||||
-c, -concurrency uint Number of concurrent requests (default %d)
|
-c, -concurrency uint Number of concurrent requests (default %d)
|
||||||
-r, -requests uint Number of total requests
|
-r, -requests uint Number of total requests
|
||||||
-d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h)
|
-d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h)
|
||||||
-q, -quiet bool Hide the progress bar and runtime logs (default %v)
|
-l, -log-level string Runtime log levels to emit, comma-separated (possible values: info, error) (default %s)
|
||||||
|
-w, -log-file string Write runtime logs to this file instead of the terminal/stderr
|
||||||
|
-p, -progress string Progress display (possible values: bar, none) (default '%v')
|
||||||
-o, -output string Output format (possible values: table, json, yaml, none) (default '%v')
|
-o, -output string Output format (possible values: table, json, yaml, none) (default '%v')
|
||||||
-z, -dry-run bool Run without sending requests (default %v)
|
-z, -dry-run bool Run without sending requests (default %v)
|
||||||
|
|
||||||
@@ -88,7 +90,9 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
concurrency uint
|
concurrency uint
|
||||||
requestCount uint64
|
requestCount uint64
|
||||||
duration time.Duration
|
duration time.Duration
|
||||||
quiet bool
|
logLevel string
|
||||||
|
logFile string
|
||||||
|
progress string
|
||||||
output string
|
output string
|
||||||
dryRun bool
|
dryRun bool
|
||||||
|
|
||||||
@@ -127,8 +131,14 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
flagSet.DurationVar(&duration, "duration", 0, "Maximum duration for the test")
|
flagSet.DurationVar(&duration, "duration", 0, "Maximum duration for the test")
|
||||||
flagSet.DurationVar(&duration, "d", 0, "Maximum duration for the test")
|
flagSet.DurationVar(&duration, "d", 0, "Maximum duration for the test")
|
||||||
|
|
||||||
flagSet.BoolVar(&quiet, "quiet", false, "Hide the progress bar and runtime logs")
|
flagSet.StringVar(&logLevel, "log-level", "", "Runtime log levels to emit, comma-separated (possible values: info, error)")
|
||||||
flagSet.BoolVar(&quiet, "q", false, "Hide the progress bar and runtime logs")
|
flagSet.StringVar(&logLevel, "l", "", "Runtime log levels to emit, comma-separated (possible values: info, error)")
|
||||||
|
|
||||||
|
flagSet.StringVar(&logFile, "log-file", "", "Write runtime logs to this file instead of the terminal/stderr")
|
||||||
|
flagSet.StringVar(&logFile, "w", "", "Write runtime logs to this file instead of the terminal/stderr")
|
||||||
|
|
||||||
|
flagSet.StringVar(&progress, "progress", "", "Progress display (possible values: bar, none)")
|
||||||
|
flagSet.StringVar(&progress, "p", "", "Progress display (possible values: bar, none)")
|
||||||
|
|
||||||
flagSet.StringVar(&output, "output", "", "Output format (possible values: table, json, yaml, none)")
|
flagSet.StringVar(&output, "output", "", "Output format (possible values: table, json, yaml, none)")
|
||||||
flagSet.StringVar(&output, "o", "", "Output format (possible values: table, json, yaml, none)")
|
flagSet.StringVar(&output, "o", "", "Output format (possible values: table, json, yaml, none)")
|
||||||
@@ -205,8 +215,12 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
config.Requests = new(requestCount)
|
config.Requests = new(requestCount)
|
||||||
case "duration", "d":
|
case "duration", "d":
|
||||||
config.Duration = new(duration)
|
config.Duration = new(duration)
|
||||||
case "quiet", "q":
|
case "log-level", "l":
|
||||||
config.Quiet = new(quiet)
|
config.LogLevel = new(logLevel)
|
||||||
|
case "log-file", "w":
|
||||||
|
config.LogFile = new(logFile)
|
||||||
|
case "progress", "p":
|
||||||
|
config.Progress = new(ConfigProgressType(progress))
|
||||||
case "output", "o":
|
case "output", "o":
|
||||||
config.Output = new(ConfigOutputType(output))
|
config.Output = new(ConfigOutputType(output))
|
||||||
case "dry-run", "z":
|
case "dry-run", "z":
|
||||||
@@ -265,7 +279,8 @@ func (parser ConfigCLIParser) PrintHelp() {
|
|||||||
cliUsageText+"\n",
|
cliUsageText+"\n",
|
||||||
Defaults.ShowConfig,
|
Defaults.ShowConfig,
|
||||||
Defaults.Concurrency,
|
Defaults.Concurrency,
|
||||||
Defaults.Quiet,
|
Defaults.LogLevel,
|
||||||
|
Defaults.Progress,
|
||||||
Defaults.Output,
|
Defaults.Output,
|
||||||
Defaults.DryRun,
|
Defaults.DryRun,
|
||||||
|
|
||||||
|
|||||||
+108
-30
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
"github.com/charmbracelet/glamour/styles"
|
"github.com/charmbracelet/glamour/styles"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/charmbracelet/x/term"
|
"github.com/charmbracelet/x/term"
|
||||||
|
"go.aykhans.me/sarin/internal/sarin"
|
||||||
"go.aykhans.me/sarin/internal/script"
|
"go.aykhans.me/sarin/internal/script"
|
||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
"go.aykhans.me/sarin/internal/version"
|
"go.aykhans.me/sarin/internal/version"
|
||||||
@@ -31,25 +33,28 @@ var Defaults = struct {
|
|||||||
RequestTimeout time.Duration
|
RequestTimeout time.Duration
|
||||||
Concurrency uint
|
Concurrency uint
|
||||||
ShowConfig bool
|
ShowConfig bool
|
||||||
Quiet bool
|
Progress ConfigProgressType
|
||||||
Insecure bool
|
Insecure bool
|
||||||
Output ConfigOutputType
|
Output ConfigOutputType
|
||||||
DryRun bool
|
DryRun bool
|
||||||
|
LogLevel string
|
||||||
}{
|
}{
|
||||||
UserAgent: "Sarin/" + version.Version,
|
UserAgent: "Sarin/" + version.Version,
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
RequestTimeout: time.Second * 10,
|
RequestTimeout: time.Second * 10,
|
||||||
Concurrency: 1,
|
Concurrency: 1,
|
||||||
ShowConfig: false,
|
ShowConfig: false,
|
||||||
Quiet: false,
|
Progress: ConfigProgressTypeBar,
|
||||||
Insecure: false,
|
Insecure: false,
|
||||||
Output: ConfigOutputTypeTable,
|
Output: ConfigOutputTypeTable,
|
||||||
DryRun: false,
|
DryRun: false,
|
||||||
|
LogLevel: "error",
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ValidProxySchemes = []string{"http", "https", "socks5", "socks5h"}
|
ValidProxySchemes = []string{"http", "https", "socks5", "socks5h"}
|
||||||
ValidRequestURLSchemes = []string{"http", "https"}
|
ValidRequestURLSchemes = []string{"http", "https"}
|
||||||
|
ValidLogLevels = []string{"info", "error"}
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -70,27 +75,36 @@ var (
|
|||||||
ConfigOutputTypeNone ConfigOutputType = "none"
|
ConfigOutputTypeNone ConfigOutputType = "none"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ConfigProgressType string
|
||||||
|
|
||||||
|
var (
|
||||||
|
ConfigProgressTypeBar ConfigProgressType = "bar"
|
||||||
|
ConfigProgressTypeNone ConfigProgressType = "none"
|
||||||
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ShowConfig *bool `yaml:"showConfig,omitempty"`
|
ShowConfig *bool `yaml:"showConfig,omitempty"`
|
||||||
Files []types.ConfigFile `yaml:"files,omitempty"`
|
Files []types.ConfigFile `yaml:"files,omitempty"`
|
||||||
Methods []string `yaml:"methods,omitempty"`
|
Methods []string `yaml:"methods,omitempty"`
|
||||||
URL *url.URL `yaml:"url,omitempty"`
|
URL *url.URL `yaml:"url,omitempty"`
|
||||||
Timeout *time.Duration `yaml:"timeout,omitempty"`
|
Timeout *time.Duration `yaml:"timeout,omitempty"`
|
||||||
Concurrency *uint `yaml:"concurrency,omitempty"`
|
Concurrency *uint `yaml:"concurrency,omitempty"`
|
||||||
Requests *uint64 `yaml:"requests,omitempty"`
|
Requests *uint64 `yaml:"requests,omitempty"`
|
||||||
Duration *time.Duration `yaml:"duration,omitempty"`
|
Duration *time.Duration `yaml:"duration,omitempty"`
|
||||||
Quiet *bool `yaml:"quiet,omitempty"`
|
Progress *ConfigProgressType `yaml:"progress,omitempty"`
|
||||||
Output *ConfigOutputType `yaml:"output,omitempty"`
|
Output *ConfigOutputType `yaml:"output,omitempty"`
|
||||||
Insecure *bool `yaml:"insecure,omitempty"`
|
Insecure *bool `yaml:"insecure,omitempty"`
|
||||||
DryRun *bool `yaml:"dryRun,omitempty"`
|
DryRun *bool `yaml:"dryRun,omitempty"`
|
||||||
Params types.Params `yaml:"params,omitempty"`
|
Params types.Params `yaml:"params,omitempty"`
|
||||||
Headers types.Headers `yaml:"headers,omitempty"`
|
Headers types.Headers `yaml:"headers,omitempty"`
|
||||||
Cookies types.Cookies `yaml:"cookies,omitempty"`
|
Cookies types.Cookies `yaml:"cookies,omitempty"`
|
||||||
Bodies []string `yaml:"bodies,omitempty"`
|
Bodies []string `yaml:"bodies,omitempty"`
|
||||||
Proxies types.Proxies `yaml:"proxies,omitempty"`
|
Proxies types.Proxies `yaml:"proxies,omitempty"`
|
||||||
Values []string `yaml:"values,omitempty"`
|
Values []string `yaml:"values,omitempty"`
|
||||||
Lua []string `yaml:"lua,omitempty"`
|
Lua []string `yaml:"lua,omitempty"`
|
||||||
Js []string `yaml:"js,omitempty"`
|
Js []string `yaml:"js,omitempty"`
|
||||||
|
LogLevel *string `yaml:"logLevel,omitempty"`
|
||||||
|
LogFile *string `yaml:"logFile,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (config Config) MarshalYAML() (any, error) {
|
func (config Config) MarshalYAML() (any, error) {
|
||||||
@@ -173,8 +187,8 @@ func (config Config) MarshalYAML() (any, error) {
|
|||||||
if config.Duration != nil {
|
if config.Duration != nil {
|
||||||
addField(content, "duration", toNode(*config.Duration), "")
|
addField(content, "duration", toNode(*config.Duration), "")
|
||||||
}
|
}
|
||||||
if config.Quiet != nil {
|
if config.Progress != nil {
|
||||||
addField(content, "quiet", toNode(*config.Quiet), "")
|
addField(content, "progress", toNode(string(*config.Progress)), "")
|
||||||
}
|
}
|
||||||
if config.Output != nil {
|
if config.Output != nil {
|
||||||
addField(content, "output", toNode(string(*config.Output)), "")
|
addField(content, "output", toNode(string(*config.Output)), "")
|
||||||
@@ -221,6 +235,13 @@ func (config Config) MarshalYAML() (any, error) {
|
|||||||
addStringSlice(content, "values", config.Values, false)
|
addStringSlice(content, "values", config.Values, false)
|
||||||
addStringSlice(content, "lua", config.Lua, false)
|
addStringSlice(content, "lua", config.Lua, false)
|
||||||
addStringSlice(content, "js", config.Js, false)
|
addStringSlice(content, "js", config.Js, false)
|
||||||
|
if config.LogLevel != nil {
|
||||||
|
addField(content, "logLevel", toNode(*config.LogLevel), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.LogFile != nil {
|
||||||
|
addField(content, "logFile", toNode(*config.LogFile), "")
|
||||||
|
}
|
||||||
|
|
||||||
return root, nil
|
return root, nil
|
||||||
}
|
}
|
||||||
@@ -295,8 +316,8 @@ func (config *Config) Merge(newConfig *Config) {
|
|||||||
if newConfig.ShowConfig != nil {
|
if newConfig.ShowConfig != nil {
|
||||||
config.ShowConfig = newConfig.ShowConfig
|
config.ShowConfig = newConfig.ShowConfig
|
||||||
}
|
}
|
||||||
if newConfig.Quiet != nil {
|
if newConfig.Progress != nil {
|
||||||
config.Quiet = newConfig.Quiet
|
config.Progress = newConfig.Progress
|
||||||
}
|
}
|
||||||
if newConfig.Output != nil {
|
if newConfig.Output != nil {
|
||||||
config.Output = newConfig.Output
|
config.Output = newConfig.Output
|
||||||
@@ -331,6 +352,12 @@ func (config *Config) Merge(newConfig *Config) {
|
|||||||
if len(newConfig.Js) != 0 {
|
if len(newConfig.Js) != 0 {
|
||||||
config.Js = append(config.Js, newConfig.Js...)
|
config.Js = append(config.Js, newConfig.Js...)
|
||||||
}
|
}
|
||||||
|
if newConfig.LogLevel != nil {
|
||||||
|
config.LogLevel = newConfig.LogLevel
|
||||||
|
}
|
||||||
|
if newConfig.LogFile != nil {
|
||||||
|
config.LogFile = newConfig.LogFile
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (config *Config) SetDefaults() {
|
func (config *Config) SetDefaults() {
|
||||||
@@ -361,8 +388,8 @@ func (config *Config) SetDefaults() {
|
|||||||
if config.ShowConfig == nil {
|
if config.ShowConfig == nil {
|
||||||
config.ShowConfig = new(Defaults.ShowConfig)
|
config.ShowConfig = new(Defaults.ShowConfig)
|
||||||
}
|
}
|
||||||
if config.Quiet == nil {
|
if config.Progress == nil {
|
||||||
config.Quiet = new(Defaults.Quiet)
|
config.Progress = new(Defaults.Progress)
|
||||||
}
|
}
|
||||||
if config.Insecure == nil {
|
if config.Insecure == nil {
|
||||||
config.Insecure = new(Defaults.Insecure)
|
config.Insecure = new(Defaults.Insecure)
|
||||||
@@ -377,6 +404,14 @@ func (config *Config) SetDefaults() {
|
|||||||
if config.Output == nil {
|
if config.Output == nil {
|
||||||
config.Output = new(Defaults.Output)
|
config.Output = new(Defaults.Output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.LogLevel == nil {
|
||||||
|
config.LogLevel = new(Defaults.LogLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.LogFile == nil {
|
||||||
|
config.LogFile = new("")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate validates the config fields.
|
// Validate validates the config fields.
|
||||||
@@ -426,8 +461,21 @@ func (config Config) Validate() error {
|
|||||||
validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required")))
|
validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required")))
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Quiet == nil {
|
if config.Progress == nil {
|
||||||
validationErrors = append(validationErrors, types.NewFieldValidationError("Quiet", "", errors.New("quiet field is required")))
|
validationErrors = append(validationErrors, types.NewFieldValidationError("Progress", "", errors.New("progress field is required")))
|
||||||
|
} else {
|
||||||
|
switch *config.Progress {
|
||||||
|
case ConfigProgressTypeBar, ConfigProgressTypeNone:
|
||||||
|
default:
|
||||||
|
validationErrors = append(
|
||||||
|
validationErrors,
|
||||||
|
types.NewFieldValidationError(
|
||||||
|
"Progress",
|
||||||
|
string(*config.Progress),
|
||||||
|
fmt.Errorf("progress must be one of: %s, %s", ConfigProgressTypeBar, ConfigProgressTypeNone),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Output == nil {
|
if config.Output == nil {
|
||||||
@@ -460,6 +508,36 @@ func (config Config) Validate() error {
|
|||||||
validationErrors = append(validationErrors, types.NewFieldValidationError("DryRun", "", errors.New("dryRun field is required")))
|
validationErrors = append(validationErrors, types.NewFieldValidationError("DryRun", "", errors.New("dryRun field is required")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.LogLevel != nil {
|
||||||
|
for i, level := range sarin.SplitLogLevels(*config.LogLevel) {
|
||||||
|
if !slices.Contains(ValidLogLevels, level) {
|
||||||
|
validationErrors = append(
|
||||||
|
validationErrors,
|
||||||
|
types.NewFieldValidationError(
|
||||||
|
fmt.Sprintf("LogLevel[%d]", i),
|
||||||
|
level,
|
||||||
|
fmt.Errorf("log level must be one of: %s", strings.Join(ValidLogLevels, ", ")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.LogFile != nil && *config.LogFile != "" {
|
||||||
|
dir := filepath.Dir(*config.LogFile)
|
||||||
|
if info, err := os.Stat(dir); err != nil {
|
||||||
|
validationErrors = append(
|
||||||
|
validationErrors,
|
||||||
|
types.NewFieldValidationError("LogFile", *config.LogFile, fmt.Errorf("parent directory %q is not accessible", dir)),
|
||||||
|
)
|
||||||
|
} else if !info.IsDir() {
|
||||||
|
validationErrors = append(
|
||||||
|
validationErrors,
|
||||||
|
types.NewFieldValidationError("LogFile", *config.LogFile, fmt.Errorf("parent path %q is not a directory", dir)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for i, proxy := range config.Proxies {
|
for i, proxy := range config.Proxies {
|
||||||
if !slices.Contains(ValidProxySchemes, proxy.Scheme) {
|
if !slices.Contains(ValidProxySchemes, proxy.Scheme) {
|
||||||
validationErrors = append(
|
validationErrors = append(
|
||||||
|
|||||||
+74
-78
@@ -49,74 +49,6 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
|||||||
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
|
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
|
||||||
}
|
}
|
||||||
|
|
||||||
if quiet := parser.getEnv("QUIET"); quiet != "" {
|
|
||||||
quietParsed, err := utilsParse.ParseString[bool](quiet)
|
|
||||||
if err != nil {
|
|
||||||
fieldParseErrors = append(
|
|
||||||
fieldParseErrors,
|
|
||||||
types.NewFieldParseError(
|
|
||||||
parser.getFullEnvName("QUIET"),
|
|
||||||
quiet,
|
|
||||||
errors.New("invalid value for boolean, expected 'true' or 'false'"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
config.Quiet = &quietParsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if output := parser.getEnv("OUTPUT"); output != "" {
|
|
||||||
config.Output = new(ConfigOutputType(output))
|
|
||||||
}
|
|
||||||
|
|
||||||
if insecure := parser.getEnv("INSECURE"); insecure != "" {
|
|
||||||
insecureParsed, err := utilsParse.ParseString[bool](insecure)
|
|
||||||
if err != nil {
|
|
||||||
fieldParseErrors = append(
|
|
||||||
fieldParseErrors,
|
|
||||||
types.NewFieldParseError(
|
|
||||||
parser.getFullEnvName("INSECURE"),
|
|
||||||
insecure,
|
|
||||||
errors.New("invalid value for boolean, expected 'true' or 'false'"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
config.Insecure = &insecureParsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if dryRun := parser.getEnv("DRY_RUN"); dryRun != "" {
|
|
||||||
dryRunParsed, err := utilsParse.ParseString[bool](dryRun)
|
|
||||||
if err != nil {
|
|
||||||
fieldParseErrors = append(
|
|
||||||
fieldParseErrors,
|
|
||||||
types.NewFieldParseError(
|
|
||||||
parser.getFullEnvName("DRY_RUN"),
|
|
||||||
dryRun,
|
|
||||||
errors.New("invalid value for boolean, expected 'true' or 'false'"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
config.DryRun = &dryRunParsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if method := parser.getEnv("METHOD"); method != "" {
|
|
||||||
config.Methods = []string{method}
|
|
||||||
}
|
|
||||||
|
|
||||||
if urlEnv := parser.getEnv("URL"); urlEnv != "" {
|
|
||||||
urlEnvParsed, err := url.Parse(urlEnv)
|
|
||||||
if err != nil {
|
|
||||||
fieldParseErrors = append(
|
|
||||||
fieldParseErrors,
|
|
||||||
types.NewFieldParseError(parser.getFullEnvName("URL"), urlEnv, err),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
config.URL = urlEnvParsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if concurrency := parser.getEnv("CONCURRENCY"); concurrency != "" {
|
if concurrency := parser.getEnv("CONCURRENCY"); concurrency != "" {
|
||||||
concurrencyParsed, err := utilsParse.ParseString[uint](concurrency)
|
concurrencyParsed, err := utilsParse.ParseString[uint](concurrency)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -165,22 +97,58 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if timeout := parser.getEnv("TIMEOUT"); timeout != "" {
|
if logLevel := parser.getEnv("LOG_LEVEL"); logLevel != "" {
|
||||||
timeoutParsed, err := utilsParse.ParseString[time.Duration](timeout)
|
config.LogLevel = new(logLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
if logFile := parser.getEnv("LOG_FILE"); logFile != "" {
|
||||||
|
config.LogFile = new(logFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
if progress := parser.getEnv("PROGRESS"); progress != "" {
|
||||||
|
config.Progress = new(ConfigProgressType(progress))
|
||||||
|
}
|
||||||
|
|
||||||
|
if output := parser.getEnv("OUTPUT"); output != "" {
|
||||||
|
config.Output = new(ConfigOutputType(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
if dryRun := parser.getEnv("DRY_RUN"); dryRun != "" {
|
||||||
|
dryRunParsed, err := utilsParse.ParseString[bool](dryRun)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fieldParseErrors = append(
|
fieldParseErrors = append(
|
||||||
fieldParseErrors,
|
fieldParseErrors,
|
||||||
types.NewFieldParseError(
|
types.NewFieldParseError(
|
||||||
parser.getFullEnvName("TIMEOUT"),
|
parser.getFullEnvName("DRY_RUN"),
|
||||||
timeout,
|
dryRun,
|
||||||
errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"),
|
errors.New("invalid value for boolean, expected 'true' or 'false'"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
config.Timeout = &timeoutParsed
|
config.DryRun = &dryRunParsed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if urlEnv := parser.getEnv("URL"); urlEnv != "" {
|
||||||
|
urlEnvParsed, err := url.Parse(urlEnv)
|
||||||
|
if err != nil {
|
||||||
|
fieldParseErrors = append(
|
||||||
|
fieldParseErrors,
|
||||||
|
types.NewFieldParseError(parser.getFullEnvName("URL"), urlEnv, err),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
config.URL = urlEnvParsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if method := parser.getEnv("METHOD"); method != "" {
|
||||||
|
config.Methods = []string{method}
|
||||||
|
}
|
||||||
|
|
||||||
|
if body := parser.getEnv("BODY"); body != "" {
|
||||||
|
config.Bodies = []string{body}
|
||||||
|
}
|
||||||
|
|
||||||
if param := parser.getEnv("PARAM"); param != "" {
|
if param := parser.getEnv("PARAM"); param != "" {
|
||||||
config.Params.Parse(param)
|
config.Params.Parse(param)
|
||||||
}
|
}
|
||||||
@@ -193,10 +161,6 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
|||||||
config.Cookies.Parse(cookie)
|
config.Cookies.Parse(cookie)
|
||||||
}
|
}
|
||||||
|
|
||||||
if body := parser.getEnv("BODY"); body != "" {
|
|
||||||
config.Bodies = []string{body}
|
|
||||||
}
|
|
||||||
|
|
||||||
if proxy := parser.getEnv("PROXY"); proxy != "" {
|
if proxy := parser.getEnv("PROXY"); proxy != "" {
|
||||||
err := config.Proxies.Parse(proxy)
|
err := config.Proxies.Parse(proxy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -215,6 +179,38 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
|||||||
config.Values = []string{values}
|
config.Values = []string{values}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if timeout := parser.getEnv("TIMEOUT"); timeout != "" {
|
||||||
|
timeoutParsed, err := utilsParse.ParseString[time.Duration](timeout)
|
||||||
|
if err != nil {
|
||||||
|
fieldParseErrors = append(
|
||||||
|
fieldParseErrors,
|
||||||
|
types.NewFieldParseError(
|
||||||
|
parser.getFullEnvName("TIMEOUT"),
|
||||||
|
timeout,
|
||||||
|
errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
config.Timeout = &timeoutParsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if insecure := parser.getEnv("INSECURE"); insecure != "" {
|
||||||
|
insecureParsed, err := utilsParse.ParseString[bool](insecure)
|
||||||
|
if err != nil {
|
||||||
|
fieldParseErrors = append(
|
||||||
|
fieldParseErrors,
|
||||||
|
types.NewFieldParseError(
|
||||||
|
parser.getFullEnvName("INSECURE"),
|
||||||
|
insecure,
|
||||||
|
errors.New("invalid value for boolean, expected 'true' or 'false'"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
config.Insecure = &insecureParsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if lua := parser.getEnv("LUA"); lua != "" {
|
if lua := parser.getEnv("LUA"); lua != "" {
|
||||||
config.Lua = []string{lua}
|
config.Lua = []string{lua}
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-33
@@ -192,24 +192,26 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type configYAML struct {
|
type configYAML struct {
|
||||||
|
ShowConfig *bool `yaml:"showConfig"`
|
||||||
ConfigFiles stringOrSliceField `yaml:"configFile"`
|
ConfigFiles stringOrSliceField `yaml:"configFile"`
|
||||||
Method stringOrSliceField `yaml:"method"`
|
|
||||||
URL *string `yaml:"url"`
|
|
||||||
Timeout *time.Duration `yaml:"timeout"`
|
|
||||||
Concurrency *uint `yaml:"concurrency"`
|
Concurrency *uint `yaml:"concurrency"`
|
||||||
RequestCount *uint64 `yaml:"requests"`
|
RequestCount *uint64 `yaml:"requests"`
|
||||||
Duration *time.Duration `yaml:"duration"`
|
Duration *time.Duration `yaml:"duration"`
|
||||||
Quiet *bool `yaml:"quiet"`
|
LogLevel *string `yaml:"logLevel"`
|
||||||
|
LogFile *string `yaml:"logFile"`
|
||||||
|
Progress *string `yaml:"progress"`
|
||||||
Output *string `yaml:"output"`
|
Output *string `yaml:"output"`
|
||||||
Insecure *bool `yaml:"insecure"`
|
|
||||||
ShowConfig *bool `yaml:"showConfig"`
|
|
||||||
DryRun *bool `yaml:"dryRun"`
|
DryRun *bool `yaml:"dryRun"`
|
||||||
|
URL *string `yaml:"url"`
|
||||||
|
Method stringOrSliceField `yaml:"method"`
|
||||||
|
Bodies stringOrSliceField `yaml:"body"`
|
||||||
Params keyValuesField `yaml:"params"`
|
Params keyValuesField `yaml:"params"`
|
||||||
Headers keyValuesField `yaml:"headers"`
|
Headers keyValuesField `yaml:"headers"`
|
||||||
Cookies keyValuesField `yaml:"cookies"`
|
Cookies keyValuesField `yaml:"cookies"`
|
||||||
Bodies stringOrSliceField `yaml:"body"`
|
|
||||||
Proxies stringOrSliceField `yaml:"proxy"`
|
Proxies stringOrSliceField `yaml:"proxy"`
|
||||||
Values stringOrSliceField `yaml:"values"`
|
Values stringOrSliceField `yaml:"values"`
|
||||||
|
Timeout *time.Duration `yaml:"timeout"`
|
||||||
|
Insecure *bool `yaml:"insecure"`
|
||||||
Lua stringOrSliceField `yaml:"lua"`
|
Lua stringOrSliceField `yaml:"lua"`
|
||||||
Js stringOrSliceField `yaml:"js"`
|
Js stringOrSliceField `yaml:"js"`
|
||||||
}
|
}
|
||||||
@@ -231,33 +233,7 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
|||||||
|
|
||||||
var fieldParseErrors []types.FieldParseError
|
var fieldParseErrors []types.FieldParseError
|
||||||
|
|
||||||
config.Methods = append(config.Methods, parsedData.Method...)
|
|
||||||
config.Timeout = parsedData.Timeout
|
|
||||||
config.Concurrency = parsedData.Concurrency
|
|
||||||
config.Requests = parsedData.RequestCount
|
|
||||||
config.Duration = parsedData.Duration
|
|
||||||
config.ShowConfig = parsedData.ShowConfig
|
config.ShowConfig = parsedData.ShowConfig
|
||||||
config.Quiet = parsedData.Quiet
|
|
||||||
|
|
||||||
if parsedData.Output != nil {
|
|
||||||
config.Output = new(ConfigOutputType(*parsedData.Output))
|
|
||||||
}
|
|
||||||
|
|
||||||
config.Insecure = parsedData.Insecure
|
|
||||||
config.DryRun = parsedData.DryRun
|
|
||||||
for _, kv := range parsedData.Params {
|
|
||||||
config.Params = append(config.Params, types.Param(kv))
|
|
||||||
}
|
|
||||||
for _, kv := range parsedData.Headers {
|
|
||||||
config.Headers = append(config.Headers, types.Header(kv))
|
|
||||||
}
|
|
||||||
for _, kv := range parsedData.Cookies {
|
|
||||||
config.Cookies = append(config.Cookies, types.Cookie(kv))
|
|
||||||
}
|
|
||||||
config.Bodies = append(config.Bodies, parsedData.Bodies...)
|
|
||||||
config.Values = append(config.Values, parsedData.Values...)
|
|
||||||
config.Lua = append(config.Lua, parsedData.Lua...)
|
|
||||||
config.Js = append(config.Js, parsedData.Js...)
|
|
||||||
|
|
||||||
if len(parsedData.ConfigFiles) > 0 {
|
if len(parsedData.ConfigFiles) > 0 {
|
||||||
for _, configFile := range parsedData.ConfigFiles {
|
for _, configFile := range parsedData.ConfigFiles {
|
||||||
@@ -265,6 +241,22 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config.Concurrency = parsedData.Concurrency
|
||||||
|
config.Requests = parsedData.RequestCount
|
||||||
|
config.Duration = parsedData.Duration
|
||||||
|
config.LogLevel = parsedData.LogLevel
|
||||||
|
config.LogFile = parsedData.LogFile
|
||||||
|
|
||||||
|
if parsedData.Progress != nil {
|
||||||
|
config.Progress = new(ConfigProgressType(*parsedData.Progress))
|
||||||
|
}
|
||||||
|
|
||||||
|
if parsedData.Output != nil {
|
||||||
|
config.Output = new(ConfigOutputType(*parsedData.Output))
|
||||||
|
}
|
||||||
|
|
||||||
|
config.DryRun = parsedData.DryRun
|
||||||
|
|
||||||
if parsedData.URL != nil {
|
if parsedData.URL != nil {
|
||||||
urlParsed, err := url.Parse(*parsedData.URL)
|
urlParsed, err := url.Parse(*parsedData.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -274,6 +266,18 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config.Methods = append(config.Methods, parsedData.Method...)
|
||||||
|
config.Bodies = append(config.Bodies, parsedData.Bodies...)
|
||||||
|
for _, kv := range parsedData.Params {
|
||||||
|
config.Params = append(config.Params, types.Param(kv))
|
||||||
|
}
|
||||||
|
for _, kv := range parsedData.Headers {
|
||||||
|
config.Headers = append(config.Headers, types.Header(kv))
|
||||||
|
}
|
||||||
|
for _, kv := range parsedData.Cookies {
|
||||||
|
config.Cookies = append(config.Cookies, types.Cookie(kv))
|
||||||
|
}
|
||||||
|
|
||||||
for i, proxy := range parsedData.Proxies {
|
for i, proxy := range parsedData.Proxies {
|
||||||
err := config.Proxies.Parse(proxy)
|
err := config.Proxies.Parse(proxy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -284,6 +288,12 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config.Values = append(config.Values, parsedData.Values...)
|
||||||
|
config.Timeout = parsedData.Timeout
|
||||||
|
config.Insecure = parsedData.Insecure
|
||||||
|
config.Lua = append(config.Lua, parsedData.Lua...)
|
||||||
|
config.Js = append(config.Js, parsedData.Js...)
|
||||||
|
|
||||||
if len(fieldParseErrors) > 0 {
|
if len(fieldParseErrors) > 0 {
|
||||||
return nil, types.NewFieldParseErrors(fieldParseErrors)
|
return nil, types.NewFieldParseErrors(fieldParseErrors)
|
||||||
}
|
}
|
||||||
|
|||||||
+269
-51
@@ -2,8 +2,13 @@ package sarin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,20 +19,153 @@ import (
|
|||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type runtimeMessageLevel uint8
|
type runtimeLogLevel uint8
|
||||||
|
|
||||||
const (
|
const (
|
||||||
runtimeMessageLevelWarning runtimeMessageLevel = iota
|
runtimeLogLevelInfo runtimeLogLevel = iota
|
||||||
runtimeMessageLevelError
|
runtimeLogLevelError
|
||||||
)
|
)
|
||||||
|
|
||||||
type runtimeMessage struct {
|
type runtimeLog struct {
|
||||||
timestamp time.Time
|
timestamp time.Time
|
||||||
level runtimeMessageLevel
|
level runtimeLogLevel
|
||||||
text string
|
text string
|
||||||
}
|
}
|
||||||
|
|
||||||
type messageSender func(level runtimeMessageLevel, text string)
|
type runtimeLogger func(level runtimeLogLevel, text string)
|
||||||
|
|
||||||
|
// SplitLogLevels parses a comma-separated log-level string into a deduplicated
|
||||||
|
// slice of level tokens (surrounding whitespace trimmed, empties dropped). Level
|
||||||
|
// names are matched case-sensitively, like the other enum options.
|
||||||
|
func SplitLogLevels(levels string) []string {
|
||||||
|
var out []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for part := range strings.SplitSeq(levels, ",") {
|
||||||
|
token := strings.TrimSpace(part)
|
||||||
|
if token == "" || seen[token] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[token] = true
|
||||||
|
out = append(out, token)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// respLogger logs a single completed response.
|
||||||
|
type respLogger func(duration time.Duration, resp *fasthttp.Response)
|
||||||
|
|
||||||
|
func noopLog(runtimeLogLevel, string) {}
|
||||||
|
func noopRespLog(time.Duration, *fasthttp.Response) {}
|
||||||
|
|
||||||
|
// gateSendLog wraps emit with level filtering decided once from the enabled
|
||||||
|
// levels. It stays general (any level filters correctly) while avoiding a
|
||||||
|
// per-log check when both levels are on and any work at all when both are off.
|
||||||
|
func gateSendLog(logInfo, logError bool, emit func(level runtimeLogLevel, text string)) runtimeLogger {
|
||||||
|
switch {
|
||||||
|
case logInfo && logError:
|
||||||
|
return emit
|
||||||
|
case logInfo:
|
||||||
|
return func(level runtimeLogLevel, text string) {
|
||||||
|
if level == runtimeLogLevelInfo {
|
||||||
|
emit(level, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case logError:
|
||||||
|
return func(level runtimeLogLevel, text string) {
|
||||||
|
if level == runtimeLogLevelError {
|
||||||
|
emit(level, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return noopLog
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// respBodySnippetLen bounds how many bytes of the body the compact (TUI)
|
||||||
|
// rendering shows.
|
||||||
|
const respBodySnippetLen = 100
|
||||||
|
|
||||||
|
// formatRuntimeLogLine renders a runtime log as a plain (unstyled) line for
|
||||||
|
// stderr output. ANSI styling is left to the TUI.
|
||||||
|
func formatRuntimeLogLine(timestamp time.Time, level runtimeLogLevel, text string) string {
|
||||||
|
levelStr := "ERROR"
|
||||||
|
if level == runtimeLogLevelInfo {
|
||||||
|
levelStr = "INFO"
|
||||||
|
}
|
||||||
|
return "[" + timestamp.Format("15:04:05") + "] " + levelStr + ": " + text
|
||||||
|
}
|
||||||
|
|
||||||
|
// respToLog renders a response as a compact one-line summary for the TUI log box
|
||||||
|
// (the "[time] INFO:" prefix is added by the TUI).
|
||||||
|
func respToLog(duration time.Duration, resp *fasthttp.Response) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(statusCodeToString(resp.StatusCode()))
|
||||||
|
sb.WriteString(" ")
|
||||||
|
sb.WriteString(Duration(duration).String())
|
||||||
|
if snippet := bodySnippet(resp.Body(), respBodySnippetLen); snippet != "" {
|
||||||
|
sb.WriteString(" | ")
|
||||||
|
sb.WriteString(snippet)
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
type respLogEntry struct {
|
||||||
|
Status int `json:"status"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
Headers map[string][]string `json:"headers,omitempty"`
|
||||||
|
Body string `json:"body,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// respToLogJSON renders a response as a single, self-contained JSON line so the
|
||||||
|
// stderr stream stays valid NDJSON (pipeable to jq or any consumer).
|
||||||
|
func respToLogJSON(duration time.Duration, resp *fasthttp.Response) string {
|
||||||
|
entry := respLogEntry{
|
||||||
|
Status: resp.StatusCode(),
|
||||||
|
Duration: Duration(duration).String(),
|
||||||
|
Headers: collectRespHeaders(resp),
|
||||||
|
Body: string(resp.Body()),
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(entry)
|
||||||
|
if err != nil {
|
||||||
|
return `{"error":"failed to marshal response log"}`
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectRespHeaders(resp *fasthttp.Response) map[string][]string {
|
||||||
|
headers := make(map[string][]string)
|
||||||
|
for key, value := range resp.Header.All() {
|
||||||
|
k := string(key)
|
||||||
|
headers[k] = append(headers[k], string(value))
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
// bodySnippet returns the first maxLen bytes of body collapsed onto a single
|
||||||
|
// line (control characters replaced with spaces), with an ellipsis when truncated.
|
||||||
|
func bodySnippet(body []byte, maxLen int) string {
|
||||||
|
if len(body) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
truncated := false
|
||||||
|
if len(body) > maxLen {
|
||||||
|
body = body[:maxLen]
|
||||||
|
truncated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
s := strings.Map(func(r rune) rune {
|
||||||
|
if r == '\n' || r == '\r' || r == '\t' {
|
||||||
|
return ' '
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, string(body))
|
||||||
|
|
||||||
|
if truncated {
|
||||||
|
s += "..."
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
type sarin struct {
|
type sarin struct {
|
||||||
workers uint
|
workers uint
|
||||||
@@ -40,11 +178,14 @@ type sarin struct {
|
|||||||
totalRequests *uint64
|
totalRequests *uint64
|
||||||
totalDuration *time.Duration
|
totalDuration *time.Duration
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
quiet bool
|
showProgress bool
|
||||||
skipCertVerify bool
|
skipCertVerify bool
|
||||||
values []string
|
values []string
|
||||||
collectStats bool
|
collectStats bool
|
||||||
dryRun bool
|
dryRun bool
|
||||||
|
logInfo bool
|
||||||
|
logError bool
|
||||||
|
logFile string
|
||||||
|
|
||||||
hostClients []*fasthttp.HostClient
|
hostClients []*fasthttp.HostClient
|
||||||
responses *SarinResponseData
|
responses *SarinResponseData
|
||||||
@@ -65,7 +206,7 @@ func NewSarin(
|
|||||||
workers uint,
|
workers uint,
|
||||||
totalRequests *uint64,
|
totalRequests *uint64,
|
||||||
totalDuration *time.Duration,
|
totalDuration *time.Duration,
|
||||||
quiet bool,
|
showProgress bool,
|
||||||
skipCertVerify bool,
|
skipCertVerify bool,
|
||||||
params types.Params,
|
params types.Params,
|
||||||
headers types.Headers,
|
headers types.Headers,
|
||||||
@@ -75,6 +216,8 @@ func NewSarin(
|
|||||||
values []string,
|
values []string,
|
||||||
collectStats bool,
|
collectStats bool,
|
||||||
dryRun bool,
|
dryRun bool,
|
||||||
|
logLevel string,
|
||||||
|
logFile string,
|
||||||
luaScripts []string,
|
luaScripts []string,
|
||||||
jsScripts []string,
|
jsScripts []string,
|
||||||
) (*sarin, error) {
|
) (*sarin, error) {
|
||||||
@@ -82,6 +225,17 @@ func NewSarin(
|
|||||||
workers = 1
|
workers = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve which log levels are enabled once, up front.
|
||||||
|
var logInfo, logError bool
|
||||||
|
for _, level := range SplitLogLevels(logLevel) {
|
||||||
|
switch level {
|
||||||
|
case "info":
|
||||||
|
logInfo = true
|
||||||
|
case "error":
|
||||||
|
logError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
hostClients, err := newHostClients(ctx, timeout, proxies, workers, requestURL, skipCertVerify)
|
hostClients, err := newHostClients(ctx, timeout, proxies, workers, requestURL, skipCertVerify)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -111,11 +265,14 @@ func NewSarin(
|
|||||||
totalRequests: totalRequests,
|
totalRequests: totalRequests,
|
||||||
totalDuration: totalDuration,
|
totalDuration: totalDuration,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
quiet: quiet,
|
showProgress: showProgress,
|
||||||
skipCertVerify: skipCertVerify,
|
skipCertVerify: skipCertVerify,
|
||||||
values: values,
|
values: values,
|
||||||
collectStats: collectStats,
|
collectStats: collectStats,
|
||||||
dryRun: dryRun,
|
dryRun: dryRun,
|
||||||
|
logInfo: logInfo,
|
||||||
|
logError: logError,
|
||||||
|
logFile: logFile,
|
||||||
hostClients: hostClients,
|
hostClients: hostClients,
|
||||||
fileCache: NewFileCache(time.Second * 10),
|
fileCache: NewFileCache(time.Second * 10),
|
||||||
scriptChain: scriptChain,
|
scriptChain: scriptChain,
|
||||||
@@ -128,73 +285,95 @@ func NewSarin(
|
|||||||
return srn, nil
|
return srn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) GetResponses() *SarinResponseData {
|
func (s sarin) GetResponses() *SarinResponseData {
|
||||||
return q.responses
|
return s.responses
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) Start(ctx context.Context, stopCtrl *StopController) {
|
func (s sarin) Start(ctx context.Context, stopCtrl *StopController) {
|
||||||
jobsCtx, jobsCancel := context.WithCancel(ctx)
|
jobsCtx, jobsCancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
var workersWG sync.WaitGroup
|
var workersWG sync.WaitGroup
|
||||||
jobsCh := make(chan struct{}, max(q.workers, 1))
|
jobsCh := make(chan struct{}, max(s.workers, 1))
|
||||||
|
|
||||||
var counter atomic.Uint64
|
var counter atomic.Uint64
|
||||||
|
|
||||||
totalRequests := uint64(0)
|
totalRequests := uint64(0)
|
||||||
if q.totalRequests != nil {
|
if s.totalRequests != nil {
|
||||||
totalRequests = *q.totalRequests
|
totalRequests = *s.totalRequests
|
||||||
}
|
}
|
||||||
|
|
||||||
var streamCtx context.Context
|
onTerminal := term.IsTerminal(os.Stdout.Fd())
|
||||||
var streamCancel context.CancelFunc
|
// The progress bar needs an interactive terminal to render.
|
||||||
var streamCh chan struct{}
|
showProgressBar := s.showProgress && onTerminal
|
||||||
var messageChannel chan runtimeMessage
|
// The bubbletea TUI hosts the bar and/or the live log box, so it runs whenever
|
||||||
var sendMessage messageSender
|
// either has something to show: the bar, or logs that would land in the box
|
||||||
|
// (i.e. logs are enabled and not redirected to a file).
|
||||||
|
runTUI := showProgressBar || (onTerminal && (s.logInfo || s.logError) && s.logFile == "")
|
||||||
|
|
||||||
if !q.quiet && !term.IsTerminal(os.Stdout.Fd()) {
|
// Open the log file up front, before registering any defer, so a bad path
|
||||||
q.quiet = true
|
// exits cleanly.
|
||||||
|
var logFile *os.File
|
||||||
|
if s.logFile != "" {
|
||||||
|
f, err := os.OpenFile(s.logFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "failed to open log file "+s.logFile+": "+err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer f.Close() //nolint:errcheck
|
||||||
|
logFile = f
|
||||||
}
|
}
|
||||||
|
|
||||||
if q.quiet {
|
var (
|
||||||
sendMessage = func(level runtimeMessageLevel, text string) {}
|
streamCtx context.Context
|
||||||
} else {
|
streamCancel context.CancelFunc
|
||||||
|
streamCh chan struct{}
|
||||||
|
tuiLogChannel chan runtimeLog
|
||||||
|
)
|
||||||
|
if runTUI {
|
||||||
streamCtx, streamCancel = context.WithCancel(context.Background())
|
streamCtx, streamCancel = context.WithCancel(context.Background())
|
||||||
defer streamCancel()
|
defer streamCancel()
|
||||||
streamCh = make(chan struct{})
|
streamCh = make(chan struct{})
|
||||||
messageChannel = make(chan runtimeMessage, max(q.workers, 1))
|
tuiLogChannel = make(chan runtimeLog, max(s.workers, 1))
|
||||||
sendMessage = func(level runtimeMessageLevel, text string) {
|
}
|
||||||
messageChannel <- runtimeMessage{
|
|
||||||
timestamp: time.Now(),
|
// Route logs: to the file if given, else to the TUI log box while it runs,
|
||||||
level: level,
|
// otherwise to stderr.
|
||||||
text: text,
|
var (
|
||||||
}
|
sendLog runtimeLogger
|
||||||
}
|
sendRespLog respLogger
|
||||||
|
)
|
||||||
|
switch {
|
||||||
|
case logFile != nil:
|
||||||
|
sendLog, sendRespLog = s.newWriterLog(logFile)
|
||||||
|
case runTUI:
|
||||||
|
sendLog, sendRespLog = s.newChannelLog(tuiLogChannel)
|
||||||
|
default:
|
||||||
|
sendLog, sendRespLog = s.newWriterLog(os.Stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start workers
|
// Start workers
|
||||||
q.startWorkers(&workersWG, jobsCh, q.hostClients, &counter, sendMessage)
|
s.startWorkers(&workersWG, jobsCh, s.hostClients, &counter, sendLog, sendRespLog)
|
||||||
|
|
||||||
if !q.quiet {
|
if runTUI {
|
||||||
// Start streaming to terminal
|
|
||||||
//nolint:contextcheck // streamCtx must remain active until all workers complete to ensure all collected data is streamed
|
//nolint:contextcheck // streamCtx must remain active until all workers complete to ensure all collected data is streamed
|
||||||
go q.streamProgress(streamCtx, stopCtrl, streamCh, totalRequests, &counter, messageChannel)
|
go s.streamProgress(streamCtx, stopCtrl, streamCh, totalRequests, &counter, tuiLogChannel, showProgressBar)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup duration-based cancellation
|
// Setup duration-based cancellation
|
||||||
q.setupDurationTimeout(ctx, jobsCancel)
|
s.setupDurationTimeout(ctx, jobsCancel)
|
||||||
// Distribute jobs to workers.
|
// Distribute jobs to workers.
|
||||||
// This blocks until all jobs are sent or the context is canceled.
|
// This blocks until all jobs are sent or the context is canceled.
|
||||||
q.sendJobs(jobsCtx, jobsCh)
|
s.sendJobs(jobsCtx, jobsCh)
|
||||||
|
|
||||||
// Close the jobs channel so workers stop after completing their current job
|
// Close the jobs channel so workers stop after completing their current job
|
||||||
close(jobsCh)
|
close(jobsCh)
|
||||||
// Wait until all workers stopped
|
// Wait until all workers stopped
|
||||||
workersWG.Wait()
|
workersWG.Wait()
|
||||||
if messageChannel != nil {
|
if tuiLogChannel != nil {
|
||||||
close(messageChannel)
|
close(tuiLogChannel)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !q.quiet {
|
if runTUI {
|
||||||
// Stop the progress streaming
|
// Stop the progress streaming
|
||||||
streamCancel()
|
streamCancel()
|
||||||
// Wait until progress streaming has completely stopped
|
// Wait until progress streaming has completely stopped
|
||||||
@@ -202,6 +381,45 @@ func (q sarin) Start(ctx context.Context, stopCtrl *StopController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newWriterLog builds the loggers that write formatted lines to w (a log file or
|
||||||
|
// stderr). sendLog stays general (it filters by each log's level); sendRespLog
|
||||||
|
// only ever emits info, so its decision is baked once into a no-op when off.
|
||||||
|
func (s sarin) newWriterLog(w io.Writer) (runtimeLogger, respLogger) {
|
||||||
|
// log.Logger serializes writes with its own mutex, so concurrent workers
|
||||||
|
// won't interleave lines.
|
||||||
|
logger := log.New(w, "", 0)
|
||||||
|
|
||||||
|
sendLog := gateSendLog(s.logInfo, s.logError, func(level runtimeLogLevel, text string) {
|
||||||
|
logger.Println(formatRuntimeLogLine(time.Now(), level, text))
|
||||||
|
})
|
||||||
|
|
||||||
|
var sendRespLog respLogger = noopRespLog
|
||||||
|
if s.logInfo {
|
||||||
|
sendRespLog = func(duration time.Duration, resp *fasthttp.Response) {
|
||||||
|
logger.Println(formatRuntimeLogLine(time.Now(), runtimeLogLevelInfo, respToLogJSON(duration, resp)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendLog, sendRespLog
|
||||||
|
}
|
||||||
|
|
||||||
|
// newChannelLog builds the loggers that feed the TUI log box through ch, with the
|
||||||
|
// same gating as newWriterLog.
|
||||||
|
func (s sarin) newChannelLog(ch chan<- runtimeLog) (runtimeLogger, respLogger) {
|
||||||
|
sendLog := gateSendLog(s.logInfo, s.logError, func(level runtimeLogLevel, text string) {
|
||||||
|
ch <- runtimeLog{timestamp: time.Now(), level: level, text: text}
|
||||||
|
})
|
||||||
|
|
||||||
|
var sendRespLog respLogger = noopRespLog
|
||||||
|
if s.logInfo {
|
||||||
|
sendRespLog = func(duration time.Duration, resp *fasthttp.Response) {
|
||||||
|
ch <- runtimeLog{timestamp: time.Now(), level: runtimeLogLevelInfo, text: respToLog(duration, resp)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendLog, sendRespLog
|
||||||
|
}
|
||||||
|
|
||||||
// newHostClients initializes HTTP clients for the given configuration.
|
// newHostClients initializes HTTP clients for the given configuration.
|
||||||
// It can return the following errors:
|
// It can return the following errors:
|
||||||
// - types.ProxyDialError
|
// - types.ProxyDialError
|
||||||
@@ -228,18 +446,18 @@ func newHostClients(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, hostClients []*fasthttp.HostClient, counter *atomic.Uint64, sendMessage messageSender) {
|
func (s sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, hostClients []*fasthttp.HostClient, counter *atomic.Uint64, sendLog runtimeLogger, sendRespLog respLogger) {
|
||||||
for range max(q.workers, 1) {
|
for range max(s.workers, 1) {
|
||||||
wg.Go(func() {
|
wg.Go(func() {
|
||||||
q.Worker(jobs, NewHostClientGenerator(hostClients...), counter, sendMessage)
|
s.Worker(jobs, NewHostClientGenerator(hostClients...), counter, sendLog, sendRespLog)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFunc) {
|
func (s sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFunc) {
|
||||||
if q.totalDuration != nil {
|
if s.totalDuration != nil {
|
||||||
go func() {
|
go func() {
|
||||||
timer := time.NewTimer(*q.totalDuration)
|
timer := time.NewTimer(*s.totalDuration)
|
||||||
defer timer.Stop()
|
defer timer.Stop()
|
||||||
select {
|
select {
|
||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
@@ -251,9 +469,9 @@ func (q sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) sendJobs(ctx context.Context, jobs chan<- struct{}) {
|
func (s sarin) sendJobs(ctx context.Context, jobs chan<- struct{}) {
|
||||||
if q.totalRequests != nil && *q.totalRequests > 0 {
|
if s.totalRequests != nil && *s.totalRequests > 0 {
|
||||||
for range *q.totalRequests {
|
for range *s.totalRequests {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-104
@@ -16,24 +16,61 @@ import (
|
|||||||
type tickMsg time.Time
|
type tickMsg time.Time
|
||||||
|
|
||||||
var (
|
var (
|
||||||
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#d1d1d1"))
|
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#d1d1d1"))
|
||||||
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FC5B5B")).Bold(true)
|
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FC5B5B")).Bold(true)
|
||||||
warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD93D")).Bold(true)
|
infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#5BC0FC")).Bold(true)
|
||||||
messageChannelStyle = lipgloss.NewStyle().
|
|
||||||
Border(lipgloss.ThickBorder(), false, false, false, true).
|
errorLabel = errorStyle.Render("ERROR: ")
|
||||||
BorderForeground(lipgloss.Color("#757575")).
|
infoLabel = infoStyle.Render("INFO: ")
|
||||||
PaddingLeft(1).
|
|
||||||
Margin(1, 0, 0, 0).
|
logChannelStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("#888888"))
|
Border(lipgloss.ThickBorder(), false, false, false, true).
|
||||||
|
BorderForeground(lipgloss.Color("#757575")).
|
||||||
|
PaddingLeft(1).
|
||||||
|
Margin(1, 0, 0, 0).
|
||||||
|
Foreground(lipgloss.Color("#888888"))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// renderRuntimeLog builds a single styled log line for the TUI log box.
|
||||||
|
func renderRuntimeLog(log runtimeLog) string {
|
||||||
|
label := errorLabel
|
||||||
|
if log.level == runtimeLogLevelInfo {
|
||||||
|
label = infoLabel
|
||||||
|
}
|
||||||
|
return "[" + log.timestamp.Format("15:04:05") + "] " + label + log.text
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderLogBox renders the styled log box, or "" when there are no lines.
|
||||||
|
func renderLogBox(logs []string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for i, line := range logs {
|
||||||
|
if len(line) > 0 {
|
||||||
|
b.WriteString(line)
|
||||||
|
if i < len(logs)-1 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b.Len() == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return logChannelStyle.Render(b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func helpLine(cancelling bool) string {
|
||||||
|
if cancelling {
|
||||||
|
return helpStyle.Render("Stopping... (Ctrl+C again to force)")
|
||||||
|
}
|
||||||
|
return helpStyle.Render("Press Ctrl+C to quit")
|
||||||
|
}
|
||||||
|
|
||||||
type progressModel struct {
|
type progressModel struct {
|
||||||
progress progress.Model
|
progress progress.Model
|
||||||
startTime time.Time
|
startTime time.Time
|
||||||
messages []string
|
logs []string
|
||||||
counter *atomic.Uint64
|
counter *atomic.Uint64
|
||||||
current uint64
|
|
||||||
maxValue uint64
|
maxValue uint64
|
||||||
|
showBar bool
|
||||||
ctx context.Context //nolint:containedctx
|
ctx context.Context //nolint:containedctx
|
||||||
stop func()
|
stop func()
|
||||||
cancelling bool
|
cancelling bool
|
||||||
@@ -59,19 +96,8 @@ func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case runtimeMessage:
|
case runtimeLog:
|
||||||
var msgBuilder strings.Builder
|
m.logs = append(m.logs[1:], renderRuntimeLog(msg))
|
||||||
msgBuilder.WriteString("[")
|
|
||||||
msgBuilder.WriteString(msg.timestamp.Format("15:04:05"))
|
|
||||||
msgBuilder.WriteString("] ")
|
|
||||||
switch msg.level {
|
|
||||||
case runtimeMessageLevelError:
|
|
||||||
msgBuilder.WriteString(errorStyle.Render("ERROR: "))
|
|
||||||
case runtimeMessageLevelWarning:
|
|
||||||
msgBuilder.WriteString(warningStyle.Render("WARNING: "))
|
|
||||||
}
|
|
||||||
msgBuilder.WriteString(msg.text)
|
|
||||||
m.messages = append(m.messages[1:], msgBuilder.String())
|
|
||||||
if m.ctx.Err() != nil {
|
if m.ctx.Err() != nil {
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
@@ -92,38 +118,27 @@ func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m progressModel) View() string {
|
func (m progressModel) View() string {
|
||||||
var messagesBuilder strings.Builder
|
var b strings.Builder
|
||||||
for i, msg := range m.messages {
|
if box := renderLogBox(m.logs); box != "" {
|
||||||
if len(msg) > 0 {
|
b.WriteString(box)
|
||||||
messagesBuilder.WriteString(msg)
|
b.WriteString("\n")
|
||||||
if i < len(m.messages)-1 {
|
|
||||||
messagesBuilder.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var finalBuilder strings.Builder
|
if m.showBar {
|
||||||
if messagesBuilder.Len() > 0 {
|
current := m.counter.Load()
|
||||||
finalBuilder.WriteString(messageChannelStyle.Render(messagesBuilder.String()))
|
b.WriteString("\n ")
|
||||||
finalBuilder.WriteString("\n")
|
b.WriteString(strconv.FormatUint(current, 10))
|
||||||
|
b.WriteString("/")
|
||||||
|
b.WriteString(strconv.FormatUint(m.maxValue, 10))
|
||||||
|
b.WriteString(" - ")
|
||||||
|
b.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
|
||||||
|
b.WriteString("\n ")
|
||||||
|
b.WriteString(m.progress.ViewAs(float64(current) / float64(m.maxValue)))
|
||||||
}
|
}
|
||||||
|
|
||||||
m.current = m.counter.Load()
|
b.WriteString("\n\n ")
|
||||||
finalBuilder.WriteString("\n ")
|
b.WriteString(helpLine(m.cancelling))
|
||||||
finalBuilder.WriteString(strconv.FormatUint(m.current, 10))
|
return b.String()
|
||||||
finalBuilder.WriteString("/")
|
|
||||||
finalBuilder.WriteString(strconv.FormatUint(m.maxValue, 10))
|
|
||||||
finalBuilder.WriteString(" - ")
|
|
||||||
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
|
|
||||||
finalBuilder.WriteString("\n ")
|
|
||||||
finalBuilder.WriteString(m.progress.ViewAs(float64(m.current) / float64(m.maxValue)))
|
|
||||||
finalBuilder.WriteString("\n\n ")
|
|
||||||
if m.cancelling {
|
|
||||||
finalBuilder.WriteString(helpStyle.Render("Stopping... (Ctrl+C again to force)"))
|
|
||||||
} else {
|
|
||||||
finalBuilder.WriteString(helpStyle.Render("Press Ctrl+C to quit"))
|
|
||||||
}
|
|
||||||
return finalBuilder.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func progressTickCmd() tea.Cmd {
|
func progressTickCmd() tea.Cmd {
|
||||||
@@ -138,7 +153,8 @@ type infiniteProgressModel struct {
|
|||||||
spinner spinner.Model
|
spinner spinner.Model
|
||||||
startTime time.Time
|
startTime time.Time
|
||||||
counter *atomic.Uint64
|
counter *atomic.Uint64
|
||||||
messages []string
|
logs []string
|
||||||
|
showBar bool
|
||||||
ctx context.Context //nolint:containedctx
|
ctx context.Context //nolint:containedctx
|
||||||
quit bool
|
quit bool
|
||||||
stop func()
|
stop func()
|
||||||
@@ -158,19 +174,8 @@ func (m infiniteProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case runtimeMessage:
|
case runtimeLog:
|
||||||
var msgBuilder strings.Builder
|
m.logs = append(m.logs[1:], renderRuntimeLog(msg))
|
||||||
msgBuilder.WriteString("[")
|
|
||||||
msgBuilder.WriteString(msg.timestamp.Format("15:04:05"))
|
|
||||||
msgBuilder.WriteString("] ")
|
|
||||||
switch msg.level {
|
|
||||||
case runtimeMessageLevelError:
|
|
||||||
msgBuilder.WriteString(errorStyle.Render("ERROR: "))
|
|
||||||
case runtimeMessageLevelWarning:
|
|
||||||
msgBuilder.WriteString(warningStyle.Render("WARNING: "))
|
|
||||||
}
|
|
||||||
msgBuilder.WriteString(msg.text)
|
|
||||||
m.messages = append(m.messages[1:], msgBuilder.String())
|
|
||||||
if m.ctx.Err() != nil {
|
if m.ctx.Err() != nil {
|
||||||
m.quit = true
|
m.quit = true
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
@@ -189,64 +194,60 @@ func (m infiniteProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m infiniteProgressModel) View() string {
|
func (m infiniteProgressModel) View() string {
|
||||||
var messagesBuilder strings.Builder
|
var b strings.Builder
|
||||||
for i, msg := range m.messages {
|
if box := renderLogBox(m.logs); box != "" {
|
||||||
if len(msg) > 0 {
|
b.WriteString(box)
|
||||||
messagesBuilder.WriteString(msg)
|
b.WriteString("\n")
|
||||||
if i < len(m.messages)-1 {
|
|
||||||
messagesBuilder.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var finalBuilder strings.Builder
|
// Without a spinner, the view is just the log box (plus help until quit).
|
||||||
if messagesBuilder.Len() > 0 {
|
if !m.showBar {
|
||||||
finalBuilder.WriteString(messageChannelStyle.Render(messagesBuilder.String()))
|
if !m.quit {
|
||||||
finalBuilder.WriteString("\n")
|
b.WriteString("\n\n ")
|
||||||
|
b.WriteString(helpLine(m.cancelling))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.quit {
|
if m.quit {
|
||||||
finalBuilder.WriteString("\n ")
|
b.WriteString("\n ")
|
||||||
finalBuilder.WriteString(strconv.FormatUint(m.counter.Load(), 10))
|
b.WriteString(strconv.FormatUint(m.counter.Load(), 10))
|
||||||
finalBuilder.WriteString(" ")
|
b.WriteString(" ")
|
||||||
finalBuilder.WriteString(infiniteProgressStyle.Render("∙∙∙∙∙"))
|
b.WriteString(infiniteProgressStyle.Render("∙∙∙∙∙"))
|
||||||
finalBuilder.WriteString(" ")
|
b.WriteString(" ")
|
||||||
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
|
b.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
|
||||||
finalBuilder.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
} else {
|
} else {
|
||||||
finalBuilder.WriteString("\n ")
|
b.WriteString("\n ")
|
||||||
finalBuilder.WriteString(strconv.FormatUint(m.counter.Load(), 10))
|
b.WriteString(strconv.FormatUint(m.counter.Load(), 10))
|
||||||
finalBuilder.WriteString(" ")
|
b.WriteString(" ")
|
||||||
finalBuilder.WriteString(m.spinner.View())
|
b.WriteString(m.spinner.View())
|
||||||
finalBuilder.WriteString(" ")
|
b.WriteString(" ")
|
||||||
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
|
b.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
|
||||||
finalBuilder.WriteString("\n\n ")
|
b.WriteString("\n\n ")
|
||||||
if m.cancelling {
|
b.WriteString(helpLine(m.cancelling))
|
||||||
finalBuilder.WriteString(helpStyle.Render("Stopping... (Ctrl+C again to force)"))
|
|
||||||
} else {
|
|
||||||
finalBuilder.WriteString(helpStyle.Render("Press Ctrl+C to quit"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return finalBuilder.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) streamProgress(
|
func (s sarin) streamProgress(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
stopCtrl *StopController,
|
stopCtrl *StopController,
|
||||||
done chan<- struct{},
|
done chan<- struct{},
|
||||||
total uint64,
|
total uint64,
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
messageChannel <-chan runtimeMessage,
|
logChannel <-chan runtimeLog,
|
||||||
|
showBar bool,
|
||||||
) {
|
) {
|
||||||
var program *tea.Program
|
var program *tea.Program
|
||||||
if total > 0 {
|
if total > 0 {
|
||||||
model := progressModel{
|
model := progressModel{
|
||||||
progress: progress.New(progress.WithGradient("#151594", "#00D4FF")),
|
progress: progress.New(progress.WithGradient("#151594", "#00D4FF")),
|
||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
messages: make([]string, 8),
|
logs: make([]string, 8),
|
||||||
counter: counter,
|
counter: counter,
|
||||||
current: 0,
|
|
||||||
maxValue: total,
|
maxValue: total,
|
||||||
|
showBar: showBar,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
stop: stopCtrl.Stop,
|
stop: stopCtrl.Stop,
|
||||||
}
|
}
|
||||||
@@ -274,7 +275,8 @@ func (q sarin) streamProgress(
|
|||||||
),
|
),
|
||||||
startTime: time.Now(),
|
startTime: time.Now(),
|
||||||
counter: counter,
|
counter: counter,
|
||||||
messages: make([]string, 8),
|
logs: make([]string, 8),
|
||||||
|
showBar: showBar,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
stop: stopCtrl.Stop,
|
stop: stopCtrl.Stop,
|
||||||
quit: false,
|
quit: false,
|
||||||
@@ -287,7 +289,7 @@ func (q sarin) streamProgress(
|
|||||||
defer stopCtrl.AttachProgram(nil)
|
defer stopCtrl.AttachProgram(nil)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for msg := range messageChannel {
|
for msg := range logChannel {
|
||||||
program.Send(msg)
|
program.Send(msg)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
+76
-58
@@ -29,11 +29,12 @@ func statusCodeToString(code int) string {
|
|||||||
return strconv.Itoa(code)
|
return strconv.Itoa(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) Worker(
|
func (s sarin) Worker(
|
||||||
jobs <-chan struct{},
|
jobs <-chan struct{},
|
||||||
hostClientGenerator HostClientGenerator,
|
hostClientGenerator HostClientGenerator,
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendMessage messageSender,
|
sendLog runtimeLogger,
|
||||||
|
sendRespLog respLogger,
|
||||||
) {
|
) {
|
||||||
req := fasthttp.AcquireRequest()
|
req := fasthttp.AcquireRequest()
|
||||||
resp := fasthttp.AcquireResponse()
|
resp := fasthttp.AcquireResponse()
|
||||||
@@ -43,9 +44,9 @@ func (q sarin) Worker(
|
|||||||
// Create script transformer for this worker (engines are not thread-safe)
|
// Create script transformer for this worker (engines are not thread-safe)
|
||||||
// Scripts are pre-validated in NewSarin, so this should not fail
|
// Scripts are pre-validated in NewSarin, so this should not fail
|
||||||
var scriptTransformer *script.Transformer
|
var scriptTransformer *script.Transformer
|
||||||
if !q.scriptChain.IsEmpty() {
|
if !s.scriptChain.IsEmpty() {
|
||||||
var err error
|
var err error
|
||||||
scriptTransformer, err = q.scriptChain.NewTransformer()
|
scriptTransformer, err = s.scriptChain.NewTransformer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -53,79 +54,84 @@ func (q sarin) Worker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
requestGenerator, isDynamic := NewRequestGenerator(
|
requestGenerator, isDynamic := NewRequestGenerator(
|
||||||
q.methods, q.requestURL, q.params, q.headers, q.cookies, q.bodies, q.values, q.fileCache, scriptTransformer,
|
s.methods, s.requestURL, s.params, s.headers, s.cookies, s.bodies, s.values, s.fileCache, scriptTransformer,
|
||||||
)
|
)
|
||||||
|
|
||||||
if q.dryRun {
|
if s.dryRun {
|
||||||
switch {
|
switch {
|
||||||
case q.collectStats && isDynamic:
|
case s.collectStats && isDynamic:
|
||||||
q.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, counter, sendMessage)
|
s.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, counter, sendLog)
|
||||||
case q.collectStats && !isDynamic:
|
case s.collectStats && !isDynamic:
|
||||||
q.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendMessage)
|
s.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
|
||||||
case !q.collectStats && isDynamic:
|
case !s.collectStats && isDynamic:
|
||||||
q.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, counter, sendMessage)
|
s.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, counter, sendLog)
|
||||||
default:
|
default:
|
||||||
q.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendMessage)
|
s.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
switch {
|
switch {
|
||||||
case q.collectStats && isDynamic:
|
case s.collectStats && isDynamic:
|
||||||
q.workerStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
|
s.workerStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
|
||||||
case q.collectStats && !isDynamic:
|
case s.collectStats && !isDynamic:
|
||||||
q.workerStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
|
s.workerStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
|
||||||
case !q.collectStats && isDynamic:
|
case !s.collectStats && isDynamic:
|
||||||
q.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
|
s.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
|
||||||
default:
|
default:
|
||||||
q.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
|
s.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) workerStatsWithDynamic(
|
func (s sarin) workerStatsWithDynamic(
|
||||||
jobs <-chan struct{},
|
jobs <-chan struct{},
|
||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
resp *fasthttp.Response,
|
resp *fasthttp.Response,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
hostClientGenerator HostClientGenerator,
|
hostClientGenerator HostClientGenerator,
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendMessage messageSender,
|
sendLog runtimeLogger,
|
||||||
|
sendRespLog respLogger,
|
||||||
) {
|
) {
|
||||||
for range jobs {
|
for range jobs {
|
||||||
req.Reset()
|
req.Reset()
|
||||||
resp.Reset()
|
resp.Reset()
|
||||||
|
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
q.responses.Add(err.Error(), 0)
|
s.responses.Add(err.Error(), 0)
|
||||||
sendMessage(runtimeMessageLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
err := hostClientGenerator().DoTimeout(req, resp, q.timeout)
|
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
|
||||||
|
respDuration := time.Since(startTime)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
q.responses.Add(err.Error(), time.Since(startTime))
|
s.responses.Add(err.Error(), respDuration)
|
||||||
} else {
|
} else {
|
||||||
q.responses.Add(statusCodeToString(resp.StatusCode()), time.Since(startTime))
|
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
|
||||||
|
sendRespLog(respDuration, resp)
|
||||||
}
|
}
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) workerStatsWithStatic(
|
func (s sarin) workerStatsWithStatic(
|
||||||
jobs <-chan struct{},
|
jobs <-chan struct{},
|
||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
resp *fasthttp.Response,
|
resp *fasthttp.Response,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
hostClientGenerator HostClientGenerator,
|
hostClientGenerator HostClientGenerator,
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendMessage messageSender,
|
sendLog runtimeLogger,
|
||||||
|
sendRespLog respLogger,
|
||||||
) {
|
) {
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
// Static request generation failed - record all jobs as errors
|
// Static request generation failed - record all jobs as errors
|
||||||
for range jobs {
|
for range jobs {
|
||||||
q.responses.Add(err.Error(), 0)
|
s.responses.Add(err.Error(), 0)
|
||||||
sendMessage(runtimeMessageLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -135,49 +141,57 @@ func (q sarin) workerStatsWithStatic(
|
|||||||
resp.Reset()
|
resp.Reset()
|
||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
err := hostClientGenerator().DoTimeout(req, resp, q.timeout)
|
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
|
||||||
|
respDuration := time.Since(startTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
q.responses.Add(err.Error(), time.Since(startTime))
|
s.responses.Add(err.Error(), respDuration)
|
||||||
} else {
|
} else {
|
||||||
q.responses.Add(statusCodeToString(resp.StatusCode()), time.Since(startTime))
|
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
|
||||||
|
sendRespLog(respDuration, resp)
|
||||||
}
|
}
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) workerNoStatsWithDynamic(
|
func (s sarin) workerNoStatsWithDynamic(
|
||||||
jobs <-chan struct{},
|
jobs <-chan struct{},
|
||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
resp *fasthttp.Response,
|
resp *fasthttp.Response,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
hostClientGenerator HostClientGenerator,
|
hostClientGenerator HostClientGenerator,
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendMessage messageSender,
|
sendLog runtimeLogger,
|
||||||
|
sendRespLog respLogger,
|
||||||
) {
|
) {
|
||||||
for range jobs {
|
for range jobs {
|
||||||
req.Reset()
|
req.Reset()
|
||||||
resp.Reset()
|
resp.Reset()
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
sendMessage(runtimeMessageLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_ = hostClientGenerator().DoTimeout(req, resp, q.timeout)
|
startTime := time.Now()
|
||||||
|
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
|
||||||
|
if err == nil {
|
||||||
|
sendRespLog(time.Since(startTime), resp)
|
||||||
|
}
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) workerNoStatsWithStatic(
|
func (s sarin) workerNoStatsWithStatic(
|
||||||
jobs <-chan struct{},
|
jobs <-chan struct{},
|
||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
resp *fasthttp.Response,
|
resp *fasthttp.Response,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
hostClientGenerator HostClientGenerator,
|
hostClientGenerator HostClientGenerator,
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendMessage messageSender,
|
sendLog runtimeLogger,
|
||||||
|
sendRespLog respLogger,
|
||||||
) {
|
) {
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
sendMessage(runtimeMessageLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
|
|
||||||
// Static request generation failed - just count the jobs without sending
|
// Static request generation failed - just count the jobs without sending
|
||||||
for range jobs {
|
for range jobs {
|
||||||
@@ -188,80 +202,84 @@ func (q sarin) workerNoStatsWithStatic(
|
|||||||
|
|
||||||
for range jobs {
|
for range jobs {
|
||||||
resp.Reset()
|
resp.Reset()
|
||||||
_ = hostClientGenerator().DoTimeout(req, resp, q.timeout)
|
startTime := time.Now()
|
||||||
|
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
|
||||||
|
if err == nil {
|
||||||
|
sendRespLog(time.Since(startTime), resp)
|
||||||
|
}
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) workerDryRunStatsWithDynamic(
|
func (s sarin) workerDryRunStatsWithDynamic(
|
||||||
jobs <-chan struct{},
|
jobs <-chan struct{},
|
||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendMessage messageSender,
|
sendLog runtimeLogger,
|
||||||
) {
|
) {
|
||||||
for range jobs {
|
for range jobs {
|
||||||
req.Reset()
|
req.Reset()
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
q.responses.Add(err.Error(), time.Since(startTime))
|
s.responses.Add(err.Error(), time.Since(startTime))
|
||||||
sendMessage(runtimeMessageLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
q.responses.Add(dryRunResponseKey, time.Since(startTime))
|
s.responses.Add(dryRunResponseKey, time.Since(startTime))
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) workerDryRunStatsWithStatic(
|
func (s sarin) workerDryRunStatsWithStatic(
|
||||||
jobs <-chan struct{},
|
jobs <-chan struct{},
|
||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendMessage messageSender,
|
sendLog runtimeLogger,
|
||||||
) {
|
) {
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
// Static request generation failed - record all jobs as errors
|
// Static request generation failed - record all jobs as errors
|
||||||
for range jobs {
|
for range jobs {
|
||||||
q.responses.Add(err.Error(), 0)
|
s.responses.Add(err.Error(), 0)
|
||||||
sendMessage(runtimeMessageLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for range jobs {
|
for range jobs {
|
||||||
q.responses.Add(dryRunResponseKey, 0)
|
s.responses.Add(dryRunResponseKey, 0)
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) workerDryRunNoStatsWithDynamic(
|
func (s sarin) workerDryRunNoStatsWithDynamic(
|
||||||
jobs <-chan struct{},
|
jobs <-chan struct{},
|
||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendMessage messageSender,
|
sendLog runtimeLogger,
|
||||||
) {
|
) {
|
||||||
for range jobs {
|
for range jobs {
|
||||||
req.Reset()
|
req.Reset()
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
sendMessage(runtimeMessageLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
}
|
}
|
||||||
counter.Add(1)
|
counter.Add(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q sarin) workerDryRunNoStatsWithStatic(
|
func (s sarin) workerDryRunNoStatsWithStatic(
|
||||||
jobs <-chan struct{},
|
jobs <-chan struct{},
|
||||||
req *fasthttp.Request,
|
req *fasthttp.Request,
|
||||||
requestGenerator RequestGenerator,
|
requestGenerator RequestGenerator,
|
||||||
counter *atomic.Uint64,
|
counter *atomic.Uint64,
|
||||||
sendMessage messageSender,
|
sendLog runtimeLogger,
|
||||||
) {
|
) {
|
||||||
if err := requestGenerator(req); err != nil {
|
if err := requestGenerator(req); err != nil {
|
||||||
sendMessage(runtimeMessageLevelError, err.Error())
|
sendLog(runtimeLogLevelError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
for range jobs {
|
for range jobs {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{ lib
|
||||||
|
, buildGoModule
|
||||||
|
, go_1_26
|
||||||
|
, rev ? "unknown"
|
||||||
|
, buildDate ? "unknown"
|
||||||
|
}:
|
||||||
|
|
||||||
|
(buildGoModule.override { go = go_1_26; }) (finalAttrs: {
|
||||||
|
pname = "sarin";
|
||||||
|
version = "1.4.0"; # bump per release
|
||||||
|
|
||||||
|
src = lib.cleanSource ../.;
|
||||||
|
|
||||||
|
vendorHash = "sha256-/r2mioVoMbrboumF0sjHhharkGImQAShmiOQtdS5DaE=";
|
||||||
|
|
||||||
|
subPackages = [ "cmd/cli" ];
|
||||||
|
|
||||||
|
env.CGO_ENABLED = 0; # fully static binary
|
||||||
|
|
||||||
|
ldflags = [
|
||||||
|
"-s"
|
||||||
|
"-w"
|
||||||
|
"-X=go.aykhans.me/sarin/internal/version.Version=v${finalAttrs.version}"
|
||||||
|
"-X=go.aykhans.me/sarin/internal/version.GitCommit=${rev}"
|
||||||
|
"-X=go.aykhans.me/sarin/internal/version.BuildDate=${buildDate}"
|
||||||
|
];
|
||||||
|
|
||||||
|
preBuild = ''
|
||||||
|
ldflags+=("-X 'go.aykhans.me/sarin/internal/version.GoVersion=$(go version)'")
|
||||||
|
'';
|
||||||
|
|
||||||
|
# cmd/cli produces a binary named "cli"; rename it to "sarin".
|
||||||
|
postInstall = ''
|
||||||
|
mv $out/bin/cli $out/bin/sarin
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "High-performance HTTP load testing tool built with Go and fasthttp";
|
||||||
|
homepage = "https://github.com/aykhans/sarin";
|
||||||
|
license = lib.licenses.mit;
|
||||||
|
mainProgram = "sarin";
|
||||||
|
maintainers = [ ];
|
||||||
|
};
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user