tmux-ui-tripwire
Overview
Tripwire tests drive the real ainb TUI binary in a detached tmux session, send keystrokes, capture the pane, and assert on rendered output. They are the ONLY tests that catch user-visible regressions the unit tests miss (e.g. plugin pipeline broken but cargo test green).
When to use this skill
- Writing a new
tripwire_*.rstest - An existing tripwire fails and the cause isn't obvious from
cargo testoutput - Tightening a tripwire whose assertions look weak (substring-OR on chrome strings)
- Diagnosing why a plugin "spawns but doesn't render"
Quick start — minimal viable tripwire
// crates/ainb-core/tests/tripwire_<feature>.rs
use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, Instant};
// 1. Skip gracefully if env can't support the test.
fn tmux_available() -> bool { /* see references/helpers.md */ }
fn ainb_bin() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_ainb")) }
#[test]
fn feature_renders_after_pressing_x() {
if !tmux_available() { eprintln!("SKIP: tmux"); return; }
// 2. Isolated HOME — seed onboarding.toml to skip wizard.
let home = tempfile::tempdir().unwrap();
seed_isolated_home(home.path());
// 3. Launch ainb tui in detached tmux session.
let session = format!("tripwire-{}", std::process::id());
Command::new("tmux").args(["new-session","-d","-s",&session,"-x","180","-y","50"]).status().unwrap();
let cmd = format!("HOME={} AINB_DISABLE_PLUGINS=1 exec {} tui",
home.path().display(), ainb_bin().display());
Command::new("tmux").args(["send-keys","-t",&session,&cmd,"Enter"]).status().unwrap();
// 4. Wait for HomeScreen — poll, don't bare-sleep.
let pre = poll_capture(&session, Instant::now() + Duration::from_secs(45),
|c| c.contains("Stats") && c.contains("[i]")).expect("HomeScreen never rendered");
// 5. Pre-press negative assertion — confirm we're not already on target screen.
assert!(!pre.contains("Usage Analytics"));
// 6. Send key WITHOUT Enter (single-char nav).
Command::new("tmux").args(["send-keys","-t",&session,"x"]).status().unwrap();
// 7. Post-press: positive marker AND negative placeholder.
let post = poll_capture(&session, Instant::now() + Duration::from_secs(30),
|c| c.contains("Expected Chrome") && !c.contains("Loading placeholder"))
.unwrap_or_else(|| capture_pane(&session));
Command::new("tmux").args(["kill-session","-t",&session]).status().ok();
assert!(post.contains("Expected Chrome"), "feature chrome never rendered:\n{post}");
assert!(!post.contains("Loading placeholder"), "stuck on placeholder:\n{post}");
}
Full copy-paste helper functions in references/helpers.md.
Required pre-work
| Step | Command | Why |
|---|---|---|
| Stage plugins (if test exercises plugin path) | just stage-plugins | Plugins live at dist/plugins/<id>/<id> and are re-signed for macOS AMFI |
| Build ainb | cargo build -p ainb | Test resolves binary via env!("CARGO_BIN_EXE_ainb") |
Hard rules (violating these costs hours)
- NEVER
tmux kill-server,pkill tmux,killall tmux, or wildcard kill. Onlytmux kill-session -t <exact-name>. Other agents/dev sessions live in tmux. - NEVER assert with substring-OR on chrome strings (
"ainb","session","Container"). They appear in sidebar regardless of whether the feature under test rendered. The original 7f.3 passed for 4+ commits while burndown was broken. Always pair a POSITIVE marker with a NEGATIVE placeholder check. - NEVER append
Enterto a single-character keystroke.tmux send-keys -t S "i"for nav keys.Enteras a SEPARATE arg is only for committing a shell command line. - NEVER use bare
sleepbefore capture. TUI render rate is 4–30 Hz. Usepoll_capture(deadline, predicate)with 500 ms internal sleep. - NEVER grep
Cargo.tomlfor the version in a shell wrapper. Workspace crates haveversion.workspace = trueliterally — grepping returns that string, breaks the wizard-skip seed. Useenv!("CARGO_PKG_VERSION")in Rust or read[workspace.package].versionfrom the rootCargo.toml. - ALWAYS pair forward + return navigation. When a tripwire asserts "press X → screen Y rendered", add a sibling test (or trailing assertion) that "press Esc → previous screen returned". Forward-only tests pass while return paths silently break — exactly how the Esc-from-burndown swallow shipped past 4 existing tripwires in PR #128. The
plugin/handle_keywire is a one-way notification: it cannot signal "ignored, fall through", so plugins can swallow keys with no visible failure unless you test the round-trip end-to-end. Seetripwire_burndown_esc_returns_home.rsfor the canonical return-path shape. - TOGGLE / edge keys are send-ONCE-then-poll — never resend.
poll_capture_resendingre-presses the key on every iteration, which is correct ONLY for idempotent screen/focus keys (monce the screen is open,gonce the graph is focused — re-pressing is a no-op). A toggle (vview-cycle,hhop,⏎recentre,ccommunity,Backspaceexit) gets flipped back on the next 400 ms tick, so the predicate may capture the OFF state and the test never converges (or flakes ~50/50). For those,send_key(S, key)ONCE thenpoll_capture(deadline, predicate)with no resend. (Cost: a "v did not open the map" failure whose final capture showed the neighbourhood — the map had opened then toggled shut on the next resend.)
Two-tmux pattern — KEEP BOTH PATHS
Keep BOTH a plugin-path test AND a non-plugin-path test:
| Test | Key | Env | Purpose |
|---|---|---|---|
| tripwire_real_data_in_tui | i | normal (plugins active) | Verifies plugin pipeline end-to-end |
| tripwire_sessions_screen | s | AINB_DISABLE_PLUGINS=1 | Verifies host's own code path isn't broken by plugin refactors |
A refactor that breaks one shouldn't silently pass the other. If you add a new screen test, also add a regression test for the opposite path.
Workflow when a tripwire fails
- Run with
--nocapture—cargo test -p ainb --test tripwire_X -- --nocaptureto see SKIP messages and panic output. - Read the captured pane in the panic message — it shows what the test actually saw. Often the cause is staring at you (wizard, loading state, wrong screen).
- Check the JSONL log —
~/.agents-in-a-box/logs/agents-in-a-box-*.jsonl. Key markers:"registered plugin"× N — discovery worked"eager spawn failed: Broken pipe"+"plugin exited / pipe closed"— likely macOS AMFI silent kill (seereferences/amfi.md)"inbound request"/"host->plugin response"— wire dialog activeplugin = "<name>"entries —host.log()from plugins
- Standalone-probe the plugin if Bug-2-like symptoms:
Exit 137 in <1ms = AMFI kill. Fix:./dist/plugins/<id>/<id> </dev/null; echo $?just stage-plugins(re-signs).
When to consult the references
| Question | File |
|---|---|
| What's the exact code for poll_capture, seed_isolated_home, etc.? | references/helpers.md |
| Why does my plugin exit 137 with no stderr? | references/amfi.md |
| How do I seed synthetic Claude data for $N.NN assertions? | references/seed-data.md |
| Full gotcha checklist (10 silent traps) | references/gotchas.md |
| Existing tripwires to copy patterns from | ls crates/ainb-core/tests/tripwire_*.rs — each test is self-documenting via its filename + comments |
Source files
crates/ainb-core/tests/tripwire_*.rs— every tripwire on disk; read them directly, no manifest to keep in syncscripts/build-plugins.sh—just stage-pluginsimplementation (codesign re-sign on macOS)crates/ainb-core/src/main.rssetup_logging()— defaultEnvFilterwidened to include plugin crates