# End-to-end tests against real applications Each subdirectory here runs a real application's *own* shell-integration test suite inside a container, with the shell under test swapped in for `bash`. This catches interactive-shell regressions (bindings, `bind -x`, `READLINE_*`, completion, history) that unit tests can't. ```bash cargo build cargo xtask test e2e fzf # one adapter against brush cargo xtask test e2e # default adapters (excludes blesh, bash-tests, bash-completion) cargo xtask test e2e --with bash-completion # default adapters plus an opt-in one cargo xtask test e2e blesh # opt in to the slow ble.sh suite cargo xtask test e2e --baseline fzf # baseline against Bash cargo xtask test e2e --shell /path/to/sh fzf # any other binary cargo xtask test e2e fzf -- -n /ctrl_r/ # adapter-specific arguments cargo xtask test --release e2e fzf # use target/release/brush cargo xtask test e2e --verbose fzf # stream the runner's own output cargo xtask test e2e --timeout 600 fzf # bound each adapter (default 1800s) cargo xtask test e2e --summary-output e2e.md # also write the run as a Markdown dashboard ``` A run reports one line per adapter and a summary, read back from the JUnit each runner writes: ``` Starting 7 e2e adapter(s) against brush Results target/e2e/run-ABC123 PASS [ 1.2s] starship 3 tests FAIL [ 13.3s] atuin 5 tests, 1 failed PASS [ 542.2s] blesh 9 tests, 9 xfailed ------------ Summary [ 11m35.0s] 7 adapter(s): 6 passed, 1 failed atuin failed: ctrl-r search runs the selected command -> target/e2e/run-ABC123/atuin/log.txt ``` Results land in `target/e2e/run-//`: `log.txt` (full runner output), `junit/*.xml` (JUnit, for CI upload), and any adapter-specific diagnostic logs (such as PTY transcripts). Each invocation creates and prints a fresh run directory, retaining previous runs and leaving existing files untouched, including when using `--results-dir`. Missing, malformed, or empty JUnit reports fail the adapter, as does an adapter that runs longer than `--timeout` (default 1800s): its container is killed, so a hung shell cannot hang the run. The runner's own output goes to `log.txt` rather than the terminal; `--verbose` streams it instead. Multi-adapter runs continue after failures and report them together at the end. On a terminal, statuses are green for clean passes, yellow for passes with expected failures, and red for failures. Redirected output, `TERM=dumb`, and a nonempty `NO_COLOR` disable color. They need Docker and a built shell binary. CI runs the default selection on every PR (the `e2e-tests` job in `ci.yaml`, against the release binary the build job produced) and posts the `--summary-output` dashboard to the PR comment, alongside the coverage and benchmark reports, with each failing adapter's `log.txt` in the job log and every run directory in the `e2e-logs` artifact. Because the `xfail-list.txt` files are the committed baseline, a PR that fixes or breaks a test shows it in its own diff; no run against `main` is needed. `blesh` is excluded from the default selection because compatibility failures and timeouts make it take several minutes against brush. Run it explicitly with `cargo xtask test e2e blesh`, or select one file with `cargo xtask test e2e blesh -- util`. Its per-file timeout remains 180 seconds. `bash-tests` is likewise opt-in, until it has shown itself reliable. Neither runs in CI. `bash-completion` is opt-in because its ~2000 tests take several minutes; CI adds it with `--with bash-completion`, and it is report-only (below), so it cannot fail the job on test results. ## Adapter contract An adapter is a directory `e2e//` containing a `Dockerfile` (build context is `e2e/`, so it can `COPY shim /e2e/bin`) whose entrypoint: 1. **Receives the shell** via `$SHELL_UNDER_TEST`, an absolute path to a binary bind-mounted into the container (`/shell/`). It's built on the host, so the image's glibc must be at least the host's. Most apps hard-code `bash`, so the shared `shim/bash` exec's `$SHELL_UNDER_TEST`; put `/e2e/bin` first on `PATH`. When the variable is unset the shim runs the real `bash`, which gives the baseline run. If the suite has its own knob for which shell to test (atuin's `ATUIN_TEST_BASH`), set that instead and skip the shim; a shim on `PATH` also captures test tooling written in bash (tmux, say), which must not run under the shell under test. A suite that takes a shell *path* has nowhere to put a command-line flag; turn the behavior on through brush's config file (`$HOME/.config/brush/config.toml`, and `HOME` is `/tmp` in the container) instead, which bash ignores and so leaves the baseline run alone. 2. **Writes results** to `/results`: `log.txt` plus JUnit XML under `junit/`. Write whatever else helps debugging there too. 3. **Exits non-zero** when any test fails. Entrypoints pipe the runner through `tee`, so exit `${PIPESTATUS[0]}` rather than `$?`, which is `tee`'s status. Known failures are not the adapter's problem: the runner reads them from `xfail-list.txt` (below) and decides the result from the JUnit, so an adapter that exits non-zero purely because of expected failures still passes. The container runs as the host user with `HOME=/tmp`, so `chmod` anything the tests write into. ## Reproducing a failure The dashboard names each failing test with the command that runs it alone, built from the adapter's `select-args.txt`: the adapter arguments that select one test, with `{test}` standing for its name (fzf: `-n /{test}/`). Adapters without the file are assumed to take pytest's `-k {test}`, since that is what every adapter's own tests use. ## Tracking known failures Every dashboard row shows the pass rate: passed tests over those that ran (skips excluded, xfails included). An adapter containing a `report-only` file (a comment saying why) is tracked by that rate alone: its failing tests are listed, collapsed, and its row is marked 📊, but they do not fail the adapter; nor does the non-zero exit they cause. A run that produced no results still fails it: an image that does not build, a timeout, or missing JUnit. Such a suite needs no `xfail-list.txt`; the runner's own expected failures (`@pytest.mark.xfail`) are counted as xfailed as usual. Everything below applies to the other adapters. Two lists per adapter, both optional, both `#`-commented, one test name per line. `xfail-list.txt` is the default home for anything that fails today. The test still **runs**; the runner matches it against the JUnit and counts it as `xfailed` instead of a failure. Crucially it also fails the run when such a test **passes** ("passed unexpectedly") or when an entry **matches no test that ran** ("stale xfail entry" -- renamed, removed, or moved to `skip-list.txt`), so a gap that closes is reported rather than absorbed, and the list cannot rot. Stale detection needs the whole suite, so it is skipped when adapter arguments select a subset. Because the matching happens over JUnit, one mechanism covers every runner. Name a test by the `name` its JUnit carries, or as `classname::name`. These expectations apply only to brush runs; `--baseline` and `--shell` runs use no brush expectations and fail on any reported test failure. `skip-list.txt` is for tests that must **not** run: they hang, or they cannot exercise the shell at all (nvm has one with a `/bin/zsh` shebang). This is the exception -- prefer `xfail-list.txt`, because a skipped test reports nothing when it starts working -- and listing one in both files fails the run as a stale xfail entry. The runner reports skipped tests by name, so a suite that starts skipping something itself does not pass unnoticed. Applying the list is mostly handled for you: `lib/run_pytest.sh` exports the file as `$E2E_SKIP_LIST` and loads `lib/e2e_skip.py`, a pytest plugin that skips any test named in it. Adapters that must decide before collection (blesh) or at image build time (nvm, which takes the execute bit off) parse `$E2E_SKIP_LIST` themselves; fzf turns it into a minitest `--exclude` regex, since its runner is upstream's. Every entry in either list needs a comment saying why, with an issue link where one exists. ## Shared adapter code Every adapter's own tests are pytest, so `lib/` (copied to `/e2e/lib`, on `PYTHONPATH`) carries what they would otherwise each reinvent: | | | |-----|-------| | `run_pytest.sh` | the pytest half of the contract: JUnit, log, skip list, exit status. Source it and call `e2e_run_pytest ` | | `rcfile.py` | `write_rc()`: a known prompt and no `HISTFILE`, then the app's own integration lines | | `pty_shell.py` | drives a shell over a raw PTY (pexpect) and matches its output stream. The default | | `tmux_shell.py` | drives a shell inside tmux (via `libtmux`) and matches the *rendered screen*. For full-screen TUIs, whose redraws a byte stream cannot answer questions about | | `e2e_skip.py` | the skip-list pytest plugin, plus `parse()` for adapters that must skip before collection | ## Rules 1. **Never copy upstream files into this tree.** Clone the application at build time, pinned to a commit (not a branch). 2. **The Dockerfile is our own environment recipe.** Deriving its package list from upstream's Dockerfile or CI config is fine; say so in a comment, with the source and license. 3. **The entrypoint adapts upstream's runner to the results contract.** If the runner can't emit JUnit itself, add a reporter there (fzf: `minitest-ci`). 4. **Not every app has a suite to borrow.** When upstream only tests its own code (atuin, say), the adapter brings its own tests: a small terminal-driven suite that installs the app's hooks into the shell under test and checks the observable behavior. The contract is the same; only the author differs. ## Adapters | app | notes | |-----|-------| | fzf | upstream `test/test_shell_integration.rb`, `TestBash` only; minitest + tmux, JUnit via `minitest-ci` | | atuin | our own suite in `atuin/tests/` (pytest + tmux), written to be upstreamable. atuin's bash integration is bash-preexec, so the adapter turns on brush's `zsh-hooks` through a config file to exercise the native hooks rather than the `DEBUG`-trap emulation | | bash-tests | bash's own regression suite (`tests/run-*`), from a bash-5.3 build that also provides its helper binaries and the `--baseline` shell; one pytest case per `run-*` script, which is sourced verbatim, on a terminal, with `diff` replaced by a function that captures the output for byte-exact comparison against the `.right` file. `PLATFORM_DIFFS` in `tests/test_bash.py` excuses the few differences bash itself shows on Linux. Failures report how many expected lines match, and `log.txt` ends with the total across the run. Args go to pytest: `-- -k alias`, `-- --subset minimal` for the scripts `run-minimal` runs, `-- --script-timeout 60` (default 30s per script) | | bash-completion | upstream's `test/t` pytest suite (tag 2.18.0), in upstream's own rolling `fedoradev` test image, which has the commands under test installed; built with `make` as upstream's CI does, and run under `xvfb-run`. Report-only: upstream's `xfail` markers are its only expectations. Args go to pytest: `-- -k test_ls` | | blesh | upstream `ble.sh --test`, one pytest case per test section with a process-group timeout (`BLESH_TEST_TIMEOUT`, default 180s); sections are read from ble.sh's own build output, so a section added upstream runs rather than being missed; full output and leftover per-section artifacts are retained; args select sections: `cargo xtask test e2e blesh -- util`. Every section is currently expected to fail: ble.sh gets far enough to emit no section summary at all | | mise | selected upstream bash activation tests (pytest), run directly against the shell under test | | nvm | upstream `test/fast/Listing versions` suite (urchin) plus interactive regression coverage for [#1173](https://github.com/reubeno/brush/issues/1173) | | oh-my-git | our own suite in `oh-my-git/tests/` (pytest + direct PTY); upstream has no tests or releases, so the scripts are cloned at a pinned commit | | starship | our own suite in `starship/tests/` (pytest + direct PTY) | | zoxide | our own suite in `zoxide/tests/` (pytest + direct PTY) |