From 6c02a7a692f7c75d201a151139ac5e61e7e78955 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Thu, 4 Jun 2026 15:45:40 -0500 Subject: [PATCH] Add scripts/qa: drive the real ty TUI for isolated QA A small harness to drive the actual TUI against a throwaway, isolated instance (separate DB + tmux sessions + projects_dir), so features can be QA'd via real keystrokes and asserted via --debug-state-file instead of manual one-offs. Loop: up -> key -> assert state -> down. ty-qa-up / -tui / -key / -state / -capture / -down, plus ty-qa-agent.sh which attaches a live agent window for pane/detail-view tests without a daemon (ty's daemon lock is global, so a second daemon can't run beside the live one). Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/qa/README.md | 100 ++++++++++++++++++++++++++++++++++++ scripts/qa/lib.sh | 32 ++++++++++++ scripts/qa/ty-qa-agent.sh | 52 +++++++++++++++++++ scripts/qa/ty-qa-capture.sh | 12 +++++ scripts/qa/ty-qa-down.sh | 23 +++++++++ scripts/qa/ty-qa-key.sh | 19 +++++++ scripts/qa/ty-qa-state.sh | 33 ++++++++++++ scripts/qa/ty-qa-tui.sh | 25 +++++++++ scripts/qa/ty-qa-up.sh | 50 ++++++++++++++++++ 9 files changed, 346 insertions(+) create mode 100644 scripts/qa/README.md create mode 100755 scripts/qa/lib.sh create mode 100755 scripts/qa/ty-qa-agent.sh create mode 100755 scripts/qa/ty-qa-capture.sh create mode 100755 scripts/qa/ty-qa-down.sh create mode 100755 scripts/qa/ty-qa-key.sh create mode 100755 scripts/qa/ty-qa-state.sh create mode 100755 scripts/qa/ty-qa-tui.sh create mode 100755 scripts/qa/ty-qa-up.sh diff --git a/scripts/qa/README.md b/scripts/qa/README.md new file mode 100644 index 00000000..dce0ed30 --- /dev/null +++ b/scripts/qa/README.md @@ -0,0 +1,100 @@ +# ty TUI QA harness + +Drive the **real** ty TUI programmatically against a **throwaway, isolated** instance, +so we can QA real features (board, detail view, forms, executor panes) without +touching the live daemon, DB, or tasks. + +It's a simple loop: **up → key → assert state → down**. + +## Why + +Lots of features can only really be verified by driving the actual TUI — pane +join/break, status transitions, forms, keybindings, detail rendering. This harness +makes that scriptable and repeatable instead of a manual one-off. + +## Isolation + +Everything is namespaced off two env vars (set automatically by `lib.sh`): + +| | live instance | this harness | +|---|---|---| +| DB (`WORKTREE_DB_PATH`) | `~/.local/share/task/tasks.db` | `/tmp/ty-qa/tasks.db` | +| tmux (`WORKTREE_SESSION_ID`) | pid-based | `task-{ui,daemon}-qa` | +| projects (`projects_dir`) | `~/Projects` | `/tmp/ty-qa/projects` | + +Override location/id with `TY_QA_ROOT` and `TY_QA_SID`. + +## Quickstart + +```bash +scripts/qa/ty-qa-up.sh # build binary, fresh DB, register 'qa' project +scripts/qa/ty-qa-tui.sh # launch the real TUI in tmux session task-ui-qa +"$TY_BIN" create "hello" -p qa # (or: scripts/qa/ty-qa-up.sh prints the binary path) +scripts/qa/ty-qa-key.sh n # drive it: open the new-task form +scripts/qa/ty-qa-state.sh # assert: view == "new_task", etc. +scripts/qa/ty-qa-capture.sh # or eyeball the rendered screen +scripts/qa/ty-qa-down.sh # stop (add --purge to delete the DB) +``` + +To watch live while scripting: `tmux attach -t task-ui-qa`. + +## Asserting state + +The TUI runs with `--debug-state-file`, dumping JSON on every update. +`ty-qa-state.sh` prints a summary, or pass a `jq` filter: + +```bash +scripts/qa/ty-qa-state.sh '.view' # "dashboard" | "detail" | "new_task" | ... +scripts/qa/ty-qa-state.sh '.detail.has_panes' # true once panes are joined +scripts/qa/ty-qa-state.sh '.dashboard.selected_task_id' +``` + +## Keybindings (most useful for scripting) + +Board: `P`/`B`/`L`/`D` focus In-Progress/Backlog/Blocked/Done · `Up`/`Down` select · +`Enter` open detail · `n` new · `e` edit · `x` execute · `X` execute dangerous · +`!` toggle dangerous/safe · `S` change status · `/` filter · `?` help. + +Detail: `Enter` (from board) opens it and fires the real `joinTmuxPane` · `!` toggles +mode (fires the resume path → `agentSendTarget` "continue working") · `\` toggle shell +pane · `Esc` close. + +## Three tiers of test + +1. **No agent (default).** Most UI features — navigation, forms, filters, status + changes, rendering — need only the TUI over the isolated DB. Just `up` + `tui` + drive. + +2. **Live panes without the daemon** — `ty-qa-agent.sh `. Stands up a real + agent window for a task and points its DB row at it, so opening the task in the TUI + exercises the real `joinTmuxPane` / nudge / shell-pane code. Use this for pane and + executor-interaction QA. (Needed because ty's daemon lock is global — you can't run + a second daemon next to the live one.) + +3. **Full daemon** — only if no other ty daemon is running (stop the live one first), + then `WORKTREE_DB_PATH=… WORKTREE_SESSION_ID=qa "$TY_BIN" daemon`. Real end-to-end + executor spawning. Heaviest; depends on Claude auth/trust. + +## Worked example — the pane-routing regression + +Reproduces "executor stops working when the detail view is open": with the detail +view open, the agent pane is joined into the UI session and a nudge sent to the +window's pane `.0` lands in the shell instead of the agent. + +```bash +scripts/qa/ty-qa-up.sh +"$TY_BIN" create "pane routing" -p qa +scripts/qa/ty-qa-agent.sh 1 # live agent for task 1 +scripts/qa/ty-qa-tui.sh +scripts/qa/ty-qa-key.sh P Enter # open detail -> real joinTmuxPane +scripts/qa/ty-qa-state.sh '.detail.has_panes' # => true (panes joined) +# the agent pane is now in task-ui-qa; sending to the persisted claude_pane_id reaches +# the agent, while the old window-relative target (task-daemon-qa:task-1.0) does not. +scripts/qa/ty-qa-down.sh --purge +``` + +## Gotchas + +- The TUI must run **inside** `task-ui-` — `joinTmuxPane` attaches agent panes there. +- An agent's `pane_current_command` shows as the Claude **version string** (e.g. `2.1.162`), not `claude`. +- Claude's folder-trust prompt needs one `Enter` unless `~/.claude.json` already trusts the worktree (`ty-qa-agent.sh` sends it). +- Requires `tmux`, `go`, `python3`; `jq` and `sqlite3` for state filters / the agent helper. diff --git a/scripts/qa/lib.sh b/scripts/qa/lib.sh new file mode 100755 index 00000000..c32f5c42 --- /dev/null +++ b/scripts/qa/lib.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Shared config + helpers for the ty TUI QA harness. +# Source this from the other ty-qa-*.sh scripts. + +TY_QA_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TY_REPO_ROOT="$(git -C "$TY_QA_DIR" rev-parse --show-toplevel)" + +# Where the isolated instance lives, and its tmux session id. Override via env. +TY_QA_ROOT="${TY_QA_ROOT:-/tmp/ty-qa}" +TY_QA_SID="${TY_QA_SID:-qa}" + +# ty reads these — they fully isolate the DB and tmux sessions from the live instance. +export WORKTREE_DB_PATH="$TY_QA_ROOT/tasks.db" +export WORKTREE_SESSION_ID="$TY_QA_SID" + +# Derived handles. +TY_BIN="${TY_BIN:-$TY_QA_ROOT/ty}" +TY_QA_PROJECTS="$TY_QA_ROOT/projects" +TY_QA_STATE="$TY_QA_ROOT/uistate.json" +TY_UI_SESSION="task-ui-$TY_QA_SID" +TY_DAEMON_SESSION="task-daemon-$TY_QA_SID" +TY_UI_PANE="$TY_UI_SESSION:tui" + +# Run the isolated binary with the instance env. +ty() { "$TY_BIN" "$@"; } + +ty_qa_require_built() { + if [[ ! -x "$TY_BIN" ]]; then + echo "ty-qa: binary not built ($TY_BIN). Run scripts/qa/ty-qa-up.sh first." >&2 + exit 1 + fi +} diff --git a/scripts/qa/ty-qa-agent.sh b/scripts/qa/ty-qa-agent.sh new file mode 100755 index 00000000..e4fa66bf --- /dev/null +++ b/scripts/qa/ty-qa-agent.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Attach a LIVE agent window to an existing task WITHOUT the daemon, and point the +# task's DB row at it. This lets you QA detail-view / pane behaviour (the real +# joinTmuxPane, "continue working" nudges, the shell pane) without standing up a +# second daemon — ty's daemon lock is global, so a second one can't run beside +# the live instance. +# +# Usage: ty-qa-agent.sh [project] [agent-cmd] +# default agent-cmd launches Claude with a trivial "say READY and wait" prompt. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +ty_qa_require_built +command -v sqlite3 >/dev/null || { echo "sqlite3 required" >&2; exit 1; } + +TASK_ID="${1:?usage: ty-qa-agent.sh [project] [agent-cmd]}" +PROJECT="${2:-qa}" +AGENT_CMD="${3:-claude --dangerously-skip-permissions \"Say the single word READY and then wait. Do nothing else.\"}" +PROJECT_PATH="$TY_QA_PROJECTS/$PROJECT" +WT="$PROJECT_PATH/.task-worktrees/$TASK_ID-qa" +WIN="$TY_DAEMON_SESSION:task-$TASK_ID" + +echo "==> worktree $WT" +git -C "$PROJECT_PATH" worktree remove --force "$WT" 2>/dev/null || true +git -C "$PROJECT_PATH" worktree add -q "$WT" -b "qa-$TASK_ID" 2>/dev/null \ + || git -C "$PROJECT_PATH" worktree add -q "$WT" + +tmux has-session -t "$TY_DAEMON_SESSION" 2>/dev/null \ + || tmux new-session -d -s "$TY_DAEMON_SESSION" -n _placeholder "tail -f /dev/null" +tmux kill-window -t "$WIN" 2>/dev/null || true + +runner="$TY_QA_ROOT/agent-$TASK_ID.sh" +printf '#!/usr/bin/env bash\ncd %q\nexec %s\n' "$WT" "$AGENT_CMD" > "$runner" +chmod +x "$runner" + +tmux new-window -d -t "$TY_DAEMON_SESSION" -n "task-$TASK_ID" -c "$WT" "bash $runner" +sleep 6 +tmux send-keys -t "$WIN.0" Enter # accept Claude folder-trust prompt if shown +sleep 6 +tmux split-window -h -t "$WIN.0" -c "$WT" "${SHELL:-/bin/zsh}" +sleep 1 + +CLAUDE_PANE=$(tmux display-message -t "$WIN.0" -p '#{pane_id}') +SHELL_PANE=$(tmux display-message -t "$WIN.1" -p '#{pane_id}') +WIN_ID=$(tmux display-message -t "$WIN" -p '#{window_id}') + +sqlite3 "$WORKTREE_DB_PATH" "UPDATE tasks SET \ + status='processing', worktree_path='$WT', daemon_session='$TY_DAEMON_SESSION', \ + tmux_window_id='$WIN_ID', claude_pane_id='$CLAUDE_PANE', shell_pane_id='$SHELL_PANE' \ + WHERE id=$TASK_ID;" + +echo "==> task $TASK_ID is live: claude=$CLAUDE_PANE shell=$SHELL_PANE window=$WIN_ID" +echo " In the TUI: focus In-Progress (P), Enter to open -> fires the real joinTmuxPane." diff --git a/scripts/qa/ty-qa-capture.sh b/scripts/qa/ty-qa-capture.sh new file mode 100755 index 00000000..5b046cf7 --- /dev/null +++ b/scripts/qa/ty-qa-capture.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Print what the TUI currently shows. Optional arg: scrollback lines (default 0 = visible). +# +# Usage: ty-qa-capture.sh [scrollback-lines] +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +if [[ -n "${1:-}" ]]; then + tmux capture-pane -t "$TY_UI_PANE" -p -S "-$1" | sed 's/[[:space:]]*$//' +else + tmux capture-pane -t "$TY_UI_PANE" -p | sed 's/[[:space:]]*$//' +fi diff --git a/scripts/qa/ty-qa-down.sh b/scripts/qa/ty-qa-down.sh new file mode 100755 index 00000000..81936db1 --- /dev/null +++ b/scripts/qa/ty-qa-down.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Tear down the isolated instance: kill its tmux sessions and prune QA worktrees. +# Pass --purge to also delete the DB and project tree. +# +# Usage: ty-qa-down.sh [--purge] +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +tmux kill-session -t "$TY_UI_SESSION" 2>/dev/null || true +tmux kill-session -t "$TY_DAEMON_SESSION" 2>/dev/null || true + +if [[ -d "$TY_QA_PROJECTS" ]]; then + for p in "$TY_QA_PROJECTS"/*/; do + [[ -d "${p}.git" ]] && git -C "$p" worktree prune 2>/dev/null || true + done +fi + +if [[ "${1:-}" == "--purge" ]]; then + rm -rf "$TY_QA_ROOT" + echo "purged $TY_QA_ROOT" +else + echo "stopped sessions; DB kept at $WORKTREE_DB_PATH (use --purge to delete everything)" +fi diff --git a/scripts/qa/ty-qa-key.sh b/scripts/qa/ty-qa-key.sh new file mode 100755 index 00000000..e972cc50 --- /dev/null +++ b/scripts/qa/ty-qa-key.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Send keystrokes to the TUI. tmux key names (Enter, Escape, Up, Down) are sent +# as keys; bare strings are typed literally. +# +# Examples: +# ty-qa-key.sh P Enter # focus In-Progress column, open detail +# ty-qa-key.sh Down Down Enter # move selection, open +# ty-qa-key.sh n # new-task form +# ty-qa-key.sh '!' # toggle dangerous/safe mode +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +if [[ $# -eq 0 ]]; then + echo "usage: ty-qa-key.sh [key...]" >&2 + exit 1 +fi + +tmux send-keys -t "$TY_UI_PANE" "$@" +sleep "${TY_QA_KEY_DELAY:-0.4}" diff --git a/scripts/qa/ty-qa-state.sh b/scripts/qa/ty-qa-state.sh new file mode 100755 index 00000000..ba7e3220 --- /dev/null +++ b/scripts/qa/ty-qa-state.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Read the TUI's debug state (written by --debug-state-file). With no args, prints +# a summary. With a jq filter arg, runs it against the raw JSON. +# +# Examples: +# ty-qa-state.sh # summary +# ty-qa-state.sh '.detail.has_panes' # jq filter (requires jq) +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +if [[ ! -f "$TY_QA_STATE" ]]; then + echo "no debug state at $TY_QA_STATE — is the TUI running (ty-qa-tui.sh)?" >&2 + exit 1 +fi + +if [[ $# -ge 1 ]]; then + command -v jq >/dev/null || { echo "jq required for filters" >&2; exit 1; } + jq "$1" "$TY_QA_STATE" +else + python3 - "$TY_QA_STATE" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +dash = d.get("dashboard") or {} +det = d.get("detail") or {} +print("view :", d.get("view")) +print("focused_column :", dash.get("focused_column")) +print("selected_task :", dash.get("selected_task_id")) +if det: + print("detail.task_id :", det.get("task_id")) + print("detail.status :", det.get("status")) + print("detail.has_panes:", det.get("has_panes")) +PY +fi diff --git a/scripts/qa/ty-qa-tui.sh b/scripts/qa/ty-qa-tui.sh new file mode 100755 index 00000000..2a5d2b91 --- /dev/null +++ b/scripts/qa/ty-qa-tui.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Launch the real TUI for the isolated instance inside its own tmux session. +# The TUI must live in task-ui- because joinTmuxPane attaches agent panes there. +# +# Usage: ty-qa-tui.sh +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" +ty_qa_require_built + +COLS="${TY_QA_COLS:-230}" +ROWS="${TY_QA_ROWS:-55}" + +tmux kill-session -t "$TY_UI_SESSION" 2>/dev/null || true + +# Inside tmux, TMUX is set automatically, so ty runs the bubbletea TUI in-pane (runLocal). +# --debug-state-file dumps UI state as JSON on every update for assertions. +tmux new-session -d -s "$TY_UI_SESSION" -x "$COLS" -y "$ROWS" -n tui \ + "WORKTREE_DB_PATH='$WORKTREE_DB_PATH' WORKTREE_SESSION_ID='$WORKTREE_SESSION_ID' '$TY_BIN' --debug-state-file '$TY_QA_STATE'" + +sleep 4 +echo "==> TUI running: session '$TY_UI_SESSION', pane '$TY_UI_PANE'" +echo " attach : tmux attach -t $TY_UI_SESSION" +echo " drive : scripts/qa/ty-qa-key.sh " +echo " state : scripts/qa/ty-qa-state.sh" +echo " view : scripts/qa/ty-qa-capture.sh" diff --git a/scripts/qa/ty-qa-up.sh b/scripts/qa/ty-qa-up.sh new file mode 100755 index 00000000..8d7b43e0 --- /dev/null +++ b/scripts/qa/ty-qa-up.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Stand up an isolated ty instance: build the binary, create a throwaway DB, +# and register a git-backed project. Does NOT start the daemon (see README). +# +# Usage: ty-qa-up.sh [project-name] (default: qa) +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +PROJECT="${1:-qa}" +PROJECT_PATH="$TY_QA_PROJECTS/$PROJECT" + +echo "==> Building ty -> $TY_BIN" +mkdir -p "$TY_QA_ROOT" +( cd "$TY_REPO_ROOT" && go build -o "$TY_BIN" ./cmd/task ) + +echo "==> Fresh isolated DB at $WORKTREE_DB_PATH" +rm -f "$WORKTREE_DB_PATH" + +echo "==> projects_dir -> $TY_QA_PROJECTS" +mkdir -p "$TY_QA_PROJECTS" +ty settings set projects_dir "$TY_QA_PROJECTS" >/dev/null + +if [[ ! -d "$PROJECT_PATH/.git" ]]; then + echo "==> Creating git project '$PROJECT' at $PROJECT_PATH" + mkdir -p "$PROJECT_PATH" + git -C "$PROJECT_PATH" init -q + git -C "$PROJECT_PATH" config user.email qa@ty.local + git -C "$PROJECT_PATH" config user.name "ty qa" + echo "# $PROJECT" > "$PROJECT_PATH/README.md" + git -C "$PROJECT_PATH" add -A + git -C "$PROJECT_PATH" commit -qm init +fi +# Reuse your authed Claude config so agents (if you spawn any) don't need re-login. +ty projects create "$PROJECT" --path "$PROJECT_PATH" --claude-config-dir "$HOME/.claude" >/dev/null 2>&1 \ + || echo " (project '$PROJECT' already registered)" + +cat < Isolated ty instance ready + binary : $TY_BIN + db : $WORKTREE_DB_PATH + project : $PROJECT ($PROJECT_PATH) + sessions : $TY_UI_SESSION (tui) / $TY_DAEMON_SESSION (agents) + +Next: + scripts/qa/ty-qa-tui.sh # launch the TUI + scripts/qa/ty-qa-key.sh n # send keystrokes (here: open new-task form) + scripts/qa/ty-qa-state.sh # read TUI state (no screen-scraping) + scripts/qa/ty-qa-down.sh # tear down +EOF