# sinq > Concurrent integration and end-to-end HTTP testing tool for stateful workflows. sinq runs sequences of HTTP requests as stateful workflows. Tests are plain-text .sinq files (raw HTTP plus Lua) organised into directories; every leaf directory is one scenario, and scenarios run in parallel. # Start here # Introduction to sinq **sinq** is a concurrent integration and end-to-end HTTP testing tool that treats your filesystem as a workflow definition. `sinq` is built around **stateful workflows**. You write a flow as an ordered set of files, `sinq` compiles each flow into a scenario, and it runs the scenarios in parallel. ______________________________________________________________________ ## 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. ______________________________________________________________________ ## Next - **[Getting Started](https://sinq.veitangie.dev/getting-started/index.md)**: install and run `sinq`. - **[CLI Reference](https://sinq.veitangie.dev/cli/index.md)**: every flag, the scenario filters, and the environment variables. - **[Scenarios & Configuration](https://sinq.veitangie.dev/scenario/index.md)**: the request lifecycle and `.scenario` files. - **[Lua API Reference](https://sinq.veitangie.dev/lua-api/index.md)**: built-in functions, fake data, encoding, and crypto helpers. - **[Versioning & Compatibility](https://sinq.veitangie.dev/versioning/index.md)**: what the SemVer promise does and does not cover. # Getting Started Install `sinq`, write one scenario, run it. ## Installation Pre-compiled binaries for each platform are on the [Releases page](https://github.com/Veitangie/sinq/releases) (Windows `.zip`, Debian `.deb`, Fedora `.rpm`), along with Docker images and Nix flakes. Or via a package manager: ```bash # Build natively brew install Veitangie/tap/sinq ``` ```powershell scoop bucket add veitangie https://github.com/Veitangie/scoop-bucket scoop install sinq ``` ```bash nix run github:Veitangie/sinq-nix ``` ```bash yay -S sinq-bin ``` ```bash curl -sL https://raw.githubusercontent.com/Veitangie/sinq/refs/heads/main/install.sh | bash ``` ```bash go install veitangie.dev/sinq/cmd/sinq@latest ``` 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. ## IDE Integration To get syntax highlighting and better editor support, check out the community extensions: - **VSCode / Code OSS**: Install the [sinq-helper](https://marketplace.visualstudio.com/items?itemName=Veitangie.sinq-helper) extension. - **Other Editors**: Use the [Tree-sitter Grammar](https://github.com/Veitangie/tree-sitter-sinq) for Neovim or any tree-sitter compatible editor. ## Your First Test `sinq` builds scenarios from your directory layout. Here is a one-request health check. 1. Make a directory for it: ```bash mkdir -p my-tests/health cd my-tests/health ``` 1. Create `01_healthcheck.sinq`: ```http ### Check API Health GET https://httpbin.org/get $ASSERT{ sinq.assert.code(200, "Healthcheck failed") } ``` ## Running `sinq` Point `sinq` at the directory: ```bash sinq ./my-tests ``` `sinq` finds one scenario (`health`), sends its single request, and prints a pass line for it. The process exits `0`. If the assertion fails, the line is red and the exit code is `1`. ## Next - **[CLI Reference](https://sinq.veitangie.dev/cli/index.md)**: flags, scenario filters, environment variables. - **[Scenarios & Config](https://sinq.veitangie.dev/scenario/index.md)**: chaining requests, passing values between them, `.scenario` files. - **[Lua API](https://sinq.veitangie.dev/lua-api/index.md)**: assertions, JSON, request lifecycle control. # Writing & Debugging Scenarios A scenario is a directory of `.sinq` files. `sinq` runs the files in natural-sorted order and they share state through Lua globals. This page covers the shapes that come up most and how to work out why one is failing. ## A complete scenario Two leaf directories that share a setup file, four requests each. ```text api/ 00_setup.sinq # health check + login, shared by both scenarios users/ users.scenario # tags this scenario "users" 01_users.sinq # create a user, then read it back orders/ orders.scenario # tags this scenario "orders" 01_orders.sinq # create an order, then cancel it ``` `api/` has subdirectories, so it is not a scenario itself. `api/users/` and `api/orders/` are leaf directories, so each one is a scenario. Both inherit `00_setup.sinq` and run it first: - **`users`**: Health check → Log in → Create user → Fetch user - **`orders`**: Health check → Log in → Create order → Cancel order ### The shared setup file **`api/00_setup.sinq`** holds two requests, separated by `###`. The text after `###` names the request in `--list` output and in reports. ```text ### Health check GET ${env.BASE_URL}/health $ASSERT{ sinq.assert.code(200, "API is not up") } ### Log in POST ${env.BASE_URL}/auth/login Content-Type: application/json { "user": "e2e", "pass": "${secrets.E2E_PASSWORD}" } $ASSERT{ sinq.assert.code(200, "login failed") } $POST{ AUTH_TOKEN = res.json().token } ``` - Each request is literal HTTP. `${env.BASE_URL}` and `${secrets.E2E_PASSWORD}` are inline scripts, evaluated just before the request is sent. - Blocks run in lifecycle order (`$PRE`, the request, `$RETRY`, `$ASSERT`, `$POST`) no matter where you write them in the file. - `$POST` runs last. `AUTH_TOKEN` has no `local`, so it is a scenario global that every later file can read. - `BASE_URL` and `E2E_PASSWORD` are not set anywhere in the tree. Pass them at run time (`-e BASE_URL=... -s E2E_PASSWORD=...`, or `--secrets-file`), or add an `api/config.scenario`. ### The `users` scenario **`api/users/users.scenario`** adds a tag so this scenario can be run on its own: ```json { "tags": ["users"] } ``` **`api/users/01_users.sinq`**: ```text ### Create user POST ${env.BASE_URL}/users Authorization: Bearer ${AUTH_TOKEN} Content-Type: application/json { "name": "Ada", "email": "ada@example.test" } $ASSERT{ sinq.assert.code(201, "user not created") } $POST{ USER_ID = res.json().id } ### Fetch user GET ${env.BASE_URL}/users/${USER_ID} Authorization: Bearer ${AUTH_TOKEN} $ASSERT{ sinq.assert.code(200) sinq.assert.equals(res.json(), { name = "Ada", email = "ada@example.test" }) } ``` - The first request's `$POST` saves `USER_ID`; the second interpolates it into the URL with `${USER_ID}`. - `sinq.assert.equals` with a table is a partial match: the listed keys must be present and equal, and other fields in the response are ignored. ### The `orders` scenario Same shape, different resource. **`api/orders/orders.scenario`**: ```json { "tags": ["orders"] } ``` **`api/orders/01_orders.sinq`**: ```text ### Create order POST ${env.BASE_URL}/orders Authorization: Bearer ${AUTH_TOKEN} Content-Type: application/json { "sku": "WIDGET-1", "qty": 3 } $ASSERT{ sinq.assert.code(201, "order not created") } $POST{ ORDER_ID = res.json().id } ### Cancel order DELETE ${env.BASE_URL}/orders/${ORDER_ID} Authorization: Bearer ${AUTH_TOKEN} $ASSERT{ sinq.assert.code(204, "order not cancelled") } ``` Run both scenarios with `sinq ./api`, or just one by tag with `sinq -t orders ./api`. ## Patterns ### Run once per data set Put an `env_matrix` in a `.scenario` file. Each object is an axis; `sinq` runs the scenario once per combination and merges the chosen values into `env`. ```json { "env_matrix": [ { "admin": { "role": "admin" }, "guest": { "role": "guest" } }, { "card": { "expect": 200 }, "crypto": { "expect": 202 } } ] } ``` ```text POST ${env.BASE_URL}/checkout Content-Type: application/json { "role": "${env.role}" } $ASSERT{ sinq.assert.code(env.expect, "wrong status for " .. env.role) } ``` Four runs: `admin`+`card`, `admin`+`crypto`, `guest`+`card`, `guest`+`crypto`. The axis labels are appended to the scenario name. ### Upload a file When the file *is* the whole request body, use `req.attach`: ```text ### Upload raw POST ${env.BASE_URL}/documents Authorization: Bearer ${AUTH_TOKEN} Content-Type: application/pdf $PRE{ req.attach("fixtures/report.pdf") } $ASSERT{ sinq.assert.code(201) } ``` When it is one field among several (a real HTML-form upload), use `req.multipart`: ```text ### Upload avatar POST ${env.BASE_URL}/avatar Authorization: Bearer ${AUTH_TOKEN} $PRE{ req.multipart( { name = "caption", data = "hello" }, { name = "file", source = "fixtures/a.png", filename = true } ) } $ASSERT{ sinq.assert.code(201) } ``` In both, the path is relative to this `.sinq` file. `req.attach` fails if the file also has a literal body; `req.multipart` sets `Content-Type: multipart/form-data; boundary=...` itself unless you set a `Content-Type` yourself, and `filename = true` advertises `basename(source)`. ### Negative test ```text POST ${env.BASE_URL}/admin/wipe Content-Type: application/json { "confirm": true } $ASSERT{ sinq.assert.code(401) } ``` A 401 here is a pass, because that is what the assertion checks for. ### Change the flow ```text GET ${env.BASE_URL}/status $POST{ if res.json().done then sinq.finishScenario() -- end the scenario after this request else sinq.setNextRequest(2) -- jump back to the 2nd request end } ``` A request that runs several times shows up once in the report. It is marked failed if any of its runs failed, lists the failures of all runs, and adds up its attempts and timings. The summary's "requests sent" counts every run and retry, including ones answered from the response cache (`req.cache`) without reaching the server. ## Lua API cheat-sheet Available in every script block: ```text env.KEY scenario environment (also -e / --env overrides) secrets.KEY values from -s / --secret / --secrets-file res current response (see below) req current request (mutate only in $PRE) sinq.responses[n] the nth response, 1-indexed sinq.setNextRequest(index) jump execution to request `index` (1-indexed) sinq.finishScenario() stop after the current request finishes print(...) shown only with -p / --print, otherwise discarded ``` Response table: ```text res.code status code res.headers["Name"] string, or array of strings if the header repeats res.bodyRaw raw body string res.json() parse bodyRaw; fails the request on invalid JSON res.extractBodyJson() -> (table, err); does not throw res.attempt retry iteration res.oversized true if the body was truncated at max_body res.size bytes written (only set after req.saveResponseTo) ``` `$PRE` (setup and file I/O): ```text req.attach(path) use a file's contents as the whole request body req.saveResponseTo(path) stream the response to disk instead of buffering it req.cache(enable? | key) opt in to the response cache; a string key is shared by every request using it req.skip(enable?) do not send this request (enable defaults to true) req.multipart(entry, ...) build a multipart/form-data body req.multipartBoundary(str) pin the multipart boundary instead of a random one ``` `$RETRY` (must return a number): ```text sinq.retry.stop -1 sinq.retry.when(cond, delay?) delay while cond (delay defaults to 500ms) sinq.retry.whenExponential(cond, base?, const?) base^attempt * const sinq.retry.withJitter(cond, range?, fn?, args...) add +/- jitter to a retry ``` `$ASSERT`: ```text sinq.assert.code(want, msg?) sinq.assert.equals(actual, expected, msg?) partial match when expected is a table sinq.assert.contains(str, substr, msg?) sinq.assert.isTrue(cond, msg?) sinq.assert.fail(msg) records a failure but does not stop the block sinq.assert.fileMatches(path) needs a prior req.saveResponseTo ``` Helpers (all script blocks): ```text sinq.time.ms | .second | .minute | .hour millisecond constants sinq.time.now() unix milliseconds sinq.time.fromString(s, fmt?) | toString(ms, fmt?) sinq.time.sleep(ms) unconditional delay, bounded by script_timeout sinq.encoding.base64Encode/Decode, base64UrlEncode/Decode, hexEncode/Decode sinq.encoding.urlEncode/urlDecode query-string and form-body escaping sinq.crypto.md5/sha1/sha256/sha512(s, enc?) | hmac(s, algo?, key?, enc?) sinq.jwt.decode(t) | verify(t, key, algo?) | sign(claims, key, method?) sinq.json.parse(s) | serialize(t, indent?) | array(t?) | sinq.json.null sinq.fake.uuid/email/name/int/oneOf/... see the Lua API page for the full set ``` Full signatures and behaviour: [Lua API](https://sinq.veitangie.dev/lua-api/index.md). ## Common mistakes - **Missing `return` in an inline script.** `${PLAYER_ID}` works because `sinq` retries a bare expression with `return` prepended. A call like `${tostring(x)}` is ambiguous; write `${return tostring(x)}`. - **Multiple return values in an inline script.** Only the first one is inserted, so `${return sinq.json.serialize(t)}` works as is. A `nil` first value fails the request instead of inserting the text `nil`. - **Quoting a table.** An inline script that returns a table inserts JSON, so write `"address": ${env.pointA}`, not `"address": "${env.pointA}"`. Strings are the opposite: they're inserted raw, so they need the quotes (`"${env.NAME}"`). - **Empty arrays become `{}`.** Lua has no empty-array value, so an empty table serializes as an object. Arrays parsed from JSON keep their shape; for one built in Lua, use `sinq.json.array()` (or `sinq.json.array(t)` on a list that might end up empty). - **`local` on a value you need later.** State passed between files must be a global: `AUTH_TOKEN = ...`, not `local AUTH_TOKEN = ...`. - **Lua tables are 1-indexed.** The first element of a top-level JSON array is `res.json()[1]`. - **`res.json()` fails the request on non-JSON.** Use `res.extractBodyJson()` when the body might not be JSON. - **`$RETRY` must return a number.** Returning nothing, a boolean, or a table errors the request. - **`$PRE` cannot read the body.** It is not built yet. Read it in an inline script or in `$POST` of an earlier request. - **One body source per request.** A literal body together with `req.attach` or `req.multipart` is an error. - **`$POST` is skipped after a failed assertion** when `fail_fast` is true (the default). ## Debugging a failing scenario Reach for these flags, roughly in order: | Flag | Use | | ------------------- | --------------------------------------------------------------------------------------------------------- | | `--list` | Confirm which files make up the scenario and in what order. | | `-V` / `--verbose` | Per-stage timings, so you can see which stage is slow or hanging. | | `--dump-on-failure` | Print the full request and response on a failed assertion. | | `-p` / `--print` | Attach your Lua `print` output to the report. | | `-L debug` | Everything, including request and response bodies. Avoid in CI with retained logs; it can expose secrets. | ### Failure vs error - **Failure**: an assertion said no (`sinq.assert.*`). The service responded and the check did not pass. JUnit ``. - **Error**: something broke before an assertion could run, such as a Lua exception, a timeout, a refused connection, or a malformed request. JUnit ``. ### Error messages | Message | Cause | | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- | | `Empty request (did you add ### ...)` | The file, or the part after a `###`, has no request line. | | `Expected method to be defined, got nil` | The first non-hook line is not `METHOD url`. | | `:: Failed to parse lua script` | Lua syntax error in a hook or inline script. | | `Error occurred while executing lua script` | Lua runtime error: a nil index, an explicit `error(...)`, and so on. | | `Pre script is defined more than once` | Two `$PRE` (or `$ASSERT`, or `$POST`) blocks in one request. | | `Too many retries` | `$RETRY` kept returning a non-negative number past `max_retries`. | | `Request has more than one body ...` | A literal body plus `req.attach` or `req.multipart`. | | `req.attach: invalid file path ...` / `req.multipart: invalid source path ...` | The path is absolute, missing, or a directory. Paths are relative to the `.sinq` file. | | `Failed to parse scenario config` | Invalid JSON in a `.scenario` file. | | `Failed to parse env matrix: keys ... have non-object values` | An `env_matrix` axis value is not an object. | | `Non positive request timeout` (or `script` / `scenario`) | A timeout in a `.scenario` file is `0` or negative. | | `Failed to extract body as json` | `res.json()` on a body that is not valid JSON. | | `Context cancelled` / `connection refused` | The run was interrupted, or the target was unreachable. Reported as an error, not a failure. | # Reference # CLI Reference ```text sinq [flags] [paths...] ``` Each path is a directory to scan for `.sinq` / `.scenario` files, or a single `.sinq` file to run on its own. If no path is given, `sinq` uses the current directory. Multiple paths may be listed and are scanned independently. ## Flags ### Execution | Flag | Value | Default | Notes | | -------------------- | ----- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `-w, --workers` | int | `10` | Number of scenarios run concurrently. Must be positive. | | `-c, --count` | uint | `1` | Run every scenario this many times. Multiplies with `env_matrix` variants, so a scenario with a 4-way matrix and `--count 3` runs 12 times. | | `-i, --insecure` | | off | Skip TLS certificate verification. | | `-u, --unrestricted` | | off | Load the Lua `os` and `io` libraries. This disables the script sandbox; only use it with files you trust. See [Libraries](https://sinq.veitangie.dev/lua-api/#12-libraries). | | `-l, --list` | | | Parse the paths and print the scenarios that would run, without executing them. | ### Selecting scenarios | Flag | Value | Default | Notes | | ------------ | ------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `-t, --tag` | string | | Run only scenarios carrying at least one of the given tags. Repeatable. | | `--no-tag` | string | | Skip scenarios carrying any of the given tags. Repeatable. | | `-n, --name` | regex | | Run only scenarios whose name matches at least one of the given [Go regular expressions](https://pkg.go.dev/regexp/syntax). Repeatable. | | `--no-name` | regex | | Skip scenarios whose name matches any of the given regular expressions. Repeatable. | How the four combine: - Within one flag the values are **OR**-ed: `-t smoke -t auth` runs scenarios tagged `smoke` *or* `auth`. - Across flag types the conditions are **AND**-ed: a scenario must satisfy the tag filter *and* the name filter to run. - The `--no-*` filters win: if a scenario matches an exclude, it is skipped even if it also matches an include. A scenario's name comes from its `.scenario` file (`"name"`), defaulting to the leaf directory path. Its tags are the union of the `"tags"` arrays in every `.scenario` file along its path. ### Output and reporting | Flag | Value | Default | Notes | | ------------------- | -------- | --------- | --------------------------------------------------------------------------------------------------------------------------- | | `-f, --format` | `std` | `junit` | `std` | | `-o, --out` | path | stdout | Write the report to a file instead of stdout. Missing parent directories are created. | | `-S, --show` | `all` | `no-skip` | `failed` | | `-V, --verbose` | | off | Add per-stage timings to each request in the report. `std` format only. | | `-p, --print` | | off | Capture Lua `print` / `io.write` output and attach it to the scenario result. Without this, that output is discarded. | | `-C, --color` | `always` | `never` | `auto` | | `--no-spinner` | | off | Disable the live spinner / progress bar. | | `--dump-on-failure` | | off | Print the full request and response on a failed assertion. May expose secrets; use with care in CI where logs are retained. | | `-L, --log-level` | `debug` | `info` | `warn` | ### Overrides and inputs | Flag | Value | Default | Notes | | ---------------- | ----------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `-e, --env` | `key=value` | | Override an environment value for every scenario. Repeatable. Keys use dot-notation for nesting (`API.TIMEOUT=500`) and values are parsed as JSON, so arrays and objects work (`HOSTS=["a","b"]`). | | `-s, --secret` | `key=value` | | Same as `--env`, but for the `secrets` table. Repeatable. | | `--secrets-file` | path | | Load `secrets` from a JSON file. Values from `--secret` override individual keys. | | `--plugins` | path | | Directories to add to the Lua `require` search path. Join multiple with `:` (`;` on Windows), or pass the flag more than once. Takes precedence over `SINQ_LUA_PATH`. See [Libraries](https://sinq.veitangie.dev/lua-api/#12-libraries). | ### Caching These tune the opt-in per-request response cache (`req.cache(true)` or `req.cache(key)`); see [Execution Runner](https://sinq.veitangie.dev/runner/#ast-bytecode-caching-request-collapsing). | Flag | Value | Default | Notes | | ------------------ | -------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--cache-timeout` | duration | `10s` | Timeout for the network call that fills the cache. That call is shared across scenarios via singleflight, so it can't use any single scenario's `req_timeout`. Accepts Go durations (`500ms`, `2m`). | | `--max-cache-size` | size | `5MiB` | Largest response body that will be cached. Accepts `B`, `KiB`, `MiB`, `GiB` (and `KB`/`MB`/`GB`). | ### Information | Flag | Notes | | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `-v, --version` | Print the version and exit. | | `-h, --help` | Print the help text and exit. | | `--completion` | Print a shell completion script for the detected shell (Bash, Zsh, Fish, or PowerShell on Windows) and exit. See [Getting Started](https://sinq.veitangie.dev/getting-started/index.md). | ## Environment variables | Variable | Effect | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SINQ_LUA_PATH` | `PATH`-style list of directories added to the Lua `require` search path. Ignored if `--plugins` is given. | | `NO_COLOR` / `SINQ_NO_COLOR` | Any non-empty value disables colored output and the spinner, regardless of `--color auto`. | | `CI` | Any non-empty value has the same effect as `NO_COLOR`, and additionally makes `sinq` warn when `--log-level debug` or `--dump-on-failure` is set, since CI logs are usually retained. | | `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | Standard Go proxy configuration for outgoing requests (lowercase names are also honored). | ## Exit codes | Code | Meaning | | ---- | ------------------------------------------------------------------------- | | `0` | Every scenario ran and passed. | | `1` | A scenario failed an assertion or errored, or the arguments were invalid. | ## Examples ```bash # Run a suite with 20 workers and write a JUnit report sinq -w 20 -f junit -o report.xml ./tests/e2e # Only the scenarios tagged "smoke", skipping anything tagged "flaky" sinq -t smoke --no-tag flaky ./tests # Only scenarios whose name contains "checkout", run 5 times each sinq -n 'checkout' --count 5 ./tests # Override a nested env key and a secret, ignore self-signed certs sinq -e 'API.BASE=https://staging.local' -s 'DB.PASS=hunter2' -i ./tests # List what would run, without sending anything sinq --list ./tests ``` # Scenarios & Configuration A **scenario** is a sequence of HTTP requests run in order, defined across one or more `.sinq` files. ## Writing Requests A `.sinq` file is a standard HTTP request. You can define multiple requests in a single file by separating them with the `###` delimiter. The text after `###` on the same line names the request that *follows* it. This name shows up in `--list` output and in the standard/JUnit reports. ```http ### Login Request POST /api/auth {"user": "admin"} ### Fetch Data GET /api/data ``` A leading `###` (before any request content) works the same way and names the file's *first* request, even if the file only contains one: ```http ### Healthcheck GET /health ``` To make your requests dynamic, you can use Lua scripts. `sinq` provides two ways to run scripts: 1. **Inline Interpolation (`${...}`)**: Used to dynamically insert values into headers, URLs, or JSON payloads (e.g., `${env.BASE_URL}`). 1. **Lifecycle Hooks (`$PRE`, `$ASSERT`, etc.)**: Used to control the flow of the request, such as failing a test if a status code is wrong, or polling until a background job completes. ## Configuration You can configure timeouts, environment variables, and limits using JSON files ending in `.scenario`. The filename itself is arbitrary, only the extension matters (e.g. `config.scenario`, `00_base.scenario`). When you place a `.scenario` file in a directory, those settings apply to all tests in that directory and its subdirectories. Available settings: ```json { "name": "My API Test", "description": "Tests the core user flow", "env": { "BASE_URL": "https://api.local" }, "req_timeout": "5s", "script_timeout": "5s", "timeout": "10m", "fail_fast": true, "max_retries": 10, "max_redirects": 5, "max_body": "1MiB", "env_matrix": [], "tags": [] } ``` - **`name`**: Scenario name, shown in reports and used by the `-n` / `--no-name` filters. Defaults to the leaf directory path. If the same `.scenario` file is inherited by several leaf directories, they all take this name. - **`description`**: Free-text description of the scenario. - **`env`**: Variables defined here become accessible in your `.sinq` files as `${env.VARIABLE_NAME}` or in Lua scripts via the `env` table. - **`req_timeout`**: Maximum time to wait for a single HTTP network request. - **`script_timeout`**: Maximum time a single Lua script block is allowed to run. - **`timeout`**: Maximum time allowed for the entire scenario to complete. - **`fail_fast`**: If true, the scenario aborts immediately upon the first assertion failure. - **`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`**: The maximum response body size stored in memory. Responses exceeding this are safely truncated. - **`env_matrix`**: Data sets for running the scenario once per combination (parameterized / matrix testing). Aggregated along the path. See [Environment Matrix](https://sinq.veitangie.dev/env-matrix/index.md). - **`tags`**: Labels attached to every scenario that inherits this file. Collected into one list along the path and matched by the `-t` / `--tag` and `--no-tag` filters. In JUnit reports they're written as a `tags` property of the scenario's ``. ______________________________________________________________________ ## The Request Lifecycle State Machine Each request goes through these stages in order: 1. **`$PRE` Execution:** Executes first. This is where you configure dynamic variables or file I/O (`req.attach`, `req.multipart`, `req.saveResponseTo`, `req.cache`, `req.skip`). *Current HTTP request body and headers are not yet accessible.* 1. **Materialization:** The engine scans the raw HTTP text and evaluates inline scripts (e.g., `${env.HOST}`). The output is injected directly into the byte stream: strings as is, numbers and booleans as text, tables as compact JSON (see [Lua API](https://sinq.veitangie.dev/lua-api/#returned-values)). 1. **HTTP Parsing:** The materialized byte stream is parsed into a standard Go `http.Request`. 1. **Execution (Send):** The HTTP request is sent over the network. 1. **`$RETRY` Loop:** Executes immediately after receiving the response. Must return a number: milliseconds to sleep before retrying (jumps back to Step 4). A negative number breaks the loop. 1. **`$ASSERT` Execution:** Evaluates the final response to pass or fail the test. 1. **`$POST` Execution:** Used to extract state. *Skipped if `$ASSERT` failed and `fail_fast` is true.* ## Configuration Aggregation (Deep Merging) When a leaf directory inherits a `config.scenario` file from a parent, configurations are **deep merged**. If a parent sets `"req_timeout": "5s"` and a child sets `"env": {"NEW": "true"}`, the resulting scenario will have both settings. If both define the same key, the child's value overwrites the parent's value. Unmentioned default values (like `fail_fast`) are preserved throughout the merge chain. ## AST Caching & Request Collapsing `sinq` compiles every Lua block to bytecode (AST) and caches it in memory. The cache key is a hash of the script's file, name, byte-offset, and contents. If multiple workers process an identical request at the same time, `sinq` can collapse them with a `singleflight` mechanism. This is strictly **opt-in per request** by calling `req.cache(true)` (or `req.cache(key)` with an explicit cache key) in the `$PRE` block. When enabled, the first worker performs the network call, while all other waiting workers receive the cached result instantly when the first finishes. # sinq Lua API & Type Translation `sinq` runs your Lua in a sandbox and translates values across the Go/Lua boundary. The API is **scoped**: some functions are only available inside specific lifecycle hooks, which keeps concurrent scenarios from leaking state into each other. ______________________________________________________________________ ## 1. Global State & Environment These variables and functions are available globally in **all** script blocks. ### `env` A table containing the environment variables configured for the current scenario merged with all the values passed via the `-e` / `--env` flags. Modifications made to this table from a user script persist for the lifetime of the scenario. ```lua -- Example usage in a script or inline string interpolation local host = env.BASE_URL ``` ### `secrets` A table containing sensitive values passed to `sinq` via the `-s` / `--secret` / `--secrets-file` CLI flags. ### `req` and `res` (Current Request Context) Shorthands for the *current* request and response being processed. - `req`: Used in `$PRE` to modify the outgoing request (e.g., `req.attach()`). - `res`: A direct reference to `sinq.responses[%current%]`. ### Flow Control - **`sinq.setNextRequest(index)`**: Alters execution flow. The next request executed will be the one at the specified `index` (1-based). Useful for building loops or conditional skips within a scenario. It can be called from any hook of the current request, including `$PRE`, which is the place to drive a loop whose runs may call `req.skip()`, since `$POST` doesn't run for a skipped request. A request that runs more than once is reported once. Its status is the most severe across its runs (error, then failure, then aborted, then success, then skipped). It keeps the assertion failures of every run, its timings and attempt counts add up, and `--dump-on-failure` shows the first failing run. - **`sinq.finishScenario()`**: Alters execution flow. Tells `sinq` to finish the scenario once the current request completes its life cycle. Useful for gracefully finishing loops or conditional scenario shutdowns. ### Standard Output (`print` & `io.write`) `print` (and `io.write`, if `io` is enabled via `--unrestricted`) works in every script. Because scenarios run in parallel, printing straight to the terminal would interleave badly, so this output is discarded by default. Pass `-p` / `--print` to buffer it and attach it to the scenario result instead. ______________________________________________________________________ ## 2. Variable Scoping (Local vs. Global) Control variable scoping carefully to avoid leaking state between requests. - **Temporary Math & Logic:** Use the `local` keyword. This ensures the variable is garbage-collected immediately after the script block finishes. ```lua local data = res.json() local id = data.id ``` - **Passing State Across Files:** If you need a value from `01_login.sinq` to be accessible in `02_action.sinq`, you must declare it globally (without `local`). It will be attached to the Lua sandbox for the lifespan of that specific scenario (`_G`). ```lua AUTH_TOKEN = res.json().token -- Equivalent to: _G.AUTH_TOKEN = ... ``` ______________________________________________________________________ ## 3. Inline Scripts (Request Templating) Aside from lifecycle hooks, you can use General/Inline scripts to dynamically build your HTTP requests. These are evaluated *after* `$PRE` but *before* the request is sent. The return value of these scripts is injected directly into the raw HTTP text, converted as described in [Returned Values](#returned-values). You can name them (e.g., `$MY_SCRIPT{...}`) or leave them anonymous (`${...}`). **Single-line Interpolation:** If an inline script fails to compile, `sinq` automatically prepends `return` and retries. This allows for clean, single-line variable interpolation: ```http GET ${env.BASE_URL}/users/${CREATED_USER_ID} Authorization: Bearer ${secrets.API_KEY} ``` Note Calling functions inside an inline script can make it ambiguous for the compiler to determine if a `return` should be prepended. In those cases, you must explicitly write `return`. **Multi-line Dynamic Generation:** For complex logic, use explicit returns: ```http POST ${env.BASE_URL}/users Content-Type: application/json { "email": "$GENERATE_EMAIL{ local random_num = math.random(1000, 9999) return 'testuser_' .. random_num .. '@example.com' }", "role": "admin" } ``` ### Returned Values Inline scripts must return a value. Only the **first** returned value is used, so helpers that return `(result, error)` can be used directly: `${ return sinq.json.serialize(t) }` inserts the JSON, not the error. Returning nothing, or `nil` as the first value, fails the request materialization. The value is inserted according to its type: | Returned value | Inserted as | Example | | ------------------------------ | ------------------------------------------------------------------------------------------ | -------------------------------- | | string | the string itself, as is | `return 'abc'` → `abc` | | number | its Lua text | `return 3.5` → `3.5` | | boolean | `true` or `false` | `return true` → `true` | | table | compact JSON, using the same rules as [`sinq.json.serialize`](#10-json-utilities-sinqjson) | `return { id = 7 }` → `{"id":7}` | | `sinq.json.null` | `null` | `return sinq.json.null` → `null` | | `nil` or nothing | an error, the request fails | | | function, channel or coroutine | an error (`unexpected type …`), the request fails | `return print` | Tables let you pass a JSON sub-document straight through, for example `"address": ${ env.pointA }`. If the table can't be serialized (a mixed or sparse table, a cycle), the request fails with the serialization error. An empty table becomes `{}`; use [`sinq.json.array()`](#10-json-utilities-sinqjson) for `[]`. Warning Strings are inserted raw, with no quoting or escaping, because the template supplies the quotes (`"${ name }"`). Don't wrap a table in quotes: `"${ { a = 1 } }"` produces `"{"a":1}"`, which is invalid JSON. ______________________________________________________________________ ## 4. Lifecycle-Specific APIs The following APIs are dynamically injected and destroyed depending on the execution phase of the request. ### `$PRE` (Setup Phase) Executes before the HTTP request is materialized. Used for file I/O operations. - **`req.attach(filepath string)`**: Replaces the request body with the contents of the specified file. *Note: Fails if a textual body is already defined in the `.sinq` file.* - **`req.saveResponseTo(filepath string)`**: Streams the upcoming response body directly to disk, bypassing the Lua memory buffer. Ideal for downloading large files. Automatically creates missing directories in the filepath. If used, `bodyRaw` and JSON methods will not be available in subsequent hooks. - **`req.cache(enable bool?)`** / **`req.cache(key string)`**: Turns on client-side response caching for this request, or off with `false`. With no argument, the cache key is derived from the request: the bytes sent over the wire, the cookies sent with it, attached file names (`attach`, `saveResponseTo`), multipart parts and the scenario directory. With a string, that string is the key, used as is: every request that calls `req.cache` with the same key shares one cached response, whatever its method, URL or body. Any other argument (a number, `nil`, a table) raises an error. Calling it again replaces the earlier setting. - Each retry attempt is cached separately, so `$RETRY` polling still sends a new request on every attempt. - A response served from the cache still counts toward "requests sent" in the summary, even though nothing reached the server. - A custom key is not scoped for you. To keep two requests apart, put a prefix in the key yourself: `req.cache("auth:" .. env.phoneNumber)`. Warning The first request to fill a key decides what is stored, including where its body went. If that request used `req.saveResponseTo`, later requests sharing the key get an empty body and their own `saveResponseTo` file isn't written. An error is cached the same way, and nothing is evicted until the run ends. Share a key only between requests that expect the same response. - **`req.skip(enable 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 reported as skipped, without a test failure. - **`req.multipart(entry table, ...)`**: Builds a `multipart/form-data` request body from one or more part definitions. See [Multipart Bodies](#multipart-bodies) below. - **`req.multipartBoundary(boundary string)`**: Overrides the randomly generated multipart boundary. See [Multipart Bodies](#multipart-bodies). Note `req.attach`, `req.saveResponseTo`, and a multipart entry's `source` all expect a path relative to the current `.sinq` file. Passing an absolute path fails. #### Multipart Bodies **`req.multipart(entry table, ...)`** assembles a `multipart/form-data` body. Parts may be passed as positional arguments (`req.multipart(a, b)`) or as a single contiguous array table (`req.multipart{ a, b }`); both forms are equivalent. Repeated calls **accumulate** parts in order, and duplicate field names are allowed. Each `entry` is a table with these keys (all optional unless noted): | Key | Type | Meaning | | -------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `name` | string | Form field name. **Required** unless `contentDisposition` is set. | | `data` | string | Literal part body. Mutually exclusive with `source`. | | `source` | string | Path to a file whose contents become the part body. Mutually exclusive with `data`. Resolved relative to the current `.sinq` file; absolute paths and paths that do not resolve to a readable file are rejected. | | `filename` | string | boolean | | `contentType` | string | The part's `Content-Type`. If omitted **and** the part has a filename, it is sniffed from the filename extension, then the `source` extension, falling back to `application/octet-stream`. Field parts get no `Content-Type` unless you set one. | | `contentDisposition` | string | Sets the raw `Content-Disposition` header verbatim. When present, `name` and `filename` are ignored. | | `headers` | table | Extra per-part headers, e.g. `{ ["X-Chunk"] = "3" }`. Keys are canonicalised; values may be string, number, or boolean. An explicit `contentType` / `contentDisposition` wins over a same-named `headers` entry. | A non-string value for any of the string-typed keys is an error, as is providing both `data` and `source`, or an entry with neither `name` nor `contentDisposition`. A table that mixes array entries with named keys uses only the array entries. `name` and `filename` only apply when `sinq` builds the `Content-Disposition` header for you. If you set `contentDisposition` yourself, it is used verbatim and `name` / `filename` on that entry are ignored entirely (not even type-checked). **`req.multipartBoundary(boundary string)`** replaces the random boundary with a fixed one. It is validated (RFC 2046) when the body is assembled; an invalid value fails the request. It is a no-op without `req.multipart`, and you are responsible for ensuring the boundary does not occur in any part's content. Note `req.multipart` sets `Content-Type: multipart/form-data; boundary=…` on the request **only if you have not already set a `Content-Type` header yourself**. A header written in the `.sinq` file (even one with a deliberately mismatched boundary) is left untouched. `req.multipart` is mutually exclusive with `req.attach` and with a literal body in the `.sinq` file. The assembled body is buffered in memory, so it is meant for form fields and modestly sized files rather than multi-gigabyte uploads. ```text POST ${env.BASE_URL}/avatar Authorization: Bearer ${AUTH_TOKEN} $PRE{ req.multipart( { name = "caption", data = "vacation 2026" }, { name = "photo", source = "fixtures/beach.jpg", filename = true }, { name = "meta", data = sinq.json.serialize({ album = "trips" }), contentType = "application/json" } ) } $ASSERT{ sinq.assert.code(201) } ``` ### `$RETRY` (Polling Phase) Executes after receiving a response. The script **must** return a number indicating how many milliseconds to wait before retrying, or a negative number to stop. - **`sinq.retry.stop`**: A constant (`-1`) indicating the retry loop should break immediately. - **`sinq.retry.when(condition boolean, delay number?)`** - Retries if `condition` is true. `delay` defaults to `500ms`. - **`sinq.retry.whenExponential(condition boolean, base number?, constant number?)`** - Retries if `condition` is true, using exponential backoff (`base ^ attempt * constant`). - `base` defaults to `2` (Max `10`). `constant` defaults to `500ms`. - **`sinq.retry.withJitter(condition boolean, range number?, delegate function?, delegate_args any...)`** - Adds randomized jitter to a retry calculation to prevent thundering herd problems. - `range` defaults to `50` (±50ms jitter). `delegate` defaults to `sinq.retry.when`, delegate will be passed condition and delegate_args when called. - Usage is: `sinq.retry.withJitter(res.code ~= 200, 100, sinq.retry.when, 2 * sinq.time.second)` - jitter conditional retry with range of [-100:100] ### `$ASSERT` (Validation Phase) Executes after the retry loop finishes. Used to validate the final state of the response. - **`sinq.assert.fail(reason string)`**: Marks the test as failed with the provided reason. **Note: This does not halt Lua execution.** The rest of the `$ASSERT` block will continue to run, allowing you to collect multiple failure reasons for a single request. ```lua $ASSERT{ local data = res.json() if data.id == nil then sinq.assert.fail("ID is missing") end if data.status ~= "active" then sinq.assert.fail("User is not active") end -- If both conditions are met, the report will show TWO failures for this request. } ``` - **`sinq.assert.code(expectedHttpCode number, message string?)`**: Fails if the actual status code does not match. - **`sinq.assert.equals(actual any, expected any, message string?)`**: Fails if `actual` does not equal `expected`. When comparing tables, checks that every key-value pair in `expected` recursively matches those in `actual`, but ignores pairs from `actual` not present in `expected`. Without `message`, the failure text never includes the compared values. It reports a type mismatch (`Expected type string, got type nil`) or an incorrect value, and for tables the path of the mismatching field (`Incorrect value in field auth.token`). A `message` replaces that text. - **`sinq.assert.contains(source string, substring string, message string?)`**: Fails if the string does not contain the specified substring. Without `message`, the failure text includes neither `source` nor `substring`. A `message` replaces the default text. - **`sinq.assert.isTrue(condition boolean, message string?)`**: Fails if the condition resolves to `false` or `nil`. - **`sinq.assert.fileMatches(filepath string)`**: Fails if the response previously saved using `req.saveResponseTo()` does not exactly match the contents of `filepath`. Fails immediately if `req.saveResponseTo()` was not called. ### `$POST` (State Extraction Phase) Executes after a successful `$ASSERT` phase. Typically used to parse the final response payload and store relevant data in the global sandbox for subsequent requests. No special scoped APIs are injected here. ______________________________________________________________________ ## 5. The Responses Table (`sinq.responses`) When an HTTP request completes, `sinq` parses the response and injects it into the `sinq.responses` table at the index corresponding to the request number. Lua is 1-indexed, meaning the response to the first request in your scenario is accessed via `sinq.responses[1]`. Note A response object only exists *after* the request has been executed. Accessing `sinq.responses[2]` or the alias `res` during the `$PRE` hook of the second request will return nil. ### Response Object Structure - `attempt` *(number)*: The current execution attempt (useful during `$RETRY`). - `code` *(number)*: The HTTP status code (e.g., `200`, `404`). - `oversized` *(boolean | nil)*: `true` if the payload exceeded the scenario's `max_body` limit and was safely truncated. ### Body Access Methods Note These are only available if `req.saveResponseTo()` was NOT used in the `$PRE` hook. - `bodyRaw` *(string)*: The raw string of the response payload. - `extractBodyJson()` *(function)*: Safely attempts to parse `bodyRaw` into a Lua table. - **Returns:** `(result table, error string)` - `json()` *(function)*: An unsafe convenience wrapper around `extractBodyJson`. - **Returns:** `table` directly. - **Throws:** Calls a fatal `error()` if the body is not valid JSON, failing the scenario immediately. ### HTTP Headers Translation HTTP headers are complex because a single key can have multiple values. `sinq` handles this translation automatically. - **Single Value Headers:** Translated to a standard Lua string. ```lua local contentType = res.headers["Content-Type"] ``` - **Multi-Value Headers:** Translated to a 1-indexed Lua table (array) of strings. ```lua local firstCookie = res.headers["Set-Cookie"][1] ``` JSON Blindspot (1-Indexed Arrays) In Go and in general, arrays are `0-indexed`. In Lua, tables are `1-indexed`. If your API returns a top-level JSON array, `sinq` translates it into a Lua table starting at index 1. **API Response:** ```json [ {"id": 42}, {"id": 99} ] ``` **Lua Assertion:** ```lua $ASSERT{ -- Correct: Access the first element at index 1 local data = res.json() local first_id = data[1].id if first_id ~= 42 then sinq.assert.fail("ID mismatch") end } ``` ______________________________________________________________________ ## 6. Extensions Quick Reference - [`sinq.time.ms`](#constants) / [`sinq.time.second`](#constants) / [`sinq.time.minute`](#constants) / [`sinq.time.hour`](#constants) - [`sinq.time.now()`](#functions) - [`sinq.time.fromString(str string, format string?)`](#functions) - [`sinq.time.toString(ms number, format string?)`](#functions) - [`sinq.time.sleep(ms number)`](#functions) - [`sinq.encoding.base64Encode(source string)`](#encoding) - [`sinq.encoding.base64Decode(source string)`](#encoding) - [`sinq.encoding.base64UrlEncode(source string)`](#encoding) - [`sinq.encoding.base64UrlDecode(source string)`](#encoding) - [`sinq.encoding.hexEncode(source string)`](#encoding) - [`sinq.encoding.hexDecode(source string)`](#encoding) - [`sinq.encoding.urlEncode(source string)`](#encoding) - [`sinq.encoding.urlDecode(source string)`](#encoding) - [`sinq.crypto.md5(source string, encoding string?)`](#hashing) - [`sinq.crypto.sha1(source string, encoding string?)`](#hashing) - [`sinq.crypto.sha256(source string, encoding string?)`](#hashing) - [`sinq.crypto.sha512(source string, encoding string?)`](#hashing) - [`sinq.crypto.hmac(source string, algo string?, key string?, encoding string?)`](#hashing) - [`sinq.jwt.decode(token string)`](#9-jwt-api-sinqjwt) - [`sinq.jwt.verify(token string, key string, algo string?)`](#9-jwt-api-sinqjwt) - [`sinq.jwt.sign(claimsTable table, key string, method string?)`](#9-jwt-api-sinqjwt) - [`sinq.json.parse(source string)`](#10-json-utilities-sinqjson) - [`sinq.json.serialize(tbl table, indent string?)`](#10-json-utilities-sinqjson) - [`sinq.json.array(tbl table?)`](#10-json-utilities-sinqjson) ______________________________________________________________________ ## 7. Time API (`sinq.time.*`) Built-in constants and functions to make time-based logic and parsing possible. ### Constants - **`sinq.time.ms`** (1) - **`sinq.time.second`** (1000) - **`sinq.time.minute`** (60000) - **`sinq.time.hour`** (3600000) Note Lua uses `float64` for numbers. When converting a timestamp from milliseconds to another unit (e.g., seconds) using division, use `math.floor` to ensure a clean integer: `math.floor(sinq.time.now() / sinq.time.second)`. ### Functions - **`sinq.time.now()`**: Returns the current UNIX timestamp. - **Returns:** `number` (milliseconds since epoch). - **`sinq.time.fromString(str string, format string?)`**: Parses a time string into a UNIX timestamp (milliseconds). - **Returns:** `(result number, error string)` - **Format Rules:** Uses [Go's time layout rules](https://pkg.go.dev/time#pkg-constants). If omitted, defaults to ISO8601 (`2006-01-02T15:04:05.000Z07:00`). - **`sinq.time.toString(ms number, format string?)`**: Formats a UNIX timestamp (milliseconds) into a time string. - **Returns:** `string` - **Format Rules:** Uses [Go's time layout rules](https://pkg.go.dev/time#pkg-constants). If omitted, defaults to ISO8601. - **`sinq.time.sleep(ms number)`**: Blocks the current script for `ms` milliseconds, then returns nothing. Useful as an unconditional delay before a request fires (e.g. `$PRE{ sinq.time.sleep(2 * sinq.time.second) }` to wait out a fixed propagation delay). - The sleep is interrupted immediately by `Ctrl+C` or the scenario `timeout`. - It counts against `script_timeout` like any other script time. A sleep longer than `script_timeout` will time the script out (or, if it is the last statement in the block, be cut short at the `script_timeout` mark). Raise `script_timeout` in the `.scenario` file for longer waits. - A worker running a sleep cannot pick up another scenario, exactly like a `$RETRY` backoff. - Prefer a `$RETRY` policy (`sinq.retry.when(condition, delay)`) whenever the endpoint gives you a readiness signal; reach for `sleep` only for genuinely unconditional waits. Note A non-numeric argument is an error. A negative or zero `ms` returns immediately. ______________________________________________________________________ ## 8. Encoding & Crypto APIs `sinq.encoding.*` holds reversible, non-cryptographic transforms (Base64, hex, URL escaping). `sinq.crypto.*` holds hashing and message authentication. They are separate namespaces, and the encoding helpers are **not** reachable via `sinq.crypto`. ### Encoding `sinq.encoding.*` - **`sinq.encoding.base64Encode(source string)`**: Encodes a string into standard (padded) Base64. - **Returns:** `string` - **`sinq.encoding.base64Decode(source string)`**: Decodes a standard Base64 string. - **Returns:** `(result string, error string)` - **`sinq.encoding.base64UrlEncode(source string)`**: Encodes a string into URL-safe Base64. - **Returns:** `string` - **`sinq.encoding.base64UrlDecode(source string)`**: Decodes a URL-safe Base64 string. - **Returns:** `(result string, error string)` - **`sinq.encoding.hexEncode(source string)`**: Encodes a string into a hexadecimal representation. - **Returns:** `string` - **`sinq.encoding.hexDecode(source string)`**: Decodes a hexadecimal string. - **Returns:** `(result string, error string)` - **`sinq.encoding.urlEncode(source string)`**: Percent-encodes a string for use in a query string or an `application/x-www-form-urlencoded` body. Spaces become `+`. - **Returns:** `string` - **`sinq.encoding.urlDecode(source string)`**: Reverses `urlEncode` (`+` is decoded back to a space). - **Returns:** `(result string, error string)`. The error is set for malformed percent escapes. Building an `application/x-www-form-urlencoded` body There is no dedicated form helper, so assemble the body yourself and set the header: ```text POST ${env.BASE_URL}/login Content-Type: application/x-www-form-urlencoded ${ "grant_type=password&username=" .. sinq.encoding.urlEncode(env.USER) .. "&password=" .. sinq.encoding.urlEncode(secrets.PASS) } ``` ### Hashing `sinq.crypto.*` - **`sinq.crypto.md5(source string, encoding string?)`**, **`sinq.crypto.sha1(source string, encoding string?)`**, **`sinq.crypto.sha256(source string, encoding string?)`**, **`sinq.crypto.sha512(source string, encoding string?)`**: Computes the cryptographic hash of the input string. - **Returns:** `(result string, error string)` - **Parameters:** `encoding` string defaults to `"hex"`. Supported values are `"hex"`, `"base64"`, `"base64url"`, and `"raw"`. Note Since it defaults to `"hex"`, the output is safe to print and transmit. If `"raw"` is used, the function returns the raw bytes. - **`sinq.crypto.hmac(source string, algo string?, key string?, encoding string?)`**: Computes the HMAC of the source string. - **Returns:** `(result string, error string)` - **Parameters:** `algo` string defaults to `"sha256"`. Supported values are `"sha256"`, `"sha1"`, `"sha512"`, and `"md5"`. `key` string defaults to `""`. `encoding` string defaults to `"hex"`. Supported values are `"hex"`, `"base64"`, `"base64url"`, and `"raw"`. ______________________________________________________________________ ## 9. JWT API (`sinq.jwt.*`) Allows for generation, decoding, and validation of JSON Web Tokens natively within your scenario flow. - **`sinq.jwt.decode(token string)`**: Decodes a JWT token without validating its signature. - **Returns:** `(result table, error string)` - **Table Structure:** Contains `header`(table), `claims` (table), `signature` (string), and `method` (string). - **`sinq.jwt.verify(token string, key string, algo string?)`**: Verifies the token using the provided key and optional algorithm constraint. - **Returns:** `(result table, error string)` Note Symmetric algorithms (`HS*`) use raw string keys. Asymmetric algorithms (`RS*`, `ES*`, `EdDSA`) require PEM-encoded public keys. - **`sinq.jwt.sign(claimsTable table, key string, method string?)`**: Creates a signed JWT string. - **Returns:** `(result string, error string)` - `claimsTable`: A Lua table representing the JWT payload. - `key` string: The signing key string. - `method` string?: The signing algorithm. Defaults to `HS256`. Note The `claimsTable` must have strictly string keys. Mixing list-style (integer) indices with string keys in Lua will cause parsing to fail and return an error. Asymmetric algorithms require PEM-encoded private keys. Warning Passing a cyclic table as the `claimsTable` will result in a serialization error being returned as a second return value (`nil, "Failed to serialize..."`). It is safe and will not crash the runner, but the token will not be generated. ______________________________________________________________________ ## 10. JSON Utilities (`sinq.json.*`) The `sinq.json` table provides explicit methods to parse and serialize JSON data from Lua. - **`sinq.json.parse(source string)`**: Parses a JSON string into a Lua table. - **Returns:** `(result table, error string)` - Every JSON array is returned as a table marked as an array (the mark is kept in its metatable), so an array that is empty, or becomes empty later, still serializes as `[]`. The table is otherwise an ordinary Lua table: `#`, `ipairs` and `table.insert` work as usual. Replacing its metatable with `setmetatable` removes the mark. - **`sinq.json.serialize(tbl table, indent string?)`**: Serializes a Lua table into a JSON string. - **Returns:** `(result string, error string)` - `indent` string?: Optional string used for formatting (e.g., `" "`). If omitted, produces compact JSON. If present, also introduces newlines between object and array entries. - How a table is serialized: - Keys `1..n` with no gaps and no other keys: a JSON array. - Only string keys: a JSON object. - Empty: `[]` if it's marked as an array (parsed from a JSON array, or passed through `sinq.json.array`), `{}` otherwise. - Anything else is an error: integer and string keys mixed together, gaps in the integer keys (`{ 1, nil, 3 }`), number keys that aren't whole or fall outside `1..n`, or keys of other types. Note Passing a cyclic table will immediately return an error (`"Cycle detected, unable to serialize"`). Note Lua can't store `nil` in a table, so a hole in an array is a gap and fails serialization. To send `[1, null, 3]`, use `{ 1, sinq.json.null, 3 }`. - **`sinq.json.array(tbl table?)`**: Marks a table as a JSON array, so it serializes as `[]` when empty instead of `{}`. - **Returns:** `(result table, error string)` - Without an argument (or with `nil`), returns a new empty array: `"tags": ${ return sinq.json.array() }` sends `"tags": []`. - With a table, marks that table in place and returns it. The table must be array-shaped: empty, or keys `1..n` with no gaps and nothing else. A non-empty array already serializes as an array; marking it matters when it may end up empty, such as a list that is filtered down later. - Returns `nil` and an error for a table that isn't array-shaped (in which case the table isn't marked), for an argument that isn't a table, or for more than one argument. Note If the table already has a metatable, the mark is added to that metatable and nothing else changes. If several tables share that metatable, they are all marked. - **`sinq.json.null`**: A special constant representing a JSON `null` value, allowing Lua tables to explicitly serialize `null` properties (since standard Lua drops `nil` table keys). Tables, parsed from JSON will also include this constant to represent explicit `null`. Can be compared with standard `==` operator (`sinq.assert.isTrue(res.json().myNull == sinq.json.null)`) ______________________________________________________________________ ## 11. Fake Data Generation (`sinq.fake.*`) The `sinq.fake` table exposes deterministic fake data generators. All generators respect the current seed. #### Primitives & Core Data - **`sinq.fake.uuid()`** (alias: **`sinq.fake.uuidv4()`**): Returns a random UUIDv4 string. - **`sinq.fake.int(min?, max?)`**: Returns a random integer. - **`sinq.fake.float(min?, max?)`**: Returns a random float. - **`sinq.fake.shakespeare()`**: Returns a random boolean (`true` or `false`). - **`sinq.fake.oneOf(array)`**: Accepts a Lua array (table with integer keys) and returns a random element. #### Networking & Web - **`sinq.fake.email()`**: Returns a random email address. - **`sinq.fake.ipv4()`**: Returns a random IPv4 address. - **`sinq.fake.ipv6()`**: Returns a random IPv6 address. - **`sinq.fake.url()`**: Returns a random URL string. - **`sinq.fake.userAgent()`**: Returns a random User-Agent string. - **`sinq.fake.trace()`**: Returns a random W3C traceparent header string. - **`sinq.fake.username()`**: Returns a random username. - **`sinq.fake.password()`**: Returns a random password. #### Identity & Text - **`sinq.fake.name()`**: Returns a full name. - **`sinq.fake.firstName()`**: Returns a first name. - **`sinq.fake.lastName()`**: Returns a last name. - **`sinq.fake.phone()`**: Returns a random phone number. - **`sinq.fake.address()`**: Returns a full address. - **`sinq.fake.company()`**: Returns a company name. - **`sinq.fake.word()`**: Returns a single random word. #### Time & Configuration - **`sinq.fake.timestamp(fromMs, toMs?)`**: Returns a random UNIX timestamp (integer milliseconds) between `fromMs` and `toMs`. If `toMs` is omitted, it defaults to the current time. - **`sinq.fake.setSeed(int64)`**: Seeds the fake data generator to ensure deterministic output across runs. #### Additional Randomness - **`math.random(max?)`, `math.random(min, max)`**: Lua's standard way of generating pseudo-random data is present in `sinq` and always available. ______________________________________________________________________ ## 12. Libraries `sinq` does not load two common core Lua libraries - `io` and `os` - by default, so that a `.sinq` script cannot touch the process, spawn a shell, or reach the filesystem outside its scenario workspace. `--unrestricted` disables the sandbox Passing `--unrestricted` (`-u`) loads the full `os` and `io` libraries. Scripts then gain `os.execute` (run arbitrary shell commands), `os.exit` (terminate `sinq` immediately, skipping all reporting and cleanup), `os.remove` / `os.rename` / `os.setenv`, and `io.open` (read and write any path the `sinq` process can, not just files under the scenario directory). Only pass `-u` for scenario files you fully trust - treat it the way you would treat piping a script straight into your shell. `sinq` allows you to import external Lua packages. To make them accessible via `require("package")`, you must provide the directory paths containing those packages. You can do this using the `SINQ_LUA_PATH` environment variable or the `--plugins` CLI flag (which takes precedence). Multiple paths should be separated by a colon (`:`) on macOS/Linux, or a semicolon (`;`) on Windows. You can also pass the `--plugins` flag multiple times to aggregate paths. Note All paths passed to `sinq` as positional arguments and the current working directory also get appended to the end of path for the purposes of searching for Lua plugins. So if you run `sinq` from a directory containing a file `my-module.lua`, `require("my-module")` will work for all `.sinq` files. # Environment Matrix Mechanism `env_matrix` runs one scenario multiple times, once per combination of a set of environment variables (a Cartesian product). It lets you run the same workflow against different data without copying `.sinq` files or directories. ## Definition and Cartesian Product The `env_matrix` property in a `.scenario` file takes an array of objects. Each object represents an axis in the matrix. The keys in these objects serve as labels, and their values are the environment variable overrides/additions applied to that specific branch of the execution. The objects must have all their top-level keys correspond to objects. Empty objects (`{}`) are safely ignored and dropped by the parser. **`config.scenario`:** ```json { "env_matrix": [ { "admin": { "role": "admin", "auth_token": "sys-jwt-999" }, "guest": { "role": "guest", "auth_token": "guest-jwt-111" } }, { "card": { "method": "card", "expected_status": 200 }, "crypto": { "method": "crypto", "expected_status": 202 } } ] } ``` During the Treewalker aggregation phase, `sinq` gathers all of these arrays into a single flat array of objects. Then the runner dispatches the resulting scenario combined with every possible combination of environments from these objects (creating a Cartesian product by picking exactly one key-value pair from each axis object). These combinations are then deep merged with the static `env` for this particular run. The example above yields 4 distinct data sets that are sent to the worker pool: 1. **`admin_card`**: `role`="admin", `auth_token`="sys-jwt-999", `method`="card", `expected_status`=200 1. **`admin_crypto`**: `role`="admin", `auth_token`="sys-jwt-999", `method`="crypto", `expected_status`=202 1. **`guest_card`**: `role`="guest", `auth_token`="guest-jwt-111", `method`="card", `expected_status`=200 1. **`guest_crypto`**: `role`="guest", `auth_token`="guest-jwt-111", `method`="crypto", `expected_status`=202 Each generated scenario appends its labels to the base scenario name, e.g. `checkout_admin_card`. Note Because the combination parsing relies on Go map iteration, the generation and execution sequence of the matrix combinations is inherently non-deterministic. ## Usage in Requests The generated matrix values are injected directly into the scenario's environment. They can be accessed via standard interpolation (`${env.variable}`) in the raw HTTP text or inside Lua scripts via the `env` global table. **`01_checkout.sinq`:** ```text POST ${env.BASE_URL}/checkout Authorization: Bearer ${env.auth_token} Content-Type: application/json { "role": "${env.role}", "payment_method": "${env.method}" } $ASSERT{ sinq.assert.code(env.expected_status, "Matrix status code mismatch for " .. env.role .. " using " .. env.method) } ``` ## Inheritance and Deep Merging Matrix environments follow the normal Treewalker inheritance rules: 1. **Base Environment Overrides:** Values generated by a matrix combination take precedence over the base `env` aggregated for the scenario. The matrix values are deep-merged on top of the base environment. 1. **Parent-Child Matrix Multiplication:** If a parent directory defines an `env_matrix`, and a child directory also defines its own `env_matrix`, the child inherits the parent's axes. This results in the matrix expanding exponentially as the dimensions are multiplied together. Warning Be cautious when nesting matrices deeply within the directory tree, as it can unintentionally generate a massive number of concurrent workers and exhaust network or system resources. # CI/CD Integration & Reporting `sinq` runs in CI (GitHub Actions, GitLab CI, Jenkins) with nothing to install beyond the binary. ## Exit Codes `sinq` communicates test status to the CI runner via standard UNIX exit codes: - **`0` (Success):** All discovered scenarios executed successfully, all network calls completed, and no `$ASSERT` blocks triggered a failure. - **`1` (Failure):** One or more scenarios failed an assertion, encountered a network timeout, crashed, or the CLI received invalid arguments. ## JUnit XML Reporting The console output is for local runs. Most CI platforms read JUnit XML to track results over time and surface failing steps. To generate a JUnit report, use the `-f junit` flag and output it to a file: ```bash sinq -f junit -o report.xml ./tests/integration ``` ### How `sinq` maps to JUnit Elements - **``:** Maps directly to a single `sinq` Scenario (a leaf directory). - **``:** Holds the scenario's tags as a single ``, joined with `,`. It comes before the `` elements, as the JUnit schema expects, and is left out entirely for a scenario without tags. - **``:** Maps directly to a single `.sinq` file request within that scenario. - **``:** Triggered exclusively by Lua `$ASSERT` block failures (e.g., `sinq.assert.fail("Data missing")`). This indicates the system is up, but the business logic is wrong. - **``:** Triggered by Go-level runtime panics, context timeouts, dial connection refusals, or malformed HTTP parsing errors. This indicates the system or the test architecture is fundamentally broken. ### Example Output ```xml ``` # Versioning & Compatibility From `v1.0.0` onward, `sinq` follows [Semantic Versioning](https://semver.org/): - **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. # Architecture # Architecture Overview `sinq` works in three stages: it discovers the tests on disk, runs them across a worker pool, and reports the results. ## 1. Treewalker The Treewalker walks the directories you point `sinq` at and turns them into a list of scenarios. Test flows are folders rather than one large file, and a child folder inherits the setup files and configuration of its parents. [Treewalker & configuration aggregation](https://sinq.veitangie.dev/treewalker/index.md) ## 2. Execution Runner The Runner takes the scenarios from the Treewalker and runs them on a pool of workers. Each worker has its own Lua state and its own HTTP cookie jar, so one scenario cannot see another's variables or session even though they run at the same time. [Runner & concurrency](https://sinq.veitangie.dev/runner/index.md) ## 3. Reporters The Runner streams results to a reporter as scenarios finish. Reporters render the console output or write a JUnit XML file for CI. [CI/CD integration](https://sinq.veitangie.dev/ci-cd-integration/index.md) # Treewalker & Configuration The **Treewalker** discovers your tests and works out how to run them. API requests live in separate files and folders instead of one large file. ## How it Discovers Tests When you point `sinq` at a directory, the Treewalker scans it for `.sinq` and `.scenario` files. - **Parent folders** hold shared setup steps, such as a `01_login.sinq` script. - **Leaf folders** (no subdirectories) are one scenario each, such as `create_user` or `delete_user`. A leaf folder inherits every script from its parent folders. Write the login once in a parent folder and every leaf scenario runs it before its own requests. ## Sharing Configuration Configuration is inherited the same way as scripts. Put a JSON file ending in `.scenario` (for example `config.scenario`) in any directory to set environment variables or execution limits. Only the extension matters, not the filename. A leaf scenario inherits the `.scenario` files from every directory above it. When a child and a parent set the same key, the child's value wins. This per-key merge is called **deep merging**. **Parent `config.scenario`:** ```json { "req_timeout": "5s", "env": { "BASE_URL": "https://api.local", "FEATURE_FLAG": "true" } } ``` **Child (Leaf) `config.scenario`:** ```json { "req_timeout": "15s", "env": { "FEATURE_FLAG": "false", "NEW_VAR": "hello" } } ``` **Final Aggregated Configuration for the Leaf Scenario:** ```json { "req_timeout": "15s", "fail_fast": true, "env": { "BASE_URL": "https://api.local", "FEATURE_FLAG": "false", "NEW_VAR": "hello" } } ``` *(Notice how the unmentioned defaults, like `fail_fast`, are preserved, `BASE_URL` is inherited, and `FEATURE_FLAG` is overwritten).* ______________________________________________________________________ ## DAG Engine & Blueprints Under the hood, the Treewalker treats your physical filesystem as a Directed Acyclic Graph (DAG) to build test workflows. ### Core Algorithm 1. **Discovery:** Starting at the target root, the engine finds all `.scenario` and `.sinq` files. 1. **Sorting:** Files within the same directory are sorted in **natural alphanumeric order**. This means `2_request.sinq` will correctly execute before `10_finalize.sinq`. 1. **Descent & Inheritance:** The engine recursively descends into subdirectories. Child directories *inherit and append* the sorted `.scenario` and `.sinq` files from their parents. 1. **Blueprint Emission:** Once the engine reaches a directory containing **at least one** `.sinq` or `.scenario` file but **no subdirectories**, it compiles the accumulated path into a "scenario blueprint". 1. The Treewalker then emits a slice of these generated scenario blueprints to the Runner. *Note: Sibling leaf directories are completely isolated. `sinq` will spin up separate workers to execute these emitted blueprints concurrently.* ### Scenario Ordering **The Treewalker does not guarantee the order scenarios run in.** Blueprints go to the worker pool with no global ordering. Files *within* one scenario always run in natural-sorted order; only the order of scenarios relative to each other is unspecified. # Execution Runner The **Runner** executes the scenarios the Treewalker discovered. ## Concurrent Execution The unit of concurrency is the **scenario**, a whole workflow chain, not the individual request. `sinq` runs a pool of workers (10 by default, set with `-w` / `--workers`). With 10 workers and 10 scenarios of 5 requests each, all 10 scenarios run at once. The 5 requests inside a scenario always run in order. A login-then-extract-token-then-poll chain behaves predictably, while unrelated scenarios still overlap. ## Network & Session State Workers run independently but share two things: - **Connection pool.** All workers use one TCP transport pool, so keep-alive connections are reused across scenarios. - **Cookie isolation.** Each scenario run still gets its own `http.CookieJar`. A cookie set for Scenario A is not visible to Scenario B. ______________________________________________________________________ ## Concurrency Architecture The engine is a coordinator-worker pool. ### The Coordinator-Worker Model The Treewalker emits a slice of scenario blueprints. The coordinator feeds them into a buffered channel, and each worker pulls the next blueprint when it finishes the current one, until the channel is empty. Every worker has its own Lua VM. If Leaf A and Leaf B both inherit `01_login.sinq`, each runs its own copy in its own VM. ### AST Bytecode Caching & Request Collapsing A worker does not run a Lua block as raw text. It compiles the block to bytecode (an AST) first. So that 100 workers do not all compile the same `01_login.sinq` script at once, the Runner keeps one shared, thread-safe AST cache, keyed by a hash of the script's file, name, byte offset, and contents. `sinq` can also cache the HTTP responses themselves, opt-in per request via `req.cache(true)` in `$PRE`, or `req.cache(key)` to share one response between requests that call it with the same key. With it enabled, the cache doubles as a singleflight coalescer: if several workers fire the identical request (or requests with the same key) at the same time, the first one runs it and the rest wait and take its result. Because that one request is shared across scenarios rather than belonging to any single one, it can't reuse a scenario's `req_timeout`; it's bounded by `--cache-timeout` instead (default 10s), reading up to `--max-cache-size` (default 5 MiB) of body. The cache has no eviction: every distinct cached request stays until the run ends. ### Context Cancellation The Runner drives the suite lifecycle with Go's `context`. When a scenario exceeds its `timeout`, or you send `SIGINT` / `Ctrl+C`, the context is canceled: - in-flight requests are dropped, - sleeping `$RETRY` loops wake and stop, - the worker marks the scenario `Aborted` and skips the rest of its requests. # Background # Alternatives & Comparisons How `sinq` compares to the API testing tools people bring up most, starting with the ones closest to it in approach. | Tool | Tests written as | Scripting | Parallel by default | Built for | | --------------------- | ---------------------------------------------- | ---------------------------- | ------------------- | -------------------------------------- | | **sinq** | Plain-text `.sinq` (raw HTTP) + directory tree | Embedded Lua | Yes | Stateful HTTP workflows in CI | | Hurl | Plain-text `.hurl` (raw HTTP) | Predicates and filters | No | Plain-text HTTP tests from the CLI | | Venom | YAML | Go templates and `govaluate` | Yes | Multi-protocol integration tests in CI | | Postman / Insomnia | GUI collections (JSON) | JavaScript | No | Interactive API exploration | | Bruno | Plain-text collections (`.bru`) | JavaScript (Node) | No | Git-friendly Postman-style client | | REST Client / httpyac | `.http` / `.rest` files | Limited, or JS in httpyac | No | Sending requests from your editor | | k6 | JavaScript | JavaScript | Yes (virtual users) | Load and performance testing | ## Sinq vs. Hurl [Hurl](https://hurl.dev) is probably the closest tool to `sinq`. Both keep requests and assertions in plain-text files that stay close to raw HTTP. The differences: - **Scripting.** Hurl provides a fixed set of predicates and filters. For things outside that set (pulling one claim out of a JWT, generating a payload on the fly, retrying based on what came back in the body) you reach the limit of what they can express. `sinq` runs a full Lua VM, so those cases are just code. - **Concurrency.** `sinq` runs every leaf directory as its own scenario and fires them all at once. Hurl runs files one after another unless you parallelize it yourself. - **Reuse.** A `sinq` scenario inherits requests and config from the directories above it, so shared auth and setup live in one file at the root. With Hurl, that setup is usually repeated per file or passed along as state you manage yourself. If your assertions stay simple, Hurl is the lighter choice: no scripting language to learn and a longer track record. ## Sinq vs. Venom [Venom](https://github.com/ovh/venom) is OVHcloud's test runner. Like `sinq` it ships as a single Go binary, runs headless in CI, and writes JUnit XML. Suites are YAML: a suite holds test cases, a test case holds steps, and each step runs an "executor" with assertions attached. - **Format.** Venom suites are YAML, with logic written as Go templates and `govaluate` expressions inside strings. `sinq` files are near-raw HTTP with Lua for the dynamic parts. - **Structure.** Venom describes the flow inside the YAML. `sinq` uses the directory layout, and child folders inherit setup and config from their parents. - **Concurrency.** `sinq` runs scenarios in parallel by default. Venom also covers far more than HTTP, with executors for gRPC, SQL, SSH, Kafka, Redis, AMQP, IMAP, and shell commands. `sinq` is HTTP-only, so if a suite also needs to talk to a database or a queue, Venom is the better fit. ## Sinq vs. Postman / Insomnia [Postman](https://www.postman.com) and [Insomnia](https://insomnia.rest) are GUI-first tools for exploring and testing APIs interactively. - **Workflow.** Chaining requests is done by wiring them together in the UI or writing JavaScript in the app's script panel. In `sinq` the chain is the order of the files, and everything lives in your editor. - **Concurrency.** `sinq` runs independent scenarios in parallel. - **Version control.** `sinq` tests are plain `.sinq` files next to your code, with no exported JSON collections to sync or JSON diffs to review, and no paid workspace for team access. Their strength is the interface: a visual request explorer, built-in mock servers, and shared team workspaces. `sinq` is a CLI and does not offer those. ## Sinq vs. Bruno [Bruno](https://www.usebruno.com) is a Postman-style client that stores collections as plain text, which makes it much friendlier under git. - **Model.** Bruno is organized as collections of requests. `sinq` is organized as scenarios and directory trees, which suits end-to-end flows without one large shared environment. - **Scripting.** Bruno scripts in JavaScript on Node. `sinq` has a small Lua VM built into the binary, with nothing to install. Bruno also keeps a desktop GUI for building and browsing requests, so if you want plain-text storage together with a visual client, that is what it offers. ## Sinq vs. REST Client / httpyac The VS Code [REST Client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) and [httpyac](https://httpyac.github.io) are made for sending requests straight from your editor as you work. They fit the edit-and-send loop during development. `sinq` is made to run headless in CI, with retry policies, JUnit XML, environment matrices for parameterized runs, tag and name filters, and parallel execution. If you mainly want to hit an endpoint from your editor, they do that well. ## Sinq vs. k6 [k6](https://k6.io) is a load and performance testing tool from Grafana. Tests are JavaScript, executed by a Go engine that can drive many virtual users at once. - **Goal.** k6 measures how a system behaves under load: throughput, latency percentiles, error rate at a given number of virtual users. `sinq` checks whether a workflow returns the correct result, and runs scenarios concurrently to finish the suite sooner rather than to generate load. - **Assertions.** k6 uses lightweight checks and thresholds suited to load runs. `sinq` is built around per-request assertion hooks for functional correctness. - **Reporting.** k6 produces load metrics and time series. `sinq` produces per-scenario pass/fail and JUnit XML for CI gating. For measuring how fast or how reliable a service is under pressure, use k6. For confirming that a multi-step flow does the right thing, use `sinq`. The two work well side by side. ## Summary Reach for `sinq` when you want a small, fast, file-based CLI for writing stateful HTTP workflows, keeping them DRY, and running them in CI. # Frequently Asked Questions (FAQ) ## Is `sinq` Open Source? Can I contribute? `sinq` is free and Open Source software, distributed under the GNU General Public License v3 (GPLv3). However, at this time, **the project operates on a closed contribution model**. While you are free to fork, read, and use the code according to the GPLv3 license, I am currently not accepting pull requests or feature contributions from the community. This allows me to keep my sanity, as this is a single man project. Possibly subject to change in the future. ## How do I pass variables between requests? You can extract data from a response in the `$POST` hook and assign it to a global variable (by omitting the `local` keyword in Lua). ```lua $POST{ local data = res.json() -- This saves the token globally for the rest of the scenario AUTH_TOKEN = data.token } ``` In subsequent requests, you can inject it using inline scripts: ```http GET ${env.BASE_URL}/protected-route Authorization: Bearer ${AUTH_TOKEN} ``` ## How does concurrency work in `sinq`? When you point `sinq` at a root directory, it recursively scans for files. **Every leaf directory (a folder with no subfolders) becomes an isolated "Scenario".** `sinq` runs all of these Scenarios concurrently by default, bounded only by the `-w/--workers` flag (which defaults to 10). Each Scenario gets its own Lua VM and environment sandbox, so there is no shared state to race on. ## Can I share state between different scenarios? **No.** Every scenario runs in isolation, which is what makes running them in parallel safe. If you need a shared setup step (like fetching an authentication token or creating a base database record), place that `.sinq` file in a parent directory. Every child scenario will inherit that file and execute the setup independently before running its own specific tests. ## How do I do Data-Driven / Parameterized Testing? Use the `env_matrix` key in a `.scenario` file. You define an array of environments and `sinq` runs the scenario once per combination. See the [Environment Matrix documentation](https://sinq.veitangie.dev/env-matrix/index.md) for examples. ## How do I handle secrets securely? Never hardcode secrets (like API keys or passwords) in your `.sinq` files. Instead, use the `--secrets-file` argument to point to a JSON file containing your secrets, or pass them inline using `-s KEY=VALUE` (supports dot-notation and JSON values, e.g. `-s API.KEY=123`). You can then access them safely in your Lua scripts via the `secrets` table: ```http POST ${env.BASE_URL}/login { "username": "admin", "password": "${secrets.ADMIN_PASS}" } ``` ## How do I upload a file? If the whole request body **is** the file, use `req.attach()` in `$PRE`. `sinq` streams the file straight from disk into the body: ```http POST ${env.BASE_URL}/upload Content-Type: application/octet-stream $PRE{ req.attach("path/to/my_file.bin") } ``` For a real HTML-style upload (a file plus other form fields), use `req.multipart()` in `$PRE`. Each argument is one part; `filename` makes a part a file part (`true` reuses the source's basename), and the `Content-Type` is sniffed from the extension when omitted: ```http POST ${env.BASE_URL}/avatar $PRE{ req.multipart( { name = "user_id", data = env.USER_ID }, { name = "avatar", source = "fixtures/face.png", filename = true } ) } ``` `sinq` sets `Content-Type: multipart/form-data; boundary=…` for you (unless you set a `Content-Type` yourself). See the [Lua API reference](https://sinq.veitangie.dev/lua-api/#multipart-bodies) for every entry key. ## How do I send a form (`application/x-www-form-urlencoded`) body? There is no dedicated helper. Build the body with `sinq.encoding.urlEncode` and set the header explicitly: ```http POST ${env.BASE_URL}/login Content-Type: application/x-www-form-urlencoded ${ "username=" .. sinq.encoding.urlEncode(env.USER) .. "&password=" .. sinq.encoding.urlEncode(secrets.PASS) } ``` ## How do I enable request caching? By default, `sinq` fires every request fresh. However, if you have an expensive endpoint that returns the same data and you want to speed up local testing, you can opt-in to caching using the `$PRE` hook: ```http GET ${env.BASE_URL}/expensive-lookup $PRE{ req.cache(true) } ``` *Note: Caching is based on the request method, URL, headers and body, plus the cookies sent with it, attached files and the scenario directory. Each retry attempt is cached separately. Responses served from the cache still count toward "requests sent" in the summary.* To share one response between requests that differ in ways that don't matter, such as an `Accept-Language` from an environment matrix or a trace header, pass a key instead: ```http POST ${env.BASE_URL}/auth $PRE{ req.cache("auth:" .. env.phoneNumber) } ``` Every request that uses the same key gets the same cached response, even with a different method, URL or body, so include whatever must keep requests apart (here the phone number) in the key. The first request to fill a key decides what is stored. If it streamed its body to disk with `req.saveResponseTo`, later requests with the same key get an empty body. ## Can I skip a request conditionally? Yes. If you determine during the `$PRE` hook that a request is not needed (e.g., you already have the data or an environment variable disables it), you can mark it to be skipped. ```http POST ${env.BASE_URL}/optional-step $PRE{ if env.SKIP_OPTIONAL == "true" then req.skip(true) end } ``` Skipped requests bypass the actual network call and all subsequent hooks (`$RETRY`, `$ASSERT`, `$POST`), but are not marked as failures. ## What happens if an API returns a massive payload? `sinq` buffers responses in memory by default so that Lua scripts can parse them. To prevent out-of-memory crashes on massive payloads (like downloading a 2GB ISO), `sinq` enforces a `max_body` setting (configurable in `.scenario` files, defaults to 1MiB). If a response exceeds this limit, `sinq` safely truncates it and flags the response with `oversized = true`. ## How do I download/save a response directly to disk? If you *expect* a massive payload and want to save it without buffering it in memory (or hitting the `max_body` limit), you can stream it straight to disk using the `$PRE` hook: ```http GET ${env.BASE_URL}/download-report $PRE{ req.saveResponseTo("path/to/report.pdf") } ``` When using `saveResponseTo`, the `res.bodyRaw` and `res.bodyJson` fields will remain empty to save memory. ## How do I handle polling or wait for a job to complete? You can write retry policies inside the `$RETRY` hook. It executes immediately after the response is received. It must return the number of milliseconds to wait before retrying, or a negative number to stop retrying. ```http GET ${env.BASE_URL}/job-status $RETRY{ -- If status is pending, retry in 500ms if res.json().status == "pending" then return 500 end -- Stop retrying otherwise return -1 } ``` The `sinq.retry.*` helpers wrap this pattern, e.g. `sinq.retry.when(condition, delay)`. ## How do I override a deeply nested env/secret key or value? If you have a complex configuration object and you need to override a specific nested key from the command line, you can use dot-notation. `sinq` will automatically expand the dots into a nested JSON object structure. ```bash sinq -e "API.TIMEOUT=500" -s "DB.CREDENTIALS.PASSWORD=secret123" ``` Additionally, because the CLI arguments are unmarshaled via JSON under the hood, you can pass entire JSON objects or arrays as values: ```bash sinq -e 'DB.HOSTS=["10.0.0.1", "10.0.0.2"]' ```