All variable values are strings, no other types exist
Uninitialized variables (let x) default to empty string ""
Variables are scoped to their enclosing block (test, shell, fn, effect)
Inner blocks can shadow outer variables with a new let declaration
Reassignment (x = expr) mutates an existing variable from an outer scope
Environment variables from the host process are available as pre-set variables in all scopes (read-only — let creates a shadow, not a modification of the process environment)
Hierarchical .env files, when present, layer over the host process environment and take precedence over it; their values feed interpolation, marker evaluation, and the shell under test
Regex capture groups ($1, $2, …) are set after a <? match and remain in scope until overwritten by the next <?
A shell is a spawned PTY process (default: /bin/sh)
stdout and stderr are merged into a single output stream
Send operators (>, =>) write to the shell’s stdin
Match operators (<?, <=) assert against the shell’s accumulated output
Match operations block until a match is found or the timeout expires
A timeout expiry is a test failure
Any match operator can include an inline timeout override (<~dur or <@dur):
Applies only to that single operation (one-shot)
Does not affect the shell’s scoped timeout
Duration uses compact humantime format (no spaces): 2s, 500ms, 1m30s
Timeouts come in two kinds:
Tolerance (~) — scaled by --timeout-multiplier. Used for operations that may be slower under load
Assertion (@) — never scaled. Used to assert the system responds within a hard deadline
Each shell has one active fail pattern slot — if shell output matches the fail pattern, the test fails immediately
Fail patterns are checked inline during match operations (under the same lock as consume) and at statement boundaries
Setting a fail pattern immediately rescans the buffer for the pattern
An empty fail pattern operator (!? or != with no payload) clears the active fail pattern
A match operator with no payload (<? or <= with nothing after it) resets the output buffer cursor, consuming all current output
Each shell has one active timeout value, initially set to a framework default
Multiple shell <name> blocks with the same name in a test/effect refer to the same shell (switching the active shell, like lux’s [shell name])
A multi-pattern match block (<{ <line>+ }) waits for several patterns at once:
Each inner line is ? <pattern> (regex) or = <pattern> (literal); patterns are mixed freely
The block is atomic with respect to the cursor: every byte that arrives is offered to every still-unmatched pattern, the cursor sits still until block exit
A pattern transitions to matched the first time it succeeds against the slice [block_entry, current_buffer_end]; once matched, it no longer participates in subsequent scans
The block completes when every pattern has matched at least once
At block exit the cursor advances once, to the maximum of the per-pattern match-end offsets (overlapping matches are permitted; duplicate inner patterns are independent slots that may land on the same bytes)
Capture groups in inner regex patterns do not bind - $n is not written by a multimatch block
If the block timeout fires before all patterns have matched, the test fails; the failure record lists every pattern with its matched/unmatched status
Fail patterns remain active during the block; a fail-pattern hit aborts the block exactly as it would abort a single <? / <=
The inline-timeout prefix shape <~Ns{ ... } / <@Ns{ ... } carries the standard tolerance/assertion semantics, applied to the whole block
Effect names must start with an uppercase letter (CamelCase) — this is enforced at the syntactic level, disambiguating effects from functions in imports
An effect is a reusable setup procedure that produces running shells and computed values
An effect has three explicit interface components:
expect — declares required environment variables the effect reads; the resolver validates these are satisfiable
expose — declares which shells and variables the effect makes available to callers; the expose keyword requires a shell or var discriminator (expose shell db, expose var port); internal shells not listed in expose are terminated after setup
start — declares dependency effects with optional env remapping via overlay
None of these declarations are mandatory: an effect may have no expect, no start, and no expose
start Effect runs the dependency for side effects only — its shells are not accessible
start Effect as Alias runs the dependency and makes its exposed shells/variables available via dot-access (shell Alias.shell_name, ${Alias.var_name})
Effect aliases (the name after as) must be CamelCase, matching effect naming conventions
start Effect as Alias { KEY = expr } provides an overlay that remaps the caller’s environment into the dependency’s environment
The shorthand form KEY (without = expr) is equivalent to KEY = KEY
Effects inherit the full parent environment — overlay entries override specific keys
Effect instance identity is determined by (effect-name, evaluated overlay restricted to expect-declared vars):
Same identity tuple = same instance (deduplicated, reused)
Different identity tuple = separate instances
When a test or effect starts the same effect multiple times with the same evaluated overlay, only one instance is created
Exposed shells are accessed via dot notation: shell Alias.shell_name { ... }
Exposed variables are accessed via dot notation in interpolation: ${Alias.var_name}
Exposed variables are only accessible in shell contexts (runtime); test-level and effect-level let bindings cannot reference them (purity violation — let is evaluated at resolve time, before effects are started)
Exposed variables are read-only from the caller’s perspective
For composed effects, expose can re-export a dependency’s shell or variable: expose shell Dep.shell as public_name, expose var Dep.port as db_port
Effects run before the test body; the dependency graph is resolved and executed in topological order
Circular effect dependencies are a parse error
If an effect fails (a match times out during setup), all tests depending on it are failed
Each effect has an optional cleanup block that runs when the effect is torn down
Condition markers are placed immediately before test, effect, fn, or pure fn declarations
Condition markers evaluate before any shells are spawned
Test-level markers are checked before execute_effects
Effect-level markers are checked before the effect’s shells are created
Function-level markers are checked during resolution; a skipped function causes all tests that call it to be skipped
A bare marker (kind only, no modifier) is unconditional:
# skip always skips, # flaky always marks flaky, # run is a no-op
A conditional marker requires a modifier (if/unless) and an expression
Expressions are quoted strings with ${VAR} interpolation or bare numbers:
"${CI}" — environment variable reference
"literal" — literal string
"${HOST}:${PORT}" — compound interpolation
42 — bare number (compared as string)
Bare variable identifiers (e.g. CI) are valid in markers
Expression evaluation uses ENV-only lookup (Arc<LayeredEnv> — the layered host-plus-.env environment) — no frame variables or test-scope variables exist at evaluation time
Truthiness: empty string or unset variable is false, any non-empty string is true
= operator: evaluates both sides, returns the LHS value if LHS equals RHS, empty string otherwise
? operator: evaluates LHS, compiles the regex pattern (with ${var} interpolation), returns the match if found, empty string otherwise
Modifier semantics:
if acts when the result is truthy
unless acts when the result is falsy
Kind semantics:
skip: skips the test/effect when the condition is met
run: skips the test/effect when the condition is NOT met (inverse of skip)
flaky: marks the test as flaky — with [flaky].max_retries > 0 in Relux.toml, a failing flaky test is retried from scratch with exponentially increasing tolerance timeouts (base × m^(retry-1)). With max_retries = 0 (default), the marker is documentary only
Multiple markers stack with AND semantics: all conditions must pass or the test is skipped
When an effect is skipped, all tests depending on it are also skipped
When a function is skipped, all tests that call it are also skipped
# flaky propagates the same way skip does: a test is marked flaky if it, or any function or effect it reaches, has a # flaky marker whose condition applies
Tests are independent — no test depends on another test’s execution or side effects
Condition markers (# skip/run/flaky ...) are placed immediately before the test declaration
Test structure (in order):
Doc string (optional """...""")
let declarations (test-scoped variables)
start declarations (effect dependencies)
shell blocks (test body)
cleanup block (optional)
Effects are instantiated and their shells are available before the test body runs
A test succeeds if all match operations in all shell blocks pass
A test fails if any match operation times out or any fail pattern matches
A test is cancelled (a distinct outcome from failure) when execution is stopped before the test could finish: the test’s own ~T timeout fired, the suite-wide timeout fired, fail-fast cut sibling tests short, or the process received SIGINT. Cancelled outcomes exit nonzero in CI, exactly like failures, but they preserve the distinction that the test was not misbehaving