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.
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 userorders: 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.
### 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. $POSTruns last.AUTH_TOKENhas nolocal, so it is a scenario global that every later file can read.BASE_URLandE2E_PASSWORDare not set anywhere in the tree. Pass them at run time (-e BASE_URL=... -s E2E_PASSWORD=..., or--secrets-file), or add anapi/config.scenario.
The users scenario
api/users/users.scenario adds a tag so this scenario can be run on its own:
api/users/01_users.sinq:
### 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
$POSTsavesUSER_ID; the second interpolates it into the URL with${USER_ID}. sinq.assert.equalswith 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:
api/orders/01_orders.sinq:
### 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.
{
"env_matrix": [
{ "admin": { "role": "admin" }, "guest": { "role": "guest" } },
{ "card": { "expect": 200 }, "crypto": { "expect": 202 } }
]
}
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:
### 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:
### 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
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
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
}
Lua API cheat-sheet
Available in every script block:
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:
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):
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?) opt in to the response cache (enable defaults to true)
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):
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:
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):
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?) | 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.
Common mistakes
- Missing
returnin an inline script.${PLAYER_ID}works becausesinqretries a bare expression withreturnprepended. A call like${tostring(x)}is ambiguous; write${return tostring(x)}. localon a value you need later. State passed between files must be a global:AUTH_TOKEN = ..., notlocal 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. Useres.extractBodyJson()when the body might not be JSON.$RETRYmust return a number. Returning nothing, a boolean, or a table errors the request.$PREcannot read the body. It is not built yet. Read it in an inline script or in$POSTof an earlier request.- One body source per request. A literal body together with
req.attachorreq.multipartis an error. $POSTis skipped after a failed assertion whenfail_fastis 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<failure>. - Error: something broke before an assertion could run, such as a Lua exception, a timeout, a refused connection, or a malformed request. JUnit
<error>.
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. |
<file>:<line>:<col> 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. |