Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Relux Semantic Model

Modules

  • Every .relux file 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/matchers resolves to lib/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
  • as aliases 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 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 <?

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 shell blocks (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 ~30s or !? error changes 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 fn instead of fn
  • Cannot contain shell operators (>, =>, <?, <=, !?, !=, timeouts)
  • Cannot call impure built-in functions (e.g., match_prompt(), ctrl_c())
  • Cannot call regular fn functions — only other pure functions and pure built-in functions
  • Can only contain: let declarations, 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() and log() are allowed

Shells

  • 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

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 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

  • 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

  • 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 the test declaration
  • Test structure (in order):
    1. Doc string (optional """...""")
    2. let declarations (test-scoped variables)
    3. start declarations (effect dependencies)
    4. shell blocks (test body)
    5. 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

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 .relux files, parses them, resolves imports and effect dependencies
  • Tests are the entry points — only modules with test blocks are executed
  • For each test:
    1. Resolve the effect dependency graph
    2. Run effects in topological order (reusing deduplicated instances)
    3. Execute the test body (shell blocks in declaration order)
    4. Run test cleanup
    5. 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

FieldTypeDefaultDescription
namestringdirectory containing Relux.tomlSuite name

[shell] section

FieldTypeDefaultDescription
commandstring/bin/shShell executable spawned for each shell
promptstringrelux> PS1 prompt set on shell init

[timeout] section

All durations use humantime format (e.g. 5s, 1m30s, 2h).

FieldTypeDefaultDescription
matchduration5sPer-match timeout
testduration5mMax wall-clock time per test
suiteduration10mMax wall-clock time for the entire test run

[run] section

FieldTypeDefaultDescription
jobsinteger1Number of parallel test workers

[flaky] section

FieldTypeDefaultDescription
max_retriesinteger0Max retries for # flaky-marked tests (0 = no retries)
timeout_multiplierfloat1.5Exponential 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 when relux run is invoked without --file flags.
  • 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. A latest symlink 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.

FlagDescription
-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, --jobsNumber of parallel test workers (default: 1)
--tapGenerate TAP artifact file in the run directory
--junitGenerate JUnit XML artifact file in the run directory
-m, --timeout-multiplierScale 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
--rerunRe-run only non-passing tests from the latest run
--flaky-retriesMax retries for # flaky-marked tests
--flaky-multiplierExponential timeout multiplier base for flaky retries (default: 1.5, must be > 1.0)
--test-timeoutOverride per-test timeout (humantime string)
--suite-timeoutOverride 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.

FlagDescription
--manifest <path>Path to Relux.toml (default: auto-discover by walking upward)

relux history [flags]

Analyze run history from relux/out/.

FlagDescription
--manifest <path>Path to Relux.toml (default: auto-discover by walking upward)
--flakyShow tests that have been both passing and failing
--failuresShow tests that have failed
--first-failShow the first failure for each test
--durationsShow 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.

FlagDescription
--shell <shell>Shell to generate completions for: bash, zsh, fish (default: autodetect from $SHELL)
--installWrite 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 fn functions
  • 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> }
}
  • expect declares required environment variables (comma-separated)
  • start declares dependencies (one per line)
  • 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
  • Effect aliases must be CamelCase
  • start Effect as Alias { KEY = expr } provides an overlay; shorthand KEY is equivalent to KEY = KEY
  • expose shell declares which shells are part of the effect’s public interface
  • expose var declares which variables are part of the effect’s public interface; these are let-bound values computed during setup
  • expose shell Alias.shell as name re-exports a dependency’s shell under a new name
  • expose var Alias.var as name re-exports a dependency’s variable under a new name
  • shell Alias.shell_name { ... } — qualified shell block for operating on a dependency’s exposed shell
  • Internal shells not listed in expose are terminated after setup
  • cleanup block: 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 | flaky
  • modifier: if | unless
  • expr: 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, or pure fn declarations (not inside the body)
  • When a function is skipped, all tests that call it are also skipped
  • A # flaky marker 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
MarkerModifierConditionMeaning
# skip(none)(unconditional)always skip
# skipiftruthyskip when condition is true
# skipunlessfalsyskip when condition is false
# run(none)(unconditional)no-op (always run)
# runiffalsyskip when condition is false
# rununlesstruthyskip when condition is true
# flaky(none)(unconditional)always mark as flaky
# flakyiftruthymark as flaky when condition is true
# flakyunlessfalsymark 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 effect and test blocks

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 let assignments
  • 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 .env files — is available everywhere)

Operators

All operators are followed by a space, then payload to end of line.

Send

OperatorPayloadValue
> text to EOLsent string
=> text to EOLsent string
  • > sends with trailing newline
  • => sends without trailing newline (raw send)
  • Variable interpolation applies in payload

Match

OperatorPayloadValue
<? regex to EOLfull match ($0)
<= literal to EOLmatched 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

OperatorPayload
!? 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:

ExpressionValue
"<text>"string literal
namevariable value
$1, $2regex 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

FunctionSignatureReturnsDescription
trimtrim(s)stringRemove leading and trailing whitespace from s.
upperupper(s)stringConvert s to uppercase.
lowerlower(s)stringConvert s to lowercase.
replacereplace(s, from, to)stringReplace all occurrences of from with to in s.
splitsplit(s, sep, index)stringSplit 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.
lenlen(s)stringReturn the byte length of s as a decimal string.
defaultdefault(a, b)stringReturn a if it is non-empty, otherwise return b.

Generators

FunctionSignatureReturnsDescription
uuiduuid()stringGenerate a random UUID v4 (e.g. "550e8400-e29b-41d4-a716-446655440000").
randrand(n)stringGenerate a random alphanumeric string of length n. Errors if n is not a valid integer.
randrand(n, mode)stringGenerate 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

FunctionSignatureReturnsDescription
available_portavailable_port()stringBind 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.
whichwhich(name)stringSearch 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.
sleepsleep(duration)""Pause execution for duration. Accepts humantime format: 500ms, 2s, 1m30s, etc. Errors if the duration is invalid.

Logging

FunctionSignatureReturnsDescription
loglog(message)stringEmit message to the event log and HTML report. Returns message.
annotateannotate(text)stringEmit 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

FunctionSignatureReturnsDescription
match_promptmatch_prompt()stringMatch the shell prompt configured in Relux.toml. Advances the output cursor past the prompt.
match_okmatch_ok()stringMatch the shell prompt, send echo $?, match 0, and match the prompt again. Verifies the previous command exited with status 0.
match_not_okmatch_not_ok()stringMatch 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_okmatch_not_ok(code)stringMatch 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_codematch_exit_code(code)stringSend 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

FunctionSignatureReturnsDescription
ctrl_cctrl_c()""Send ETX (0x03) — interrupt the current process.
ctrl_dctrl_d()""Send EOT (0x04) — signal end of input.
ctrl_zctrl_z()""Send SUB (0x1A) — suspend the current process.
ctrl_lctrl_l()""Send FF (0x0C) — clear the terminal screen.
ctrl_backslashctrl_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. See events.json Schema 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 TAP log_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 via DecompressionStream, sets window.RELUX_DATA, and runs the three JS payloads in order (hljs core → Relux grammar → viewer). Opens directly via file://; 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-summary index.html, JUnit [[ATTACHMENT|...]] markers, and TAP log: 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 (~T on the test): the per-test watchdog fired. Carried as reason: { 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 with reason: { 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 ok in TAP, with a diagnostic block carrying cancellation: <reason-tag>.
  • Render as <error type="cancelled" message="cancelled: <reason-tag>"/> in JUnit XML (distinct from <failure> and <skipped>).
  • Render as a cancelled row in the HTML run index and a CANCELLED pill in the per-test viewer.
  • A cancelled event in events.json marks 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:

  1. App bar — test identity, outcome, modal launchers, run timing.
  2. Timeline bar — proportional bar of the test’s time range with click-to-jump slices.
  3. Events list (left pane) — the structured event log as a foldable tree.
  4. 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 pillpass, fail, cancelled, skip, or invalid. cancelled indicates the test was stopped before completion (test-timeout, suite-timeout, fail-fast, or SIGINT); the in-stream cancelled event tells you which one.
  • Modal launcher chipsenv, shells (N), artifacts (N). The artifacts chip is disabled when the test produced no files.
  • Timing summary — total duration, event count, span count.
KeyAction
EToggle the env modal
SToggle the shells modal
AToggle 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-done pair, a sleep-start / sleep-done pair).
  • Log bar — emitted by the log BIF; 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, each multi-match-pattern-done event renders as its own selectable row, labelled match in the kind column (the same label used by a folded single-match match-start / match-done pair).

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.
KeyAction
Up / DownMove selection to the previous / next row
RightExpand the selected span
LeftCollapse the selected span
Enter / SpaceIf an event is selected, deselect it; if a span is selected, toggle its fold
FToggle the filter popup
TToggle the error-path preset (no-op when the test passed)
MToggle the send / match preset
CCollapse all spans
XExpand 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.
  • let variables — 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.

KeyAction
EnterCycle to the next hit
Shift+EnterCycle to the previous hit
EscClear the query; blur the field if already empty
Cmd+S / Ctrl+SFocus / 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:

  1. 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.
  2. .env files — 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.
  3. 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.

KeyAction
EToggle the modal
EscClose the modal
Cmd+S / Ctrl+SFocus 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 event badge 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.

KeyAction
SToggle the modal
EscClose the modal
Cmd+S / Ctrl+SCycle 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.

KeyAction
AToggle the modal (no-op when the test has no artifacts)
EscClose the modal
Cmd+S / Ctrl+SFocus 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).

KeyScopeAction
EglobalToggle env modal
SglobalToggle shells modal
AglobalToggle artifacts modal
TglobalToggle error-path preset in the events list
MglobalToggle send / match preset in the events list
FglobalToggle the events-list filter popup
CglobalCollapse all spans
XglobalExpand all spans
EscglobalClose the open modal
Cmd+S / Ctrl+SglobalFocus / cycle search inputs in the current scope (modal if one is open, otherwise the main view)
Up / Downevents listMove selection by one row
Right / Leftevents listExpand / collapse the selected span
Enter / Spaceevents listToggle the current row
Entersearchable bufferCycle to the next hit
Shift+Entersearchable bufferCycle to the previous hit
Escsearchable bufferClear 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 a TimeoutValue, 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 is 2. 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 }. path is the source-relative path of the test file; duration_ms is 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 }, where source is a tagged EnvSourceRecord: { "kind": "base" } (process env), { "kind": "dot-env", "path": "<file>" } (a .env layer), { "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 only base, dot-env, or relux-internal; effect-overlay exists on the shared EnvSourceRecord type but does not appear in this list.
  • shells — every PTY spawned during the test, keyed by stable identity marker (see ShellRecord).
  • spans — every span opened during the test, keyed by SpanId (see Span). Forms a tree via parent.
  • events — execution events in seq order (see Event).
  • buffer_events — parallel timeline of PTY-buffer transitions in seq order (see BufferEvent).
  • sources.relux file contents referenced by any Span.location or Event.source. Keyed by relative path; only files actually referenced are embedded.
  • artifacts — files written under the test’s artifacts directory (see ArtifactEntry).

Outcome

outcome is a tagged enum on kind:

kindExtra 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).

typeSource 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:

typeExtra 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:

kindPurpose
"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

kindExtra 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

kindExtra fields
"send"data
"recv"data

Matching (buffer_seq cross-references a buffer_events entry)

kindExtra 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).
kindExtra 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 x multi-match-pattern-done (emitted in match-completion order, not source order) + multi-match-done.
  • Timeout: multi-match-start + 0..N x multi-match-pattern-done + multi-match-timeout.
  • Fail-pattern abort: multi-match-start + 0..N x multi-match-pattern-done, then the standard fail-pattern-triggered propagation. No multi-match-done or multi-match-timeout follow.

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

kindExtra 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

kindExtra fields
"sleep-start"duration (ms)
"sleep-done"none
"timeout-set"timeout, previous (both TimeoutValue)

Values

kindExtra 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

kindExtra fields
"annotate"text
"log"message
"warning"message
"error"message

Cancellation

kindExtra 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:

kindExtra fieldsMeaning
"grew"dataNew bytes appended to the buffer.
"matched"before, matched, afterA match consumed the cursor up through matched; before is what preceded, after is what now remains.
"reset"consumedThe 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:

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.