Relux Semantic Model
Modules
- Every
.reluxfile is a module - A module can contain any combination of: imports, functions, effects, tests
- There is no distinction between “library” and “test” modules
- Module path is its filesystem path relative to the project root (e.g.
lib/matchersresolves tolib/matchers.relux) - The project root is defined by the location of
Relux.toml
Imports
- Imports resolve from the project root, never relative to the importing file
- Selective imports bring specific names into scope:
import lib/m { foo, bar, StartDb } - Wildcard imports bring all names into scope:
import lib/m asaliases rename an imported name locally:foo as f,StartDb as Db- Aliases must preserve the casing kind: lowercase names get lowercase aliases, CamelCase names get CamelCase aliases
- Each module is loaded once regardless of how many files import it
- Circular imports are a parse error
Variables
- 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
letdeclaration - 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 —
letcreates a shadow, not a modification of the process environment) - Hierarchical
.envfiles, 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<?
Functions
- Function and shell names must start with a lowercase letter or underscore (
snake_case) — this is enforced at the syntactic level - Functions are reusable sequences of statements
- A function executes in the caller’s shell context — it has no shell of its own
- Functions can only be called inside
shellblocks (since shell operators require an active shell) - The return value is the last expression’s value in the body
- If the caller doesn’t capture the return value, it is discarded
- Side effects persist in the caller’s shell: a function that sets
~30sor!? errorchanges the shell’s timeout/fail-pattern for subsequent statements - Functions can call other functions
- Functions can use imports from their own module
Pure Functions
- Declared with
pure fninstead offn - Cannot contain shell operators (
>,=>,<?,<=,!?,!=, timeouts) - Cannot call impure built-in functions (e.g.,
match_prompt(),ctrl_c()) - Cannot call regular
fnfunctions — only other pure functions and pure built-in functions - Can only contain:
letdeclarations, variable reassignment, and expressions - Can be called from condition markers, overlay expressions, and regular shell blocks
- “Pure” means shell-independent, not side-effect-free — pure BIFs like
sleep()andlog()are allowed
Shells
- A shell is a spawned PTY process (default:
/bin/sh) stdoutandstderrare merged into a single output stream- Send operators (
>,=>) write to the shell’sstdin - 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 (
<~duror<@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
- Tolerance (
- 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 -
$nis 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
- Each inner line is
Effects
- 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 satisfiableexpose— declares which shells and variables the effect makes available to callers; theexposekeyword requires ashellorvardiscriminator (expose shell db,expose var port); internal shells not listed inexposeare terminated after setupstart— declares dependency effects with optional env remapping via overlay
- None of these declarations are mandatory: an effect may have no
expect, nostart, and noexpose start Effectruns the dependency for side effects only — its shells are not accessiblestart Effect as Aliasruns 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 toKEY = KEY
- The shorthand form
- 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
letbindings cannot reference them (purity violation —letis evaluated at resolve time, before effects are started) - Exposed variables are read-only from the caller’s perspective
- For composed effects,
exposecan 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
cleanupblock that runs when the effect is torn down
Condition Markers
- Condition markers are placed immediately before
test,effect,fn, orpure fndeclarations - 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
- Test-level markers are checked before
- A bare marker (kind only, no modifier) is unconditional:
# skipalways skips,# flakyalways marks flaky,# runis 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 interpolation42— 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-.envenvironment) — 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:
ifacts when the result is truthyunlessacts when the result is falsy
- Kind semantics:
skip: skips the test/effect when the condition is metrun: skips the test/effect when the condition is NOT met (inverse ofskip)flaky: marks the test as flaky — with[flaky].max_retries > 0inRelux.toml, a failing flaky test is retried from scratch with exponentially increasing tolerance timeouts (base × m^(retry-1)). Withmax_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
# flakypropagates the same way skip does: a test is marked flaky if it, or any function or effect it reaches, has a# flakymarker whose condition applies
Tests
- A test is the top-level unit of execution
- Tests are independent — no test depends on another test’s execution or side effects
- Condition markers (
# skip/run/flaky ...) are placed immediately before thetestdeclaration - Test structure (in order):
- Doc string (optional
"""...""") letdeclarations (test-scoped variables)startdeclarations (effect dependencies)shellblocks (test body)cleanupblock (optional)
- Doc string (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
~Ttimeout 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
Cleanup
- Cleanup blocks exist in both effects and tests
- Cleanup runs in a freshly spawned implicit shell, not in any existing shell
- Existing shells are terminated automatically by the runtime (cleanup is not for graceful shutdown)
- Cleanup is for external side effects: temp files, docker containers, log collection
- Any statement valid in a shell block is valid in a cleanup block
- Cleanup always executes, regardless of whether the test/effect passed or failed
- Cleanup failures are logged as warnings but do not change the test result
- Cleanup order: test cleanup runs first, then effect cleanups
Execution Model
- The runtime discovers all
.reluxfiles, parses them, resolves imports and effect dependencies - Tests are the entry points — only modules with
testblocks are executed - For each test:
- Resolve the effect dependency graph
- Run effects in topological order (reusing deduplicated instances)
- Execute the test body (shell blocks in declaration order)
- Run test cleanup
- Tear down effect instances (cleanup + shell termination)
- All shells within a test share the same test-scoped variables
- Only one shell is “active” at a time — statements execute sequentially, switching shells as blocks are entered
Configuration
Relux.toml
Every Relux project requires a Relux.toml file at the project root. The relux binary discovers this file by searching the current directory and all parent directories.
Scaffold a new project
relux init
Creates Relux.toml and the conventional directory structure in the current directory.
Minimal example
An empty Relux.toml is valid — all fields have defaults:
# empty Relux.toml is valid — all fields have defaults
The name defaults to the directory containing Relux.toml. Override it explicitly if needed:
name = "my-test-suite"
Full example
name = "my-test-suite"
[shell]
command = "/bin/sh"
prompt = "relux> "
[timeout]
match = "5s"
test = "5m"
suite = "10m"
[run]
jobs = 1
[flaky]
max_retries = 0
timeout_multiplier = 1.5
Root-level fields
| Field | Type | Default | Description |
|---|---|---|---|
name | string | directory containing Relux.toml | Suite name |
[shell] section
| Field | Type | Default | Description |
|---|---|---|---|
command | string | /bin/sh | Shell executable spawned for each shell |
prompt | string | relux> | PS1 prompt set on shell init |
[timeout] section
All durations use humantime format (e.g. 5s, 1m30s, 2h).
| Field | Type | Default | Description |
|---|---|---|---|
match | duration | 5s | Per-match timeout |
test | duration | 5m | Max wall-clock time per test |
suite | duration | 10m | Max wall-clock time for the entire test run |
[run] section
| Field | Type | Default | Description |
|---|---|---|---|
jobs | integer | 1 | Number of parallel test workers |
[flaky] section
| Field | Type | Default | Description |
|---|---|---|---|
max_retries | integer | 0 | Max retries for # flaky-marked tests (0 = no retries) |
timeout_multiplier | float | 1.5 | Exponential timeout multiplier base for flaky retries (must be > 1.0) |
Project structure
project-root/
├── Relux.toml
└── relux/
├── tests/ # test files (*.relux)
├── lib/ # reusable functions and effects
├── out/ # run output (auto-generated)
│ ├── run-2025-03-05-…/
│ └── latest -> run-2025-03-05-…
└── .gitignore # ignores out/
relux/tests/— test files are discovered recursively whenrelux runis invoked without--fileflags.relux/lib/— library files are always loaded alongside tests to make functions and effects available. May be empty or absent.relux/out/— run output directory. Each run creates a timestamped subdirectory. Alatestsymlink points to the most recent run.
CLI reference
relux init
Scaffolds a new project in the current directory. Errors if Relux.toml already exists.
relux new --test <module_path>
Creates a test module file from a template. The module path uses / separators and each segment must be lowercase alphanumeric with underscores ([a-z_][a-z0-9_]*). The .relux extension is optional.
relux new --test foo/bar/baz # creates relux/tests/foo/bar/baz.relux
relux new --test foo/bar/baz.relux # same
relux new --effect <module_path>
Creates an effect module file from a template in relux/lib/. Same path rules as --test.
relux new --effect network/tcp_server # creates relux/lib/network/tcp_server.relux
relux new --lib <module_path>
Creates a library module file (pure and impure functions) from a template in relux/lib/. Same path rules as --test.
relux new --lib utils/helpers # creates relux/lib/utils/helpers.relux
relux run [flags]
Runs tests. Discovers Relux.toml by walking upward from the current directory.
Use -f/--file to specify files or directories. Directories are searched recursively for *.relux files. If no --file flags are given, tests are discovered from relux/tests/.
Use -t/--test to filter by test name within a single file. Requires exactly one --file.
Library files from relux/lib/ are always loaded regardless of which files are specified.
Exits with code 1 if any test fails.
| Flag | Description |
|---|---|
-f, --file <path> | Test file or directory to run (repeatable; default: relux/tests/) |
-t, --test <name> | Run only tests with this name (repeatable; requires exactly one --file) |
--manifest <path> | Path to Relux.toml (default: auto-discover by walking upward) |
-j, --jobs | Number of parallel test workers (default: 1) |
--tap | Generate TAP artifact file in the run directory |
--junit | Generate JUnit XML artifact file in the run directory |
-m, --timeout-multiplier | Scale tolerance (~) timeout values (default: 1.0). Assertion (@) timeouts are never scaled |
--progress <mode> | Display mode: auto (TUI if TTY), plain (results only), tui (force TUI) |
--strategy <mode> | all (default) or fail-fast |
--rerun | Re-run only non-passing tests from the latest run |
--flaky-retries | Max retries for # flaky-marked tests |
--flaky-multiplier | Exponential timeout multiplier base for flaky retries (default: 1.5, must be > 1.0) |
--test-timeout | Override per-test timeout (humantime string) |
--suite-timeout | Override suite timeout (humantime string) |
relux check [paths...] [flags]
Validates test files without executing them. Runs the parser and resolver, reports diagnostics, and exits with code 1 if any diagnostics are found. Same path discovery as run.
| Flag | Description |
|---|---|
--manifest <path> | Path to Relux.toml (default: auto-discover by walking upward) |
relux history [flags]
Analyze run history from relux/out/.
| Flag | Description |
|---|---|
--manifest <path> | Path to Relux.toml (default: auto-discover by walking upward) |
--flaky | Show tests that have been both passing and failing |
--failures | Show tests that have failed |
--first-fail | Show the first failure for each test |
--durations | Show test duration statistics |
--tests <path>... | Filter to specific test files or directories |
--last <N> | Limit analysis to the N most recent runs |
--top <N> | Show only the top N results |
--format <format> | Output format: human (default) or toml |
relux completions [flags]
Installs shell completions for bash, zsh, or fish. Relux uses dynamic completions — the shell calls back into the relux binary at tab-press time, enabling context-aware completions like .relux file discovery and timeout presets.
Without --install, shows a dry-run of what would be written. With --install, writes the completion script.
| Flag | Description |
|---|---|
--shell <shell> | Shell to generate completions for: bash, zsh, fish (default: autodetect from $SHELL) |
--install | Write the completion script to the target location |
--path <path> | Override the install path (required for zsh, optional for bash/fish) |
Default install paths:
- bash:
~/.local/share/bash-completion/completions/relux - fish:
~/.config/fish/completions/relux.fish - zsh: no default — specify with
--path
relux dump tokens <file>
Dumps lexer tokens for the given file.
relux dump ast <file>
Dumps the parsed AST for the given file.
relux dump ir <files...>
Dumps the resolved IR (execution plans) for the given files.
Relux Syntax Reference
General
- Line-oriented, newline-terminated statements (no
;) - Comments:
//to end of line - All values are strings
- Every expression produces a string value
- Blocks use
{ }
Naming Conventions
Naming conventions are enforced at the syntactic level (parse error on violation):
- Effect names and effect aliases must start with an uppercase letter (
CamelCase):StartDb,Effect1,start Db as MyDb - Function names and shell names must be
snake_case:start_server,_helper,my_shell - Variable names and parameters are permissive (any alphanumeric + underscore, starting with letter or
_):port,DB_HOST,_private - Import aliases must preserve the casing kind of the original name:
foo as bar(both lowercase),StartDb as Db(both uppercase) - Overlay keys accept either casing (environment variables are conventionally
UPPER_SNAKE_CASE)
Imports
import <path> { <name>, <name> as <alias>, }
import <path>
<path>resolves from project root (e.g.lib/module1)- Selective:
import lib/m { foo, bar as b, StartDb as Db }— trailing commas allowed - Wildcard:
import lib/m— imports all names
Functions
fn <name>(<param>, <param>) {
<body>
}
- Return value: last expression in body
- Execute in the caller’s shell context
- Shell operators (
>,=>,<?,<=, etc.) are valid inside body
Pure Functions
pure fn <name>(<param>, <param>) {
<body>
}
- Return value: last expression in body
- Cannot contain shell operators (
>,=>,<?,<=,!?,!=, timeouts) - Cannot call impure built-in functions or regular
fnfunctions - Only
let, variable reassignment, and expressions (including pure BIF calls) are allowed - Can be called from condition markers, overlay expressions, and regular shell blocks
Effects
effect <EffectName> {
expect <VAR>, <VAR>, <VAR>
start <EffectName>
start <EffectName> as <Alias>
start <EffectName> as <Alias> { KEY = expr, KEY }
let <name> = <expr>
expose shell <shell_name>
expose shell <Alias>.<shell_name> as <public_name>
expose var <var_name>
expose var <Alias>.<var_name> as <public_name>
shell <name> { <body> }
shell <Alias>.<shell_name> { <body> }
cleanup { <body> }
}
expectdeclares required environment variables (comma-separated)startdeclares dependencies (one per line)start Effectruns the dependency for side effects only — its shells are not accessiblestart Effect as Aliasruns the dependency and makes its exposed shells/variables available via dot-access- Effect aliases must be CamelCase
start Effect as Alias { KEY = expr }provides an overlay; shorthandKEYis equivalent toKEY = KEYexpose shelldeclares which shells are part of the effect’s public interfaceexpose vardeclares which variables are part of the effect’s public interface; these arelet-bound values computed during setupexpose shell Alias.shell as namere-exports a dependency’s shell under a new nameexpose var Alias.var as namere-exports a dependency’s variable under a new nameshell Alias.shell_name { ... }— qualified shell block for operating on a dependency’s exposed shell- Internal shells not listed in
exposeare terminated after setup cleanupblock: only>,=>,let, variable reassignment allowed (no match operators)
Tests
test "<name>" ~<duration> {
test "<name>" @<duration> {
test "<name>" {
"""
<doc string>
"""
let <name>
start <EffectName>
start <EffectName> as <Alias>
start <EffectName> as <Alias> { KEY = expr, KEY }
shell <name> { <body> }
shell <Alias>.<shell_name> { <body> }
cleanup { <body> }
}
Condition Markers
# kind // unconditional
# kind modifier expr // truthiness check
# kind modifier expr = expr // equality comparison
# kind modifier expr ? regex // regex match
Where:
kind:skip|run|flakymodifier:if|unlessexpr: quoted string with interpolation ("${VAR}","literal","${A}:${B}") or bare number (42)regex: regex pattern with${var}interpolation, to end of line
Examples:
# skip
# skip unless "${CI}"
# run if "${OS}" = "linux"
# run if "${COUNT}" = 0
# skip unless "${ARCH}" ? ^(x86_64|aarch64)$
# flaky if "${CI}" = "true"
# run if "${HOST}:${PORT}" = "localhost:8080"
# skip unless "${VER}" ? ^${MAJOR}\..*$
- A bare marker (kind only, no modifier) is unconditional
- One marker per line
- Multiple markers stack with AND semantics (all must pass or test is skipped)
- Placed immediately before
test,effect,fn, orpure fndeclarations (not inside the body) - When a function is skipped, all tests that call it are also skipped
- A
# flakymarker on a function or effect propagates too: the test is marked flaky when a function or effect it reaches is flaky - Comments between markers and the declaration are allowed
| Marker | Modifier | Condition | Meaning |
|---|---|---|---|
# skip | (none) | (unconditional) | always skip |
# skip | if | truthy | skip when condition is true |
# skip | unless | falsy | skip when condition is false |
# run | (none) | (unconditional) | no-op (always run) |
# run | if | falsy | skip when condition is false |
# run | unless | truthy | skip when condition is true |
# flaky | (none) | (unconditional) | always mark as flaky |
# flaky | if | truthy | mark as flaky when condition is true |
# flaky | unless | falsy | mark as flaky when condition is false |
Truthiness
- Empty string or unset variable = false
- Any non-empty string = true
=returns the LHS value if LHS equals RHS, empty string otherwise?returns the regex match if matched, empty string otherwise
Shell Blocks
shell <name> {
<statements>
}
shell <Alias>.<shell_name> {
<statements>
}
- Unqualified form (
shell name) creates or switches to a local shell; name must be snake_case - Qualified form (
shell Alias.shell_name) operates on a dependency’s exposed shell; qualifier is a CamelCase effect alias, name is snake_case - Valid inside
effectandtestblocks
Variables
let <name> # declare, defaults to ""
let <name> = "<value>" # declare with value
let <name> = <expression> # declare from expression
<name> = <expression> # reassign existing variable
- Quoted values required for
letassignments - Interpolation inside strings:
"${name}","${1}","${2}", etc. - Bare variable reference:
name,$1,$2 - Escape
$with$$ - Scoped to enclosing block; inner blocks can shadow outer variables
- Environment variables are readable (the layered base environment — host process plus any
.envfiles — is available everywhere)
Operators
All operators are followed by a space, then payload to end of line.
Send
| Operator | Payload | Value |
|---|---|---|
> | text to EOL | sent string |
=> | text to EOL | sent string |
>sends with trailing newline=>sends without trailing newline (raw send)- Variable interpolation applies in payload
Match
| Operator | Payload | Value |
|---|---|---|
<? | regex to EOL | full match ($0) |
<= | literal to EOL | matched text |
<?matches regex against shell output; sets$1,$2, etc. for capture groups<=matches literal with variable substitution- Both block until match or timeout
Multi-Pattern Match
<{
? <regex>
= <literal>
}
- Inner lines are
? <regex>(regex) or= <literal>(literal), one per line - The block waits for every pattern to match at least once, in any order
- The cursor advances once at block exit, to
max(end)across all per-pattern matches - Capture groups in inner regex patterns do not bind
- The empty form
<{ }is a parse error - Comments are permitted between inner lines
Buffer Reset
<?
<=
- A match operator with no payload consumes all current output and resets the cursor
- Useful to skip past output you don’t care about
Inline Timeout Override
Any match operator can be prefixed with ~<duration> (tolerance) or @<duration> (assertion) to set a one-shot timeout:
<~2s? regex pattern # regex match with 2s tolerance timeout
<~500ms= literal text # literal match with 500ms tolerance timeout
<@2s? regex pattern # regex match with 2s assertion timeout
<@500ms= literal text # literal match with 500ms assertion timeout
<~10s{ ? a ? b } # multi-pattern block with 10s tolerance timeout
<@500ms{ = a = b } # multi-pattern block with 500ms assertion timeout
- Duration uses compact humantime format (no spaces):
2s,500ms,1m30s - Applies only to that single operation — does not affect the scoped timeout
- Works with both match operators (
?,=) and the multi-pattern form<{ ... } - Tolerance (
~) timeouts are scaled by--timeout-multiplier; assertion (@) timeouts are never scaled
Fail Pattern
| Operator | Payload |
|---|---|
!? | regex to EOL |
!= | literal to EOL |
- One active fail pattern at a time (single slot)
- Setting a new one replaces the previous (regex or literal)
- An empty
!?or!=(no payload) clears the active fail pattern
Timeout
~<duration>
@<duration>
- Compact humantime format (no spaces):
~10s,@2s,~500ms,~2m30s ~sets a tolerance timeout — scaled by--timeout-multiplier@sets an assertion timeout — never scaled (asserts the system responds within a hard deadline)- Sets timeout for subsequent match operations in the current shell
- Overrides previous timeout
- Scoped to the current function call — reverts when the function returns
Expressions
Every expression produces a string value:
| Expression | Value |
|---|---|
"<text>" | string literal |
name | variable value |
$1, $2 | regex capture group |
<fn>(<args>) | function return value |
> <text> / => <text> | sent string |
<? <regex> | full match ($0) |
<= <literal> | matched text |
<~dur? <regex> | full match with timeout override |
<~dur= <literal> | matched text with timeout override |
let x = <expr> | assigned value |
Last expression in a function body is the return value.
Effect Identity
(effect-name, evaluated overlay restricted to expect-declared vars) determines instance identity:
- Same tuple = same instance (deduplicated)
- Different tuple = different instance
- Overlay expressions are evaluated at setup time; identity is based on evaluated values, not AST form
Cleanup Blocks
cleanup {
<statements>
}
- Runs in a fresh implicit shell
- Any statement valid in a shell block is valid in a cleanup block
- Always executes, regardless of pass/fail
Built-in Functions
Relux provides built-in functions (BIFs) that are always available without imports. BIFs are divided into two categories based on their purity — whether they require a shell context to operate.
Purity
- Pure BIFs do not interact with any shell. They can be called from pure functions, condition markers, overlay expressions, and regular shell blocks.
- Impure BIFs require a shell context (they send input or match output). They can only be called inside shell blocks and regular (non-pure) functions.
“Pure” here means shell-independent, not side-effect-free — pure BIFs may still perform I/O (e.g. sleep, log, which).
Pure BIFs
String
| Function | Signature | Returns | Description |
|---|---|---|---|
trim | trim(s) | string | Remove leading and trailing whitespace from s. |
upper | upper(s) | string | Convert s to uppercase. |
lower | lower(s) | string | Convert s to lowercase. |
replace | replace(s, from, to) | string | Replace all occurrences of from with to in s. |
split | split(s, sep, index) | string | Split s by sep and return the part at index (0-based). Returns "" if the index is out of bounds. Errors if index is not a valid integer. |
len | len(s) | string | Return the byte length of s as a decimal string. |
default | default(a, b) | string | Return a if it is non-empty, otherwise return b. |
Generators
| Function | Signature | Returns | Description |
|---|---|---|---|
uuid | uuid() | string | Generate a random UUID v4 (e.g. "550e8400-e29b-41d4-a716-446655440000"). |
rand | rand(n) | string | Generate a random alphanumeric string of length n. Errors if n is not a valid integer. |
rand | rand(n, mode) | string | Generate a random string of length n using the given charset mode. Modes: alpha, num, alphanum, hex, oct, bin. Errors if mode is unknown or n is not a valid integer. |
System
| Function | Signature | Returns | Description |
|---|---|---|---|
available_port | available_port() | string | Bind to an ephemeral TCP port on 127.0.0.1 and return the port number. The port is released after the call, so it may be reused — call this close to where the port is needed. |
which | which(name) | string | Search PATH for an executable named name. Returns the absolute path to the first match, or "" if not found. Checks that the file has an executable permission bit set. If name contains a path separator, checks that path directly instead of searching PATH. |
sleep | sleep(duration) | "" | Pause execution for duration. Accepts humantime format: 500ms, 2s, 1m30s, etc. Errors if the duration is invalid. |
Logging
| Function | Signature | Returns | Description |
|---|---|---|---|
log | log(message) | string | Emit message to the event log and HTML report. Returns message. |
annotate | annotate(text) | string | Emit text as a progress annotation. Renders inline on the live progress line (between the surrounding fn-call ( and )) and is recorded as an event in the structured log. Returns text. |
Impure BIFs
Shell matching
| Function | Signature | Returns | Description |
|---|---|---|---|
match_prompt | match_prompt() | string | Match the shell prompt configured in Relux.toml. Advances the output cursor past the prompt. |
match_ok | match_ok() | string | Match the shell prompt, send echo $?, match 0, and match the prompt again. Verifies the previous command exited with status 0. |
match_not_ok | match_not_ok() | string | Match the shell prompt, verify the previous command exited with a non-zero status, and match the prompt again. The inverse of match_ok(). |
match_not_ok | match_not_ok(code) | string | Match the shell prompt, verify the previous command exited with a specific non-zero status code, and match the prompt again. Like match_exit_code(code) but also asserts the code is non-zero. |
match_exit_code | match_exit_code(code) | string | Send echo $?, match code, and match the prompt. Verifies the previous command exited with the given status. code is passed as a bare literal (e.g. match_exit_code(1)). |
Control characters
| Function | Signature | Returns | Description |
|---|---|---|---|
ctrl_c | ctrl_c() | "" | Send ETX (0x03) — interrupt the current process. |
ctrl_d | ctrl_d() | "" | Send EOT (0x04) — signal end of input. |
ctrl_z | ctrl_z() | "" | Send SUB (0x1A) — suspend the current process. |
ctrl_l | ctrl_l() | "" | Send FF (0x0C) — clear the terminal screen. |
ctrl_backslash | ctrl_backslash() | "" | Send FS (0x1C) — send SIGQUIT to the current process. |
CI Integration
Relux can produce TAP and JUnit output for integration with CI systems:
relux run --tap --junit
This writes results.tap and junit.xml into the run directory at
relux/out/run-<timestamp>-<id>/. A relux/out/latest symlink always
points to the most recent run. The run directory also contains index.html
(summary report), logs/ (per-test event logs), and artifacts/.
Key point: Always archive the entire run directory, not just the XML/TAP
files. The JUnit XML references log files via relative paths, and CI systems
that support attachments (Jenkins, GitLab) can link directly to per-test
event.html logs when the directory structure is preserved.
Each per-test directory under logs/ contains two artifacts:
events.json— canonical structured payload (spans, events, buffer events, outcome record, embedded source files), consumable by external tooling. Seeevents.jsonSchema for the on-disk shape, the tagged-enum variants for spans/events/buffer events/outcome, and the schema-versioning policy. Surfaced to machine consumers via the TAPlog_json:YAML field, a second JUnit[[ATTACHMENT|...]]marker, and a<property name="events_json" ...>element on each test case.event.html— self-contained Svelte SPA viewer. The structured log, highlight.js core, Relux language definition, and the viewer bundle are each gzipped, base64-encoded, and inlined into<script type="application/octet-stream">payload tags. A small bootstrap script decompresses them in-browser viaDecompressionStream, setswindow.RELUX_DATA, and runs the three JS payloads in order (hljs core → Relux grammar → viewer). Opens directly viafile://; no server required. Requires Chrome 80+ / Firefox 113+ / Safari 16.4+; older browsers see a one-line message and nothing else. This is the recommended human entry point and the link target used by the run-summaryindex.html, JUnit[[ATTACHMENT|...]]markers, and TAPlog:fields.
Skipped-test logs
Tests skipped by a marker — either # skip if X evaluating true or
# run if X evaluating false, on the test itself or on any effect/function
it depends on — produce a per-test log alongside passed and failed tests.
The skipped-test log contains only the MARKERS section: the synthetic
markers span tree with one marker-eval child per evaluated marker
(including any flaky markers that ran before the skip-causing one).
Opening event.html focuses the marker that triggered the skip and expands
its ancestors so the tree is unfolded. For a skip propagated from a fn or
effect, the focused marker is the originating one on that fn/effect, not
on the test.
Tests skipped for other reasons (e.g., “skipped because an earlier test caused fail-fast and this test was never started”) do not produce a log: there are no marker evaluations to show; the actionable information lives on the test that caused the cancellation.
Cancelled outcome
A test that was started but did not run to completion produces a
Cancelled outcome — distinct from Fail. Sources:
- Test timeout (
~Ton the test): the per-test watchdog fired. Carried asreason: { type: "test-timeout", duration_ms }. - Suite timeout: the suite-wide watchdog fired. Other live tests are
cancelled with
reason: { type: "suite-timeout", duration_ms }. - Fail-fast: a sibling test failed with
--strategy fail-fast. Live tests are cancelled withreason: { type: "fail-fast", trigger_test }. - SIGINT: the CLI process received SIGINT. Live tests are cancelled
with
reason: { type: "sigint" }.
Cancelled outcomes:
- Exit nonzero from
relux run(same as failures). - Render as
not okin TAP, with a diagnostic block carryingcancellation: <reason-tag>. - Render as
<error type="cancelled" message="cancelled: <reason-tag>"/>in JUnit XML (distinct from<failure>and<skipped>). - Render as a
cancelledrow in the HTML run index and aCANCELLEDpill in the per-test viewer. - A
cancelledevent inevents.jsonmarks the exact point where the VM observed the cancel, on the span execution was inside at that moment.
Flaky-retry semantics: a test marked # flaky is retried on Fail and
on Cancelled { reason: TestTimeout } (the test’s own clock running out —
exactly what scaled-timeout retries target). Other cancellation reasons
(suite-timeout, fail-fast, SIGINT) are not retried.
Artifacts
Anything a test writes under $__RELUX_TEST_ARTIFACTS is enumerated in
events.json under artifacts and surfaced in the viewer through an
artifacts modal (AppBar chip, hotkey A). Each entry is a relative link
that opens in a new browser tab; this works whether event.html is opened
directly via file:// or served over HTTP. The chip is rendered as
disabled when the test wrote no artifacts.
The viewer bundle is committed at vendor/relux-viewer.js.gz; regenerate it
(and the TypeScript schema bindings) with just viewer-build.
GitLab CI
GitLab natively consumes JUnit XML via artifacts:reports:junit. Archive the
full run directory so that [[ATTACHMENT|...]] markers in <system-out>
resolve to the event logs.
test:
stage: test
script:
- relux run --junit
artifacts:
when: always
paths:
- relux/out/latest/
reports:
junit: relux/out/latest/junit.xml
Setting when: always ensures artifacts are uploaded even when tests fail.
GitHub Actions
GitHub Actions does not have built-in JUnit support. Use
actions/upload-artifact to preserve the run directory, and a third-party
action to surface test results in the PR.
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Run tests
run: relux run --junit
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: relux-results
path: relux/out/latest/
- name: Publish test report
if: always()
uses: mikepenz/action-junit-report@v5
with:
report_paths: relux/out/latest/junit.xml
Other JUnit report actions (e.g., dorny/test-reporter) work the same way –
point them at relux/out/latest/junit.xml.
Jenkins
Use the JUnit post-build step to parse results. Install the JUnit
Attachments Plugin to make per-test event logs clickable – it reads the
[[ATTACHMENT|...]] markers embedded in <system-out>.
pipeline {
agent any
stages {
stage('Test') {
steps {
sh 'relux run --junit'
}
post {
always {
junit testResults: 'relux/out/latest/junit.xml',
allowEmptyResults: true
archiveArtifacts artifacts: 'relux/out/latest/**',
allowEmptyArchive: true
}
}
}
}
}
With the JUnit Attachments Plugin installed, each test case in the Jenkins UI
will link to its event.html log automatically.
Azure DevOps
Use the PublishTestResults task to ingest JUnit XML.
steps:
- script: relux run --junit
displayName: Run tests
- task: PublishTestResults@2
condition: always()
inputs:
testResultsFormat: JUnit
testResultsFiles: relux/out/latest/junit.xml
mergeTestResults: true
testRunTitle: Relux
- task: PublishBuildArtifacts@1
condition: always()
inputs:
pathToPublish: relux/out/latest
artifactName: relux-results
Gitea Actions
Gitea Actions uses the same workflow syntax as GitHub Actions. Gitea does not render JUnit reports natively, but you can archive results and use compatible actions from the Gitea marketplace.
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Run tests
run: relux run --junit --tap
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: relux-results
path: relux/out/latest/
The uploaded artifact preserves the full run directory including index.html,
which serves as a self-contained test report you can browse locally.
TAP Consumers
The --tap flag produces TAP version 14 output in results.tap. This is
useful with any TAP consumer (e.g., tap-diff, tap-dot, Jenkins TAP
Plugin):
# Stream TAP to a formatter
cat relux/out/latest/results.tap | tap-diff
# Or use the file directly with CI plugins that accept TAP input
TAP output includes log file paths in YAML diagnostics blocks (log: field),
but most CI systems do not parse TAP diagnostics for attachments. Use --junit
when you need CI-native log attachment support.
Test Log Viewer
The test log viewer is the per-test HTML report that ships with every Relux run as event.html inside the run directory’s logs/<test>/ folder. It is a single self-contained SPA: open it directly via file://, no server required. See CI Integration for how the file is packaged and shipped.
This article catalogs the viewer’s surface. Each region is listed with the data it shows and the keys that act on it.
Layout
The viewer has four persistent regions, top to bottom:
- App bar — test identity, outcome, modal launchers, run timing.
- Timeline bar — proportional bar of the test’s time range with click-to-jump slices.
- Events list (left pane) — the structured event log as a foldable tree.
- Detail panel (right pane, 2x2 grid) — source / shell / variables / call stack views of the current selection.
Three modal overlays — env, shells, artifacts — open on top of the layout.
Selection is the spine of the UI: nearly every pane reads from selectedSpanId or selectedEventSeq. Clicking a row in the events list, clicking a timeline slice, or clicking a frame in the call stack all change the selection; every other pane re-derives from there.
App bar
The strip across the top of the viewer.
Contents, left to right:
- Breadcrumb —
<directory>/<file>followed by the test name. - Outcome pill —
pass,fail,cancelled,skip, orinvalid.cancelledindicates the test was stopped before completion (test-timeout, suite-timeout, fail-fast, or SIGINT); the in-streamcancelledevent tells you which one. - Modal launcher chips —
env,shells (N),artifacts (N). The artifacts chip is disabled when the test produced no files. - Timing summary — total duration, event count, span count.
| Key | Action |
|---|---|
E | Toggle the env modal |
S | Toggle the shells modal |
A | Toggle the artifacts modal (no-op when empty) |
Timeline bar
A proportional time track spanning the test’s duration.
The selected event or span is rendered as a pulsing accent slice over the bar. Hovering anywhere on the track reveals one or more preview cards for the spans active at that timestamp: a short stack of cards anchored to the slice on the track, each summarizing one candidate span. Clicking the track selects:
- the only span there, if there is one;
- otherwise, it pins the preview cards so you can click the one you want.
Clicking outside a pinned card stack dismisses the pin. Clicking a preview card selects its span and reveals the matching row in the events list.
No keyboard shortcuts; the timeline bar is mouse-driven.
Events list
The left pane. Renders the structured event log as a foldable, indented tree.
Row types:
- Span entry — a span’s opening row. Indented by depth, foldable.
- Event — a non-span event (send, match, var-let, interpolation, …). Some related events are folded into a single row (e.g. a
match-start/match-donepair, asleep-start/sleep-donepair). - Log bar — emitted by the
logBIF; rendered as a horizontal bar carrying the log level and message. - BIF row — a transparent impure BIF call (e.g.
match_prompt) shown as a single row instead of a foldable span. - Gap — a synthetic row marking a duration with no events.
- Per-pattern match — inside a
<{ ... }block, eachmulti-match-pattern-doneevent renders as its own selectable row, labelledmatchin the kind column (the same label used by a folded single-matchmatch-start/match-donepair).
The footer below the list carries chips for filtering and bulk fold control:
- Filter — opens a popup with one checkbox per event type. The chip is highlighted when any types are hidden.
- Error path preset — hides everything except
error,fail-pattern-triggered,match-timeout. Disabled when the test passed. - Send / match only preset — hides everything except
send,match,match-timeout. - Collapse all / Expand all — fold or unfold every span at once.
| Key | Action |
|---|---|
Up / Down | Move selection to the previous / next row |
Right | Expand the selected span |
Left | Collapse the selected span |
Enter / Space | If an event is selected, deselect it; if a span is selected, toggle its fold |
F | Toggle the filter popup |
T | Toggle the error-path preset (no-op when the test passed) |
M | Toggle the send / match preset |
C | Collapse all spans |
X | Expand all spans |
Detail panel
The right pane. A 2x2 grid of panes, all driven by the current selection:
+---------------------+---------------------+
| source | shell |
+---------------------+---------------------+
| variables in scope | call stack |
+---------------------+---------------------+
Source
Renders the .relux file the selection points to, with the relevant byte range outlined by a pulsing accent frame. The header hint shows <file>:<line>; the view auto-scrolls to vertically center the anchor line and horizontally to keep the framed range on screen. Function calls, BIF rows, and imported items resolve to the file that actually defines them, not the file that called them.
When the selection has no source location, the pane shows no location and a placeholder.
Shell
The output buffer of the shell that owns the selected event, snapshotted at the moment of selection.
Renders three regions concatenated, top to bottom:
- Consumed — bytes already matched and advanced past. Dimmed.
- Matched — bytes consumed by the most recent match up to and including the selected event. Accent color, pulsing.
- Tail — bytes still in the buffer after the cursor. Default ink.
The header hint surfaces the shell’s state at the moment of selection: timestamp, matched ✓ if the selected event was a successful match, the active timeout, and the count of fail patterns armed in scope.
Inside a multi-match span, selecting a per-pattern match row splits the Tail region into two halves around the matched bytes: tail-before (bytes that appear before the match in the undrained buffer), the matched highlight (the pattern’s own match), and tail-after (bytes that appear after). The Consumed region remains the cursor’s position at block entry — because the multimatch block advances the cursor once, at block exit, individual per-pattern matches inside the block do not move the consumed boundary.
When the selection has no shell context (e.g. a pure-function span), the pane shows this event has no shell context.
This pane embeds the searchable buffer (see below) — type into the search field to find substrings inside the buffer.
Variables in scope
A two-section key/value table:
- Captures (
$name) — regex captures live in this scope, rendered with the accent color. letvariables — variables declared at this scope or any enclosing scope.
The footer carries a chip that links to the env modal — environment variables are not shown in this pane; they live in the env modal because they are global to the test.
When the selection has no scope context, the pane shows a placeholder.
Call stack
The stack of lexical scopes that contain the selected event, deepest frame at the top. Each frame shows its kind (e.g. TEST, FN-CALL, EFFECT-SETUP), name, optional alias, source location, and any bound arguments.
The topmost frame is fixed (it is the selection’s own frame). Clicking a lower frame “promotes” it: the viewer selects that frame’s inner neighbor, effectively walking out one level. Use this to navigate from a deeply nested BIF call back up to the test body.
The pane’s footer lists also-live shells — shells that are running but are not the one owning the selected event. Useful for multi-shell tests where work is happening in parallel.
Searchable buffer
Used in two places: the shell pane of the detail panel, and inside every card in the shells modal. A single-line search bar above a buffer view.
The query is matched against the rendered (escape-expanded) buffer text using substring search with smart case: case-insensitive unless the query contains an uppercase letter. The bar shows <current> / <total> matches; the current hit is rendered with a stronger accent than the rest.
| Key | Action |
|---|---|
Enter | Cycle to the next hit |
Shift+Enter | Cycle to the previous hit |
Esc | Clear the query; blur the field if already empty |
Cmd+S / Ctrl+S | Focus / cycle search inputs (see Global hotkeys) |
Env modal
Snapshot of the environment seeded when the test started — the host process environment, any .env file values layered over it, and Relux’s own run internals.
The body lists every variable, grouped by its true origin, in precedence order top to bottom:
- relux internals — values Relux injects for the run, including the per-test
__RELUX_TEST_ROOT/__RELUX_TEST_ARTIFACTS. Relux sets the whole reserved__RELUX*namespace on this layer, so any copy inherited from the host environment is shadowed and this group shows Relux’s own values. .envfiles — one section per source file, headed by the file’s path relative to the suite root, written as.../<relative path>. Sections are ordered deepest-first, so the higher-precedence file (the one nearer the test) sits nearer the top. Hover a header to see the absolute path.- host environment — values inherited from the process that launched
relux.
A section only appears when it has at least one variable, so a run with no .env files shows just the relux and host groups. A filter row at the top accepts a query and a scope toggle: filter by name, value, or name · matches (either side). The counter shows <filtered> / <total>.
The header carries a copy all action that copies the full environment as KEY=VALUE lines, one per row.
| Key | Action |
|---|---|
E | Toggle the modal |
Esc | Close the modal |
Cmd+S / Ctrl+S | Focus the filter input |
Shells modal
One card per shell spawned during the test, sorted by spawn time.
Each card has:
- Header — shell name, command, state dot (
running,awaiting input,ended,error), and a★ this eventbadge when the card corresponds to the shell owning the currently selected event. - Stats line — spawn timestamp, buffer size, events seen up to the selection, and termination timestamp (when ended before the selected event).
- Buffer column — a searchable buffer view for that shell’s output at the moment of the selected event.
The modal subtitle reflects the current selection (@ event #N · kind · t = ... · in <test>) so you always know what timestamp the buffers are snapshotted at.
| Key | Action |
|---|---|
S | Toggle the modal |
Esc | Close the modal |
Cmd+S / Ctrl+S | Cycle through the cards’ search inputs |
Artifacts modal
The list of files the test wrote under its artifacts/ directory.
Each row is path · size · mime. The path is a link that opens the artifact in a new tab (resolved relative to the report directory). A filter row at the top narrows the list; the header copy all action copies every path as a newline-separated list.
The modal launcher chip in the app bar is disabled when the test produced no artifacts; the A hotkey is a no-op in that case.
| Key | Action |
|---|---|
A | Toggle the modal (no-op when the test has no artifacts) |
Esc | Close the modal |
Cmd+S / Ctrl+S | Focus the filter input |
Global hotkeys
A consolidated reference. Keys without a modifier are ignored while a text input or contenteditable element has focus; the search-input cycle is the one exception (it deliberately runs before the input-focus guard so it can move from one input to the next).
| Key | Scope | Action |
|---|---|---|
E | global | Toggle env modal |
S | global | Toggle shells modal |
A | global | Toggle artifacts modal |
T | global | Toggle error-path preset in the events list |
M | global | Toggle send / match preset in the events list |
F | global | Toggle the events-list filter popup |
C | global | Collapse all spans |
X | global | Expand all spans |
Esc | global | Close the open modal |
Cmd+S / Ctrl+S | global | Focus / cycle search inputs in the current scope (modal if one is open, otherwise the main view) |
Up / Down | events list | Move selection by one row |
Right / Left | events list | Expand / collapse the selected span |
Enter / Space | events list | Toggle the current row |
Enter | searchable buffer | Cycle to the next hit |
Shift+Enter | searchable buffer | Cycle to the previous hit |
Esc | searchable buffer | Clear the query; blur if already empty |
Browser support
The bootstrap script in event.html uses
DecompressionStream
to unpack the inlined gzip payloads. Supported floors:
- Chrome / Edge 80+
- Firefox 113+
- Safari 16.4+
Older browsers (Safari 15, ancient Chromium forks) cannot decode the
payloads and see a one-line fallback message in place of the viewer.
The underlying events.json is unaffected — open it directly or feed
it to your own tooling.
events.json Schema
Every test run writes a per-test events.json next to its event.html
under relux/out/run-<timestamp>-<id>/logs/<test>/. The file is the
canonical structured artifact: the viewer that ships in event.html is
one consumer, and downstream tooling (dashboards, CI integrations,
custom reporters) can read the same file.
This article describes the on-disk shape. The TypeScript declarations
shipped under viewer/src/types/ are generated from the Rust source
via ts-rs and stay in sync; they are the
machine-readable equivalent of what this page describes in prose.
Conventions
- Tagged enums carry their discriminator either as
"kind"(Span,Event,BufferEvent,TestOutcome,SpanKind,EventKind,BufferEventKind) or as"type"(CancelReasonRecord,TimeoutValue,FailureRecord). The discriminator is always a kebab-case string. The remaining variant-specific fields are flattened alongside it. - Timestamps (
ts,start_ts,end_ts,spawn_ts,terminate_ts) are fractional milliseconds since test start, encoded as JSON numbers. - Durations exposed to JSON are JSON numbers in milliseconds
(
elapsed,duration) unless they live inside aTimeoutValue, where they are pre-formatted humantime strings. - IDs (
SpanId,EventSeq) are 64-bit unsigned integers. JSON object keys (e.g.spans) are stringified per JSON rules; the generated TS types reflect this. - String values are arbitrary UTF-8. Shell output is captured through a UTF-8 stream sanitizer.
Top-level shape
{
"schema_version": 2,
"info": { ... TestInfo ... },
"outcome": { "kind": "pass" | "fail" | "cancelled" | "skip", ... },
"env": { "bootstrap": [{ "key": "KEY", "value": "value", "source": { "kind": "base" } }, ...] },
"shells": { "<shell-marker>": { ... ShellRecord ... }, ... },
"spans": { "<span-id>": { ... Span ... }, ... },
"events": [ { ... Event ... }, ... ],
"buffer_events": [ { ... BufferEvent ... }, ... ],
"sources": { "<relative-path>": "<file contents>", ... },
"artifacts": [ { "path": "...", "size": 123, "mime": "..." }, ... ]
}
Field notes:
schema_version(u32) — current version is2. Bumped on any backwards-incompatible change. Consumers should verify this matches the version they expect and fail loudly otherwise. The viewer rejects mismatched artifacts with a banner.info—{ name, path, duration_ms }.pathis the source-relative path of the test file;duration_msis the total wall-clock from test start to outcome.env.bootstrap— list of env entries captured at test startup (the seed for the test’s environment chain, including this test’s own__RELUX_TEST_*internals). Each entry is{ key, value, source }, wheresourceis a taggedEnvSourceRecord:{ "kind": "base" }(process env),{ "kind": "dot-env", "path": "<file>" }(a.envlayer),{ "kind": "relux-internal" }(__RELUX_*run internals), or{ "kind": "effect-overlay", "mnemonic": "<id>" }. The tag is the provenance of the layer that supplied the winning value. Because effect envs are not dumped here, bootstrap entries in practice carry onlybase,dot-env, orrelux-internal;effect-overlayexists on the sharedEnvSourceRecordtype but does not appear in this list.shells— every PTY spawned during the test, keyed by stable identity marker (seeShellRecord).spans— every span opened during the test, keyed bySpanId(seeSpan). Forms a tree viaparent.events— execution events inseqorder (seeEvent).buffer_events— parallel timeline of PTY-buffer transitions inseqorder (seeBufferEvent).sources—.reluxfile contents referenced by anySpan.locationorEvent.source. Keyed by relative path; only files actually referenced are embedded.artifacts— files written under the test’s artifacts directory (seeArtifactEntry).
Outcome
outcome is a tagged enum on kind:
kind | Extra fields |
|---|---|
"pass" | none |
"fail" | one of the four FailureRecord variants, flattened |
"cancelled" | CancellationRecord, flattened |
"skip" | SkipRecord, flattened |
The variant tag carried by FailureRecord lives on type (not
kind) to avoid colliding with the outer outcome tag — so a
fail-outcome payload looks like
{ "kind": "fail", "type": "match-timeout", ... }.
Failure
FailureRecord is a tagged enum on type. All variants carry a
pre-computed call_stack (the active span stack at the failure site)
and vars_in_scope. Most variants also carry a buffer_tail (the
last bytes of the PTY buffer when the failure landed).
type | Source of failure |
|---|---|
"match-timeout" | match exceeded its effective timeout |
"fail-pattern-matched" | an installed fail pattern matched a recv line |
"shell-exited" | the PTY shell died unexpectedly (carries exit_code: i32 | null) |
"runtime" | any other runtime error (carries message; span/event_seq optional) |
"multi-match" | a <{ ... } block timed out before all patterns matched (carries patterns, matched indices, effective) |
Each variant also carries the span and event_seq that pinpoint the
event-stream location of the failure.
Cancellation
CancellationRecord:
{
"reason": { ... CancelReasonRecord ... },
"span": <span-id> | null,
"event_seq": <seq> | null,
"shell": "<name>" | null,
"call_stack": [ { ... StackFrame ... }, ... ]
}
CancelReasonRecord is a tagged enum on type:
type | Extra fields |
|---|---|
"test-timeout" | duration_ms |
"suite-timeout" | duration_ms |
"fail-fast" | trigger_test |
"sigint" | none |
Skip
SkipRecord is a pointer into the in-stream marker evaluations:
{
"span": <marker-eval span-id>,
"event_seq": <bool-check event seq>,
"marker_kind": "skip" | "run" | "flaky",
"evaluation": { ... MarkerEvalDetail ... }
}
The viewer focuses these at open time and expands ancestors so the markers tree is unfolded.
Shells
Keyed by stable identity marker. Each entry:
{
"marker": "<same as the map key>",
"name": "<spawn-time bare name>",
"spawn_ts": <ms>,
"terminate_ts": <ms> | null,
"command": "<the spawning shell command>"
}
The display layer renders qualified forms like Db.inner from events
(ShellSwitch, EffectExposeShell); the record itself holds the
bare name observed at spawn time.
Spans
A span represents one bracketed region of execution. Spans nest via
parent. Each span:
{
"id": <span-id>,
"parent": <span-id> | null,
"start_ts": <ms>,
"end_ts": <ms> | null,
"location": { ... SourceLocation ... } | null,
"kind": "<one of the kinds below>",
... // kind-specific fields, flattened
}
SpanKind is tagged on kind:
kind | Purpose |
|---|---|
"test" | Root span for the test body. name. |
"effect-setup" | An effect being acquired. effect, overlay, alias, marker, is_reuse. The bootstrap span has is_reuse: false; dedup’d reuse spans have is_reuse: true and zero duration. |
"effect-cleanup" | An effect being released. effect, alias, setup_span, marker, is_deferred. Parented under the test, not the long-closed setup; setup_span back-references its pair. |
"shell-block" | A shell <name> block. shell. |
"cleanup-block" | A cleanup block. No payload. |
"fn-call" | A function call (user or BIF). name, args, result, callee_kind ("user" | "bif"), is_pure. |
"markers" | Synthetic root grouping per-test marker evaluations. |
"marker-eval" | One marker evaluation under markers. marker_kind, modifier ("if" | "unless"), decision ("pass" | "mark"). |
"multi-match" | A <{ ... } block. shell. |
Events
An event is a point-in-time observation made by the VM. Events are
emitted in monotonic seq order. Each event carries the span it
landed on, the shell it acted on (when applicable), and a source
location resolving against sources. The common envelope:
{
"seq": <u64>,
"ts": <ms>,
"span": <span-id>,
"shell": "<display name>" | null,
"shell_marker": "<shell map key>" | null,
"source": { ... SourceLocation ... } | null,
"kind": "<one of the kinds below>",
... // kind-specific fields, flattened
}
shell and shell_marker are present iff a shell was in scope at
the emit site; shell_marker is the stable identity, shell is the
display name at that moment.
EventKind is tagged on kind. The variants, grouped by concern:
Shell lifecycle
kind | Extra fields |
|---|---|
"shell-spawn" | name, command |
"shell-ready" | name |
"shell-switch" | name |
"shell-terminate" | name |
"effect-expose-shell" | name, target, qualifier |
"effect-expose-var" | name, target, qualifier, value |
I/O
kind | Extra fields |
|---|---|
"send" | data |
"recv" | data |
Matching (buffer_seq cross-references a buffer_events entry)
kind | Extra fields |
|---|---|
"match-start" | pattern, is_regex, effective (a TimeoutValue) |
"match-done" | matched, elapsed (ms), captures: { [name]: string } | null, buffer_seq |
"timeout" | pattern, buffer_seq: u64 | null, effective. buffer_seq is null when no buffer event corresponds (the failure record’s buffer_tail is canonical in that case). |
kind | Extra fields |
|---|---|
"multi-match-start" | effective (a TimeoutValue), patterns: MultiMatchPattern[] |
"multi-match-pattern-done" | index (into patterns), elapsed (ms), buffer_seq (-> the per-pattern Matched buffer event) |
"multi-match-done" | advance_to (EventSeq -> the per-pattern Matched whose match ends farthest) |
"multi-match-timeout" | unmatched: number[] (pattern indices that did not match) |
The per-pattern payload type:
{
"pattern": "<string>",
"is_regex": <bool>
}
Event sequences:
- Success:
multi-match-start+ N xmulti-match-pattern-done(emitted in match-completion order, not source order) +multi-match-done. - Timeout:
multi-match-start+ 0..N xmulti-match-pattern-done+multi-match-timeout. - Fail-pattern abort:
multi-match-start+ 0..N xmulti-match-pattern-done, then the standardfail-pattern-triggeredpropagation. Nomulti-match-doneormulti-match-timeoutfollow.
The per-pattern Matched buffer events have the same before + matched + after shape as single-match. Inside a multi-match span, individual Matched events do not advance the reconstructed cursor; the block-end cursor advance is applied once at multi-match-done by dropping len(before) + len(matched) bytes from the front of the buffer of the Matched event referenced by advance_to.
Fail patterns
kind | Extra fields |
|---|---|
"fail-pattern-set" | pattern, is_regex |
"fail-pattern-cleared" | none |
"fail-pattern-triggered" | pattern, is_regex, matched_line, buffer_seq: u64 | null (null because fail-pattern hits observe without advancing the cursor) |
Control flow
kind | Extra fields |
|---|---|
"sleep-start" | duration (ms) |
"sleep-done" | none |
"timeout-set" | timeout, previous (both TimeoutValue) |
Values
kind | Extra fields |
|---|---|
"var-let" | name, value |
"var-assign" | name, value, previous |
"var-read" | name, value ("" when undefined) |
"string-eval" | result |
"interpolation" | template, result, bindings: Array<[name, value]> |
"pure-match" | match_kind ("regex" | "literal"), value, pattern, result (matched substring or ""), captures: { [name]: string } |
"bool-check" | evaluation: MarkerEvalDetail. Emitted as the last event inside a marker-eval span. |
MarkerEvalDetail is tagged on shape: "unconditional",
"bare" + { value, met }, "eq" + { lhs, rhs, met }, or
"regex" + { value, pattern, met }.
Diagnostics
kind | Extra fields |
|---|---|
"annotate" | text |
"log" | message |
"warning" | message |
"error" | message |
Cancellation
kind | Extra fields |
|---|---|
"cancelled" | reason: CancelReasonRecord. Emitted on the span the VM was inside when it observed cancellation. |
Buffer events
A parallel timeline tracking transitions of each shell’s PTY output buffer. Buffer events always carry a shell. The common envelope:
{
"seq": <u64>,
"ts": <ms>,
"shell": "<display name>",
"shell_marker": "<shell map key>",
"kind": "<one of the kinds below>",
... // kind-specific fields, flattened
}
BufferEventKind is tagged on kind:
kind | Extra fields | Meaning |
|---|---|---|
"grew" | data | New bytes appended to the buffer. |
"matched" | before, matched, after | A match consumed the cursor up through matched; before is what preceded, after is what now remains. |
"reset" | consumed | The buffer was reset (e.g. cleared between shell blocks); consumed is what got dropped. |
Stack frames
StackFrame (used in FailureRecord.call_stack and
CancellationRecord.call_stack):
{
"span": <span-id>,
"kind": "<span kind discriminator>",
"name": "<fn or effect name>" | null,
"args": [["name", "value"], ...],
"alias": "<user-supplied alias>" | null,
"location": { ... SourceLocation } | null
}
kind mirrors the span’s SpanKind discriminator (e.g.
"fn-call", "shell-block"). alias is the user-supplied
start FX as Alias binding when present; only effect-setup /
effect-cleanup frames carry one today.
TimeoutValue
// Either:
{ "type": "tolerance",
"duration": "5s", // humantime-formatted
"multiplier": "1.0",
"total_duration": "5s",
"source": { ... SourceLocation } | null }
// or:
{ "type": "assertion",
"duration": "30s",
"source": { ... SourceLocation } | null }
tolerance is the soft kind that scales with --timeout-multiplier;
assertion is the hard kind that does not. All three duration fields
are humantime strings — consumers should display them verbatim
rather than re-parsing.
SourceLocation
{
"file": "<relative path; matches a key in `sources`>",
"line": <1-based line number>,
"start": <byte offset into the source>,
"end": <byte offset into the source>
}
start/end resolve against sources[file].
Artifacts
{
"path": "<forward-slash relative path>",
"size": <bytes>,
"mime": "<mime/type>" | null
}
path never starts with / and never contains . / .. segments.
The list is sorted with files preceding subdirectory contents at each
level (cmp_artifact_paths). mime is derived from the extension
via mime_guess; the browser does its own sniffing on click.
Versioning
schema_version is currently 2. Version 2 changed env.bootstrap
from [name, value] tuples to { key, value, source } objects. A
future change that adds new
optional fields or new tagged-enum variants is not a breaking
change and does not bump the version; consumers should ignore unknown
variants gracefully. Any change that removes or renames fields, or
narrows the meaning of an existing field, bumps the version.
To regenerate the TypeScript bindings after editing the Rust types,
run just viewer-build — it runs the ts-rs export tests and then
rebuilds the viewer bundle.
Editor Support
Relux ships syntax highlighting and language support plugins for VS Code (and Cursor / VSCodium / code-server) and IntelliJ-family IDEs.
VS Code, Cursor, code-server, VSCodium
The extension is published to two registries and works wherever you install it from:
- Visual Studio Marketplace - VS Code, Cursor.
- Open VSX - VSCodium, code-server, Gitpod, air-gapped derivatives.
Install from the command line:
code --install-extension spawnlink-eu.relux
Or search for Relux in the Extensions sidebar of your editor.
Features
- Syntax highlighting for keywords, operators, strings, regex patterns, timeouts, comments.
- Bracket matching, auto-closing, folding.
- String interpolation highlighting (
${var},$1).
Source
editors/vscode/ in shizzard/relux. Contributions welcome - see editors/vscode/CONTRIBUTING.md.
IntelliJ IDEA, RustRover, CLion, PyCharm, GoLand, WebStorm
The IntelliJ plugin is published to the JetBrains Marketplace.
Install via Settings -> Plugins -> Marketplace -> search “Relux”.
Source
editors/intellij/ in shizzard/relux.