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)
  • 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 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])

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 satisfiable
    • expose — declares which shells the effect makes available to callers; 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 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 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 { ... }
  • For composed effects, expose can 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 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<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:
    • 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

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

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

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

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 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 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_name>
    expose <alias>.<shell_name>
    expose <alias>.<shell_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 available via dot-access
  • start Effect as alias { KEY = expr } provides an overlay; shorthand KEY is equivalent to KEY = KEY
  • expose declares which shells are part of the effect’s public interface
  • expose alias.shell as name re-exports a dependency’s shell 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
  • 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
  • Qualified form (shell alias.shell_name) operates on a dependency’s exposed shell
  • 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 (base env 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

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

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 (visible in verbose output). 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.


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.