feat(tests): introduce pexpect for TUI integration testing#295
feat(tests): introduce pexpect for TUI integration testing#295Xiang-Pan wants to merge 1 commit intoasheshgoplani:mainfrom
Conversation
Add a proof-of-concept test suite using Python's pexpect to drive the real agent-deck binary in a pseudo-terminal. This fills the gap between unit-level Bubble Tea tests and the existing repterm-based E2E tests by exercising the actual TUI rendering and keyboard interaction flow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Introduces a proof-of-concept Python/pexpect integration test suite that exercises the real agent-deck binary in a pseudo-terminal to validate interactive TUI behavior (bridging the gap between in-process Bubble Tea unit tests and CLI-oriented E2E tests).
Changes:
- Added
tests/tui/pytest + pexpect smoke tests for basic TUI rendering and key interactions. - Added pytest fixtures to build the binary and spawn
agent-deckin a PTY with isolated HOME/profile. - Added
make test-tuitarget to run the TUI test suite.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
| tests/tui/test_basic_tui.py | Adds pexpect-driven smoke tests for help/version and basic TUI interactions. |
| tests/tui/conftest.py | Adds session build + PTY spawn fixtures with environment isolation. |
| tests/tui/requirements.txt | Pins pexpect and pytest dependencies for the suite. |
| Makefile | Adds a test-tui target to install deps and run pytest. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
|
|
||
| class TestVersionFlag: | ||
| """Sanity: --version exits cleanly.""" |
There was a problem hiding this comment.
The docstring says this is testing --version, but the test actually invokes the version subcommand. Either update the docstring/name to match the version command, or change the invocation to --version so the intent is consistent.
| """Sanity: --version exits cleanly.""" | |
| """Sanity: 'version' subcommand exits cleanly.""" |
|
|
||
| # Run pexpect-based TUI tests (requires Python 3 + pexpect) | ||
| test-tui: build | ||
| @cd tests/tui && pip install -q -r requirements.txt && pytest -v . |
There was a problem hiding this comment.
The test-tui target uses pip/pytest directly, which can pick up the wrong Python (or install into the global environment). Prefer python3 -m pip ... and python3 -m pytest ... (optionally with --user or a venv) to ensure consistent interpreter selection and avoid polluting system site-packages.
| @cd tests/tui && pip install -q -r requirements.txt && pytest -v . | |
| @cd tests/tui && python3 -m pip install -q -r requirements.txt && python3 -m pytest -v . |
| import shutil | ||
| import subprocess | ||
| import tempfile |
There was a problem hiding this comment.
shutil and tempfile are imported but never used in this file. Removing unused imports will keep the fixture module clean and avoids failing stricter linters.
| import shutil | |
| import subprocess | |
| import tempfile | |
| import subprocess |
| def _ensure_binary(): | ||
| """Build the binary if it doesn't exist or is stale.""" | ||
| if not os.path.isfile(BINARY_PATH): |
There was a problem hiding this comment.
The _ensure_binary() docstring claims it rebuilds when the binary is "stale", but the implementation only checks for existence. Either update the docstring or add a staleness check (e.g., compare mtimes vs. relevant Go sources) so the behavior matches the comment.
| def _ensure_binary(): | |
| """Build the binary if it doesn't exist or is stale.""" | |
| if not os.path.isfile(BINARY_PATH): | |
| def _binary_is_stale() -> bool: | |
| """Return True if the agent-deck binary is missing or older than any Go sources.""" | |
| if not os.path.isfile(BINARY_PATH): | |
| return True | |
| binary_mtime = os.path.getmtime(BINARY_PATH) | |
| for root, _dirs, files in os.walk(PROJECT_ROOT): | |
| # Skip the build output directory when scanning for source files. | |
| if os.path.commonpath([root, BUILD_DIR]) == BUILD_DIR: | |
| continue | |
| for filename in files: | |
| if filename.endswith(".go"): | |
| source_path = os.path.join(root, filename) | |
| if os.path.getmtime(source_path) > binary_mtime: | |
| return True | |
| return False | |
| def _ensure_binary(): | |
| """Build the binary if it doesn't exist or is stale.""" | |
| if _binary_is_stale(): |
| # Isolate test runs from the user's real profile / data. | ||
| env["AGENTDECK_PROFILE"] = "_tui_test" | ||
| # Provide a throwaway home so config files don't collide. | ||
| env["HOME"] = str(tmp_path) |
There was a problem hiding this comment.
spawn_deck() launches the TUI without disabling update checks. By default, agent-deck does a GitHub release check before starting the TUI (up to a 10s HTTP timeout on a cold cache), but the tests only wait 5s for the first render—this can make the suite flaky or hang behind an update prompt. Consider writing a minimal ~/.agent-deck/config.toml into the temporary HOME to set [updates] check_enabled=false (and set check_interval_hours to a non-zero value so it isn’t defaulted back to true) to make launches deterministic/offline-safe.
| env["HOME"] = str(tmp_path) | |
| env["HOME"] = str(tmp_path) | |
| # Disable online update checks for deterministic, offline-safe test runs. | |
| # This writes ~/.agent-deck/config.toml inside the temporary HOME. | |
| config_dir = tmp_path / ".agent-deck" | |
| config_dir.mkdir(parents=True, exist_ok=True) | |
| config_path = config_dir / "config.toml" | |
| if not config_path.exists(): | |
| config_path.write_text( | |
| "[updates]\n" | |
| "check_enabled = false\n" | |
| "check_interval_hours = 24\n", | |
| encoding="utf-8", | |
| ) |
| env = os.environ.copy() | ||
| # Isolate test runs from the user's real profile / data. | ||
| env["AGENTDECK_PROFILE"] = "_tui_test" | ||
| # Provide a throwaway home so config files don't collide. | ||
| env["HOME"] = str(tmp_path) | ||
| if env_overrides: | ||
| env.update(env_overrides) |
There was a problem hiding this comment.
To avoid false failures when running the test suite inside a tmux session, consider unsetting TMUX in the spawned process environment. The binary blocks launching the TUI when it detects it’s running inside an agentdeck_* tmux session, which can cause these tests to exit early on developers’ machines.
| def _factory(extra_args: str = "", env_overrides: dict | None = None): | ||
| env = os.environ.copy() | ||
| # Isolate test runs from the user's real profile / data. | ||
| env["AGENTDECK_PROFILE"] = "_tui_test" | ||
| # Provide a throwaway home so config files don't collide. | ||
| env["HOME"] = str(tmp_path) | ||
| if env_overrides: | ||
| env.update(env_overrides) | ||
|
|
||
| cmd = f"{BINARY_PATH} {extra_args}".strip() | ||
| child = pexpect.spawn( | ||
| cmd, | ||
| encoding="utf-8", |
There was a problem hiding this comment.
Building the command via string concatenation (f"{BINARY_PATH} {extra_args}") makes argument quoting brittle (e.g., paths with spaces) and makes it harder to extend the helper. Prefer accepting extra_args as a list[str]/tuple[str, ...] (or *args) and passing them to pexpect.spawn as separate argv elements so quoting is handled correctly.
| import pytest | ||
|
|
||
|
|
There was a problem hiding this comment.
pytest is imported but not used in this test module. Removing the unused import keeps the file tidy and avoids tripping linting tools.
| import pytest |
Summary
pexpect-based test suite (tests/tui/) that launches the realagent-deckbinary in a pseudo-terminal and validates TUI behavior through keyboard interaction and screen output assertionsrepterm-based E2E tests (which only exercise CLI/JSON commands, not the interactive TUI)make test-tuitarget for running the suiteMotivation
The current test suite has excellent coverage at two levels:
testing+testifyView()output, message handlingrepterm(TypeScript)add --quick --json,session start) over SSHMissing layer: Nobody launches the binary, renders the Bubble Tea TUI in a real terminal, presses keys, and verifies what appears on screen. Bugs in initialization, terminal capability negotiation, ANSI rendering, and cross-component keyboard routing are invisible to the existing tests.
pexpectis the standard tool for this class of testing — it spawns a process in a PTY, sends keystrokes, and pattern-matches on terminal output. It is mature (20+ years), well-documented, and widely used for CLI/TUI testing in projects like GDB, IPython, and Fish shell.What's included (proof-of-concept)
Tests:
--helpprints usage and exits 0versionexits 0?key opens help overlayqkey exits cleanlynkey opens new-session dialogEscapecloses the dialogFixtures (
conftest.py):make buildonce per pytest sessionspawn_deck()factory spawns the binary withAGENTDECK_PROFILE=_tui_testisolation (same safety pattern used by Go tests) and a throwaway$HOMEUsage
Alternatives considered
goexpect(Go)vhs(Charm)repterm(TS)teatest(Go, Charm)tea.Modelin-process, doesn't exercise real terminal renderingTest plan
make test-tuion Linux — all 7 tests passAGENTDECK_PROFILE=_tui_test)test-tuibe added tomake ci/ lefthook pre-push?🤖 Generated with Claude Code