Architecture

.ci Test File Format

The .ci format is used by Ze's test runner to define functional tests. It supports embedded files (Tmpfs), test options, expectations, and commands.

For the execution architecture (how tests are scheduled and run concurrently) and the web .wb format, see runner-architecture.md.

Syntax Overview

All lines use key=value format with : separators:

action=type:key=value:key=value:...
Action Purpose
stdin= Embed stdin content for processes
tmpfs= Embed file content inline
option= Test configuration
cmd= Commands (API, shell, foreground/background)
expect= Expectations to validate
await= Block until the daemon's stderr carries every awaited line, then tear down (deterministic fence); then=stop makes the runner stop the daemon itself
reject= Negative expectations (fail if matched)
action= Actions (send notification, raw bytes)
http= HTTP endpoint checks and readiness polls

Key Concepts

An unparseable test file fails; it never hides or vanishes

A file that does not parse is recorded as a permanent failure and discovery continues. It is never dropped, and it never aborts the rest of the directory. All three discoverers behave identically.

Discoverer Format Marker
EncodingTests.Discover .ci, suites registered by harnesstool.SuiteAnswer and the bgp runner (encode, plugin, ui, ...) Record.ParseFailed + State=StateFail + FailureType=parse_error
ParsingTests.Discover .ci (parse suite) parsingTest.ParseError
ParsingTests.Discover legacy + with a companion .expect. valid/*.confinvalid/*.conf parsingTest.ParseError
DecodingTests.Discover .ci and .test (decode suite) decodingTest.ParseError

The legacy .conf layout has no instances in the tree today, and it is held to the same contract anyway: a missing, empty, or bad-regex .expect file records that one fixture as a failure rather than abandoning the directory. An unreachable abort becomes reachable the moment someone adds the directory, and the shape is the bug.

Both alternatives are silent-coverage-loss bugs this project has already paid for, which is why neither is permitted:

Anti-pattern What it costs
Return the parse error out of Discover The whole directory is abandoned. One bad .ci made the test/ui suite discover and run ZERO tests, and a suite that runs nothing reads as green.
continue past the bad file The file leaves the suite with no warning and no failure record. Its coverage disappears and nothing says so.

A guard that neither denies nor speaks does not exist (ai/rules/evidence.md). The runner short-circuits a parse-failed test before executing anything, so the reported error is the parse error rather than a confusing downstream symptom.

Unparseable outranks skipped. A file that both fails to parse and carries option=needs-linux / option=skip-os is reported FAIL, not SKIP. Its skip marker was parsed from the same broken file, so it is not trustworthy evidence that the file need not run: a contradicting directive may sit past the break, or the marker itself may be what was mis-parsed. Honoring it would mean trusting a broken file's own claim that it can be ignored.

The consequences are asymmetric, which is what settles the ordering. 158 .ci files carry one of those markers (12 in test/ui), and on a non-Linux host they never run. A wrongly-SKIPPED malformed file is invisible indefinitely, which is exactly how test/ui rotted. A wrongly-FAILED one is loud and costs a single commit. The check therefore lives in parallel.go's per-test goroutine, ahead of the skip short-circuit: that is the real entry point, and both Runner.runTest and parsingRunner.runTest are reached through it.

Suite label, test id, and failure identity

The verify debugging protocol identifies a functional failure with:

Field Source Purpose
Suite label le test runner label such as plugin, ui, or managed First routing boundary inside ./le test functional
Test id One-based decimal id printed by --list and per-test result lines Exact single-test rerun scope
Run number N/TOTAL printed by --list and per-test result lines Human progress marker for long suites
CI file path Parsed .ci source path Full test definition and embedded fixtures
Failure kind Runner failure type, timeout state, or mismatch class Conservative grouping key
Expected / received evidence TEST FAILURE block detail Full debugging evidence in the stage log

In ZE_VERIFY_MODE=1, failed suites emit native failure-group metadata before the full failure blocks. The compact verify index uses that metadata for group routing and keeps the full TEST FAILURE blocks in the stage log.

Per-step trace output

All three runner families (.ci, .wb, .et) record per-step outcomes during execution and emit dual-format trace output:

Trace is emitted automatically for failed tests. Under -v, passing tests also show their trace. The .ci runner includes the trace in its TEST FAILURE report block when StepTrace is non-empty.

conn and seq

Most directives use conn=N and seq=N to identify message ordering:

A test with two peers and one UPDATE each uses conn=1:seq=1 and conn=2:seq=1, not conn=1:seq=1 and conn=1:seq=2.

Port Substitution

Each test owns a pair of ports, exposed as variables in commands, tmpfs= content, option=env values, and URLs:

Variable Meaning
$PORT BGP peer port (leased by the runner, used by le test peer --port $PORT)
$PORT2 Secondary port, $PORT+1 (web UI, looking glass, plugin acceptor)

Never hardcode port numbers. Use $PORT in cmd= exec values and $PORT2 in http= URLs.

The pair is LEASED when the test starts, not when the suite discovers it (runner.LeaseTestPorts, internal/test/runner/ports.go). Discovery numbers the Nth test of every suite from the same base, so the preference alone collides whenever two le test processes run at once; the lease takes a machine-wide advisory lock in $TMPDIR/ze-test-port-locks and probes the pair, and a test whose preferred pair is locked or occupied gets one from 25000-32759 instead. A hardcoded number in a .ci file takes part in neither step, which is why the rule above is a rule.

Stdin Blocks

A stdin block embeds content the runner pipes to a process's standard input. Two commands take it as a FILE instead, and the table under "Where the block goes" says which, why, and what the .ci writes to select each route.

Syntax

Multi-line (with terminator):

stdin=<name>:terminator=<TERM>
<content>
<TERM>

Single-line hex:

stdin=<name>:hex=<hex-value>

Single-line text:

stdin=<name>:text=<text-value>

Parameters

Parameter Description
name Identifier referenced by cmd=...:stdin=<name>
terminator End marker for multi-line content
hex Hex-encoded content (single-line)
text Plain text content (single-line, newline appended)

Examples

Multi-line (config):

stdin=ze-bgp:terminator=EOF_CONF
peer test-peer {
    remote {
        ip 127.0.0.1;
        as 65533;
    }
    local-as 65533;
}
EOF_CONF

cmd=foreground:seq=1:exec=ze -:stdin=ze-bgp

The block goes to a file and the daemon runs as ze start <file>. The example said exec=ze bgp server -:stdin=ze until 2026-09-07, naming a command the CLI does not have and a block the file does not declare.

Single-line hex (decode test):

stdin=payload:hex=FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF003C020000001C...
cmd=foreground:seq=1:exec=ze bgp decode --json --family ipv4/unicast -:stdin=payload
expect=json:json={ "type": "update", ... }

The block is PIPED: the - belongs to decode, not to the daemon.

Single-line text:

stdin=cmd:text=update text nhop set 10.0.0.1 nlri ipv4/unicast add 10.0.0.0/24

Where the block goes

The runner pipes the block, with two exceptions. Each one exists because the child needs a path it can open, and each is selected by what the exec= line already writes: no new directive chooses between them.

The exec= line Where the block goes Why
ze -, and its flagged forms ze -d -, ze --plugin <p> -, ze --mcp <port> -, ze --web <port> --insecure-web - a FILE in a stable per-daemon directory, and argv becomes ze [flags] start <file> SIGHUP reads the source again and a restart reuses its tree
le test peer ... with NO - in argv a temporary FILE appended to argv le test peer takes its expect script as a path argument
le test peer ... - PIPED LoadExpectFile opens its argument through cliio, so - is standard input there
every other line, ze bgp decode -, ze config validate -, le test replay -, sh -c ... included PIPED - is the cliio stdin token, and the command reads standard input. ai/rules/cli.md

The daemon - is recognized by POSITION, not by a list of verbs: the runner asks zeDaemonConfigArgIndex which argument is the config, and substitutes only when that argument is the -. A - belonging to a verb is left in argv and the block is piped, so ze config validate - and ze bgp decode pcap - test the form the operator types.

The first daemon block uses ze-bgp.conf in the test work directory, so a fixture rewriting that bare name still edits the source the daemon reads. Further distinct blocks use daemon-2/ze-<block>.conf, daemon-3/…, each with its own sibling database/. Reusing a block reuses its directory. Numbered directories distinguish even block names that sanitise identically. Wrapped launches such as le test fail-syscall … -- ze - retain real stdin and receive an isolated ze.config.dir by the same allocation rule. Explicit file paths remain authoritative; the runner does not choose a backend.

Until 2026-09-07 the runner took the FIRST - in argv whatever it meant, so every verb form ran against a file path the author never wrote. A test that named a stdin block on a verb was testing the path form under a - that said otherwise, and test/ui/bgp-decode-stdin-hex.ci failed with invalid hex: encoding/hex: invalid byte: U+002F '/' for that reason.

What a ze-peer block may carry

A block handed to le test peer (named on a cmd=...:exec=le test peer ...:stdin=<name> line) is read first by ze-peer and then by the test runner. The block named peer is validated by the same rules even with no such line, because a .ci with no cmd= at all feeds its expect= lines to ze-peer by another route. A line neither of them acts on fails the file at parse time, naming the block, the line number and the directive.

Line Read by
expect=bgp, reject=bgp, action=*, and the peer's own option= (asn, bind, tcp_connections, conn_map, open, update, linger, silent, await_eor, established-file) le test peer
expect=json, expect=stderr, expect=syslog, reject=stderr, reject=stdout, reject=syslog the test runner, where the line stands
cmd=api nobody. It documents the command that produced the expected bytes
option=timeout the test runner parses it, and adopts it only when the file declares none. Its scope is the whole test, not this peer, so a file-level value always wins. 450 tracked peer blocks carry one. Write the one that governs outside the block
option=env refused. It sets the environment of every process the test starts, not of this peer, so it must be written outside the block
a line neither parser accepts refused

Any OTHER directive the runner parses is accepted where it stands and applies to the whole test. option=file, expect=exit, await= and http= all work inside a peer block, and none of them is peer-scoped. Only option=env and option=timeout are singled out. Those two read as peer scope and are not.

A value the named option does not have is refused as well, not just an unknown option name. option=update:value=inspect-update-message and option=asn:value=abc each used to parse into nothing, which is the same silent drop one level down.

The accepted set is derived from the two parsers rather than written out a third time (peer.ClaimLine reports what ze-peer did with the line, and the runner's own line parser answers for the rest), so a directive added to either one cannot start being dropped here. Before the guard existed the block forwarded only expect= and action= lines and discarded everything else in silence: eleven reject=bgp directives across nine RFC-behaviour tests, and the reject=stderr of a tenth, asserted nothing while reading as the negative half of the proof.

Tmpfs (Virtual File System)

Tmpfs allows embedding multiple files within a single .ci file. Files are extracted to a temp directory at runtime.

Syntax

tmpfs=<path>[:mode=<octal>][:encoding=<type>]:terminator=<TERM>
<content>
<TERM>

Parameters

Parameter Required Default Description
path ✓ - Relative path (no .., no absolute)
mode ✕ Auto File permissions (octal: 644, 755)
encoding ✕ text Content encoding: text or base64
terminator ✓ - End marker (alone on line)

Mode Defaults

Files default to 0644. Use an explicit mode= only when the test needs a different permission.

Terminator Rules

Example

tmpfs=peer.conf:terminator=EOF_CONF
peer test-peer {
    remote {
        ip 127.0.0.1;
        as 65533;
    }
    local-as 65533;
}
EOF_CONF


option=file:path=peer.conf
option=asn:value=65533
expect=bgp:conn=1:seq=1:hex=FFFF...

Security Constraints

  1. No absolute paths - must be relative
  2. No parent traversal - no .. components
  3. No hidden files - no . prefix in path components
  4. Path length limit - max 256 characters
  5. Path depth limit - max 10 levels

Limits

Configurable via environment variables:

Limit Default Environment Variable
Max file size 1 MB ze.bgp.ci.max_file_size
Max total size 1 MB ze.bgp.ci.max_total_size
Max files 100 ze.bgp.ci.max_files
Max path length 256 ze.bgp.ci.max_path_length
Max path depth 10 ze.bgp.ci.max_path_depth

Options

option=<type>:key=value[:key=value...]
Type Keys Description
file path=<name> Config file to use
asn value=<N>[:peer=<ip>] The AS ze-peer opens with. It reaches BOTH carriers RFC 6793 defines: the two-octet My Autonomous System field, narrowed to AS_TRANS (23456) above 65535, and the Capability Value of capability 65. The range is 1 to 4294967295 and a value outside it fails the file when it is read, naming the option and the value. peer=<ip> binds the declaration to one of ze's endpoint addresses, for a peer process that serves several of ze's peers at once (option=conn_map). With no option=asn line at all the runner derives one from the session { asn { remote N } } leaf of the ze configuration the .ci names. It reads a tmpfs= block, a stdin= block and the file an option=file:path= points at, and it follows the inheritance chain: the router's own bgp { session { asn {. } } }, then a group or template, then the peer, each level overriding the one above. Three things fail the file at read time rather than being guessed: two peers ze dials at ONE address expecting different ASNs, a declared AS the reader cannot read, and an eBGP peer the derivation reached with nothing. That last refusal knows only what the reader knows. A peer is judged eBGP by comparing the local and remote AS, so a peer for which NO local AS is declared at any level of the chain is not judged eBGP and is not refused; it inherits ze's own AS from the mirror, which is right for an iBGP session and wrong for an eBGP one. The derivation reaches no peer at all when a compiled fixture under internal/test/fixture writes the configuration and launches le test peer itself, because no .ci block is involved; such a fixture passes --asn (cliWirePeerAS). internal/test/fixture/ui_fixture_send_bgp.go
bind value=ipv6 Bind to IPv6
timeout value=<duration> Test timeout (e.g., 30s). Overrides auto-timeout.
tcp_connections value=<N> The number of TCP connections the peer serves. It is a BOUND, never a witness: the count says how many connections happened and not who caused them. A peer that closes when its expectations are met makes ze dial again on its retry timer, so value=2 is reached by a daemon that did nothing. To assert that the DAEMON dropped and restarted a session, add option=linger, which makes the peer incapable of causing the second connection.
linger value=true Peer-block only: the check peer never closes a connection itself. After ALL expectations complete it prints its success token and holds the session open (answering KEEPALIVEs) until test teardown. BETWEEN connections, where one connection's expectations are met and a later connection is still owed, it holds that connection until the REMOTE closes it, and fails the test if teardown comes first. Without it a completed peer closes, which ze correctly treats as session-down; that withdraws the peer's routes and races any forwarding still in flight toward other peers, and it also makes the daemon's OWN close unobservable, because ze dials again on its retry timer whoever closed (asserts a stop and a restart, and needs the peer to be incapable of causing one). A conn_map peer is excluded from the between-connections hold: it serves every connection of one batch in turn and then waits for the daemon to close them all, so holding the first would starve the batch. A reject=bgp that fires during either hold RETRACTS the success token already printed, so the peer still fails the test. test/reload/config-apply-ordering-address-swap.ci
silent value=true Peer-block only, check mode only: the peer stops sending the automatic KEEPALIVE reply it otherwise writes for every message it receives. It holds the TCP connection open and keeps reading and matching expectations. Needed to reach ze's receive hold timer: ze sends its own KEEPALIVE every hold/3 seconds, each automatic reply resets ze's hold timer, and "the peer went quiet" is otherwise unexpressible. A closed connection is a different event on a different code path, so action=close does not substitute. Explicit writes still happen: action=send, action=notification, the OPEN handshake itself, and option=linger's post-completion KEEPALIVE loop are unaffected, so silent with linger is not silent. Sink and echo modes ignore it. See. test/plugin/deadpeer-holddown.ci
established-file path=<name> Peer-block only: ze-peer creates the empty file <name> when the daemon's first UPDATE arrives, once for the whole run. A relative name resolves in the test's work directory, where ze-peer and every compiled fixture run. The UPDATE, not ze's KEEPALIVE, is the event: RFC 4271 Section 9 permits an UPDATE only in Established, so the file proves the DAEMON is Established, while its KEEPALIVE arrives when it can still be in OpenConfirm. It lets a fixture wait on that state instead of a fixed delay: the reload trigger's awaitEstablished holds the SIGHUP until the file named established exists. A session that sends no UPDATE never writes the file. See. internal/test/fixture/misc_fixture_shellports.gotest/reload/reload-dynamic-peer-survives.ci
open value=<behavior> OPEN message behavior
update value=<behavior> UPDATE message behavior
env var=<KEY>:value=<V> Set environment variable
skip-os value=<os>[,<os>] Skip test on listed GOOS values (e.g., darwin, linux)
needs-linux [caps=<tok>[,<tok>]] Linux-only test. It skips on non-Linux hosts and runs in the QEMU guest through ./le test qemu all-tests. caps= declares required capabilities such as net-admin, net-raw, bpf, and sys-time; an unavailable capability produces a visible skip.
needs-path value=<repo-rel-path>[:hint=<cmd>] Declares an optional heavyweight artifact. The runner resolves the path against the repository root and prints the native hint when the artifact is absent. A malformed or escaping path is a parse error.
netns-link name=<if>[:address=<cidr>] Provisions a dummy interface inside the per-test namespace. The test skips outside the ./le test qemu netns-test path because the named link must never be created on the host.
exclusive group=<name> Never run concurrently with another test carrying the same group name. Tests outside the group are unaffected and keep running alongside, so this costs far less wall-clock than dropping a whole suite to -p 1. Use it when tests contend for a kernel-global observation surface that unique names or addresses cannot partition: the ddos tests (group=ddos-flood) all flood the same loopback interface, and each daemon's detector picks its victim by top-destination-bytes over that interface's counters, so a sibling's concurrent flood is indistinguishable from the test's own. Applies on every platform and in every runner mode, because the contention is a property of the tests rather than of the host.

Choosing between needs-linux, caps=, and skip-os

The .ci test ... Use
Only validates config (ze config validate -), parses, or runs an offline ze show / ze env Nothing. It runs natively on every OS
Boots a daemon that APPLIES Linux-only config (interface, VLAN, firewall, L2TP kernel) option=needs-linux
The same, and needs privileged network configuration (creates interfaces, brings links up, programs netlink) option=needs-linux:caps=net-admin
The same, and opens a raw or packet socket (resolve ping, traceroute) option=needs-linux:caps=net-raw
The same, and loads eBPF option=needs-linux:caps=bpf
The same, and sets the system clock (the NTP plugin restoring persisted time) option=needs-linux:caps=sys-time
Skips on one non-Linux OS for a reason unrelated to the kernel option=skip-os:value=darwin
Needs an optional heavyweight artifact the checkout does not carry option=needs-path:value=<repo-rel>:hint=<cmd>

caps= takes a comma-separated list, so a test that programs netlink and loads eBPF declares caps=net-admin,bpf and is gated on both. An unknown token is a parse error on every host, macOS included, so a typo cannot silently disable the gate.

caps=net-admin exists because Linux alone is not the requirement. On an unprivileged Linux host, a CI runner or a rootless container, a test that applies interface config does not fail cleanly: the interface plugin fails its configure handshake with operation not permitted and the DAEMON exits 1, then the TEST hangs because its check peer waits for a session the exited daemon will never open. The gate reads CapEff from /proc/self/status, not uid 0: a setcap'd binary holds the capability without being root, and a restricted container can be root without it.

A caps= test does not run in the merge gate. ./le verify worktree runs unprivileged, so the marker turns an opaque hang into an honest skip there, and the coverage relocates to .github/workflows/qemu-nightly.yml. TestCapabilityGatedTestsHaveANativeVMHome fails when that link is broken: marking a test with a capability nobody's CI has would be a coverage deletion wearing a skip's clothing.

OPEN Behaviors

Value Description
send-unknown-capability Add unknown capability (code 66) to OPEN
inspect-open-message Validate received OPEN against expectations
send-unknown-message Send unknown message type (255) after OPEN
drop-capability Remove a capability from ze-peer's OPEN response
add-capability Add a capability to ze-peer's OPEN response
router-id Send an explicit BGP Identifier instead of the mirrored one
hold-time State the Hold Time of the OPEN body (seconds=<N>)
graceful-restart State ze-peer's own Graceful Restart capability (restart-time=<N>, family=, forward-state=)
llgr State ze-peer's own Long-Lived Graceful Restart capability (stale-time=<N>, family=, forward-state=)
paths-limit State ze-peer's own PATHS-LIMIT entries (family=, limit=<N>)

Sender Facts (hold-time, graceful-restart, llgr, paths-limit)

Each of these four values states a fact about ZE-PEER that a receiver acts on. They exist because a mirror asserts sameness: mirrored, each one made ze read its own configuration back out of the peer's OPEN and believe the peer had said it.

option=open:value=hold-time:seconds=<N>
option=open:value=graceful-restart:restart-time=<N>[:family=<f>[,<f>]][:forward-state=true|false]
option=open:value=llgr:stale-time=<N>[:family=<f>[,<f>]][:forward-state=true|false]
option=open:value=paths-limit:family=<f>[,<f>]:limit=<N>
Key Range What reads it
seconds 0, or 3 to 65535 session_negotiate takes the smaller of this and ze's receive-hold-time, then calls timers.SetHoldTime. RFC 4271 Section 4.2. 1 and 2 fail the file
restart-time 0 to 4095 grStateManager.onSessionDown arms the restart timer on it, runPeer gives it to startEORTimer, and show bgp peer prints it. RFC 4724 Section 3 gives the field 12 bits
stale-time 0 to 16777215 enterLLGRLocked arms one timer per family on it. RFC 9494 Section 3 gives the field 24 bits
limit 0 to 65535 The Max Paths field of the code 76 entry this peer advertises. Session.filterPathsLimit drops paths past it, so ze sends this peer no more than limit paths for one prefix
family a family name, or a comma-separated list The <AFI, SAFI, Flags> tuples of code 64, the 7-octet tuples of code 71, and the entries of code 76
forward-state true (default) or false The F bit of every tuple. onSessionReestablished purges the stale routes of a family whose F bit is clear

The families are what make graceful restart act at all. RFC 4724 Section 3 pairs the Restart Time with a tuple list, and onSessionDown builds its stale family set from that list and returns without dispatching anything when the set is empty. Ze's own code 64 carries the time and no tuples (parseGRCapValue), so until ze-peer owned the value, retain-routes, mark-stale and purge-stale were never dispatched in any test.

A file that states none of them gets the harness's own defaults, never ze's.

Fact Default
Hold Time 65535, the largest the two-octet field states. RFC 4271 Section 4.2 takes the smaller of the two, so the negotiated value stays ze's own and no existing test changes cadence. Only a .ci stating a smaller one opts in
Restart Time 300 seconds, longer than any functional test runs
Long-Lived Stale Time 600 seconds
Max Paths 65535, which bounds nothing
Software version (code 75) le test peer. It has no option, because no session decision turns on it: capability.Parse holds no code-75 arm and the only reader is the offline ze bgp decode
Families, for all three capabilities the families ze-peer's own OPEN advertises, read from its Multiprotocol capabilities. An OPEN carrying none is an ipv4/unicast speaker (RFC 4760 Section 8)

Two refusals fail the file rather than dropping a line in silence:

BGP Identifier Control (router-id)

option=open:value=router-id:id=<a.b.c.d>

Ze-peer's default OPEN carries ze's own BGP Identifier with the last octet incremented, which is always a distinct, valid identifier. This option replaces it outright, so a test can present an identifier the default can never produce: 0.0.0.0, or ze's own router-id (RFC 6286 Section 2.2 rejects both, the second only from an internal peer). A malformed or IPv6 value fails the file when it is read. It was ignored until 2026-09-08, which sent the DEFAULT identifier from a test that asked for an invalid one: the file then tested the valid identifier and passed.

Capability Control (drop-capability / add-capability)

Ze-peer mirrors the capability SET of ze's OPEN, so a .ci that says nothing about capabilities still negotiates whatever ze offers. It does NOT mirror the values that describe the SENDER. The AS, the BGP Identifier, the Hold Time, the Role (code 9), the Graceful Restart time, families and flags (code 64), the ADD-PATH directions (code 69), the Long-Lived Graceful Restart stale time and flags (code 71), the FQDN (code 73), the software version (code 75) and the PATHS-LIMIT entries (code 76) are each resolved from the test's own configuration and written into ze-peer's OPEN, because a mirror asserts sameness and every one of those facts is about the speaker rather than about the session. ownedCapabilities (internal/test/peer/open_capability.go) is the list: a code absent from it is mirrored by construction.

The drop-capability and add-capability options act on that reconciled OPEN at wire level, allowing tests to control exactly which capabilities ze-peer advertises.

A capability the .ci states REPLACES the one ze-peer would have resolved. An add-capability naming any resolved code -- 9, 64, 65, 69, 71, 73, 75 or 76 -- is sent as written and ze-peer adds no second capability of that code, so a file that drops a code and adds it back gets exactly what it asked for. Dropping code 65 removes the four-octet AS capability, which leaves the two-octet My Autonomous System field as the only carrier of the peer's AS: above 65535 that field can carry AS_TRANS alone, which is what RFC 6793 Section 3 defines for a speaker with no two-octet AS.

An add-capability:code=65 carrying four octets IS the AS declaration, and the My Autonomous System field follows it. It outranks option=asn and the derivation, because it names the octets that reach the wire and RFC 6793 Section 4.1 makes those the octets a receiver reads. Letting the header field come from anywhere else would put two ASNs in one OPEN, which is the disagreement ze answers with NOTIFICATION 2/2 Bad Peer AS. A stated code 65 of any OTHER length declares no AS: it is a malformed capability the test is driving on purpose, so it is sent as written and the header field keeps the AS the rest of the configuration resolved. That is the one case where the two carriers differ, and they differ because the .ci asked.

Drop a capability:

option=open:value=drop-capability:code=<N>

Removes the capability with the given code from ze-peer's OPEN response. The peer will not see this capability in the mirrored OPEN.

Add a capability:

option=open:value=add-capability:code=<N>:hex=<value-bytes>

Adds a capability with the given code and hex-encoded value bytes to ze-peer's OPEN response.

Key Description
code Capability code (1-255), e.g., 65 for ASN4, 2 for route-refresh
hex Hex-encoded capability value bytes (only for add-capability)

Use case: testing capability mode enforcement:

When Ze is configured with require mode for a capability, it sends a NOTIFICATION if the peer lacks that capability. To test this, use drop-capability to make ze-peer omit the capability from its response:

# Test: Ze requires ASN4, ze-peer drops it → Ze should send NOTIFICATION
option=open:value=drop-capability:code=65

When Ze is configured with refuse mode, it sends a NOTIFICATION if the peer has a capability. To test this, the default mirror behavior already includes the capability, but add-capability can add capabilities not in the original OPEN:

# Test: Add a custom capability for refuse testing
option=open:value=add-capability:code=73:hex=067A652D626770

Multiple overrides can be combined:

option=open:value=drop-capability:code=65
option=open:value=drop-capability:code=2
option=open:value=add-capability:code=73:hex=067A652D626770

UPDATE Behaviors

Routes ze-peer sends after the OPEN handshake, before it starts matching expectations.

Value Keys Description
send-default-route none Send one UPDATE for 192.0.2.1, an address no test host holds, so the route passes the RFC 4271 Section 6.3 next-hop check. 0.0.0.0/32
send-route prefix, origin-as, next-hop, and optionally as-path, as-set, originator-id, cluster-list, label Send one UPDATE for one prefix. Repeat the line for more
send-bulk prefix, count, next-hop, origin-as, and optionally max-msg, eor Generate count sequential prefixes from prefix and send them as whole BGP messages

send-default-route and send-route build the AS_PATH that a real speaker sends. On an eBGP session, where ze-peer's OPEN AS differs from ze's, the path starts with ze-peer's own AS (RFC 4271 Section 5.1.2). An origin-as that names a different AS follows it, so origin-as=65001 from AS 65002 sends [65002 65001]. On iBGP the path is origin-as alone, or empty. An as-path key is the whole path and goes out as written. Ze drops a route from an eBGP neighbor whose leftmost AS is not the neighbor's (RFC 4271 Section 6.3), so a hand-written hex= UPDATE from an eBGP peer MUST start its AS_PATH with that peer's AS.

Generating one oversize UPDATE (max-msg):

option=update:value=send-bulk:prefix=10.0.0.0/24:count=16373:next-hop=10.0.0.1:origin-as=65001:max-msg=65535

max-msg caps one generated message, header included. It defaults to the RFC 4271 limit of 4096, and accepts 23 to 65535. Raise it to 65535 when the session negotiates the Extended Message capability (RFC 8654), which ze advertises when the peer carries capability { extended-message enable }. Prefixes that do not fit one message spill into the next, so the example above is exactly one 65535 byte message: a 65516 byte body, the largest BGP permits.

Use it whenever a test needs an UPDATE too large to write as a hex literal. A max-size message is 131070 hex characters, which is past the 64 KiB line limit of both .ci scanners and past what a reviewer can check. It is also the only way to hand the daemon a single oversize body: splitting the same prefixes across several standard messages is a different input, because the daemon decides per message.

A malformed key fails the test load rather than sending nothing. That is deliberate: a spec that silently degraded to count=0 would let a test asserting a route was NOT forwarded pass because no route was ever offered.

Commands

cmd=<type>:key=value[:key=value...]

API Commands

cmd=api:conn=<N>:seq=<N>:text=<command>
Key Description
conn Connection number (1-4)
seq Sequence number within connection
text API command text

Example

cmd=api:conn=1:seq=1:text=update text origin set igp nhop set 10.0.1.1 nlri ipv4/unicast add 10.0.0.0/24

Process Commands (Foreground/Background)

For orchestrating multiple processes:

cmd=background:seq=<N>:exec=<command>[:stdin=<name>][:name=<handle>][:timeout=<dur>][:ready=<text>]
cmd=foreground:seq=<N>:exec=<command>[:stdin=<name>][:timeout=<dur>][:exit=<N>]
cmd=stop:seq=<N>:name=<handle>[:signal=kill|term]
Key Description
seq Execution order (lower first)
exec Command to execute
stdin Stdin block name to pipe
timeout Foreground test budget, or background process lifetime (e.g., 10s).
exit Exit code asserted for this command (0..255). See below.
name Handle for a background process, so a later cmd=stop can target it.
ready cmd=background only: text the process prints once it can serve. See below.
signal cmd=stop only: kill (SIGKILL, default) or term (SIGTERM).

Markers may appear in any order; each value runs to the next key in the table above, whichever key that is.

The table is the whole vocabulary. Any other :<word>= on a cmd= line fails the file, naming the key, the accepted set and the line. The scan reads the whole line, exec= included, because that is exactly where a key the parser does not read ends up: a value runs to the next KNOWN key, so an unknown one is swallowed into the value before it rather than dropped. cmd=foreground:seq=2:exec=ze -:stdin=ze-bgp:timeout=15s:env=ZE_FWD_WRITE_DEADLINE=10s parsed with timeout="15s:env=ZE_FWD_WRITE_DEADLINE=10s", so the line got neither the timeout it declared nor the variable, and every assertion still passed. Set an environment variable with option=env:var=<name>:value=<value>.

One consequence: a command carrying a :<word>= span of its own cannot be written on a cmd= line. Put it in a tmpfs= script and run the script.

timeout= must be a Go duration (10s, 1m30s). Both readers of the value keep their own default when it does not parse, so it is refused here instead.

The test deadline ends the whole process tree of every cmd= process. Each one leads its own process group, and the deadline kills that group, so a fixture's daemon dies with the fixture. A descendant that left the group cannot hold the test open either: the runner stops reading its output pipes 10s after the kill.

Background: Starts and keeps running until its timeout, an explicit stop, or test completion. Foreground: Setup commands finish before the next step. A ze daemon starts without blocking later steps. Its peers or observer determine when teardown starts. Stop: Terminates a named background process mid-test (see below).

A foreground helper that is the LAST cmd= line is the exception, and a file that asserts on its output MUST declare expect=exit:code=. The runner waits for a foreground helper only while another command still follows it; as the last line it is classed with the daemons, so it is started and torn down with no Wait, and its output reaches the accumulators only if it wins that race. The exit assertion is what makes the runner wait for it (fgProc.Wait()), and the code it asserts is that helper's own. Without it an expect=stdout:contains= over the helper's span reads an empty buffer, and a reject= over the same span can never fail.

When an embedded observer sends request shutdown, teardown waits out that daemon's bounded self-stop grace before it stops any other background process, whatever order those processes started in. A helper the daemon still talks to during its own shutdown stays up for that wait.

How an exec= value becomes argv

The runner splits the value on spaces and tabs, and a span inside " or ' stays ONE argument. Quote an argument that carries a space or a pipe, exactly as you would in a shell:

cmd=foreground:seq=1:exec=ze cli -c "show config dump - | json":stdin=config

-c receives show config dump - | json, and the quotes do not reach the process. Three limits apply:

One splitter serves every suite, so a cmd= line produces the same argv wherever it runs.

The first word selects the program. ze is the daemon under test, and le test <name> runs the harness, which is the runner's own executable. Any other head, a retired standalone harness name among them, is no head of the runner's: it reaches the PATH lookup, finds no program, and fails the step. A name that the suite compiled into its temporary directory runs from there: the chaos suites compile le, so exec=le chaos run ... runs that build. Any other name is found on PATH. A shim directory on the child's PATH holds ze and le, so a plugin run "le test ..." line in a config reaches the same binaries.

Provenance: until 2026-09-02 the .ci runner split the value on whitespace alone while the parse suite honored quotes. The example above was already published here, so four test/ui tests were written against it. -c received only the first word of the quoted command, ze cli fell through to its SSH client, and each test failed with no credentials for 127.0.0.1:2222.

ready= -- wait for a background process to serve

A background process that is not ze-peer gets 100ms to start before the next step runs. That is a guess, and a loaded host makes it wrong. A fixture that serves a socket the next step dials (a BMP collector, an RTR cache) then starts after ze has already dialed, ze gets connection refused, and ze waits out its reconnect interval: 30s for BMP (RFC 7854), 600s for RTR. The test fails as a protocol stall.

ready=<text> removes the guess. The runner reads the process's stdout and stderr, and it starts no later step until the text appears:

cmd=background:seq=1:exec=le test fixture plugin/bmp-sender-statistics-collector $PORT2:ready=BMP-COLLECTOR: listening on
cmd=background:seq=2:exec=le test peer --port $PORT:stdin=peer
cmd=foreground:seq=3:exec=ze --plugin ze.bgp-bmp -:stdin=ze-bgp

cmd=stop -- terminate a background process mid-test

A background process started with name=<handle> can be stopped at a chosen step by cmd=stop:seq=<N>:name=<handle>. The runner looks the process up, signals it, and waits for it to exit before the next step runs, so a later step can deterministically observe what happens after the process dies (e.g. show vpn ipsec sa emptying once an IKE responder is killed and DPD fires).

Fail-closed: a cmd=stop naming a process that was never started (no matching name=) fails the test with a clear error; it never silently no-ops, and it can only ever signal a process the runner itself started (never an arbitrary PID). Teardown still kills every remaining background process, and tolerates one the stop step already reaped.

Provenance: added by spec-fixit-runner-kill-background to unblock end-to-end peer-death observation (the deleted test/ipsec/ipsec-dpd-timeout.ci needed it).

exit= vs expect=exit:code= (per-command vs file-level)

expect=exit:code= is file-level: Record.ExpectExitCode is a single value (a later expect=exit:code= silently overwrites an earlier one) and the runner compares it against lastQuickZeErr -- the exit status of the last quick-exit ze command in the file. A file that runs several ze config validate commands therefore asserts only the final one; every earlier command can exit with any code and the test still passes.

Use exit= on the cmd= line to assert a specific command's own exit code. It is checked the moment that command finishes, and names the offending seq on failure:

cmd seq=2 (ze config validate -): expected exit code 1, got 0

Prefer exit= whenever a file runs more than one quick-exit ze command. A "quick-exit ze command" is any foreground ze whose verb is not a daemon verb (hub, start, cli, monitor) and which has no config-file argument or --web flag.

Every stream assertion is scoped to the command above it

expect=stdout:, reject=stdout:, expect=stderr:contains= and reject=stderr:contains= are checked against the output of the cmd= line directly above them, and against nothing else. Position is the grammar: the line means what its place in the file says it means.

A command's span is the half-open slice of the run's two accumulators it wrote between starting and finishing. A quick-exit ze marks both ends around its own fold, so its span is exact. A background process is still writing when the test ends, and a foreground daemon's bytes arrive throughout its life, so both close at the final lengths: a long-running command's span can therefore contain bytes a later command wrote. A command the run never reached has an EMPTY span, never the whole buffer.

This replaced a file-level check over one combined buffer. Every assertion used to read Record.ClientOutput, the concatenated stdout AND stderr of every command in the file, so an assertion could be satisfied by a command its author never named and a reject could trip on any command's output. Two files did exactly that: test/plugin/kernel-capability-unknown-starts.ci and kernel-capability-doctor-reports.ci each asserted that ze doctor printed a diagnostic code and then ran ze explain <that code>, whose output satisfied the assertion on a host where the doctor check does not run at all.

So a file no longer needs splitting to make a negative assertion mean something. test/plugin/vpp-doctor-hugepages.ci and vpp-doctor-hugepages-quiet.ci, and test/appliance/no-install-appliance.ci and appliance-help-not-deprecated.ci, are each one scenario in two files for a reason that no longer applies; they are correct as they stand and splitting a new file for that reason is not.

A failure names the command: cmd seq=2 (ze config validate -): output does not contain "...".

Known gap: 108 quick-exit ze commands across 50 .ci files predate exit= and are still unasserted (their expect=exit:code= never reaches them). Arming them may surface real defects; tracked in plan/known-failures/.

The parse suite reads its own dialect

test/parse runs under ParsingTests, a second parser with its own execution model. Two differences are load-bearing for an author.

Every assertion is scoped to one command, as it is in the generic runner (above). This suite has always worked that way and the generic runner now does too, so the two dialects no longer disagree about what an assertion's position means. One difference survives: here expect=stderr:contains= MAY appear before the first cmd=, because a .ci holding an inline config and no command at all is a legacy negative test whose expected error it carries.

The dialect is a subset of the generic vocabulary, and nothing else parses. A directive no arm reads fails the file at discovery, naming the directive, the file, and every directive the suite does read. It is the same rule as "Unknown Keys Are a Parse Error" above, applied to the whole line rather than to a key inside it.

Read by the parse suite
cmd=, expect=exit:code=
expect=stdout:contains=, expect=stdout:pattern=
expect=stderr:contains=, expect=stderr:pattern=
reject=stdout:contains=, reject=stdout:pattern=, reject=stderr:pattern=
option=skip-os:value=, option=env:

Two spellings this suite once had are deleted, not aliased. Both existed only here, and both are what two parsers over one corpus costs:

cmd=...:timeout=<duration> is honored, and a bare ze - runs as ze start <file>, the same translation the generic runner makes. Substituting the path in place produced ze <path>, which is not a command: the daemon answered unknown command: <path> with its usage and exit 1 before reading a line of config, so a file could assert expect=exit:code=1 and be satisfied by the usage error.

Daemon readiness (ze only): a ze daemon launched either foreground or background is told (via ZE_READY_FILE) to write daemon.ready once startup completes, and the runner publishes its PID to daemon.pid in the tmpfs directory. Tests poll both files directly or through a compiled driver in internal/test/fixture before signaling the daemon or asserting on it. The readiness handshake is armed only for Ze daemons.

Example (Decode Test)

stdin=payload:hex=FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF003C...
cmd=foreground:seq=1:exec=le test decode --family ipv4/unicast -:stdin=payload
expect=json:json={ "type": "update", ... }

Example (Multi-Process)

stdin=peer:terminator=EOF_PEER
option=asn:value=65000
expect=bgp:conn=1:seq=1:hex=FFFF...
EOF_PEER

stdin=ze-bgp:terminator=EOF_CONF
peer test-peer { remote { ip 127.0.0.1; } ... }
EOF_CONF

cmd=background:seq=1:exec=le test peer --port $PORT:stdin=peer
cmd=foreground:seq=2:exec=ze bgp server -:stdin=ze-bgp:timeout=10s

Example (Multi-Peer)

Tests needing two or more BGP peers use the same $PORT on different loopback addresses. The ze_bgp_tcp_port override sets all peer ports uniformly, which is correct because every peer listens on $PORT.

# Source peer on 127.0.0.1 (default)
stdin=source:terminator=EOF_SOURCE
option=tcp_connections:value=1
action=send:conn=1:seq=1:hex=FFFF...
EOF_SOURCE

# Dest peer on 127.0.0.2 -- sink mode absorbs any UPDATE
stdin=dest:terminator=EOF_DEST
option=tcp_connections:value=1
EOF_DEST

cmd=background:seq=1:exec=le test peer --port $PORT:stdin=source
cmd=background:seq=2:exec=le test peer --bind 127.0.0.2 --mode sink --port $PORT:stdin=dest
cmd=foreground:seq=3:exec=ze -:stdin=ze-bgp:timeout=20s

Each ze-peer gets independent output capture and WaitFor synchronization. The runner waits for each peer's "listening on" message before starting the next command.

On Linux, 127.0.0.2 works automatically (127.0.0.0/8 routes to lo). On macOS and FreeBSD, the test runner adds loopback aliases via the SIOCAIFADDR ioctl.

IPv6 works differently, because a host carries exactly one IPv6 loopback address. A fixture that needs a second one uses fd00::2, which is unique-local (RFC 4193) and never globally routable. ./le setup install adds it, and ./le setup check reports whether it is there. The runner never adds it: the ioctl returns EPERM to an unprivileged process, and ./le verify current mode full runs as an ordinary user. A test that binds an address this host does not carry fails at once with loopback_address_missing and the command to run, rather than timing out on a bind that could not succeed.

The check reads the three places a fixture names an address it binds. One is le test peer --bind <ip> on a cmd= line. The second is connection { local { ip <addr> } } in the config the fixture embeds: Ze sends from that address and listens on it when accept is true, so the host must carry it too. The third is local-address <addr>; in an ExaBGP-syntax config the exabgp-compat suite keeps beside its .ci and names with option=file:. That suite has a parser and a runner of its own, so it calls the same scan through EnsureConfigFileBindAddresses rather than through the embedded path.

A local address outside 127.0.0.0/8 and fc00::/7 is left alone. A config-validation fixture names a routable one (local { ip 192.0.2.1 }), the daemon exits before it binds anything, and ./le setup install adds no such address. The local-link-local fe80::1 leaf that sits beside local-address in an exabgp-compat config is left alone for the same reason.

An exabgp-compat case runs TWO processes, ze and the mock BGP server, and its verdict names which one ended the case:

Both used to print the bare exit status N of whichever process failed, which told a reader nothing about which of the two to open.

Both processes are stopped by a defer beside the start that created them, so a case that returns by any route, a panic included, leaves neither running. The stops written inside the branch arms are ORDERING barriers rather than lifetimes: a process's output must not be read while it is still writing, and its exit status does not exist until it has one. How the suite's forks are created and canceled is in docs/architecture/testing/runner-architecture.md, "How a suite's child processes end".

Expectations

expect=<type>:key=value[:key=value...]

BGP Wire Expectations

expect=bgp:conn=<N>:seq=<N>:hex=<hex-bytes>
expect=bgp:conn=<N>:seq=<N>:prefix=<hex-bytes>
expect=bgp:conn=<N>:seq=<N>:contains=<hex-bytes>
expect=bgp:conn=<N>:seq=<N>:ordered=<hex-bytes>

Validates the BGP wire message received: hex= matches the exact message, prefix= the message start, contains= a substring anywhere in one message. Within one seq group, hex/prefix/contains checks match in any order and each consumes exactly one received message.

ordered= checks in a seq group form a strict FIFO subqueue for asserting in-order delivery across message boundaries: the front needle must appear in the received message, and one message may consume several consecutive needles, each matched at an advancing offset (so order inside a packed message is enforced too). Use ordered= instead of per-message contains= when the sender may legally pack several NLRIs into one UPDATE (the forward rail's bucket merge): per-message framing is not a property ze owes, but delivery order is. A message whose content matches only a non-front needle consumes nothing and is reported as a mismatch.

These forms are peer-block directives (inside a stdin=<name>: block); top-level expect=bgp lines support hex= only.

JSON Expectations

expect=json:conn=<N>:seq=<N>:json=<json-object>
expect=json:json=<json-object>

Validates the decoded message matches expected JSON.

Validation rules:

Exit Code Expectations

expect=exit:code=<N>

Validates the foreground process exit code. A test whose ONLY assertion is expect=exit:code=0 is accept-only (weak) and is gated by a lint; see Assertion Strength.

An Unknown Directive or Key Is a Parse Error

Every directive the runner reads is declared: the action= word, the type after it, and the keys inside it. A word or a key outside its declared set fails the file at discovery, naming what was written, the accepted set, and the line. Nothing is dropped in silence, and nothing is guessed.

line 12: unknown action "exepct" (accepts action, await, cmd, command, expect, http, option, reject, stream)
line 12: unknown expect type "stdoutt" (accepts bgp, command-error, event, exit, file, json, output, stderr, stdout, stream, syslog)
line 12: cmd=foreground: unknown key "env" (accepts exec, exit, name, ready, seq, stdin, timeout)

The accepted set in each message is the list the parser gates on, so it cannot describe a vocabulary the runner does not have. The line number is the line in the FILE: comments, blank lines and whole stdin= and tmpfs= blocks are consumed before the directives are parsed, and the refusal used to count only the directives that survived, which named line 2 for a line that was number 69.

The rule exists because a dropped key takes the whole assertion with it. An expect=stdout:not-contains=X line recorded NO assertion and then passed whatever the command printed. Ten such lines were live across seven .ci files when the check was added, and one of them was the only assertion its test made.

Two spellings get a named message, because both are the right key somewhere else: not-contains= belongs to expect=file:, and !contains= was the expect=stdout spelling until stream non-containment moved to reject=.

Stdout Expectations

expect=stdout:contains=<text>
expect=stdout:pattern=<regex>

Two modes:

Stdout must NOT contain text is reject=stdout:contains=, below. A stream has ONE negation spelling and it lives on reject=.

Stderr Expectations

expect=stderr:pattern=<regex>
expect=stderr:contains=<text>

Two modes:

pattern= reads the daemon's stderr alone. contains= reads the same combined buffer every stdout assertion reads (see the scope note below), so it matches a needle the command wrote to stdout. Reach for pattern= when the stream matters.

Stderr must NOT contain text is reject=stderr:contains=, below.

Await (deterministic stderr fence)

await=stderr:contains=<text>[:timeout=<dur>][:then=stop]

Blocks the runner until the daemon's relayed stderr contains <text>, then tears the daemon down. This is a deterministic replacement for a blind time.sleep that only held the daemon open long enough for a line to appear. timeout= is an optional Go duration (default: 80% of the test budget, at least 10s); on timeout the test fails with a precise message that names the needles still missing. <text> follows the same rule as expect=stderr:contains=.

Several await= lines form ONE fence. It holds once every needle has appeared, in any order: an external plugin's stderr is relayed apart from the daemon's own writes, so an order between them would be a race of its own. A needle may be declared once, and timeout= on one line only.

Without then=stop, the fence is a precondition of the normal end: after it holds, the runner still waits for the peers to exit (a peer script that signals the daemon several times, test/reload/reload-rapid-sighup.ci, relies on this).

then=stop on any await line makes the runner END the test once the fence holds. Use it when a peer lingers (option=linger:value=true), because a lingering peer never exits by itself, so the default end waits forever and a fixture then had to stop the daemon a fixed delay after its signal. Under load that SIGTERM landed while the reload under test was still verifying, and shutdown canceled it. With then=stop the sequence waits on events only:

Step Waits for
1 every awaited needle on the daemon's stderr
2 every lingering check peer to print successful (its last expectation met), or to exit
3 the runner sends the daemon SIGTERM (a kill after the teardown grace) and reaps it; expect=exit:code= asserts that exit
4 every check peer to exit. A lingering peer ends when the daemon closes its session; one still running after the peer drain grace is sent SIGTERM

A plain fence publishes daemon.pid without waiting for daemon.ready, because a plugin that aborts startup may never write it. A then=stop fence keeps that wait: its daemon is expected to run, and a trigger that sends SIGHUP before the handler is installed kills the daemon.

Steps 1 and 2 share the fence's one deadline. A NON-lingering check peer is not waited in step 2, because it ends itself, and its expectation can be the Cease the stop in step 3 produces (test/reload/reload-dynamic-peer-survives.ci). Await the reload's OUTCOME line, never an earlier one: sighup reload complete for a reload that succeeded, reload error for one that was refused.

cmd=foreground:seq=3:exec=ze -:stdin=ze-bgp:timeout=20s
await=stderr:contains=incomplete peer definition:then=stop
await=stderr:contains=reload error
expect=stderr:contains=incomplete peer definition
reject=stderr:contains=sighup reload complete

Pair it with a matching expect=stderr:contains= so the line is both the fence and the assertion. Use it for the reject-fence bucket: an external plugin whose refusal aborts the daemon's plugin-startup coordinator (StartupCoordinator.PluginFailed) leaves no in-daemon observer able to poll it, so the relayed stderr line is the only non-plugin signal.

Choose a needle that is SPECIFIC to the plugin under test, not just a shared phrase. Example (test/plugin/as112-external-refuses.ci): the bare "refusing to start as an external plugin process" is emitted verbatim by three plugins, so the needle includes the as112-only tail -- the address-ownership registry:

cmd=foreground:seq=1:exec=ze -:stdin=ze-bgp:timeout=15s
await=stderr:contains=refusing to start as an external plugin process -- the address-ownership registry
expect=stderr:contains=refusing to start as an external plugin process -- the address-ownership registry

Syslog Expectations

expect=syslog:pattern=<regex>

Validates that captured syslog output matches the regex pattern. When any expect=syslog: line is present, the test runner automatically starts a UDP syslog server and injects ze.log.backend=syslog and ze.log.destination=127.0.0.1:<port> into the test environment.

File Expectations

expect=file:path=<rel>:exists=true
expect=file:path=<rel>:absent=true
expect=file:path=<rel>:contains=<text>
expect=file:path=<rel>:not-contains=<text>
expect=file:glob=<rel-pattern>:count=<N>
expect=file:glob=<rel-pattern>:contains=<text>
expect=file:glob=<rel-pattern>:not-contains=<text>

Validates files after the test process or peer sequence has completed. Paths and glob patterns are relative to the test's own work directory, which is where every child ran and where a daemon writes its artifacts. A test that declares tmpfs= files gets them in that same directory, so the two spellings name one place. For glob contains, at least one matched file must contain the text. For glob not-contains, no matched file may contain it.

Use file expectations for loose source files and exported artifacts such as logs. Persistent config, versions, pointers and plugin state use key expectations:

expect=key:path=meta/config/router.conf/active:exists=true
expect=key:path=meta/config/router.conf/candidate:absent=true
expect=key:glob=file/[0-9]*/router.conf:count=2
expect=key:glob=object/*:count=2
expect=key:glob=object/*:contains=router-id 1.2.3.4

A dated history entry holds sha256:<hex>, not the config: the bytes live in object/<hex> (docs/architecture/storage-backends.md, "Content-addressed history"). Count versions on the entries and assert config text on object/*.

Key expectations resolve beneath database/ in the work directory and decode the complete netcapstring frame before inspecting its value. A bad CRC or trailing frame bytes fail the assertion. They accept the same path, glob, count and containment checks as files. A missing key satisfies absent; an unreadable or corrupt key does not.

Negative Expectations (reject)

reject=stderr:contains=<text>
reject=stderr:pattern=<regex>
reject=stdout:contains=<text>
reject=stdout:pattern=<regex>
reject=syslog:pattern=<regex>
reject=bgp:conn=<N>:pattern=<hex>

Inverse of expect= -- the test fails if the pattern matches. Used to verify that unwanted output (e.g., deprecated warnings, ERROR-level messages) does NOT appear.

Type Description
reject=stderr:contains=<text> Fail if the combined output contains substring (the mirror of expect=stderr:contains=)
reject=stderr:pattern=<regex> Fail if stderr matches regex
reject=stdout:contains=<text> Fail if stdout contains substring
reject=stdout:pattern=<regex> Fail if stdout matches regex
reject=syslog:pattern=<regex> Fail if syslog output matches regex
reject=bgp:conn=<N>:pattern=<hex> Fail if connection N of a ze-peer receives a frame carrying these wire bytes

reject=bgp -- bytes a peer must never receive

reject=bgp is the only reject type ze-peer reads, because ze-peer is what sees the wire. It goes inside the peer's stdin block, beside the expect=bgp lines. The hex needle is matched on wire bytes at a byte BOUNDARY, which expect=bgp:contains= is not: that one is a plain substring match over the hex text, so it can also match at an odd nibble offset.

Five properties make it an assertion rather than a hope:

stdin=external:terminator=EOF_EXTERNAL
option=tcp_connections:value=1
option=linger:value=true
# The delivery that makes the rejection an assertion rather than an empty session.
expect=bgp:conn=1:seq=1:contains=18C00002
# 180A0100 is 10.1.0.0/24, which NO_ADVERTISE forbids on any peer.
reject=bgp:conn=1:pattern=180A0100
EOF_EXTERNAL

Assertion Strength: accept-only tests and readback

A test whose ONLY assertion is expect=exit:code=0 is accept-only (weak): it proves a config or command was ACCEPTED, never that it parsed to the CORRECT tree. A parser that accepts interval 300 but stores 0, or silently drops a source 0.0.0.0/0 block, still passes such a test green. This is the functional-suite analog of the "count-only assertion" mistake class (ai/rules/testing.md).

A lint enforces that the class cannot GROW: TestCIAcceptOnlyLint walks every test/**/*.ci, classifies each with the single accept-only predicate, and FAILS on a NEW accept-only test that is neither strengthened nor annotated. Existing accept-only tests are grandfathered in test/.accept-only-baseline (a sorted allow-list that only shrinks; strengthening or annotating a test removes its line). Correctly EXCLUDED (never weak): a test whose real check lives in a tmpfs set -e script (e.g. test/managed/auth-reject.ci), and any reject= test.

Strengthen with a readback

Add a second cmd= that dumps the parsed tree and assert a representative value with expect=stdout:contains= / pattern=. show config dump - reads a config from stdin and answers the stored tree, and | json renders it, so the assertion observes the parsed VALUE, not just that parsing did not error.

cmd=foreground:seq=1:exec=ze config validate -:stdin=config:exit=0
cmd=foreground:seq=2:exec=ze cli -c "show config dump - | json":stdin=config-dump
expect=exit:code=0
expect=stdout:pattern="interval": "300"

Two gotchas, both load-bearing:

Annotate when a unit test already covers the value

When a unit test already asserts the parsed value, a readback would duplicate it. Mark the test accept-only instead, with a comment naming the covering test:

# accept-only: md5 { password; ip } value-parsing is unit-covered by
# TestParsePeerMD5FieldsParsed (internal/component/bgp/reactor/config_test.go).

The marker is a comment line whose content is accept-only: followed by a non-empty reason. A file carrying it is allowlisted by the lint without a baseline entry. Keep the reason greppable and truthful (name the covering test).

Actions

action=<type>:key=value[:key=value...]

Notification

action=notification:conn=<N>:seq=<N>:text=<message>

Sends NOTIFICATION with shutdown message.

Send Raw

action=send:conn=<N>:seq=<N>:hex=<hex-bytes>

Sends raw bytes to peer.

Rewrite Config File

action=rewrite:conn=<N>:seq=<N>:source=<tmpfs-file>:dest=<config-file>

Copies a tmpfs file over the daemon's config file. Used with action=sighup to test config reload.

It also writes a marker a second process can wait on, which is its other use: test/reload/config-apply-ordering-address-swap.ci copies one file to session-returned.txt once the restarted BGP session has re-announced its routes, and the test's own driver waits for that file before it signals the daemon. When the rewrite is the LAST item the peer block queues, the exchange is over and the peer completes on it, exactly as action=sighup and action=sigterm do. Without that the peer kept matching, and the daemon's shutdown NOTIFICATION arrived against an empty expectation list and failed the test as a message mismatch.

Key Description
conn Connection number triggering the rewrite
seq Sequence number (after matching messages)
source Source file name in tmpfs
dest Destination file name in tmpfs (usually ze-bgp.conf)

Send SIGHUP

action=sighup:conn=<N>:seq=<N>

Sends SIGHUP to the daemon process. Reads PID from daemon.pid in the tmpfs directory (written automatically by the test runner).

Key Description
conn Connection number triggering the signal
seq Sequence number (after matching messages)

Send SIGTERM

action=sigterm:conn=<N>:seq=<N>

Sends SIGTERM to the daemon process. Reads PID from daemon.pid in the tmpfs directory (written automatically by the test runner). After sending SIGTERM, the connection is expected to close (daemon shuts down gracefully).

Key Description
conn Connection number triggering the signal
seq Sequence number (after matching messages)

HTTP Checks

HTTP checks validate web endpoint responses after all cmd= processes have started. Executed in seq order with automatic retry on connection errors (server starting up).

Assertion Checks (get/post)

http=get:seq=N:url=URL:status=CODE[:contains=TEXT][:bodyfile=PATH][:header=NAME: VALUE]
http=post:seq=N:url=URL:status=CODE[:contains=TEXT][:bodyfile=PATH][:sendfile=PATH][:content-type=TYPE][:header=NAME: VALUE][:insecure-tls=true]
Key Required Description
seq ✓ Execution order (>= 1, lower first)
url ✓ Request URL (supports $PORT and $PORT2 substitution)
status ✓ Expected HTTP status code
contains ✕ Expected body substring
bodyfile ✕ Path to file with expected body (exact match, resolved relative to .ci file)
sendfile ✕ Path to file sent as POST request body, resolved from tmpfs first
content-type ✕ Request body content type for sendfile, defaults to application/json
header ✕ Request header in Name: Value wire form. Repeatable -- the only key that can appear more than once on one line
insecure-tls ✕ Set true for self-signed local HTTPS endpoints

Request Headers (header=)

header= takes the header exactly as it appears on the wire, Name: Value. Repeat header= to set several headers on one check. It works on get, post, and wait.

http=post:seq=1:url=http://127.0.0.1:$PORT/mcp:status=200:sendfile=call.json:header=MCP-Protocol-Version: 2026-07-28:header=Mcp-Method: tools/call:header=Mcp-Name: ze
Behavior Detail
Splitting On the first colon only, so a value can contain colons (header=Referer: http://127.0.0.1:8080/page)
Whitespace Trimmed around both name and value, so header=Foo: bar and header=Foo:bar are equivalent
Precedence Applied after the sendfile default Content-Type, so an explicit header=Content-Type: ... wins
Repeats of one name The first occurrence replaces the value. Each later occurrence adds one more value to that field
Host Routed to the request's Host field, because net/http ignores a Host entry in the header map
Malformed A header= value with no colon is a parse error naming the offending value, never a silent drop
Value stops at The next known key marker (:status=, :contains=, the next :header=, ...), like every other key

Retries up to 20 times at 200ms intervals on transient connection errors (ECONNREFUSED, ECONNRESET, EOF). Non-connection errors (wrong status, missing content) fail immediately.

Readiness Polls (wait)

http=wait:seq=N:url=URL:status=CODE[:contains=TEXT][:header=NAME: VALUE][:timeout=DUR]
Key Required Default Description
seq ✓ - Execution order (>= 1)
url ✓ - Request URL
status ✓ - Expected HTTP status code
contains ✕ - Expected body substring
header ✕ - Request header in Name: Value form, repeatable (see above)
timeout ✕ 15s Poll timeout duration

Unlike assertion checks, wait retries on all failures: connection errors, wrong status codes, and content mismatches. Polls at 500ms intervals until the condition is met or the timeout expires. Wait checks run before assertion checks, making them suitable for waiting until a server has populated data (e.g., routes injected by an async plugin).

Example

cmd=background:seq=1:exec=le test peer --port $PORT:stdin=peer
cmd=background:seq=2:exec=ze -:stdin=ze-bgp

# Wait until routes are available before checking graph output
http=wait:seq=1:url=http://127.0.0.1:$PORT2/lg/graph?prefix=10.10.1.0/24&mode=aspath&format=text:status=200:contains=AS2914:timeout=15s

# Exact SVG match against reference file
http=get:seq=1:url=http://127.0.0.1:$PORT2/lg/graph?prefix=10.10.1.0/24&mode=aspath:status=200:bodyfile=expect/graph.svg

# Substring check
http=get:seq=2:url=http://127.0.0.1:$PORT2/lg/graph?prefix=10.10.1.0/24&mode=nexthop&format=text:status=200:contains=egress

Sleeps and their justification markers

Every time.sleep( in a live .ci carries a marker comment in the form // sleep(<kind>): <reason>, on one # comment line directly above the sleep, indented to match it exactly. The embedded .ci observer body is indentation sensitive. Two producers enforce the marker: checkSleepJustification in internal/le/doc/wiring (run by ./le doc wiring, scoped to changed .ci files, listing every unjustified file:line and exiting 1), and writeCISleep in internal/le/hookruntime/writeedit.go, which blocks a Write or Edit that introduces an unmarked sleep.

The kinds are a closed set, and each one owes a different reason:

Kind What the reason states
poll-interval The real condition the enclosing loop breaks or returns on. This is already a deterministic wait; the sleep is only its granularity
timer The delay itself IS the behavior under test, and the mechanism plus where its period is set
timeout-under-test The fixed internal timeout the sleep waits out, which the test asserts on
needs-linux A dataplane effect (tc, qdisc, nft, kernel FIB) with no readback in the driver, convertible only after a QEMU run
✕ The awaited effect exposes no queryable state to this driver, and what is held until instead

A free-text // settle comment is insufficient: it names no mechanism a reader can check. "The tracker pushes live carrier once a second" is a reason a later reader can overturn; "needs a moment" is the shape that makes a deliberate timer and a guessed duration the same line of code.

A separate ratchet caps how MANY sleeps exist: the total time.sleep( count across test/**/*.ci may not exceed the committed baseline in test/.ci-sleep-baseline, and ./le doc wiring fails when it does. The markers cap how many are unexplained.

The compiled observer API

Compiled .ci observers live under internal/test/fixture. They use pkg/plugin/sdk for the five-stage plugin protocol (Plugin.Run owns it) and the local fixture package for registration, dispatch, polling and failure reporting.

Function Purpose
fixture.Register(name, driver) Register one compiled fixture command
fixture.Run(args) Dispatch le test fixture <name> [args...]
fixture.Observe(...) Connect through the SDK, complete startup, run the scenario after all plugins are ready, then request shutdown
observeConfigured(...) Install callbacks before startup, then run the same observer lifecycle. It is unexported, so only a fixture in this package calls it
fixture.Dispatch(...) Send one command and decode its JSON answer into a Go value
fixture.Poll(...) Retry a predicate until success, exhaustion, or context cancellation
fixture.ReportFailure(err) Emit the ZE-OBSERVER-FAIL sentinel checkObserverSentinel detects. internal/test/runner/runner_validate.go
sdk.Plugin.DispatchCommand(...) Send a typed command request through the plugin connection

fixture.Poll around fixture.Dispatch is the payload-predicate wait: it blocks until the observed payload matches, within a bounded attempt count. It is what replaces a time.Sleep followed by a one-shot assertion.

fixture.Observe can request a clean daemon shutdown even after an assertion failed, so the daemon's exit code does not prove the observer's assertion. A failing observer returns an error, which fixture.Run hands to fixture.ReportFailure.

A fixture-driven .ci needs no bgp block and no le test peer to get a daemon that stops. request shutdown reaches a reactorless daemon through the shutdown callback the daemon wires beside its plugin server, before any plugin can dispatch, so a BFD-only or DHCP-only configuration stops on the fixture's request like a BGP one. Adding a BGP peer only to make the daemon stoppable adds a second protocol to the test's failure surface: test/bfd/bfd-detection-interval.ci was red for exactly that reason until 2026-09-07.

option=asn moves both AS numbers. The AS is resolved once and written into the two-octet My Autonomous System field AND the four-octet AS capability, so the two disagree only where a .ci states a malformed capability 65 itself (see "Capability Control" above). RFC 6793 Section 4.1 states the precedence a receiver applies: it "MUST use the AS number encoded in the Capability Value field of the 'support for four-octet AS number capability' in lieu of the 'My Autonomous System' field of the OPEN message". So a half-applied option leaves the capability carrying ze's own AS, ze reads THAT, finds an AS the session is not configured for, and answers OPEN Message Error / Bad Peer AS under RFC 4271 Section 6.2. The refusal is RFC 4271's; RFC 6793 decides which field ze read.

The sentinel is written where the scenario fails, BEFORE request shutdown. The runner reads it out of the DAEMON's stderr, and the daemon relays a plugin's stderr only while both processes live (internal/component/plugin/process, relayStderrFrom). fixture.Run reports the same error after Plugin.Run returns, which is after the daemon was asked to stop, so that line reaches the runner only when it wins the race with the shutdown. Measured 2026-09-02: an observer asserting a route that can never arrive still passed test/plugin/rpki-group-action.ci, and three plugin cases (fib-table, metrics-name-show, modify-increment-localpref) passed with a failing observer. fixture.ReportFailure emits the FIRST failure only, so the report at the failure site and the one on the way out are one line.

A driver's working directory is the per-test directory, so a driver names its files relatively and fixture.Run refuses to start in the checkout. The runner creates one directory for each test, gives it to every child of that test and removes it at the end (Record.WorkDir, internal/test/runner/runner_exec.go), so daemon.ready, testkey and <case>.conf are written where the test can find them and nothing survives the run. The same names in the checkout land beside tracked source: on 2026-09-05 a run left two ed25519 private keys and five config files at the repository root, none of them ignored, so a git add -A from any session would have committed a private key. fixture.Run therefore reads its own working directory first and exits 1 with the ZE-OBSERVER-FAIL sentinel when that directory holds the ze go.mod (sessionpath.IsRepoRoot). A driver that needs the checkout reads $ZE_REPO_ROOT, which the runner exports; it never reads the working directory for one.

An observer assertion is therefore worth only what its wait is worth: a value that a plugin fills asynchronously (the Adj-RIB-In after RPKI validation, the SPF table after the first run, a session that drops when the peer completes) needs fixture.Poll around the read, never a fixed settle before it.

Engine Steps

Engine steps drive a live daemon through CLI dispatch, first-class in .ci instead of an embedded Python observer. The runner serializes the parsed steps to engine-steps.json in the test tmpfs, and links it into the daemon-N/ config directory of every further daemon, because a plugin runs in its daemon's config directory; the .ci declares the executor as an external plugin (run "le test engine-steps ./engine-steps.json"), which runs the steps from OnAllPluginsReady and reports failures via the ZE-OBSERVER-FAIL sentinel the runner gates on.

command=<cli command text>
stream=<monitor command text>
expect=output:<predicate>[:timeout=<dur>]
expect=event:namespace=<ns>:name=<name>[:timeout=<dur>]
expect=stream:<predicate>[:timeout=<dur>]
expect=command-error:contains=<text>

command=/stream= keep their full raw text (colons included). expect=output re-dispatches the most recent command= until its predicate holds or the timeout expires; expect=stream matches delivered stream= events; expect=event matches a delivered event by its (namespace, name) identity.

expect=event

The executor declares ONE startup event subscription, derived from the expect=event steps of the file itself, and asks for enveloped delivery. A .ci therefore names each event once, in the step that waits for it, and nothing else declares the subscription.

A startup subscription rides the ready RPC, so it is registered before any plugin's OnAllPluginsReady runs. That is what lets a step observe the FIRST delivery of an event the daemon emits while it starts. test/ipsec/ipsec-sa-installed.ci is the case that forced it: the IKE engine emits vpn-ipsec/sa-up from its startup peer reconciliation, so a subscription dispatched from the step could only ever see a SECOND establishment, and a test with no re-negotiation has none.

Enveloped delivery wraps each event with its (namespace, event) identity (rpc.EventEnvelope), which is what tells one subscribed event from another once several share the one subscription. A bare payload decodes with an empty namespace and event, so a delivery the executor did not ask to be enveloped can never satisfy a step.

Two consequences follow, and both are load-bearing when writing a test:

expect=command-error

expect=command-error:contains=<text> asserts that the PRECEDING command= FAILED, and that its message contains the text.

Use it for a command that must refuse. The plugin SDK turns a StatusError response into a Go error, so without this directive the command step aborts the run before any expect= is reached, and no .ci can assert an operational error at all. That left one class untestable end to end, and it is the class where a wrong answer costs most: a command that must refuse is exactly the one whose failure mode is answering confidently instead. test/ipsec/ipsec-dataplane-show.ci uses it to prove that a dataplane which cannot be read SAYS so rather than rendering an empty table.

It takes contains= only, and no timeout. An error is the result of one dispatch, so re-dispatching until one appears would wait for a state change no expect= can cause.

A command failure that NO expect=command-error consumes still fails the run, whether the next step is of another kind or the command is the last step in the file. Every .ci written before this directive existed relies on that.

It must not be vacuous. A command= that SUCCEEDS does not satisfy expect=command-error: the step fails with "the preceding command succeeded". Without that rule the directive would pass for the very regression it guards, a refusal quietly becoming a successful empty answer (TestRunEngineStepsCommandErrorRequiresAFailure).

expect=output / expect=stream predicates

The optional trailing :timeout=<dur> is split off the END first, so a predicate operand may itself contain : (a compact-JSON fragment, an IPv6 address). The remainder is one predicate:

Predicate Surfaces Holds when
contains=<text> output, stream the output contains the substring (the default)
matches=<regexp> output, stream the Go regexp matches the output (compiled at parse time, so a bad regexp fails the test immediately, not at timeout)
absent=<text> output only the output does NOT contain the substring
json=<dotted.path>=<value> output only the dotted path into the JSON data field stringifies to <value>

json= walks the raw data field (not status data): each .-segment indexes a JSON object by key or a JSON array by integer index (0..len-1; out-of-range or missing is "not yet", named at timeout). The leaf is compared as a string (numbers/bools stringified via JSON). absent=/json= are expect=output only: they re-dispatch a query, whereas expect=stream is an append-only event stream with no "absent" and no single-event JSON path.

absent= must be non-vacuous. An absent= on output that was never populated passes instantly (false green). Precede it with a step that makes the substring present (a contains=/json= after an inject), then the transition (e.g. a withdraw) the absent= proves. See test/plugin/engine-steps-predicates.ci.

Example

command=request bgp rib inject 10.0.0.1 ipv4/unicast 172.16.0.0/16 origin igp nexthop 10.0.0.2
command=show rib
expect=output:matches=172\.16\.[0-9.]+/16:timeout=10
expect=output:json=0.prefix=172.16.0.0/16:timeout=10
command=request bgp rib withdraw 10.0.0.1 ipv4/unicast 172.16.0.0/16
command=show rib
expect=output:absent=172.16.0.0/16:timeout=10

Complete Example

# Embed config using Tmpfs
tmpfs=test.conf:terminator=EOF_CONF
peer test-peer {
    remote {
        ip 127.0.0.1;
        as 65000;
    }
    router-id 10.0.0.2;
    local-address 127.0.0.1;
    local-as 65533;
    hold-time 180;

    family {
        ipv4/unicast;
    }
    announce {
        ipv4 {
            unicast 10.0.0.0/24 next-hop 10.0.1.254;
        }
    }
}
EOF_CONF

# Test configuration
option=file:path=test.conf
option=asn:value=65000

# Expected API command and wire output
cmd=api:conn=1:seq=1:text=update text origin set igp nhop set 10.0.1.254 nlri ipv4/unicast add 10.0.0.0/24
expect=bgp:conn=1:seq=1:hex=FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF002F02000000144001010040020602010000FFFD4003040A0001FE180A0000

# EOR
cmd=api:conn=1:seq=1:text=announce eor ipv4/unicast
expect=bgp:conn=1:seq=1:hex=FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00170200000000

Consumers

Different components consume different line types:

Line Type Consumer
stdin= Test runner (pipes to processes)
tmpfs= Test runner (writes to temp)
option= Test runner + ze-peer
cmd=api: nobody. parseCmd stores the text on the message and only the failure reporter prints it, so the line documents the command that produced the expected bytes and executes nothing. A test that expects it to inject routes asserts against an empty RIB
cmd=foreground:, cmd=background: Test runner (process orchestration)
expect=exit:, stdout:, stderr:, json:, syslog:, file: Test runner
reject=stderr:, reject=syslog: Test runner (negative expectations)
http=get:, http=post: Test runner (HTTP assertion checks)
http=wait: Test runner (HTTP readiness polls)
expect=bgp: le test peer
action=notification:, action=send: le test peer
action=rewrite:, action=sighup:, action=sigterm: le test peer (reload/signal tests)

Lines not recognized by a consumer are ignored.

A check-mode peer block MUST declare a ze-peer-consumed expectation

The consumer split above is load-bearing, not trivia. Only the four le test peer rows reach le test peer. Everything else -- including expect=json -- is validated by the test runner from its own copy of the messages.

A check-mode le test peer with no consumed directive has nothing to check, so it prints no test data available to test against and exits 1 before binding a listening socket. ze then dials a dead port, gets connection refused, and backs off 5->10->20->40s. That looks exactly like a BGP establishment stall and cost a multi-day investigation before the cause was found in the harness.

So a peer block whose only expectation is expect=json runs no BGP at all. The parser now rejects that at discovery time, naming the file and the remedy:

stdin=peer block: check-mode ze-peer (cmd seq=1) declares no ze-peer-consumed
expectation, so it exits with "no test data available to test against" before
binding a listening socket and the test can only pass vacuously.
Want Do
Assert the wire exchange Add expect=bgp:conn=N:seq=N:hex=... (or an action=send/notification/rewrite/close/sighup/sigterm) to the peer block
A peer that is only a dial target for ze (routes injected through an external process plugin, assertions made by that plugin or http=) Run it as le test peer --mode sink -- sink/echo/inject peers legitimately declare nothing

expect=json still works, but only in addition to a consumed directive: it cannot make the peer listen.

A ze-peer always governs its test's result

A test with a check-mode ze-peer is never self-validated: the peer must print successful and its expect=json expectations must match, whatever else the file asserts. An expect=exit:code=0 is additive and does NOT disable the BGP checks.

Until 2026-07-16 it did, which is how a test could pass on ze's exit code while its peer never listened and its JSON assertion never ran. sink/echo/inject peers do not govern (they never report completion), so a test whose only peers are scaffolding is still governed by its own exit/output/file assertions.

A check peer prints successful only when EVERY expectation it holds was met: every expect=bgp line of every conn=N it declares. A peer stopped before that, by SIGTERM from the runner, by the test's timeout, or because a dialing peer's one connection ended while conn=2 was still owed, exits 1 with stopped with expectations unmet: conn=N still owes <expectation> (K expectations unmet). Until 2026-09-24 a peer that SIGTERM reached while it waited for its next connection printed successful, so a test passed with its later connections never opened.

A scaffolding ze-peer is signaled at teardown

A sink, echo or inject le test peer never ends itself: its accept loop runs until its context is cancelled, and le test peer maps SIGTERM to that cancel. The runner sends that SIGTERM at teardown, after the last step and before the barrier that collects peer output. The peer exits with status 0 and its capture is complete, so a .ci author needs no teardown directive and must not add one.

A check-mode peer is never signaled. It exits by itself when its expectations are met, and the runner reads its verdict from the capture a signal would truncate.

Before 2026-08-10 no code signaled a scaffolding peer, so the drain barrier waited out its full 10s grace on every --mode sink or --mode echo test. That cost was not only latency: test/plugin/event-predicate-wait.ci failed at its 15s budget with TYPE: timeout while the daemon itself completed correctly.

Migration from Old Format

Old format (deprecated):

option:file:test.conf
option:asn:65000
1:raw:FFFF...
1:json:{...}

New format:

option=file:path=test.conf
option=asn:value=65000
expect=bgp:conn=1:seq=1:hex=FFFF...
expect=json:conn=1:seq=1:json={...}

Key changes:

Editor Test Format (.et)

The .et format extends .ci for interactive editor testing. Tests are located in test/editor/.

Overview

Editor tests simulate user input sequences against the headless configuration editor and verify state changes.

Input Actions

Action Purpose Example
input=type:text=<text> Type text input=type:text=edit bgp
input=key:name=<key> Send special key input=key:name=tab
input=tab Tab key (shorthand) input=tab
input=enter Enter key (shorthand) input=enter
input=ctrl:key=<c> Ctrl+key input=ctrl:key=u
input=space Space key input=space

Named keys input=key:name=<key> accepts: tab, enter, esc, escape, up, down, left, right, backspace, delete, home, end, pgup, pgdn, pgdown, space. shift+tab is handled separately as a modifier.

Expectations

Expectation Purpose Example
expect=context:path=<p> Context equals path expect=context:path=bgp.peer.1.1.1.1
expect=context:root Context is root expect=context:root
expect=completion:contains=<list> Completions include all items expect=completion:contains=set,delete,edit
expect=completion:excludes=<list> Completions must NOT include items expect=completion:excludes=vpp,kernel
expect=completion:count=<N> Number of completions expect=completion:count=5
expect=ghost:text=<suffix> Ghost text suggestion expect=ghost:text=-id
expect=dirty:true Has unsaved changes expect=dirty:true
expect=content:contains=<text> Config content includes text expect=content:contains=router-id
expect=content:not-contains=<text> Config content must NOT include expect=content:not-contains=old-value
expect=content:lines=<N> Config content line count expect=content:lines=5
expect=viewport:contains=<text> Displayed output includes text expect=viewport:contains=10.0.0.1
expect=viewport:not-contains=<text> Displayed output must NOT include expect=viewport:not-contains=error
expect=errors:count=<N> Validation error count expect=errors:count=0
expect=status:contains=<text> Status message expect=status:contains=committed
expect=hint:contains=<text> Second message line includes text expect=hint:contains=show the peers
expect=hint:empty Second message line is blank expect=hint:empty
expect=explanation:contains=<text> Revealed long explanation includes text expect=explanation:contains=established
expect=explanation:empty ✕ expect=explanation:empty
expect=error:none ✕ expect=error:none
expect=timer:active Confirm timer running expect=timer:active
expect=file:path=<rel>:contains=<text> On-disk file content expect=file:path=test.conf:contains=bgp
expect=file:path=<rel>:not-contains=<text> File must NOT contain expect=file:path=test.conf:not-contains=old
expect=file:path=<rel>:absent=true File does not exist expect=file:path=test.conf:absent=true
expect=key:path=<key>:contains=<text> Decoded persistent value expect=key:path=file/active/test.conf:contains=bgp
expect=key:path=<key>:not-contains=<text> Decoded value excludes text expect=key:path=file/active/test.conf:not-contains=old
expect=key:path=<key>:absent Key does not exist expect=key:path=file/active/test.conf.draft:absent

Editor tests use a tree store by default; option=storage:value=tree states that choice explicitly. option=storage:value=blob runs the test on a backup artifact the way ze config edit --backup opens one: the runner creates backup.zefs, seeds it, closes it, reopens it writable through storage.OpenBlob, and builds each session with cli.NewOfflineSessionEditor, so a commit publishes into the artifact. Blob mode needs option=session:user=.... Other values are refused. The runner seeds config fixtures once and all sessions and restart= steps share the same lifetime store handle. expect=key reads that handle, while expect=file reads the original loose fixture or a separate exported artifact.

hint reads the second message line with every style stripped. That row carries the completion hint, and the summary of the candidate the operator selected. explanation reads the long explanation Tab reveals, without the box that frames it on screen. Both accept empty and contains=. Both refuse an expectation that names neither key.

A test that runs option=mode:value=command or option=mode:value=operational completes against the real command tree, built from the registered -cmd YANG modules. The tree carries no value hints and no plugin commands. A hint provider reads the live RIB, and a plugin command comes from the live dispatcher. A headless test has neither, so a completion over a VALUE stays empty there.

Wait Actions

Action Purpose Example
wait=ms:<N> Wait N milliseconds wait=ms:200
wait=validation Wait for validation wait=validation
wait=timer:expire Wait for timer expiry wait=timer:expire

Example

# Test: Edit navigation
tmpfs=test.conf:terminator=EOF_CONF
bgp {
  router-id 1.2.3.4;
  peer upstream1 {
    remote {
      ip 1.1.1.1;
      as 65001;
    }
  }
}
EOF_CONF

option=file:path=test.conf

expect=context:root
input=type:text=edit bgp
input=enter
expect=context:path=bgp
expect=error:none

input=type:text=set
input=space
expect=completion:contains=router-id,local-as,peer

Test Categories

Category Location Tests
Navigation test/editor/navigation/ edit, up, top, context
Completion test/editor/completion/ commands, YANG paths, values
Commands test/editor/commands/ set, delete, show, compare
Lifecycle test/editor/lifecycle/ commit, rollback, load, history
Validation test/editor/validation/ hold-time, peer-as
Pipe test/editor/pipe/ grep, head, tail

Full format specification: plan/spec-editor-testing-framework.md