From 879080dcf87a80f6a28f8d76197f124fa7bcda18 Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Wed, 29 Jul 2026 17:23:49 +0000 Subject: [PATCH 1/3] test(windows): ConPTY e2e coverage for the PR #124 parity fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a #[cfg(windows)] integration suite with no Cargo-feature gate, so the stock 'cargo test --workspace' CI leg on windows-latest runs it (the docker/bwrap suites stay feature-gated and Linux-only). Six tests, one per fix in #124: 1. run | consumer in a cmd.exe pipeline sees prompt EOF (win_spawn handle-inheritance allow-list; 90s bound vs the 15min idle timeout that masked the pre-fix hang). 2. Interactive pane's startup DSR (ESC[6n) is answered — new dsr_probe bin (src/bin) enables VT input on its own console, emits the query, and prints the reply; without take_pty_writes write-back it blocks forever. 3. tail --follow delivers a fast-exiting child's trailing bytes before the stream ends (exit_drained / DRAIN_GRACE). 4. die reaps the descendant tree (verified via Win32_Process with a per-test sentinel in the grandchild's command line). 5. signal SIGINT interrupts via ConPTY ETX (ping -t exits). 6. signal SIGBREAK is rejected with an actionable error. Each test runs an isolated session + socket dir and ties the daemon to the test process (AGENT_TUI_MONITOR_PARENT_PID) plus a best-effort 'daemon shutdown' on drop, so the file is parallel-safe and leaves no orphaned daemons. Verified: cargo check/clippy --all-targets -D warnings for x86_64-pc-windows-msvc, cargo fmt --check, cargo test -p agent-tui-integration (Linux aarch64; the new file is cfg-stripped there). The tests themselves require a Windows host — the windows-latest CI leg is the first execution environment. Co-authored-by: c1-squire-dev[bot] --- Cargo.lock | 1 + crates/agent-tui-integration/Cargo.toml | 5 + .../src/bin/dsr_probe.rs | 83 ++++ .../tests/windows_conpty.rs | 409 ++++++++++++++++++ 4 files changed, 498 insertions(+) create mode 100644 crates/agent-tui-integration/src/bin/dsr_probe.rs create mode 100644 crates/agent-tui-integration/tests/windows_conpty.rs diff --git a/Cargo.lock b/Cargo.lock index bdc61d7..03af5b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,6 +137,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "windows-sys 0.59.0", ] [[package]] diff --git a/crates/agent-tui-integration/Cargo.toml b/crates/agent-tui-integration/Cargo.toml index ace53c7..3d0404c 100644 --- a/crates/agent-tui-integration/Cargo.toml +++ b/crates/agent-tui-integration/Cargo.toml @@ -40,6 +40,11 @@ chrono = { workspace = true } [target.'cfg(unix)'.dependencies] nix = { workspace = true } +[target.'cfg(windows)'.dependencies] +# GetConsoleMode/SetConsoleMode for the dsr_probe test binary's VT-input setup +# (src/bin/dsr_probe.rs). Same windows-sys version the daemon uses. +windows-sys = { version = "0.59", features = ["Win32_System_Console", "Win32_Foundation"] } + [dev-dependencies] # Optional Docker-API features in the dev tier so `cargo test -p # agent-tui-integration --features docker` works without --no-default-features. diff --git a/crates/agent-tui-integration/src/bin/dsr_probe.rs b/crates/agent-tui-integration/src/bin/dsr_probe.rs new file mode 100644 index 0000000..e168b11 --- /dev/null +++ b/crates/agent-tui-integration/src/bin/dsr_probe.rs @@ -0,0 +1,83 @@ +//! Windows-only test probe for the ConPTY DSR (Device Status Report) write-back +//! path shipped in PR #124. +//! +//! Interactive programs emit `ESC[6n` at startup and block until the terminal +//! answers with a cursor-position report (`ESC[;R`). The daemon's +//! engine produces that reply (`Event::PtyWrite` → `take_pty_writes`) and the +//! reader loop writes it back to the ConPTY master. This probe is the minimal +//! such child: it enables VT input on its own console (so the reply arrives as +//! raw bytes, the way a real TUI reads it), emits `ESC[6n`, reads the reply, +//! and prints it as `DSR-REPLY:`. The companion test +//! (`tests/windows_conpty.rs::interactive_pane_answers_startup_dsr`) asserts +//! the reply arrives; without the write-back the read below blocks forever. +//! +//! Built as a bin of the integration crate so `cargo test` places it next to +//! `agent-tui.exe` under `target/debug/` on the Windows CI leg. On non-Windows +//! it compiles to an inert stub so the workspace builds everywhere. + +#![deny(unsafe_code)] + +#[cfg(windows)] +mod imp { + use std::io::{Read, Write}; + + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::System::Console::{ + GetConsoleMode, GetStdHandle, STD_INPUT_HANDLE, SetConsoleMode, + }; + + /// `ENABLE_VIRTUAL_TERMINAL_INPUT` (0x0200): deliver VT sequences — including + /// the terminal's DSR reply — as raw input bytes instead of cooked key events. + const ENABLE_VIRTUAL_TERMINAL_INPUT: u32 = 0x0200; + + pub(crate) fn run() -> i32 { + // SAFETY: standard Win32 console calls on our own process's stdin + // handle; the returned handle is borrowed, never closed here. + #[allow(unsafe_code)] + unsafe { + let h: HANDLE = GetStdHandle(STD_INPUT_HANDLE); + if h.is_null() { + return 2; + } + let mut mode: u32 = 0; + if GetConsoleMode(h, &raw mut mode) == 0 { + return 2; + } + // VT input only: no line buffering, no echo of the reply bytes. + if SetConsoleMode(h, ENABLE_VIRTUAL_TERMINAL_INPUT) == 0 { + return 2; + } + } + + print!("\x1b[6n"); + let _ = std::io::stdout().flush(); + + let mut buf = [0u8; 64]; + let mut n = 0usize; + let stdin = std::io::stdin(); + let mut lock = stdin.lock(); + while n < buf.len() { + match lock.read(&mut buf[n..=n]) { + Ok(0) | Err(_) => break, + Ok(m) => { + n += m; + if buf[n - 1] == b'R' { + break; + } + } + } + } + println!("DSR-REPLY:{}", String::from_utf8_lossy(&buf[..n])); + 0 + } +} + +fn main() { + #[cfg(windows)] + std::process::exit(imp::run()); + #[cfg(not(windows))] + { + eprintln!("dsr_probe is a Windows-only test probe"); + std::process::exit(2); + } +} diff --git a/crates/agent-tui-integration/tests/windows_conpty.rs b/crates/agent-tui-integration/tests/windows_conpty.rs new file mode 100644 index 0000000..9c1c4ba --- /dev/null +++ b/crates/agent-tui-integration/tests/windows_conpty.rs @@ -0,0 +1,409 @@ +//! Windows ConPTY end-to-end coverage for the parity work shipped in PR #124. +//! +//! These tests are `#[cfg(windows)]` with NO Cargo-feature gate, so the stock +//! `cargo test --workspace` CI leg on `windows-latest` runs them (the +//! docker/bwrap suites are feature-gated and stay Linux-only). Each test gets +//! an isolated session + socket dir, so the file is safe under cargo's +//! default parallel test threads, and every rig ties its daemon's lifetime to +//! the test process (`AGENT_TUI_MONITOR_PARENT_PID`) plus a best-effort +//! `daemon shutdown` on drop. +//! +//! Covered (each maps to a fix in #124): +//! 1. `run | consumer` in a `cmd.exe` pipeline sees prompt EOF (the `win_spawn` +//! handle-inheritance allow-list). +//! 2. An interactive pane's startup DSR (`ESC[6n`) is answered, unblocking +//! the child (engine `take_pty_writes` write-back via `dsr_probe`). +//! 3. `tail --follow` delivers a fast-exiting child's trailing bytes before +//! the stream ends (`exit_drained` / `DRAIN_GRACE`). +//! 4. `die` reaps the whole descendant tree (`taskkill /F /T`). +//! 5. `signal SIGINT` interrupts via ETX written to the ConPTY input. +//! 6. `signal SIGBREAK` is rejected with an actionable error (no false +//! success). + +#![cfg(windows)] + +use std::future::Future; +use std::path::PathBuf; +use std::process::Stdio; +use std::time::Duration; + +use agent_tui_integration::{agent_tui_binary, workspace_root}; +use anyhow::{Context, Result, bail}; + +/// Isolated per-test daemon context: unique session, private socket dir and +/// state home under %TEMP%. Drop shuts the daemon down best-effort and +/// removes the directory. +struct Rig { + session: String, + root: PathBuf, + socket_dir: PathBuf, + state_home: PathBuf, +} + +impl Rig { + fn new(name: &str) -> Result { + let id = uuid::Uuid::new_v4().simple().to_string(); + let root = std::env::temp_dir().join(format!("agent-tui-win-e2e-{name}-{}", &id[..8])); + let socket_dir = root.join("sock"); + let state_home = root.join("state"); + std::fs::create_dir_all(&socket_dir) + .with_context(|| format!("create {}", socket_dir.display()))?; + std::fs::create_dir_all(&state_home) + .with_context(|| format!("create {}", state_home.display()))?; + Ok(Self { + session: format!("e2e-{}", &id[..8]), + root, + socket_dir, + state_home, + }) + } + + /// Per-rig env every child process needs (CLI invocations and the + /// cmd.exe pipeline repro alike). + fn apply_env(&self, cmd: &mut tokio::process::Command) { + cmd.env("AGENT_TUI_SOCKET_DIR", &self.socket_dir) + .env("AGENT_TUI_ALLOWED_BINARIES", "*") + // Tie any lazy-spawned daemon's lifetime to this test process so + // a panicking or SIGKILLed test runner doesn't orphan a daemon. + .env( + "AGENT_TUI_MONITOR_PARENT_PID", + std::process::id().to_string(), + ) + .env("XDG_STATE_HOME", &self.state_home) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + } + + /// Base command with the per-rig env every invocation needs. `json` + /// selects the global `--json` flag; text surfaces (`tail`, streaming + /// verbs) are asserted on raw stdout instead. + fn cli(&self, json: bool) -> tokio::process::Command { + let bin = agent_tui_binary().expect("agent-tui binary built by cargo test"); + let mut cmd = tokio::process::Command::new(bin); + cmd.arg("--session") + .arg(&self.session) + .arg("--socket-dir") + .arg(&self.socket_dir); + if json { + cmd.arg("--json"); + } + self.apply_env(&mut cmd); + cmd + } + + async fn output(&self, json: bool, args: &[&str]) -> Result { + let mut cmd = self.cli(json); + Ok(cmd.args(args).output().await?) + } + + /// `cmd.exe /d /c