Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions scripts/qa/README.md
Original file line number Diff line number Diff line change
@@ -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 <task-id>`. 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-<sid>` — `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.
32 changes: 32 additions & 0 deletions scripts/qa/lib.sh
Original file line number Diff line number Diff line change
@@ -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
}
52 changes: 52 additions & 0 deletions scripts/qa/ty-qa-agent.sh
Original file line number Diff line number Diff line change
@@ -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 <task-id> [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 <task-id> [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."
12 changes: 12 additions & 0 deletions scripts/qa/ty-qa-capture.sh
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions scripts/qa/ty-qa-down.sh
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions scripts/qa/ty-qa-key.sh
Original file line number Diff line number Diff line change
@@ -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> [key...]" >&2
exit 1
fi

tmux send-keys -t "$TY_UI_PANE" "$@"
sleep "${TY_QA_KEY_DELAY:-0.4}"
33 changes: 33 additions & 0 deletions scripts/qa/ty-qa-state.sh
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions scripts/qa/ty-qa-tui.sh
Original file line number Diff line number Diff line change
@@ -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-<sid> 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 <keys>"
echo " state : scripts/qa/ty-qa-state.sh"
echo " view : scripts/qa/ty-qa-capture.sh"
50 changes: 50 additions & 0 deletions scripts/qa/ty-qa-up.sh
Original file line number Diff line number Diff line change
@@ -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 <<EOF

==> 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
Loading