Skip to content
VeitangiePublic

About

Concurrent integration and end-to-end HTTP testing tool for stateful workflows.

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

License: GPL v3 Coverage CodeFactor Code Quality Pipeline Status Release Version

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 running 15 scenarios: auth, an environment matrix, retries, and a failed Lua assertion with captured output

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.

Table of Contents

Why sinq?

Most API testing tools treat a test as a collection of independent requests that you stitch together after the fact. sinq treats it as a workflow: an ordered run of requests that pass state from one to the next, the way a real session does. Log in, hold the token, create a record, poll until it settles, check the result.

That workflow is your directory layout. A folder is a flow and the files inside it are its steps, in natural-sorted order. Setup that several flows share, like authentication, goes in a parent folder and everything beneath it inherits it. Each leaf folder is one scenario with its own Lua state and its own cookie jar, so nothing bleeds between them. Since scenarios share nothing, sinq runs them all at once by default, limited only by the worker count and your network.

A .sinq file is a plain HTTP request with Lua wherever you need logic. Nothing sits between what you write and what goes on the wire. Four hooks cover the rest of the lifecycle: $PRE for setup and file I/O, $RETRY for polling, $ASSERT for checks, and $POST for pulling values out of a response so later requests can use them.

It ships as one static binary or a small container with nothing else to install, and it writes JUnit XML for CI.

sinq performance: 30000 requests across 15000 scenarios in 5.08s

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 and find the latest version.
  2. Download the .zip file for your architecture (x86_64 or arm64).
  3. Extract sinq.exe and add the containing folder to your system %PATH%.

Using Scoop? If you use the Scoop 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 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 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)
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.26.6+)

If you have a Go environment set up, you can compile and install directly from the module.

go install veitangie.dev/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/
🪄 Setting up shell auto-completion

(Note: If you installed sinq via native Linux packages (.deb, .rpm, .apk), the AUR, or the Homebrew Formula (brew install Veitangie/tap/sinq), completions are installed automatically and you can skip this step. For all other methods like the Homebrew Cask (sinq-bin), scoop, the install script, or go install, you'll need to set them up manually).

sinq supports auto-completion for Bash, Zsh, Fish, and PowerShell.

To load completions dynamically in your current session:

  • Bash/Zsh: source <(sinq --completion)
  • Fish: sinq --completion | source
  • PowerShell: sinq --completion | Invoke-Expression

To install completions permanently, save the output to your shell's completions directory:

  • Bash: sinq --completion > ~/.local/share/bash-completion/completions/sinq
  • Zsh: sinq --completion > ~/.zfunc/_sinq (and add fpath+=~/.zfunc before compinit in .zshrc)
  • Fish: sinq --completion > ~/.config/fish/completions/sinq.fish
  • PowerShell: Add sinq --completion | Invoke-Expression to your PowerShell profile.
🤖 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.sinq ➔ 00_process.sinq ➔ 00_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.sinq ➔ 02_create.sinq ➔ 03_delete.sinq
  2. Scenario B (The payments leaf):
    • Config: Aggregation of 00_base.scenario + payments.scenario (Timeout is now 15s)
    • Execution Order: 01_auth.sinq ➔ 02_process.sinq ➔ 03_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. Text after ### on the same line names the request that follows (shown in --list output and reports) — a leading ### before any content names the file's first request the same way, even in a single-request file.

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}. Only the first returned value is used; nil, functions, channels and coroutines fail the request. Strings are inserted as is, numbers and booleans as text, and tables as compact JSON, so "address": ${env.pointA} sends a JSON object.
  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?) / req.cache(key) — Opts-in to client-side request caching. Parameter defaults to true if omitted. Without a key, the cache is based on the data sent over the wire, its cookies and any attached filenames. With a string key, every request using that key shares one cached response, so prefix keys yourself when requests must stay apart ("auth:" .. env.phone). The first request to fill a key decides what's stored, including a saveResponseTo target. Retries are cached separately, so polling still works.
    • 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.
    • req.multipart(entry, ...) — Builds a multipart/form-data body from one or more part tables ({ name, data | source, filename?, contentType?, contentDisposition?, headers? }), passed positionally or as one array table. Repeated calls accumulate. Mutually exclusive with req.attach and a literal body.
    • req.multipartBoundary(string) — Pins the multipart boundary instead of using a random one.
  • $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. A request that runs several times is reported once, with the most severe status of its runs, the failures of all runs, and its attempts and timings added up.
  • 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?), sleep(ms)

Encoding API (sinq.encoding)

  • Base64/hex: base64Encode(str), base64Decode(str), base64UrlEncode(str), base64UrlDecode(str), hexEncode(str), hexDecode(str)
  • URL: urlEncode(str), urlDecode(str) — for query strings and application/x-www-form-urlencoded bodies

Crypto API (sinq.crypto)

  • 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?)

JSON API (sinq.json)

  • Functions: parse(source), serialize(tbl, indent?), array(tbl?)
  • sinq.json.null — a constant representing an explicit JSON null, since Lua tables drop nil keys.
  • Empty arrays: Lua can't tell an empty array from an empty object, so an empty table serializes as {}. Arrays parsed from JSON stay arrays, and sinq.json.array(tbl?) marks a table (or returns a new one) so it serializes as [] when empty.
  • Arrays must be gapless: serialize returns an error for { 1, nil, 3 }. Use sinq.json.null for holes.

Fake Data API (sinq.fake)

  • Functions: See the Lua API documentation for a comprehensive suite of fake data generators (sinq.fake.uuid() / sinq.fake.uuidv4(), 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. In JUnit reports they're written as a tags property of the scenario's <testsuite>.

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. Assertion failure messages from sinq.assert.equals and sinq.assert.contains leave out the values they compare and report only types and field paths. Custom messages are shown as written, so don't put secrets in them.

--unrestricted (-u) loads the full Lua os and io libraries, which lets scenario scripts run shell commands, access the filesystem outside the scenario directory, and terminate sinq. Only use it with scenario files you fully trust. See Libraries for details.


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 with default configuration
sinq ./tests/integration/001-auth/01-login.sinq

# Override deeply nested environment keys using dot-notation and JSON values
sinq -e "API.TIMEOUT=500" -s 'DB.HOSTS=["10.0.0.1", "10.0.0.2"]' ./tests

Options

  -v, --version           Print the current sinq version and exit
  -h, --help              Print this help message and exit
  -i, --insecure          Disable SSL/TLS certificate verification
  -V, --verbose           Enable verbose reporting (reports each stage duration, only affects "std" format)
  -l, --list              Parse and list scenarios at specified directories
  -u, --unrestricted      Load Lua os/io modules - disables the script sandbox
  -p, --print             Capture lua output and show it in the report
  -w, --workers int       Number of concurrent workers (default 10)
  -c, --count uint        Number of launches for every scenario (default 1)
  -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")
  -C, --color string      Terminal colors: always, never, auto (default "auto")
  -S, --show string       Which results to show in the output: all, no-skip, failed, none (default "no-skip"); std format only, junit always shows everything
  -t, --tag string        Execute only scenarios that have at least one of passed tags
  -n, --name string       Execute only scenarios which names match at least one of passed regular expressions
  --no-spinner            Disable spinner animation
  --dump-on-failure       Print full request and response data on failed assertion
  --secrets-file string   Path to JSON-formatted secrets file
  --no-tag string         Do not execute scenarios that have the tag
  --no-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 5MiB
  --cache-timeout string  Global timeout for the cached requests, default 10s

Combining filters: -t/--tag and -n/--name (and their --no-tag/--no-name counterparts) OR within the same flag type (any one of several -t tags is enough) but AND across types — a scenario must satisfy the tag filter (if any) and the name filter (if any) to be included.


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.

Versioning & Compatibility

From v1.0.0 onward, sinq follows Semantic Versioning:

  • Patch (v1.0.x): bug fixes only, with no intended change to behavior or output.
  • Minor (v1.x.0): new, backward-compatible functionality. New flags, new Lua APIs, or new config keys with safe defaults.
  • Major (vX.0.0): any breaking change.

The compatibility promise covers:

  • CLI flag names, their semantics and defaults, and process exit codes.
  • The .sinq file format and the .scenario configuration schema.
  • The Lua API surface exposed to scripts (globals, the sinq.* namespace, and the lifecycle scopes).
  • The structure of the JUnit XML report.

It does not cover, and any of the following may change in any release:

  • The exact text, layout, and colors of the human-readable std reporter.
  • Log message wording and formatting.
  • Anything under internal/ (this module exposes no importable Go API).
  • Performance characteristics.

Useful Links


Acknowledgments & Credits

This project includes natural sorting logic adapted from facette/natsort, which is distributed under the 3-Clause BSD License. Copyright (c) 2015, Vincent Batoufflet and Marc Falzon. All rights reserved.

Big thanks to yuin for his gopher-lua project.


License

Copyright (C) 2026 Veitangie. Distributed under the terms of the GNU General Public License v3 (GPLv3).

About

Concurrent integration and end-to-end HTTP testing tool for stateful workflows.

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages