Skip to content

Config & environments

tflw.config is parsed by the same lexer/parser as your test files — it's the same DSL, declaration-only (test is a checker error here). Two tiers, defaults then the active env (same-key-wins, no extends chains):

defaults
header "Accept" is "application/json"
timeout step 10s, browser 30s, expect 5s, wait 30s
workers 4
report "./report"
env local default
web "http://localhost:5173"
api "http://localhost:3001"
env staging
api "https://stg.example.com/api"
timeout wait 60s # overrides just this key

Active env selection: --env <name> flag > TFLW_ENV env var > the block marked default. No resolvable env is a startup error. Unknown config keys are checker errors, not silently ignored.

Five targets, in two families.

defaults
timeout step 10s # the fallback budget for both transports
timeout api 10s # HTTP requests only
timeout browser 60s # browser steps only
timeout expect 5s # a UI expect/check's retry budget
timeout wait 30s # a `wait until` step's poll ceiling

timeout api and timeout browser narrow timeout step rather than replacing it: an HTTP request uses timeout api if you set one and timeout step otherwise, and a browser step uses timeout browser if you set one and timeout step otherwise. Setting only timeout step therefore still bounds both, which is what it has always done.

writtenHTTP requestbrowser step
nothing30s30s
timeout step 10s10s10s
timeout step 30s, api 10s10s30s
timeout browser 60s30s60s

Reach for timeout browser when a page renders slowly: an api step can carry its own budget on its own line (api GET /slow timeout 90s), while a browser step cannot, so before this existed the only way to be more patient with the UI was to be equally patient with every request in the suite.

timeout api is also the one that matters under load. A request that overruns its budget is aborted, so that iteration fails and never reaches the duration percentiles — meaning the budget defines the right-hand tail your threshold p95 duration … reads. Browser steps aren't supported inside a workload-bearing test at all, so timeout browser never affects a load run.

The two tiers merge per key, and one case catches people out: defaults setting timeout api 10s beside an env setting timeout step 20s gives api 10s and browser 20s. The env's broader key doesn't reset the narrower one it inherited — same-key-wins is about the same key.

timeout api 0s and timeout browser 0s are refused (they'd abort every operation before it started), while timeout expect 0s and timeout wait 0s are legal and mean evaluate once, don't poll.

A freshly scaffolded config carries api "tflw://demo" instead of a real address:

env local default
api "tflw://demo" # ← swap for "http://localhost:3001", or wherever your API lives

tflw://demo is tflw's own bundled demo service — a small HTTP server tflw run starts on a loopback port for the duration of the run and stops afterwards, so a brand-new project is green before you have wired anything up. It answers GET /health and 404s everything else; tflw check never starts it. Runs against it are labelled ℹ demo in the summary and in report.html, because they prove nothing about your system — replacing that one line is the first thing to do.

env staging
api "https://stg.example.com/api" # default service
api billing "https://billing-stg.example.com"

api <name> "<url>" declares an extra service; steps address it by name (api billing GET /invoices/{id}). Headers/auth can scope to one service: header "X-Key" is env(BILLING_KEY) for billing.

require env ADMIN_USER, ADMIN_PW

require env validates at startup — one error lists every missing var, and every listed var is pre-registered with the redactor from the very first step (masked even if never actually evaluated). .env at the project root auto-loads for local dev; real environment variables win over it. Anything that ever flowed through env(NAME) prints as •••(NAME) everywhere — reports, traces, CLI output — automatically, by construction.

A top-level require env line is required under every env. A secret only one environment sends is required by that environment's block instead, so a local run does not have to set it:

env local default
api "http://localhost:3000"
env staging
api "https://staging.example.com"
require env STAGING_TOKEN
header "Authorization" is env(STAGING_TOKEN)

tflw run --env staging without it stops before the first request and says STAGING_TOKEN (required by env staging). A test file can only read the top-level names, because a test runs under every env; read a per-env secret in its env block, or in a session scoped to that env with session robot for env staging. tflw check refuses an env(NAME) read where nothing requires it, and names the env that does.

For a secret that doesn't come from an env var — a token the API mints and hands back mid-run, a Set-Cookie, an API key baked into a fixture — name its position with redact instead:

env staging
redact header "Authorization", body.accessToken, query "token"

A value captured out of a position you named is tracked from then on and masked wherever it later appears. See CI, reporting & safety.

Three real-world blockers Node's plain fetch doesn't handle, each with a zero-new-dependency fix:

  • Self-signed/expired staging cert: insecure true (per-env or defaults) disables TLS verification for the run — loudly: the CLI summary and report.html header both carry a bold warning banner, never a silent trade-off.
  • Private/internal CA: prefer NODE_EXTRA_CA_CERTS=/path/to/ca.pem npx tflw run over insecure true — verification stays on, only your org's CA is added.
  • Corporate HTTP(S) proxy: NODE_USE_ENV_PROXY=1 makes fetch honor HTTP_PROXY/HTTPS_PROXY on Node 24 and on current Node 22 (measured on 22.22.0). An older Node 22 has no built-in env-var proxy path for fetch — update Node; it is not worked around with a proxy-agent dependency.

Network failures name the likely cause instead of a bare fetch failed — a cert problem points at insecure true/NODE_EXTRA_CA_CERTS, ENOTFOUND names a DNS failure, ECONNREFUSED asks whether the service is actually listening.

env staging
api "https://staging.example.com"
cert "./certs/client.pem"
key "./certs/client.key"

cert/key are required together. Every request against that env presents the client certificate during the TLS handshake; both insecure true and NODE_EXTRA_CA_CERTS still apply alongside it.

Host allowlist — an anti-pointed-at-prod guardrail

Section titled “Host allowlist — an anti-pointed-at-prod guardrail”
defaults
allow hosts "api.example.com", "*.staging.example.com"
env staging default
api "https://api.staging.example.com"
allow hosts "billing-staging.example.com"

Refuses to send a request to any host not explicitly listed — enforced before any network I/O, so a violation never even opens a connection. *.domain matches that suffix or the bare domain; never declaring allow hosts means no enforcement at all (the unchanged default). The list accumulates: a baseline in defaults, extended per env.

Covers every real network call a run makes — every api step on every client path, an oauth2 session's token request, a matches schema ... from ... contract fetch (see Assertions in depth), every hop of a redirect chain, and every request the browser makes, including the page's own XHR calls and not just what you open. A stubbed request is answered locally and never reaches the network, so the list doesn't apply to it.

If the env you're running has an api/web base URL that isn't on its own list, tflw check says so (TF036) rather than letting every step fail identically at run time. It checks the env you selected, not every env in the file — so a suite can keep a deliberately-blocked env around as the negative-case fixture for this feature, and only hears about it when it actually runs that env.

authorized target — what this suite may scan

Section titled “authorized target — what this suite may scan”
env secureLocal
api "https://localhost:8443/v1"
authorized target "https://localhost:8443" reason "self-hosted test fixture"

Required before any expect response has no … security violations or … authorization violations assertion, and only for those — an ordinary suite never needs this key. Unlike allow hosts directly above, it takes no wildcards: it is an affirmation that you may point a scanner at one named host, and a pattern is not something anyone can affirm. The reason is required and is printed in the run summary and the report, so the claim travels with the findings.

Four optional indented sub-clauses grant further permissions for that host, each granting only itself. The one-line declaration above is unchanged; these are lines beneath it:

defaults
authorized target "http://localhost:4001" reason "self-hosted test fixture"
probe mutating
probe oversized
probe traversal
probe ciphers
sub-clausegrantsread by
probe mutatingre-issuing a POST/PUT/PATCH/DELETE against this hostauthorization, input handling, crawling
probe oversizedsending a 64 KiB payloadinput handling
probe traversalsending ../ payloadsinput handling
probe ciphersone TLS handshake per candidate suite, to read this host's offerhygiene scanning

Without the relevant one, the scan that needed it reports not probed rather than silently doing the thing nobody said it could — and not probed is never counted as clean. They hang off the target because each is a property of that host: staging may be safe to read as a stranger and not safe to write to. Grants accumulate across declarations covering the same origin, and are never inherited from a neighbouring one.

See Security hygiene scanning and Authorization testing.

baseline — the accepted findings this suite grades against

Section titled “baseline — the accepted findings this suite grades against”
defaults
baseline "./security-baseline.json"

The accepted-findings document, so a security run grades itself against the same file in CI, in a script and in your shell. Single-valued, legal in defaults and in an env block (an env's line replaces the default one), resolved against tflw.config's own directory, and overridden by --baseline <file> for one run.

Full reference: SPEC.md §3.

exclude takes glob patterns that discovery skips when a run names a folder rather than a file. Naming a file explicitly still runs it: the key shapes what a sweep picks up, and never overrides an instruction you gave directly.

It is the right place for fixtures that parse as tflw but are not tests, and for a directory of work in progress you do not want a pipeline to pick up yet.

A use "./helpers/sign.ts" line loads a JavaScript or TypeScript module and calls its exports like actions. That is arbitrary code, and helpers says where in the project it may come from:

helpers "./helpers", "./lib/tflw"

Directories are relative to tflw.config. A config that declares none allows ./helpers and ./tests/helpers. A use that resolves anywhere else is TF083 from tflw check and the editor, and the run refuses to start — move the module into an allowed directory, or name its directory here. tflw check also prints one helper … line per module a run would load, so a review of a suite sees its code without opening every file.

tflw run --no-helpers refuses every use for that run, whatever this directive allows.

tflw ui lists the runs you start from it, and forgets the oldest one that has ended once the list is full. Fifty by default; one line changes it:

runs keep 20

It bounds the page's list and nothing else — every run still writes its own report directory, and this never deletes one. The page reads it at each run, so an edit in the Config tab applies to the next run.

Overriding workers for one test — parallel and sequential

Section titled “Overriding workers for one test — parallel and sequential”

workers above sets how many test files run at once for the whole project. A single test can override the file's default behaviour by carrying sequential — test "mutates shared stock" sequential — which is what you reach for when one test touches shared state the others assume.

parallel is the opposite override, for a test in an otherwise sequential file. Writing both on one declaration is refused rather than resolved, because there is no reading of it that is obviously right.