fix(tui): paste-Enter must not auto-submit (#1073) + PTY QA harness

Two pieces:

**#1073 fix.** When a paste burst is currently being assembled, or when
the burst's Enter-suppression window is still open after a flush, the
trailing newline of the paste was firing `submit_input()` and the
in-flight burst buffer was getting destroyed by `clear_after_explicit_paste()`.
The PasteBurst module already exposed `newline_should_insert_instead_of_submit`
and `append_newline_if_active` for exactly this case, but no caller had
been wired up. Added `App::handle_composer_enter`, which checks the
suppression state and either appends `\n` to the burst buffer or inserts
it directly into the composer text — no submit. The `KeyCode::Enter`
arm in the composer event loop now dispatches through that helper.
Reproduces the Windows/PowerShell symptom from the report:
multi-line paste ending with `\n` no longer auto-submits AND the text
no longer leaks into the now-empty composer.

Four unit tests cover: active-burst Enter, post-flush window Enter,
normal Enter outside the window, and Enter with paste-burst detection
disabled (suppression must be off).

**PTY QA harness.** New `crates/tui/tests/support/qa_harness/` wraps
`portable-pty` (already a runtime dep) and `vt100` (new dev-dep) into
a small surface for scenarios that need a real PTY: spawn a binary,
send keys/paste/resize, parse the ANSI stream into a frame, assert
on visible text + filesystem state. The harness seals `$HOME` so
scenarios cannot read the developer's real `~/.deepseek/` and points
the base URL at 127.0.0.1:1 so no live request escapes. README under
`support/qa_harness/README.md` documents how to add a scenario.

Initial scenarios in `crates/tui/tests/qa_pty.rs`: smoke boot,
keystroke round-trip, and bracketed/unbracketed paste-with-trailing-
newline regression guards for #1073. The unbracketed scenario does
not deterministically reproduce the bug on macOS (single-syscall
PTY writes keep the burst continuously active), but the unit tests
above cover the path conclusively; the PTY test stands as a
regression guard for the visible-text invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-05-07 12:23:57 -05:00
parent 22442d58b1
commit 4e285595b0
11 changed files with 1148 additions and 1 deletions
Generated
+40
View File
@@ -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"
+1
View File
@@ -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"
+136
View File
@@ -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<String> {
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";
+1 -1
View File
@@ -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;
}
+167
View File
@@ -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(())
}
@@ -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=<localhost>`.
- **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 1015 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.
@@ -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<Instant>,
}
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
}
}
@@ -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<String>,
cwd: Option<PathBuf>,
env: HashMap<String, String>,
rows: u16,
cols: u16,
clear_env: bool,
seal_home: Option<PathBuf>,
}
impl HarnessBuilder {
pub fn new(program: impl Into<PathBuf>) -> 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<String>) -> Self {
self.args.push(a.into());
self
}
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.args.extend(args.into_iter().map(Into::into));
self
}
pub fn cwd(mut self, p: impl Into<PathBuf>) -> Self {
self.cwd = Some(p.into());
self
}
pub fn env(mut self, k: impl Into<String>, v: impl Into<String>) -> 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<PathBuf>) -> Self {
self.seal_home = Some(home.into());
self
}
pub fn spawn(self) -> Result<Harness> {
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<PathBuf>) -> 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<F>(&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_<name>`).
/// Tests should call this rather than hard-coding paths.
pub fn cargo_bin(name: &str) -> PathBuf {
// CARGO_BIN_EXE_<name> 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<i32> {
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<SealedWorkspace> {
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")
}
}
@@ -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<u8> {
let mut buf = [0u8; 4];
c.encode_utf8(&mut buf).as_bytes().to_vec()
}
pub fn enter() -> Vec<u8> {
b"\r".to_vec()
}
pub fn tab() -> Vec<u8> {
b"\t".to_vec()
}
pub fn shift_tab() -> Vec<u8> {
b"\x1b[Z".to_vec()
}
pub fn esc() -> Vec<u8> {
b"\x1b".to_vec()
}
pub fn backspace() -> Vec<u8> {
b"\x7f".to_vec()
}
pub fn ctrl(c: char) -> Vec<u8> {
// 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<u8> {
b"\x1b[A".to_vec()
}
pub fn down() -> Vec<u8> {
b"\x1b[B".to_vec()
}
pub fn right() -> Vec<u8> {
b"\x1b[C".to_vec()
}
pub fn left() -> Vec<u8> {
b"\x1b[D".to_vec()
}
pub fn text(s: &str) -> Vec<u8> {
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<u8> {
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<u8> {
text.replace('\n', "\r").as_bytes().to_vec()
}
}
@@ -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;
+229
View File
@@ -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<dyn MasterPty + Send>,
child: Box<dyn Child + Send + Sync>,
writer: Box<dyn Write + Send>,
buffer: Arc<Mutex<Vec<u8>>>,
reader_handle: Option<JoinHandle<()>>,
rows: u16,
cols: u16,
}
pub struct PtySessionBuilder<'a> {
program: &'a Path,
args: Vec<String>,
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<String>) -> Self {
self.args.push(a.into());
self
}
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
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<String>, v: impl Into<String>) -> 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<PtySession> {
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<Mutex<Vec<u8>>> = 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<u8> {
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<i32> {
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<i32> {
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();
}
}