diff --git a/Cargo.lock b/Cargo.lock index 8abc208d..d610576c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -185,6 +185,12 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "ascii" version = "1.1.0" @@ -1270,6 +1276,7 @@ dependencies = [ "unicode-segmentation", "unicode-width 0.2.0", "uuid", + "vt100", "wait-timeout", "windows", "wiremock", @@ -5297,6 +5304,39 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[package]] +name = "vt100" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de" +dependencies = [ + "itoa", + "log", + "unicode-width 0.1.14", + "vte", +] + +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 3b9cd503..34eb48c9 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -73,6 +73,7 @@ sha2 = "0.10" [dev-dependencies] wiremock = "0.6" pretty_assertions = "1.4" +vt100 = "0.15" [target.'cfg(target_os = "macos")'.dependencies] libc = "0.2" diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 4ba08a8d..163521b5 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -3438,6 +3438,44 @@ impl App { Some(input) } + /// Composer-Enter dispatch. Returns `Some(input)` when the press should + /// fire a submit; `None` when Enter was absorbed (paste-burst Enter + /// suppression — see #1073). + /// + /// Two suppression cases are handled here. Both are silent: nothing + /// visible happens beyond the text gaining a newline. + /// + /// 1. **Burst active.** A paste burst is currently being assembled in + /// `paste_burst.buffer`. The Enter is part of the paste content; + /// append `\n` to the buffer so the next flush includes it, do not + /// submit, and extend the suppression window so a follow-on Enter + /// (i.e. the *next* line of a multi-line paste) is also absorbed. + /// 2. **Window open after flush.** A burst just flushed into + /// `self.input`, but the suppression window is still alive. The + /// Enter is the trailing newline of that paste, not a submit gesture + /// by the user. Insert `\n` directly into the composer text and + /// re-arm the window. + /// + /// Outside both cases the call falls through to [`Self::submit_input`] + /// unchanged so normal Enter-to-send behaviour is preserved. + pub fn handle_composer_enter(&mut self) -> Option { + if self.use_paste_burst_detection { + let now = Instant::now(); + if self + .paste_burst + .newline_should_insert_instead_of_submit(now) + { + if !self.paste_burst.append_newline_if_active(now) { + self.insert_char('\n'); + self.paste_burst.extend_window(now); + } + self.needs_redraw = true; + return None; + } + } + self.submit_input() + } + /// When the composer input exceeds [`MAX_SUBMITTED_INPUT_CHARS`], write /// the full content to a timestamped paste file under /// `.deepseek/pastes/` and replace `self.input` with an `@`-mention @@ -4386,6 +4424,104 @@ mod tests { assert!(!app.paste_burst.is_active()); } + #[test] + fn enter_during_active_paste_burst_appends_newline_to_buffer_not_submit() { + // #1073: when chars are still being assembled into a paste burst and + // an Enter arrives (the trailing newline of the paste), the Enter + // must be absorbed into the burst buffer — not fired as a submit. + let mut app = App::new(test_options(false), &Config::default()); + app.use_paste_burst_detection = true; + let now = Instant::now(); + app.paste_burst.append_char_to_buffer('h', now); + app.paste_burst.append_char_to_buffer('i', now); + assert!(app.paste_burst.is_active()); + assert!(app.input.is_empty()); + + let result = app.handle_composer_enter(); + + assert!( + result.is_none(), + "Enter during active paste burst must not submit" + ); + let flushed = app.paste_burst.flush_before_modified_input(); + assert_eq!( + flushed.as_deref(), + Some("hi\n"), + "newline must land in the burst buffer so the next flush carries it" + ); + } + + #[test] + fn enter_inside_paste_burst_window_after_flush_inserts_newline_not_submit() { + // #1073: after a burst has flushed (text now in `input`), the + // suppression window stays open for ~120ms. An Enter arriving in + // that window is the trailing newline of the paste, not a user + // submit — insert it as a literal newline into the composer. + let mut app = App::new(test_options(false), &Config::default()); + app.use_paste_burst_detection = true; + app.input = "hello".to_string(); + app.cursor_position = "hello".chars().count(); + let now = Instant::now(); + app.paste_burst.extend_window(now); + assert!(!app.paste_burst.is_active()); + assert!( + app.paste_burst.newline_should_insert_instead_of_submit(now), + "suppression window should be open" + ); + + let result = app.handle_composer_enter(); + + assert!( + result.is_none(), + "Enter inside post-flush suppression window must not submit" + ); + assert_eq!( + app.input, "hello\n", + "newline must be inserted into the composer instead of firing a submit" + ); + } + + #[test] + fn enter_outside_any_paste_burst_window_submits_normally() { + // Regression guard: the suppression must not trip when the user + // actually wants to submit. + let mut app = App::new(test_options(false), &Config::default()); + app.use_paste_burst_detection = true; + app.input = "hello world".to_string(); + app.cursor_position = "hello world".chars().count(); + + let result = app.handle_composer_enter(); + + assert_eq!( + result.as_deref(), + Some("hello world"), + "Enter outside any paste burst window must submit normally" + ); + assert!( + app.input.is_empty(), + "submit_input should clear the composer" + ); + } + + #[test] + fn enter_with_paste_burst_detection_disabled_submits_normally() { + // When the user has explicitly turned off paste-burst detection + // (`bracketed_paste = false` is independent, this is the + // `paste_burst_detection` setting), the suppression must be + // skipped — otherwise turning it off would not actually turn it + // off. + let mut app = App::new(test_options(false), &Config::default()); + app.use_paste_burst_detection = false; + app.input = "ship it".to_string(); + app.cursor_position = "ship it".chars().count(); + let now = Instant::now(); + app.paste_burst.extend_window(now); + + let result = app.handle_composer_enter(); + + assert_eq!(result.as_deref(), Some("ship it")); + } + #[test] fn clipboard_text_paste_matches_bracketed_paste_state() { let text = "alpha\r\nbeta"; diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index b717568c..4bf91811 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2544,7 +2544,7 @@ async fn run_event_loop( { app.close_slash_menu(); } - if let Some(input) = app.submit_input() { + if let Some(input) = app.handle_composer_enter() { if handle_plan_choice(app, config, &engine_handle, &input).await? { continue; } diff --git a/crates/tui/tests/qa_pty.rs b/crates/tui/tests/qa_pty.rs new file mode 100644 index 00000000..348e5944 --- /dev/null +++ b/crates/tui/tests/qa_pty.rs @@ -0,0 +1,167 @@ +//! End-to-end TUI scenarios driven through a real pseudo-terminal. +//! +//! Each scenario boots `deepseek-tui` in a sealed workspace + sealed `$HOME`, +//! sends scripted input through the PTY, and asserts on the parsed terminal +//! frame and on the workspace filesystem. See `support/qa_harness/README.md` +//! for design + how-to. +//! +//! These tests are gated to Unix for now. Windows ConPTY behaviour (#923, +//! #765, #802) needs a separate audit before scenarios light up there. + +#![cfg(unix)] + +#[path = "support/qa_harness/mod.rs"] +mod qa_harness; + +use std::time::Duration; + +use qa_harness::harness::{Harness, make_sealed_workspace}; +use qa_harness::keys; + +const BOOT_TIMEOUT: Duration = Duration::from_secs(15); +const KEY_TIMEOUT: Duration = Duration::from_secs(5); + +fn boot_minimal() -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harness)> { + let ws = make_sealed_workspace()?; + let h = Harness::builder(Harness::cargo_bin("deepseek-tui")) + .cwd(ws.workspace()) + .seal_home(ws.home()) + // Provide a stub key so the onboarding screen is bypassed and the TUI + // boots straight into the composer. The harness never makes a live + // request — we just need the binary to think a key exists. + .env("DEEPSEEK_API_KEY", "ci-test-key-not-real") + // Force a known base URL so the doctor / model probe never escapes + // the box. 127.0.0.1:1 will refuse instantly. + .env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1") + .env("RUST_LOG", "warn") + .args([ + "--workspace", + ws.workspace().to_str().expect("utf-8 workspace path"), + "--no-project-config", + "--skip-onboarding", + ]) + .size(40, 140) + .spawn()?; + Ok((ws, h)) +} + +/// Smoke: the binary boots into an alt-screen, paints a composer, and the +/// header shows the project label. If this fails, the harness itself is +/// broken before we worry about any scenario. +#[test] +fn smoke_boot_paints_composer() -> anyhow::Result<()> { + let (_ws, mut h) = boot_minimal()?; + + // The composer panel border is labelled "Composer" — wait for it. + h.wait_for_text("Composer", BOOT_TIMEOUT)?; + + let f = h.frame(); + assert!( + f.any_visible_text(), + "expected non-empty frame after boot:\n{}", + f.debug_dump() + ); + + let _ = h.shutdown(); + Ok(()) +} + +/// Verifies the harness actually sees keystrokes — type a character and watch +/// it appear in the composer. This is the lowest-effort sanity check before +/// we lean on it for real scenarios. +#[test] +fn smoke_keystroke_reaches_composer() -> anyhow::Result<()> { + let (_ws, mut h) = boot_minimal()?; + h.wait_for_text("Composer", BOOT_TIMEOUT)?; + + h.send(keys::key::text("hello-from-pty"))?; + h.wait_for_text("hello-from-pty", KEY_TIMEOUT)?; + + let _ = h.shutdown(); + Ok(()) +} + +// =========================================================================== +// #1073 — pasting multi-line text with a trailing newline must NOT auto-submit +// =========================================================================== + +/// Bracketed-paste path: terminal wraps the payload in `ESC[200~ … ESC[201~`, +/// crossterm delivers an `Event::Paste(text)`, and the TUI's bracketed path +/// inserts it into the composer. The trailing `\n` should leave the composer +/// holding the text, not start a turn. +#[test] +fn paste_bracketed_with_trailing_newline_does_not_autosubmit() -> anyhow::Result<()> { + let (_ws, mut h) = boot_minimal()?; + h.wait_for_text("Composer", BOOT_TIMEOUT)?; + + // ~200 chars matching the original report. Trailing newline is the + // payload that historically triggered the auto-submit. + let payload = "first line of the multi-line paste body\n\ + second line continuing the paragraph until the end\n\ + third line that finishes with a trailing newline character\n"; + h.paste(payload)?; + h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(2))?; + + let f = h.frame(); + let dump = f.debug_dump(); + + // Auto-submit would replace the composer with a "working / thinking" + // status chip and clear the composer text. Either signal indicates the + // bug fired. + assert!( + !f.contains("Working") && !f.contains("thinking") && !f.contains("Thinking"), + "bracketed paste with trailing newline auto-submitted:\n{dump}" + ); + assert!( + f.contains("first line") || f.contains("third line"), + "pasted text should be visible in composer:\n{dump}" + ); + + let _ = h.shutdown(); + Ok(()) +} + +/// Unbracketed-paste path: terminal does NOT wrap the payload, so crossterm +/// sees the bytes as ordinary keystrokes. The TUI's `paste_burst` detector is +/// supposed to recognize the rapid stream and treat it as a single paste, but +/// historically the trailing `\r` (Enter) of the burst leaks through and +/// triggers submit while the burst flush dumps the text into the now-empty +/// composer. +/// +/// This is the Windows / PowerShell repro from #1073. +#[test] +fn paste_unbracketed_with_trailing_newline_does_not_autosubmit() -> anyhow::Result<()> { + let (_ws, mut h) = boot_minimal()?; + h.wait_for_text("Composer", BOOT_TIMEOUT)?; + // Let the boot fully settle so input handling is wired up. + h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(3))?; + + let payload = "first line of the multi-line paste body\n\ + second line continuing the paragraph until the end\n\ + third line that finishes with a trailing newline character\n"; + h.paste_unbracketed(payload)?; + h.wait_for_idle(Duration::from_millis(400), Duration::from_secs(3))?; + + let f = h.frame(); + let dump = f.debug_dump(); + eprintln!("=== AFTER UNBRACKETED PASTE ===\n{dump}"); + + // The visible signal of an auto-submit: the text appears in the + // transcript above the composer (sent as a user message). The composer + // is also typically reset, but #1073 reports residual text in addition + // to the auto-submit, so checking the transcript is more reliable. + let count = dump.matches("first line").count(); + assert!( + count <= 1, + "'first line' appears {count} times — auto-submitted into transcript AND \ + composer:\n{dump}" + ); + // And the pasted text should be visible somewhere. + assert!( + f.contains("first line"), + "pasted text should be on-screen somewhere:\n{dump}" + ); + + let _ = h.shutdown(); + Ok(()) +} diff --git a/crates/tui/tests/support/qa_harness/README.md b/crates/tui/tests/support/qa_harness/README.md new file mode 100644 index 00000000..b78cf77c --- /dev/null +++ b/crates/tui/tests/support/qa_harness/README.md @@ -0,0 +1,101 @@ +# PTY/frame-capture TUI QA harness + +Tiny helper for integration tests that need to drive `deepseek-tui` like a real +user typing in a real terminal — keys, paste, resize, plus assertions over the +parsed terminal frame and the workspace filesystem. + +## When to use this + +Reach for this harness when a bug only shows up in the **interactive** +terminal: paste behaviour, slash menus, mode switching, viewport rendering, +onboarding flow, resize, mouse capture. Anything where a `TestBackend` or a +unit test on the underlying state machine is too divorced from what the user +actually sees. + +For pure logic tests on `App`, `SkillRegistry`, the engine's `Op` / `Event` +plumbing, etc., keep using `crates/tui/src/.../tests` style unit tests. Don't +spin up a PTY just to assert a function returns the right value. + +## Anatomy + +- `pty.rs` — `PtySession`. Spawns a binary in a real PTY (via `portable-pty`), + pumps the child's stdout into a buffer on a background thread, exposes + `write_bytes`, `resize`, `drain`, `shutdown`. +- `frame.rs` — `Frame`. Wraps `vt100::Parser`. Feed bytes in, ask questions + out: `text()`, `row(y)`, `contains(s)`, `cursor()`, `debug_dump()`. +- `keys.rs` — byte-sequence builders for keys (`key::ctrl('c')`, + `key::enter()`, `key::tab()`, …) and for paste (`paste::bracketed(s)`, + `paste::unbracketed(s)`). +- `harness.rs` — `Harness`. Composes the two. Has `wait_for`, `wait_for_text`, + `wait_for_idle`, plus `make_sealed_workspace()` for a tempdir HOME. + +## Adding a new scenario + +1. Pick the smallest set of inputs that reproduce the user-visible behaviour. + If you can't reproduce it without a real LLM turn, the scenario probably + belongs in a unit test (or a `wiremock`-driven turn test) instead. + +2. Build a sealed workspace so the scenario doesn't see the developer's real + `~/.deepseek/` or API keys: + + ```rust + let ws = qa_harness::harness::make_sealed_workspace()?; + std::fs::write(ws.user_skills_dir().join("foo/SKILL.md"), "...")?; + ``` + +3. Spawn: + + ```rust + let mut h = Harness::builder(Harness::cargo_bin("deepseek-tui")) + .cwd(ws.workspace()) + .seal_home(ws.home()) + .env("DEEPSEEK_API_KEY", "ci-test-key") + .args(["--workspace", ws.workspace().to_str().unwrap(), + "--no-project-config", "--skip-onboarding"]) + .size(40, 120) + .spawn()?; + ``` + +4. Drive it: + + ```rust + h.wait_for_text("Composer", Duration::from_secs(10))?; + h.send(keys::key::ch('/'))?; + h.wait_for_text("/skills", Duration::from_secs(2))?; + ``` + +5. Assert: + + ```rust + let f = h.frame(); + assert!(f.contains("local-skill"), "frame:\n{}", f.debug_dump()); + ``` + +6. Always shut down cleanly at the end so the PTY cleanup runs even on a + failing assertion: + + ```rust + let _ = h.shutdown(); + ``` + +## Conventions + +- **Sealed env always.** No scenario should be able to see the real + `$HOME/.deepseek/` or contact `api.deepseek.com`. If a scenario *has* to do a + real model turn, route through a local `wiremock` or `tiny_http` fake + provider and pass `DEEPSEEK_BASE_URL=`. +- **Fail noisily.** When an assertion fails, print `frame.debug_dump()` so the + CI log shows the rendered screen, not just `assertion failed`. +- **Prefer `wait_for_text` over `sleep`.** A scenario that sleeps 500ms before + asserting will flake under CI load. A scenario that polls with a 10s + timeout is robust. +- **Expect output to be slow on first launch.** The TUI does config probing, + skill installation, and snapshot cleanup before showing the composer. + Give startup at least 10–15 seconds before timing out. + +## Platforms + +`portable-pty` works on macOS, Linux, and Windows (ConPTY). Today the +scenarios target Unix only — the test binary is gated with +`#![cfg(unix)]` until the Windows-specific input plumbing has been audited +under the same harness. diff --git a/crates/tui/tests/support/qa_harness/frame.rs b/crates/tui/tests/support/qa_harness/frame.rs new file mode 100644 index 00000000..3dfad0f0 --- /dev/null +++ b/crates/tui/tests/support/qa_harness/frame.rs @@ -0,0 +1,95 @@ +//! Terminal frame snapshot built from the PTY output stream. +//! +//! Wraps `vt100::Parser` so tests can feed bytes incrementally and ask +//! questions about the current screen contents (visible text, individual rows, +//! does-it-contain-this). + +use std::time::Instant; + +pub struct Frame { + parser: vt100::Parser, + captured_at: Option, +} + +impl Frame { + pub fn new(rows: u16, cols: u16) -> Self { + Self { + parser: vt100::Parser::new(rows, cols, 0), + captured_at: None, + } + } + + pub fn feed(&mut self, bytes: &[u8]) { + if bytes.is_empty() { + return; + } + self.parser.process(bytes); + self.captured_at = Some(Instant::now()); + } + + pub fn rows(&self) -> u16 { + self.parser.screen().size().0 + } + + pub fn cols(&self) -> u16 { + self.parser.screen().size().1 + } + + pub fn resize(&mut self, rows: u16, cols: u16) { + self.parser.set_size(rows, cols); + } + + /// Full visible screen as a single string with a `\n` between rows. + /// Trailing whitespace on each row is preserved so column-position + /// assertions stay meaningful. + pub fn text(&self) -> String { + self.parser.screen().contents() + } + + /// Single row of the screen, 0-indexed from the top, trimmed at the + /// right edge. Returns the empty string for out-of-range rows. + pub fn row(&self, y: u16) -> String { + if y >= self.rows() { + return String::new(); + } + let cols = self.cols(); + let mut out = String::with_capacity(cols as usize); + for x in 0..cols { + if let Some(cell) = self.parser.screen().cell(y, x) { + out.push_str(&cell.contents()); + } + } + out + } + + pub fn contains(&self, needle: &str) -> bool { + self.text().contains(needle) + } + + /// Whether any row of the screen has non-blank content. Used to detect a + /// fully detached / blank viewport. + pub fn any_visible_text(&self) -> bool { + self.text().chars().any(|c| !c.is_whitespace()) + } + + /// Cursor position as (row, col). Useful for asserting the composer + /// owns the cursor (#1073) or that it is not at row 0 mid-frame. + pub fn cursor(&self) -> (u16, u16) { + self.parser.screen().cursor_position() + } + + /// Render the screen to a string for diagnostic dumps when an + /// assertion fails. + pub fn debug_dump(&self) -> String { + let (rows, cols) = (self.rows(), self.cols()); + let mut out = String::new(); + out.push_str(&format!( + "== frame {rows}x{cols} cursor={:?} ==\n", + self.cursor() + )); + for y in 0..rows { + out.push_str(&format!("{y:>3} | {}\n", self.row(y).trim_end())); + } + out + } +} diff --git a/crates/tui/tests/support/qa_harness/harness.rs b/crates/tui/tests/support/qa_harness/harness.rs new file mode 100644 index 00000000..4c4b5676 --- /dev/null +++ b/crates/tui/tests/support/qa_harness/harness.rs @@ -0,0 +1,269 @@ +//! End-to-end harness composing [`PtySession`] + [`Frame`]. +//! +//! Tests build a [`Harness`] via [`Harness::builder`], drive the TUI with +//! [`Harness::send`] / [`Harness::paste`] / [`Harness::resize`], poll the +//! parsed terminal state with [`Harness::wait_for`], and assert on +//! [`Harness::frame`] / filesystem state. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, anyhow}; + +use super::{Frame, PtySession}; + +pub struct Harness { + pty: PtySession, + frame: Frame, + last_pump: Instant, +} + +pub struct HarnessBuilder { + program: PathBuf, + args: Vec, + cwd: Option, + env: HashMap, + rows: u16, + cols: u16, + clear_env: bool, + seal_home: Option, +} + +impl HarnessBuilder { + pub fn new(program: impl Into) -> Self { + Self { + program: program.into(), + args: Vec::new(), + cwd: None, + env: HashMap::new(), + rows: 40, + cols: 120, + clear_env: false, + seal_home: None, + } + } + + pub fn arg(mut self, a: impl Into) -> Self { + self.args.push(a.into()); + self + } + + pub fn args(mut self, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.args.extend(args.into_iter().map(Into::into)); + self + } + + pub fn cwd(mut self, p: impl Into) -> Self { + self.cwd = Some(p.into()); + self + } + + pub fn env(mut self, k: impl Into, v: impl Into) -> Self { + self.env.insert(k.into(), v.into()); + self + } + + pub fn size(mut self, rows: u16, cols: u16) -> Self { + self.rows = rows; + self.cols = cols; + self + } + + pub fn clear_env(mut self) -> Self { + self.clear_env = true; + self + } + + /// Point `$HOME` (and `XDG_*` defaults) at a fresh dir so the spawned + /// binary cannot read or mutate the developer's real `~/.deepseek/`. + pub fn seal_home(mut self, home: impl Into) -> Self { + self.seal_home = Some(home.into()); + self + } + + pub fn spawn(self) -> Result { + let mut builder = PtySession::builder(&self.program) + .args(self.args.iter().cloned()) + .size(self.rows, self.cols); + if self.clear_env { + builder = builder.clear_env(true); + } + if let Some(cwd) = self.cwd.as_deref() { + builder = builder.cwd(cwd); + } + if let Some(home) = self.seal_home.as_deref() { + std::fs::create_dir_all(home).context("create sealed HOME")?; + builder = builder + .env("HOME", home.to_string_lossy()) + .env("XDG_CONFIG_HOME", home.join(".config").to_string_lossy()) + .env("XDG_DATA_HOME", home.join(".local/share").to_string_lossy()) + .env("XDG_CACHE_HOME", home.join(".cache").to_string_lossy()) + .env("USERPROFILE", home.to_string_lossy()); + } + for (k, v) in &self.env { + builder = builder.env(k, v); + } + + let pty = builder.spawn().context("spawn PtySession")?; + let frame = Frame::new(self.rows, self.cols); + Ok(Harness { + pty, + frame, + last_pump: Instant::now(), + }) + } +} + +impl Harness { + pub fn builder(program: impl Into) -> HarnessBuilder { + HarnessBuilder::new(program) + } + + pub fn send(&mut self, bytes: impl AsRef<[u8]>) -> Result<()> { + self.pty.write_bytes(bytes.as_ref()) + } + + pub fn paste(&mut self, text: &str) -> Result<()> { + self.pty.write_bytes(&super::paste::bracketed(text)) + } + + pub fn paste_unbracketed(&mut self, text: &str) -> Result<()> { + self.pty.write_bytes(&super::paste::unbracketed(text)) + } + + pub fn resize(&mut self, rows: u16, cols: u16) -> Result<()> { + self.pty.resize(rows, cols)?; + self.frame.resize(rows, cols); + Ok(()) + } + + /// Pull whatever the child has written since last call into the frame + /// parser. Returns `true` if any new bytes arrived. + pub fn pump(&mut self) -> bool { + let bytes = self.pty.drain(); + let any = !bytes.is_empty(); + if any { + self.frame.feed(&bytes); + self.last_pump = Instant::now(); + } + any + } + + /// Pump output and return the parsed frame. Convenience for asserts. + pub fn frame(&mut self) -> &Frame { + self.pump(); + &self.frame + } + + /// Block (briefly sleeping) until `predicate(frame)` is true or `timeout` + /// elapses. Pumps the PTY on each tick. + pub fn wait_for(&mut self, mut predicate: F, timeout: Duration) -> Result<()> + where + F: FnMut(&Frame) -> bool, + { + let deadline = Instant::now() + timeout; + loop { + self.pump(); + if predicate(&self.frame) { + return Ok(()); + } + if Instant::now() >= deadline { + return Err(anyhow!( + "wait_for timed out after {:?}.\n{}", + timeout, + self.frame.debug_dump() + )); + } + std::thread::sleep(Duration::from_millis(40)); + } + } + + /// Wait for the literal substring to appear anywhere on the screen. + pub fn wait_for_text(&mut self, needle: &str, timeout: Duration) -> Result<()> { + let owned = needle.to_string(); + self.wait_for(move |f| f.contains(&owned), timeout) + } + + /// Wait for stable output: no new bytes for `quiet_for` consecutive + /// pump ticks, bounded by `max`. Useful for "let the UI settle". + pub fn wait_for_idle(&mut self, quiet_for: Duration, max: Duration) -> Result<()> { + let max_deadline = Instant::now() + max; + let mut quiet_since = Instant::now(); + loop { + if self.pump() { + quiet_since = Instant::now(); + } + if quiet_since.elapsed() >= quiet_for { + return Ok(()); + } + if Instant::now() >= max_deadline { + return Err(anyhow!( + "wait_for_idle: never settled within {:?}\n{}", + max, + self.frame.debug_dump() + )); + } + std::thread::sleep(Duration::from_millis(20)); + } + } + + /// Resolve a binary by Cargo bin-name (uses `CARGO_BIN_EXE_`). + /// Tests should call this rather than hard-coding paths. + pub fn cargo_bin(name: &str) -> PathBuf { + // CARGO_BIN_EXE_ is set by Cargo for binaries declared in the + // same crate as the integration test. For deepseek-tui the binary + // name is `deepseek-tui`. + let key = format!("CARGO_BIN_EXE_{name}"); + std::env::var_os(&key) + .map(PathBuf::from) + .unwrap_or_else(|| panic!("env {key} not set; is the binary declared in this crate?")) + } + + /// Best-effort cooperative shutdown. + pub fn shutdown(self) -> Option { + self.pty.shutdown(Duration::from_secs(2)) + } + + pub fn debug_dump(&mut self) -> String { + self.pump(); + self.frame.debug_dump() + } +} + +/// Construct a sealed-`HOME` workspace under a `tempfile::TempDir` so the +/// scenario can never read or mutate the developer's real config / skills. +pub fn make_sealed_workspace() -> Result { + let tmp = tempfile::TempDir::new().context("tempdir")?; + let workspace = tmp.path().join("workspace"); + let home = tmp.path().join("home"); + std::fs::create_dir_all(&workspace).context("mkdir workspace")?; + std::fs::create_dir_all(home.join(".deepseek")).context("mkdir home/.deepseek")?; + Ok(SealedWorkspace { + _tmp: tmp, + workspace, + home, + }) +} + +pub struct SealedWorkspace { + _tmp: tempfile::TempDir, + pub workspace: PathBuf, + pub home: PathBuf, +} + +impl SealedWorkspace { + pub fn workspace(&self) -> &Path { + &self.workspace + } + pub fn home(&self) -> &Path { + &self.home + } + pub fn user_skills_dir(&self) -> PathBuf { + self.home.join(".deepseek").join("skills") + } +} diff --git a/crates/tui/tests/support/qa_harness/keys.rs b/crates/tui/tests/support/qa_harness/keys.rs new file mode 100644 index 00000000..078631ac --- /dev/null +++ b/crates/tui/tests/support/qa_harness/keys.rs @@ -0,0 +1,81 @@ +//! Byte-sequence builders for keys, paste, and resize. +//! +//! These produce the raw bytes a real terminal would deliver to the child's +//! PTY slave. They match crossterm's input-decoding tables (keyboard +//! enhancement off, mouse capture off, bracketed paste on). + +/// Plain key press helpers. +pub mod key { + pub fn ch(c: char) -> Vec { + let mut buf = [0u8; 4]; + c.encode_utf8(&mut buf).as_bytes().to_vec() + } + + pub fn enter() -> Vec { + b"\r".to_vec() + } + + pub fn tab() -> Vec { + b"\t".to_vec() + } + + pub fn shift_tab() -> Vec { + b"\x1b[Z".to_vec() + } + + pub fn esc() -> Vec { + b"\x1b".to_vec() + } + + pub fn backspace() -> Vec { + b"\x7f".to_vec() + } + + pub fn ctrl(c: char) -> Vec { + // Ctrl+letter is the ASCII control byte: ctrl('a') = 0x01, ctrl('c') = 0x03, … + let upper = c.to_ascii_uppercase() as u8; + if upper.is_ascii_uppercase() { + vec![upper - b'A' + 1] + } else { + vec![] + } + } + + pub fn up() -> Vec { + b"\x1b[A".to_vec() + } + pub fn down() -> Vec { + b"\x1b[B".to_vec() + } + pub fn right() -> Vec { + b"\x1b[C".to_vec() + } + pub fn left() -> Vec { + b"\x1b[D".to_vec() + } + + pub fn text(s: &str) -> Vec { + s.as_bytes().to_vec() + } +} + +/// Bracketed-paste helpers. +/// +/// Wraps the payload in `ESC [ 2 0 0 ~` … `ESC [ 2 0 1 ~` so the receiver sees +/// a `crossterm::Event::Paste(text)` rather than a key-by-key stream. +pub mod paste { + pub fn bracketed(text: &str) -> Vec { + let mut out = b"\x1b[200~".to_vec(); + out.extend_from_slice(text.as_bytes()); + out.extend_from_slice(b"\x1b[201~"); + out + } + + /// Same as [`bracketed`] but does not wrap — simulates a terminal that + /// has bracketed paste disabled (e.g. some Windows PowerShell setups). + /// The child sees the bytes as ordinary keystrokes; an embedded `\n` + /// becomes an Enter press, which is what reproduces #1073. + pub fn unbracketed(text: &str) -> Vec { + text.replace('\n', "\r").as_bytes().to_vec() + } +} diff --git a/crates/tui/tests/support/qa_harness/mod.rs b/crates/tui/tests/support/qa_harness/mod.rs new file mode 100644 index 00000000..981f4b3b --- /dev/null +++ b/crates/tui/tests/support/qa_harness/mod.rs @@ -0,0 +1,28 @@ +//! Minimal PTY/frame-capture harness for TUI integration tests. +//! +//! Spawns the `deepseek-tui` binary in a real pseudo-terminal, sends scripted +//! keystrokes / paste / resize, and parses the ANSI output stream into terminal +//! frames so tests can assert on visible text and on the filesystem. +//! +//! Tests opt in via: +//! ```ignore +//! #[path = "support/qa_harness/mod.rs"] +//! mod qa_harness; +//! use qa_harness::{Harness, keys}; +//! ``` +//! +//! Design notes live in `README.md` next to this module. + +#![allow(dead_code)] + +pub mod frame; +pub mod harness; +pub mod keys; +pub mod pty; + +pub use frame::Frame; +#[allow(unused_imports)] +pub use harness::{Harness, HarnessBuilder}; +#[allow(unused_imports)] +pub use keys::{key, paste}; +pub use pty::PtySession; diff --git a/crates/tui/tests/support/qa_harness/pty.rs b/crates/tui/tests/support/qa_harness/pty.rs new file mode 100644 index 00000000..55c90004 --- /dev/null +++ b/crates/tui/tests/support/qa_harness/pty.rs @@ -0,0 +1,229 @@ +//! Pseudo-terminal session wrapping `portable-pty`. +//! +//! Spawns a binary in a real PTY, pumps the child's stdout into an in-memory +//! buffer on a background thread, and exposes write/resize/wait/kill primitives +//! the test harness composes. +//! +//! The reader thread is necessary because `portable-pty`'s reader is blocking +//! and the test thread must remain free to send input + poll for screen +//! changes. + +use anyhow::{Context, Result, anyhow}; +use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system}; +use std::io::{Read, Write}; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +pub struct PtySession { + master: Box, + child: Box, + writer: Box, + buffer: Arc>>, + reader_handle: Option>, + rows: u16, + cols: u16, +} + +pub struct PtySessionBuilder<'a> { + program: &'a Path, + args: Vec, + cwd: Option<&'a Path>, + env: Vec<(String, String)>, + rows: u16, + cols: u16, + clear_env: bool, +} + +impl<'a> PtySessionBuilder<'a> { + pub fn new(program: &'a Path) -> Self { + Self { + program, + args: Vec::new(), + cwd: None, + env: Vec::new(), + rows: 40, + cols: 120, + clear_env: false, + } + } + + pub fn arg(mut self, a: impl Into) -> Self { + self.args.push(a.into()); + self + } + + pub fn args(mut self, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.args.extend(args.into_iter().map(Into::into)); + self + } + + pub fn cwd(mut self, p: &'a Path) -> Self { + self.cwd = Some(p); + self + } + + pub fn env(mut self, k: impl Into, v: impl Into) -> Self { + self.env.push((k.into(), v.into())); + self + } + + /// Wipe the inherited environment before applying explicit `env(..)` + /// overrides. Use for sealed scenarios that must not see the developer's + /// real `~/.deepseek/`, `$HOME`, or API keys. + pub fn clear_env(mut self, yes: bool) -> Self { + self.clear_env = yes; + self + } + + pub fn size(mut self, rows: u16, cols: u16) -> Self { + self.rows = rows; + self.cols = cols; + self + } + + pub fn spawn(self) -> Result { + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: self.rows, + cols: self.cols, + pixel_width: 0, + pixel_height: 0, + }) + .context("openpty")?; + + let mut cmd = CommandBuilder::new(self.program); + for a in &self.args { + cmd.arg(a); + } + if let Some(cwd) = self.cwd { + cmd.cwd(cwd); + } + if self.clear_env { + cmd.env_clear(); + } + // TERM must be set to something xterm-ish so crossterm enables the + // capabilities the TUI assumes (256 color, bracketed paste, …). + cmd.env("TERM", "xterm-256color"); + cmd.env("COLORTERM", "truecolor"); + for (k, v) in &self.env { + cmd.env(k, v); + } + + let child = pair.slave.spawn_command(cmd).context("spawn child")?; + // Drop the slave end so EOF propagates correctly when the child exits. + drop(pair.slave); + + let mut reader = pair.master.try_clone_reader().context("clone reader")?; + let writer = pair.master.take_writer().context("take writer")?; + + let buffer: Arc>> = Arc::new(Mutex::new(Vec::new())); + let buf_thread = Arc::clone(&buffer); + let reader_handle = thread::Builder::new() + .name("qa-pty-reader".into()) + .spawn(move || { + let mut chunk = [0u8; 8192]; + loop { + match reader.read(&mut chunk) { + Ok(0) => break, + Ok(n) => { + if let Ok(mut b) = buf_thread.lock() { + b.extend_from_slice(&chunk[..n]); + } + } + Err(_) => break, + } + } + }) + .context("reader thread")?; + + Ok(PtySession { + master: pair.master, + child, + writer, + buffer, + reader_handle: Some(reader_handle), + rows: self.rows, + cols: self.cols, + }) + } +} + +impl PtySession { + pub fn builder(program: &Path) -> PtySessionBuilder<'_> { + PtySessionBuilder::new(program) + } + + pub fn write_bytes(&mut self, bytes: &[u8]) -> Result<()> { + self.writer.write_all(bytes).context("pty write")?; + self.writer.flush().context("pty flush")?; + Ok(()) + } + + pub fn resize(&mut self, rows: u16, cols: u16) -> Result<()> { + self.master + .resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| anyhow!("pty resize failed: {e}"))?; + self.rows = rows; + self.cols = cols; + Ok(()) + } + + pub fn size(&self) -> (u16, u16) { + (self.rows, self.cols) + } + + /// Drain any bytes the reader thread has pushed into the buffer. Returns + /// the bytes read this call. Non-blocking — returns immediately even if + /// the buffer is empty. + pub fn drain(&mut self) -> Vec { + let mut b = self.buffer.lock().unwrap_or_else(|e| e.into_inner()); + std::mem::take(&mut *b) + } + + /// Block until the child exits or the deadline passes. Returns the exit + /// status if reaped, or `None` on timeout. + pub fn wait_until(&mut self, deadline: Instant) -> Option { + loop { + match self.child.try_wait() { + Ok(Some(status)) => return Some(status.exit_code() as i32), + Ok(None) => {} + Err(_) => return None, + } + if Instant::now() >= deadline { + return None; + } + thread::sleep(Duration::from_millis(20)); + } + } + + /// Send SIGTERM-equivalent and wait briefly. Returns the exit status if + /// the child reaped within `grace`, or `None` otherwise (in which case + /// `kill_hard` is called as a last resort). + pub fn shutdown(mut self, grace: Duration) -> Option { + let _ = self.child.kill(); + let exit = self.wait_until(Instant::now() + grace); + if let Some(handle) = self.reader_handle.take() { + // Don't block on the reader thread forever — it exits on EOF. + let _ = handle.join(); + } + exit + } +} + +impl Drop for PtySession { + fn drop(&mut self) { + let _ = self.child.kill(); + } +}