Runtime/E2E testing of the ralph-tui TUI
Use this when a change must be proven in the real TUI (watch mode, engine status labels, pause/resume, shutdown/quit paths) rather than with unit tests.
Build & run
cd /path/to/ralph-tui && bun run build # produces dist/cli.js
Run the built CLI (not bun run dev) so behaviour matches shipped code:
bun /path/to/ralph-tui/dist/cli.js run [flags]
Scratch tracker (never touch the repo's own .beads)
Create a throwaway project dir with its own beads store and drive scenarios with bd:
export RALPH_TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/ralph-tui-run.XXXXXX")" && cd "$RALPH_TEST_DIR"
bd init # or `bd create ...` which bootstraps the store
bd create "Watch mode epic" --type epic -p 1 # note the epic id bd prints
bd create "Alpha task" --type task -p 2 --parent <epic-id>
A fresh dir per run, not a fixed one: a reused dir carries over .beads tasks, the
.ralph-tui session file, locks and guards — exactly the things most scenarios here assert on, so
a Recovered stale session banner or a stale-lock prompt from the previous run reads as a
finding of the build under test. The unique dir also gives the run a unique epic id, which is how
you address its process (see the signal trap below).
Gotchas:
bdcommands must be run with the scratch dir as the working directory, otherwise you get "no beads database found".- With
--tracker beads, the TUI requires--epic <id>; without it the TUI shows "No epics found" and interactive epic selection blocks automation. br(beads-rust) may not be installed even though AGENTS.md references it;bdworks.
Fake agents (no AI credentials needed)
Put plugins in ~/.config/ralph-tui/plugins/agents/:
echo.ts— returnsecho-agent: local fake response\n<promise>COMPLETE</promise>instantly.slowecho.ts— same but sleeps ~25s in interruptible 500 ms chunks. Essential for testing quit/pause mid-iteration; an instant agent gives you no window to interrupt.
Select with --agent echo / --agent slowecho.
Making timing visible on screen
Wrap the CLI so quit latency is provable in the recording:
#!/bin/bash
cd "${RALPH_TEST_DIR:?export RALPH_TEST_DIR to the scratch dir first}"
export PATH="$PATH:$HOME/.local/bin"
echo "START $(date +%H:%M:%S.%3N) args: $*"
bun /path/to/ralph-tui/dist/cli.js run "$@"
echo "=== PROCESS EXITED code=$? at $(date +%H:%M:%S.%3N) ==="
exec bash
Typical invocation:
./runwatch.sh --watch --poll 60 --agent echo --tracker beads --epic <epic-id> --no-setup --force
Use a long --poll (60s) to make a stalled pause/quit obvious, and a short one (10s) when you
want auto-pickup to happen quickly.
TUI keys
s start · p pause/resume · q → quit dialog → y · d dashboard · r refresh.
Header status labels: Waiting (watch idle), Selecting, Executing, Paused, Complete.
Dashboard shows Waiting for new tasks when watch-idle.
Known traps
- Interrupt (Ctrl+C) path: on builds that include the #431 fix (
src/tui/utils/keyboard-shortcuts.tsonInterruptRequestinRunApp.tsx+exitSignals: ['SIGQUIT','SIGABRT']inrun.tsx), keyboard Ctrl+C and externalkill -INT <pid>both render the⚠ Interrupt Ralph?dialog with the display intact;n/Esccancel,yexits code 0 in ~2s and resets the active task toopen, and a second Ctrl+C within ~1s force-quits viaprocess.exit(1)(wrappercode=1, terminal left un-repainted; on builds whoseexithandler callsreleaseLockSyncthe force quit no longer leaves a lock file behind, on older ones it does). On older builds Ctrl+C is a complete no-op andkill -INTblanks the display and hangs, so always confirm which build you are on before blaming a change.
- Ctrl+Shift+C is indistinguishable from Ctrl+C in most terminals: konsole sends byte
0x03for both (verify withstty -isig; cat -v— Ctrl+Shift+C prints^C, Alt+C prints^[c), so the "copy" shortcut opens the interrupt dialog whenever the terminal does not consume it itself (konsole only consumes it when a konsole-level text selection exists). Alt+C is safely distinct. Clipboard contents cannot be verified on a box withoutxclip/xsel/wl-paste(that is whatsrc/utils/clipboard.tsshells out to on Linux) — record the clipboard write as untested. - Early-startup signals need timing, and are quirky: the app's SIGINT/SIGTERM handlers are not
installed for roughly the first
0.7s. Drive it with0.05s) uses default disposition but cannot leave a lock. Always sweep several delays (0.05/0.2/0.3/0.4/0.5/0.7s), log that the signal was actually sent, and check( sleep 0.5; kill -INT "$(pgrep -f "cli\.js run.*--epic $EPIC_ID")" ) & ./runwatch.sh ..., where$EPIC_IDis this run's epic — check the pattern matches exactly one pid (pgrep -cf ...) before signalling, and note the wrapper script is a different process from the CLI it launches, so signal the pid you matched rather than the wrapper. Do not usepkill -f 'dist/cli.js run': it hits every ralph run on the box, including a parallel test or amainworktree build you are comparing against, and mutates their session and task state. Historic behaviour (before the #434 fix insrc/session/lock.ts): SIGINT in that window exited130but left a stale lock, and SIGTERM was delivered but ignored (the TUI mounted and kept running). On builds whereregisterLockCleanupHandlersowns the startup signals (guarded byprocess.listenerCount(signal) > 1), expectcode=143for SIGTERM /code=130for SIGINT at every delay in the window, with.ralph-tui/ralph.lockremoved; a signal landing beforeacquireLockWithPrompt(ls .ralph-tui/ralph.lockplus the next launch's banner each time. Two signals in quick succession inside the window should still yield a single clean 130/143, nevercode=1. Recovered stale sessionis not the same thing asStale lock detected: the lock file can be correctly removed while.ralph-tui/session.jsonkeepsstatus: "running", in which case every next launch prints⚠ Recovered stale session / Session status set to "interrupted" (resumable)— even after a gracefulq/yexit (code=0). Checkjq .status .ralph-tui/session.jsonandsession-meta.jsonbefore attributing that banner to a lock/signal change; it may be an independent session-persistence bug.- In TUI mode the process does not exit by itself when all tasks are done in non-watch mode;
it parks on
Completeuntil you quit. To prove "exits when work runs out", use headless mode:--no-tui(alias--headless) exits with code 0 and prints a run summary. Headless watch mode logs[watch] All tasks complete — polling every Nsand stays alive. - Killed sessions may leave a stale lock (older builds; fixed once the lock layer owns
startup signals and the
exithandler releases synchronously): the next launch prompts "Remove the stale lock and continue? (Y/n)" before the TUI starts. Answeryfirst; keys you send too early (e.g.s) land in that prompt. Prefer gracefulq/yexits to avoid this. - After creating a task externally, allow up to one full poll interval plus a few seconds before concluding auto-pickup failed.
- Lock guard file (
.ralph-tui/ralph.lock.guard) andlockId: on builds that serialize lock mutations,.ralph-tui/ralph.lockcarries alockIdnonce and every create/stale-clean/--force/release goes through a guard file created withO_EXCL. Useful observer helper: print the lock'spid/lockId, whether the guard exists (and its contents), andpgreped ralph pids, and sample it before/after every scenario. The guard should never be observed present while a run merely executes — only during a mutation. Things worth planting by hand: an empty ornot-jsonguard (should be reclaimed as malformed after ~250ms and the run starts normally), a guard naming a dead pid (reclaimed), and a guard naming a live pid (respected: acquire fails after ~2s withTimed out waiting for the session lock (another ralph-tui process may be starting or exiting)). On such builds a guard held past the sync budget at exit time is expected to leave the lock behind (later recovered as stale) rather than unlink it — a lock left behind there is a pass; a hang, a multi-second stall, or a live process's lock disappearing is the bug. Make sure the "live pid" in a planted guard is really alive (kill -0); a pid that has already exited silently converts the test into the dead-pid case. run --resumemay fail where standaloneresumeworks:run.tsxacquires the session lock before callingresumeSession, which acquires again and refuses because a lock exists — observed asResuming previous session... / Failed to resume sessionwith exit 1, whileralph-tui resumeon the same interrupted session starts fine. Reproduced onmaintoo, so treat it as pre-existing unless the build under test claims to fix it; always cross-check against agit worktreebuild ofmainbefore calling it a regression.- Watch for an orphaned run with no lock file: observed once — the TUI painted nothing at all
yet the engine kept executing iterations and
.ralph-tui/ralph.lockwas absent. That is the signature of an uncaught exception handler that releases the lock without exiting. It did not reproduce in follow-up attempts; if you hit a blank TUI, capture stdout/stderr by wrapping the run inscript -q -c "./runwatch.sh ..." /tmp/run.log(this preserves the TTY, unlike a pipe) before killing it. Noterunwatch.shends withexec bash, so ascript-wrapped run leaves you in an inner shell — typeexitto continue a scripted loop.
Devin Secrets Needed
None — the fake agent plugins and the local bd store avoid all external credentials.