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:
Generated
+40
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 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.
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user