Skip to main content

ftui_pty/
lib.rs

1#![forbid(unsafe_code)]
2
3//! PTY utilities for subprocess-based integration tests.
4//!
5//! # Why this exists
6//! FrankenTUI needs PTY-backed tests to validate terminal cleanup behavior and
7//! to safely capture subprocess output without corrupting the parent terminal.
8//!
9//! # Safety / policy
10//! - This crate forbids unsafe code (`#![forbid(unsafe_code)]`).
11//! - We use `portable-pty` as a safe, cross-platform abstraction.
12//!
13//! # Modules
14//!
15//! - [`pty_process`] - Shell process management with `spawn()`, `kill()`, `is_alive()`.
16//! - [`virtual_terminal`] - In-memory terminal state machine for testing.
17//! - [`input_forwarding`] - Key-to-sequence conversion and paste handling.
18//! - [`ws_bridge`] - WebSocket-to-PTY bridge for remote FrankenTerm sessions.
19//!
20//! # Role in FrankenTUI
21//! `ftui-pty` underpins end-to-end and integration tests that need real PTYs.
22//! It is used by the harness and test suites to validate behavior that cannot
23//! be simulated with pure unit tests.
24//!
25//! # How it fits in the system
26//! This crate does not participate in the runtime or render pipeline directly.
27//! Instead, it provides test infrastructure used by `ftui-harness` and E2E
28//! scripts to verify correctness and cleanup behavior.
29
30/// Input forwarding: key events to ANSI sequences.
31pub mod input_forwarding;
32
33/// PTY process management for shell spawning and lifecycle control.
34pub mod pty_process;
35
36/// In-memory virtual terminal state machine for testing.
37pub mod virtual_terminal;
38
39/// WebSocket-to-PTY bridge for remote terminal sessions.
40pub mod ws_bridge;
41
42use std::fmt;
43use std::io::{self, Read, Write};
44use std::sync::mpsc;
45use std::thread;
46use std::time::{Duration, Instant};
47
48use ftui_core::terminal_session::SessionOptions;
49use portable_pty::{CommandBuilder, ExitStatus, PtySize};
50
51/// Configuration for PTY-backed test sessions.
52#[derive(Debug, Clone)]
53pub struct PtyConfig {
54    /// PTY width in columns.
55    pub cols: u16,
56    /// PTY height in rows.
57    pub rows: u16,
58    /// TERM to set in the child (defaults to xterm-256color).
59    pub term: Option<String>,
60    /// Extra environment variables to set in the child.
61    pub env: Vec<(String, String)>,
62    /// Optional test name for logging context.
63    pub test_name: Option<String>,
64    /// Enable structured PTY logging to stderr.
65    pub log_events: bool,
66    /// Maximum time to wait for PTY input writes before failing.
67    pub input_write_timeout: Duration,
68}
69
70impl Default for PtyConfig {
71    fn default() -> Self {
72        Self {
73            cols: 80,
74            rows: 24,
75            term: Some("xterm-256color".to_string()),
76            env: Vec::new(),
77            test_name: None,
78            log_events: true,
79            input_write_timeout: DEFAULT_INPUT_WRITE_TIMEOUT,
80        }
81    }
82}
83
84impl PtyConfig {
85    /// Override PTY dimensions.
86    #[must_use]
87    pub fn with_size(mut self, cols: u16, rows: u16) -> Self {
88        self.cols = cols;
89        self.rows = rows;
90        self
91    }
92
93    /// Override TERM in the child.
94    #[must_use]
95    pub fn with_term(mut self, term: impl Into<String>) -> Self {
96        self.term = Some(term.into());
97        self
98    }
99
100    /// Add an environment variable in the child.
101    #[must_use]
102    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
103        self.env.push((key.into(), value.into()));
104        self
105    }
106
107    /// Attach a test name for logging context.
108    #[must_use]
109    pub fn with_test_name(mut self, name: impl Into<String>) -> Self {
110        self.test_name = Some(name.into());
111        self
112    }
113
114    /// Enable or disable log output.
115    #[must_use]
116    pub fn logging(mut self, enabled: bool) -> Self {
117        self.log_events = enabled;
118        self
119    }
120
121    /// Override the PTY input write timeout.
122    #[must_use]
123    pub fn with_input_write_timeout(mut self, timeout: Duration) -> Self {
124        self.input_write_timeout = timeout;
125        self
126    }
127}
128
129/// Options for `read_until_with_options`.
130#[derive(Debug, Clone)]
131pub struct ReadUntilOptions {
132    /// Maximum time to wait for the pattern.
133    pub timeout: Duration,
134    /// Number of retries on transient errors (0 = no retries).
135    pub max_retries: u32,
136    /// Delay between retries.
137    pub retry_delay: Duration,
138    /// Minimum bytes to collect before considering a match (0 = no minimum).
139    pub min_bytes: usize,
140}
141
142impl Default for ReadUntilOptions {
143    fn default() -> Self {
144        Self {
145            timeout: Duration::from_secs(5),
146            max_retries: 0,
147            retry_delay: Duration::from_millis(100),
148            min_bytes: 0,
149        }
150    }
151}
152
153impl ReadUntilOptions {
154    /// Create options with specified timeout.
155    pub fn with_timeout(timeout: Duration) -> Self {
156        Self {
157            timeout,
158            ..Default::default()
159        }
160    }
161
162    /// Set maximum retries on transient errors.
163    #[must_use]
164    pub fn retries(mut self, count: u32) -> Self {
165        self.max_retries = count;
166        self
167    }
168
169    /// Set delay between retries.
170    #[must_use]
171    pub fn retry_delay(mut self, delay: Duration) -> Self {
172        self.retry_delay = delay;
173        self
174    }
175
176    /// Set minimum bytes to collect before matching.
177    #[must_use]
178    pub fn min_bytes(mut self, bytes: usize) -> Self {
179        self.min_bytes = bytes;
180        self
181    }
182}
183
184/// Expected cleanup sequences after a session ends.
185#[derive(Debug, Clone)]
186pub struct CleanupExpectations {
187    pub sgr_reset: bool,
188    pub show_cursor: bool,
189    pub alt_screen: bool,
190    pub mouse: bool,
191    pub bracketed_paste: bool,
192    pub focus_events: bool,
193    pub kitty_keyboard: bool,
194}
195
196impl CleanupExpectations {
197    /// Strict expectations for maximum cleanup validation.
198    pub fn strict() -> Self {
199        Self {
200            sgr_reset: true,
201            show_cursor: true,
202            alt_screen: true,
203            mouse: true,
204            bracketed_paste: true,
205            focus_events: true,
206            kitty_keyboard: true,
207        }
208    }
209
210    /// Build expectations from the session options used by the child.
211    pub fn for_session(options: &SessionOptions) -> Self {
212        Self {
213            sgr_reset: false,
214            show_cursor: true,
215            alt_screen: options.alternate_screen,
216            mouse: options.mouse_capture,
217            bracketed_paste: options.bracketed_paste,
218            focus_events: options.focus_events,
219            kitty_keyboard: options.kitty_keyboard,
220        }
221    }
222}
223
224pub(crate) const DEFAULT_INPUT_WRITE_TIMEOUT: Duration = Duration::from_secs(5);
225
226pub(crate) fn normalize_line_input(line: &[u8]) -> Vec<u8> {
227    let trimmed = if line.last() == Some(&b'\n') {
228        &line[..line.len().saturating_sub(1)]
229    } else {
230        line
231    };
232
233    let mut normalized = Vec::with_capacity(trimmed.len() + 2);
234    normalized.extend_from_slice(trimmed);
235    if normalized.last() == Some(&b'\r') {
236        normalized.push(b'\n');
237    } else {
238        normalized.extend_from_slice(b"\r\n");
239    }
240    normalized
241}
242
243enum WriterCommand {
244    Write {
245        bytes: Vec<u8>,
246        response: mpsc::Sender<io::Result<()>>,
247    },
248    Flush {
249        response: mpsc::Sender<io::Result<()>>,
250    },
251}
252
253pub(crate) struct PtyInputWriter {
254    tx: mpsc::Sender<WriterCommand>,
255    thread: Option<thread::JoinHandle<()>>,
256}
257
258impl PtyInputWriter {
259    pub(crate) fn spawn(writer: Box<dyn Write + Send>, thread_name: &str) -> io::Result<Self> {
260        let (tx, rx) = mpsc::channel::<WriterCommand>();
261        let handle = thread::Builder::new()
262            .name(thread_name.to_string())
263            .spawn(move || {
264                let mut writer = writer;
265                while let Ok(command) = rx.recv() {
266                    match command {
267                        WriterCommand::Write { bytes, response } => {
268                            let result = writer.write_all(&bytes).and_then(|_| writer.flush());
269                            let _ = response.send(result);
270                        }
271                        WriterCommand::Flush { response } => {
272                            let _ = response.send(writer.flush());
273                        }
274                    }
275                }
276            })
277            .map_err(|error| {
278                io::Error::other(format!("failed to spawn PTY writer thread: {error}"))
279            })?;
280
281        Ok(Self {
282            tx,
283            thread: Some(handle),
284        })
285    }
286
287    pub(crate) fn write_with_timeout(
288        &mut self,
289        bytes: &[u8],
290        timeout: Duration,
291        _worker_name: &str,
292        _detach_name: &str,
293    ) -> io::Result<()> {
294        let (response_tx, response_rx) = mpsc::channel::<io::Result<()>>();
295        self.tx
296            .send(WriterCommand::Write {
297                bytes: bytes.to_vec(),
298                response: response_tx,
299            })
300            .map_err(|_| {
301                io::Error::new(io::ErrorKind::BrokenPipe, "PTY input writer is unavailable")
302            })?;
303
304        match response_rx.recv_timeout(timeout) {
305            Ok(result) => result,
306            Err(mpsc::RecvTimeoutError::Timeout) => Err(io::Error::new(
307                io::ErrorKind::TimedOut,
308                format!("PTY input write timed out after {} ms", timeout.as_millis()),
309            )),
310            Err(mpsc::RecvTimeoutError::Disconnected) => Err(io::Error::new(
311                io::ErrorKind::BrokenPipe,
312                "PTY input writer thread exited unexpectedly",
313            )),
314        }
315    }
316
317    pub(crate) fn flush_best_effort(&mut self) {
318        let (response_tx, response_rx) = mpsc::channel::<io::Result<()>>();
319        if self
320            .tx
321            .send(WriterCommand::Flush {
322                response: response_tx,
323            })
324            .is_ok()
325        {
326            let _ = response_rx.recv_timeout(Duration::from_millis(100));
327        }
328    }
329
330    pub(crate) fn detach_thread(&mut self, detach_name: &str) {
331        if let Some(handle) = self.thread.take() {
332            detach_join(handle, detach_name);
333        }
334    }
335}
336
337#[derive(Debug)]
338enum ReaderMsg {
339    Data(Vec<u8>),
340    Eof,
341    Err(io::Error),
342}
343
344/// A spawned PTY session with captured output.
345pub struct PtySession {
346    child: Box<dyn portable_pty::Child + Send + Sync>,
347    input_writer: PtyInputWriter,
348    rx: mpsc::Receiver<ReaderMsg>,
349    reader_thread: Option<thread::JoinHandle<()>>,
350    captured: Vec<u8>,
351    eof: bool,
352    config: PtyConfig,
353}
354
355impl fmt::Debug for PtySession {
356    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
357        f.debug_struct("PtySession")
358            .field("child_pid", &self.child.process_id())
359            .field("captured_len", &self.captured.len())
360            .field("eof", &self.eof)
361            .field("config", &self.config)
362            .finish()
363    }
364}
365
366/// Spawn a command into a new PTY.
367///
368/// `config.term` and `config.env` are applied to the `CommandBuilder` before spawn.
369pub fn spawn_command(mut config: PtyConfig, mut cmd: CommandBuilder) -> io::Result<PtySession> {
370    if let Some(name) = config.test_name.as_ref() {
371        log_event(config.log_events, "PTY_TEST_START", name);
372    }
373
374    if let Some(term) = config.term.take() {
375        cmd.env("TERM", term);
376    }
377    for (k, v) in config.env.drain(..) {
378        cmd.env(k, v);
379    }
380
381    let pty_system = portable_pty::native_pty_system();
382    let pair = pty_system
383        .openpty(PtySize {
384            rows: config.rows,
385            cols: config.cols,
386            pixel_width: 0,
387            pixel_height: 0,
388        })
389        .map_err(portable_pty_error)?;
390
391    let child = pair.slave.spawn_command(cmd).map_err(portable_pty_error)?;
392    let mut reader = pair.master.try_clone_reader().map_err(portable_pty_error)?;
393    let writer = pair.master.take_writer().map_err(portable_pty_error)?;
394    let input_writer = PtyInputWriter::spawn(writer, "ftui-pty-session-writer")?;
395
396    let (tx, rx) = mpsc::channel::<ReaderMsg>();
397    let reader_thread = thread::spawn(move || {
398        let mut buf = [0u8; 8192];
399        loop {
400            match reader.read(&mut buf) {
401                Ok(0) => {
402                    let _ = tx.send(ReaderMsg::Eof);
403                    break;
404                }
405                Ok(n) => {
406                    let _ = tx.send(ReaderMsg::Data(buf[..n].to_vec()));
407                }
408                Err(err) => {
409                    let _ = tx.send(ReaderMsg::Err(err));
410                    break;
411                }
412            }
413        }
414    });
415
416    Ok(PtySession {
417        child,
418        input_writer,
419        rx,
420        reader_thread: Some(reader_thread),
421        captured: Vec::new(),
422        eof: false,
423        config,
424    })
425}
426
427impl PtySession {
428    /// Read any available output without blocking.
429    pub fn read_output(&mut self) -> Vec<u8> {
430        match self.read_output_result() {
431            Ok(output) => output,
432            Err(err) => {
433                log_event(
434                    self.config.log_events,
435                    "PTY_READ_ERROR",
436                    format!("error={err}"),
437                );
438                self.captured.clone()
439            }
440        }
441    }
442
443    /// Read any available output without blocking (fallible).
444    pub fn read_output_result(&mut self) -> io::Result<Vec<u8>> {
445        let _ = self.read_available(Duration::from_millis(0))?;
446        Ok(self.captured.clone())
447    }
448
449    /// Read output until a pattern is found or a timeout elapses.
450    /// Uses bounded retries for transient read errors.
451    pub fn read_until(&mut self, pattern: &[u8], timeout: Duration) -> io::Result<Vec<u8>> {
452        let options = ReadUntilOptions::with_timeout(timeout)
453            .retries(3)
454            .retry_delay(Duration::from_millis(25));
455        self.read_until_with_options(pattern, options)
456    }
457
458    /// Read output until a pattern is found, with configurable retry behavior.
459    ///
460    /// This variant supports:
461    /// - Bounded retries on transient errors (e.g., `WouldBlock`, `Interrupted`)
462    /// - Minimum bytes threshold before pattern matching
463    /// - Configurable retry delay
464    ///
465    /// # Example
466    /// ```ignore
467    /// let options = ReadUntilOptions::with_timeout(Duration::from_secs(5))
468    ///     .retries(3)
469    ///     .retry_delay(Duration::from_millis(50))
470    ///     .min_bytes(10);
471    /// let output = session.read_until_with_options(b"ready", options)?;
472    /// ```
473    pub fn read_until_with_options(
474        &mut self,
475        pattern: &[u8],
476        options: ReadUntilOptions,
477    ) -> io::Result<Vec<u8>> {
478        if pattern.is_empty() {
479            return Ok(self.captured.clone());
480        }
481
482        let deadline = Instant::now() + options.timeout;
483        let mut retries_remaining = options.max_retries;
484        let mut last_error: Option<io::Error> = None;
485
486        loop {
487            // Check if we have enough bytes and the pattern is found
488            if self.captured.len() >= options.min_bytes
489                && find_subsequence(&self.captured, pattern).is_some()
490            {
491                log_event(
492                    self.config.log_events,
493                    "PTY_CHECK",
494                    format!(
495                        "pattern_found=0x{} bytes={}",
496                        hex_preview(pattern, 16).trim(),
497                        self.captured.len()
498                    ),
499                );
500                return Ok(self.captured.clone());
501            }
502
503            if self.eof || Instant::now() >= deadline {
504                break;
505            }
506
507            let remaining = deadline.saturating_duration_since(Instant::now());
508            match self.read_available(remaining) {
509                Ok(_) => {
510                    // Reset retry count on successful read
511                    retries_remaining = options.max_retries;
512                    last_error = None;
513                }
514                Err(err) if is_transient_error(&err) => {
515                    if retries_remaining > 0 {
516                        retries_remaining -= 1;
517                        log_event(
518                            self.config.log_events,
519                            "PTY_RETRY",
520                            format!(
521                                "transient_error={} retries_left={}",
522                                err.kind(),
523                                retries_remaining
524                            ),
525                        );
526                        std::thread::sleep(options.retry_delay.min(remaining));
527                        last_error = Some(err);
528                        continue;
529                    }
530                    return Err(err);
531                }
532                Err(err) => return Err(err),
533            }
534        }
535
536        // Return the last transient error if we exhausted retries, otherwise timeout
537        if let Some(err) = last_error {
538            return Err(io::Error::new(
539                err.kind(),
540                format!("PTY read_until failed after retries: {}", err),
541            ));
542        }
543
544        Err(io::Error::new(
545            io::ErrorKind::TimedOut,
546            format!(
547                "PTY read_until timed out (captured {} bytes, need {} + pattern)",
548                self.captured.len(),
549                options.min_bytes
550            ),
551        ))
552    }
553
554    /// Send raw input bytes to the child process.
555    ///
556    /// For interactive shell commands, prefer [`Self::send_line`] so Enter is
557    /// encoded as carriage return instead of a bare line feed.
558    pub fn send_input(&mut self, bytes: &[u8]) -> io::Result<()> {
559        if bytes.is_empty() {
560            return Ok(());
561        }
562
563        let result = self.input_writer.write_with_timeout(
564            bytes,
565            self.config.input_write_timeout,
566            "ftui-pty-session-write",
567            "ftui-pty-session-detached-write",
568        );
569        if matches!(
570            result.as_ref().err().map(io::Error::kind),
571            Some(io::ErrorKind::TimedOut)
572        ) {
573            let _ = self.child.kill();
574        }
575        result?;
576
577        log_event(
578            self.config.log_events,
579            "PTY_INPUT",
580            format!("sent_bytes={}", bytes.len()),
581        );
582
583        Ok(())
584    }
585
586    /// Send a line of interactive input, normalizing Enter to carriage return.
587    pub fn send_line(&mut self, line: impl AsRef<[u8]>) -> io::Result<()> {
588        let normalized = normalize_line_input(line.as_ref());
589        self.send_input(&normalized)
590    }
591
592    /// Wait for the child to exit and return its status.
593    pub fn wait(&mut self) -> io::Result<ExitStatus> {
594        self.child.wait()
595    }
596
597    /// Access all captured output so far.
598    pub fn output(&self) -> &[u8] {
599        &self.captured
600    }
601
602    /// Child process id (if available on this platform).
603    pub fn child_pid(&self) -> Option<u32> {
604        self.child.process_id()
605    }
606
607    fn read_available(&mut self, timeout: Duration) -> io::Result<usize> {
608        if self.eof {
609            return Ok(0);
610        }
611
612        let mut total = 0usize;
613
614        // First read: optionally wait up to `timeout`.
615        let first = if timeout.is_zero() {
616            match self.rx.try_recv() {
617                Ok(msg) => Some(msg),
618                Err(mpsc::TryRecvError::Empty) => None,
619                Err(mpsc::TryRecvError::Disconnected) => {
620                    self.eof = true;
621                    None
622                }
623            }
624        } else {
625            match self.rx.recv_timeout(timeout) {
626                Ok(msg) => Some(msg),
627                Err(mpsc::RecvTimeoutError::Timeout) => None,
628                Err(mpsc::RecvTimeoutError::Disconnected) => {
629                    self.eof = true;
630                    None
631                }
632            }
633        };
634
635        let mut msg = match first {
636            Some(m) => m,
637            None => return Ok(0),
638        };
639
640        loop {
641            match msg {
642                ReaderMsg::Data(bytes) => {
643                    total = total.saturating_add(bytes.len());
644                    self.captured.extend_from_slice(&bytes);
645                }
646                ReaderMsg::Eof => {
647                    self.eof = true;
648                    break;
649                }
650                ReaderMsg::Err(err) => return Err(err),
651            }
652
653            match self.rx.try_recv() {
654                Ok(next) => msg = next,
655                Err(mpsc::TryRecvError::Empty) => break,
656                Err(mpsc::TryRecvError::Disconnected) => {
657                    self.eof = true;
658                    break;
659                }
660            }
661        }
662
663        if total > 0 {
664            log_event(
665                self.config.log_events,
666                "PTY_OUTPUT",
667                format!("captured_bytes={}", total),
668            );
669        }
670
671        Ok(total)
672    }
673
674    /// Drain all remaining output until EOF or timeout.
675    ///
676    /// Call this after `wait()` to ensure all output from the child process
677    /// has been captured. This is important because output may still be in
678    /// transit through the PTY after the process exits.
679    ///
680    /// Returns the total number of bytes drained.
681    pub fn drain_remaining(&mut self, timeout: Duration) -> io::Result<usize> {
682        if self.eof {
683            return Ok(0);
684        }
685
686        let deadline = Instant::now() + timeout;
687        let mut total = 0usize;
688
689        log_event(
690            self.config.log_events,
691            "PTY_DRAIN_START",
692            format!("timeout_ms={}", timeout.as_millis()),
693        );
694
695        loop {
696            if self.eof {
697                break;
698            }
699
700            let remaining = deadline.saturating_duration_since(Instant::now());
701            if remaining.is_zero() {
702                log_event(
703                    self.config.log_events,
704                    "PTY_DRAIN_TIMEOUT",
705                    format!("captured_bytes={}", total),
706                );
707                break;
708            }
709
710            // Wait for data with remaining timeout
711            let msg = match self.rx.recv_timeout(remaining) {
712                Ok(msg) => msg,
713                Err(mpsc::RecvTimeoutError::Timeout) => break,
714                Err(mpsc::RecvTimeoutError::Disconnected) => {
715                    self.eof = true;
716                    break;
717                }
718            };
719
720            match msg {
721                ReaderMsg::Data(bytes) => {
722                    total = total.saturating_add(bytes.len());
723                    self.captured.extend_from_slice(&bytes);
724                }
725                ReaderMsg::Eof => {
726                    self.eof = true;
727                    break;
728                }
729                ReaderMsg::Err(err) => return Err(err),
730            }
731
732            // Drain any immediately available data without waiting
733            loop {
734                match self.rx.try_recv() {
735                    Ok(ReaderMsg::Data(bytes)) => {
736                        total = total.saturating_add(bytes.len());
737                        self.captured.extend_from_slice(&bytes);
738                    }
739                    Ok(ReaderMsg::Eof) => {
740                        self.eof = true;
741                        break;
742                    }
743                    Ok(ReaderMsg::Err(err)) => return Err(err),
744                    Err(mpsc::TryRecvError::Empty) => break,
745                    Err(mpsc::TryRecvError::Disconnected) => {
746                        self.eof = true;
747                        break;
748                    }
749                }
750            }
751        }
752
753        log_event(
754            self.config.log_events,
755            "PTY_DRAIN_COMPLETE",
756            format!("captured_bytes={} eof={}", total, self.eof),
757        );
758
759        Ok(total)
760    }
761
762    /// Wait for the child and drain all remaining output.
763    ///
764    /// This is a convenience method that combines `wait()` with `drain_remaining()`.
765    /// It ensures deterministic capture by waiting for both the child to exit
766    /// AND all output to be received.
767    pub fn wait_and_drain(&mut self, drain_timeout: Duration) -> io::Result<ExitStatus> {
768        let status = self.child.wait()?;
769        let _ = self.drain_remaining(drain_timeout)?;
770        Ok(status)
771    }
772}
773
774impl Drop for PtySession {
775    fn drop(&mut self) {
776        let _ = self.child.kill();
777        self.input_writer.flush_best_effort();
778        self.input_writer
779            .detach_thread("ftui-pty-session-detached-writer");
780
781        if let Some(handle) = self.reader_thread.take() {
782            detach_reader_join(handle);
783        }
784    }
785}
786
787fn detach_reader_join(handle: thread::JoinHandle<()>) {
788    detach_join(handle, "ftui-pty-detached-reader-join");
789}
790
791pub(crate) fn detach_join(handle: thread::JoinHandle<()>, thread_name: &str) {
792    let _ = thread::Builder::new()
793        .name(thread_name.to_string())
794        .spawn(move || {
795            let _ = handle.join();
796        });
797}
798
799/// Assert that terminal cleanup sequences were emitted.
800pub fn assert_terminal_restored(
801    output: &[u8],
802    expectations: &CleanupExpectations,
803) -> Result<(), String> {
804    let mut failures = Vec::new();
805
806    if expectations.sgr_reset && !contains_any(output, SGR_RESET_SEQS) {
807        failures.push("Missing SGR reset (CSI 0 m)");
808    }
809    if expectations.show_cursor && !contains_any(output, CURSOR_SHOW_SEQS) {
810        failures.push("Missing cursor show (CSI ? 25 h)");
811    }
812    if expectations.alt_screen && !contains_any(output, ALT_SCREEN_EXIT_SEQS) {
813        failures.push("Missing alt-screen exit (CSI ? 1049 l)");
814    }
815    if expectations.mouse && !contains_any(output, MOUSE_DISABLE_SEQS) {
816        failures.push("Missing mouse disable (CSI ? 1000... l)");
817    }
818    if expectations.bracketed_paste && !contains_any(output, BRACKETED_PASTE_DISABLE_SEQS) {
819        failures.push("Missing bracketed paste disable (CSI ? 2004 l)");
820    }
821    if expectations.focus_events && !contains_any(output, FOCUS_DISABLE_SEQS) {
822        failures.push("Missing focus disable (CSI ? 1004 l)");
823    }
824    if expectations.kitty_keyboard && !contains_any(output, KITTY_DISABLE_SEQS) {
825        failures.push("Missing kitty keyboard disable (CSI < u)");
826    }
827
828    if failures.is_empty() {
829        log_event(true, "PTY_TEST_PASS", "terminal cleanup sequences verified");
830        return Ok(());
831    }
832
833    for failure in &failures {
834        log_event(true, "PTY_FAILURE_REASON", *failure);
835    }
836
837    log_event(true, "PTY_OUTPUT_DUMP", "hex:");
838    for line in hex_dump(output, 4096).lines() {
839        log_event(true, "PTY_OUTPUT_DUMP", line);
840    }
841
842    log_event(true, "PTY_OUTPUT_DUMP", "printable:");
843    for line in printable_dump(output, 4096).lines() {
844        log_event(true, "PTY_OUTPUT_DUMP", line);
845    }
846
847    Err(failures.join("; "))
848}
849
850fn log_event(enabled: bool, event: &str, detail: impl fmt::Display) {
851    if !enabled {
852        return;
853    }
854
855    let timestamp = timestamp_rfc3339();
856    eprintln!("[{}] {}: {}", timestamp, event, detail);
857}
858
859fn timestamp_rfc3339() -> String {
860    time::OffsetDateTime::now_utc()
861        .format(&time::format_description::well_known::Rfc3339)
862        .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
863}
864
865fn hex_preview(bytes: &[u8], limit: usize) -> String {
866    let mut out = String::new();
867    for b in bytes.iter().take(limit) {
868        out.push_str(&format!("{:02x}", b));
869    }
870    if bytes.len() > limit {
871        out.push_str("..");
872    }
873    out
874}
875
876fn hex_dump(bytes: &[u8], limit: usize) -> String {
877    let mut out = String::new();
878    let slice = bytes.get(0..limit).unwrap_or(bytes);
879
880    for (row, chunk) in slice.chunks(16).enumerate() {
881        let offset = row * 16;
882        out.push_str(&format!("{:04x}: ", offset));
883        for b in chunk {
884            out.push_str(&format!("{:02x} ", b));
885        }
886        out.push('\n');
887    }
888
889    if bytes.len() > limit {
890        out.push_str("... (truncated)\n");
891    }
892
893    out
894}
895
896fn printable_dump(bytes: &[u8], limit: usize) -> String {
897    let mut out = String::new();
898    let slice = bytes.get(0..limit).unwrap_or(bytes);
899
900    for (row, chunk) in slice.chunks(16).enumerate() {
901        let offset = row * 16;
902        out.push_str(&format!("{:04x}: ", offset));
903        for b in chunk {
904            let ch = if b.is_ascii_graphic() || *b == b' ' {
905                *b as char
906            } else {
907                '.'
908            };
909            out.push(ch);
910        }
911        out.push('\n');
912    }
913
914    if bytes.len() > limit {
915        out.push_str("... (truncated)\n");
916    }
917
918    out
919}
920
921fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
922    if needle.is_empty() {
923        return Some(0);
924    }
925    haystack
926        .windows(needle.len())
927        .position(|window| window == needle)
928}
929
930fn contains_any(haystack: &[u8], needles: &[&[u8]]) -> bool {
931    needles
932        .iter()
933        .any(|needle| find_subsequence(haystack, needle).is_some())
934}
935
936fn portable_pty_error<E: fmt::Display>(err: E) -> io::Error {
937    io::Error::other(err.to_string())
938}
939
940/// Check if an I/O error is transient and worth retrying.
941fn is_transient_error(err: &io::Error) -> bool {
942    matches!(
943        err.kind(),
944        io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted | io::ErrorKind::TimedOut
945    )
946}
947
948const SGR_RESET_SEQS: &[&[u8]] = &[b"\x1b[0m", b"\x1b[m"];
949const CURSOR_SHOW_SEQS: &[&[u8]] = &[b"\x1b[?25h"];
950const ALT_SCREEN_EXIT_SEQS: &[&[u8]] = &[b"\x1b[?1049l", b"\x1b[?1047l"];
951const MOUSE_DISABLE_SEQS: &[&[u8]] = &[
952    b"\x1b[?1000l\x1b[?1002l\x1b[?1006l",
953    b"\x1b[?1002l\x1b[?1006l",
954    b"\x1b[?1000;1002;1006l",
955    b"\x1b[?1000;1002l",
956    b"\x1b[?1000l",
957    b"\x1b[?1002l",
958    b"\x1b[?1006l",
959];
960const BRACKETED_PASTE_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[?2004l"];
961const FOCUS_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[?1004l"];
962const KITTY_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[<u"];
963
964#[cfg(test)]
965mod tests {
966    use super::*;
967
968    #[test]
969    fn normalize_line_input_appends_carriage_return() {
970        assert_eq!(normalize_line_input(b"echo hi"), b"echo hi\r\n");
971    }
972
973    #[test]
974    fn normalize_line_input_replaces_trailing_line_feed() {
975        assert_eq!(normalize_line_input(b"echo hi\n"), b"echo hi\r\n");
976    }
977
978    #[test]
979    fn normalize_line_input_preserves_existing_carriage_return() {
980        assert_eq!(normalize_line_input(b"echo hi\r"), b"echo hi\r\n");
981    }
982    #[cfg(unix)]
983    use ftui_core::terminal_session::{TerminalSession, best_effort_cleanup_for_exit};
984
985    #[test]
986    fn cleanup_expectations_match_sequences() {
987        let output =
988            b"\x1b[0m\x1b[?25h\x1b[?1049l\x1b[?1000;1002;1006l\x1b[?2004l\x1b[?1004l\x1b[<u";
989        assert_terminal_restored(output, &CleanupExpectations::strict())
990            .expect("terminal cleanup assertions failed");
991    }
992
993    #[test]
994    #[should_panic]
995    fn cleanup_expectations_fail_when_missing() {
996        let output = b"\x1b[?25h";
997        assert_terminal_restored(output, &CleanupExpectations::strict())
998            .expect("terminal cleanup assertions failed");
999    }
1000
1001    #[cfg(unix)]
1002    #[test]
1003    fn spawn_command_captures_output() {
1004        let config = PtyConfig::default().logging(false);
1005
1006        let mut cmd = CommandBuilder::new("sh");
1007        cmd.args(["-c", "printf hello-pty"]);
1008
1009        let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1010
1011        let _status = session.wait().expect("wait should succeed");
1012        // Use read_until with a timeout to avoid a race condition:
1013        // after wait() returns, the reader thread may not have drained
1014        // all PTY output yet. A non-blocking read_output() can miss data.
1015        let output = session
1016            .read_until(b"hello-pty", Duration::from_secs(5))
1017            .expect("expected PTY output to contain test string");
1018        assert!(
1019            output
1020                .windows(b"hello-pty".len())
1021                .any(|w| w == b"hello-pty"),
1022            "expected PTY output to contain test string"
1023        );
1024    }
1025
1026    #[cfg(unix)]
1027    #[test]
1028    fn read_until_with_options_min_bytes() {
1029        let config = PtyConfig::default().logging(false);
1030
1031        let mut cmd = CommandBuilder::new("sh");
1032        cmd.args(["-c", "printf 'short'; sleep 0.05; printf 'longer-output'"]);
1033
1034        let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1035
1036        // Wait for at least 10 bytes before matching "output"
1037        let options = ReadUntilOptions::with_timeout(Duration::from_secs(5)).min_bytes(10);
1038
1039        let output = session
1040            .read_until_with_options(b"output", options)
1041            .expect("expected to find pattern with min_bytes");
1042
1043        assert!(
1044            output.len() >= 10,
1045            "expected at least 10 bytes, got {}",
1046            output.len()
1047        );
1048        assert!(
1049            output.windows(b"output".len()).any(|w| w == b"output"),
1050            "expected pattern 'output' in captured data"
1051        );
1052    }
1053
1054    #[cfg(unix)]
1055    #[test]
1056    fn read_until_with_options_retries_on_timeout_then_succeeds() {
1057        let config = PtyConfig::default().logging(false);
1058
1059        let mut cmd = CommandBuilder::new("sh");
1060        cmd.args(["-c", "sleep 0.1; printf done"]);
1061
1062        let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1063
1064        // Short initial timeout but with retries
1065        let options = ReadUntilOptions::with_timeout(Duration::from_secs(3))
1066            .retries(3)
1067            .retry_delay(Duration::from_millis(50));
1068
1069        let output = session
1070            .read_until_with_options(b"done", options)
1071            .expect("should succeed with retries");
1072
1073        assert!(
1074            output.windows(b"done".len()).any(|w| w == b"done"),
1075            "expected 'done' in output"
1076        );
1077    }
1078
1079    // --- Deterministic capture ordering tests ---
1080
1081    #[cfg(unix)]
1082    #[test]
1083    fn large_output_fully_captured() {
1084        let config = PtyConfig::default().logging(false);
1085
1086        // Generate 64KB of output to ensure large buffers are handled
1087        let mut cmd = CommandBuilder::new("sh");
1088        cmd.args(["-c", "dd if=/dev/zero bs=1024 count=64 2>/dev/null | od -v"]);
1089
1090        let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1091
1092        let _status = session
1093            .wait_and_drain(Duration::from_secs(5))
1094            .expect("wait_and_drain");
1095
1096        // Should have captured substantial output (od output is larger than input)
1097        let output = session.output();
1098        assert!(
1099            output.len() > 50_000,
1100            "expected >50KB of output, got {} bytes",
1101            output.len()
1102        );
1103    }
1104
1105    #[cfg(unix)]
1106    #[test]
1107    fn late_output_after_exit_captured() {
1108        let config = PtyConfig::default().logging(false);
1109
1110        // Script that writes output slowly, including after main processing
1111        let mut cmd = CommandBuilder::new("sh");
1112        cmd.args([
1113            "-c",
1114            "printf 'start\\n'; sleep 0.05; printf 'middle\\n'; sleep 0.05; printf 'end\\n'",
1115        ]);
1116
1117        let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1118
1119        // Wait for process to exit
1120        let _status = session.wait().expect("wait should succeed");
1121
1122        // Now drain remaining output
1123        let _drained = session
1124            .drain_remaining(Duration::from_secs(2))
1125            .expect("drain_remaining should succeed");
1126
1127        let output = session.output();
1128        let output_str = String::from_utf8_lossy(output);
1129
1130        // Verify all output was captured including late writes
1131        assert!(
1132            output_str.contains("start"),
1133            "missing 'start' in output: {output_str:?}"
1134        );
1135        assert!(
1136            output_str.contains("middle"),
1137            "missing 'middle' in output: {output_str:?}"
1138        );
1139        assert!(
1140            output_str.contains("end"),
1141            "missing 'end' in output: {output_str:?}"
1142        );
1143
1144        // Verify deterministic ordering (start before middle before end)
1145        let start_pos = output_str.find("start").unwrap();
1146        let middle_pos = output_str.find("middle").unwrap();
1147        let end_pos = output_str.find("end").unwrap();
1148        assert!(
1149            start_pos < middle_pos && middle_pos < end_pos,
1150            "output not in expected order: start={start_pos}, middle={middle_pos}, end={end_pos}"
1151        );
1152
1153        // Drain should return 0 on second call (all captured)
1154        let drained_again = session
1155            .drain_remaining(Duration::from_millis(100))
1156            .expect("second drain should succeed");
1157        assert_eq!(drained_again, 0, "second drain should return 0");
1158    }
1159
1160    #[cfg(unix)]
1161    #[test]
1162    fn wait_and_drain_captures_all() {
1163        let config = PtyConfig::default().logging(false);
1164
1165        let mut cmd = CommandBuilder::new("sh");
1166        cmd.args([
1167            "-c",
1168            "for i in 1 2 3 4 5; do printf \"line$i\\n\"; sleep 0.02; done",
1169        ]);
1170
1171        let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1172
1173        // Use wait_and_drain for deterministic capture
1174        let status = session
1175            .wait_and_drain(Duration::from_secs(2))
1176            .expect("wait_and_drain should succeed");
1177
1178        assert!(status.success(), "child should succeed");
1179
1180        let output = session.output();
1181        let output_str = String::from_utf8_lossy(output);
1182
1183        // Verify all 5 lines were captured
1184        for i in 1..=5 {
1185            assert!(
1186                output_str.contains(&format!("line{i}")),
1187                "missing 'line{i}' in output: {output_str:?}"
1188            );
1189        }
1190    }
1191
1192    #[cfg(unix)]
1193    #[test]
1194    fn wait_and_drain_large_output_ordered() {
1195        let config = PtyConfig::default().logging(false);
1196
1197        let mut cmd = CommandBuilder::new("sh");
1198        cmd.args([
1199            "-c",
1200            "i=1; while [ $i -le 1200 ]; do printf \"line%04d\\n\" $i; i=$((i+1)); done",
1201        ]);
1202
1203        let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1204
1205        let status = session
1206            .wait_and_drain(Duration::from_secs(3))
1207            .expect("wait_and_drain should succeed");
1208
1209        assert!(status.success(), "child should succeed");
1210
1211        let output = session.output();
1212        let output_str = String::from_utf8_lossy(output);
1213        let lines: Vec<&str> = output_str.lines().collect();
1214
1215        assert_eq!(
1216            lines.len(),
1217            1200,
1218            "expected 1200 lines, got {}",
1219            lines.len()
1220        );
1221        assert_eq!(lines.first().copied(), Some("line0001"));
1222        assert_eq!(lines.last().copied(), Some("line1200"));
1223    }
1224
1225    #[cfg(unix)]
1226    #[test]
1227    fn drain_remaining_respects_eof() {
1228        let config = PtyConfig::default().logging(false);
1229
1230        let mut cmd = CommandBuilder::new("sh");
1231        cmd.args(["-c", "printf 'quick'"]);
1232
1233        let mut session = spawn_command(config, cmd).expect("spawn_command should succeed");
1234
1235        // Wait for exit and drain
1236        let _ = session
1237            .wait_and_drain(Duration::from_secs(2))
1238            .expect("wait_and_drain");
1239
1240        // Session should now be at EOF
1241        assert!(session.eof, "should be at EOF after wait_and_drain");
1242
1243        // Further drain attempts should return 0 immediately
1244        let result = session
1245            .drain_remaining(Duration::from_secs(1))
1246            .expect("drain");
1247        assert_eq!(result, 0, "drain after EOF should return 0");
1248    }
1249
1250    #[cfg(unix)]
1251    #[test]
1252    fn pty_terminal_session_cleanup() {
1253        let mut cmd = CommandBuilder::new(std::env::current_exe().expect("current exe"));
1254        cmd.args([
1255            "--exact",
1256            "tests::pty_terminal_session_cleanup_child",
1257            "--nocapture",
1258        ]);
1259        cmd.env("FTUI_PTY_CHILD", "1");
1260        cmd.env("FTUI_TEST_PROFILE", "modern");
1261        cmd.env("TERM", "xterm-256color");
1262
1263        let config = PtyConfig::default()
1264            .with_test_name("terminal_session_cleanup")
1265            .logging(false);
1266        let mut session = spawn_command(config, cmd).expect("spawn PTY child");
1267
1268        let status = session.wait().expect("wait for child");
1269        assert!(status.success(), "child test failed: {:?}", status);
1270
1271        let _ = session
1272            .read_until(b"\x1b[?25h", Duration::from_secs(5))
1273            .expect("expected cursor show sequence");
1274        let _ = session
1275            .drain_remaining(Duration::from_secs(1))
1276            .expect("drain remaining");
1277        let output = session.output();
1278
1279        let options = SessionOptions {
1280            alternate_screen: true,
1281            mouse_capture: true,
1282            bracketed_paste: true,
1283            focus_events: true,
1284            kitty_keyboard: true,
1285            intercept_signals: true,
1286        };
1287        let expectations = CleanupExpectations::for_session(&options);
1288        assert_terminal_restored(output, &expectations)
1289            .expect("terminal cleanup assertions failed");
1290    }
1291
1292    #[cfg(unix)]
1293    #[test]
1294    fn pty_terminal_session_cleanup_child() {
1295        if std::env::var("FTUI_PTY_CHILD").as_deref() != Ok("1") {
1296            return;
1297        }
1298
1299        let options = SessionOptions {
1300            alternate_screen: true,
1301            mouse_capture: true,
1302            bracketed_paste: true,
1303            focus_events: true,
1304            kitty_keyboard: true,
1305            intercept_signals: true,
1306        };
1307
1308        let _session = TerminalSession::new(options).expect("TerminalSession::new");
1309    }
1310
1311    #[cfg(unix)]
1312    #[test]
1313    fn pty_terminal_session_cleanup_on_panic() {
1314        let mut cmd = CommandBuilder::new(std::env::current_exe().expect("current exe"));
1315        cmd.args([
1316            "--exact",
1317            "tests::pty_terminal_session_cleanup_panic_child",
1318            "--nocapture",
1319        ]);
1320        cmd.env("FTUI_PTY_PANIC_CHILD", "1");
1321        cmd.env("FTUI_TEST_PROFILE", "modern");
1322        cmd.env("TERM", "xterm-256color");
1323
1324        let config = PtyConfig::default()
1325            .with_test_name("terminal_session_cleanup_panic")
1326            .logging(false);
1327        let mut session = spawn_command(config, cmd).expect("spawn PTY child");
1328
1329        let status = session.wait().expect("wait for child");
1330        assert!(
1331            !status.success(),
1332            "panic child should exit with failure status"
1333        );
1334
1335        let _ = session
1336            .read_until(b"\x1b[?25h", Duration::from_secs(5))
1337            .expect("expected cursor show sequence");
1338        let _ = session
1339            .drain_remaining(Duration::from_secs(1))
1340            .expect("drain remaining");
1341        let output = session.output();
1342
1343        let options = SessionOptions {
1344            alternate_screen: true,
1345            mouse_capture: true,
1346            bracketed_paste: true,
1347            focus_events: true,
1348            kitty_keyboard: true,
1349            intercept_signals: true,
1350        };
1351        let expectations = CleanupExpectations::for_session(&options);
1352        assert_terminal_restored(output, &expectations)
1353            .expect("terminal cleanup assertions failed");
1354    }
1355
1356    #[cfg(unix)]
1357    #[test]
1358    fn pty_terminal_session_cleanup_panic_child() {
1359        if std::env::var("FTUI_PTY_PANIC_CHILD").as_deref() != Ok("1") {
1360            return;
1361        }
1362
1363        let options = SessionOptions {
1364            alternate_screen: true,
1365            mouse_capture: true,
1366            bracketed_paste: true,
1367            focus_events: true,
1368            kitty_keyboard: true,
1369            intercept_signals: true,
1370        };
1371
1372        let _session = TerminalSession::new(options).expect("TerminalSession::new");
1373        std::panic::panic_any("intentional panic to verify cleanup on unwind");
1374    }
1375
1376    #[cfg(unix)]
1377    #[test]
1378    fn pty_terminal_session_cleanup_on_exit() {
1379        let mut cmd = CommandBuilder::new(std::env::current_exe().expect("current exe"));
1380        cmd.args([
1381            "--exact",
1382            "tests::pty_terminal_session_cleanup_exit_child",
1383            "--nocapture",
1384        ]);
1385        cmd.env("FTUI_PTY_EXIT_CHILD", "1");
1386        cmd.env("FTUI_TEST_PROFILE", "modern");
1387        cmd.env("TERM", "xterm-256color");
1388
1389        let config = PtyConfig::default()
1390            .with_test_name("terminal_session_cleanup_exit")
1391            .logging(false);
1392        let mut session = spawn_command(config, cmd).expect("spawn PTY child");
1393
1394        let status = session.wait().expect("wait for child");
1395        assert!(status.success(), "exit child should succeed: {:?}", status);
1396
1397        let _ = session
1398            .read_until(b"\x1b[?25h", Duration::from_secs(5))
1399            .expect("expected cursor show sequence");
1400        let _ = session
1401            .drain_remaining(Duration::from_secs(1))
1402            .expect("drain remaining");
1403        let output = session.output();
1404
1405        let options = SessionOptions {
1406            alternate_screen: true,
1407            mouse_capture: true,
1408            bracketed_paste: true,
1409            focus_events: true,
1410            kitty_keyboard: true,
1411            intercept_signals: true,
1412        };
1413        let expectations = CleanupExpectations::for_session(&options);
1414        assert_terminal_restored(output, &expectations)
1415            .expect("terminal cleanup assertions failed");
1416    }
1417
1418    #[cfg(unix)]
1419    #[test]
1420    fn pty_terminal_session_cleanup_exit_child() {
1421        if std::env::var("FTUI_PTY_EXIT_CHILD").as_deref() != Ok("1") {
1422            return;
1423        }
1424
1425        let options = SessionOptions {
1426            alternate_screen: true,
1427            mouse_capture: true,
1428            bracketed_paste: true,
1429            focus_events: true,
1430            kitty_keyboard: true,
1431            intercept_signals: true,
1432        };
1433
1434        let _session = TerminalSession::new(options).expect("TerminalSession::new");
1435        best_effort_cleanup_for_exit();
1436        std::process::exit(0);
1437    }
1438
1439    // --- find_subsequence tests ---
1440
1441    #[test]
1442    fn find_subsequence_empty_needle() {
1443        assert_eq!(find_subsequence(b"anything", b""), Some(0));
1444    }
1445
1446    #[test]
1447    fn find_subsequence_empty_haystack() {
1448        assert_eq!(find_subsequence(b"", b"x"), None);
1449    }
1450
1451    #[test]
1452    fn find_subsequence_found_at_start() {
1453        assert_eq!(find_subsequence(b"hello world", b"hello"), Some(0));
1454    }
1455
1456    #[test]
1457    fn find_subsequence_found_in_middle() {
1458        assert_eq!(find_subsequence(b"hello world", b"o w"), Some(4));
1459    }
1460
1461    #[test]
1462    fn find_subsequence_found_at_end() {
1463        assert_eq!(find_subsequence(b"hello world", b"world"), Some(6));
1464    }
1465
1466    #[test]
1467    fn find_subsequence_not_found() {
1468        assert_eq!(find_subsequence(b"hello world", b"xyz"), None);
1469    }
1470
1471    #[test]
1472    fn find_subsequence_needle_longer_than_haystack() {
1473        assert_eq!(find_subsequence(b"ab", b"abcdef"), None);
1474    }
1475
1476    #[test]
1477    fn find_subsequence_exact_match() {
1478        assert_eq!(find_subsequence(b"abc", b"abc"), Some(0));
1479    }
1480
1481    // --- contains_any tests ---
1482
1483    #[test]
1484    fn contains_any_finds_first_match() {
1485        assert!(contains_any(b"\x1b[0m test", &[b"\x1b[0m", b"\x1b[m"]));
1486    }
1487
1488    #[test]
1489    fn contains_any_finds_second_match() {
1490        assert!(contains_any(b"\x1b[m test", &[b"\x1b[0m", b"\x1b[m"]));
1491    }
1492
1493    #[test]
1494    fn contains_any_no_match() {
1495        assert!(!contains_any(b"plain text", &[b"\x1b[0m", b"\x1b[m"]));
1496    }
1497
1498    #[test]
1499    fn contains_any_empty_needles() {
1500        assert!(!contains_any(b"test", &[]));
1501    }
1502
1503    // --- hex_preview tests ---
1504
1505    #[test]
1506    fn hex_preview_basic() {
1507        let result = hex_preview(&[0x41, 0x42, 0x43], 10);
1508        assert_eq!(result, "414243");
1509    }
1510
1511    #[test]
1512    fn hex_preview_truncated() {
1513        let result = hex_preview(&[0x00, 0x01, 0x02, 0x03, 0x04], 3);
1514        assert_eq!(result, "000102..");
1515    }
1516
1517    #[test]
1518    fn hex_preview_empty() {
1519        assert_eq!(hex_preview(&[], 10), "");
1520    }
1521
1522    // --- hex_dump tests ---
1523
1524    #[test]
1525    fn hex_dump_single_row() {
1526        let result = hex_dump(&[0x41, 0x42], 100);
1527        assert!(result.starts_with("0000: "));
1528        assert!(result.contains("41 42"));
1529    }
1530
1531    #[test]
1532    fn hex_dump_multi_row() {
1533        let data: Vec<u8> = (0..20).collect();
1534        let result = hex_dump(&data, 100);
1535        assert!(result.contains("0000: "));
1536        assert!(result.contains("0010: ")); // second row at offset 16
1537    }
1538
1539    #[test]
1540    fn hex_dump_truncated() {
1541        let data: Vec<u8> = (0..100).collect();
1542        let result = hex_dump(&data, 32);
1543        assert!(result.contains("(truncated)"));
1544    }
1545
1546    #[test]
1547    fn hex_dump_empty() {
1548        let result = hex_dump(&[], 100);
1549        assert!(result.is_empty());
1550    }
1551
1552    // --- printable_dump tests ---
1553
1554    #[test]
1555    fn printable_dump_ascii() {
1556        let result = printable_dump(b"Hello", 100);
1557        assert!(result.contains("Hello"));
1558    }
1559
1560    #[test]
1561    fn printable_dump_replaces_control_chars() {
1562        let result = printable_dump(&[0x01, 0x02, 0x1B], 100);
1563        // Control chars should be replaced with '.'
1564        assert!(result.contains("..."));
1565    }
1566
1567    #[test]
1568    fn printable_dump_truncated() {
1569        let data: Vec<u8> = (0..100).collect();
1570        let result = printable_dump(&data, 32);
1571        assert!(result.contains("(truncated)"));
1572    }
1573
1574    // --- PtyConfig builder tests ---
1575
1576    #[test]
1577    fn pty_config_defaults() {
1578        let config = PtyConfig::default();
1579        assert_eq!(config.cols, 80);
1580        assert_eq!(config.rows, 24);
1581        assert_eq!(config.term.as_deref(), Some("xterm-256color"));
1582        assert!(config.env.is_empty());
1583        assert!(config.test_name.is_none());
1584        assert!(config.log_events);
1585    }
1586
1587    #[test]
1588    fn pty_config_with_size() {
1589        let config = PtyConfig::default().with_size(120, 40);
1590        assert_eq!(config.cols, 120);
1591        assert_eq!(config.rows, 40);
1592    }
1593
1594    #[test]
1595    fn pty_config_with_term() {
1596        let config = PtyConfig::default().with_term("dumb");
1597        assert_eq!(config.term.as_deref(), Some("dumb"));
1598    }
1599
1600    #[test]
1601    fn pty_config_with_env() {
1602        let config = PtyConfig::default()
1603            .with_env("FOO", "bar")
1604            .with_env("BAZ", "qux");
1605        assert_eq!(config.env.len(), 2);
1606        assert_eq!(config.env[0], ("FOO".to_string(), "bar".to_string()));
1607        assert_eq!(config.env[1], ("BAZ".to_string(), "qux".to_string()));
1608    }
1609
1610    #[test]
1611    fn pty_config_with_test_name() {
1612        let config = PtyConfig::default().with_test_name("my_test");
1613        assert_eq!(config.test_name.as_deref(), Some("my_test"));
1614    }
1615
1616    #[test]
1617    fn pty_config_logging_disabled() {
1618        let config = PtyConfig::default().logging(false);
1619        assert!(!config.log_events);
1620    }
1621
1622    #[test]
1623    fn pty_config_builder_chaining() {
1624        let config = PtyConfig::default()
1625            .with_size(132, 50)
1626            .with_term("xterm")
1627            .with_env("KEY", "val")
1628            .with_test_name("chain_test")
1629            .logging(false);
1630        assert_eq!(config.cols, 132);
1631        assert_eq!(config.rows, 50);
1632        assert_eq!(config.term.as_deref(), Some("xterm"));
1633        assert_eq!(config.env.len(), 1);
1634        assert_eq!(config.test_name.as_deref(), Some("chain_test"));
1635        assert!(!config.log_events);
1636    }
1637
1638    // --- ReadUntilOptions tests ---
1639
1640    #[test]
1641    fn read_until_options_defaults() {
1642        let opts = ReadUntilOptions::default();
1643        assert_eq!(opts.timeout, Duration::from_secs(5));
1644        assert_eq!(opts.max_retries, 0);
1645        assert_eq!(opts.retry_delay, Duration::from_millis(100));
1646        assert_eq!(opts.min_bytes, 0);
1647    }
1648
1649    #[test]
1650    fn read_until_options_with_timeout() {
1651        let opts = ReadUntilOptions::with_timeout(Duration::from_secs(10));
1652        assert_eq!(opts.timeout, Duration::from_secs(10));
1653        assert_eq!(opts.max_retries, 0); // other fields unchanged
1654    }
1655
1656    #[test]
1657    fn read_until_options_builder_chaining() {
1658        let opts = ReadUntilOptions::with_timeout(Duration::from_secs(3))
1659            .retries(5)
1660            .retry_delay(Duration::from_millis(50))
1661            .min_bytes(100);
1662        assert_eq!(opts.timeout, Duration::from_secs(3));
1663        assert_eq!(opts.max_retries, 5);
1664        assert_eq!(opts.retry_delay, Duration::from_millis(50));
1665        assert_eq!(opts.min_bytes, 100);
1666    }
1667
1668    // --- is_transient_error tests ---
1669
1670    #[test]
1671    fn is_transient_error_would_block() {
1672        let err = io::Error::new(io::ErrorKind::WouldBlock, "test");
1673        assert!(is_transient_error(&err));
1674    }
1675
1676    #[test]
1677    fn is_transient_error_interrupted() {
1678        let err = io::Error::new(io::ErrorKind::Interrupted, "test");
1679        assert!(is_transient_error(&err));
1680    }
1681
1682    #[test]
1683    fn is_transient_error_timed_out() {
1684        let err = io::Error::new(io::ErrorKind::TimedOut, "test");
1685        assert!(is_transient_error(&err));
1686    }
1687
1688    #[test]
1689    fn is_transient_error_not_found() {
1690        let err = io::Error::new(io::ErrorKind::NotFound, "test");
1691        assert!(!is_transient_error(&err));
1692    }
1693
1694    #[test]
1695    fn is_transient_error_connection_refused() {
1696        let err = io::Error::new(io::ErrorKind::ConnectionRefused, "test");
1697        assert!(!is_transient_error(&err));
1698    }
1699
1700    // --- CleanupExpectations tests ---
1701
1702    #[test]
1703    fn cleanup_strict_all_true() {
1704        let strict = CleanupExpectations::strict();
1705        assert!(strict.sgr_reset);
1706        assert!(strict.show_cursor);
1707        assert!(strict.alt_screen);
1708        assert!(strict.mouse);
1709        assert!(strict.bracketed_paste);
1710        assert!(strict.focus_events);
1711        assert!(strict.kitty_keyboard);
1712    }
1713
1714    #[test]
1715    fn cleanup_for_session_matches_options() {
1716        let options = SessionOptions {
1717            alternate_screen: true,
1718            mouse_capture: false,
1719            bracketed_paste: true,
1720            focus_events: false,
1721            kitty_keyboard: true,
1722            intercept_signals: true,
1723        };
1724        let expectations = CleanupExpectations::for_session(&options);
1725        assert!(!expectations.sgr_reset); // always false for for_session
1726        assert!(expectations.show_cursor); // always true
1727        assert!(expectations.alt_screen);
1728        assert!(!expectations.mouse);
1729        assert!(expectations.bracketed_paste);
1730        assert!(!expectations.focus_events);
1731        assert!(expectations.kitty_keyboard);
1732    }
1733
1734    #[test]
1735    fn cleanup_for_session_all_disabled() {
1736        let options = SessionOptions {
1737            alternate_screen: false,
1738            mouse_capture: false,
1739            bracketed_paste: false,
1740            focus_events: false,
1741            kitty_keyboard: false,
1742            intercept_signals: true,
1743        };
1744        let expectations = CleanupExpectations::for_session(&options);
1745        assert!(expectations.show_cursor); // still true
1746        assert!(!expectations.alt_screen);
1747        assert!(!expectations.mouse);
1748        assert!(!expectations.bracketed_paste);
1749        assert!(!expectations.focus_events);
1750        assert!(!expectations.kitty_keyboard);
1751    }
1752
1753    // --- assert_terminal_restored edge cases ---
1754
1755    #[test]
1756    fn assert_restored_with_alt_sequence_variants() {
1757        // Both alt-screen exit sequences should be accepted
1758        let output1 = b"\x1b[0m\x1b[?25h\x1b[?1049l\x1b[?1000l\x1b[?2004l\x1b[?1004l\x1b[<u";
1759        assert_terminal_restored(output1, &CleanupExpectations::strict())
1760            .expect("terminal cleanup assertions failed");
1761
1762        let output2 = b"\x1b[0m\x1b[?25h\x1b[?1047l\x1b[?1000;1002l\x1b[?2004l\x1b[?1004l\x1b[<u";
1763        assert_terminal_restored(output2, &CleanupExpectations::strict())
1764            .expect("terminal cleanup assertions failed");
1765    }
1766
1767    #[test]
1768    fn assert_restored_sgr_reset_variant() {
1769        // Both \x1b[0m and \x1b[m should be accepted for sgr_reset
1770        let output = b"\x1b[m\x1b[?25h\x1b[?1049l\x1b[?1000l\x1b[?2004l\x1b[?1004l\x1b[<u";
1771        assert_terminal_restored(output, &CleanupExpectations::strict())
1772            .expect("terminal cleanup assertions failed");
1773    }
1774
1775    #[test]
1776    fn assert_restored_partial_expectations() {
1777        // Only cursor show required — should pass with just that sequence
1778        let expectations = CleanupExpectations {
1779            sgr_reset: false,
1780            show_cursor: true,
1781            alt_screen: false,
1782            mouse: false,
1783            bracketed_paste: false,
1784            focus_events: false,
1785            kitty_keyboard: false,
1786        };
1787        assert_terminal_restored(b"\x1b[?25h", &expectations)
1788            .expect("terminal cleanup assertions failed");
1789    }
1790
1791    // --- sequence constant tests ---
1792
1793    #[test]
1794    fn sequence_constants_are_nonempty() {
1795        assert!(!SGR_RESET_SEQS.is_empty());
1796        assert!(!CURSOR_SHOW_SEQS.is_empty());
1797        assert!(!ALT_SCREEN_EXIT_SEQS.is_empty());
1798        assert!(!MOUSE_DISABLE_SEQS.is_empty());
1799        assert!(!BRACKETED_PASTE_DISABLE_SEQS.is_empty());
1800        assert!(!FOCUS_DISABLE_SEQS.is_empty());
1801        assert!(!KITTY_DISABLE_SEQS.is_empty());
1802    }
1803
1804    #[cfg(unix)]
1805    #[test]
1806    fn drop_does_not_block_when_background_process_keeps_pty_open() {
1807        let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
1808        let (done_tx, done_rx) = mpsc::channel();
1809        let drop_thread = thread::spawn(move || {
1810            let mut cmd = CommandBuilder::new(&shell);
1811            cmd.arg("-c");
1812            cmd.arg("sleep 1 >/dev/null 2>&1 &");
1813            let session =
1814                spawn_command(PtyConfig::default().logging(false), cmd).expect("spawn session");
1815            drop(session);
1816            done_tx.send(()).expect("signal drop completion");
1817        });
1818
1819        assert!(
1820            done_rx.recv_timeout(Duration::from_millis(400)).is_ok(),
1821            "PtySession drop should not wait for background descendants to close the PTY"
1822        );
1823        drop_thread.join().expect("drop thread join");
1824    }
1825
1826    #[cfg(unix)]
1827    #[test]
1828    fn send_input_times_out_when_child_does_not_drain_stdin() {
1829        let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
1830        let mut cmd = CommandBuilder::new(&shell);
1831        cmd.arg("-c");
1832        cmd.arg("sleep 5");
1833        let mut session = spawn_command(
1834            PtyConfig::default()
1835                .logging(false)
1836                .with_input_write_timeout(Duration::from_millis(100)),
1837            cmd,
1838        )
1839        .expect("spawn session");
1840
1841        let payload = vec![b'x'; 8 * 1024 * 1024];
1842        let start = Instant::now();
1843        let err = session
1844            .send_input(&payload)
1845            .expect_err("send_input should time out when the child never reads stdin");
1846        assert_eq!(err.kind(), io::ErrorKind::TimedOut);
1847        assert!(
1848            start.elapsed() < Duration::from_secs(2),
1849            "send_input should fail promptly instead of hanging"
1850        );
1851    }
1852}