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) - Regex capture groups (
$1,$2, …) are set after a<?match and remain in scope until overwritten by the next<?
Functions
- Function 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])
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
- An effect has three explicit interface components:
expect— declares required environment variables the effect reads; the resolver validates these are satisfiableexpose— declares which shells the effect makes available to callers; 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 available via dot-access (shell alias.shell_name)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 { ... } - For composed effects,
exposecan re-export a dependency’s shell:expose dep.shell as public_name - 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<Env>) — 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
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
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 new
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 new
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 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 must start with an uppercase letter (
CamelCase):StartDb,Effect1,MyService - Function names, variable names, shell names, and parameters must start with a lowercase letter or underscore (
snake_case):start_server,_helper,my_shell - 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_name>
expose <alias>.<shell_name>
expose <alias>.<shell_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 available via dot-accessstart Effect as alias { KEY = expr }provides an overlay; shorthandKEYis equivalent toKEY = KEYexposedeclares which shells are part of the effect’s public interfaceexpose alias.shell as namere-exports a dependency’s shell 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
- 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 - Qualified form (
shell alias.shell_name) operates on a dependency’s exposed shell - 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 (base env 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
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
- 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:
?,= - 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 (visible in verbose output). 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.
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.