Skip to content

Have you ever felt the pain of wiring up a bunch of independent requests into one stateful flow from start to finish? I have, and while manageable, one thing always bugged me. Why do we treat end-to-end API tests as a bunch of independent requests that we have to somehow bundle together, when they actually are much closer to stateful workflows? That's why this tool was born. It is designed to do one job - execute sequences of requests to walk through different workflow scenarios. Instead of maintaining large request collections or YAML-heavy test definitions, I wanted to describe real user workflows directly in files and let sinq compile them into executable scenarios. And make it natively parallel, why not? So I present to you:

sinq

sinq is a concurrent integration and end-to-end http testing tool that treats your filesystem as a workflow definition.

Write requests as near-raw HTTP, add Lua where logic is needed, and organize files into directory trees. Every leaf directory becomes an isolated execution scenario with its own state, configuration, and request chain.

Why sinq?

sinq is: * Workflow-Oriented: - Build authentication, creation, processing and verification flows as file trees. - Shared setup lives in parent directories. - Leaf directories become executable scenarios. * Simple: A .sinq file is just raw HTTP + Lua. There are no abstractions to fight, what you write is what gets sent over the wires. If you can write a cURL command and a basic script, you can write a sinq test. * Natively parallel: Scenarios don't share global state, which allows to run all of them in parallel, bounded only by network (or configuration). * Fully scriptable: Pass JWTs, correlation IDs, and dynamic payloads between chained requests, manage execution flow, run scripts on lifecycle hooks and more. * Lightweight & built for CI/CD: Distributed both as a lightweight binary and a container, requires minimal setup to run. Native support for JUnit XML reporting.

Show Don't Tell: A Simple Healthcheck

Here is what a simple one-off request looks like in sinq.

healthcheck.sinq

GET ${env.BASE_URL}/health

$ASSERT{ sinq.assert.code(200, "Healthcheck failed") }

Show Don't Tell 2: A Stateful Poller

Here is what a complete authentication, execution, and polling chain looks like in sinq.

01_login.sinq

POST ${env.BASE_URL}/login
Accept: application/json

$ASSERT{ sinq.assert.code(200, "Login failed") }

$POST{
    -- Parse the body as json
    local data = res.json()
    -- Save only what matters to the global environment
    AUTH_TOKEN = data.token
}

02_trigger_and_poll.sinq

POST ${env.BASE_URL}/jobs
Authorization: Bearer ${AUTH_TOKEN}

{ "action": "export" }

$RETRY{ return sinq.retry.when(res.json().status == "pending", 50 * sinq.time.ms) }

$ASSERT{ sinq.assert.isTrue(res.json().status == "complete", "Job never completed") }


Installation & Usage

Choose your preferred installation method below to get started.

🍺 Homebrew (macOS & Linux) Available via the official Homebrew tap. You can choose to build from source (avoids macOS Gatekeeper warnings) or install the pre-compiled binary. **Option 1: Build from Source (Recommended for macOS)** Builds natively on your machine. This automatically bypasses Apple's Gatekeeper "unverified developer" blocks.
brew install Veitangie/tap/sinq
**Option 2: Pre-compiled Binary (Cask)** Installs instantly, but macOS users will need to manually remove the quarantine flag due to Gatekeeper policies.
brew install --cask Veitangie/tap/sinq-bin

# macOS only: Remove quarantine flag
xattr -d com.apple.quarantine $(which sinq)
🪟 Windows Pre-compiled `.zip` archives are generated for every release. 1. Go to the [Releases page](https://github.com/Veitangie/sinq/releases) and find the latest version. 2. Download the `.zip` file for your architecture (`amd64` or `arm64`). 3. Extract `sinq.exe` and add the containing folder to your system `%PATH%`. > **Using Scoop?** > If you use the [Scoop](https://scoop.sh/) package manager, you can install and stay updated automatically: >
scoop bucket add veitangie https://github.com/Veitangie/scoop-bucket
scoop install sinq
🐧 Debian / Ubuntu (.deb) Pre-compiled `.deb` packages are generated for every release. 1. Go to the [Releases page](https://github.com/Veitangie/sinq/releases) and find the latest version. 2. Download the `.deb` file for your architecture (`amd64` or `arm64`). 3. Install it using `dpkg`:
sudo dpkg -i sinq-*.deb
🎩 Fedora / RHEL (.rpm) Pre-compiled `.rpm` packages are generated for every release. 1. Go to the [Releases page](https://github.com/Veitangie/sinq/releases) and find the latest version. 2. Download the `.rpm` file for your architecture (`amd64` or `arm64`). 3. Install it using `rpm`:
sudo rpm -i sinq-*.rpm
❄️ Nix & NixOS Nix flakes are officially supported via the dedicated `sinq-nix` registry. You can run `sinq` directly without installing it:
nix run github:Veitangie/sinq-nix
Or add it to your environment/configuration:
# flake.nix
inputs.sinq.url = "github:Veitangie/sinq-nix";

# In your configuration package list:
# inputs.sinq.packages.${system}.default
📦 Arch Linux (AUR) Available via the Arch User Repository. You can build from source (`sinq`) or install the pre-compiled binary (`sinq-bin`). Using an AUR helper like `yay` or `paru`:
# Build from source natively
yay -S sinq

# Or install the pre-compiled binary instantly
yay -S sinq-bin
Or, you can build and install manually:
git clone https://aur.archlinux.org/sinq.git
cd sinq
makepkg -si
🐳 Docker (Alpine Minimal) Official multi-architecture images are hosted on the GitHub Container Registry. Mount your local test directory into the container to execute scenarios.
docker pull ghcr.io/veitangie/sinq:latest
docker run -v $(pwd):/tests ghcr.io/veitangie/sinq /tests
⚡ Install Script (macOS & Linux) A quick curl script that downloads the correct binary archive, verifies the SHA256 checksum, and extracts it to `/usr/local/bin`.
curl -sL https://raw.githubusercontent.com/Veitangie/sinq/refs/heads/main/install.sh | bash
> *Note: This script targets stable releases by default. To install a specific version (like a release candidate), pass the version tag as an argument:* > `curl -sL .../install.sh | bash -s v1.0.0-rc.3`
🐹 Go Install (Requires Go 1.25+) If you have a Go environment set up, you can compile and install directly from the module.
go install github.com/Veitangie/sinq/cmd/sinq@latest
> *Note: Ensure your `$(go env GOPATH)/bin` directory is in your system `$PATH`.*
🔧 Build From Source
git clone git@github.com:Veitangie/sinq.git
cd sinq
go build -ldflags="-w -s" -o sinq ./cmd/sinq/
🤖 GitHub Actions (CI/CD) Integrate `sinq` natively into your GitHub Actions pipeline using the official action.
steps:
  - name: Checkout code
    uses: actions/checkout@v6

  - name: Run Sinq Integration Tests
    uses: Veitangie/sinq-action@v1
    with:
      args: '-w 10 --secrets-file path/to/secrets.json tests/e2e'

File & Directory Structure

Example File Structure And Resulting Scenarios

The basic unit of execution for sinq is a scenario. They are built from the filesystem roots passed to the tool.

Because sinq relies on directory hierarchy to build scenarios, you can define shared setup steps (like authentication) at the root, and branch off into specific test cases in subdirectories. Every leaf directory becomes one executable scenario. Parent files are inherited by all descendant scenarios. Scenario configurations are aggregated along the whole path, with the deeper nested ones taking precedence.

Example 1: Deep Chain

If you don't branch, sinq just keeps appending files until it hits the bottom.

flow/
├── 01_init.sinq
└── stage_one/
    ├── 00_process.sinq
    └── stage_two/
        └── 00_finalize.sinq
Because stage_two is the only directory with no subfolders, this resolves into exactly one scenario:

  • Execution Order: 01_init.sinq00_process.sinq00_finalize.sinq Notice how the files in the subdirectories always follow the files from parent directories despite natural order globally being different. Natural ordering only applies within the same directory**

Example 2: Branching

tests/
├── 00_base.scenario       (Sets "req_timeout": "5s", "env": {"host": "api.local"})
├── 01_auth.sinq           (Logs in, saves AUTH_TOKEN to globals)
├── users/
│   ├── 02_create.sinq     (Uses AUTH_TOKEN)
│   └── 03_delete.sinq     (Uses AUTH_TOKEN)
└── payments/
    ├── payments.scenario  (Overrides "req_timeout": "15s" only for the payments/ scenario)
    ├── 02_process.sinq    (Uses AUTH_TOKEN)
    └── 03_refund.sinq     (Uses AUTH_TOKEN)

Running sinq ./tests identifies two leaf directories (users/ and payments/), resulting in two distinct scenarios that will run concurrently:

  1. Scenario A (The users leaf):
    • Config: 00_base.scenario
    • Execution Order: 01_auth.sinq02_create.sinq03_delete.sinq
  2. Scenario B (The payments leaf):
    • Config: Aggregation of 00_base.scenario + payments.scenario (Timeout is now 15s)
    • Execution Order: 01_auth.sinq02_process.sinq03_refund.sinq

Notice how 01_auth.sinq is executed independently at the start of both scenarios. They do not share the same Lua VM instance; they just inherit the same structure.

More detailed explanation of the algorithm can be found in the Treewalker documentation

Currently, leaf directories are expected to contain at least one .sinq or .scenario file. Directories that contain neither .sinq/.scenario files nor subdirectories are not considered valid scenario definitions.


The .sinq Format

A .sinq file is a standard HTTP request with optional embedded Lua scripts. You can also define multiple requests in a single .sinq file by separating them with the ### delimiter.

There are two categories of scripts within a .sinq file: 1. General/Inline Scripts: $MY_VAR, $ (unnamed). These are evaluated to dynamically generate the outgoing HTTP request. The return value is injected directly into the request payload or headers. If a general script fails, sinq attempts to automatically prepend return and retry, enabling simple string interpolations like ${env.HOST}. 2. Lifecycle Scripts: $PRE, $RETRY, $ASSERT, $POST. These strictly control the execution flow and state of the request. To prevent side-effect leaks and ensure thread safety, specific APIs are only available during specific lifecycle stages.

Lifecycle Scopes

  • $PRE (Setup & File I/O): Executes immediately when a worker picks up the request, before it is materialized. This is the only scope where you can modify the filesystem interactions for the request. Current request body payload is inaccessible here.

    • req.attach("path/file.txt") — Replaces the request body with the contents of a file. (Fails if a body is already defined in the request).
    • req.saveResponseTo("path/download.bin") — Streams the incoming response body directly to disk, bypassing the Lua memory buffer.
    • req.cache(bool?) — Opts-in to client-side request caching. Parameter defaults to true if omitted. The cache is based on the data sent over the wire and any attached filenames.
    • req.skip(bool?) — Marks the request to be skipped. Parameter defaults to true if omitted. The $PRE script will finish executing, but the HTTP request will not be fired and subsequent hooks are bypassed. The request is marked as Aborted without throwing a failure.
  • $RETRY (Retry Policies): Executes immediately after receiving the HTTP response. This is the only lifecycle script that must return a value. It must return a Lua number representing milliseconds to wait before retrying, or a negative number to stop retrying.

    • Scope-Exclusive API: sinq.retry.when(), sinq.retry.whenExponential(), sinq.retry.withJitter().
  • $ASSERT (Validation): Executes after the retry loop completes. Used to fail tests.

    • Scope-Exclusive API: sinq.assert.code(), sinq.assert.equals(), sinq.assert.contains(), sinq.assert.isTrue(), sinq.assert.fileMatches(), sinq.assert.fail().
  • $POST (State Extraction): Executes after assertions. Used to extract data from the response and save it to the global sandbox for subsequent requests. It will not execute if $ASSERT calls sinq.assert.fail and the scenario setting fail_fast is true.


Lua API

sinq exposes the following API and global variables to Lua scripts:

Globals & Control Flow

  • env — Table of environment variables configured for the scenario.
  • secrets — Table of secrets passed via the -s, --secrets, --secrets-file arguments.
  • sinq.setNextRequest(index) — Change execution flow to the n-th request (1-indexed). Allows loops or skipping requests.
  • sinq.finishScenario() — Change execution flow to end after the current request.
  • res — Shorthand for current request's response table
  • req — Shorthand for current request table

Time Constants

  • sinq.time.ms (1)
  • sinq.time.second (1000)
  • sinq.time.minute (60000)
  • sinq.time.hour (3600000)

Additional APIs

Time API (sinq.time) * Functions: now(), fromString(str, format?), toString(ms, format?)

Crypto API (sinq.crypto) * Encoding: base64Encode(str), base64Decode(str), base64UrlEncode(str), base64UrlDecode(str), hexEncode(str), hexDecode(str) * Hashing: md5(str, enc?), sha1(str, enc?), sha256(str, enc?), sha512(str, enc?), hmac(str, algo?, key?, enc?)

JWT API (sinq.jwt) * Functions: decode(token), verify(token, key, algo?), sign(claimsTable, key, method?)

Fake Data API (sinq.fake) * Functions: See lua-api.md for a comprehensive suite of fake data generators (sinq.fake.uuid(), sinq.fake.email(), sinq.fake.company(), etc.) to generate random, realistic payloads for endpoints.

Responses

All completed responses are stored in the global sinq.responses table. Lua is 1-indexed, so the response to the first request is sinq.responses[1].

Each response table contains: * attempt (number) — The current retry iteration. * code (number) — HTTP status code. * headers (table) — Response headers (multiple headers with the same key are stored as nested array tables). * oversized (boolean | nil)true if the payload exceeded MaxBodySize and was clamped. * size (number | nil) — Total bytes written to disk (only present if req.saveResponseTo() was used in $PRE). * bodyRaw (string | nil) — Raw response body bytes (only present if req.saveResponseTo() wasn't used in $PRE). * bodyJson (table | nil) — The cached JSON table (initially nil).

JSON Parsing Methods (Only Present If req.saveResponseTo() Wasn't Used In $PRE): * extractBodyJson() — Safely parses bodyRaw, stores it in bodyJson, and returns (table, error). * json() — Unsafe convenience parser. Returns the table directly, but calls error() and fails the scenario if the body is not valid JSON.

Note: The response table is populated during execution; a response index is only non-nil after the corresponding request has been sent.


Configuration & Environment

sinq uses JSON-formatted .scenario files along the scenario path to manage environments, timeouts, and other configurations.

Default configuration that can be overridden in .scenario files:

{
  "name": "path/to/dir/of/last/file",
  "description": "",
  "env": {},
  "req_timeout": "5s",
  "script_timeout": "5s",
  "timeout": "10m",
  "fail_fast": true,
  "max_retries": 10,
  "max_redirects": 5,
  "max_body": "1MiB",
  "env_matrix": [],
  "tags": []
}

  • name: The name of the scenario. If this particular .scenario file is used in several scenarios - they will all have the same name.
  • description: Description of the scenario.
  • env: Object that will be parsed into sinq.env Lua table, which will then be accessible from all Lua scripts.
  • req_timeout: Timeout for any single request in the scenario.
  • script_timeout: Timeout for any single script run in the scenario.
  • timeout: Total timeout for the whole scenario.
  • fail_fast: When true, scenarios will not be ran if any of them fails to compile for some reason, and the scenarios stop at the first failed assertion.
  • max_retries: The maximum amount of times any request in the scenario can be retried upon retry script returning a valid non-negative number.
  • max_redirects: The maximum amount of redirects the client will follow before returning the redirect as the actual response.
  • max_body: Maximum size of response body that will be stored in memory during scenario execution. If a response exceeds this limit, it is safely truncated and the response's oversized flag is set to true.
  • env_matrix: Data sets for the environment matrix mechanism - sinq's take on matrix/combinatorial/parametrized testing. For more information and examples please check out the documentation.
  • tags: Tags or labels assigned to scenarios containing this .scenario file. They get collected into one list for the resulting scenario.

Secrets & Security

Secrets are loaded from a JSON file provided through --secrets-file and overrides passed with --secret|-s flag (for multiple overrides use the flag multiple times).

To reduce the risk of accidental exposure, some error messages intentionally omit possibly sensitive data. Verbose mode (--verbose) as well as --log-level debug and --dump-on-failure may include additional debugging information and should be used carefully in CI environments where logs are retained.


Usage

Point sinq at a directory containing your .sinq files. sinq will automatically sort them in natural order, bundle them into scenarios and execute them concurrently. You can also point sinq to a single .sinq file to execute just that file as its own standalone scenario.

# Run a standard test suite
sinq ./tests/integration

# Run tests and output a JUnit report for your CI pipeline
sinq -f junit -o report.xml ./tests/integration

# Ignore self-signed TLS certificates and enable verbose debug logging
sinq -iV ./tests/local

# Run a single file as a standalone scenario
sinq ./tests/integration/001-auth/01-login.sinq

Options

  -v, --version           Print the current sinq version and exit
  -h, --help              Print this help message and exit
  -w, --workers int       Number of concurrent workers (default 10)
  -i, --insecure          Disable SSL/TLS certificate verification
  -s, --secret string     Key=value pair overrides for scenario secrets
  -e, --env string        Key=value pair overrides for all scenario environments
  -o, --out path          Path to write the output file (prints to stdout if omitted)
  -L, --log-level string  Log level to use: debug, info, warn or error (default "warn")
  -f, --format string     Output format: std or junit (default "std")
  -V, --verbose           Enable verbose reporting (reports each stage duration, only affects "std" format)
  -c, --color string      Terminal colors: always, never, auto (default "auto")
  -S, --show string       Which results to show in the output: all, no-skip, failed (default "no-skip")
  -l, --list              Parse and list scenarios at specified directories
  -t, --tag string        Execute only scenarios that have the tag
  -n, --name string       Execute only scenarios which names match the regular expression
  -u, --unrestricted      Load lua "os" and "io" modules for scripts
  --secrets-file string   Path to JSON-formatted secrets file
  --skip-tag string       Do not execute scenarios that have the tag
  --skip-name string      Do not execute scenarios which names match the regular expression
  --plugins string        Paths to lua plugin directory entries, joined with ':' on Linux and MacOS, ';' on Windows
  --max-cache-size string Global maximum response size for cached requests, default 5MB
  --cache-timeout string  Global timeout for the cached requests, default 10s
  --dump-on-failure       Print full request and response data on failed assertion

When To Choose Sinq

Choose sinq when: - You need stateful integration workflows with minimal setup. - You prefer files over GUI collections. - You don't need the whole JS runtime to make http requests. - You run tests primarily in CI or CLI interfaces.

Choose another tool when: - You need a graphical API explorer. - You need browser automation. - You need fully custom test frameworks written in a general-purpose language.