| 1 | # PTY/frame-capture TUI QA harness |
| 2 | |
| 3 | Tiny helper for integration tests that need to drive `deepseek-tui` like a real |
| 4 | user typing in a real terminal — keys and paste, plus assertions over the |
| 5 | parsed terminal frame and the workspace filesystem. |
| 6 | |
| 7 | ## When to use this |
| 8 | |
| 9 | Reach for this harness when a bug only shows up in the **interactive** |
| 10 | terminal: paste behaviour, slash menus, mode switching, viewport rendering, |
| 11 | onboarding flow, mouse capture. Anything where a `TestBackend` or a |
| 12 | unit test on the underlying state machine is too divorced from what the user |
| 13 | actually sees. |
| 14 | |
| 15 | For pure logic tests on `App`, `SkillRegistry`, the engine's `Op` / `Event` |
| 16 | plumbing, etc., keep using `crates/tui/src/.../tests` style unit tests. Don't |
| 17 | spin up a PTY just to assert a function returns the right value. |
| 18 | |
| 19 | ## Anatomy |
| 20 | |
| 21 | - `pty.rs` — `PtySession`. Spawns a binary in a real PTY (via `portable-pty`), |
| 22 | pumps the child's stdout into a buffer on a background thread, exposes |
| 23 | `write_bytes`, `drain`, `shutdown`. |
| 24 | - `frame.rs` — `Frame`. Wraps `rio-vt`. Feed bytes in, ask questions |
| 25 | out: `text()`, `row(y)`, `contains(s)`, `cursor()`, `debug_dump()`. |
| 26 | - `keys.rs` — byte-sequence builders for keys (`key::ch('/')`, |
| 27 | `key::enter()`, `key::text("hello")`) and for paste (`paste::bracketed(s)`, |
| 28 | `paste::unbracketed(s)`). |
| 29 | - `harness.rs` — `Harness`. Composes the two. Has `wait_for`, `wait_for_text`, |
| 30 | `wait_for_idle`, plus `make_sealed_workspace()` for a tempdir HOME. |
| 31 | |
| 32 | ## Adding a new scenario |
| 33 | |
| 34 | 1. Pick the smallest set of inputs that reproduce the user-visible behaviour. |
| 35 | If you can't reproduce it without a real LLM turn, the scenario probably |
| 36 | belongs in a unit test (or a `wiremock`-driven turn test) instead. |
| 37 | |
| 38 | 2. Build a sealed workspace so the scenario doesn't see the developer's real |
| 39 | `~/.deepseek/` or API keys: |
| 40 | |
| 41 | ```rust |
| 42 | let ws = qa_harness::harness::make_sealed_workspace()?; |
| 43 | std::fs::write(ws.user_skills_dir().join("foo/SKILL.md"), "...")?; |
| 44 | ``` |
| 45 | |
| 46 | 3. Spawn: |
| 47 | |
| 48 | ```rust |
| 49 | let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui")) |
| 50 | .cwd(ws.workspace()) |
| 51 | .seal_home(ws.home()) |
| 52 | .env("DEEPSEEK_API_KEY", "ci-test-key") |
| 53 | .args(["--workspace", ws.workspace().to_str().unwrap(), |
| 54 | "--no-project-config", "--skip-onboarding"]) |
| 55 | .size(40, 120) |
| 56 | .spawn()?; |
| 57 | ``` |
| 58 | |
| 59 | 4. Drive it: |
| 60 | |
| 61 | ```rust |
| 62 | h.wait_for_text("Composer", Duration::from_secs(10))?; |
| 63 | h.send(keys::key::ch('/'))?; |
| 64 | h.wait_for_text("/skills", Duration::from_secs(2))?; |
| 65 | ``` |
| 66 | |
| 67 | 5. Assert: |
| 68 | |
| 69 | ```rust |
| 70 | let f = h.frame(); |
| 71 | assert!(f.contains("local-skill"), "frame:\n{}", f.debug_dump()); |
| 72 | ``` |
| 73 | |
| 74 | 6. Always shut down cleanly at the end so the PTY cleanup runs even on a |
| 75 | failing assertion: |
| 76 | |
| 77 | ```rust |
| 78 | let _ = h.shutdown(); |
| 79 | ``` |
| 80 | |
| 81 | ## Conventions |
| 82 | |
| 83 | - **Sealed env always.** No scenario should be able to see the real |
| 84 | `$HOME/.deepseek/` or contact `api.deepseek.com`. If a scenario *has* to do a |
| 85 | real model turn, route through a local `wiremock` or `tiny_http` fake |
| 86 | provider and pass `DEEPSEEK_BASE_URL=<localhost>`. |
| 87 | - **Fail noisily.** When an assertion fails, print `frame.debug_dump()` so the |
| 88 | CI log shows the rendered screen, not just `assertion failed`. |
| 89 | - **Prefer `wait_for_text` over `sleep`.** A scenario that sleeps 500ms before |
| 90 | asserting will flake under CI load. A scenario that polls with a 10s |
| 91 | timeout is robust. |
| 92 | - **Expect output to be slow on first launch.** The TUI does config probing, |
| 93 | skill installation, and snapshot cleanup before showing the composer. |
| 94 | Give startup at least 10–15 seconds before timing out. |
| 95 | |
| 96 | ## Platforms |
| 97 | |
| 98 | `portable-pty` works on macOS, Linux, and Windows (ConPTY). Today the |
| 99 | scenarios target Unix only — the test binary is gated with |
| 100 | `#![cfg(unix)]` until the Windows-specific input plumbing has been audited |
| 101 | under the same harness. |
| 102 |