diff --git a/.github/workflows/tui-tests.yml b/.github/workflows/tui-tests.yml new file mode 100644 index 0000000..03d2b80 --- /dev/null +++ b/.github/workflows/tui-tests.yml @@ -0,0 +1,87 @@ +name: TUI Tests + +# Interactive terminal (TUI) smoke suite. Drives the real coven-code binary +# through a tmux pseudo-terminal and asserts on the rendered output. +# Offline / headless-first: no live model call, no network, no credentials. +# See scripts/tui-tests/README.md. + +on: + push: + branches: [main] + paths: + - 'src-rust/**' + - 'scripts/tui-tests/**' + - '.github/workflows/tui-tests.yml' + pull_request: + paths: + - 'src-rust/**' + - 'scripts/tui-tests/**' + - '.github/workflows/tui-tests.yml' + workflow_dispatch: + +# A newer push to the same ref supersedes an in-flight run. +concurrency: + group: tui-tests-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + tui-tests: + name: Interactive terminal suite + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + # tmux drives the pseudo-terminal; ALSA + pkg-config are needed to build + # the binary (voice feature), mirroring the release workflow. + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y tmux libasound2-dev pkg-config + + - name: Cache cargo registry & build + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + src-rust/target + key: tui-tests-cargo-${{ hashFiles('src-rust/Cargo.lock') }} + restore-keys: tui-tests-cargo- + + - name: Build debug binary + working-directory: src-rust + run: cargo build --locked --package claurst + + # Belt-and-suspenders: mark onboarding complete so a credential-less + # runner lands on the main screen rather than (potentially) a blocking + # provider-setup dialog. The suite makes no network calls. + - name: Seed offline settings + run: | + mkdir -p "$HOME/.coven-code" + printf '{"hasCompletedOnboarding": true}' > "$HOME/.coven-code/settings.json" + + - name: Run interactive terminal suite + env: + # Fail loudly if tmux is missing instead of silently skipping the + # interactive cases, and persist pane captures for any failure. + REQUIRE_TMUX: '1' + TUI_LOG_DIR: ${{ runner.temp }}/tui-failures + run: bash scripts/tui-tests/run.sh + + - name: Upload failure pane captures + if: failure() + uses: actions/upload-artifact@v4 + with: + name: tui-failure-captures + path: ${{ runner.temp }}/tui-failures + if-no-files-found: ignore + retention-days: 14 diff --git a/scripts/tui-tests/README.md b/scripts/tui-tests/README.md new file mode 100644 index 0000000..ce30ecd --- /dev/null +++ b/scripts/tui-tests/README.md @@ -0,0 +1,107 @@ +# Interactive terminal (TUI) test suite + +Pre-release smoke tests that drive the real `coven-code` binary through a +`tmux` pseudo-terminal, send keystrokes, capture the rendered pane, and assert +on what the user actually sees. This automates the manual tmux workflow +described in [`AGENTS.md`](../../AGENTS.md) ("Testing the TUI in a controlled +terminal"). + +**Offline / headless-first:** no test makes a live model call or hits the +network. Every assertion is deterministic and runnable on any machine with the +binary and `tmux`. + +## Usage + +```bash +# From the repo root. Auto-detects target/{debug,release}/coven-code. +scripts/tui-tests/run.sh + +# Build the debug binary first, then test it. +scripts/tui-tests/run.sh --build + +# Test a specific binary (e.g. an installed release). +COVEN_BIN=/usr/local/bin/coven-code scripts/tui-tests/run.sh + +# Run only matching case files (substring match on the filename). +scripts/tui-tests/run.sh 03 05 +``` + +Exit code is `0` only if every assertion passed, `1` otherwise — suitable for +a release gate or CI step. + +## CI + +Runs automatically via [`.github/workflows/tui-tests.yml`](../../.github/workflows/tui-tests.yml) +on pushes to `main` and on PRs that touch `src-rust/**` or this suite (plus +manual `workflow_dispatch`). The job installs `tmux`, builds the debug binary +(`cargo build --locked --package claurst`), seeds an offline settings file +(`hasCompletedOnboarding: true`) so a credential-less runner lands on the main +screen, and runs `run.sh`. No secrets or network access required. + +## Requirements + +- The `coven-code` binary (built debug/release, or on `PATH`). +- `tmux` (3.x). If absent, the headless cases still run and the interactive + cases are reported as **skipped** rather than failed. + +## What it covers + +| Case file | Area | Sample assertions | +|---|---|---| +| `cases/01_headless.sh` | CLI surface (no TTY) | `--version` semver, `--help` usage + flags, unknown flag exits non-zero, `auth status`, `models` catalog, `--dump-system-prompt` | +| `cases/02_startup.sh` | TUI cold start | title banner, welcome/changelog sections, input glyph, footer keybindings, no panic on boot | +| `cases/03_command_palette.sh` | Slash palette | `/` opens it, lists `/clear` `/compact` `/config`, typing filters the list, `Ctrl+K` entry point | +| `cases/04_help_overlay.sh` | Help overlay | `?` opens keybinding + command reference, `Esc` closes it | +| `cases/05_input_editing.sh` | Prompt input | typed text echoes into the buffer, `Ctrl+U` clears it | +| `cases/06_quit.sh` | Shutdown | `Ctrl+C` twice exits cleanly back to the shell | + +## Configuration + +Override via environment variables: + +| Var | Default | Purpose | +|---|---|---| +| `COVEN_BIN` | auto-detected | Binary under test | +| `TUI_WIDTH` / `TUI_HEIGHT` | `120` / `40` | tmux pane size (the TUI is layout-sensitive) | +| `TUI_WAIT_TIMEOUT` | `20` | Seconds to wait for an expected string | +| `TUI_SESSION` | `coven-tui-test` | tmux session name | +| `TUI_SOCKET` | `coven-tui-test` | Dedicated tmux server socket (`-L`) so the harness never touches your own tmux sessions | +| `TUI_LOG_DIR` | _(unset)_ | If set, the full pane capture for each failing case is written here (CI uploads these as artifacts) | +| `REQUIRE_TMUX` | `0` | If `1`, a missing `tmux` is a hard error instead of skipping the interactive cases (set in CI) | + +## Writing a new case + +Drop a `cases/NN_name.sh` file. Each file registers one function: + +```bash +register_case tc_mything + +tc_mything() { + describe "My thing" + if ! have_tmux; then _skip "tmux not installed"; return 0; fi + tui_start || { tui_stop; return 0; } + + tui_keys C-k # send a binding (tmux key tokens) + tui_type "some text" # type literal characters + tui_settle # let it redraw + + local s; s="$(tui_capture)" + assert_contains "$s" "expected" "palette shows expected" + + tui_stop +} +``` + +Helpers from [`lib.sh`](lib.sh): `tui_start` / `tui_stop`, `tui_keys`, +`tui_type`, `tui_settle`, `tui_capture`, `wait_for`, and the assertions +`assert_contains` / `assert_absent` / `assert_matches` / `assert_eq`. For +headless checks, call `run_bin ` and read `$RUN_OUT` / `$RUN_RC`. + +## Known tmux limitations + +- `Ctrl+Shift+` chords (e.g. the `Ctrl+Shift+A` model picker) cannot be + sent reliably through `tmux send-keys`, so they are not asserted here. Use + the slash palette path instead where one exists. +- After spawning a session, the inner shell needs a moment before it accepts + input. `tui_start` proves readiness with a marker echo before launching the + binary; replicate that pattern if you script sessions by hand. diff --git a/scripts/tui-tests/cases/01_headless.sh b/scripts/tui-tests/cases/01_headless.sh new file mode 100644 index 0000000..7f601e9 --- /dev/null +++ b/scripts/tui-tests/cases/01_headless.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# Headless CLI surface — no tmux, no TTY, no network. These exercise the +# argument parser and the offline subcommands that must work before release. + +register_case tc_headless + +tc_headless() { + describe "Headless CLI surface (offline)" + + # --version prints the workspace version. + run_bin --version + assert_eq "$RUN_RC" "0" "--version exits 0" + assert_contains "$RUN_OUT" "coven-code" "--version names the binary" + # Version is a semver-ish x.y.z; assert the shape, not a hardcoded number. + assert_matches "$RUN_OUT" '[0-9]+\.[0-9]+\.[0-9]+' "--version prints a semver" + + # --help describes usage and a couple of stable flags. + run_bin --help + assert_eq "$RUN_RC" "0" "--help exits 0" + assert_contains "$RUN_OUT" "Usage: coven-code" "--help shows usage line" + assert_contains "$RUN_OUT" "--print" "--help lists --print" + assert_contains "$RUN_OUT" "--model" "--help lists --model" + assert_contains "$RUN_OUT" "--permission-mode" "--help lists --permission-mode" + + # An unknown flag must error and exit non-zero (clap behavior). + run_bin --definitely-not-a-flag + if [ "$RUN_RC" -ne 0 ]; then + _pass "unknown flag exits non-zero (rc=$RUN_RC)" + else + _fail "unknown flag exits non-zero" "$RUN_OUT" + fi + + # auth status: must run offline and report a status without crashing. + # Outcome (logged in / not) depends on the machine, so accept either, + # but require a clean exit and recognizable wording. + run_bin auth status + if [ "$RUN_RC" -eq 0 ] || [ "$RUN_RC" -eq 1 ]; then + _pass "auth status exits cleanly (rc=$RUN_RC)" + else + _fail "auth status exits cleanly" "$RUN_OUT" + fi + assert_matches "$RUN_OUT" '[Ll]ogged in|[Nn]ot logged in|[Ll]og in' \ + "auth status reports a login state" + + # models: the static model catalog renders offline. + run_bin models + assert_eq "$RUN_RC" "0" "models exits 0" + assert_matches "$RUN_OUT" 'ctx:.*in:.*out:' "models lists priced model rows" + + # --dump-system-prompt: offline prompt assembly (hidden flag). + run_bin --dump-system-prompt + assert_eq "$RUN_RC" "0" "--dump-system-prompt exits 0" + assert_contains "$RUN_OUT" "Working directory" "system prompt includes working directory" +} diff --git a/scripts/tui-tests/cases/02_startup.sh b/scripts/tui-tests/cases/02_startup.sh new file mode 100644 index 0000000..30e0735 --- /dev/null +++ b/scripts/tui-tests/cases/02_startup.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# TUI cold-start: the welcome screen, status line, and footer render. + +register_case tc_startup + +tc_startup() { + describe "TUI startup & chrome" + if ! have_tmux; then _skip "tmux not installed"; return 0; fi + tui_start || { tui_stop; return 0; } + + # tui_start only waits for the frame title; give the welcome card a beat to + # populate before snapshotting. + wait_for "What's new" 5 + local s; s="$(tui_capture)" + + assert_contains "$s" "Coven Code v" "title banner renders with version" + + # Right-hand welcome column. Wording is stable; the username is not, so we + # assert on the static labels only. + assert_contains "$s" "Tips for getting started" "welcome shows tips section" + assert_contains "$s" "What's new" "welcome shows changelog section" + + # Input affordance. + assert_contains "$s" "❯" "input prompt glyph present" + + # Footer hint bar exposes the core keybindings. + assert_contains "$s" "help" "footer advertises help binding" + assert_contains "$s" "familiar" "footer advertises familiar binding" + + # No panic / error banner on a clean boot. + assert_absent "$s" "panicked at" "no rust panic on startup" + assert_absent "$s" "RUST_BACKTRACE" "no backtrace prompt on startup" + + tui_stop +} diff --git a/scripts/tui-tests/cases/03_command_palette.sh b/scripts/tui-tests/cases/03_command_palette.sh new file mode 100644 index 0000000..731bee1 --- /dev/null +++ b/scripts/tui-tests/cases/03_command_palette.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# Slash command palette: opening, filtering, and Ctrl+K entry point. + +register_case tc_palette + +tc_palette() { + describe "Command palette" + if ! have_tmux; then _skip "tmux not installed"; return 0; fi + tui_start || { tui_stop; return 0; } + + # Typing "/" as the first character opens the palette. + tui_type "/" + if ! wait_for "/config"; then + _fail "palette opened on '/' (/config never appeared)" "$(tui_capture)" + tui_stop; return 0 + fi + local s; s="$(tui_capture)" + assert_contains "$s" "/clear" "palette lists /clear" + assert_contains "$s" "/compact" "palette lists /compact" + assert_contains "$s" "/config" "palette lists /config" + + # Filtering narrows the list: "/config" should keep /config, drop /clear. + tui_type "config" + # Wait for the filter to take effect (a non-matching entry disappears). + wait_absent "/clear" 5 + s="$(tui_capture)" + assert_contains "$s" "/config" "filter '/config' keeps /config" + assert_absent "$s" "/clear" "filter '/config' drops /clear" + + # Esc dismisses the palette; Ctrl+U clears the leftover input. + tui_keys Escape; tui_settle + tui_keys C-u; tui_settle + + # Ctrl+K is the second documented entry point to the palette. + tui_keys C-k + if wait_for "/clear" 5; then + _pass "Ctrl+K opens the command palette" + else + _fail "Ctrl+K opens the command palette" "$(tui_capture)" + fi + tui_keys Escape; tui_settle + + tui_stop +} diff --git a/scripts/tui-tests/cases/04_help_overlay.sh b/scripts/tui-tests/cases/04_help_overlay.sh new file mode 100644 index 0000000..b266cbe --- /dev/null +++ b/scripts/tui-tests/cases/04_help_overlay.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# Help overlay: "?" opens the keybinding + command reference, Esc closes it. + +register_case tc_help + +tc_help() { + describe "Help overlay" + if ! have_tmux; then _skip "tmux not installed"; return 0; fi + tui_start || { tui_stop; return 0; } + + tui_keys "?" + # The overlay is tall; under load the lower rows render a beat after the + # top. Poll for a late item before snapshotting so the assertions don't + # race the draw. + if ! wait_for "/permissions"; then + _fail "help overlay rendered (/permissions never appeared)" "$(tui_capture)" + tui_stop; return 0 + fi + local s; s="$(tui_capture)" + assert_contains "$s" "Toggle help" "help overlay documents the help toggle" + assert_contains "$s" "Command palette" "help overlay documents the command palette" + assert_contains "$s" "Model picker" "help overlay documents the model picker" + # Command reference section. + assert_contains "$s" "/login" "help overlay lists /login" + assert_contains "$s" "/permissions" "help overlay lists /permissions" + + # Esc closes the overlay. + tui_keys Escape + if wait_absent "Toggle help"; then + _pass "Esc closes the help overlay" + else + _fail "Esc closes the help overlay" "$(tui_capture)" + fi + + tui_stop +} diff --git a/scripts/tui-tests/cases/05_input_editing.sh b/scripts/tui-tests/cases/05_input_editing.sh new file mode 100644 index 0000000..e8c9eba --- /dev/null +++ b/scripts/tui-tests/cases/05_input_editing.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# Prompt input: text echoes into the buffer, and Ctrl+U clears it. + +register_case tc_input + +tc_input() { + describe "Prompt input editing" + if ! have_tmux; then _skip "tmux not installed"; return 0; fi + tui_start || { tui_stop; return 0; } + + local marker="zzqx_typed_marker" + tui_type "$marker" + if wait_for "$marker" 5; then + _pass "typed text appears in the input buffer" + else + _fail "typed text appears in the input buffer" "$(tui_capture)" + fi + + # Ctrl+U clears the line (verified binding). + tui_keys C-u + if wait_absent "$marker" 5; then + _pass "Ctrl+U clears the input buffer" + else + _fail "Ctrl+U clears the input buffer" "$(tui_capture)" + fi + + tui_stop +} diff --git a/scripts/tui-tests/cases/06_quit.sh b/scripts/tui-tests/cases/06_quit.sh new file mode 100644 index 0000000..8d8238f --- /dev/null +++ b/scripts/tui-tests/cases/06_quit.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# Shutdown: Ctrl+C twice exits the TUI and returns the shell. + +register_case tc_quit + +tc_quit() { + describe "Quit / shutdown" + if ! have_tmux; then _skip "tmux not installed"; return 0; fi + tui_start || { tui_stop; return 0; } + + # First Ctrl+C is "cancel"; the second confirms quit. + tui_keys C-c; sleep 0.4 + tui_keys C-c + + # Prove the process actually exited by dropping a marker on the shell that + # is revealed once the TUI tears down. + if wait_for "Coven Code v" 2; then + : # still showing — fall through to explicit check below + fi + tui_type "echo __TUI_EXITED__"; tui_keys Enter + if wait_for "__TUI_EXITED__" 5; then + _pass "Ctrl+C twice exits to the shell" + else + _fail "Ctrl+C twice exits to the shell" "$(tui_capture)" + fi + + tui_stop +} diff --git a/scripts/tui-tests/lib.sh b/scripts/tui-tests/lib.sh new file mode 100755 index 0000000..fa8195a --- /dev/null +++ b/scripts/tui-tests/lib.sh @@ -0,0 +1,265 @@ +#!/usr/bin/env bash +# shellcheck shell=bash +# +# Shared harness for Coven Code interactive-terminal (TUI) smoke tests. +# +# Drives the real `coven-code` binary inside a controlled tmux session, +# sends keystrokes, captures the rendered pane, and asserts on the output. +# Mirrors the tmux pattern documented in AGENTS.md ("Testing the TUI in a +# controlled terminal"). Offline / headless-first: no test here requires a +# live model call or network. +# +# Source this file; do not execute it directly. + +# --- strictness (no -e: we want every assertion to run) ----------------- +set -uo pipefail + +# --- configuration (override via env) ----------------------------------- +: "${COVEN_BIN:=}" # path to the binary; auto-detected if empty +: "${TUI_SESSION:=coven-tui-test}" # tmux session name +: "${TUI_SOCKET:=coven-tui-test}" # dedicated tmux server socket (-L) +: "${TUI_WIDTH:=120}" # terminal columns +: "${TUI_HEIGHT:=40}" # terminal rows +: "${TUI_BOOT_STRING:=Coven Code v}" # string proving the TUI has drawn +: "${TUI_WAIT_TIMEOUT:=20}" # seconds to wait for a string +: "${TUI_POLL_INTERVAL:=0.4}" # seconds between capture polls +: "${TUI_SETTLE:=0.6}" # seconds to let a keypress redraw +: "${TUI_LOG_DIR:=}" # if set, failing-case pane captures are written here +: "${REQUIRE_TMUX:=0}" # if 1, a missing tmux is a hard error (CI), not a skip + +# --- counters (persist across sourced case files) ----------------------- +TESTS_RUN=0 +TESTS_PASS=0 +TESTS_FAIL=0 +TESTS_SKIP=0 +declare -a FAILED_NAMES=() +declare -a REGISTERED_CASES=() +CURRENT_CASE="(none)" + +# --- colors ------------------------------------------------------------- +if [ -t 1 ]; then + C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m' + C_CYN=$'\033[36m'; C_DIM=$'\033[2m'; C_BLD=$'\033[1m'; C_RST=$'\033[0m' +else + C_RED=''; C_GRN=''; C_YEL=''; C_CYN=''; C_DIM=''; C_BLD=''; C_RST='' +fi + +# --- logging ------------------------------------------------------------ +log() { printf '%s\n' "$*"; } +info() { printf '%s%s%s\n' "$C_CYN" "$*" "$C_RST"; } +warn() { printf '%s%s%s\n' "$C_YEL" "$*" "$C_RST" >&2; } + +# --- binary discovery --------------------------------------------------- +detect_bin() { + if [ -n "$COVEN_BIN" ]; then return 0; fi + local here root + here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + root="$(cd "$here/../.." && pwd)" # repo root + local candidates=( + "$root/src-rust/target/debug/coven-code" + "$root/src-rust/target/release/coven-code" + ) + local c + for c in "${candidates[@]}"; do + if [ -x "$c" ]; then COVEN_BIN="$c"; return 0; fi + done + if command -v coven-code >/dev/null 2>&1; then + COVEN_BIN="$(command -v coven-code)"; return 0 + fi + return 1 +} + +# --- dependency guards -------------------------------------------------- +have_tmux() { command -v tmux >/dev/null 2>&1; } + +# --- assertion primitives ---------------------------------------------- +# _pass +_pass() { + TESTS_RUN=$((TESTS_RUN + 1)); TESTS_PASS=$((TESTS_PASS + 1)) + printf ' %sok%s %s\n' "$C_GRN" "$C_RST" "$1" +} +# _fail [evidence] +_fail() { + TESTS_RUN=$((TESTS_RUN + 1)); TESTS_FAIL=$((TESTS_FAIL + 1)) + FAILED_NAMES+=("$CURRENT_CASE :: $1") + printf ' %sFAIL%s %s\n' "$C_RED" "$C_RST" "$1" + if [ "${2:-}" != "" ]; then + printf '%s\n' "$2" | sed 's/^/ │ /' | head -45 + # Persist the full pane capture for post-mortem (uploaded as a CI artifact). + if [ -n "$TUI_LOG_DIR" ]; then + mkdir -p "$TUI_LOG_DIR" + local safe; safe="$(printf '%s' "$CURRENT_CASE" | tr -c 'A-Za-z0-9._-' '_')" + { printf '### %s :: %s\n\n' "$CURRENT_CASE" "$1"; printf '%s\n' "$2"; } \ + >> "$TUI_LOG_DIR/${safe}.log" + fi + fi +} +# _skip +_skip() { + TESTS_RUN=$((TESTS_RUN + 1)); TESTS_SKIP=$((TESTS_SKIP + 1)) + printf ' %sskip%s %s\n' "$C_YEL" "$C_RST" "$1" +} + +# Note: all matching uses grep with a here-string (`<<<`), never `printf | grep`. +# `grep -q` closes its input early on a match, which sends SIGPIPE to a feeding +# printf; under `set -o pipefail` that turns a successful match into a failing +# pipeline. Here-strings avoid the pipe entirely. + +# assert_contains +assert_contains() { + local hay="$1" needle="$2" msg="$3" + if grep -qF -- "$needle" <<<"$hay"; then + _pass "$msg" + else + _fail "$msg" "$hay" + fi +} + +# assert_absent +assert_absent() { + local hay="$1" needle="$2" msg="$3" + if grep -qF -- "$needle" <<<"$hay"; then + _fail "$msg (unexpectedly present: '$needle')" "$hay" + else + _pass "$msg" + fi +} + +# assert_matches +assert_matches() { + local hay="$1" pat="$2" msg="$3" + if grep -qE -- "$pat" <<<"$hay"; then + _pass "$msg" + else + _fail "$msg" "$hay" + fi +} + +# assert_eq +assert_eq() { + if [ "$1" = "$2" ]; then _pass "$3"; else _fail "$3 (got '$1', want '$2')"; fi +} + +# --- headless helpers --------------------------------------------------- +# run_bin -> sets globals RUN_OUT (stdout+stderr) and RUN_RC. +# Sets globals directly (not via command substitution) so the exit code +# survives — `$(run_bin ...)` would trap RUN_RC inside a subshell. +RUN_RC=0 +RUN_OUT="" +run_bin() { + RUN_OUT="$("$COVEN_BIN" "$@" 2>&1)" + RUN_RC=$? +} + +# --- tmux session helpers ---------------------------------------------- +# All tmux calls go through a dedicated server socket (-L) so the harness +# never attaches to the user's existing tmux server (which would carry the +# wrong environment) and never disturbs their sessions. +_tmux() { command tmux -L "$TUI_SOCKET" "$@"; } + +tui_capture() { _tmux capture-pane -t "$TUI_SESSION" -p 2>/dev/null; } + +# tui_keys (special keys: Enter, Escape, C-c, ...) +tui_keys() { _tmux send-keys -t "$TUI_SESSION" "$@"; } + +# tui_type (typed verbatim, no Enter) +tui_type() { _tmux send-keys -t "$TUI_SESSION" -l -- "$1"; } + +# tui_settle [seconds] +tui_settle() { sleep "${1:-$TUI_SETTLE}"; } + +# wait_for [timeout-seconds] -> 0 found / 1 timeout +wait_for() { + local needle="$1" timeout="${2:-$TUI_WAIT_TIMEOUT}" + local waited=0 + # bash arithmetic is integer; scale by 10 to honor sub-second poll interval + local step_ms=400 budget_ms=$((timeout * 1000)) elapsed_ms=0 + while [ "$elapsed_ms" -lt "$budget_ms" ]; do + if tui_capture | grep -qF -- "$needle"; then return 0; fi + sleep "$TUI_POLL_INTERVAL" + elapsed_ms=$((elapsed_ms + step_ms)) + done + return 1 +} + +# wait_absent [timeout-seconds] -> 0 once gone / 1 still present +# Mirror of wait_for for disappearance (e.g. an overlay closing). +wait_absent() { + local needle="$1" timeout="${2:-$TUI_WAIT_TIMEOUT}" + local step_ms=400 budget_ms=$((timeout * 1000)) elapsed_ms=0 + while [ "$elapsed_ms" -lt "$budget_ms" ]; do + if ! tui_capture | grep -qF -- "$needle"; then return 0; fi + sleep "$TUI_POLL_INTERVAL" + elapsed_ms=$((elapsed_ms + step_ms)) + done + return 1 +} + +tui_session_alive() { _tmux has-session -t "$TUI_SESSION" 2>/dev/null; } + +# tui_start [extra binary args...] +# Spins up a fresh tmux session, waits for the shell, launches the binary, +# and blocks until the TUI has drawn (or fails the current assertion). +tui_start() { + _tmux kill-session -t "$TUI_SESSION" 2>/dev/null + _tmux new-session -d -s "$TUI_SESSION" -x "$TUI_WIDTH" -y "$TUI_HEIGHT" + + # The shell inside the new pane is not immediately ready to accept input; + # sending keys too early drops them (observed: command echoed, never run). + # Prove readiness with a marker before launching the binary. + tui_type "echo __SHELL_READY__"; tui_keys Enter + if ! wait_for "__SHELL_READY__" 6; then + warn "shell did not become ready in tmux session" + fi + tui_type "clear"; tui_keys Enter; sleep 0.3 + + local args="" + if [ "$#" -gt 0 ]; then printf -v args ' %q' "$@"; fi + tui_type "$COVEN_BIN$args"; tui_keys Enter + + if wait_for "$TUI_BOOT_STRING" "$TUI_WAIT_TIMEOUT"; then + return 0 + fi + _fail "TUI failed to boot (no '$TUI_BOOT_STRING' within ${TUI_WAIT_TIMEOUT}s)" "$(tui_capture)" + return 1 +} + +# tui_stop — best-effort graceful quit, then hard kill. +tui_stop() { + if tui_session_alive; then + tui_keys C-c 2>/dev/null; sleep 0.3 + tui_keys C-c 2>/dev/null; sleep 0.4 + _tmux kill-session -t "$TUI_SESSION" 2>/dev/null + fi +} + +# tui_shutdown_server — tear down the dedicated tmux server entirely. +tui_shutdown_server() { _tmux kill-server 2>/dev/null || true; } + +# --- case framework ----------------------------------------------------- +# register_case (called at top of each cases/*.sh file) +register_case() { REGISTERED_CASES+=("$1"); } + +# describe +describe() { + CURRENT_CASE="$1" + printf '\n%s▸ %s%s\n' "$C_BLD" "$1" "$C_RST" +} + +# print_summary -> sets global SUITE_RC (0 pass / 1 fail) +SUITE_RC=0 +print_summary() { + printf '\n%s────────────────────────────────────────%s\n' "$C_DIM" "$C_RST" + printf '%sSummary%s %s%d passed%s %s%d failed%s %s%d skipped%s (%d checks)\n' \ + "$C_BLD" "$C_RST" \ + "$C_GRN" "$TESTS_PASS" "$C_RST" \ + "$C_RED" "$TESTS_FAIL" "$C_RST" \ + "$C_YEL" "$TESTS_SKIP" "$C_RST" \ + "$TESTS_RUN" + if [ "$TESTS_FAIL" -gt 0 ]; then + printf '\n%sFailures:%s\n' "$C_RED" "$C_RST" + local f + for f in "${FAILED_NAMES[@]}"; do printf ' - %s\n' "$f"; done + SUITE_RC=1 + fi +} diff --git a/scripts/tui-tests/run.sh b/scripts/tui-tests/run.sh new file mode 100755 index 0000000..9dccb07 --- /dev/null +++ b/scripts/tui-tests/run.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# +# Coven Code interactive-terminal test suite. +# +# Drives the real binary through a tmux pseudo-terminal and asserts on the +# rendered output. Offline / headless-first: no live model call is made. +# +# Usage: +# scripts/tui-tests/run.sh # auto-detect binary, run all cases +# scripts/tui-tests/run.sh --build # cargo build (debug) first +# COVEN_BIN=/path/to/coven-code run.sh # test a specific binary +# scripts/tui-tests/run.sh 03 05 # run only cases matching 03* / 05* +# +# Exit code: 0 if every assertion passed, 1 otherwise. + +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +# shellcheck source=lib.sh +source "$HERE/lib.sh" + +DO_BUILD=0 +declare -a FILTERS=() +for arg in "$@"; do + case "$arg" in + --build) DO_BUILD=1 ;; + -h|--help) + sed -n '2,16p' "$0"; exit 0 ;; + *) FILTERS+=("$arg") ;; + esac +done + +if [ "$DO_BUILD" -eq 1 ]; then + info "Building debug binary (cargo build)…" + ( cd "$ROOT/src-rust" && cargo build ) || { warn "cargo build failed"; exit 2; } +fi + +if ! detect_bin; then + warn "Could not find the coven-code binary." + warn "Build it first: (cd src-rust && cargo build) or run: $0 --build" + warn "Or point COVEN_BIN at an installed binary." + exit 2 +fi +info "Binary: $COVEN_BIN" +if have_tmux; then + info "tmux: $(tmux -V)" +elif [ "$REQUIRE_TMUX" = "1" ]; then + warn "tmux not found but REQUIRE_TMUX=1 — refusing to skip interactive cases." + exit 2 +else + warn "tmux not found — interactive TUI cases will be skipped (headless cases still run)." +fi +info "Term: ${TUI_WIDTH}x${TUI_HEIGHT}" +[ -n "$TUI_LOG_DIR" ] && info "Logs: $TUI_LOG_DIR (pane captures on failure)" + +# Clean up any stray server from a previous aborted run, and guarantee the +# dedicated tmux server is torn down on exit. +if have_tmux; then + tui_shutdown_server + trap 'tui_shutdown_server' EXIT +fi + +# Load all case files (each calls register_case). +shopt -s nullglob +for f in "$HERE"/cases/*.sh; do + # Apply filename filters if any were supplied. + if [ "${#FILTERS[@]}" -gt 0 ]; then + keep=0 + for pat in "${FILTERS[@]}"; do + [[ "$(basename "$f")" == *"$pat"* ]] && keep=1 + done + [ "$keep" -eq 1 ] || continue + fi + # shellcheck source=/dev/null + source "$f" +done + +if [ "${#REGISTERED_CASES[@]}" -eq 0 ]; then + warn "No test cases matched."; exit 2 +fi + +for case_fn in "${REGISTERED_CASES[@]}"; do + "$case_fn" + have_tmux && command tmux -L "$TUI_SOCKET" kill-session -t "$TUI_SESSION" 2>/dev/null || true +done + +print_summary +exit "$SUITE_RC"